バックエンド

【DI】Dependency Injectionについて

なんとなく理解しているけど、クリアに説明するのが難しいDependency Injection(以下 DI)についてまとめてみました!

概要

あるサービス(呼び出される側)クラスのメソッドを呼び出す場合には、クライアント(呼び出す側)はサービスのインスタンスを持ち、そのサービスに対してメソッドを実行します。ここでは、SMTPのメールサービスを呼び出す処理を、DIでないような単純なコードで表現してみます。


public class SMTPEmailService {
    public void sendEmail(String message, String recipient) {
        // SMTPを使用したメール送信のロジック
        System.out.println("Email sent via SMTP to " + recipient + " with message: " + message);
    }
}

class Client {
    private SMTPEmailService smtpEmailService;

    public Client() {
        this.smtpEmailService = new SMTPEmailService(); // 直接インスタンス化
    }

    public void notifyCustomer(String message, String email) {
        smtpEmailService.sendEmail(message, email);
    }
}

public class Main {
    public static void main(String[] args) {
        Client client = new Client();
        client.notifyCustomer("Welcome to our service!", "customer@example.com");
    }
}

クライアントからはSMTPメールサービスの処理を呼び出すために、コード内でnew宣言しインスタンス化しています。

次にDIを考慮したコードを表現すると以下のようになります。


public interface EmailService {
    public void sendEmail(String message, String recipient);
}

public class SMTPEmailService implements EmailService {
    public void sendEmail(String message, String recipient) {
        System.out.println("Sending email via SMTP to " + recipient + ": " + message);
    }
}

public class Client {
    private EmailService emailService;

    public Client(EmailService emailService) {
        this.emailService = emailService; // 依存性は外部から注入される
    }

    public void notifyCustomer(String message, String email) {
        emailService.sendEmail(message, email);
    }
}

public class Main {
    public static void main(String[] args) {
        EmailService emailService = new SMTPEmailService();
        Client client = new Client(emailService);
        client.notifyCustomer("Welcome to our service!", "customer@example.com");
    }
}

DIのないコードと比較すると、DIを使用したコードではサービスにインターフェースが定義されており、SMTPメールサービスはそのインターフェースを実装しています。クライアント側では、使用するサービスがコンストラクタを通じて設定されます。この場合、事前にインスタンス化されたSMTPメールサービスがEmailService型として注入されます。クライアントの観点から見ると、サービスはEmailService型として扱われ、具体的な実装は隠蔽されます。これは、クライアントがEmailServiceインターフェースで定義されているメソッドのみを知っていれば十分ということを意味します。

クラス間が具体的な実装クラスに依存せず、インターフェース(または抽象クラス)にのみ依存する状態を疎結合と言います。DIのある実装のように、このアプローチにより、クラスは参照するインスタンスを直接newで生成するのではなく、外部から提供されたインスタンスを利用します。これにより、クラスは自身の依存関係についてより柔軟になり、変更や拡張が容易になります。

メリット

DIにより疎結合となることでいくつかのメリットがあります。

拡張性・柔軟性向上

SMTPメールサービスとは別に、EmailService型を実装したAPIメールサービスを追加するシーンを想定しましょう。


public class APIEmailService implements EmailService {
    public void sendEmail(String message, String recipient) {
        System.out.println("Sending email via external API to " + recipient + ": " + message);
    }
}

public class Main {
    public static void main(String[] args) {
        // SMTPメールサービスを使用するClientインスタンス
        EmailService smtpEmailService = new SMTPEmailService();
        Client clientUsingSmtp = new Client(smtpEmailService);
        clientUsingSmtp.sendEmail("Hello via SMTP!", "recipient@example.com");

        // APIメールサービスを使用するClientインスタンス
        EmailService apiEmailService = new APIEmailService();
        Client clientUsingApi = new Client(apiEmailService);
        clientUsingApi.sendEmail("Hello via API!", "recipient@example.com");
    }
}

SMTPメールサービスとクライアントのコードはDIの有無に関わらず変更の必要がありませんが、APIメールサービスを導入する際や、メインの処理を変更する際には、DIの利点が顕著になります。
DIがなければ、APIメールサービスの導入に伴い、クライアントのコードも修正する必要が生じます。特に商用アプリでは、多数の異なる箇所からEmailServiceが使用されることが想定されます。アプリの規模が大きくなるほど、DIを用いない場合のクライアント側の修正範囲が広がり、メンテナンスの複雑さが増します。

DIを使用することで、新しいサービスの導入や既存サービスの変更が、クライアントコードに影響を与えることなく行えます。これは、クライアントが具体的なサービス実装ではなく、抽象化されたインターフェースにのみ依存しているためです。このアプローチにより、システムの拡張性が高まり、異なる実装間での切り替えがスムーズに行えるようになります。結果として、DIは呼び出し側のコードの修正を不要にし、アプリの柔軟性と拡張性を向上させるメリットを提供します。

拡張性:新しい実装の追加

EmailServiceインターフェースの異なる実装(SMTPメールサービスとAPIメールサービス)を追加することで、新しいメール送信方法をシステムに容易に組み込むことができます。将来的に新しいメール送信サービス(例えば、クラウドベースのメールサービス)を追加する場合でも、既存のクライアントコードを変更する必要がなく、新しいサービスの実装を追加するだけで済みます。

柔軟性の表現:実行時の実装の切り替え

クライアントはどのEmailServiceの実装を使用するかを実行時に決定できます(SMTPメールサービスとAPIメールサービスか)。異なる状況や要件に基づいて、使用するメールサービスを容易に切り替えることができます。

テストがやりやすい

テストを実行する際に、クライアントクラスのテストでは本番環境で使用される実際のEmailServiceインスタンスの代わりにモックのEmailServiceインスタンスを使用します。モックインスタンスをクライアントに注入することで、実際のメール送信をシミュレートし、クライアントクラスの動作を効果的にテストすることが可能になります


class MockEmailService implements EmailService {
    String lastMessageSent = "";

    public void sendEmail(String message, String recipient) {
        lastMessageSent = message; // 実際の送信は行わず、メッセージを記録する
    }

    public String getLastMessageSent() {
        return lastMessageSent;
    }
}

public class ClientTest {
    public static void main(String[] args) {
        MockEmailService mockService = new MockEmailService();
        Client client = new Client(mockService);

        client.sendEmail("Test message", "recipient@example.com");

        if (mockService.getLastMessageSent().equals("Test message")) {
            System.out.println("Test passed: Email sent correctly.");
        } else {
            System.out.println("Test failed: Email was not sent correctly.");
        }
    }
}

依存関係の明確化

クラスが直接的に他クラスをインスタンス化することなく、依存関係が外部から注入されます。これにより、クラス間の依存関係がコンストラクタやセッターを通じて明示的になり、開発者がコードを読む際に各コンポーネントの関係を簡単に理解できます。

その他

DIコンテナ

Springのような主要なFWでは、DIコンテナを活用してインスタンスの生成と管理を行い、クラス間の依存関係を効率的に解決します。このアプローチにより、アプリの構成要素がより柔軟かつ効率的に連携し、開発者は複雑な依存性の管理から解放されます。

シングルトン

シングルトンパターンは、ソフトウェア開発において、特定のクラスのインスタンスがアプリ全体でただ一つだけ存在することを保証する設計パターンです。このアプローチは、共有リソースやサービスに対して一元的なアクセスポイントを提供し、システム全体での一貫性と効率を確保します。また、メモリの使用効率を高め、アプリのパフォーマンスを向上させることが可能になります。

例として先に挙げたEmailServiceを使用するケースを考えてみましょう。もしDIコンテナを使用せず、クライアントクラス内で直接newを使ってインスタンス化している場合、各クライアントごとに別々のインスタンスが生成されます。

しかし、EmailServiceのインスタンスが複数存在しても、それぞれが同じ動作をすることが期待されています。このため、実際には複数のインスタンスを生成する必要はなく、アプリ全体で一つのインスタンスを共有する方が効率的です。これはメモリ使用の効率化やインスタンスの状態管理の容易さといった観点からも重要です。

実際のアプリでは、クライアントは様々なクラスを指し、EmailServiceはアプリの多くの場所から呼び出されることが一般的です。各クラスで個別にEmailServiceの新しいインスタンスを生成すると、不必要にメモリを消費し、パフォーマンスに影響を及ぼす可能性があります。シングルトンパターンを使用することで、これら全てのクラスが同一のEmailServiceインスタンスを共有し、リソースの効率的な利用が可能になります。

依存関係の自動解決

クラス内で必要とされる依存オブジェクトが、設定ファイルやアノテーションを通じて自動的に識別され、適切な時点でインスタンスに注入されます。例えば、Springでは、@Autowiredアノテーションを使用して特定のフィールドやコンストラクタに依存オブジェクトを自動注入することができます。このアプローチにより、開発者が手動での依存関係の設定や管理することを大幅に減少させます。結果として、アプリの構成がよりシンプルかつ効率的になり、開発者はビジネスロジックの実装に集中できるようになります。依存関係の自動解決は、特に大規模なアプリや複数のコンポーネントが連携する複雑なシステムにおいて、コードの整合性と可読性を保つために重要です。


import org.springframework.stereotype.Service;

@Service
public class SMTPEmailService implements EmailService {
    @Override
    public void sendEmail(String message, String recipient) {
        // SMTPを使ったメール送信のロジック
        System.out.println("Sending email via SMTP to " + recipient + ": " + message);
    }
}

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class Client {
    private final EmailService emailService;

    @Autowired
    public Client(EmailService emailService) {
        this.emailService = emailService;
    }

    public void sendEmail(String message, String recipient) {
        emailService.sendEmail(message, recipient);
    }
}

SMTPメールサービスはEmailServiceインターフェースの実装であり、Springの@Serviceアノテーションが付けられています。これにより、Spring DIコンテナはこのクラスを自動的に検出し、管理下に置きます。

クライアントクラスはEmailServiceに依存しています。この依存関係は、コンストラクタを通じて注入されます。@Autowiredアノテーションは、Springに対して依存オブジェクト(この場合はEmailServiceの実装)を自動的に注入するよう指示します。

DIの種類

コンストラクタインジェクション

依存オブジェクトがクラスのコンストラクタを通じて注入されます。この方法は不変性と依存関係の明確化を促進し、テストのしやすさを向上させます。クラスがインスタンス化される際に全ての依存性が満たされるため、部分的に構築された状態になることがなく、オブジェクトの一貫性が保証されます。

セッターインジェクション

依存オブジェクトがセッターを通じて注入されます。これにより、オブジェクトの作成後に依存関係を変更する柔軟性が得られますが、オブジェクトが完全に構成される前に使用されるリスクがあります。

フィールドインジェクション

依存オブジェクトが直接フィールドに注入されます。これはコード量を減らすことができますが、不変性の欠如やテストの困難さなどの問題が生じる可能性があります。

まとめ

今まで大体は理解しているけどちゃんと説明するのが難しいなと思い言語化してみました!

-バックエンド
-