1. 未分類
  2. 1846 view

Inversifyの基本

こんにちわ。はじぴー(hajipy)です。

inversifyはJavaScriptでInversion of Control(制御の反転、IoC)もしくはDependency Injection(オブジェクトの注入、DI)を行うためのライブラリです。IoCやDIについての説明はここでは省きます。テスト時や開発中にクラスを差し替えることでネットワークやデータベースへのアクセスをモックに置き換えることができる便利なライブラリです。この記事ではinversifyの基本的な使い方を記載します。

この記事では以下のバージョンを使用して動作を検証しました。inversifyはオブジェクトの注入指定をTypeScriptのデコレータで行っています。そのため、ソースコードはTypeScriptで記述する必要があります。reflect-metadataは実行時に型情報を取得するために必要です。

– Node.js 8.11.3
– TypeScript 3.0.1
– inversify 4.13.0
– reflect-metadata 0.1.12

サンプルコードはGitHubで公開してあります。

セットアップ

inversifyを利用するには、TypeScriptの設定ファイルであるtsconfig.jsonで以下のように設定する必要があります。

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

最小ケース

inversifyは多機能なIoCコンテナです。自分も使いこなせていない機能が多数あります。公式ドキュメントにはこれらの機能が分類されずに列挙されているだけなところが辛いところです。自分なりに全機能を確認し、最小ケースと思われる使い方を考えました。まずはそれをお伝えします。

この後、インターフェイス・クラス・識別子が4セット12個登場します。名前が似ておりと紛らわしいため、以下のように命名しています。これがベストの命名パターンというわけではないので、実利用時は適切な名前を付けていただければと思います。

インターフェイス
I+名前
クラス
名前+Impl
識別子
名前そのまま

reflect-metadataのインポート

まずは先ほどインストールしたreflect-metadataをインポートします。このライブラリはシングルトンだそうなので、実行時にどこかのスクリプトで一度インポートすればOKです。

import "reflect-metadata";

インターフェイス定義

次にinversifyで管理したいすべてのインターフェイスを定義します。ここでは以下の4つを定義します。

IRepository
データを保存するリポジトリのインターフェイス
IGateway
ネットワークアクセスを担当するゲートウェイのインターフェイス
IService
クラス利用者に何らかのサービスを提供するインターフェイス
IClient
サービスの利用者であるクライアントのインターフェイス

インターフェイスが提供するメソッドもそれっぽくしようかと思ったのですが、コードが冗長になっただけで、あまり得るものがなかったので、すべてfunc()というメソッド1つだけを提供しています。各位、想像で補っていただければと思います。

interface IRepository {
    func(): void;
}

interface IGateway {
    func(): void;
}

interface IService {
    func(): void;
}

interface IClient {
    func(): void;
}

クラス実装

先ほどの4つのインターフェイスを実装していきます。inversifyで管理したいクラスは@injectableデコレータを付ける必要があります。メソッドの中身はどのクラスのメソッドが呼ばれたのかが分かれば十分なので、console.logを出しておきます。まずはIRepositoryインターフェイスを実装したRepositoryImplクラスです。

@injectable()
class RepositoryImpl implements IRepository {
    public func(): void {
        console.log("RepositoryImpl.func()");
    }
}

同様にIGatewayインターフェイスを実装したGatewayImplです。名前が違うだけで先ほどとまったく同じコードです。

@injectable()
class GatewayImpl implements IGateway {
    public func(): void {
        console.log("GatewayImpl.func()");
    }
}

次のIServiceインターフェイスを実装したServiceImplクラスですが、このクラスは前述のIRepositoryインターフェイスとIGatewayインターフェイスを利用するものとしましょう。inversifyを使わずにコンストラクタで2つのインターフェイスを実装したオブジェクトを受け取る場合は以下のようなクラス定義になります。

class ServiceImpl implements IService {
    private repository: IRepository;
    private gateway: IGateway;

    public constructor(
        repository: IRepository,
        gateway: IGateway
    ) {
        this.repository = repository;
        this.gateway = gateway;
    }

    public func(): void {
        console.log("ServiceImpl.func()");
        this.repository.func();
        this.gateway.func();
    }
}

この場合、コンストラクタの引数に適切なオブジェクトを渡すのはあなたの責務になります。IRepositoryインターフェイスやIGatewayインターフェイスは、何らかの永続化機構とネットワークへのアクセスがあると思われますので、本番・開発・テストで別のオブジェクトを渡したくなると思います。また、開発中に接続先の永続化機構やネットワークの準備が整っていない場合、ダミーデータを返すようにしたいという場合もあると思います。

この程度の規模であれば、本番用のオブジェクト生成コードをコメントアウトし、代わりのオブジェクト生成コードに差し替えることで対応もできます。しかし、プロジェクトが進むにつれて、このようなコードがあちこちに存在し、「テストを実行するためには10箇所書き換えなくてはいけなくて大変」という問題が起きてきます。場合によっては「書き換え漏れに気付かないまま、実行してしまい、本番データベースへアクセスしてしまった!」という不幸が起きないとも限りません。

この問題を解決し、各環境でのオブジェクト生成を一元管理し、プロダクトコードを書き換えることなく、一括で切り替えることを可能にするのが、inversifyなどのIoCコンテナの提供する機能です。現在の環境で適切なクラスのオブジェクトを生成し、必要なクラス提供することを「オブジェクトの注入」と呼びます。オブジェクトの注入方法は、コンストラクタ・プロパティ・引数などのいくつかの方法がありますが、ここではinversifyでの基本となるコンストラクタでの注入を行います。

先ほどのコンストラクタの各引数に@inject()デコレータを追加し、引数として識別子を渡します。これでinversifyに「ServiceImplクラスのコンストラクタを呼ぶときは、第1引数に識別子Repositoryのオブジェクトを、第2引数に識別子Gatewayのオブジェクトを渡す必要がある」ということを伝えることができます。

@injectable()
class ServiceImpl implements IService {
    private repository: IRepository;
    private gateway: IGateway;

    // @inject()デコレータは引数で指定した識別子のオブジェクトを注入する
    public constructor(
        @inject("Repository") repository: IRepository,
        @inject("Gateway") gateway: IGateway
    ) {
        this.repository = repository;
        this.gateway = gateway;
    }

    public func(): void {
        console.log("ServiceImpl.func()");
        this.repository.func();
        this.gateway.func();
    }
}

実際にinversifyからオブジェクトを生成するにはもう少し準備が必要になります。まずは最後のIClientインターフェイスを実装したClientImplクラスを作ってしまいましょう。こちらはIServiceインターフェイスを実装したオブジェクトが必要ということにします。行っていることはServiceImplとだいたい同じです。

@injectable()
class ClientImpl implements IClient {
    private service: IService;
    
    public constructor(
        @inject("Service") service: IService
    ) {
        this.service = service;
    }

    public func(): void {
        console.log("ClientImpl.func()");
        this.service.func();
    }
}

コンテナの作成

これで必要なインターフェイスとクラスの定義が完了しました。次はこの2つを紐付けていくことになります。

@inject()デコレータの説明で出てきたとおり、inversifyからあるインターフェイスを実装したオブジェクトを取得する際には識別子を使って欲しいものを指定します。識別子には色んな型のオブジェクトが使えるのですが、ここでは一番シンプルで分かりやすい文字列を使います。

ある環境での識別子とオブジェクト生成方法のバインディングすべてを格納するオブジェクトをコンテナと呼びます。開発用コンテナ・テスト用コンテナ・本番用コンテナなどの作り方のコードを記述し、現在の環境で必要なものを使うことになります。

今回の環境用のコンテナを作りましょう。

// 依存関係を格納するコンテナを作成
const container = new Container();

次にこのコンテナにマッピングを登録していきます。識別子「Repository」に「RepositoryImpl」クラスをバインドします。bind()メソッドでインターフェイス型を指定することで、メソッドチェーンで呼び出すto()メソッドの引数が適切であるかをTypeScriptにチェックさせることができます。

to()メソッドはinversifyの一番単純なバインディング方法でクラス名を引数で渡します。そうすることでinversifyに「この識別子のオブジェクトを生成するときは、このクラスのコンストラクタを呼び出すように」ということを伝えることができます。他にもtoConstantValue()やtoFactroy()などというメソッドがあります。詳しくは公式ドキュメントを参照してください。(しかし、数が多い上にユースケースが分かりにくいものもあるので、自分もいまだに混乱します。ニーズがあれば別記事にまとめたいな、と思っています)

// 識別子「Repository」に「RepositoryImpl」クラスをバインドする。その型は「IRepository」である
container.bind<IRepository>("Repository").to(RepositoryImpl);
// 同様に残りもバインドしていく
container.bind<IGateway>("Gateway").to(GatewayImpl);
container.bind<IService>("Service").to(ServiceImpl);
container.bind<IClient>("Client").to(ClientImpl);

これでようやくinversifyを使う用意が整いました。

コンテナからオブジェクトの取得

コンテナからオブジェクトを取得するには、get()メソッドにサービス識別子を渡します。TypeScriptが型を認識できるようにインターフェイス型を伝える必要があります。Client識別子のオブジェクトの取得は以下のようになります。

const client = container.get<IClient>("Client");

この時、inversifyは各クラスの@injectable(), @inject()デコレータと、コンテナのバインディング情報を元に以下を特定していきます。

  • オブジェクトの生成方法。コンストラクタなのか、すでに生成済みのオブジェクトを使うのか、ファクトリー関数を使うのか
  • (必要な場合のみ)オブジェクトを生成するクラス
  • (必要な場合のみ)クラスのコンストラクタに渡すべきオブジェクトの識別子

今回のコンテナの場合、以下のような手順になります。

  1. 識別子「Client」は「ClientImpl」クラスのコンストラクタを使ってオブジェクトを生成すればよいことを調べる
  2. 「ClientImpl」クラスのコンストラクタには、識別子「Service」のオブジェクトを渡す必要があることを調べる
  3. 識別子「Service」は「ServiceImpl」クラスのコンストラクタを使ってオブジェクトを生成すればよいことを調べる
  4. 「ServiceImpl」クラスのコンストラクタには、識別子「Repository」「Gateway」のオブジェクトを渡す必要があることを調べる
  5. 識別子「Repository」は「RepositoryImpl」クラスのコンストラクタを使ってオブジェクトを生成すればよいことを調べる
  6. 「RepositoryImpl」クラスのコンストラクタにはオブジェクトを渡す必要がないことを調べる
  7. 「RepositoryImpl」クラスのコンストラクタ、引数なしで呼び出し、「IRepository」インターフェイスを実装したオブジェクトを生成する
  8. 識別子「Gateway」は「GatewayImpl」クラスのコンストラクタを使ってオブジェクトを生成すればよいことを調べる
  9. 「GatewayImpl」クラスのコンストラクタにはオブジェクトを渡す必要がないことを調べる
  10. 「GatewayImpl」クラスのコンストラクタを、引数なしで呼び出し、「IGateway」インターフェイスを実装したオブジェクトを生成する
  11. 「ServiceImpl」クラスのコンストラクタを、手順7と9のオブジェクト引数にして呼び出し、「IService」インターフェイスを実装したオブジェクトを生成する
  12. 「ClientImpl」クラスのコンストラクタを、手順11のオブジェクト引数にして呼び出し、「IClient」インターフェイスを実装したオブジェクトを生成する
  13. オブジェクトの生成が完了したので、手順12のオブジェクトを呼び出し元に返す

inversify、とても頑張ってくれているのが分かると思います。実際に取得できたオブジェクトのfunc()メソッドを呼び出してみましょう。ちゃんとコンテナに設定した通りのオブジェクトが取得できていることがわかると思います。

// 取得できた「IClient」インターフェイスを実装したオブジェクトに含まれるfunc()を呼び出すと以下の出力が得られる
// ClientImpl.func()
// ServiceImpl.func()
// RepositoryImpl.func()
// GatewayImpl.func()
client.func();

コンテナ切り替えの例

さて、ここまで大変な手順を踏んできた割りには、得られたメリットはコンストラクタの引数を省略できた程度に感じられているのではないかと思います。実際にテスト環境を想定したコンテナの切り替えを試してみて、inversifyのメリットを感じてみましょう。

ユースケース1. ClientImplクラスのユニットテスト

以下のClientImplクラスのユニットテストを行いたいとします。

@injectable()
export class ClientImpl implements IClient {
    private service: IService;

    public constructor(
        @inject("Service") service: IService
    ) {
        this.service = service;
    }

    public func(): void {
        console.log("ClientImpl.func()");
        this.service.func();
    }
}

ClientImplクラスはIServiceインターフェイスを実装したオブジェクトを利用しています。これを以下のモッククラスに差し替えましょう。

// 「IService」インターフェイスを実装したモッククラス
@injectable()
export class ServiceMock implements IService {
    public func(): void {
        console.log("ServiceMock.func()");
    }
}

まずはテスト用のコンテナを新たに作ります。

const container = new Container();

次にこのコンテナにクラスをバインドしていきます。Client識別子にはテスト対象のClientImplクラスをバインドします。Service識別子には先ほど作ったServiceMockクラスをバインドします。ServiceMockクラスはIRepositoryインターフェイスやIGatewayインターフェイスを実装したオブジェクトを必要としませんので、Repository識別子やGateway識別子へのバインドは不要です。

// 「ClientImpl」クラスのテストを行いたい場合、識別子「Service」に「ServiceMock」クラスをバインドする
// 識別子「Repository」と識別子「Gateway」は使用されないため、バインドは不要
container.bind<IService>("Service").to(ServiceMock);
container.bind<IClient>("Client").to(ClientImpl);

このコンテナからClient識別子のオブジェクトを取得し、メソッドを実行してみます。ServiceImplクラスではなくServiceMockクラスのメソッドが呼び出されており、RepositoryImplクラスやGatewayImplクラスのメソッドは呼び出されていないことが確認できます。

// // 識別子「Client」にバインドされているオブジェクトを取得する
const client = container.get<IClient>("Client");

// 取得できた「IClient」インターフェイスを実装したオブジェクトに含まれるfunc()を呼び出すと以下の出力が得られる
// ClientImpl.func()
// ServiceMock.func()
client.func();

これがユニットテストにおけるinversifyの使い方になります。もう1例見てみましょう。

ユースケース2. ServiceImplクラスのユニットテスト

今度はServiceImplクラスをユニットテストしたい場合を考えてみましょう。

@injectable()
export class ServiceImpl implements IService {
    private repository: IRepository;
    private gateway: IGateway;

    public constructor(
        @inject("Repository") repository: IRepository,
        @inject("Gateway") gateway: IGateway
    ) {
        this.repository = repository;
        this.gateway = gateway;
    }

    public func(): void {
        console.log("ServiceImpl.func()");
        this.repository.func();
        this.gateway.func();
    }
}

ServiceImplクラスはIRepositoryインターフェイスを実装したオブジェクトとIGatewayインターフェイスを実装したオブジェクトが必要なので、それぞれのモッククラスを作ります。内容は先ほどと同じ感じです。

// 「IRepository」インターフェイスを実装したモッククラス
@injectable()
export class RepositoryMock implements IRepository {
    public func(): void {
        console.log("RepositoryMock.func()");
    }
}

// 「IGateway」インターフェイスを実装したモッククラス
@injectable()
export class GatewayMock implements IGateway {
    public func(): void {
        console.log("GatewayMock.func()");
    }
}

また新しいコンテナを作ってクラスをバインドしていきます。今度はServiceImplの呼び出し元であるClient識別子の登録は不要になります。

const container = new Container();

// 「ServiceImpl」クラスのテストを行いたい場合、識別子「Repository」と識別子「Gateway」にモックをバインドする
// 識別子「Client」は使用されないため、バインドは不要
container.bind<IRepository>("Repository").to(RepositoryMock);
container.bind<IGateway>("Gateway").to(GatewayMock);
container.bind<IService>("Service").to(ServiceImpl);

このコンテナからService識別子のオブジェクトを取得し、メソッドを実行してみます。RepositoryMockクラスとGatewayMockクラスのメソッドが呼び出されていることが確認できます。

// // 識別子「Service」にバインドされているオブジェクトを取得する
const service = container.get<IService>("Service");

// 取得できた「IService」インターフェイスを実装したオブジェクトに含まれるfunc()を呼び出すと以下の出力が得られる
// ServiceImpl.func()
// RepositoryMock.func()
// GatewayMock.func()
service.func();

(ほとんど同じですが)inversifyの活用方法を2例見てきました。イメージが付いたでしょうか。他にも以下のような使い方ができます。

  • 本番環境と開発環境でオブジェクトを切り替える
  • 多人数開発で他のメンバーが担当しているクラスが完成するまでモックを使う

終わりに

これでinversifyの基本の説明はおしまいです。inversifyは非常に多機能で

  • オブジェクトの生成にコンストラクタ以外の方法を指定する
  • 生成したオブジェクトの再利用範囲(スコープ)を指定する

ようなこともできます。例えば複数オブジェクトから同時にメソッドが呼ばれると問題が起きる可能性があるファイルアクセスクラスなどはシングルトンスコープに設定しておくと便利です。このような機能は公式ドキュメントを参照してください。最後まで読んでいただき、ありがとうございました。

未分類の最近記事

  1. さなのばくたん。感想 #名取爆誕

  2. JestでVue.js 単一ファイルコンポーネントのカバレッジが計測できなかった問題への対…

  3. GitHub Actionsのキャッシュは常に有効にしない方がいいかも、という話

  4. 個人開発を丸2年続けられたので進捗報告させてください

  5. Inversifyの基本

関連記事

PAGE TOP