🦴

TypeScriptのDIとTsyringeについて

2021/08/19に公開

DIとは

DI(Dependency Injection)とは、日本語訳で依存性の注入です。依存性の注入と聞くと、依存性という抽象的な概念を何かに注入するような印象を与えますが、依存性という言葉自体は依存対象を表します。

DIにおける依存対象は、オブジェクトのインスタンスです。つまり、Dependencyはオブジェクトのインスタンスを指します。そして、Injectionは外部から挿入するという意味を持つため、DIはオブジェクトのインスタンスを外部から挿入するという事になります。

DIのコード

DIの対応前後のサンプルコードで比較を確認します。次のコードは、ブラウザのコンソールに「Saved yamada!」と出力します。実用性はないコードです。

DI対応前

database.ts
import User from './user'

export default class Database {
  saveUser(user: User) {
    console.log(`Saved ${user.userName}!`) // Saved yamada! 
  }
}
user.ts
import Database from './database'

export default class User {
  userId: number = 0
  userName: string = ''

  saveUser() {
    if (this.userId) {
      const database = new Database()
      database.saveUser(this)
    }
  }
}
index.ts
import User from './user'

const user = new User()
user.userId = 1
user.userName = 'yamada'
user.saveUser()

DI対応前は、user.tsのsaveUserメソッド内でDatabaseクラスをインスタンス化しています。

DI対応後

  • index.ts: Userクラスの引数に、Databaseクラスのインスタンスを挿入します
  • user.ts: コンストラクタにインスタンス化したDatabaseクラスを渡します
index.ts
import User from './user'
+ import Database from './database'

- const user = new User()
+ const user = new User(new Database())
user.userId = 1
user.userName = 'yamada'
user.saveUser()
user.ts
import Database from './database'

export default class User {
  userId: number = 0
  userName: string = ''
+  constructor(private database: Database) {}

  saveUser() {
    if (this.userId) {
-     const database = new Database()
-     database.saveUser(this)
+     this.database.saveUser(this)
    }
  }
}

Userクラスの外部からDatabaseクラスのインスタンスを挿入するだけで、DIの対応は完了です。このように、依存対象となるインスタンス(dependency)を外部から注入(injection)していることからDependencyInjection(DI)と呼ばれます。

DIの利点は、単体テストがしやすくなることです。DI対応前の場合、Userクラスの単体テストをしようとすると、Databaseクラスに依存しているため、テストが失敗した時に原因の切り分けが面倒になります。

しかし、この方法にも問題があります。Userクラスをインスタンス化する際に、Databaseクラスをインスタンス化して渡さなければいけません。そのため、Userクラスは常にDatabaseクラスに依存している事になります。

この問題を解決するには、DIコンテナと呼ばれる依存関係の情報を持ったオブジェクトを利用します。DIコンテナでは、注入される側のクラスと注入する側のクラスを保持します。そして注入される側のクラスをインスタンス化する際に、DIコンテナから注入するクラスを選択し、インスタンス化して受け渡します。そして、DIコンテナの対応関係を記述する箇所を、メイン処理を担うコンポーネントに集約します。

DIコンテナを利用するには、先にDIコンテナで使われる「Decorator」と「reflect-metadata」に触れておく必要があります。

Decoratorとは

デコレータを使うとクラスやメソッドの実行時に、割り込んで処理を入れ込むことが出来ます。デコレーターを使うために、tsconfig.jsonを編集します。

tsconfig.json
{
  'compilerOptions': {
    'target': 'ES5',
    'experimentalDecorators': true
  }
}

そして、次のようにコードを書きます。@Loggerがデコレーター用の関数です。実行するとコンソールにLoggerHelloが出力されます。

クラスに対してデコレータを設定する場合、HelloクラスのconstructorをLogger関数が引数として受け取る必要があります。どうしても不要な場合は、先頭にアンダースコアを入れることでconstructorの使用を回避出来ます。

function Logger(_constructor: Function) {
  console.log('Logger') // Logger
}

@Logger
class Hello {
  constructor() {
    console.log('Hello') // Hello
  }
}
new Hello()

Logger関数に引数が必要な場合は以下の通りです。

function Logger(arg: string) {
  return function (_constructor: Function) {
    console.log(arg) // Logger
  }
}

@Logger('Logger')
class Hello {
  constructor() {
    console.log('Hello') // Hello
  }
}
new Hello()

上記の例ではクラスにデコレーターを割り当てましたが、その他にもメソッド、アクセサ、プロパティ、パラメータに割り当てることが出来ます。

メソッドにデコレーターを割り当てた例は、以下の通りです。

function enumerable(value: boolean) {
    return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.enumerable = value
    }
}

class Greeter {
    greeting: string
    constructor(message: string) {
        this.greeting = message
    }
    @enumerable(false)
    greet() {
        return `Hello, ${this.greeting}`
    }
}

const greeter = new Greeter('World')
console.log(greeter.greet()) // Hello, World

アクセサ、プロパティ、パラメータへの割り当ては、以下のサイトをご確認ください。

https://www.typescriptlang.org/docs/handbook/decorators.html

※記事公開のデコレーターは、JSのstage 2 proposalの段階であり、TSでは実験的機能として提供されています。そのため、今後のリリースで変更される可能性があります。

reflect-metadataとは

reflect-metadataというライブラリを入れると、Metadata Reflection APIが使えるようになります。DIのライブラリ、Tsyringeでもreflect-metadataのimportが必要になります。

本記事ではDIコンテナを作るために必要な機能のひとつ、getMetadataについて取り上げます。

getMetadata

getMetadataは、クラスやメソッドが持つ情報を取得します。Reflect.getMetadataの第一引数にdesign:typeを指定すると引数の型の情報を取得できます。

import 'reflect-metadata'

function Log(target: any, key: string) {
  const type = Reflect.getMetadata('design:type', target, key)
  console.log(type.name) // Function

  const paramtypes = Reflect.getMetadata('design:paramtypes', target, key)
  console.log(paramtypes[0].name) // String

  const returntype = Reflect.getMetadata('design:returntype', target, key)
  console.log(returntype.name) // Boolean
}

class Demo {
  @Log
  public foo(bar: string): boolean {
    return typeof bar === 'string'
  }
}
new Demo()

getMetadataの第一引数に渡す内容によって、取得する情報が変わります。

  • design:type: 引数の型
  • design:paramtypes: 引数の型(配列)
  • design:returntype: 戻り値の型

DIコンテナは上記の仕組みを使って、自作することが可能です。しかし、自作しなくても既に便利なライブラリがあるため、DIライブラリを使って実装してみます。

DIライブラリのTsyringeを使う

TSyringeとは、Microsoftが開発を行っているIDライブラリです。TypeScriptのIDライブラリはInversifyも有名です。

npm trendsで、比較するとダウンロード数やスターの数はTsyringeの方が劣ります。しかし、DIの提供機能はTsyringeの方が絞られていて、シンプルな実装ができそうなためTsyringeを使いたいと思います。

Tsyringeを使う前

最初にTSyringe導入前後で比較が出来るように、序盤にお見せしたDI対応後のコードを振り返ります。Tsyringeを使う前は、index.tsのように、UserクラスはDatabaseクラスに依存していました。

database.ts
import User from './user'

export default class Database {
  saveUser(user: User) {
    console.log(`Saved ${user.userName}!`) // Saved yamada! 
  }
}
user.ts
import Database from './database'

export default class User {
  userId: number = 0
  userName: string = ''
  constructor(private database: Database) {}

  saveUser() {
    if (this.userId) {
      this.database.saveUser(this)
    }
  }
}
index.ts
import User from './user'
import Database from './database'

const user = new User(new Database())
user.userId = 1
user.userName = 'yamada'
user.saveUser()

Tsyringeを使って、この依存関係を解消していきます。

インストール

https://github.com/microsoft/tsyringe

tsyringeとreflect-metadataをインストールします。

% yarn add tsyringe reflect-metadata

TSのDecoratorsという実験的な機能を利用するため、tsconfig.jsonを書き換えます。

tsconfig.json
{
  'compilerOptions': {
    'experimentalDecorators': true,
    'emitDecoratorMetadata': true
  }
}

'experimentalDecorators': trueでデコレーターを、'emitDecoratorMetadata': trueでメタデータを有効化します。

Tsyringeを使ってみる

Tsyringeはinterfaceを定義する場合としない場合とで、実装が異なるようなので今回はinterfaceを定義します。

user.ts

  • interfaceを定義します
  • @injectableで、クラスをinject可能なオブジェクトとしてDIコンテナに登録します
  • @injectで、interfaceへの依存を注入します
user.ts
import { injectable, inject } from 'tsyringe'

export interface IDatabase {
  saveUser: (user: User) => void
}

@injectable()
export default class User {
  userId: number = 0
  userName: string = ''

  constructor(
    @inject('IDatabase')
    private database: IDatabase
  ) {}

  saveUser() {
    if (this.userId) {
      this.database.saveUser(this)
    }
  }
}

database.ts

  • Databaseクラスにinterfaceを実装します
database.ts
import User, { IDatabase } from './user'

export default class Database implements IDatabase {
  saveUser(user: User) {
    console.log(`Saved ${user.userName}!`) // Saved yamada!
  }
}

index.ts

  • container.register()で、IDatabaseにDatabaseを登録します
  • container.resolve()で、コンテナに登録された依存関係からインスタンスを取り出します
index.ts
import 'reflect-metadata'
import { container } from 'tsyringe'
import User from './user'
import Database from './database'

container.register('IDatabase', {
  useClass: Database
})

export const user = container.resolve(User)

user.userId = 1
user.userName = 'yamada'
user.saveUser()

このようにTsyringeを使うことにより、container.register()をindex.tsに集約しました。そして、container.resolve(User)とすることで、UserクラスはDatabaseクラスに依存しなくなりました。

まとめ

依存関係を自動で解決し、インスタンスを欲しい時にすぐに取り出すことが出来るDIライブラリについて記事を書きました。今回のサンプルコードでは、とりあえず動かす事にフォーカスしたため、メリットを最大限に受容出来ていないかもしれません。

しかし、クラス間の依存関係が増るほど、DIのコストは膨らみます。そのような場合に、DIライブラリのメリットを最大限発揮しそうです。

参考

https://www.typescriptlang.org/docs/handbook/decorators.html
http://blog.wolksoftware.com/decorators-metadata-reflection-in-typescript-from-novice-to-expert-part-4
https://blog.wotw.pro/typescript-decorators-reflection/
https://mae.chab.in/archives/59845
https://qiita.com/Quramy/items/e3a43bb1734b8a7331e8
https://qiita.com/taqm/items/4bfd26dfa1f9610128bc

Discussion