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“にあります。

使っている技術スタックは概ね以下です。

Out of Scope

今回のサンプルでは以下を扱いません。

  • Web Frontend
  • Routing
  • Business or domain logic
  • Test
  • Deploy

How to run

次の 3 ステップで起動できるはずです。

  1. cargo-makeリリースページからダウンロードして bin/cargo-make に配置
  2. bin/cargo-make make --makefile tasks/setup-project.toml を実行して空の設定ファイルを生成
  3. 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-devindex.ts ファイルを直接実行しています。
(後述の TypeScriptpahts のために 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%るぅる

Issue も賑わっておりロックされています。

その上で bff/webpack.config.jsresolve.modules にも src ディレクトリを追加します。

modules: ['node_modules', path.resolve(__dirname, 'src')],

これで src 以下にモジュールを追加する限りは、開発時でもビルド時でも webpack.config.jstsconfig.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 イメージをビルドするサンプルが作れたので、これを叩き台に開発してみたいと思っています。