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 に値を取り出せる