Vim Vixen では Clean Architecture 風の設計をしており、扱うモデルやレイヤー毎にクラスを作成します。 現在はクラス数が 100 を超えて、クラスにインスタンスをいちいち渡したり、インスタンスの作成と管理が面倒になってきました。 そこで Vim Vixen では Dependency Injection (DI)コンテナを導入することにしました。
いろいろ探してみると、すでに JavaScript/TypeScript 用の DI コンテナがいくつか存在するようです。 その中で(巨人の肩に乗るつもりで)Microsoft の「TSyringe」という軽量 DI コンテナを採用しました。
TSyringe はデコレーター(Java のアノテーションのようなもの)で DI するクラスを指定したり、必要なクラスをコンストラクタで受け取れます。
セットアップ
まずはtsyringe
本体と、reflect を扱うための polyfill パッケージreflect-metadata
をインストールします。
$ npm install --save tsyringe reflect-metadata
TSyringe を使うには TypeScript のコンパイルオプションでデコレーターを有効にする必要があります。
以下の 2 つのオプションをtsconfig.json
で有効にします。
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
そしてreflect-metadata
パッケージをトップレベルのスクリプトで import します。
// index.ts
import "reflect-metadata"
使えるデコレーター
利用できるデコレーターは 4 つです。
@injectable()
@singleton()
@autoInjectable()
@inject()
@injectable()
クラスをインジェクト可能なオブジェクトとして DI コンテナに登録します。 コンストラクタのパラメータに、依存するクラスを受け取ることができます。 DI コンテナは自動で、コンストラクタに渡すべきインスタンスを作成したり、DI コンテナから取り出します。
import { injectable } from "tsyringe"
@injectable()
class UserRepository {
constructor(private sqlDriver: SQLDriver) {}
}
@injectable()
class UserUseCase {
constructor(private userRepository: UserRepository) {}
}
import { container } from "tsyringe"
// container.resolve() でクラスのインスタンスを解決する
let userUseCase = container.resolve(UserUseCase)
@singleton()
クラスをインジェクト可能なシングルトンオブジェクトとして DI コンテナに登録します。
import { singleton } from "tsyringe"
@singleton()
class UserClientFactory {
constructor() {}
}
@autoInjectable()
コンストラクタをパラメータを自動で解決して、パラメータ無しのコンストラクタ呼び出しを可能にします。
これは DI コンテナからではなくnew
でオプジェクトを作成できるようになります。
import { autoInjectable } from "tsyringe"
@autoInjectable()
class UserUseCase {
constructor(private userRepository?: UserUseCase) {}
}
let userUseCase = new UserUseCase()
@inject()
コンストラクタのパラメータをクラス名ではなくトークンを使って解決します。 例えば文字列を指定してクラスのインスタンスを解決できます。
import { inject } from "tsyringe"
class UserUseCase {
constructor(
@inject("UserRepository") private userReository: UserRepository
) {}
}
import { container } from "tsyringe"
// for test
container.register("UserRepository", { useClass: MockUserRepository })
// for production
container.register("UserRepository", { useClass: UserRepositoryImpl })
例: チケット販売管理
チケット販売システムを作ります。 チケットを購入すると、在庫から 1 枚チケットが減ります。 在庫のチケット枚数がゼロの場合はチケット購入に失敗したことがわかり、在庫の枚数はゼロのままです。
以下のようなクラスを定義します。
TicketRepository
: チケットを管理する永続化層。ここでは簡易化のためにオンメモリに記録する。getStock(): number
: 現在の在庫数を取得setStock(x: number): void
: 在庫数を更新
TicketUseCase
: チケットを販売するビジネスロジックbuy(): boolean
: チケットを購入する。購入できたら在庫数から 1 減らしてtrue
を返す。購入できなければfalse
を返す。
まずはTicketRepository
の実装です。
グローバル変数のチケット枚数を取得したり更新できます。
// TicketRepository.ts
import { injectable } from "tsyringe"
let stock: number = 5
@injectable()
export default class TicketRepository {
getStock(): number {
return stock
}
setStock(newValue: number): void {
stock = newValue
}
}
続いてチケットを販売するビジネスロジックの記述です。
TicketUseCase
ではチケットの在庫にアクセスするため、TicketRepository
のインスタンスをコンストラクタで受け取ります。
今は平行処理とか気にせず雑に実装します。
// TicketUseCase.ts
import { injectable } from "tsyringe"
import TicketRepository from "./TicketRepository"
@injectable()
export default class TicketUseCase {
constructor(private ticketRepository: TicketRepository) {}
buy(): boolean {
let current = this.ticketRepository.getStock()
if (current === 0) {
return false
}
this.ticketRepository.setStock(current - 1)
return true
}
}
最後にトップレベルの記述です。
// index.ts
import "reflect-metadata"
import { container } from "tsyringe"
import TicketUseCase from "./TicketUseCase"
let usecase = container.resolve(TicketUseCase)
while (usecase.buy()) {
console.log("bought ticket")
}
console.log("ticket sold out!")
TicketUseCase
のインスタンスはnew
で作成せずにcontainer.resolve()
で取得できます。
TicketUseCase
のコンストラクタのパラメータへの渡し方や、TicketRepository
とTicketUseCase
のインスタンスの作成などは全て DI コンテナに任せることができました。
TypeScript interface
TypeScript の interface を扱うには工夫が必要です。
interface は型情報の実体は持たないため(実行時にはクラスが interface を実装してるかわからない)、TSyringe ではうまく扱えません。
そのためデコレータを使うのではなく、明示的にトークンを付与して実装クラスを DI コンテナに登録します。
そして実装クラスの取得も、@inject()
デコレータを使って登録時のトークンで参照します。
たとえば先程の例で、TicketRepository
をインターフェイス化して、その実装をTicketRepositoryImpl
に分離します。
// TicketRepository.ts
export default interface TicketRepository {
getStock(): number
setStock(newValue: number): void
}
// TicketRepositoryImpl.ts
import TicketRepository from "./TicketRepository"
export default class TicketRepositoryImpl implements TicketRepository {
// ...
}
TicketRepository
を使う側は、コンストラクタでは interface で受け取ります。
そして DI コンテナから取り出すときに文字列トークン'TicketRepository'
を使用します。
// UserUseCase.ts
import { injectable, inject } from "tsyringe"
import TicketRepository from "./TicketRepository"
@injectable()
export default class TicketUseCase {
constructor(
@inject("TicketRepository") private ticketRepository: TicketRepository
) {}
// ...
}
アプリケーション起動時には実装クラスTicketRepositoryImpl
クラスを、トークン'TicketRepository'
で登録します。
これでTicketUseCase
のコンストラクタには、TicketRepositoryImpl
のインスタンスオプジェクトが渡ります。
// index.ts
import "reflect-metadata"
import { container } from "tsyringe"
import TicketRepositoryImpl from "./TicketRepositoryImpl"
import TicketUseCase from "./TicketUseCase"
// TicketRepositoryの登録
container.register("TicketRepository", { useClass: TicketRepositoryImpl })
let usecase = container.resolve(TicketUseCase)
少しくどい書き方に感じますが、インターフェイスと実装を分けることで、状況に応じて実装クラスを切り替えることができるようになります。
たとえばプロダクションではTicketRepositoryImpl
を利用して、テスト時にはモック実装を使うといったことができます。。
Clean Architecture の原則でもビジネスロジック(Use case)では Rpeository の実装に依存してはいけません。
まとめ
TSyringe の使い方を簡単に紹介しました。 ここでは簡単な例だったので、その利便性は実感しにくいかも知れません。
Vim Vixen では 100 を超えるクラス間の依存性の注入に TSyringe を使いました。 そして Clean Architecture や DDD などのクラス間の依存が多くなると、DI コンテナはパワーを発揮します。