フレームワークを保持する–依存性注入パターンの調査

公開: 2022-03-11

制御の反転(IoC)に関する従来の見方は、サービスロケーターと依存性注入(DI)パターンという2つの異なるアプローチの間に明確な線を引くようです。

私が知っている事実上すべてのプロジェクトには、DIフレームワークが含まれています。 人々は、ボイラープレートコードを最小限に抑えるか、まったく使用せずに、クライアントとその依存関係(通常はコンストラクターインジェクションを介して)の間の緩い結合を促進するため、彼らに惹かれます。 これは迅速な開発には最適ですが、コードのトレースとデバッグが困難になる可能性があることに気付く人もいます。 「舞台裏の魔法」は通常、反射によって実現されます。これにより、一連の新しい問題が発生する可能性があります。

この記事では、Java8以降およびKotlinコードベースに適した代替パターンについて説明します。 外部ツールを必要とせずに、サービスロケーターと同じくらい簡単でありながら、DIフレームワークの利点のほとんどを保持します。

モチベーション

  • 外部依存関係を回避する
  • 反射を避ける
  • コンストラクターインジェクションを促進する
  • 実行時の動作を最小限に抑える

次の例では、さまざまなソースを使用してコンテンツを取得できるTV実装をモデル化します。 さまざまなソース(地上、ケーブル、衛星など)から信号を受信できるデバイスを構築する必要があります。 次のクラス階層を構築します。

任意の信号源を実装するTVデバイスのクラス階層

それでは、Springなどのフレームワークがすべてを配線している従来のDI実装から始めましょう。

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

私たちはいくつかのことに気づきます:

  • TVクラスは、TvSourceへの依存関係を表します。 外部フレームワークはこれを確認し、具体的な実装(地上波またはケーブル)のインスタンスを注入します。
  • コンストラクタインジェクションパターンを使用すると、代替実装を使用してTVインスタンスを簡単に構築できるため、テストが簡単になります。

順調なスタートを切っていますが、このためのDIフレームワークを導入するのは少しやり過ぎかもしれません。 一部の開発者は、構築の問題(長いスタックトレース、追跡不可能な依存関係)のデバッグに関する問題を報告しています。 クライアントはまた、製造時間が予想よりも少し長いことを表明しており、プロファイラーはリフレクティブコールの速度低下を示しています。

別の方法は、ServiceLocatorパターンを適用することです。 これは単純で、リフレクションを使用せず、小さなコードベースには十分かもしれません。 もう1つの方法は、クラスをそのままにして、それらの周りに依存関係の場所のコードを記述することです。

多くの選択肢を評価した後、プロバイダーインターフェイスの階層として実装することを選択します。 各依存関係には、クラスの依存関係を特定し、注入されたインスタンスを構築する唯一の責任を持つプロバイダーが関連付けられます。 また、使いやすさのためにプロバイダーを内部インターフェースにします。 各プロバイダーが他のプロバイダーと混合されて依存関係を特定するため、これをMixinインジェクションと呼びます。

私がこの構造に落ち着いた理由の詳細は、詳細と理論的根拠で詳しく説明されていますが、ここに短いバージョンがあります。

  • 依存関係の場所の動作を分離します。
  • インターフェイスの拡張は、菱形継承問題には該当しません。
  • インターフェイスにはデフォルトの実装があります。
  • 依存関係が欠落していると、コンパイルが妨げられます(ボーナスポイント!)。

次の図は、依存関係とプロバイダーがどのように相互作用するかを示しており、実装を以下に示します。 また、依存関係を作成してTVオブジェクトを作成する方法を示すためのmainメソッドを追加します。 この例のより長いバージョンも、このGitHubにあります。

プロバイダーと依存関係の間の相互作用

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

この例に関するいくつかの注意事項:

  • TVクラスはTvSourceに依存していますが、実装を認識していません。
  • TV.ProviderはTvSource.Providerを拡張します。これは、TvSourceを構築するためにtvSource()メソッドが必要であり、そこで実装されていない場合でも使用できるためです。
  • 地上波とケーブルのソースは、テレビで交換可能に使用できます。
  • Terrestrial.ProviderおよびCable.Providerインターフェイスは、具体的なTvSource実装を提供します。
  • mainメソッドには、TVインスタンスを取得するために使用されるTV.Providerの具体的な実装MainContextがあります。
  • このプログラムでは、TVをインスタンス化するために、コンパイル時にTvSource.Providerの実装が必要になるため、例としてCable.Providerを含めます。

詳細と理論的根拠

動作中のパターンとその背後にあるいくつかの理由を見てきました。 あなたは今までにそれを使うべきだと確信していないかもしれません、そしてあなたは正しいでしょう。 それは正確には銀の弾丸ではありません。 個人的には、ほとんどの面でサービスロケーターパターンよりも優れていると思います。 ただし、DIフレームワークと比較した場合、ボイラープレートコードを追加するオーバーヘッドを利点が上回るかどうかを評価する必要があります。

プロバイダーは、他のプロバイダーを拡張して依存関係を特定します

プロバイダーが別のプロバイダーを拡張する場合、依存関係は一緒にバインドされます。 これは、無効なコンテキストの作成を防ぐ静的検証の基本的な基盤を提供します。

サービスロケーターパターンの主な問題点の1つは、依存関係を何らかの方法で解決する汎用のGetService<T>()メソッドを呼び出す必要があることです。 コンパイル時に、依存関係がロケーターに登録される保証はなく、プログラムは実行時に失敗する可能性があります。

DIパターンもこれに対応していません。 依存関係の解決は通常、ユーザーからほとんど隠されている外部ツールによるリフレクションによって行われます。依存関係が満たされない場合、実行時にも失敗します。 IntelliJのCDI(有料版でのみ利用可能)などのツールは、ある程度の静的検証を提供しますが、注釈プリプロセッサを備えたDaggerのみが、設計上この問題に取り組んでいるようです。

クラスは、DIパターンの典型的なコンストラクター注入を維持します

これは必須ではありませんが、開発者コミュニティによって確実に望まれています。 一方では、コンストラクターを見るだけで、クラスの依存関係をすぐに確認できます。 一方、それは、多くの人々が従うようなユニットテストを可能にします。それは、その依存関係のモックを使用してテスト対象を構築することです。

これは、他のパターンがサポートされていないということではありません。 実際、Mixinインジェクションは、サブジェクトのプロバイダーを拡張するコンテキストクラスを実装するだけでよいため、テスト用の複雑な依存関係グラフの作成を簡素化することに気付くかもしれません。 上記のMainContextは、すべてのインターフェースにデフォルトの実装があるため、空の実装を持つことができる完璧な例です。 依存関係を置き換えるには、プロバイダーメソッドをオーバーライドするだけで済みます。

TVクラスの次のテストを見てみましょう。 TVをインスタンス化する必要がありますが、クラスコンストラクターを呼び出す代わりに、TV.Providerインターフェイスを使用しています。 TvSource.Providerにはデフォルトの実装がないため、自分で作成する必要があります。

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

次に、TVクラスに別の依存関係を追加しましょう。 CathodeRayTube依存関係は、画像をTV画面に表示する魔法の働きをします。 将来的にLCDまたはLEDに切り替えたい可能性があるため、TVの実装から切り離されています。

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

これを行うと、先ほど作成したテストが引き続きコンパイルされ、期待どおりに合格することがわかります。 TVに新しい依存関係を追加しましたが、デフォルトの実装も提供しました。 つまり、実際の実装を使用するだけの場合は、モックを作成する必要はありません。テストでは、必要なレベルのモック粒度で複雑なオブジェクトを作成できます。

これは、複雑なクラス階層(たとえば、データベースアクセス層のみ)で特定の何かをモックしたい場合に便利です。 このパターンにより、孤独なテストよりも好まれることがあるような社交的なテストを簡単に設定できます。

好みに関係なく、それぞれの状況でのニーズにより適した任意の形式のテストに頼ることができると確信できます。

外部依存を回避する

ご覧のとおり、外部コンポーネントへの参照や言及はありません。 これは、サイズやセキュリティ上の制約がある多くのプロジェクトにとって重要です。 フレームワークは特定のDIフレームワークにコミットする必要がないため、相互運用性にも役立ちます。 Javaでは、互換性の問題を軽減するJSR-330 Dependency Injection forJavaStandardなどの取り組みが行われています。

反射を避ける

サービスロケーターの実装は通常リフレクションに依存しませんが、DIの実装はリフレクションに依存します(Dagger 2の注目すべき例外を除く)。 これには、フレームワークがモジュールをスキャンし、依存関係グラフを解決し、オブジェクトを反射的に構築する必要があるため、アプリケーションの起動が遅くなるという主な欠点があります。

Mixinインジェクションでは、サービスロケーターパターンの登録手順と同様に、サービスをインスタンス化するためのコードを記述する必要があります。 この少し余分な作業により、リフレクティブコールが完全に削除され、コードがより高速で簡単になります。

最近私の注意を引き、リフレクションを回避することで恩恵を受ける2つのプロジェクトは、GraalのSubstrateVMとKotlin/Nativeです。 どちらもネイティブバイトコードにコンパイルされます。これには、コンパイラが、実行するリフレクティブ呼び出しを事前に認識している必要があります。 Graalの場合、書き込みが難しく、静的にチェックできず、お気に入りのツールを使用して簡単にリファクタリングできないJSONファイルで指定されます。 そもそもリフレクションを回避するためにMixinインジェクションを使用することは、ネイティブコンパイルのメリットを享受するための優れた方法です。

実行時の動作を最小限に抑える

必要なインターフェースを実装および拡張することにより、依存関係グラフを一度に1つずつ作成します。 各プロバイダーは、プログラムに順序とロジックをもたらす具体的な実装の隣にあります。 この種のレイヤリングは、以前にMixinパターンまたはCakeパターンを使用したことがある場合はおなじみです。

この時点で、MainContextクラスについて説明する価値があるかもしれません。 これは依存関係グラフのルートであり、全体像を把握しています。 このクラスにはすべてのプロバイダーインターフェイスが含まれ、静的チェックを有効にするための鍵となります。 例に戻ってCable.Providerを実装リストから削除すると、次のことがはっきりとわかります。

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

ここで起こったことは、アプリが使用する具体的なTvSourceを指定しておらず、コンパイラーがエラーをキャッチしたことです。 サービスロケーターとリフレクションベースのDIを使用すると、すべての単体テストに合格した場合でも、実行時にプログラムがクラッシュするまで、このエラーに気付かなかった可能性があります。 私たちが示したこれらの利点やその他の利点は、パターンを機能させるために必要な定型文を書くことの欠点を上回っていると思います。

循環依存をキャッチする

CathodeRayTubeの例に戻り、循環依存関係を追加しましょう。 TVインスタンスを注入したいので、TV.Providerを拡張するとします。

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

コンパイラは循環継承を許可しておらず、この種の関係を定義することはできません。 これが発生すると、ほとんどのフレームワークは実行時に失敗し、開発者はプログラムを実行するためだけにフレームワークを回避する傾向があります。 このアンチパターンは現実の世界で見られますが、通常は悪いデザインの兆候です。 コードのコンパイルに失敗した場合は、変更するのに手遅れになる前に、より良い解決策を探すことをお勧めします。

オブジェクト構築のシンプルさを維持する

DIよりもSLを支持する議論の1つは、デバッグが簡単で簡単であるということです。 例から、依存関係のインスタンス化はプロバイダーメソッド呼び出しのチェーンにすぎないことは明らかです。 依存関係のソースをさかのぼるのは、メソッド呼び出しにステップインして、最終的にどこに到達するかを確認するのと同じくらい簡単です。 プロバイダーから直接、依存関係がインスタンス化される場所を正確にナビゲートできるため、デバッグは両方の方法よりも簡単です。

サービスの存続期間

注意深い読者は、この実装がサービスの存続期間の問題に対処していないことに気付いたかもしれません。 プロバイダーメソッドへのすべての呼び出しは、新しいオブジェクトをインスタンス化し、これをSpringのPrototypeスコープに似たものにします。

詳細を気にせずにパターンの本質を提示したかっただけなので、これやその他の考慮事項はこの記事の範囲から少し外れています。 ただし、製品の完全な使用と実装では、生涯サポートを備えた完全なソリューションを考慮する必要があります。

結論

依存性注入フレームワークに慣れている場合でも、独自のサービスロケーターを作成している場合でも、この代替手段を検討することをお勧めします。 今見たミックスインパターンの使用を検討し、コードをより安全で推論しやすくすることができるかどうかを確認してください。

関連: JSのベストプラクティス:TypeScriptと依存性注入を使用してDiscordボットを構築する