初めに
前自分で書いたDIの記事を見て、いろいろと説明が足りてないと思うところがあったので、
改めて記事を書いてみました。
対象読者
- DIの基本概念を理解したいエンジニア
- 設計パターンとしてDIを使うことを検討しているエンジニア
まず結論
DI(Dependency Injection)とはコードの一部が他のコードに依存している場合、その依存関係を外部から注入するデザインパターン。
-
メリット:
- コードの柔軟性が向上し、変更やテストがしやすくなります。
-
デメリット:
- テストを書かない場合は冗長な実装になります。
Dependencyの管理にかかわる要素
Dependency (依存)にかかわる要素について解説します。
Dependency(依存)
Dependency(依存)とは、あるクラスが他のクラスの機能を必要とする関係を指します。
例えば、クラスAがクラスBのインスタンスを作成して使用している場合、クラスAはクラスBに依存しています。
class Service {
getMessage() {
return "Hello, Dependency!";
}
}
class Client {
private service: Service;
constructor() {
this.service = new Service();
}
showMessage() {
console.log(this.service.getMessage());
}
}
const client = new Client();
client.showMessage();
ポイント
-
Client
クラスは直接Service
クラスがないと動きません(Client
←Service
の依存関係がある。) -
Service
クラスの変更があるとClient
クラスにも変更が必要になります。
Dependency Injection(依存性の注入)
Dependency Injectionとは、依存を外部から注入するデザインパターンです。
クラスAがクラスBのインスタンスを外部から受け取る場合、クラスAに依存性(クラスB)を注入したといえます。
class Service {
getMessage() {
return "Hello, DI!";
}
}
class Client {
private service: Service;
constructor(service: Service) {
this.service = service;
}
showMessage() {
console.log(this.service.getMessage());
}
}
const service = new Service();
const client = new Client(service);
client.showMessage();
ポイント
-
Client
クラス内でService
クラスのインスタンスを作成する必要がありません。 -
Service
クラス以外をClient
クラスで使いたい場合、Client
クラスに渡すインスタンスを変えるだけで済みます。
Dependency Inversion Principle(依存性逆転の原則)
Dependency Inversion PrincipleはSOLID原則の一つで、クラスは別のクラスに依存するべきではなく、両者は抽象(インターフェイス)に依存するべきという考え方です。
インターフェイスとはクラスのメソッドの型のみを定義したものです。
クラスはインターフェイスを継承(implements)して実装されます。
Dependency InjectionとDependency Inversion Principleは併用されることが多いです。
service.ts
interface IService {
getMessage(): string;
}
class Service implements IService {
getMessage(): string {
return "Hello, Dependency Inversion!";
}
}
client.ts
class Client {
private service: IService;
constructor(service: IService) {
this.service = service;
}
showMessage() {
console.log(this.service.getMessage());
}
}
const service = new Service();
const client = new Client(service);
client.showMessage();
ポイント
-
Client
クラスはIService
インターフェイスという抽象的なものに依存し、具体的な実装に依存しないため、変更に強い設計になります。
JavascriptでのDIの実装例
よく使われそうなNest.jsとInversifyJSを例に挙げて説明します。
Nest.jsでのDIの実装
service.module.ts
import { Module } from '@nestjs/common';
import { Service } from './service';
@Module({
providers: [Service],
exports: [Service],
})
export class ServiceModule {}
service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class Service {
getMessage(): string {
return "Hello, Nest.js DI!";
}
}
app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Service } from './service';
@Controller()
export class AppController {
constructor(private readonly service: Service) {}
@Get()
getMessage(): string {
return this.service.getMessage();
}
}
app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ServiceModule } from './service.module';
@Module({
imports: [ServiceModule],
controllers: [AppController],
})
export class AppModule {}
-
service.module.ts
:Service
クラスを提供するモジュールを定義しています。 -
service.ts
:Service
クラスはメッセージを返すメソッドgetMessage
を持っています。 -
app.controller.ts
:AppController
クラスは、Service
クラスのインスタンスをコンストラクタで受け取り、HTTP GETリクエストに応じてメッセージを返します。 -
app.module.ts
:アプリケーションモジュールを定義し、ServiceModule
とAppController
をインポートしています。
InversifyJSでのDIの実装
inversify.config.ts
import { Container } from 'inversify';
import { Service } from './service';
import { Client } from './client';
import { TYPES } from './types';
const container = new Container();
container.bind<Service>(TYPES.Service).to(Service);
container.bind<Client>(TYPES.Client).to(Client);
export { container };
service.ts
import { injectable } from 'inversify';
@injectable()
export class Service {
getMessage(): string {
return "Hello, InversifyJS!";
}
}
client.ts
import { inject, injectable } from 'inversify';
import { Service } from './service';
import { TYPES } from './types';
@injectable()
export class Client {
private service: Service;
constructor(@inject(TYPES.Service) service: Service) {
this.service = service;
}
showMessage() {
console.log(this.service.getMessage());
}
}
types.ts
const TYPES = {
Service: Symbol.for("Service"),
Client: Symbol.for("Client")
};
export { TYPES };
app.ts
import 'reflect-metadata';
import { container } from './inversify.config';
import { Client } from './client';
import { TYPES } from './types';
const client = container.get<Client>(TYPES.Client);
client.showMessage();
-
inversify.config.ts
:DIコンテナを設定し、Service
とClient
クラスをバインドします。 -
service.ts
:Service
クラスは@injectable
デコレーターを使用してDIコンテナに登録されています。 -
client.ts
:Client
クラスはService
クラスのインスタンスをコンストラクタで受け取ります。@inject
デコレーターを使用して、Service
クラスのインスタンスを注入します。 -
types.ts
:DIコンテナで使用する識別子を定義します。 -
app.ts
:DIコンテナからClient
クラスのインスタンスを取得し、showMessage
メソッドを呼び出します。
DIの活用事例
テストコードにおいてテストクラスを使う
テスト時に依存関係のあるクラスをモック化してInjectionすることで、自身のクラスのメソッドのロジックのテストのみに集中できます。
app.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { Service } from './service';
describe('AppController', () => {
let appController: AppController;
let service: Service;
beforeEach(async () => {
// モックサービスの作成
const mockService = {
getMessage: jest.fn().mockReturnValue('Hello, Mocked DI!'),
};
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [
{
provide: Service,
useValue: mockService,
},
],
}).compile();
appController = app.get<AppController>(AppController);
service = app.get<Service>(Service);
});
describe('getMessage', () => {
it('should return "Hello, Mocked DI!"', () => {
expect(appController.getMessage()).toBe('Hello, Mocked DI!');
});
it('should call getMessage method of the service', () => {
appController.getMessage();
expect(service.getMessage).toHaveBeenCalled();
});
});
});
-
モックサービスの作成:
-
jest.fn()
を使用してService
クラスのモックを作成し、そのgetMessage
メソッドが'Hello, Mocked DI!'
を返すように設定しています。
-
-
DIコンテナの設定:
-
Test.createTestingModule
を使用してテストモジュールを設定し、AppController
をコントローラーとして登録しています。 -
Service
プロバイダーに対してモックサービスをuseValue
オプションで提供しています。
-
-
依存関係の注入:
-
app.get<AppController>(AppController)
でAppController
のインスタンスを取得し、モックサービスが注入された状態でテストを実行します。 - 同様に、
app.get<Service>(Service)
でService
のインスタンスを取得します。
-
-
テストの実行:
-
getMessage
メソッドが正しいメッセージを返すかをテストしています。 -
getMessage
メソッドが呼び出されたかを確認するテストを追加しています。
-
環境ごとに依存関係を切り替える
状況によって使用されるサービスクラスを切り替えたいような場面で、依存関係を切り替えることが容易になります。
app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { Service } from './service';
import { AlternativeService } from './alternative.service';
@Module({
imports: [],
controllers: [AppController],
providers: [
{
provide: Service,
useClass: process.env.USE_ALTERNATIVE_SERVICE ? AlternativeService : Service,
},
],
})
export class AppModule {}
service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class Service {
getMessage(): string {
return 'Hello, Service!';
}
}
alternative.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AlternativeService {
getMessage(): string {
return 'Hello, Alternative Service!';
}
}
app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Service } from './service';
@Controller()
export class AppController {
constructor(private readonly service: Service) {}
@Get()
getMessage(): string {
return this.service.getMessage();
}
}
-
app.module.ts:
- 環境変数
USE_ALTERNATIVE_SERVICE
が設定されている場合、AlternativeService
を使用し、そうでない場合はService
を使用します。 -
Service
プロバイダーに対してuseClass
オプションを使用して、実行時に依存関係を切り替えるように設定しています。
- 環境変数
-
service.ts:
- デフォルトのサービス実装です。
getMessage
メソッドが'Hello, Service!'
を返します。
- デフォルトのサービス実装です。
-
alternative.service.ts:
- 代替サービス実装です。
getMessage
メソッドが'Hello, Alternative Service!'
を返します。
- 代替サービス実装です。
-
app.controller.ts:
-
Service
クラスのインスタンスをコンストラクタで受け取り、HTTP GETリクエストに応じてメッセージを返します。
-
まとめ
DIのメリットとデメリットをまとめます。
DIのメリット
依存関係の解決の責務を分離できる
DIを使用することで、依存関係の解決をクラス自身から分離できます。これにより、クラスは自身の主な責務に集中でき、依存関係の管理は外部に委ねられます。
環境による実装の切り替えが楽になる
DIを使用すると、異なる環境や状況に応じて簡単に実装を切り替えることができます。例えば、開発環境ではモックサービス、本番環境では実際のサービスを注入することが可能です。
複雑なテストの実装が楽になる
DIにより、依存関係を簡単にモックに置き換えることができるため、テストの実装が容易になります。これにより、ユニットテストやインテグレーションテストの効率が向上します。
DIのデメリット
テストを実装しな場合、冗長になる
DIを適切に使用しない場合、あまりうま味がありません。
インターフェイスの実装が増えるので、かえって冗長になる可能性があります。
オーバーヘッド
小規模なプロジェクトでは、DIの導入によるオーバーヘッドがパフォーマンスやコードのシンプルさに影響を与えることがあります。
学習コスト
DIパターンやフレームワークの使い方を理解するための学習コストが発生します。特に、新しいメンバーがプロジェクトに参加する際には、その学習が必要になります。
FAQ
よくありそうな質問と回答内容を記載します。
jest.mockとDIによるテストクラスのinjectionの使い分けは
依存関係の多い複雑なクラスのテストではテストクラスのinjectionのほうが適しています。
また、テストクラスは再利用できるので、同じモックを複数のテストで使いまわしたい場合に有用です。
DIを使うべきでない場合はありますか?
小規模なプロジェクトや単純なアプリケーションでは、DIのオーバーヘッドがデメリットになる場合があります。
DIを使用しなくても依存関係が簡単に管理できる場合には、DIの導入は不要かもしれません。