AKARI Tech Blog

燈株式会社のエンジニア・開発メンバーによる技術ブログです

依存性の注入(DI)と依存関係逆転の原則(DIP)で書く良いコード

はじめに

今回のAKARI Tech Blogは、AI SaaS事業部 生成AIサービス Dev の森田が担当します。

本記事では、保守性の高い良いコードを書くために重要な「依存性の注入(Dependency Injection)」と「依存関係逆転の原則(Dependency Inversion Principle)」について紹介します。 これらを理解し実践することで、コードのテスト容易性や拡張性を大きく高めることができます。 これらは実際の開発現場においても実践されています。

具体例:画像生成機能のリファクタリング

依存性の注入と依存関係逆転の原則がどのようなものか、具体的なコードを例に説明します。

弊社の生成AIサービスにて、プロンプトを受け取って画像を返す関数generate_imageを実装したケースを紹介します。

def generate_image(
    prompt: str,
):
    """Image1を使って画像を生成する"""
    # 環境変数を読み込み
    api_key = os.getenv("API_KEY", "") 

    # クライアントを生成
    client = AzureOpenAI(api_key)

    # 画像生成
    result = client.images.generate(
        model="gpt-image-1",
        prompt=prompt,
    )
        
    return result
        
if __name__ == "__main__":
    prompt = "a cute cat"
    result = generate_image(prompt)

※説明しやすいようにmainをつけたり色々本番のコードから変更しています。

このgenerate_image関数を実際のシステムで運用しようとすると、いくつか問題が生じます。 関数内で環境変数の読み込みやクライアントの生成を行っているため、テストを書きづらく、複数の責務が1つの関数に混在している状態です。

まずは、関数を分割して責務の分離を試みます。

def _load_env() -> str:
    """環境変数を読み込み"""
    return os.getenv("API_KEY", "") 

def _create_openai_client() -> AzureOpenAI:
    """クライアントを初期化"""
    return AzureOpenAI(_load_env())

def generate_image(
    prompt: str,
):
    """Image1を使って画像を生成する"""
    # クライアントを生成
    client = _create_openai_client()

    # 画像生成
    result = client.images.generate(
        model="gpt-image-1",
        prompt=prompt,
    )
        
    return result

環境変数の読み込みやクライアントの作成を別関数でgenerate_imageの外側に出すことができました。しかし、よく確認してみるとgenerate_image_create_openai_clientに、_create_openai_client_load_envに依存したままの状態です。これでは関数分割をした恩恵があまり得られません。

依存性の注入(DI: Dependency Injection)

ここで「依存性の注入」を使います。

オブジェクトが依存するインスタンスを関数内で生成するのではなく、外部から引数として渡してもらう(=注入してもらう)ように変更します。

def generate_image(client, prompt: str):
    """Image1を使って画像を生成する"""
    # 画像生成
    result = client.images.generate(
        model="gpt-image-1",
        prompt=prompt,
    )

    return result

if __name__ == "__main__":
    prompt = "a cute cat"

    # 呼び出す側で依存オブジェクトを生成する 
    client = _create_openai_client()

    # ここで注入!
    result = generate_image(client, prompt)

変更により、generate_image_create_openai_clientという具体的な生成処理を知る必要がなくなりました。 必要なclientを外側からもらう形に変えたため、例えばテスト時にclientをモックオブジェクトに差し替えることが容易になります。

これが「依存性の注入」の基本的な考え方です。

依存関係逆転の原則(DIP: Dependency Inversion Principle)

これでテストも書きやすくなり、だいぶ良くなりました。 もう少し先に進んでみましょう。

現在は画像生成モデルは1種類だけですが、将来的に別のモデルを使いたいという要望が出てくるかもしれません。 今のgenerate_image関数はclient.images.generateという特定のクライアントの実装詳細と強く結びついています。

結合度をさらに下げるために、「依存関係逆転の原則」の考え方である以下のルールを取り入れます。

「詳細に依存せず、抽象に依存せよ」

まずは依存先となる「抽象(インターフェース)」を作成します。 Pythonではabcモジュールを使用します。

from abc import ABC, abstractmethod

# 抽象クラス(インターフェース)を定義
class IClient(ABC):
    @abstractmethod
    def generate_image(self, prompt: str):
        pass

# 詳細を作る
class AzureOpenAIClient(IClient):
    def __init__(self, api_key):
        self.client = AzureOpenAI(api_key)

    def generate_image(self, prompt: str):
        # 具体的な処理を書く
        result = self.client.images.generate(
            model="gpt-image-1",
            prompt=prompt,
        )
        return result

そして、利用側である関数も「具体的なクラス」ではなく「抽象」に依存するように書き換えます。

def run_generate_image(iclient: IClient, prompt: str):
    """抽象化されたクライアントを使って画像を生成する"""
    result = iclient.generate_image(prompt)
    return result

if __name__ == "__main__":
    prompt = "a cute cat"

    # 具体的な実装を注入する
    client_impl = AzureOpenAIClient(os.getenv("API_KEY", ""))
    result = run_generate_image(client_impl, prompt)

こうすることで、依存関係の向きが整理されました。

  • 適用前:上位モジュール → 詳細モジュール
  • 適用後:上位モジュール → 抽象 ← 詳細モジュール

この設計にしておけば、別の画像生成AIを使いたくなった場合でも、 IClientを継承した新しいクラスを作るだけで済みます。 呼び出し元の run_image_generation 関数を1行も変更せずに機能を差し替えることが可能になるのです。

まとめ

この記事ではコードの品質を高めるための重要なテクニックを紹介しました。

  • 依存性の注入
  • 依存関係逆転の原則

これらを意識することで変更に強く、テストしやすい堅牢なアプリケーションを作ることができます。 日々の開発でも、ぜひ意識してみてください。

最後に

燈ではより良いプロダクトを作るための技術や仕組みに興味があるエンジニアを募集しています。 ぜひカジュアル面談でお話ししましょう。