依存性注入(Dependency Injection)を理解するファーストステップ
はじめに
依存性注入(Dependency Injection、以下DI)は、ソフトフェア開発における設計パターンの1つであり、コードの保守性やテストの容易さを向上させる手法です。
と、ソフトウェアエンジニアであればここまではなんとなく見聞きしたことはあるのではないかと思いますが、その具体的な手法や活用方法などにあまりピンときていない方もいるのではないでしょうか。
本稿ではDIの活用方法をなるべくプラットフォームに依存しない形で具体的に説明し、ぼんやりとしたDIのイメージから一歩踏み出すお手伝いをしたいと思います。
ざっくりいうと
DIとは「疎結合な設計を実現するためのテクニック」です。ソフトウェア開発において、疎結合な設計には以下のようなメリットがあります。(疎結合 = コンポーネント同士の依存関係を最小限に抑えること)
- 拡張容易性:アプリケーションを長く運用する中で、変更や新しい機能の追加に柔軟になる。
- 保守容易性:各クラスが単一の責任を負い、責務を分担することで原因になる箇所の特定がしやすい。また、新たな機能が必要な際にも、どこにその変更を適用するべきかがわかりやすい。
- 並列開発:関心事が分割され、他の開発者とともに並列での開発がしやすくなる。各開発者はアプリケーションの一部の領域だけに集中できる。
- テスト容易性:アプリケーション内のコードが単一の責任を負い、単体テストを行いやすいものになる。そのアプリケーションはテスト容易性が高いものとみることができる。
具体的にはどのように書くか
たとえば以下のようなコードがあったとします。
class Service {
execute():void {
print("Service executed")
}
}
class Client {
private service: Service
coustructor() {
self.service = new Service()
}
run():void {
service.execute()
}
}
let client = new Client()
client.run()
このコードはClientクラスがServiceクラスと密結合(疎結合ではない)しています。
たとえば、Serviceクラスが現在開発中のもので、executeメソッドがまだ存在しない場合、Clientクラスは正常に動作しません。一方の実装に強く依存している状態です。
Clientクラスを疎結合にするために、以下のように書くことができます。
interface IService {
execute():void;
}
class Service impliments IService {
execute():void {
print("Service executed")
}
}
class Client {
private service: IService
coustructor(service: IService) {
this.service = service
}
run():void {
this.service.execute()
}
}
const client = new Client(
new Service()
)
client.run()
この場合、ClientクラスはインターフェースであるIServiceに依存しており、具体的なServiceクラスには依存していません。そのためServiceクラスが開発中のものでもClientクラスは正常に動作することができます。なぜなら、IServiceの実体であるモックサービスを簡単に作成しテストすることができるからです。そしてまた、Serviceクラスの開発者もインターフェースのみに集中することができます。
またClientクラスからは依存クラスの生成という責務が取り除かれ、より関心事がシンプルになっています。
簡単なコードでの説明ですが、このようにDIの肝は「具象クラスではなく、インターフェースに対して実装を行う」ことにあります。
これは現実世界においても非常に有用な設計で、いたるところで目にします。
例えば、部屋に備え付けのダウンライトは天井と密結合しています。
照明が故障した場合の交換にはひと手間かかりますし、慣れていない人は専門業者を呼ぶ必要があるかもしれません。
しかし、昔ながらの引掛シーリングを介すと、照明と天井は疎結合になります。
照明器具はこの引掛シーリングのインターフェースを守る限りどのようなものにも変更可能ですし、
最近で言えばスピーカーやプロジェクターなどの機能を部屋に追加することもできます。
では、具象クラスを使わずにひたすらインターフェースを介した実装をすればよいかというと、そうでもありません。
安全依存と揮発性依存
インターフェースを介した実装は、具象クラスを利用する場合と比べて手間がかかるものです。
そのため、インターフェースの実装は必要に迫られた場合のみにすべきです。
インターフェースを介した実装が好ましい依存を揮発性依存と呼び、頻繁に変更される可能性が高い依存のことを指します。例としては以下のようなものが挙げられます。
- 外部サービスに強く依存しているコード(APIやデータベースなど)
- 開発中のコード
- 非決定的な振る舞い(例えばランダムな値を返すこと)をするコード
安全依存は上記の逆で
- 破壊的な変更がないことが期待できる
- 予測可能なアルゴリズムである
ようなことが挙げられます。組み込みのクラスや、出力が信頼できる古典的なライブラリなどは安全依存とみなし、インターフェースを設けずに依存しても問題ありません。
外部からの依存注入と合成基点
2つのクラス同士の実装に関しては前述した非常に単純なルールで実装をすることができますが、実際のアプリケーションとなるとどうでしょうか。Clientクラスはインターフェースに対して実装を行い、依存クラスの生成を破棄することで単純な責務を全うすることができました。大きなアプリケーションで疎結合なクラスが大量に書かれ、その責務が譲渡され続けた場合、最終的にオブジェクト生成の責務は誰が負うことになるでしょうか?
サンプルコードの解説から漏れていましたが、Clientクラスが依存するServiceクラスの生成はClientクラスの外部が担っていました。Clientクラスは自らのコンストラクタにて依存するクラスを宣言するのみです。
const client = new Client(
new Service()
)
coustructor(service: IService) {
このようにコンストラクタを介して外部から依存を注入する手法をコンストラクタインジェクションと呼び、守るべき基本的な依存の注入方法です。コンストラクタインジェクションを行うと、上記コードのように、クラスの関係性が明確化されます。
上記のアプリケーションが大きくなると、Serviceクラスが別の依存クラスを使い初期化され、その別クラスがさらに依存クラスを使い、という構造ができあがります。
その構造はそのままアプリケーション自体の依存関係を表すものとなり、それはオブジェクトグラフと呼ばれます。
アプリケーションが大きくなるに従ってオブジェクトグラフも巨大になります。小さなサブシステムに分割して様々な場所でオブジェクトを組み合わせたくなりますが、オブジェクトグラフが小さなサブシステムに分割され配置された場合、それらのサブシステム同士の把握が難しくなり、振る舞いを変えることが簡単にできなくなってしまいます。
そのためオブジェクトグラフはアプリケーションのなるべく上流、ルートとなる場所に配置されるべきで、その場所を合成基点と呼びます。
実際のアプリケーションにDIを導入する場合、上記のようになるべくアプリケーションの上流に合成基点を置くことになりますが、その場所は利用しているフレームワークなどにもよって様々です。
また、アーキテクチャを的確にオブジェクトグラフで設計することも簡単では無いでしょう。
それぞれのフレームワークに応じたDIの実践方法、本稿では触れずに過去に書かれた優秀な記事に譲るとして、今回は疎結合で安全なコードを書くためのファーストステップとしてDIの基本的な実装方法を紹介しました。
他にも、データオブジェクトへの依存はどう書くべきか(メソッドインジェクションを使った方法)?インターフェースを通して機能を拡張するにはどうするべきか(デコレータパターンを用いた拡張)?などのTipsは書けばキリがありませんが、今回はここまでとします。
さらに実践的なDIの利用法については以下の書籍をおすすめいたします。
まとめ
DIは、ソフトウェアの設計において疎結合を実現し、拡張性、保守性、テスト容易性を向上させるための重要なテクニックで、基本的な方針は以下の通りです。
インターフェースに対して実装を行う:具象クラスに依存しないことで、柔軟性と拡張性が向上する。
コンストラクタインジェクションを利用する:依存関係を明示的に管理し、コードの可読性と保守性を高める。
依存の種類を見極める:頻繁に変更される揮発性依存はインターフェースを介して実装し、安全依存は直接利用する。
実際のアプリケーションに応用する際にはオブジェクトグラフと合成基点を考慮し、オブジェクト生成の責務を適切に管理し、依存関係を整理する必要があります。
Discussion