Chapter 06無料公開

✅依存性の注入を採用する

たった
たった
2021.06.25に更新
アプリケーションコード
src
├── middleware     ... 認証・認可とGraphQLのコンテキスト
├── domain         ... ビジネスロジックの共通化
├── usecases       ... アプリケーションロジック
├── infrastructure ... 外部サービスとのやりとり
├── entities       ... エンティティとGraphQLのフィールド
├── resolvers      ... GraphQLのリゾルバー
>>└── inversify.config.ts ... 依存性の注入(以下、DI)の設定

このチャプターで使用するライブラリ

  • InversifyJS
  • TypeORM
  • TypeGraphQL

概要

依存性の注入(Dependency Injection, 以下DI)はオブジェクト指向プログラミングにおいて非常に強力な設計パターンです。私は、今回の設計でDIコンテナを導入することを強く推奨します。

DIそのものの話はfukabori.fmの第48回・第49回でiwashiさんとtwadaさんの対談が素晴らしいクオリティなのでそちらを参照ください。

DIについて、今回は簡単に「DIコンテナに依存関係を全て管理させ、あとはひたすらインターフェースに対してプログラミングすることで、実装を疎結合にすること」と理解いただければ良いです。

このチャプターではInversifyJSを例にとって実装を紹介していきます。

実装

DIコンテナが効力を発揮するユースケースで真っ先に思い浮かぶのはTypeORMのConnectionの注入です。環境によって接続するDBが違うのでConnectionを管理する必要があります。

InversifyJSを使わない場合

まずinversifyを使わないでConnectionを管理する場合は、以下のようになります。

be/src/index.ts
+ import { getConnection } from 'typeorm'

async function bootstrap() {
  const app = express()
  app.use(cors())

+  const opt = await getConnectionOptions()
+  const connection = await createConnection(opt)

  new ApolloServer({
    schema: await buildSchema(),
+    context: () => ({
+      connection: connection, // ←このconnectionをそれぞれのモジュールが使う。
+    }),
  }).applyMiddleware({
    app,
    path: '/',
    cors: false,
  })

  app.listen(4000, () => console.log('Server has started!'))
}

bootstrap()

では、テスト用のConnectionを用意しようと思ったらどうなるでしょうか?
よくある実装としてはGraphQLのContextを通じて渡すパターンがあります。

be/src/index.ts
+ import * as TypeMoq from 'typemoq'

async function bootstrap() {
  const app = express()
  app.use(cors())

+  const mockConnection = TypeMoq.Mock.ofType<Connection>()
  const opt = await getConnectionOptions()
  const connection = await createConnection(opt)

  new ApolloServer({
    schema: await buildSchema(),
    context: () => ({
-      connection: connection,
+      connection: process.env.OPT_ENV === 'test' ? mockConnection : connection,
    }),
  }).applyMiddleware({
    app,
    path: '/',
    cors: false,
  })

  app.listen(4000, () => console.log('Server has started!'))
}

bootstrap()

これでContextにとっては、テスト環境と実行環境の依存関係ができたことになります。

ではConnectionを扱うUserDomainについて考えてみます。
以下のようにContextを外部から与えてもらい、Connectionを使う実装になります。

be/src/domain/UserDomain.ts
export class UserDomain {
  constructor(
    private readonly ctx: Context
  ) {}

  public async doSomething() {
    /* do something */
    await this.ctx.connection.doSomething()
    /* do something */
  }
}

続いてUserDomainを使うUserUsecaseについて考えてみます。
こちらも同じく、以下のような実装になりますね。

be/src/usecases/UserUsecase.ts
import { UserDomain } from 'src/domain/UserDomain.ts'

export class UserUsecase {
  constructor(
    private readonly ctx: Context    
  ) {
    this.domain = new UserDomain(ctx)
  }

  public async doSomething() {
    /* do something */
    await this.domain.doSomething()
    /* do something */
  }
}

そしてUserUsecaseを使うUserResolverについて考えてみます。

be/src/resolvers/UserResolver.ts
import { Ctx, Mutation, Resolver } from 'type-graphql' 

import { UserUsecase } from 'src/usecases/UserUsecase.ts'

@Resolver(() => User)
export class UserResolver {
  @Mutation(() => [User])
  public async usersDoSomething(
    @Ctx() ctx: Context
  ): Promise<User[]> {
    const usecase = new UserUsecase(ctx)
    await usecase.doSomething()
    /* do something */
  }
}

最後にresolverにcontextを渡します。

be/src/middleware/buildSchema.ts
+ import { UserResolver } from 'src/resolvers/UserResolver.ts'

const buildSchema = async (
  options: Partial<BuildSchemaOptions> = {}
): Promise<GraphQLSchema> => {
  return buildSchemaSync({
+   resolvers: [UserResolver],
    emitSchemaFile: `${path.dirname(__dirname)}/../schema/generated/schema.gql`,
    ...options,
  })
}

export default buildSchema

もうお気づきかと思いますが、Contextの環境依存がresolver, usecase, domainと波及しました。こうなると、常に与えられるContextの環境を意識しながらプログラムを書く必要があります。「誰から」「何を」与えられるかモジュール自身が知る必要性が出てきます。

これでは本来集中したい処理に全てのリソースを割くことができません。

InversifyJSを使う場合

早速ですが、コードを見ていきましょう。

DIコンテナの設定と中身を書きます。
InversifyJSではクラスのインターフェースに対して注入することができます。

be/src/middleware/inversify.config.ts
import { Connection, createConnection, getConnectionOptions } from 'typeorm'
import {
  decorate,
  injectable,
  interfaces,
  BindingScopeEnum,
  Container,
  AsyncContainerModule,
} from 'inversify'

decorate(injectable(), Connection)

const bindings = new AsyncContainerModule(async (bind) => {
  if (process.env.OPT_ENV === 'test') {
    // テスト環境ではmockを注入する
    const mockConnection = TypeMoq.Mock.ofType<Connection>()
    bind<Connection>(Connection).toConstantValue(mockConnection.object)
    return
  }

  const opt = await getConnectionOptions()
  const connection = await createConnection(opt)
  bind<Connection>(Connection).toConstantValue(connection)
})

const container = new Container({
  autoBindInjectable: true,
  defaultScope: BindingScopeEnum.Singleton,
})

export { container, bindings }

そして、Schemaに定義したDIコンテナをセットします。

be/src/middleware/buildSchema.ts
+ import { bindings, container } from 'src/middleware/inversify.config'

const buildSchema = async (
  options: Partial<BuildSchemaOptions> = {}
): Promise<GraphQLSchema> => {
+ await container.loadAsync(bindings)
  return buildSchemaSync({
+    container,
    resolvers: [UserResovler],
    emitSchemaFile: `${path.dirname(__dirname)}/../schema/generated/schema.gql`,
    ...options,
  })
}

export default buildSchema

では問題になっていた、domain, usecase, resolverの依存は解決されたのでしょうか?
コードを見ていきます。

be/src/domain/UserDomain.ts
+ import { inject, injectable } from 'inversify'

+ // 他のクラスにinjectできるようにする
+ @injectable()
export class UserDomain {
  constructor(
+    // Contextではなく、欲しかったConnectionを注入する
+    @inject(Connection) private readonly conn: Connection,
-    private readonly ctx: Context
  ) {}

  public async doSomething() {
    /* do something */
-    await ctx.connection.doSomething()
+    await this.connection.doSomething()
    /* do something */
  }
}
be/src/usecases/UserUsecase.ts
+ import { inject, injectable } from 'inversify'
import { UserDomain } from 'src/domain/UserDomain.ts'

+ @injectable()
export class UserUsecase {
  constructor(
+   // Contextではなく、欲しかったUserDomainを注入する
+   @inject(UserDomain) private readonly domain: UserDomain
-   private readonly ctx: Context 
  ) {
-    this.domain = new UserDomain(ctx)
  }

  public async doSomething() {
    await this.domain.doSomething()
    /* do something */
  }
}
be/src/resolvers/UserResolver.ts
+ import { inject, injectable } from 'inversify'
import { Ctx, Mutation, Resolver } from 'type-graphql' 
import { UserUsecase } from 'src/usecases/UserUsecase.ts'

+ @injectable()
@Resolver(() => User)
export class UserResolver {
+  constructor(
+    @inject(UserUsecase) private readonly usecase: UserUsecase
+  ) {}

  @Mutation(() => [User])
  public async usersDoSomething(
    @Ctx() ctx: Context
  ): Promise<User[]> {
-    const usecase = new UserUsecase(ctx)
-    await usecase.doSomething()
+    await this.usecase.doSomething()
    /* do something */
  }
}

どうでしょうか。
それぞれのモジュールは注入されるインターフェースに対してのみ実装を行っており、その具体的な実装との依存関係は知る必要がありません。また、「誰から=DIコンテナ」「何を=インターフェースの実装」与えられるかは明確であるため、こちらも意識する必要はありません。

これでConnectionの依存関係を閉じ込めることができました。
このほかに、外部サービスのAPIクライアントなど環境によって差分が発生する場合、それによって生み出される依存関係は全てDIコンテナに閉じ込めることができます。