TSyringe - JavaScript/TypeScript向けの軽量DIコンテナ

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のコンストラクタのパラメータへの渡し方や、TicketRepositoryTicketUseCaseのインスタンスの作成などは全て 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 コンテナはパワーを発揮します。


Profile picture

Shin'ya Ueoka

B2B向けSaaSを提供する会社の、元Webエンジニア。今はエンジニアリング組織のマネジメントをしている。