2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なんとなく使っているDIのありがたさを理解する

Last updated at Posted at 2024-11-29

はじめに

筆者は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の概念について、少しでも理解が進めば嬉しいです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?