Web APIをTypeScriptで書いてDockerでビルドする
「手っ取り早く Web インターフェースを手に入れる方法 2020」みたいなことを考えながら勉強のために BFF(Backend For Frontend) のサンプルプロジェクトを作ってみました。
ただしプロジェクト構成やビルド方法の検証が主なので BFF に特化した機能はなく、実行すると Express が起動してコンソールログが表示されるだけのアプリケーションです。
❯ docker run -it --rm 19472c5d8df4
💽 Loaded the configuration: version: 2020.1.0-default, baseVersion: 2020.1.0-default
⚡ App is running at :4000 in production mode
Press CTRL-C to stop
コードは"mazgi-showcase/202001.express-typescript-build-with-webpack“にあります。
使っている技術スタックは概ね以下です。
- TypeScript
- webpack
- Node.js
- Express
- (TypeORM)
- ある程度実用的な設定ファイルとして DB 接続設定を例にしたかったので含めています
- Docker & Docker Compose
- cargo-make
Out of Scope
今回のサンプルでは以下を扱いません。
- Web Frontend
- Routing
- Business or domain logic
- Test
- Deploy
How to run
次の 3 ステップで起動できるはずです。
cargo-make
をリリースページからダウンロードしてbin/cargo-make
に配置bin/cargo-make make --makefile tasks/setup-project.toml
を実行して空の設定ファイルを生成docker-compose up
docker-compose up
すると次の 2 つの Docker コンテナが起動します。
- Visual Studio Code用の Docker コンテナ
- 起動してすぐ終了しますが意図した動作です
- BFF の Docker コンテナ
❯ docker-compose up
Creating network "202001express-typescript-build-with-webpack_default" with the default driver
Creating 202001express-typescript-build-with-webpack_bff_1 ... done
Creating 202001express-typescript-build-with-webpack_vscode_1 ... done
Attaching to 202001express-typescript-build-with-webpack_bff_1, 202001express-typescript-build-with-webpack_vscode_1
vscode_1 | npm WARN workspace No repository field.
vscode_1 | npm WARN workspace No license field.
vscode_1 |
vscode_1 | audited 280 packages in 1.558s
vscode_1 |
vscode_1 | 2 packages are looking for funding
vscode_1 | run `npm fund` for details
vscode_1 |
vscode_1 | found 0 vulnerabilities
vscode_1 |
202001express-typescript-build-with-webpack_vscode_1 exited with code 0
bff_1 | npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.11 (node_modules/fsevents):
bff_1 | npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.11: wanted {"os":"darwin","arch":"any"} (current: {"os":"linux","arch":"x64"})
bff_1 |
bff_1 | removed 1 package and audited 5783 packages in 4.334s
bff_1 |
bff_1 | 6 packages are looking for funding
bff_1 | run `npm fund` for details
bff_1 |
bff_1 | found 0 vulnerabilities
bff_1 |
bff_1 |
bff_1 | > bff@ dev /workspace
bff_1 | > ts-node-dev -r tsconfig-paths/register src/index.ts
bff_1 |
bff_1 | Using ts-node version 8.5.4, typescript version 3.7.4
bff_1 | 💽 Loaded the configuration: version: 2020.1.0-default, baseVersion: 2020.1.0-default
bff_1 | ⚡ App is running at :4000 in development mode
bff_1 | Press CTRL-C to stop
bff_1 |
接続するとエラーページが返りますが、route を 1 つも作っていないのでこれも意図した挙動です。
❯ curl -L 127.0.0.1:4000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /</pre>
</body>
</html>
Programming Language and Web Framework
Backend For Frontend なので開発者に求めるスキルセットが Web フロントエンドに近く、開発時に Web フロントエンドと行き来してもコンテキストスイッチが少ないことを意識しました。
そのため TypeScript で実装し Node.js + Express で動かす構成にしてます。
Directory structure for Development
きっと世の中の多くの方がそう願っていると思うのですが、私も開発時は TypeScript を書いて保存するとアプリケーションに自動反映されててほしいのですが、ビルドした際には 1 つの JavaScript ファイルに pack されてほしいと思っています。
そのためにwebpackがあるのですが、個人的にとても難しいと ERROR in Entry module not found: Error: Cannot resolve file or directory...
本サンプルでも試行錯誤の結果、どうにか開発時の利便性とビルドの成功を両立できた気がします。
まず、BFF ソースコード部分のディレクトリ構成は次のようにしています。
└── bff
├── ormconfig.json
├── package.json
├── package-lock.json
├── src
│ ├── config
│ │ ├── Config.ts
│ │ ├── ConfigType.ts
│ │ ├── default.json
│ │ └── index.ts
│ └── index.ts
├── tsconfig.json
└── webpack.config.js
そして /bff/package.json
ではビルド時のみ webpack
を使い、開発時は ts-node-dev
で index.ts
ファイルを直接実行しています。
(後述の TypeScript
の pahts
のために tsconfig-paths
を使っています。)
"scripts": {
"dev": "ts-node-dev -r tsconfig-paths/register src/index.ts",
"build": "webpack",
"build:debug": "webpack --mode development"
},
TypeScript paths
そしてビルド時は webpack
で pack するのですが、ここで TypeScript 特有の問題に遭遇します。
同じプロジェクト内のモジュールを参照するときに import X from '../../lib/xxx'
ではなく import X from 'lib/xxx'
と書きたいので /bff/tsconfig.json
で次のように paths
を設定するのですが、ビルド時にモジュールが見つからず頭を抱えます(抱えました)。
"baseUrl": "src",
"paths": {
"*": ["*"]
},
理由についてはこちらの記事がわかりやすかったです。
TypeScript の paths はパスを解決してくれないので注意すべし! – 自主的 20%るぅる
その上で bff/webpack.config.js
の resolve.modules
にも src
ディレクトリを追加します。
modules: ['node_modules', path.resolve(__dirname, 'src')],
これで src
以下にモジュールを追加する限りは、開発時でもビルド時でも webpack.config.js
も tsconfig.json
も書き変えずに参照できる、はずです。たぶん。
Application Configuration
アプリケーションの設定をどのように適用するかは常に悩ましいですが、本サンプルではデフォルトの設定をアプリケーション内に持ち、外部から上書きする想定をしました。
/bff/src/config/default.json
がデフォルトの設定ファイルです。
TypeScript で開発する恩恵として/bff/src/config/ConfigType.ts
で定義された型で JSON が読み込まれ、ConfigType
型として扱えます。
それなりに実用的な設定ファイルの例として RDBMS 接続の設定をConfigType
型の中にConnectionOptions
型のフィールドとして持たせています。
ここで使っている ConnectionOptions
型は TypeORM
で定義されています。
なお最近の webpack では json-loader
はデフォルトで組み込まれておりrequire('path/to/json')
を書くだけで読み込めるので便利ですね。
eslint
に怒られるのですがこれは除外する以外に回避方法あるのでしょうか。
// load default config from the file.
// eslint-disable-next-line @typescript-eslint/no-var-requires
let defaultConfig = require("./default.json");
json-loader
is not required anymore
In Development env
開発中はデフォルトの設定ファイルに加えてローカルの開発用設定ファイルを参照しています。
if (isDevelopment) {
// load development config from the file that mounted by docker-compose.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const devConfig = require("/data/config/bff/config.json");
defaultConfig = { ...defaultConfig, ...devConfig };
}
開発用設定ファイルは docker-compose.yml
内で bind しています。
volumes:
- ./bff:/workspace
- ./config.development/bff/config.json:/data/config/bff/config.json:ro
In Production env
本番環境では S3 や GCS のようなオブジェクトストレージから設定ファイルを読み込むことを考えています。
Build and just before Deploy
「Docker イメージまで作れば Deploy はどうにでもなるでしょ」と考えているので cargo-make
で Docker イメージのビルドまで行なってます。
しかし本サンプルではどういう tag を打ってどこに push するかまでは考えていません。
/tasks/build-production-images.toml がビルドのためのファイルです。
次のように実行すると Docker イメージがビルドされます。
bin/cargo-make make --makefile tasks/build-production-images.toml
本来は Docker イメージに tag を打って ECR や GCR に push してしまいたいですが本サンプルでは tag を打っていません。
そのためビルドの最後に表示されるイメージ ID を控えておきます。
Step 11/11 : CMD ["node", "/app/main.js"]
---> Running in 1c99cb90cc27
Removing intermediate container 1c99cb90cc27
---> 19472c5d8df4
Successfully built 19472c5d8df4
[cargo-make] INFO - Running Task: default
[cargo-make] INFO - Running Task: empty
[cargo-make] INFO - Build Done in 22 seconds.
イメージ ID を指定して docker run
すると次のようなコンソールログと共に BFF が起動します。
❯ docker run -it --rm 19472c5d8df4
💽 Loaded the configuration: version: 2020.1.0-default, baseVersion: 2020.1.0-default
⚡ App is running at :4000 in production mode
Press CTRL-C to stop
cargo-make
Docker コンテナの作り方はできる限り Dockerfile
に入れてしまいたいのですが、 Dockerfile
の表現力や各種制約によりどうしてもビルド用スクリプトが必要になることがあります。
しかしビルドのために shellscript を書き始めるとキリがなく、実行順や依存関係などに悩みがちです。
Makefile の書き方もすっかり忘れてしまい思い出したくないので、TOML でやりたいことを書いてバイナリをぽんっと置けばビルドできる cargo-make を導入しました。
同僚でも使ってる方がおり「いいよ〜」と評判は聞いていたのですが今回の要件を概ね満たしていると感じました。
タスクランナーを make から cargo-make へ移行 · tkat0.github.io
cwd
で起点となる path を設定できるので、すべてのスクリプトファイルに「想定外の階層から実行されたら叱る」実装を書かなくて済みますし、
cwd = "${CARGO_MAKE_CURRENT_TASK_INITIAL_MAKEFILE_DIRECTORY}/../"
依存関係を書けるのもタスクランナーならではです。
dependencies = [
"prepare"
]
一方でビルドの内容自体はコマンドをそのまま記載できるので初期の学習コストも小さいと感じました。
script = [
'''
rm -rf Dockerfile.d/bff/rootfs
mkdir -p Dockerfile.d/bff/rootfs/
cp -Rp bff Dockerfile.d/bff/rootfs/workspace
docker build --no-cache --target production Dockerfile.d/bff
'''
]
ただしハマりどころや機能の不足も感じるので機会があればまとめたいと思っています。
That’s all
だいぶ手探りしましたがこんな感じで TypeScript で Web API を書いて Docker イメージをビルドするサンプルが作れたので、これを叩き台に開発してみたいと思っています。