単一レポジトリで複数のアプリケーションを動かすときの NestJS の取り扱い方法と罠について

この記事は [NestJS Advent Calendar 2019](https://qiita.com/advent-calendar/2019/nestjs) 22 日目の記事です。遅れてすみません :bow: 

この記事では、 NestJS とそれ以外のプロジェクトを単一のレポジトリで管理するときの手法、いわゆる monorepo に近いような開発をうするときのやり方と注意点を紹介します。

なお、筆者の宗教上の理由により Lerna などの専用のツールにおいては取り扱いません。

## サンプルについて

サンプルは以下のレポジトリに格納しています。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day22-multiple-application-build

## 想定するシチュエーションについて

単純に一つのレポジトリで複数のデプロイ先がある場合を想定します。例えば SPA / API なら以下のようになります。

```
- /path/to/project
  - /.github/
    - /workflows/
      - frontend.yml
      - backend.yml
  - /frontend/ - SPA 用のプロジェクト
  - /api/ - API 用のプロジェクト
```

また、API とはいえ PaaS 基盤と FaaS 基盤を両方使う場合もありそうです。その場合は共通コードも考えると以下のようになるでしょうか。

```
- /path/to/project
  - /.github/
    - /workflows/
      - nest.yml
      - function.yml
  - /common/ - 共通のコード(型定義や計算ロジックなど)
  - /api/ - NestJS のプロジェクト
  - /functions/ - Lambda や Cloud Functions
```

今回はより現実的に多そうなシチュエーションかつ複雑度がます後者を例にやってみます。Functions 部分は本質ではないため、今回は以下をゴールとします。

- common を正しく NestJS / それ以外にて扱うことができる
- 開発時において、NestJS とそれ以外を簡単に立ち上げて開発をすすめることができる
- ビルド及び実行が全て正しく動作する

## 実際の作業内容

実際に作業していく場合の手順です。今回も CLI から生成したばかりのプロジェクトを対象とします。

### Nest のアプリケーションディレクトリの移動と設定ファイルの追従

まずは `src` および `test` を `api/src` へと移動します。

```bash
$ mkidir api
$ mv ./src/ ./api/src
$ mv ./src/ ./api/test
```

続いて各種設定ファイルも更新します。ビルド周りは設定ファイルが中心となるため、まずは package.json を nest 以外を受け入れる前提のものへと変更します。

```diff
diff --git a/day22-multiple-application-build/package.json b/day22-multiple-application-build/package.json
index 6589c48..fec1ef0 100644
--- a/day22-multiple-application-build/package.json
+++ b/day22-multiple-application-build/package.json
@@ -6,26 +6,31 @@
   "license": "MIT",
   "scripts": {
     "prebuild": "rimraf dist",
-    "build": "nest build",
-    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
-    "start": "nest start",
-    "start:dev": "nest start --watch",
-    "start:debug": "nest start --debug --watch",
-    "start:prod": "node dist/main",
+    "format": "prettier --write \"api/**/*.ts\" \"test/**/*.ts\"",
+    "start:nest": "nest start",
+    "build:nest": "nest build",
+    "dev:nest": "nest start --watch",
+    "debug:nest": "nest start --debug --watch",
+    "prod:nest": "node dist/main",
     "lint": "tslint -p tsconfig.json -c tslint.json",
     "test": "jest",
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
     "test:e2e": "jest --config ./api/test/jest-e2e.json"
   },
```

Nest.js 関連のコマンドをすべて `nest:*` へと変更しました。また、 build や start といった共通のものは利用しない状態としておきます。

テストについても、E2E のものを `api/test` に移動した影響は package.json に収まっているので、 jest.config.js への影響はありません。

これで `dev:nest` で動作する状態まで持ってきた気がします。実際に実行してみます。

```bash
> yarn dev:nest
yarn run v1.21.1
$ nest start --watch
17:40:15 - Starting compilation in watch mode...


17:40:19 - Found 0 errors. Watching for file changes.
internal/modules/cjs/loader.js:797
    throw err;
    ^

Error: Cannot find module '/Users/potato4d/.ghq/github.com/nestjs-jp/advent-calendar-2019/day22-multiple-application-build/dist/main'
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:794:15)
    at Function.Module._load (internal/modules/cjs/loader.js:687:27)
    at Function.Module.runMain (internal/modules/cjs/loader.js:1025:10)
    at internal/main/run_main_module.js:17:11 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}
```

エラーになりました。原因を追います。

### nest-cli.json と sourceRoot の更新

ここまでで、全ての設定が正しくなされており、一見正しく動作するように見えます。

が、NestJS は、開発サーバーの立ち上げまで含めて全域に CLI を利用しており、この設定についても書き換えを必要としています。

今回の場合、以下のように、 sourceRoot を変更することで、 Nest の CLI に関する設定は全て変更先のディレクトリを参照するようになります。

```json
{
  "collection": "@nestjs/schematics",
  "sourceRoot": "api/src"
}
```

これを怠ると、 `$ nest g` などのコマンドにおいても、不具合を起こすため対応する必要があります。

修正したら、そのまま動かします。

```bash
> yarn dev:nest
yarn run v1.21.1
$ nest start --watch
17:42:31 - Starting compilation in watch mode...


17:42:32 - Found 0 errors. Watching for file changes.
[Nest] 36763   - 2019-12-31 17:42:33   [NestFactory] Starting Nest application...
[Nest] 36763   - 2019-12-31 17:42:33   [InstanceLoader] AppModule dependencies initialized +8ms
[Nest] 36763   - 2019-12-31 17:42:33   [RoutesResolver] AppController {/}: +3ms
[Nest] 36763   - 2019-12-31 17:42:33   [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 36763   - 2019-12-31 17:42:33   [NestApplication] Nest application successfully started +1ms
```

OKですね。

### common ディレクトリの作成と TypeScript ビルドの罠

Nest の準備はできたので、このまま common を作ってみます。

今回は API / Functions 問わず、アプリケーション内のコンポーネントでも利用する uuid をベースにした採番コードを作ることとします。uuid パッケージを導入してみます。

```bash
$ yarn add uuid
$ yarn add -D @types/uuid
```

続いて common/random.ts を作ります。`/items/:id` のような形式にする場合にダッシュは見栄え上悪いので、トリムするだけのコードです。

```ts:random.ts
import { v4 as uuid } from 'uuid';

export function generateRandomId() {
  return uuid().replace(/-/g, '');
}
```

これで書けていそうなので、このまま controller から呼び出してみます。

```ts:app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { generateRandomId } from 'common/random';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return generateRandomId();
  }
}
```

このまま実行してみます。すると、ビルド自体は成功しているのにサーバーの起動には失敗します。

```bash
> yarn dev:nest
yarn run v1.21.1
$ nest start --watch
17:23:06 - Starting compilation in watch mode...


17:23:11 - Found 0 errors. Watching for file changes.
internal/modules/cjs/loader.js:797
    throw err;
    ^

Error: Cannot find module '/Users/potato4d/.ghq/github.com/nestjs-jp/advent-calendar-2019/day22-multiple-application-build/dist/main'
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:794:15)
    at Function.Module._load (internal/modules/cjs/loader.js:687:27)
    at Function.Module.runMain (internal/modules/cjs/loader.js:1025:10)
    at internal/main/run_main_module.js:17:11 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}
```

この罠の理由は、 tsc の仕様によるディレクトリ構造の変化が理由となります。

これまでは `./api/` しか存在しなかったため、 `./dist/` へのビルドは以下のように出力されていました。

```
./api/main.ts -> ./dist/main.js
./api/src/app.controller.ts -> ./dist/src/app.controller.js
```

つまりは他のファイルがないから TypeScript 側が不要と判断し、 `./api/` の名称を取り除いている状況です。

しかし、今回 `common` が増えたことにより、以下のように変化しました。

```
./api/main.ts -> ./dist/api/main.js
./common/random.ts -> ./dist/common/random.js
./api/src/app.controller.ts -> ./dist/api/src/app.controller.js
```

TypeScript の世界及びアプリケーションのコードベースに置いては、これらは互換性が正しく取られており、問題ありません。しかし、成果物を node コマンドで動かしている都合上、コードベースを離れたコマンドの上ではファイルが見つからないこととなってしまいます。

そのため、package.json を以下のように変更し、対処する必要があります。

```diff
{
  // ...
  "scripts": {
    // ...
-    "prod:nest": "node dist/main",
+    "prod:nest": "node dist/api/main",
    // ...
  },
  // ..
}
```

これで再度実行してみます。

```terminal
> yarn dev:nest
yarn run v1.21.1
$ nest start --watch
17:38:09 - Starting compilation in watch mode...


17:38:13 - Found 0 errors. Watching for file changes.
[Nest] 36489   - 2019-12-31 17:38:13   [NestFactory] Starting Nest application...
[Nest] 36489   - 2019-12-31 17:38:13   [InstanceLoader] AppModule dependencies initialized +12ms
[Nest] 36489   - 2019-12-31 17:38:13   [RoutesResolver] AppController {/}: +6ms
[Nest] 36489   - 2019-12-31 17:38:13   [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 36489   - 2019-12-31 17:38:13   [NestApplication] Nest application successfully started +2ms
```

問題なく実行できました。

単一のレポジトリでコードベースを管理する場合、この挙動については気をつけた上で動作させる必要があります。

### tsconfig と npm-run-all を駆使してビルドを楽にする

前項までで NestJS 自体の設定はできましたが、最後におまけとしていくつか開発の Tips を紹介しておきます。Functions と共存する場合などには役立つため、ぜひご利用ください。

#### tsconfig の extends の利用

Nest アプリケーション自体はこのまま運用可能ですが、NestJS とそれ以外のコンポーネントでで要求する TypeScript コードが違うことは頻繁にあります。

そういうときは、 tsconfig.functions.json のような専用の設定ファイルで上書きしてやると便利です。

例えば Firebase Function は source のディレクトリ以下に成果物があることを要求するため、以下のような設定にすると良いでしょう。

```json:tsconfig.functions.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./functions/dist/"
  }
}
```

注意点としては、 **VSCode などは基本的に tsconfig.json を参照する** ということです。エディタ補完やその他のツールと連携できるような最小限のコードを tsconfig.json に記述することをおすすめします。

場合によっては、最大公約数の設定だけを残し、 tsconfig.nest.json と tsconfig.nest.build.json を作ることも考慮して良いでしょう。

#### npm-run-all について

もう一つ、 npm-run-all というパッケージも紹介いたします。これは、複数の NPM Scripts を並行で動かすための CLI ツールです。

```terminal
$ yarn add npm-run-all -D
```

使い方は簡単。例えば function の build が必要なシチュエーションの場合は、 package.json の以下のような追記を施します。

```diff
{
  // ...
  "scripts" {
    // ...
+    "build": "npm-run-all -p build:*",
+    "build:functions": "tsc --build ./tsconfig.functions.json",
    // ...
  }
  // ...
}
```

その上で、 `yarn build` を実行します。

```terminal
> yarn build
yarn run v1.21.1
$ rimraf dist
$ npm-run-all -p build:*
$ tsc --build ./tsconfig.functions.json
$ nest build
```

このように、 build を接頭辞とするコマンドが全て実行されます。

特に開発サーバーを立ち上げる場合やビルドスクリプトで重宝するので、覚えておくと良さそうです。

## おわりに

今回はかんたんなプロジェクト構築例として、 NestJS とその他の基盤を併用する場合の構造について紹介しました。

NestJS のビルド設定まわりは、基本的には package.json や tsconfig.json、 nest-cli.json で成り立っているため、これらについてはある程度詳しくなっておくとより良い開発ができるようになります。