リクルート メンバーズブログ  Go言語とDependency Injection

Go言語とDependency Injection

はじめに

この記事はリクルートエンジニアアドベントカレンダー11日目の記事です。

こんにちは、APソリューショングループの伊藤です。このブログに記事を投稿するのは2年ぶりとなります。今回はGoに関する記事です。
この記事では下期(10月~)に私達のチームで行っている取り組みについて紹介させて頂きます。

私達リクルートテクノロジーズでは、全社で共通の開発標準として現在JavaベースのWebフレームワーク(WAF)を定めています。これらのJavaベースの技術に加えて、現在Go言語を全社的に展開しようと考え、プロトタイプ作成や実際のプロダクト開発を行ってきました。

この記事では、その中でも中核をなす技術である、Dependency InjectionのGoにおける実装について紹介をさせて頂きます。

Dependency Injectionの設計と実装

最初に、Dependency Injection(以下, DI)の意義や背景について説明しようと思います。

Interfaceによる実装の隠蔽

以下の擬似コードのように、UserServiceUserRepositoryのモジュールがあったとして、
UserServiceUserRepositoryに依存している(つまり内部で用いている)とします。

UserRepositoryinterfaceとして定義されており、UserServiceの内部ではUserRepositoryの内部実装を気にすることなく使用できるようになっています。
この実装パターンはモジュール間の結合度合いを弱め、より変更に強い設計になっているかと思います。interfaceさえ変わらなければ、UserRepositoryの内部実装は他のモジュールへの影響を考慮することなく自由に変更することができます。
このケースで言うと、UserRepositoryがDBへの読み書きを担当しているモジュールだとして、UserServiceへの影響を気にすることなく、書き込み先のDBを異なるRDBへ変更したり、あるいはテスト用にインメモリのDBモックへ変更したりといったことが可能になります。

Dependency Injection

さて、上記はうまくいっている実装かのように見えますが、実は大きな問題がひとつあります。
それは、誰が、どうやってUserRepositoryを生成し、UserServiceに引き渡すかという問題です。

例えば、以下のようなコードでは破綻は目に見えているかと思います。

つまり、実際のUserRepositoryの生成をUserServiceの内部に埋め込んでしまうパターンです。これでは結局UserRepositoryの実装を切り替えることができず、interfaceにした意味がありませんね。
単体テストをイメージするとわかりやすいかと思うのですが、このコードのテストコードを書こうとした場合、DBなどをStub化 / Mock化することが非常に難しく、テスタビリティにも欠けたコードだと言わざるをえないかと思います。

Dependency Injectionとはこの問題を取り扱うための実装パターンを指す用語とし、Martin Fowlerらによって定義されたものです。
Dependency Injectionでは、依存の生成を行うモジュールと、実際に依存を使用するモジュールを完全に分離するパターンです。

つまり、依存モジュールを使う側は、そのモジュールがどこで作られたのか、どうやって作られたのかの一切の知識を有さずに、外部から渡されたものをただ使うだけになります。

Dependency Inejectionには依存モジュールの渡され方によって、4つのパターンがあります。1) Constructor Injection, 2) Setter Injection, 3) Interface Injection, 4) Field Injectionです。
1), 2) は最もイメージしやすいものかと思います。その名の通り、それぞれConstructor(Goの場合はNew…で構造体の生成を行っている関数)または、依存を受けられる関数(Setter)により依存を外部から受け取るパターンです。
3), 4)に関する説明は省略させて頂きますが、特に4)はJavaの世界では一般的な方式となります。

ここで、1)のパターンのコードを見てみましょう。

上記のように、Constructorで依存性を受け取れるように実装を変更しました。
Constructor Injectionを行ってみます。

Constructor Injectionという名前は仰々しいですが、実際はこれだけです。
これだけで、Dependency Injectionが目的とした 生成の知識の分離 が実現できています。
今回はmain()で呼んでいますが、初期化時に呼ぶ場所であればどこでも構いません。
単体テストもとてもシンプルなものになっています。UserServiceのテストを見てみましょう。

このテストコードに意味は無いですが、UserRepositoryの生成が埋め込まれ、DBのStub化が不可能だったコードに比べると、大幅な進歩だと思います。
interface化による実装の隠蔽および依存に関する知識の切り出しを行うことで、モジュールの独立性を高め、テスタビリティを向上させる。
これがDependency Injectionの大きなメリットの1つです。

DI Container

そうとは言っても、このままでは使い物にはなりません。
main()ですべての依存を生成し、コンストラクタに配って回る方式は、規模がごくごく小さければうまくいくのかもしれませんが、モジュールが数十、あるい数百数千の規模になると、破綻する未来しか見えません。
この依存の生成および注入をうまく解決してくれるのがDI Containerです。
上のmain()の内部で行っていた処理を肩代わりしてくれるもがDI Containerの本質的な役割です。

では、どのようにDI Containerを実装すればよいのでしょうか。
DI Containerと言うと、JavaのSpringなど、重厚で複雑な仕組みがあるのかとイメージしがちですが(私もそうでした)、main()の中身を見れば分かる通り、本質的には非常にシンプルです。

以下の機能を持ったモジュールを実装すれば良いわけです。

  1. モジュールの生成の方法を登録することが出来る。
  2. モジュールを取り出す時に、登録した方法でモジュールが生成される(遅延実行される)。

実際にコードを見たほうが早いと思うので、DI Containerのサンプルコードを記述します。

例外処理や諸々の仕組みは省いていますが、これでDI Containerの実装は終わりです。
実際に使ってみましょう。

如何でしょうか?DI Containerと聞くと身構えてしまいそうな気もしますが、本質はこれで十分です。
Builderの第一引数にContainerが渡され、そこから依存を引っ張ってこれること、生成時に初めてBuilderが実行され依存の解決がなされることの2点が重要なポイントになっています。
実際には一度生成したモジュールはキャッシュに保存し、再生成のコストを下げること、Circular Dependenciesに対する対処、型キャストに対する例外処理など、実際に適用する場合にはもう少し複雑な処理が必要ですが、DI Containerの挙動を把握するにはこれで必要十分かと思います。

main()など、初期化時にContainerに対し生成の仕方をそれぞれ登録し、実際に使う場面になって初めてモジュールを生成する、これにより、DIによるモジュールの独立性は保ちつつも、一つの場所ですべて初期化を行っていたような以前のコードに比べると大分現実的なコードになってきたかと思います。

dicon

さて、前置きは長くなってしまいましたが、私が個人のプロダクトとして開発し、現在プロジェクトに導入しているDI Containerの生成ツール、diconについて紹介させて頂きます。

上記のDI Containerでも本質的には十分なのですが、個人的に改善したいポイントが2つありました。

  1. interface{} でwrapしてしまうため、型が壊れ、コンパイラの恩恵を受けられない。
  2. Builderの実装が煩雑

この問題を解決するため、モジュールのAST(抽象構文木)を解析し、自動で依存関係を読み取り、DIコンテナは自動生成してくれるツール、diconを作成しました。(ちなみにSeasar2のdiconはDI CONfigurationでしたが、このdiconはDI CONtainerです。)

diconの使い方 (Generate)

diconでは、まずユーザは通常と同様にConstructor Injectionを行えるようにモジュールを実装します。なお、DI可能なモジュールはすべてinterfaceに限定されるので注意してください。

この際、コンストラクタの名前は必ずNew + interface名になるようにします。
例えば、UserServiceのコンストラクタは必ずNewUserServiceとなります。シグネチャは(interface名, error)のタプル固定となります。

次に、ContainerのBaseとなるinterfaceを定義します。

メソッドにはそれぞれのinterface名と同一のメソッド名を定義します。シグネチャは、引数なし、返り値は(interface名, error)固定になります。これがDI Containerに対し、Builderを登録するのと同義になります。
また、diconにコンテナのinterfaceであることを明示するため、必ず// +DICONというコメントを記述してください。

ここまでが準備になります。実際やることといえば、contaierに1行メソッドを追加するだけなので、簡単な作業かと思います。

あとは、コマンドラインから以下のように実行します。

DI対象が存在するパッケージを明示することを忘れないでください。(現在はサブパッケージを明示的に指定しないと動作しない仕様になっております)。
すると、dicon_gen.go というファイルがcontainerを定義したパッケージと同じパッケージに生成されているはずです。
このファイルが定義したContainer interfaceの実装となっており、内部でコンストラクタのシグネチャを読み取り、依存性を解決した上でインスタンスを返却してくれるような作りになっています。実際に使用する場合は以下のようになります。

上記のBuilderを登録するタイプのDI Containerに比べると、非常に簡単になったのではないでしょうか。
また、予めContainerに型を明示した上でメソッドを定義し、その実装を自動生成する形を取っているので、interface{}からキャストする必要がなく、型が壊れないため、コンパイラの恩恵を受けることができます。

diconではConstructorの名前とシグネチャを規約で縛ることにより、Builderの定義を不要にし、また、予めinterfaceに型を明記することにより、実行時の型キャストを排除した実装となります。

diconの規約をまとめますと、

  1. DI可能なモジュールはinterface限定
  2. DIするモジュールのConstructor名は固定
  3. DIするモジュールの依存性はすべてConstructorで受け取り、Constructorの返り値のシグネチャは固定

上記の規約を厳しすぎるとするかどうかはプロジェクト次第かと思いますが、私はこの規約は許容可能な範囲であると捉えています。

おわりに

少し長くなってしまいましたが、DIの意義、DI Containerの実装、 GoにおいてDI Containerの作成を支援するツール、dicon の紹介を行いました。実はdiconにはMockの自動作成などの機能もありますので、興味がある方はGithubの方を見ていただければと思います。現在diconは私の個人プロダクトという位置付けですが、全社で採用されることになりましたら、社のレポジトリに移管したいと思っております。

今回の記事ではDIのみしか触れられませんでしたが、Go言語を用いたWeb開発および全社標準化の取り組みについて、また機会があれば紹介させていただきたいと思います!

それでは失礼します。

 

参考文献