DIパターンの有用性がよくわからない人のために解説記事を書きます
DIを解説する前に出てくる用語の解説
- インターフェース: 変数、クラス・オブジェクト、関数の実装に対して型の制限を課すルール。インターフェースを定義することで、インターフェースの制約を満たしている場合にみ実装・実行の受け入れを許可し、互換性を破壊する実装が入り込むリスクを低下できる。
- 宣言的: 処理の内容を確定させずにコードを書く書き方。引数などで初期化時に使用したいロジックを関数やクラスインスタンスなどを差し込む事で呼び出される処理の内容を変更できる。
- DI(Dependency Injection): 宣言的なコードに対して初期化された特定のインターフェースを持つクラスのインスタンスを差し込む事でロジックを入れ替えるデザインパターン。このデザインパターンではインスタンスの差し込みはDIを行いたいクラスの初期化時に行う。型付き言語ではインターフェースを利用して制約を満たしていないインスタンスの注入を防ぐ事ができる。
- スタブ: テストしたい項目に関わらないテストを通すためのダミーオブジェクト。テスト対象のロジックを実行するために用意する(具体例: SentryをDummySentryに置き換えてテストが実行できるようにする、isDeleted=trueの時しか発動しないロジックを検証するために、isDeleteをtrueに強制するためのダミーオブジェクトなど)
- モック: テストしたい項目にかかわるダミーオブジェクト。テストしたい項目とは、入力に対する返り値は何かなどの他に、引数に何を受け取ったか、何回呼ばれたかなど内部の処理内容に関わらない内容も含まれるため、実際の関数の処理をダミーに差し替えている場合でもモックと呼ぶこともある。モックは基本的にこれらの事実を検証する特別なメソッドを呼び出せるようにAPIが定義されているが、これらをテストの中で呼び出さない場合はスタブと見做せるため、最近のテストフレームワークではスタブを作る時もモックオブジェクトを使いまわしているため曖昧に理解している人も多そうですが、区別しておくと良いと思います。
おまけ
- スパイ: 実際に呼び出されるロジックやクラスを用いて呼び出されたものに関しての情報を検証するために検証対象にするクラスなどを入力して拡張したオブジェクト、実際のロジックによって、途中経過でインスタンスやクラスの内部の状態どんな変化が起きているかや、モックのように実行処理に関するテスト項目を検証することができます。最近のフレームワークだと、publicなアクセサのテストだけであればクラスのインスタンスを生成してそのまま実行してテストするだけでも十分なので使われる事が少ないです。
解説
DIの理解のためにDBのクライアントを複数実装することになったと仮定します。具体的にはORMなどではMySQL、PostgreSQLなど部分的に処理の記述が異なるロジックに対してfind,where,and,orなどの関数と、その関数が受け付けられる引数と戻り値の型をインターフェースで制限します。
その型に従って具体的な処理内容をMySQL・PostgreSQLを接続するclientをそれぞれ別々に実装して、利用時にインスタンスを差し込むことでユーザーが求めるclientに処理を差し替える事ができるように実装してみます。今回はwhereの実装にだけフォーカスして説明してみます。
typescriptっぽい疑似コードで書くと以下のようになります。
// Interface
type QueryResult = {
all(): Array<any> // 便宜上anyにします
}
interface DBClientAdaptor {
where(query: Query): QueryResult
}
class DBClient {
constructor(private dbClient: DBClientAdaptor) {
}
findAll(query: Query) {
return this.dbClient.where(query).all()
}
}
class PostgresClientAdaptor implements DBClientAdaptor {
where(query): QueryResult {
return PostgresDBConnection.query(query,'queryをPostgres用のクエリに変換')
}
}
class MySQLClientAdaptor implements DBClientAdaptor {
where(query): QueryResult {
return MySQLDBConnection.query(query,'queryをMySQL用のクエリに変換')
}
}
// 利用時
const pgDbClient = new DBClient(new PostgresClientAdaptor())
const msqlDbClient = new DBClient(new MySQLClientAdaptor())
こうする事でdbClientのフォーマットを守っていればwhereの中身を実装したDBClientAdaptorの派生クラスのインスタンスをDBClientの初期化時に渡すことでfindAllを実行したときの挙動を好きに変える事ができます。
この宣言的なコードにロジックを注入するためにクラスインスタンスを差し込むパターンのことをDIと言います。
以上の例ではDB独自のwhereの実装がそれぞれのDBClientAdaptorクラスの中に隠蔽されていてDBClientよりも上位のコードでは、どんなDBを使っているかということはロジックに一切関わってこない事が分かるかと思います。
つまり、上記の例ではDBの挙動に関するテストを行うコードはそれぞれのクライアントクラスの中だけで記述すればよくそれより上位のコードに関しては、どんなクライアントが注入されたとしても入力するデータと返ってくるデータの型は決まっていることを前提でテストを書く事ができます。
寧ろDBの種類によって挙動が変わるようなロジックを書いてしまうと、以上のコードはうまくDIを活用する事ができておらず破綻していると判断することもできます。
インターフェースに適ってさえいればDIのインスタンスは全てダミーの実装をしたインスタンスに差し替えてもよいため、注入するインスタンスのインターフェースの実装を全てmockに置き換えたりスタブにしたりしてテストする事も容易になります。
上記の具体例ではfindAllでなんらかの値を返して、allメソッドが呼ばれたときに配列が受け取れればよいと推測できるのでDBClientを使ったテストにおいて、DBに接続できない環境でテストを行いたい場合は以下のようなモックを定義します。
class DBClientAdaptorMock {
where(query: Query) {
return {
all: [ createDummyData(query.model) ]
}
}
private createDummyData() {
return 'fakerなどを使ったデータ生成などでwhereメソッドを模倣'
}
}
実際は以上のようなスタブやモッククラスはライブラリで簡単に生成できるのでテスト方法に合わせてこのようなスタブを自前で実装して用意する事は稀ですがこれを使ってテストを書いてみます。
以下のように差し替えてあげるだけで上位のテストはDBを用意せずともロジックを検証する事ができます。以下はServiceクラスで使われているclientをダミーに差し替えてfindAllが呼び出された回数をテストする疑似コードです。
const dummy_client = new Client(new DBClientAdaptorMock())
mock(Service.client, dummy_client)
Service.find_all()
expect(dummy_client.findAll).callCount(1)
ちなみにDIにおいて引数はシングルトンクラスやメソッドなどではなく新規に生成したインスタンスを渡す理由としては
- シングルトンなどの内部変数がメモリ内で予期せずシェアされるとWebアプリケーションの作りとして他のユーザーデータを共有する可能性があり危ない
- シングルトンクラスのインスタンスを利用する場合でも、引数に取れるので意図的に他のユーザーデータを共有するような場合でも問題がでない。
- classクラスのinterface定義(クラスメソッドなどに対して)は出来ることは出来るが、インスタンスのInterfaceを定義するのに比べて煩雑になる
- インスタンスにすることでコンストラクタで初期設定のパラメータを変更することができる余地を残している
- 関数を受け付けるとService内のRepositoryのような宣言的なロジックに対して複数の関数を指定する必要があり非効率
と言う理由でインスタンスを注入することになっているのだと思いますが、classのインスタンスを差し込むことで変更するというのは決まり事だと理解して大丈夫です。よくある間違いでconstuctorの外部から注入されているこのインスタンスをconstructor内でインスタンスを生成するように書き直してしまうという事例が見られたりしますが、これはクラスが差し替えできなくなる間違いであり、かつ、基底クラスで差し替えれるようにしていたとしてもコンストラクタの引数を変える事ができるという利点を一つ潰している事になるので気を付けてください。
以下のExampleDIのような正しい書き方を誤ってると思って書き変えられてしまう事が実際の現場で何回かみた事があるのですが、そのようなミスはしないように気を付けましょう。ぱっと見違和感のあるコードに見えることと知らないとやってしまいがちではあると思います。
class ExampleDI {
// これは単なるデフォルト値設定なので、外部から別のインスタンスに差し変えOK
constructor(repository: IRepository = new DefaultRepository() ) {
}
}
// コンストラクタの初期化方法が違っても受け入れOK
var i1 = new ExampleDI(new AnotherRepository({flag: true}))
var i2 = new ExampleDI(new MoreAnotherRepository(2, 'test'))
class NGExampleDI1 {
constructor() {
// DefaultRepositoryの派生クラス以外受け付けられなくなっているのでダメ。
this.repository = new DefaultRepository()
}
}
class NGExampleDI2 {
constructor(repository: BaseRepositoryClass) {
// コンストラクタの引数が必ず指定されてしまうのでダメ。
this.repository = new BaseRepositoryClass()
}
}
// NGExampleDI2の例ではクラスを指定することしかできなくなる
var i3 = new NGExampleDI2(DefaultRepository)
まとめ
- DIを利用するとインターフェースに沿って実装したクラスによってDIを利用するクラスの内部処理の一部を任意に変更可能になる。これは特にDBの差異のようなプログラム外における要因において実装を部分的に変更しなければならないプログラムを独立に開発して差し込めるようにするときに有用である。
- インターフェースに沿ったモックやスタブのインスタンスを初期化時に渡す事で容易に内部のロジックをスタブやモックにできる。
- DIクラスに関するテストは抽象化した振る舞いのテストを行うだけでよくなり、具体的なロジックのテストは差し込むクラス側に移譲できる。
- DIクラスを利用するプログラムのインテグレーションテストの際にもDIクラスにスタブクラスのインスタンスを差し込むことで、DIクラス全体をスタブするよりも状況再現度の高いテストを行う事ができる。また、完全な状況再現に切り替える際もインスタンスを差し替えるだけで行える。
Discussion