iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

tsyringe触ってみた

はじめに

こんにちは。kogureです。最近暑くなってきて冬が恋しいです。断然冬派です。 社内でプロダクトの改善に取り組む中で、DI(Dependency Injection)を取り入れるようになりました。

ここでTypeScriptでDIコンテナライブラリって何があるんだろう?と疑問が湧きました。そこでどういったライブラリが存在するのか調べて入門してみました。

何個かライブラリは見つかったのですが、今回はその中からtsyringeを取り上げてみたいと思います。理由としてはMicrosoftが作っているライブラリなので、単純にネームバリューで選びました。PCでゲームする際はWindowsにお世話になってますしね。

本記事では、同じサンプルコードを使って以下の3段階で書いていきます。

  1. Step 0:DIを使っていない密結合なコード
  2. Step 1:手動で依存を渡すPure DI
  3. Step 2:tsyringeを使った依存解決

では早速やってみましょう。

Step 0:DIを使っていない密結合なコード

まずはDIを全く使っていないコードから出発します。例として、ユーザー登録を行う UserService を考えてみます。

このサービスは次の2つの依存を持ちます。

  • UserRepository:ユーザー情報をDBに保存する
  • EmailSender:登録完了メールを送る

これをTypeScriptで書くと、こんな感じになります。

class UserRepository {
  async save(user: { name: string; email: string }) {
    // 本来はDBへの保存処理
    console.log(`saved: ${user.name}`);
  }
}

class EmailSender {
  async send(to: string, body: string) {
    // 本来はメール送信処理
    console.log(`sent to ${to}: ${body}`);
  }
}

class UserService {
  private repository: UserRepository;
  private sender: EmailSender;

  constructor() {
    this.repository = new UserRepository(); // ← 自分で生成
    this.sender = new EmailSender();        // ← 自分で生成
  }

  async register(name: string, email: string) {
    await this.repository.save({ name, email });
    await this.sender.send(email, "ようこそ!");
  }
}

// 使う側
const service = new UserService();
await service.register("hoge", "hoge@example.com");

一見、シンプルで分かりやすいコードです。

密結合なコードは何が辛いのか整理してみましょう。

1. 依存先が外から見えない

「メール通知じゃなくてSlack通知にしたい」と思っても、UserService の中で new EmailSender() と書かれている以上、本番コードでは UserServiceクラスを書き換える必要があります。

テストでは、Jest なら jest.mock('./EmailSender')jest.spyOn(EmailSender.prototype, 'send') といったテストフレームワーク特有の機能で差し替えることはできます。

しかしこれには

  • ファイル名・クラス名・メソッド名に依存するので、リファクタリングでテストが簡単に壊れる
  • new UserService() という呼び出しだけ見ると何も必要としていないクラスに見える。実際は UserRepositoryEmailSender の2つに依存していてテストを書く際に UserService の中身を読んで初めて「ああ、ここをモックすればいいのか」と気づくことになる

2. サービスクラスが知らなくていいことまで知っている

UserService の本来の責務は「ユーザー登録の流れを表現する」ことだけのはずです。それなのに現状のコードでは、EmailSender の 作り方(コンストラクタの引数や生成手順)まで知ってしまっています。

仮に EmailSender のコンストラクタにAPIキーなどの情報が必要になったとすると、UserService の中の new EmailSender(...) にもその情報を渡さないといけません。

これら2つの問題の原因を突き詰めると

クラスが自分の依存先を、自分で生成している。

逆に言えば、依存先の生成を UserService の外で行えれば、これらの問題はまとめて解消できそうです。

次のStep 1では、密結合のコードをPure DIで書き換えていきます。

Step 1:Pure DI で書き換える

Pure DI とは、DIコンテナを使わずに手で依存を組み立てていくやり方です。

やることはシンプルで:

  • 各クラスは、依存をコンストラクタで受け取るだけにする
  • 依存の組み立ては、エントリーポイント(main 関数など)で一気にやる

密結合なコードを書き換える

まず UserService を、new で依存を作るのではなく、コンストラクタで受け取る形に変えます。

class UserService {
  constructor(
    private repository: UserRepository,
    private sender: EmailSender,
  ) {}

  async register(name: string, email: string) {
    await this.repository.save({ name, email });
    await this.sender.send(email, "ようこそ!");
  }
}

これだけで、UserService から new UserRepository()new EmailSender() が無くなります。

ただ、まだ UserRepositoryEmailSender という具体的なクラスに依存している状態です。Slack通知に切り替えたいケースに対応ができません

そこで、依存先をインタフェースで表現します。

type User = {
  name: string;
  email: string;
};

interface UserStore {
  save(user: User): Promise<void>;
}

interface Notifier {
  send(to: string, body: string): Promise<void>;
}

class UserRepository implements UserStore {
  async save(user: User) {
    console.log(`saved: ${user.name}`);
  }
}

class EmailSender implements Notifier {
  async send(to: string, body: string) {
    console.log(`sent to ${to}: ${body}`);
  }
}

class UserService {
  constructor(
    private store: UserStore,
    private notifier: Notifier,
  ) {}

  async register(name: string, email: string) {
    await this.store.save({ name, email });
    await this.notifier.send(email, "ようこそ!");
  }
}

UserService のコンストラクタの型が、インタフェースに変わりました。これで UserService は具体的なクラスに依存することがなくなりました。

依存を組み立てる場所

ただし、これだけでは動きません。どこかで具体的なインスタンスを作って UserService に渡してあげる必要があります。

その「組み立て役」を、エントリーポイント(main 関数)に集約します。

async function main() {
  // 依存の組み立てはここで一気にやる
  const store = new UserRepository();
  const notifier = new EmailSender();
  const service = new UserService(store, notifier);

  await service.register("hoge", "hoge@example.com");
}

main();

この「アプリ全体の依存関係をまとめて組み立てる場所」を Composition Root と呼びます。Pure DI では、インスタンスの初期化をここで行います。

Slack通知に切り替えたいなら、Composition Root の中の new EmailSender()new SlackSender() に変えるだけ。UserService には一切手を入れません。

テストも素直に書ける

テストでは、インタフェースを満たす偽物を作って渡すだけで済みます。

test("registerでsaveとsendが呼ばれる", async () => {
  const save = jest.fn(async () => {});
  const send = jest.fn(async () => {});

  const service = new UserService({ save }, { send });
  await service.register("test", "test@example.com");

  expect(save).toHaveBeenCalledWith({ name: "test", email: "test@example.com" });
  expect(send).toHaveBeenCalledWith("test@example.com", "ようこそ!");
});

jest.mockspyOn も使っていません。ただインタフェースを満たすオブジェクトを作って渡しているだけです。だいぶシンプルですね。

Pure DI の限界

ここまでで、Step 0 の2つの問題は解消されました。

  • 依存先が外から見える(コンストラクタの引数として明示される)
  • サービスクラスは依存の「作り方」を知らない(受け取るだけ)

依存関係がシンプルな小規模アプリなら、Pure DI で問題ありません。 Pure DI では何が辛いのか整理してみましょう。

アプリが大きくなって、依存関係の連鎖が長くなってくると、以下のようにComposition Root が肥大化していきます。

async function main() {
  const config = new Config(process.env);
  const logger = new Logger(config);
  const db = new Database(config, logger);
  const userRepo = new UserRepository(db);
  const sessionRepo = new SessionRepository(db);
  const mailer = new EmailSender(config, logger);
  const authService = new AuthService(userRepo, sessionRepo, logger);
  const userService = new UserService(userRepo, mailer, logger);
  // ... さらに何十行と続く
}

新しい依存を1つ増やすたびに、ここに行が増えます。組み立て順を間違えないようにしたり、引数を渡し違えないように気をつけないといけません。

これを解決するのがDIコンテナです。次のStep 2では、tsyringe を使ってこの組み立てを自動化していきます。

Step 2:tsyringeを使った依存解決

以下のドキュメントを参照しながら進めていきます https://github.com/microsoft/tsyringe

インストールとセットアップ

まず tsyringe をインストールします。

npm install tsyringe

加えて、tsyringe は デコレーターで型情報を扱うための補助ライブラリ を必要とします。公式では reflect-metadata / core-js / reflection の3つが候補として挙げられていますが、本記事では reflect-metadata を使います。

npm install reflect-metadata

次に、tsconfig.json に以下の2行を足します。

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

クラスに @injectable を設定する

Step 1 のクラスに、@injectable() デコレータを付けていきます。

import { injectable } from "tsyringe";
import { User } from "./types";
import { UserStore } from "./UserStore";

@injectable()
class UserRepository implements UserStore {
  async save(user: User) {
    console.log(`saved: ${user.name}`);
  }
}
import { injectable } from "tsyringe";
import { Notifier } from "./Notifier";

@injectable()
class EmailSender implements Notifier {
  async send(to: string, body: string) {
    console.log(`sent to ${to}: ${body}`);
  }
}
import { injectable } from "tsyringe";
import { UserStore } from "./UserStore";
import { Notifier } from "./Notifier";

@injectable()
class UserService {
  constructor(
    private store: UserStore,
    private notifier: Notifier,
  ) {}

  async register(name: string, email: string) {
    await this.store.save({ name, email });
    await this.notifier.send(email, "ようこそ!");
  }
}

@injectable() はクラスを tsyringe で管理する目印です。これを付けたクラスは、tsyringe が自動でインスタンスを生成してくれるようになります。

ただ、このままだとコンパイルは通っても 実行時にエラーになります

インタフェースに @inject を設定する

公式ドキュメントを参照すると

https://github.com/microsoft/tsyringe#example-with-interfaces

Interfaces don't have type information at runtime, so we need to decorate them with @inject(...) so the container knows how to resolve them.

インターフェースには実行時に型情報が存在しないため、コンテナがそれらを解決できるように、@inject(...) デコレータを付与する必要があります。

と書いてあります。 TypeScriptからJavaScriptにコンパイルされた時のことを指しているようですね

では型情報をコンテナが解決できるようにしましょう。 @inject()を設定します。引数は任意の文字列を設定します。 今回はインターフェースと合わせています。

import { injectable, inject } from "tsyringe";
import { UserStore } from "./UserStore";
import { Notifier } from "./Notifier";

@injectable()
class UserService {
  constructor(
    @inject("UserStore") private store: UserStore,
    @inject("Notifier") private notifier: Notifier,
  ) {}

  async register(name: string, email: string) {
    await this.store.save({ name, email });
    await this.notifier.send(email, "ようこそ!");
  }
}

container.resolve で一気にサービスクラスをインスタンス化

下準備が整ったので、main 関数を書き換えます。

まず、container.register() でマッピングを登録します。第1引数に @inject() で指定したのと同じ文字列、第2引数に実際に使うクラスを書きます。

次に、main関数でcontainer.resolve(UserService) を呼ぶようにします

import "reflect-metadata";
import { container } from "tsyringe";
import { UserRepository } from "./UserRepository";
import { EmailSender } from "./EmailSender";
import { UserService } from "./UserService";

container.register("UserStore", { useClass: UserRepository });
container.register("Notifier", { useClass: EmailSender });

async function main() {
  const service = container.resolve(UserService);
  await service.register("hoge", "hoge@example.com");
}

main();

この実装はどのような動きとなるのか、公式ドキュメントを確認すると

https://github.com/microsoft/tsyringe#resolution

Resolution is the process of exchanging a token for an instance. Our container will recursively fulfill the dependencies of the token being resolved in order to return a fully constructed object.

解決とは、トークンをインスタンスと交換するプロセスです。コンテナは、完全に構築されたオブジェクトを返すために、解決中のトークンの依存関係を再帰的に満たしていきます。

と説明されています。

今回の実装に当てはめると

  1. UserService のコンストラクタを見て、UserStoreNotifier が必要だと判断する
  2. それぞれの文字列から、登録されたクラスを見つける(UserRepositoryEmailSender
  3. もしそれらのクラスにも依存があれば、同じ手順を繰り返して順に解決する
  4. 最後に UserService のインスタンスを作って返す

といった動きになるようです。

最後にこの実装が動くか実行してみました。

ちゃんと実行ができました。

最終的なディレクトリ構成はこのようになりました。

まとめ

実際にtsyringeを使ってみて思ったのは、大規模なアプリケーションで依存が複雑でない限りはPure DI自前でコンテナクラスを実装してそこで依存解決する形でも良さそうだなと思いました。

必要になってから tsyringe を入れても遅くないと思います。とりあえず入れると便利になるかと問われるとあまり恩恵を感じませんでした。

今回の記事で試した内容はtsyringeの全てではないので機会があればもっと深堀りしてみたいです。

社内プロダクトに tsyringe を入れるかどうかは、もう少し他のライブラリも触ってから決めようと思います。

この記事を読んで少しでもiimonに興味を持ってくださった方がいらっしゃいましたら、まずはカジュアルにお話しましょう! ぜひお気軽にご応募ください!

iimon採用サイト / Wantedly / Green