Dependency InjectionをJSで理解する
Dependency Injectionとは
Dependency Injection(DI: 依存性注入)とは、オブジェクト(クラス)同士の依存を外部から設定する設計方針のことです。
この記事では、JSで書かれたFizzBuzzの例を使い、DIの基本概念を学びます。
DIがない場合
まずは、依存性注入を行わない例を見てみましょう。以下は、1から100までのFizzBuzzをコンソールに出力するクラスです:
export class FizzBuzzConverter {
convert(number) {
if (number % 15 === 0) return "FizzBuzz";
if (number % 3 === 0) return "Fizz";
if (number % 5 === 0) return "Buzz";
return number.toString();
}
}
import { FizzBuzzConverter } from "./fizzbuzz-converter.js";
export class FizzBuzzPrinter {
printRange(start, end) {
const converter = new FizzBuzzConverter(); // 依存関係を内部で生成
for (let i = start; i <= end; i++) {
console.log(`${i}: ${converter.convert(i)}`);
}
}
}
import { FizzBuzzPrinter } from "./fizzbuzz-printer.js";
// 実行
const printer = new FizzBuzzPrinter();
printer.printRange(1, 100);
このコードは確かに動きますが、FizzBuzzPrinter
はprintRange
メソッドの内部でFizzBuzzConverter
のインスタンスを自力で生成しています。
FizzBuzzPrinter
がFizzBuzzConverter
に直接依存しているため、もしFizzBuzzConverter
を変更すると、FizzBuzzPrinter
にも直接影響が及びます。もし、このFizzBuzzPrinter
の単体テストを行うとすると、 FizzBuzzConverter
の変更によってテストが失敗してしまう可能性もあります。
また、このプログラムは文字をconsole.log
で出力します。プログラム外部にデータを出力する機能は、通常の関数の戻り値や内部状態とは異なり、プログラム外部に影響を与えるため、単体テストが難しい機能だと言えます。FizzBuzzPrinter
の単体テストを行いたいのに、FizzBuzzConverter
の実装も含めてテストしなければならないのは、少し大変かもしれません。
DIを使った改善
これらの課題を解決するには、クラス内部から取り除くことが有効です。クラスには、固有のロジックとそれを実現するための設計だけを残すようにします。
以下のように設計を修正してみましょう:
-
FizzBuzzPrinter
のコンストラクタでFizzBuzzConverter
とConsoleOutput
を受け取る -
FizzBuzzPrinter
は、具体的なFizzBuzzConverter
やConsoleOutput
の詳細な実装を知らない
export class FizzBuzzConverter {
convert(number) {
if (number % 15 === 0) return "FizzBuzz";
if (number % 3 === 0) return "Fizz";
if (number % 5 === 0) return "Buzz";
return number.toString();
}
}
export class FizzBuzzPrinter {
constructor(converter, output) {
this.converter = converter;
this.output = output;
}
printRange(start, end) {
for (let i = start; i <= end; i++) {
const text = this.converter.convert(i);
this.output.write(`${i}: ${text}`);
}
}
}
export class ConsoleOutput {
write(data) {
console.log(data);
}
}
import { FizzBuzzConverter } from "./fizzbuzz-converter.js";
import { ConsoleOutput } from "./console-output.js";
import { FizzBuzzPrinter } from "./fizzbuzz-printer.js";
// 実行
const converter = new FizzBuzzConverter();
const output = new ConsoleOutput();
const printer = new FizzBuzzPrinter(converter, output);
printer.printRange(1, 100);
この設計を採用すれば、モックオブジェクトを使って、「依存を意図どおり呼び出していればよい」(=「FizzBuzzConverter
とConsoleOutput
を意図通り呼び出していればよい」)というテストシナリオを書くことができ、テストをシンプルにすることができます。
以下はJestを用いたFizzBuzzPrinter
のテストコードです:
import { FizzBuzzPrinter } from "./fizzbuzz-printer.js";
import { jest } from "@jest/globals";
describe("FizzBuzzPrinter", () => {
let mockConverter;
let mockOutput;
beforeEach(() => {
mockConverter = {
convert: jest.fn(),
};
mockOutput = {
write: jest.fn(),
};
});
test("範囲が無効なとき、呼び出さない", () => {
const printer = new FizzBuzzPrinter(mockConverter, mockOutput);
printer.printRange(0, -1);
expect(mockConverter.convert).not.toHaveBeenCalled();
expect(mockOutput.write).not.toHaveBeenCalled();
});
test("1〜3の範囲でFizzBuzz", () => {
mockConverter.convert.mockImplementation((number) => {
const map = {
1: "1",
2: "2",
3: "Fizz",
};
return map[number];
});
const printer = new FizzBuzzPrinter(mockConverter, mockOutput);
printer.printRange(1, 3);
expect(mockConverter.convert).toHaveBeenCalledTimes(3);
expect(mockOutput.write).toHaveBeenCalledTimes(3);
expect(mockOutput.write).toHaveBeenNthCalledWith(1, "1: 1");
expect(mockOutput.write).toHaveBeenNthCalledWith(2, "2: 2");
expect(mockOutput.write).toHaveBeenNthCalledWith(3, "3: Fizz");
});
});
ここでは、モックオブジェクトを使用して、実際の出力や変換の動作をシミュレートしています。これにより、FizzBuzzPrinter
が正しく動作するかを簡単に検証できます。
このように、外部からインスタンスを与えられる形にしたものは、自身の責務 (=ビジネスロジック) にのみ集中できる閉じた形になります。つまり、「オブジェクトのインスタンスを生成して構築すること」が別の責務に分離されたということです。
では、「オブジェクトのインスタンスを生成する部分」を考えていきましょう。
上の例ではapp.js
でオブジェクトを生成していましたが、これをファクトリクラスを用いて以下のように分離してみます:
import { FizzBuzzConverter } from "./fizzbuzz-converter.js";
import { FizzBuzzPrinter } from "./fizzbuzz-printer.js";
import { ConsoleOutput } from "./console-output.js";
export class FizzBuzzFactory {
createFizzBuzzPrinter() {
return new FizzBuzzPrinter(this.createFizzBuzz(), this.createOutput());
}
createFizzBuzz() {
return new FizzBuzzConverter();
}
createOutput() {
return new ConsoleOutput();
}
}
import { FizzBuzzFactory } from "./fizzbuzz-factory.js";
// 実行
const factory = new FizzBuzzFactory();
const printer = factory.createFizzBuzzPrinter();
printer.printRange(1, 100);
「オブジェクトのインスタンスを生成する部分」をすべてファクトリクラスのメソッドに置き換えて考えることで、ほとんどのコードが、生成されたオブジェクトを使うことしか考えなくて済むようになります。これにより、各クラスは自分の依存オブジェクトを外部から受け取り、他のクラスの依存としても簡単に組み込むことができます。
この考え方を繰り返し適用することで、全体の構造が単純化されます。アプリケーション全体がこの方法で構築されると、依存関係が連鎖的に整理され、最終的には一つのルートオブジェクトに辿り着きます。
このように、依存オブジェクトは外部から与えるという形を徹底する考え方が、Dependency Injectionです。
依存オブジェクトの中身の詳細がどうなっているかを意識せず、最小限のインターフェースで端的に「使うだけ」を意図するコードを書くことで間違いが減り、単体テストも簡単になるということです。
リポジトリ
この記事内で用いたコードは、以下のリポジトリに格納しています。
Discussion
理解しやすく、説明がとても良かったので、投稿を楽しく読ませていただきました。DI以外のデザインパターンについても、継続的に連載していただけますか?
ありがとうございます!頻繁に更新はできませんが、これからもこういった記事を上げたいと思います。
返信ありがとうございます!日本からではなく、韓国から応援しています!デザインパターンの記事、とても参考になりますので、これからの投稿も楽しみにしています!無理のない範囲で、引き続き素晴らしい記事をお待ちしております!