🎉

TypeScriptでDIコンテナも使いたくなることもあるかもしれないのでとりあえずInversify触れておく。

に公開

この記事について

この記事を書いた理由としてはタイトル通り。TypeScript等についてはあまり詳しくはないが、開発しているとインスタンスをまとめて管理しておきたかったり、シングルトンなインスタンスが欲しくなったりということはあったりもするので、DIあるなら触っておこうという理由から試してみた。

DIコンテナについて軽く説明

まず DIDependency Injection の略で依存性注入の意味。プログラムは関数やクラス等の小さなプログラムの組み合わせでできていて、何かを実現するために何かを使って動くといった依存関係を持っている。そんな依存するものをコンテナという容器に入れていき、依存関係にあるものの解決を行って(生成して依存物をセット(注入)したり)返してくれるものをDIコンテナといい、インスタンス化を任せたり、環境によってセット内容を切り替えて開発用やテスト用でシステムを動かすなんて事に利用できる。
Javaでよく使われている印象で、関連するクラスなどにアノテーション(注釈というコードに対してメタデータを付与する機能)をつけておくとDIコンテナに登録され、必要に応じてインスタンスの生成や依存物をセットして返してくれる。コンテナへの登録は設定やコードで行なうものもある。

TypeScriptのDIコンテナについて

ざっと調べた感じ、人気のあるものでTSyringeというものとInversifyというものがある、どちらもライトウェイトだが、Inversifyはパワフルでもあるらしい。
TSyringeは簡単な動作確認程度ならしたことがあるし、あまり更新されていないということもあったりで(あまり深く調べてはいないが、問題少なく、要件が満たせていればいいと思う)、最近人気があるらしいInversifyを試すことにした。
READMEさっと見た感じはTSyringe好きかもしれない。

Inversifyについて軽く

TypeScriptによるJavaScript&Node.js用のIoCコンテナ。
IoCコンテナと紹介されているが、DI(依存性注入)もやってくれる。DIはIoCの実装パターンの一つで、オブジェクトの生成や制御の流れを他に任せるというもの(IoC)をDIを使って実装したもの。

TypeScriptのコードそのまま実行する方法について詳しくなかったので環境準備から

私はTypeScriptは大抵フレームワーク側で用意される雛形から作業開始するため、ライブラリをちょっと試すだけといった実行方法は知らず、せっかくなのでそこから開始。
tsをjsに変換してnodeに放り投げるといったものはコマンド叩いてやったことはあるが、毎度そんなことしてもいられない。

まずはnpm initでpackage.json作成

依存パッケージ、実行スクリプト用。
nodeに関してはDocker等を使わずMac用のv22.12を入れてある。
以下のコマンドでカレントディレクトリにpackage.jsonが作成される。

$ npm init
	This utility will walk you through creating a package.json file.
	It only covers the most common items, and tries to guess sensible defaults.
	
	See `npm help init` for definitive documentation on these fields
	and exactly what they do.
	
	Use `npm install <pkg>` afterwards to install a package and
	save it as a dependency in the package.json file.
	
	Press ^C at any time to quit.
	package name: (app) ts-di-inversify-example
	version: (1.0.0) 
	description: 
	entry point: (index.js) index.ts
	test command: 
	git repository: 
	keywords: 
	author: 
	license: (ISC) 
	About to write to /opt/app/package.json:
	
	{
	  "name": "ts-di-inversify-example",
	  "version": "1.0.0",
	  "main": "index.ts",
	  "scripts": {
	    "test": "echo \"Error: no test specified\" && exit 1"
	  },
	  "author": "",
	  "license": "ISC",
	  "description": ""
	}
	
	
	Is this OK? (yes) yes

ts-node, SWCをインストール

初めはts-nodeは遅いという記事はよく目にしていたためesbuildを使っていたが、esbuildではInversifyで使われるTypeScriptの設定emitDecoratorMetadataに対応しておらず、一部の機能が使えないため、esbuildを使用しているツールは使えず、ts-nodeでSWCを使うことに。
emitDecoratorMetadataは型宣言やデコレータのタイプメタデータ発行するもので、コンパイル時に削除されるメタ情報を残してreflect-metadataから利用できるようにするもの。
ほかのツールでemitDecoratorMetadataと同じような設定ができるものもあるようだが、そのツール専用の設定方法となるのでそちらは使わないことに。

以下のコマンドでts-nodeとswcをインストール

$ npm install -D ts-node swc

tsconfig.jsonを作成

compilerOptionsの内容はInversifyで求められているものに絞ってある。
それぞれ、デコレーターを有効にするかと、デコレータのタイプメタデータ発行するかの設定。
ts-node.swcはts-nodeでswcを使用するかどうかの設定。

tsconfig.json
{  
  "compilerOptions": {  
    "experimentalDecorators": true,  
    "emitDecoratorMetadata": true  
  },
  "ts-node": {  
    "swc": true  
  }
}

package.jsonを編集

項目scripts.ts-nodeを追加。npm run ts-node [filepath]でtsファイルを実行できるように。

{  
  "name": "ts-di-inversify-example",  
  "version": "1.0.0",  
  "main": "index.ts",  
  "scripts": {  
    "ts-node": "npx ts-node", 
    "test": "echo \"Error: no test specified\" && exit 1"
  },  
  "author": "",  
  "license": "ISC",  
  "description": "",  
  "dependencies": {  
  },  
  "devDependencies": {
  }  
}

InversifyJSをインストール

以下のコマンドを実行するだけ

$ npm install inversify@alpha reflect-metadata

基本的な機能を実際試してみる。

まずはサンプルで動作確認

https://inversify.io/docs/introduction/getting-started/
のサンプルコードにコメントを付けておいた。以下の内容でmain.tsファイルを作成。

main.ts
import { Container, inject, injectable } from 'inversify';

// @injectableデコレーターはサービスとして提供されるすべてのクラスに添えることを進める。といったことがドキュメントにあるが、あとの行で関連付けを行っているので、なくても動きはする。コンテナによって注入してもらったり取り出したいものには付けておきましょう。こちらのクラスは注入するもの
@injectable()
class Katana {
  public readonly damage: number = 10;
}

// こちらはコンテナにKatanaを注入してもらうもの。
@injectable()
class Ninja {
  constructor(
    // コンストラクタ引数を@injectデコレータで注釈しておくとパラメーターで指定された識別子(クラス名、トークン、シンボル等)にバインドされたクラスのインスタンスが注入される
    @inject(Katana)
    public readonly katana: Katana,
  ) {}
}

// コンテナを作成
const container: Container = new Container();

// bindメソッドでクラス名に対し、toSelfメソッドで同名のクラスを関連付ける。下の行だとNinjaというクラス名で求められたらNinjaクラスのインスタンスを返すというもの。ほかの書き方だと`container.bind(Ninja).to(Ninjya)`となる
container.bind(Ninja).toSelf();
container.bind(Katana).toSelf();

// getメソッドでNinjyaにbindされたものを取り出す。上の内容だとNinjaクラスのインスタンスが取り出される。また、Ninjaのインスタンス化の際にKatanaも注入される。
const ninja: Ninja = container.get(Ninja);

console.log(ninja.katana.damage);

ためしに実行。
コンテナからNinjaでバインドされたクラスのインスタンスを取得するので、Ninjaクラスのインスタンスが生成、Ninjaクラスのコンストラクタ引数では@injectでKatanaでバインドされたクラスのインスタンスがkatanaメンバに注入されるよう指示されているのでKatanaクラスのインスタンスが生成、注入されninja.katana.damage10となり以下のような結果に。

$ npm run ts-node main.ts

    > ts-di-inversify-example@1.0.0 ts-node
    > npx ts-node main.ts

    10

依存性逆転

SOLID原則D、interface等ででbindするものもあるが、TypeScriptだとコンパイル後interfaceは削除され情報を拾えなくなるらしく、代わりとしてSymbolを使う。
例として環境(NODE_ENV環境変数の内容)によって注入するものを切り替えるコードを用意。

main2.ts
import "reflect-metadata";  
import { Container, inject, injectable } from 'inversify';  

interface Weapon {  
    damage: number;  
}  

// シンボル。サンプルプログラム等で見かける書き方だが、オブジェクトにまとめず、分けても構わない。
const TYPES = {  
    Weapon: Symbol.for('Weapon')  
}  

// 本番環境用(NODE_ENV=production)で注入されるKatanaクラスを用意
@injectable()  
export class Katana implements Weapon {  
    public readonly damage: number = 10;  
}  

// 開発環境(NODE_ENV!=production)で注入されるWoodenSwordクラスを用意
@injectable()  
export class WoodenSword implements Weapon {  
    public readonly damage: number = 3;  
}  

@injectable()  
export class Ninja {  
    constructor(
	    // 前項のサンプルコードではクラス名を指定していたが、シンボルTYPES.Weaponで指定
        @inject(TYPES.Weapon)  
        public readonly weapon: Weapon,  
    ) {}  
}  
  
const container: Container = new Container();  
  
container.bind(Ninja).toSelf();
// 環境変数NODE_ENVの内容で分岐
if (process.env.NODE_ENV === 'production') {
    // NODE_ENVがproductionであればシンボルTYPES.WeaponへKatanaを関連付ける
    container.bind<Weapon>(TYPES.Weapon).to(Katana);
} else {
    // NODE_ENVがproductionでなければシンボルTYPES.WeaponへWoodenSwordを関連付ける
    container.bind<Weapon>(TYPES.Weapon).to(WoodenSword);
}  
  
const ninja: Ninja = container.get(Ninja);  
  
console.log(ninja.weapon.damage);

NODE_ENVproductionを設定して実行してみる。(上のスクリプトのファイル名はmain2.ts)
TYPES.WeaponKatanaクラスが関連付けられるため10が出力される

$ NODE_ENV=production npm run ts-node main2.ts 

    > ts-di-inversify-example@1.0.0 ts-node
    > npx ts-node main2.ts

    10

つづいてNODE_ENVdevelopmentを設定して実行してみる。
TYPES.WeaponWoodenSwordクラスが関連付けられるため3が出力される

NODE_ENV=development npm run ts-node main2.ts 

    > ts-di-inversify-example@1.0.0 ts-node
    > npx ts-node main2.ts

    3

メタデータで注入

emitDecoratorMetadataを有効にしているとクラスのコンストラクタの引数の型情報やプロパティの型、メソッドの戻り値の型などが参照できるようになり、これを使用して注入することもできる。

main3.ts
import "reflect-metadata";  
import { Container, injectable } from 'inversify';  
  
interface Weapon {  
    damage: number;  
}

@injectable()  
export class Katana implements Weapon {  
    public readonly damage: number = 10;  
}  

@injectable()  
export class WoodenSword implements Weapon {  
    public readonly damage: number = 3;  
}  

@injectable()  
export class Ninja {  
    constructor(
        // デコーレーションなしでもメタ情報から注入可能
        public readonly katana: Katana,  
        public readonly woodenSword: WoodenSword  
    ) {}  
}  

const container: Container = new Container();
container.bind(Ninja).toSelf();
// クラス名Katanaを識別子に同名のクラスをbind
container.bind(Katana).toSelf();  
// クラス名WoodenSwordを識別子に同名のクラスをbind
container.bind(WoodenSword).toSelf();  

const ninja: Ninja = container.get(Ninja);  

console.log(ninja.katana.damage);  
console.log(ninja.woodenSword.damage);

実行(上のスクリプトのファイル名はmain3.ts)。
KatanaWoodenSworddamageの内容が参照できていることがわかる。

$ npm run ts-node main3.ts 

    > ts-di-inversify-example@1.0.0 ts-node
    > npx ts-node main3.ts

    10
    3

TransientSingletonついでに@postConstructデコレータ

Inversifyはインスタンスのライフサイクルも管理してくれ、要求されるたびに新しいインスタンスを返すTransientと、初めて要求される際にインスタンス化を行い以降は同じインスタンスを返すSingletonが利用できる。
インスタンス化はコンテナによって行われるため、以下のサンプルではコンテナによってインスタンス化が行われた際に任意のメソッドが実行されるようにする@postConstructデコレータを使用してコンテナによってインスタンス化された際に任意の文字列を出力するようにしてある。

main4.ts
import "reflect-metadata";  
import { Container, inject, injectable, postConstruct, preDestroy } from 'inversify';  

// 使わないけども
interface Counter {  
    up(): void;  
    getCount(): number;  
}  

// 常に新しいインスタンスを生成して返すものとしてTransientCounterを定義
@injectable()  
export class TransientCounter implements Counter {  
    private count: number = 0;  
  
    up(): void {  
        this.count += 1;  
    }  
  
    getCount(): number {  
        return this.count;  
    }  
  
    @postConstruct()  
    public postConstruct(): void {  
        console.log('[TransientCounter post construct]');  
    }  
}  

// 常に同じインスタンスを返すものとしてSingletonCounterを定義、内容は一緒なので継承してメッセージ出力部分だけをオーバーライド
@injectable()  
export class SingletonCounter extends TransientCounter {  
    @postConstruct()  
    public postConstruct(): void {  
        console.log('[SingletonCounter post construct]');  
    }  
}  
  
const container: Container = new Container();  
// inTransientScopeメソッドでTransientCounterのインスタンスを要求されるたびに生成するように
container.bind(TransientCounter).toSelf().inTransientScope();
// inSingletonScopeメソッドでSingletonCounterのインスタンスを要求されるたびに生成するように
container.bind(SingletonCounter).toSelf().inSingletonScope();

// TransientCounterが毎回新しいインスタンスが生成されているか確認。upメソッドを呼んだ際にcountが0から+1されていくためつねに1が出力される
for (let i = 0; i < 3; i++) {
    const counter = container.get(TransientCounter);
    counter.up();
    console.log(counter.getCount());
}

// SingletonCounterが毎回同じインスタンスが生成されているか確認。upメソッドを呼んだ際にcountが引き継がれるため、1,2,3...と1ずつ増えていく
for (let i = 0; i < 3; i++) {  
    const counter = container.get(SingletonCounter);  
    counter.up();  
    console.log(counter.getCount());  
}

実行(上のスクリプトのファイル名はmain4.ts)。
上の3つ分の出力はTransientCounterのもので、ループ毎にインスタンス化がされメッセージの出力と、count0開始の+1で1が出力されていることがわかる。残りはSingletonCounterのもので初回の要求でインスタンス化が行われてメッセージが出力され、以降は同じインスタンスが返されるためcountも保持されて1,2,3を増えていくのが確認できる

$ npm run ts-node main4.ts

    > ts-di-inversify-example@1.0.0 ts-node
    > npx ts-node main4.ts

	[TransientCounter post construct]
	1
	[TransientCounter post construct]
	1
	[TransientCounter post construct]
	1
	[SingletonCounter post construct]
	1
	2
	3

使ってみた感じ

今のところTypeScriptはインターフェースがコンパイル時に消えてしまうということで、代わりとしてシンボルを定義するなど、面倒な印象だが、TypeScriptの他のコンテナも同じような問題を抱えているはず。また、設定や環境も限られてくるため、採用できないケースもあるかもしれないと考えるとやや使いにくい印象。
ただ、軽く、使い方も難しいわけではないため環境に問題なければすぐに試すことはできるし、うまくモジュール等を分割できるもので大きくなっていきそうなシステムでは使ってみても良さそうではあるので、他のコンテナやフレームワークを見たり試したりしつつ使い方を探ってみようとは思った。

91works Tech Blog

Discussion