TypeScriptのProject Referencesを使ってソースコードを分割し、レイヤー間の依存関係を強制する
サマリ
-
Project Referencesを使うと、1つの巨大なtypescriptプロジェクトを、複数のプロジェクトに分割し、プロジェクト間の依存関係を整理することができる。これにより、例えば以下のようなことができる
-
test/
ディレクトリでexport
しているテスト用の関数を、src/
ディレクトリの本番用コードでは参照できなくする - レイヤードアーキテクチャで設計しているとき、プレゼンテーション層で定義している関数を ドメイン層から参照できなくし、レイヤー間の依存関係を強制する
-
- また、プロジェクトの分割によりビルド時のパフォーマンスが大幅に改善する。
- …と公式で書いているが、ビルド時の挙動を正確に把握しないとむしろ悪化するので、注意が必要
- 適切なプロジェクト分割を行う必要があること、eslintが遅くなること、
ts-node
やts-jest
がデフォルトで動かなくなることなど、運用するのはやや大変で、コストがメリットに見合うかはやや微妙(個人の感想)
サンプル
この記事では以下のサンプルを使って説明します。
詳細はレポジトリのコードを参考にしてください。
また、TypeScriptでWebバックエンド/フロントエンドの開発をするのを念頭にこの記事を書いています。それ以外のユースケースだと、多少勝手が違うかと思いますがご容赦ください。
Project Referencesとは
TypeScript 3.0から導入された機能です。
例えば以下のようなディレクトリ構成のとき、普通にビルドを行うと一度のビルドで a/
b/
c/
のディレクトリの *.ts
ファイルをビルドするかと思います。
.
├── a
│ ├── a.ts
├── b
│ ├── b.ts
├── c
│ ├── c.ts
├── package.json
├── tsconfig.json
└── yarn.lock
Project Referencesの機能を使うには、ルートディレクトリの tsconfig.json
で以下のような設定をしつつ、各ディレクトリでも tsconfig.json
を設定します。
これを行うと、a/
のビルド後に、 b/
のビルドが行われ、最後に c/
のビルドが行われるといった挙動になります。
// tsconfig.json
{
// ルートディレクトリのtsconfigではビルド対象のファイルを指定せず、ビルドが必要なプロジェクトを参照する
"files": [],
"references": [
{ "path": "a" },
{ "path": "b" },
{ "path": "c" }
]
}
// a/tsconfig.json
{
"compilerOptions": {
"outDir": "../dist/a",
// 参照されるprojectは、compositeをtrueにする必要がある
"composite": true,
}
}
bがaに依存している場合、以下のような記述になります。
// b/tsconfig.json
{
"compilerOptions": {
"outDir": "../dist/b",
"composite": true,
},
"references": [
{
"path": "../a"
},
]
}
この機能を使うと各ディレクトリを独立したtypescriptプロジェクトとして定義することができ、また依存関係を明示することができます。上の例だと、aからはb,cを参照できず、bからはaしか参照できなくなります。
これにより、例えば以下のようなことが達成できます。
-
test/
ディレクトリでexport
しているテスト用の関数を、src/
の本番用コードからは参照できなくする - レイヤードアーキテクチャで設計しているとき、
src/presentation
で定義している関数をsrc/infra
から参照できなくし、レイヤー間の依存関係を強制する
公式でも以下のように紹介されています。
By doing this, you can greatly improve build times, enforce logical separation between components, and organize your code in new and better ways.
yarn workspaceとの併用
必須ではないですが、yarn workspaceを使うとより各プロジェクトの独立性を高めることができます。
yarn workspaceの詳細については公式ドキュメントや以下の記事を参照してください。
上記の例だと、例えば a/package.json
を追加し、ここに googleapis
というパッケージを追加すると、aプロジェクトのみが googleapis
に依存することを明示することができます。
実際のところ、この指定に意味があるかと言うと多少微妙です。
yarn workspaceを使った場合に yarn install
すると、各ディレクトリの **/package.json
で定義したモジュールはルートディレクトリの node_modules/
に追加され、 tsc
は node_modules/
を参照します。
そのため、 b/
以下で googleapis
をimportしてもコンパイルエラーにはならず、指定したプロジェクト以外でもimportすることができてしまいます。
指定した場合のメリットを一応挙げておきます。
-
b/
以下でvscodeなどでインテリセンスを使ってgoogleapis
を補完することができなくなり、インテリセンスを使った場合の体験が良くなる - eslintと併用する場合、 node/no-extraneous-import
を使うと、aで使っているパッケージがpackage.jsonに記述されていないケースでエラーにすることができる。
ビルド時の挙動の調査
Project Referencesを使うと --incremental
オプションがデフォルトでONになり、差分があったプロジェクトにのみビルドが走ります。
これにより、全体をビルドすると数分かかるプロジェクトで1行だけソースコードを変更するようなケースで、変更があったプロジェクトでのみビルドが走るため非常に高速になります。
この挙動をより詳しく追ってみましょう。
tsc -b -v --extendedDiagnostics --listFiles
というコマンドでビルド時の挙動を詳しく表示できます。
更に詳しく調査したい場合は、 --generateTrace
オプションを使ってください。
※この辺のオプションの詳細は、以下の公式ドキュメントを参考にしてください。
サンプルレポジトリでは c -> b -> a
という形でプロジェクトの依存関係があり、 a
プロジェクトで googleapis
という重いパッケージを参照しています。
(googleapis
を参照するだけで手元のマシンで6secほどビルド時間が伸びます。実験のためこうしていますが、本番運用では各パッケージを個別でimportするのが推奨されています)
一度ビルドをした後に、 c/c.ts
を変更して再度ビルドを行うと、cとは関係のない googleapis
は参照されず、高速にビルドが行われます。
Project Referencesを使わずにソースコード全体に対してビルドをすると、毎回 googleapis
が参照されビルドが遅くなるため、これがProject Referencesのメリットと言えます。
しかし a/a.ts
と b/b.ts
を変更してビルドを行うと、 a
をビルドする時に googleapis
を参照するだけでなく、 b
をビルドするときも googleapis
が参照され、二重に参照が発生するため合計のビルド時間がかなり長くなります。これは、aでexportしている関数の返り値が googleapis
の型であるためです。
すなわち、依存関係に応じて同じファイル群が何度も読み込まれてしまい、普通に単一のプロジェクトとしてビルドするよりもよりパフォーマンスが悪くなりうることに注意してください。
個人的には、これを気を使って開発するのがなかなか大変だと感じます。
googleapis
の型をむやみにexportしないようにするくらいは気を付けることができると思うのですが、 googleapis
に全く関係ない型を a.ts
で定義し、それを c.ts
からも参照してしまった場合、ビルドの際は c.ts
が a.d.ts
を読み込みにいってしまい、結局cのビルド時にも googleapis
が読み込まれてしまう…といったケースはかなり気づきづらいと思います。
eslint, ts-jestなど関連ツール
eslintでtypescriptの型情報を使っている場合、eslintのパフォーマンスは著しく悪くなります。
またts-jestやts-nodeなどts-*系のツールについて、筆者は詳細を追い切れていませんが、基本的にproject referencesがサポートされておらず、工夫が必要になるため注意してください。
確認した限りだと、ts-jestやts-nodeでは tsconfig.json
の references
の記述を無視するような挙動をしていました。
(参考)
個人的には、以下のルールの運用が楽だと考えています。
- project referencesを使わない形の従来のtsconfig.jsonファイルを別で作っておく(
tsconfig.jest.json
など) - ts-jestやts-nodeではそちらを参照する
-
tsconfig.json
とtsconfig.jest.json
でビルド結果に差が出ないよう、各プロジェクト配下の**/tsconfig.json
はシンプルに保つ(例えばpaths
を使ってエイリアスを定義しないなど)
もしくは、eslint以外についてはts-*系のツールを使わず、tscのビルド結果をそのまま利用する形が良いかもしれません。
関連する議論
まとめ
レイヤードアーキテクチャを強制したり、一部の重いモジュールをプロジェクトとして切り出すことでビルドのパフォーマンス改善が狙えるなどといったメリットは大きいのですが、運用には正しい知識と苦労が必要そうでやや厳しそうな印象を受けました。
運用のノウハウがあればぜひコメントください。
参考ページ
パフォーマンス改善のノウハウについてはTypeScript公式にまとまっています。
英語記事
Discussion