TypeDI 素振り
TypeScript 向け DI コンテナライブラリの TypeDI を素振りしたのでメモ。
アプリケーションコード
以下の要件で考える
- 何かを検索する
SearchClient
とその依存先である Axios インスタンスを DI コンテナにいれる - エントリポイントで
SearchClient
を取り出して使う - コンテナはグローバル
外部ライブラリの値を登録
まずは他に依存が無い Axios インスタンスを DI コンテナに入れる。このインスタンスには baseURL
を設定しておきたいものとする。
src/search-axios.ts
import { AxiosInstance, create } from 'axios' import { Token, Container } from 'typedi' export function createSearchAxios(): AxiosInstance { return create({ baseURL: 'https://example.com/search' }) } export const TOKEN_SEARCH_AXIOS = new Token<AxiosInstance>('search-axios') Container.set({ id: TOKEN_SEARCH_AXIOS, factory: createSearchAxios })
外部ライブラリなどの値を DI コンテナに入れる場合、値を生成するためのファクトリー関数と Token
を定義する。
Token
は後にコンテナに登録する際に値に紐付ける識別子になる。識別子は string
でもよいが、Token
を使うことによってコンテナから値を取り出す際の型安全性が向上する。
ファクトリー関数は引数無し・コンテナに登録したい値を返すように定義する(Token
の型パラメータとファクトリー関数の戻り値型が一致していることに注目)。
Container
はグローバルなコンテナを表す。アプリ内でコンテナを分ける必要がない場合、このように Container
にどんどん値を登録していく。
自作クラスを登録
次に先程の Axios インスタンスに依存しているクラス SearchClient
を DI コンテナに登録できるように定義する。
src/search-client.ts
import { AxiosInstance } from 'axios' import { Inject, Service, Token } from 'typedi' import { TOKEN_SEARCH_AXIOS } from './search-axios' export class SearchClient { search(query: string): Promise<string[]> } export const TOKEN_SEARCH_CLIENT = new Token<SearchClient>() @Service(TOKEN_SEARCH_CLIENT) export class SearchClientImpl implements SearchClient { @Inject(TOKEN_SEARCH_AXIOS) searchAxios!: AxiosInstance search(query: string): Promise<string[]> { // this.searchAxios を使える } }
自作クラスを登録する場合、デコレータを利用する。クラス本体にデコレータ @Service
を、DI コンテナから値を注入したいフィールドにデコレータ @Inject
を付与する。
@Service
にはこのクラスの識別子として新たな Token
を指定する。先ほどとは異なり、Container.set
を使わなくても自動的に DI コンテナに登録される。
@Inject
には利用したい値に紐づく識別子を指定する。これによってコンテナ内で値を生成する際に、対象のフィールドが設定される。
あとはメソッドで注入された値込みでロジックを実装すればいい。
エントリポイント
最後にエントリポイントで SearchClient
を取り出して使う。
src/main.ts
import 'reflect-metadata' import { Container } from 'typedi' import { TOKEN_SEARCH_CLIENT } from './search-client' const searchClient: SearchClient = Container.get(TOKEN_SEARCH_CLIENT) searchClient.search('some query').then((results: string[]) => { console.log(results) })
reflect-metadata
の import は typedi
の利用において必須である。Container.get
に識別子となるトークンを指定して値を取り出すだけだ。Token
を使っているため、取り出した値にもきちんと型が付いている。
基本的には DI コンテナに登録したい値ごとにファイルを分けて、ファクトリの登録と Token
の export をすることによって、エントリポイントでは依存関係を気にせずに値を取り出すことができるはずだ(上記の main.ts
でも search-axios
は気にせずに SearchClient
を利用できている)。
テストコード
ここでは SearchClient.search()
の単体テストコードを Jest で書くことを考える。
- テストコードでも DI コンテナを使う
- ただし
search-axios
は mock にしたい
なお Axios インスタンスのモック化には jest-mock-extended
を利用する
test/search-client.test.ts
import 'reflect-metadata' import { Container } from 'typedi' import { MockProxy, mock } from 'jest-mock-extended' import { AxiosInstance } from 'axios' import { SearchClient, TOKEN_SEARCH_CLIENT } from '../src/search-client' import { TOKEN_SEARCH_AXIOS } from '../src/search-axios' describe('SearchClient', () => { let client: SearchClient let mockAxios: AxiosInstance Container.set({ id: PRODUCT_REPOSITORY_TOKEN, factory: () => { mockRepo = mock<ProductRepository>() return mockRepo }}) beforeEach(() => { client = Container.get(TOKEN_SEARCH_CLIENT) }) afterEach(() => { Container.reset() }) describe('search()', () => { // client.search() に対するテストを書く }) })
まずテストでも reflect-metadata
の import は必要になる。テストフレームワークがグローバルな setup スクリプトをサポートしている場合、それを利用するといいだろう(Jest なら setupFilesAfterEnv
が使える)。
Container.set()
は同じ識別子を上書きできるので、モック化したい値のファクトリーをテストコード内で再定義すればよい。あとは beforeEach
(もしくはテスト本体)で対象の値を通常通りコンテナから取り出すことで、依存注入済みの値を取り出すことができる。
各ファクトリーの結果はコンテナ内でキャッシュされている。Container.reset()
を呼び出すことでこのキャッシュをクリアできるので、afterEach
で呼び出している。
まとめ
- コンテナに登録したいクラスに
@Service
を付ける - コンテナ内の値を利用したいフィールド or コンストラクタ引数に
@Inject
を付ける - 識別子に
Token
を使うことで type-safe に値を取り出せる