Inversifyを使った、型堅牢なDIコンテナの構築

Inversifyを使った、型堅牢なDIコンテナの構築

こんにちは、 kotamatです。 新マイクロサービスのアーキテクチャーにNode.jsとTypeScriptを導入したのですが、そちらの基盤技術としてInversifyを導入したらめちゃくちゃ良かったので、使い方含めて紹介します。

DIコンテナって何?

DI(Dependency Injection)を達成するためのコンテナのことです。

DIは色んな所で紹介されているので、割愛しますが、簡単に言うと

class Hoge {
    get() {
        const fuga = new Fuga()
        ...
    }
}

とあったときに、Hoge::get()はFugaがnewされないと処理を実行できないため、例えばこの関数をユニットテストしたいときに、Fugaのコンストラクタの実装に依存してしまいます。 例えばコンストラクタ上でデータストアに接続するような処理がある場合、テスト環境にもデータストアの環境を整備しなければならず、本来やりたかったこと以上のことをする必要があり、面倒でありメンテコストも上がってしまいます。

これを

class Hoge {
    get(fuga: Fuga) {
        ...
    }
}

って外部から注入してあげれば、例えばfugaをモックしたオブジェクトを外から注入してあげるだけで上記の問題が解決します。 これがいわゆるDIと呼ばれるものです。

当然呼び出し元でnewすれば注入できるわけですが、そうすると呼び出し元がFugaに依存するようになってしまい、処理がより複雑になってしまいます。 CleanArchitectureなどに代表される、多層化されたアーキテクチャーを採用する場合、この問題はより顕著になり、下記のようになってしまいます。

/// A -> B -> Cという依存

class A {
    constructor() {
        new c = C()
        new b = B(c)
    }
}

class B {
    constructor(c: C) {
    ...
    }
}

class C {
}

また、どのクラスをnewすればいいかという観点において、基本的にはアプリケーションごとに唯一のクラスが指定できればいい(通常はモックなしのもの、テスト時はモックありのもの)はずなので、どこかで定義できてしまえば解決します。

これを解決するために、newする処理を一つのコンテナ(オブジェクト、グローバル変数のようなもの)に集約することによって、依存性解決をわかりやすくしようというのが、DIコンテナの役割となります。

Inversifyを使った方法

DIコンテナは各言語、フレームワークごとに独自で実装されていたりするのですが、TypeScriptではInversifyというライブラリがあります。 Inversify これは、TypeScriptのアノテーションを使うことによって、DIコンテナを実現してくれます。 TypeScriptベースで作られているため、型安全に実装できるのがポイント。 JavaScriptでも使用できるため、TSユーザじゃなくても使うことができるのが魅力的です。

インストール

npm install inversify reflect-metadata --save

refrect-metadataというものも必要なので、注意。 refrect-metadataはグローバルのシングルトンなので、一度だけimportするようにしましょう

tsconfig.jsonには下記のように入れていきましょう。

{
    "compilerOptions": {
        "target": "es5",
        "lib": ["es6", "dom"],
        "types": ["reflect-metadata"],
        "module": "commonjs",
        "moduleResolution": "node",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

使用方法

まず先に実装クラスと、対応するinterfaceを実装します。 今回はDB接続を想定した設計でやってみます。

interface DatabaseInterface {
    connect(option: Option): void;
    find<T>(query: Query): T;
}

interface UserRepositoryInterface {
    find(id: string): User;
}

次にDIで用いる識別子を登録していきます。 Symbolで実装することを推奨しているようです。

let TYPES = {
    DB: Symbol("DB"),
    UserRepo: Symbol("UserRepo"),
    UserCntl: Symbol("UserCntl")
}
export default TYPES;

最後に実装していきます。DIされるクラスには@injectableデコレータを、DIするインスタンス@injectデコレータをつけていきます。

import { injectable, inject } from 'inversify';
import 'reflect-metadata';
import TYPES from './types';

// DB
@injectable()
class Mongo implements DatabaseInterface {
    public connect(option): void {
        // ...
    }
    
    public find<T>(query: Query): T {
        // ... 
        const res: T= {} as T;
        return res
    }
}

// Repo
@injectable()
class UserRepository implements UserRepositoryInterface {

    private _db: DatabaseInterface;

    public constructor(
        @inject(TYPES.DB) db: DatabaseInterface // constructorに注入
    ) {
        this._db = db
    }

    public find(id: string): User {
        const query: Query = {};
        return this._db.find<User>(query);
    }
}

// Controller

class UserController {
    @inject(TYPES.UserRepo) private _repo: UserRepositoryInterface; // property に直接注入
    
    public get(): User {
        const userId: string = 'hoge';
        return this._repo.find(userId);
    }
}

こちらを見てもらえれば分かる通り、UserControllerはどのDBの実装クラスが使われているかも、どのUserRepositroyのインスタンスを使うかも気にすることなく、直下のUserRepositoryInterfaceを継承する何かを使うという処理を書くだけで終了します。

実際の依存関係は inversify.config.ts に記載します。

import { Container } from 'inversify';
import TYPES from './types';

const container = new Container();
container.bind<DatabaseInterface>(TYPES.DB).toConstantValue(new Mongo()); // シングルトンで登録
container.bind<UserRepositoryInterface>(TYPES.UserRepo).to(UserRepository);
container.bind<UserController>(TYPES.UserCntl).to(UserController);

最後にコンポジションルート(最上位のmain処理)に依存性解決の処理を記載します。

import container from './inversify.config';
import { UserController, DatabaseInterface } from './main';
import TYPES from './types';

function main() {
    // Controllerでゴニョゴニョする
    const controller = container.get<UserController>(TYPES.UserCntl);

    const user = controller.get()
    console.log(user) // なにかする
}

main();

何がいいの?

開発環境と本番環境でDB接続が違う場合

例えばAWSにはDocumentDBというMongoDB互換のDBがあります。 こちらのDBはほとんどMongoDBと同等なのですが、TLS接続が標準であり安全性のために必須にしているかと思います。 ただ、ローカルではTLS接続はしたくないといったパターンの場合、上記でいうconnect()の関数だけ変更したくなります。

そういった場合は、下記のように専用のクラスを作り、inversify.config.tsを変更するだけです。

@injectable()
class DocDB extends Mongo {
  protected ca: Buffer[];

  public constructor() {
    super();
    this.ca = [fs.readFileSync("/path/to/pem/rds-combined-ca-bundle.pem")]; // caファイルの読み込み
  }
  public connect(): void { // connectのオーバーライド
    ...
    this.client = new MongoClient(url, {
      auth: { user, password },
      useNewUrlParser: true,
      useUnifiedTopology: true,
      sslValidate: true,
      sslCA: this.ca
    });
  }
}

inversify.config.ts

container.bind<DatabaseInterface>(TYPES.DB).toConstantValue(
    process.env.NODE_ENV === 'production' ? new DocDB() : new Mongo()
);

たったこれだけで本番環境と開発環境でデータストアを切り替えることができます。

テストで使いたい

テスト環境で使いたい場合も非常にシンプルです。 UserRepositoryをテストしたい場合は、 下記のように注入したいモックオブジェクトを作成し、それを注入するだけです。

import * as TypeMoq from "typemoq";

describe("test", (): void => {
  test("repo", async () => {
    const user = {};
    const mock: TypeMoq.IMock<DatabaseInterface> = TypeMoq.Mock.ofType<
      DatabaseInterface
    >();
    mock
      .setup(m => m.find<User>("id"))
      .returns(() => user);
    const repo = new UserRepository(mock.object);

    const result = repo.find("id");
    expect(result).toBe(user);
  });
});

また、この他containerにはrebindという関数もあるため、テスト全体で用いるinversify.config.tsを予め用意しておき、テスト専用のモックをrebindで新たにbindした上でテストを行うことによって、多層的な依存関係のクラスのテストも容易になります。

まとめ

今回はTypeScriptでinversifyを使ったDIコンテナの紹介をさせていただきました。 軽量なアプリケーションではこのようなアーキテクチャは不要かもしれませんが、変化が激しい、レイヤーが複数あるアプリケーションや、外部との接続が多いBFFのようなものを実装する際は、処理の実行確認が非常に難しくなるため、使える技術かなと思っています。