suguru.dev

バンクーバーで働くエンジニアの備忘録

tsyringeによる依存性の注入と循環参照との戦い

概要

tsyringeを弊社のプロジェクトに導入して2年くらい経ちました。このDIライブラリの導入により、

  • インターフェースによる依存性の注入
  • 循環参照

の問題が発生し、多くのことを学ぶことができたのでいくつか紹介します。

今回はライブラリの基本的な使い方には触れません。

インターフェースによる依存性の注入

まずはじめにtsyringeではインターフェースによる依存性の注入を簡単に書くことができません。 こちらの例に習って書くと以下のようになります。

// useCases/UserItem.ts
export interface UserItem {
  give(itemId: string, quantity: number): Promise<void>;
}

export namespace UserItem {
  export interface Repository {
    increment(itemId: string, quantity: number): Promise<void>;
  }
}

class UserItemUseCase implements UserItem {
  constructor(@inject('UserItem.Repository') private readonly repository: UserItem.Repository) {}

  async give(itemId: string, quantity: number): Promise<void> {
    await this.repository.increment(itemId, quantity);
  }
}
container.register('UserItem', { useClass: UserItemUseCase });

// repositories/UserItem.ts
class UserItemRepository implements UserItem.Repository {
  async increment(itemId: string, quantity: number): Promise<void> {}
}
container.register('UserItem.Repository', { useClass: UserItemRepository });

インターフェースとクラスを割り当てたいだけなのにも関わらず、手動でstringやsymbolを使用して登録する必要があります。 これは、JavaScript上でインターフェースが存在できないため、インターフェースと実際のクラスをつなぐトークンが必要になるためです。

そこでJavaScript上で存在できて、かつTypeScript上でインターフェースとして扱うことができる抽象クラスを採用することにしました。

// useCases/UserItem.ts
export abstract class UserItem {
  abstract give(itemId: entities.Item.Id, quantity: number): Promise<void>;
}

export namespace UserItem {
  export abstract class Repository {
    abstract increment(itemId: entities.Item.Id, quantity: number): Promise<void>;
  }
}

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  constructor(private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    await this.repository.increment(itemId, quantity);
  }
}

// repositories/UserItem.ts
@injectable(UserItem.Repository)
class UserItemRepository implements UserItem.Repository {
  async increment(itemId: entities.Item.Id, quantity: number): Promise<void> {}
}

// container.ts
import * as tsyringe from 'tsyringe';

export type Constructor<T = any> = new (...args: any[]) => T;
export type AbstractConstructor<T = any> = abstract new (...args: any[]) => T;
export type Token<T = any> = tsyringe.InjectionToken<T> | AbstractConstructor<T>;

export function injectable(...tokens: Token[]) {
  return (Class: Constructor) => {
    tsyringe.injectable()(Class);
    registerInterfaces(Class, tokens);
  };
}

function registerInterfaces(Class: Constructor, tokens: Token[]) {
  for (const token of tokens) {
    tsyringe.container.register(token as tsyringe.InjectionToken, { useToken: Class });
  }
}

これにより"インターフェース"による依存性の注入が簡単に行えるようになりました。

余談ですが、インターフェース名にI接頭辞を仕様することはTypeScriptでは非推奨です。これは依存先がインターフェースかクラスかを知るべきではないからです。 この原則からもインターフェースのときのみ@inject を使用をするということは避けたいものがあります。

他のデコレータもこちらに載せてあるのでぜひ参考にしてみてください。

循環参照との戦い

Node.jsの背景

Node.jsは循環参照を言語仕様でサポートしています。実際tsryingeを使うまで、循環参照の問題に直面することは今まで一度もありませんでした。

// UserItem.ts
import * as UserReward from './UserReward';
export async function give(id: entities.Item.Id, quantity: number): Promise<void> {
  const item = await repository.get(id);
  if (item.type === entities.Item.Type.Reward) {
    await UserReward.give(item.rewardId, quantity);
  }
}

// UserReward.ts
import * as UserItem from './UserItem';
export async function give(id: entities.Item.Id, quantity: number): Promise<void> {
  const reward = await repository.get(id);
  await UserItem.give(itemId, quantity);
}

このコードはNode.js上では何も問題なく動きます。今回はあまり触れませんが、requireが循環参照を解決してくれるためです。 しかしtsryingeではうまく動きませんでいた。

// UserItem.ts
import { UserReward } from './UserReward';

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  constructor(private readonly userRewardUseCase: UserReward, private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    const item = await this.repository.get(itemId);
    if (item.type !== entities.Item.Type.Reward) {
      return this.repository.increment(itemId, quantity);
    }
  }
}

// UserReward.ts
import { UserItem } from './UserItem';

@injectable(UserReward)
class UserRewardUseCase implements UserReward {
  constructor(private readonly userItemUseCase: UserItem, private readonly repository: UserReward.Repository) {}

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

これはTypeScriptのデコレータが同期的に依存関係を解決し、requireの恩恵が得られないためでした。 TypeScriptの言語仕様の欠陥とも見ることもできますが、私はこのことがきっかけとなりClean Architectureを勉強し・導入することになりました。

解決策1: delay

tsryingeにはdelayという関数があり、これにより循環参照を利用することが可能になります。

// UserReward.ts
@injectable(UserReward)
class UserRewardUseCase implements UserReward {
  constructor(
    @inject(delay(() => UserItem as any))
    private readonly userItemUseCase: UserItem,
    private readonly repository: UserReward.Repository,
  ) {}

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

言語仕様的に循環参照が認められてるので必ずしも悪とは言いませんが、これはアーキテクチャの本質的な問題から目を逸らしているだけのようにも感じ、弊社では使用を禁止しています。

解決策2: 依存性逆転

循環参照は依存の方向を考えずに書くときに起こります。そのためコードを書き始める前に予め依存の方向を決める必要があります。このケースでは依存の方向を UserReward -> UserItem に固定し、 依存性の逆転を適用します。 f:id:suguru03:20210927122809p:plain

// UserItem.ts
export namespace UserItem {
  export abstract class UserRewardUseCase {
    abstract give(rewardId: entities.Item.RewardId, quantity: number): Promise<void>;
  }
}

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  private userRewardUseCase: UserItem.UserRewardUseCase;
  constructor(private readonly container: Container, private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    const item = await this.repository.get(itemId);
    if (item.type !== entities.Item.Type.Reward) {
      return this.repository.increment(itemId, quantity);
    }
    await this.userRewardUseCase.give(item.rewardId, quantity);
  }

  register(userRewardUseCase: UserItem.UserRewardUseCase) {
    this.userRewardUseCase = userRewardUseCase;
  }
}

// UserReward.ts
import { UserItem } from './UserItem';

@injectable(UserReward)
class UserRewardUseCase implements UserReward, UserItem.UserRewardUseCase {
  constructor(private readonly userItemUseCase: UserItem, private readonly repository: UserReward.Repository) {
    userItemUseCase.register(this);
  }

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

このようにUserReward側から依存性を注入することで循環参照を避けることができます。

解決策3:デコレータを使った依存性逆転

デコレータを使っても依存性を逆転させることができます。

// UserItem.ts
export namespace UserItem {
  export function RegisterReward() {
    return (Class: Constructor<UserItem.UserRewardUseCase>) => {
      UserItemUseCase.register(Class);
    };
  }
}

@injectable(UserItem)
class UserItemUseCase implements UserItem {
  private static UserReward: Constructor<UserItem.UserRewardUseCase>;
  constructor(private readonly container: Container, private readonly repository: UserItem.Repository) {}

  async give(itemId: entities.Item.Id, quantity: number): Promise<void> {
    const item = await this.repository.get(itemId);
    if (item.type !== entities.Item.Type.Reward) {
      return this.repository.increment(itemId, quantity);
    }
    await this.container.resolve(UserItemUseCase.UserReward).give(item.rewardId, quantity);
  }

  static register(Class: Constructor<UserItem.UserRewardUseCase>) {
    this.UserReward = Class;
  }
}

// UserReward.ts
@injectable(UserReward)
@UserItem.RegisterReward()
class UserRewardUseCase implements UserReward, UserItem.UserRewardUseCase {
  constructor(private readonly userItemUseCase: UserItem, private readonly repository: UserReward.Repository) {}

  async give(id: entities.Reward.Id, quantity: number): Promise<void> {
    const reward = await this.repository.get(id);
    await this.userItemUseCase.give(reward.itemId, reward.quantity * quantity);
  }
}

個人的にはデコレータで書くことが好きですが、どちらの方法でも良いかと思います。

まとめ

tsyringe・TypeScriptのちょっとしたテクニックと、私の循環参照との戦いについて紹介しました。 あまり触れませんでしたが、Clean Architectureは多くの依存問題を解決し、テスタブルなコードを書くことを手助けしてくれます。

こちらにコードをあげてありますのでぜひ参考にしてみてください。

おまけ

ちなみにTypeScriptのTemplate Literalのサポートにより、より厳格な型定義がしやすくなりました。

// index.d.ts
type Key<Name extends string> = string & Record<`__${Name}`, never>;


// entities/Item.ts
export namespace Item {
  export type Id = Key<'item'>;
}

リンク