TypeScript で DI (依存性注入) するためのライブラリを作ったんですが, それを紹介する前に既存手法をまとめておいた方が説明が楽だなと思ったのでまとめておきます. そもそも DI の目的とは, みたいなところは詳しく説明しないのであしからず.
手法の比較
DI なし
まずは DI を使わない場合を見ていきましょう.
ここでは例として, 以下のような時刻と乱数を必要とするコンポーネント MyService
が, 時刻と乱数を扱う機能をそれぞれ提供するコンポーネント Clock
と Random
に依存するような場合を考えます.
type Clock = { getTime: () => number; }; type Random = { getRandom: () => number; }; type MyService = { getTimeAndRandom: () => [number, number]; };
DI を使わない場合, これらの実装は以下のようになるでしょう.
MyService
の実装 myServiceImpl
は, Clock
と Random
の実装である clockImpl
と randomImpl
を直接利用しています.
const clockImpl: Clock = { getTime: () => Date.now(), }; const randomImpl: Random = { getRandom: () => Math.random(), }; const myServiceImpl: MyService = { getTimeAndRandom: () => [clockImpl.getTime(), randomImpl.getRandom()], };
MyService
を利用する場合は以下の通り, 普通に機能を呼び出すだけです.
console.log(myServiceImpl.getTimeAndRandom()); // => [<time>, <random>]
まあシンプルです. コードを書いたり読んだりするのにあたって特に迷うことはないでしょう.
一方で, 例えばテストなどにおいて Clock
と Random
のような依存コンポーネントの実装を差し替えたくなったときはちょっと困ります.
とはいえテストでのモックが目的であれば, jest.mock()
や jest.spyOn
を使うとか, 時刻には jest.useFakeTimers()
を使うとか, ネットワークリクエストに対しては msw を使うとか, DB などミドルウェアやサブシステムは Docker で本物を立ててしまうとか, まあ色々やりようはあるので, 必ずしも DI が必要とは限りません.
それ以外に問題点を挙げるとすれば, コンポーネント同士の依存関係がシグネチャ (要するに型) に現れません. これは例えばあるコンポーネントをテストしようとしたときに, 何かをモックするべきであっても実際に何をモックするべきかは実装を追ってみなければわからないということです. 実際にモックが必要なコンポーネントは直接ではなく間接的に深いところで依存している可能性もあり, これら全てを知っていないといけないのは認知負荷が高いです.
Service Locator
Service Locator と呼ばれるオブジェクトを経由して, 依存先のコンポーネントを参照するパターンです.
まずは実装の登録先となる locator
を定義します.
ここでは例として Map<string, any>
を使っているため型の保証がありませんが, 実際には良い感じにやれば特定の型の値が登録されることを保証できます.
const locator = new Map<string, any>();
コンポーネントの実装はこの locator
に依存して, 他の実装には間接的に依存させます.
const clockImpl: Clock = { getTime: () => Date.now(), }; const randomImpl: Random = { getRandom: () => Math.random(), }; const myServiceImpl: MyService = { getTimeAndRandom: () => { const clock = locator.get("clock"); const random = locator.get("random"); return [clock.getTime(), random.getRandom()]; }, };
利用する場合はあらかじめ locator
に実装を登録しておきます.
locator.set("clock", clockImpl); locator.set("random", randomImpl); console.log(myServiceImpl.getTimeAndRandom()); // => [<time>, <random>]
確かに実装の注入・差し替えは行えるようになりました.
一方で実装の注入漏れに対しては脆弱で, locator
に対する登録が漏れていてもコンパイルエラーになったりはしません.
また依存関係がシグネチャに現れないという問題も解決していません.
React の useContext
なんかもこの一種と言えます.
エフェクト
依存関係がシグネチャに現れないという問題を解決するアイデアの一つとして, 依存をエフェクトとして表現しつつ, それを型に含めてしまうというものがあります.
ここでは以下のライブラリを使った方法を紹介します.
(誤解のないようあらかじめ断っておくと, TypeScript でエフェクトを使うというのは現状は半分冗談みたいなものなので, あまり真に受けないでください.)
まずは以下のように時刻と乱数の取得を表現するエフェクト getTime
, getRandom
を定義します.
import { makeEffect } from "@susisu/effectful"; declare module "@susisu/effectful" { interface EffectDef<A> { "getTime": { k: (x: number) => A; }; "getRandom": { k: (x: number) => A; }; } } const getTime = makeEffect<"getTime", number>("getTime", { k: x => x }); const getRandom = makeEffect<"getRandom", number>("getRandom", { k: x => x });
続いて時刻と乱数を利用する機能を, getTime
, getRandom
エフェクトを発生させる関数と思うことにして, Effectful<"getTime" | "getRandom", T>
のような戻り値を持つジェネレータ関数として定義します.
ここで戻り値の型から getTime
や getRandom
の記述が漏れている場合はコンパイルエラーになります.
import { Effectful } from "@susisu/effectful"; function* getTimeAndRandom(): Effectful<"getTime" | "getRandom", [number, number]> { const time = yield* getTime; const random = yield* getRandom; return [time, random]; }
この関数を使うためには, 以下のように関数の実行時に getTime
, getRandom
エフェクトに対するハンドラを与えます.
このハンドラを場合によって差し替えることで DI 相当のことが実現できます.
import { Effectful, run } from "@susisu/effectful"; function runApp<T>(comp: Effectful<"getTime" | "getRandom", T>): T { return run(comp, x => x, { "getTime": (eff, resume) => { return resume(eff.value.k(Date.now())); }, "getRandom": (eff, resume) => { return resume(eff.value.k(Math.random())); }, }); } console.log(runApp(getTimeAndRandom())); // => [<time>, <random>]
この方法の問題点は, 確かに依存しているものが型の上に現れているのは確かなのですが, その粒度が細かくなりがちなことです.
例えば依存の依存が getTime
エフェクトを発生させるので, 自身も getTime
エフェクトを発生させる関数である, というのはある面では正しいですが, 一方で依存先がどのようなエフェクトを発生させるかというのは実装の詳細であり, 特に気にする必要がない方が嬉しい場面も多いです.
階層的にエフェクトを定義して, ライブラリもそれをうまく扱えればなんとかなるかもしれませんが... 何か上手い方法があったら教えてください.
個人的には, 局所的な DSL で使うくらいならまだ良いんですが, アプリケーション全体でこれを前提にした設計にするのは難しいと思います.
Constructor Injection
正統派の話に戻ってきて, コンストラクタまたはファクトリ関数の引数で依存コンポーネントを注入する方法です.
コンポーネントの定義は最初の DI を使う前と同じ.
type Clock = { getTime: () => number; }; type Random = { getRandom: () => number; }; type MyService = { getTimeAndRandom: () => [number, number]; };
実装はファクトリという形で定義して, 引数で依存コンポーネントを受け取るようにします.
const clockFactory = (): Clock => ({ getTime: () => Date.now(), }); const randomFactory = (): Random => ({ getRandom: () => Math.random(), }); const myServiceFactory = ({ clock, random } : { clock: Clock; random: Random }): MyService => ({ getTimeAndRandom: () => [clock.getTime(), random.getRandom()], });
利用するときは実装を順番にインスタンス化して, ファクトリの引数に与えていきます.
const clockImpl = clockFactory(); const randomImpl = randomFactory(); const myServiceImpl = myServiceFactory({ clock: clockImpl, random: randomImpl, }); console.log(myServiceImpl.getTimeAndRandom()); // => [<time>, <random>]
この方法は型安全に DI が実現されつつ, かつ依存関係もファクトリの引数としてシグネチャに表れているため, これまでに挙げた課題は全て解決できています.
デメリットを挙げると, インスタンス化を手動で依存関係の順番通りに行わなければならず, やや記述が面倒です. もちろん間違っていればコンパイルエラーになるので, その点は安心ではあるのですが.
ライブラリを使わずにシンプルに実現できる基本中の基本のような方法なので, DI を導入する場合はここを基準点として考えると良いでしょう.
Setter Injection
Constructor Injection と比較するため一応紹介しておきますが, 依存コンポーネントを setter で指定してやる方法もあります.
コンポーネントの定義は同じなまま, MyService
の実装に setter を追加します.
const clockFactory = (): Clock => ({ getTime: () => Date.now(), }); const randomFactory = (): Random => ({ getRandom: () => Math.random(), }); const myServiceFactory = (): MyService & { setClock: (clock: Clock) => void; setRandom: (random: Random) => void; } => { let clock: Clock | undefined = undefined; let random: Random | undefined = undefined; return { setClock: (_clock) => { clock = _clock; }, setRandom: (_random) => { random = _random; }, getTimeAndRandom: () => { if (!clock) { throw new Error("clock is not set"); } if (!random) { throw new Error("random is not set"); } return [clock.getTime(), random.getRandom()]; }, }; };
使う場合はあらかじめ setter で実装を指定します.
const clockImpl = clockFactory(); const randomImpl = randomFactory(); const myServiceImpl = myServiceFactory(); myServiceImpl.setClock(clockImpl); myServiceImpl.setRandom(randomImpl); console.log(myServiceImpl.getTimeAndRandom()); // => [<time>, <random>]
Constructor Injection と比べると, インスタンス化が簡潔で順番も問わなくなっていますが, 実装が注入されることが確実でなかったり, 依存関係がシグネチャ上でややわかりづらかったり, そもそもイミュータブルなスタイルと合わないといったデメリットも多いので, あまり使うことはないと思います.
(補足: この例では Setter Injection の記述は Constructor Injection と比べてちょっと冗長になっていますが, どちらも class で書いた場合は記述量はほぼ変わらないか, Setter Injection の方がやや簡潔になります.)
デコレータ
Constructor Injection のデメリットとして, 手で依存関係の順番通りにインスタンス化して引数を渡さなければならないというものがありましたが, 世の中の DI フレームワーク・DI コンテナと呼ばれるライブラリは概ねこの課題を解決するために存在しています.
そういったフレームワークの中にはデコレータを使ったものがいくつか存在します.
これらはどれも機能はよく似ていて, ここでは最もよく使われていそうな InversifyJS を使った方法を紹介します.
まずは以下のようにコンポーネントの種類を定義します.
interface Clock { getTime(): number; } interface Random { getRandom(): number; } interface MyService { getTimeAndRandom(): [number, number]; } const types = { Clock: Symbol.for("Clock"), Random: Symbol.for("Random"), MyService: Symbol.for("MyService"), };
続いて実装は @injectable()
でマークしつつ, コンストラクタ引数に @inject()
をつけてどのコンポーネントを注入するかを指定します.
import { injectable, inject } from "inversify"; @injectable() class ClockImpl implements Clock { getTime(): number { return Date.now(); } } @injectable() class RandomImpl implements Random { getRandom(): number { return Math.random(); } } @injectable() class MyServiceImpl implements MyServicew { private clock: Clock; private random: Random; constructor(@inject(types.Clock) clock: Clock, @inject(types.Random) random: Random) { this.clock = clock; this.random = random; } getTimeAndRandom(): [number, number] { return [this.clock.getTime(), this.random.getRandom()]; } }
使う場合はコンテナにそれぞれの実装を登録しておき, .get
を呼び出してやると自動で良い感じにインスタンス化されたものが得られます.
import { Container } from "inversify"; const container = new Container(); container.bind<Clock>(types.Clock).to(ClockImpl); container.bind<Random>(types.Random).to(RandomImpl); container.bind<MyService>(types.MyService).to(MyServiceImpl); const myService = container.get<MyService>(types.MyService); console.log(myService.getTimeAndRandom()); // => [<time>, <random>]
この方法で新たに登場する課題として, 少なくともメジャーどころのライブラリは (なぜか?) 型安全ではありません. たとえば実装の登録が漏れていたり, あるいは間違った型の実装を登録したりしても特にコンパイルエラーになったりはしないので, その点は注意が必要です.
またここでいうデコレータとは TypeScript の独自仕様である experimentalDecorators
を使っていて, 現在策定中の JavaScript 標準のデコレータとは互換性がありません.
TypeScript 独自のデコレータも当面の間はサポートされるとは思いますが, 将来的にどうなるかはやや不透明です.
個人的には新規に導入するのは躊躇ってしまいます...
typed-inject
デコレータを使わずにインスタンス化を楽にする, かつ型安全なライブラリとして typed-inject というものがあります.
コンポーネントの定義はこれまで通り.
type Clock = { getTime: () => number; }; type Random = { getRandom: () => number; }; type MyService = { getTimeAndRandom: () => [number, number]; };
実装は Constructor Injection の形のファクトリやクラスで定義して, inject
という static なプロパティに注入したいコンポーネントの名前を引数と同じ順番で列挙しておきます.
const clockFactory = (): Clock => ({ getTime: () => Date.now(), }); const randomFactory = (): Random => ({ getRandom: () => Math.random(), }); const myServiceFactory = (clock: Clock, random: Random): MyService => ({ getTimeAndRandom: () => [clock.getTime(), random.getRandom()], }); myServiceFactory.inject = ["clock", "random"] as const;
利用する場合は injector にこれらの実装を順番に登録しておくと, 良い感じにインスタンス化してくれます.
import { createInjector } from "typed-inject"; const injector = createInjector() .provideFactory("clock", clockFactory) .provideFactory("random", randomFactory) .provideFactory("myService", myServiceFactory); const myService = injector.resolve("myService"); console.log(myService.getTimeAndRandom()); // => [<time>, <random>]
個人的にはこれで文句なしです. 一つだけ気になるとしたら, 依存先が登録されていることを保証するために, 手で実装を順番通りに登録する必要がある点でしょうか.
2023-06-21 追記: 実はコンポーネント数が 20 くらいになるとまともに動かなくなってしまうとのこと. ままならないですね〜...
次回予告
Cake Pattern