はじめに
筆者はSIerでエンジニアをしております。やりたいことよりも、案件をベースに、その案件に合わせた技術スタックを修得しています。
今回は、やっと腰を据えてSpringBootを扱いました。あらためてDIのありがたさについて学んだことを記載します。
目次
前提
以下の技術スタックを用います。
- java(たぶんバージョンに影響しない概念です。)
- SpringBoot(たぶんバージョンに影響しない概念です。)
DIとは
Dependency Injection。直訳で依存性の注入。クラスAからクラスBに直接依存するのではなく、外側から実体への依存を注入できるようにする技術。こんなこと言ってもわからないので、実例と、使わない時の問題、使ったら嬉しくなることを見ていきましょう。
DIについて理解を深める
前提
- mainからクラスAを呼び出し、クラスAの中ではクラスBを使用します。
DIを使わない場合
愚直に実装するといかのようになります。まあ、悪くはないですね。
// Main.java
public class Main {
public static void main(String[] args) {
ClassA classA = new ClassA();
classA.doSomething();
}
}
// ClassA.java
public class ClassA {
private ClassB classB;
public ClassA() {
// クラスAの中で直接ClassBのインスタンスを生成している
this.classB = new ClassB();
}
public void doSomething() {
// ClassBの処理を呼び出す
String result = classB.process();
System.out.println("ClassA: " + result);
}
}
// ClassB.java
public class ClassB {
public String process() {
return "Processing in ClassB";
}
}
一見、「いいんじゃね」と思うのですが、以下のような問題があります。
- クラスBが外部APIなどを呼び出している場合にテストが困難
- クラスBのインタフェースが変更される場合に、クラスAの変更も必要になる
実装としては間違ってはいないのですが、拡張性や保守性に問題が生じる可能性があります。
ということで、これをDIを実装してみましょう。
DIを使う場合
以下のように、クラスAを生成する際に、クラスBも生成して注入します。これが、Aからみると、上位から「注入」されていることになります。
// ProcessorB.java (インターフェース)
public interface ProcessorB {
String process();
}
// ClassB.java (実装クラス)
public class ClassB implements ProcessorB {
@Override
public String process() {
return "Processing in ClassB";
}
}
// ClassA.java
public class ClassA {
private final ProcessorB processor; // インターフェースを使用
// コンストラクタインジェクション
public ClassA(ProcessorB processor) {
this.processor = processor;
}
public void doSomething() {
String result = processor.process();
System.out.println("ClassA: " + result);
}
}
// Main.java
public class Main {
public static void main(String[] args) {
ProcessorB processor = new ClassB();
ClassA classA = new ClassA(processor); // 依存性を外部から注入
classA.doSomething();
}
}
で、、、、なにが嬉しいのか。それは以下のようなパターンで、DIの恩恵が受けられます。
- テストがしやすくなる
- 新機能追加時の変更が最小限で済む
- クラスBはインタフェースに従った実装をする
テストがしやすくなる例
テスト時には、外部APIを呼び出さないモック実装を注入できます。
// テスト用のモック実装
public class MockClassB implements ProcessorB {
@Override
public String process() {
return "テスト用の固定値";
}
}
// テストコード
public class ClassATest {
@Test
public void testDoSomething() {
// テスト用のモックを注入
ProcessorB mockProcessor = new MockClassB();
ClassA classA = new ClassA(mockProcessor);
// テストの実行
classA.doSomething(); // -> "ClassA: テスト用の固定値" と出力される
}
}
新機能追加時の変更が最小限で済む例
ログ出力機能を追加したい場合でも、既存コードを変更せずに対応できます。
// ログ出力機能を追加した実装
public class LoggingClassB implements ProcessorB {
private final ProcessorB original;
public LoggingClassB(ProcessorB original) {
this.original = original;
}
@Override
public String process() {
System.out.println("処理開始");
String result = original.process();
System.out.println("処理終了");
return result;
}
}
// 使用例
public class Main {
public static void main(String[] args) {
ProcessorB original = new ClassB();
ProcessorB withLogging = new LoggingClassB(original);
ClassA classA = new ClassA(withLogging);
classA.doSomething();
}
}
ちょっとだけでも、DIの嬉しさが伝わりましたでしょうか。
でも、、、単純な例だと嬉しいのですが、クラスの階層はもっと深いですよね。実際にはこんなことになります。
実際はそんなに甘くない例
例えば、以下のような3階層の依存関係があるとします。
- ClassA → ServiceB → RepositoryC
そうすると、以下のように全部の依存関係の構築が必要になります。
// Main.java
public class Main {
public static void main(String[] args) {
// すべての依存関係を手動で構築する必要がある
RepositoryC repositoryC = new RepositoryC();
ServiceB serviceB = new ServiceB(repositoryC);
ClassA classA = new ClassA(serviceB);
classA.doSomething("データ");
}
}
// ClassA.java
public class ClassA {
private final ServiceB serviceB;
public ClassA(ServiceB serviceB) {
this.serviceB = serviceB;
}
public void doSomething(String data) {
String result = serviceB.process(data);
System.out.println("ClassA: " + result);
}
}
// ServiceB.java
public class ServiceB {
private final RepositoryC repositoryC;
public ServiceB(RepositoryC repositoryC) {
this.repositoryC = repositoryC;
}
public String process(String data) {
// データを加工してから保存
String processed = data + "_processed";
return repositoryC.save(processed);
}
}
// RepositoryC.java
public class RepositoryC {
public String save(String data) {
// 実際にはDBに保存する処理
return "Saved: " + data;
}
}
うーん、めんどくさいですね。
ここで、DIコンテナですよ
これまでは手作業でDIを実装してきました。階層がう深くなると手作業で管理は効率悪いですね。それも仕組みに任せましょう。ここではSpringBootのDIコンテナ使用して、ありがたみを感じましょう。
同じ構成をSpringBootで実装すると、以下のようになります。DataControllerというAPIを実装することとします。
// ClassA.java
@Component // コンポーネントとして登録
public class ClassA {
private final ServiceB serviceB;
@Autowired // DIコンテナによる依存性の注入
public ClassA(ServiceB serviceB) {
this.serviceB = serviceB;
}
public void doSomething(String data) {
String result = serviceB.process(data);
System.out.println("ClassA: " + result);
}
}
// ServiceB.java
@Service // サービスとして登録
public class ServiceB {
private final RepositoryC repositoryC;
@Autowired // DIコンテナによる依存性の注入
public ServiceB(RepositoryC repositoryC) {
this.repositoryC = repositoryC;
}
public String process(String data) {
String processed = data + "_processed";
return repositoryC.save(processed);
}
}
// RepositoryC.java
@Repository // リポジトリとして登録
public class RepositoryC {
public String save(String data) {
return "Saved: " + data;
}
}
// Controller層を追加
@RestController
@RequestMapping("/api")
public class DataController {
private final ClassA classA;
@Autowired
public DataController(ClassA classA) {
this.classA = classA;
}
@PostMapping("/process")
public ResponseEntity<String> processData(@RequestBody DataRequest request) {
String result = classA.doSomething(request.getData());
return ResponseEntity.ok(result);
}
}
さて、何か簡素になりました。それぞれのクラスにアノテーションがつきましたが、コンストラクタの引数でのやり取りは減りました。
SpringBootの環境下で、ルールに従ってアノテーションを付与することによって、DIコンテナが使用できて、
- 自動で依存を解決
- 手動での注入が不要
など、手動DI以上の恩恵が受けられます。
これでクラス間の依存関係を管理をSpringBootにお任せすることができます。
まとめ
DIを使わないコードでも、動作はします。でも、テストのしづらさや、機能追加時の改修範囲の広がりなど、いくつかの問題が出てきます。
そこでDIを使うと
- テストが楽になる(モック実装を注入できる)
- 新機能追加が楽になる(既存コードの変更が最小限で済む)
ただし、手動でDIを実装すると、クラスの階層が深くなるにつれて大変になってきます。たとえば3階層の依存関係だと、すべての依存関係を手動で構築する必要があって、なかなかめんどくさい。
そこで、SpringBootなどのDIコンテナです。アノテーションを付けるだけで
- 自動で依存関係を解決してくれる
- インスタンス生成の順番とかを気にしなくて良くなる
なんとなく使っていたDIですが、ちょっとだけでもそのありがたさが伝わりましたでしょうか。
生のJavaからSpringBootにステップアップしたら避けては通れない概念です。
最初?の壁になりそうなDIの概念について、少しでも理解が進めば嬉しいです。