pythonの依存性注入ライブラリInjectorを使ってみた

2022/12/06に公開約7,300字

概要

Python の依存性注入ライブラリである Injector趣味コーディングで使用してみた。使い方をコード例付きで紹介する。

依存性注入とは

英語 Dependency Injection (依存性注入/依存物注入) の訳語である。

書籍 Clean Architecture で紹介されている SOLID原則に絡む話である。
SOLID原則については詳細に説明しないので他の記事や書籍を参照してほしい。

以下ではC#のクラスを例に挙げて依存性注入について解説する。

MyClass は部品として MyComponent を使用しているとする。

class MyClass {
    private myComponent;

    public MyClass() {
        myComponent = new MyComponent();
    }
}

この状況では MyClass 中のメソッドが MyComponent の公開機能を自由に使えて密結合になってしまい、以下のような問題が発生する可能性がある。

  • MyComponentの仕様変更がいちいちMyClassに影響を及ぼす
  • MyComponentを違うcomponentに差し替えるときに改修コストが大きくなる
  • MyComponentをテストモックに差し替えづらくなる
    • (言語によってはmonkeypatchを当ててテストモックに差し替えてこの問題を回避する習慣があったりする (Python など))

この問題を緩和するために、 依存性注入方式が用いられる。具体的にコードに落とす方法はいろいろあると思うが、たとえば以下の要領である。

  1. まずMyClass が、部品に要求する機能は何であるかをインターフェースとして定義する。 たとえばFoo 型に対する Read Write ができるインターフェースなど。
  2. このインターフェースに合致する部品を作る。
  3. MyClass のコードはインターフェースのみを参照するようにし、具体的にどの実装を用いるかはクラスのインスタンス化時にコンストラクタの引数として渡す。

以下にコード例を示す。

MyClass とインターフェースを以下のように定義する。

class MyClass {
    private FooReadWriter fooReadWriter;

    public MyClass(FooReadWriter fooReadWriter) {
        this.fooReadWriter = fooReadWriter;
    }
}

interface FooReadWriter {
    Foo Read(string key);
    void Write(Foo value);
}

これを以下のようにインスタンス化する。

var fooReadWriter = new MyComponent();
var myClass = new MyClass(fooReadWriter);
...

これによりMyComponent 側はFooReadWriter interface に合致する部品を受け取れることを前提にしたコードを書くことができる。逆にそれ以外の機能は一切参照できない。こうすることで以下のメリットを得る。

  • MyComponent の細かい変更のせいで MyClass に変更を加えなければならない可能性がなくなる
  • MyComponentFooReadWriter interface を満たす別の部品に置き換えることが容易になる
  • テスト時に mock を差し込んでインスタンス化するのが容易になる

Injectorとは

Python ライブラリで、「引数のこの型はこの実体を与えて生成してね」という情報を管理して、最後の最後に実際にinstanceを生成する仕事を代行してくれるものである。

今回の記事でのInjectorライブラリの用い方は【Python】injectorでDIコンテナを実装するにおおむね従っている。

使ってみた

動く実装例を作成した。
お題は自分が個人で使うための AtCoder Contest 出場支援ツールである。
ベストプラクティスに沿っているかどうかはだいぶわからない。

部品を含む側のクラス作成

Controller というクラスを例にとる。

クラスが用いる部品のインターフェースを切る

一例として Controller が使用する AuthUseCase というインターフェースに注目する。
Python におけるインターフェース実装では、structural subtyping を行う Protocol というライブラリと、 nominal subtyping を行う ABC がよく用いられているようであった。今回は Protocol を用いた。

class AuthUsecase(Protocol):
    """auth を扱うサービスのプロトコル."""

    def login(self, username: str, password: str) -> None:
        """ログインする.
        Args:
            username (str): username
            password (str): password
        Raises:
            ConfigAccessError: 設定ファイルのエラー
            AtcoderAccessError: atcoderから情報を取得する際のエラー
            AlreadyLoggedIn: 既にログインしている # 今は返さないけど今後かなりの高確率で返しうるので書いておく
        """

    def logout(self) -> None:
        """logout.
        Raises:
            ConfigAccessError: 設定ファイル読み書きのエラー
        """

    def status(self) -> bool:
        """loginしているかどうかを返す.
        Returns:
            bool: loginしているか
        Raises:
            AtcoderAccessError: atcoder access error
        """

コード

部品を含む側のクラスを依存性注入スタイルで書く

以下のように、必要な部品を外から注入されるスタイルで Controller のコンストラクタを書いた。


class Controller:
    """実行時に必要な情報を持ちまわるためのクラス."""

    _auth_usecase: AuthUsecase
    _atcoder_helper_config_usecase: AtCoderHelperConfigUsecase
    _execute_test_usecase: ExecuteTestUsecase
    _fetch_task_usecase: FetchTaskUsecase
    _init_task_dir_usecase: InitTaskDirUsecase

    @inject
    def __init__(
        self,
        auth_usecase: AuthUsecase,
        atcoder_helper_config_usecase: AtCoderHelperConfigUsecase,
        execute_test_usecase: ExecuteTestUsecase,
        fetch_task_usecase: FetchTaskUsecase,
        init_task_dir_usecase: InitTaskDirUsecase,
    ) -> None:
    ...

コード

部品側の実装を書く

AuthUsecase を満たす実装として、今回はひとつ AuthInteractor というクラスを書いた。

コード

インターフェースにどの依存部品を注入するかの情報を書く

「InitTaskDirUsecase インターフェースが要求されている箇所があれば InitTaskDirInteractor を渡せ」という情報を登録する。

binder.bind(
    InitTaskDirUsecase,  # type: ignore[type-abstract]
    InitTaskDirInteractor,
)

コード

mypyに「bind の第一引数に抽象的な型が渡されている」と怒られるので、そのエラーを無視するためのコメントを書いている。ここは第一引数に抽象的な型を渡しても問題ないはずなのでmypyの偽陽性だと思われる。(そのうちmypy側が修正されるといいな...)

インスタンス化する

記事【Python】injectorでDIコンテナを実装するに従い、以下のようなメソッドを用意した。

def resolve(self, cls: Type[T]) -> T:
    """Class のインスタンスを生成する."""
    return self._injector.get(cls)

コード

これを以下のように用いてやることで、 Controller クラスのインスタンスを生成できる。

injector = Dependency()
controller = injector.resolve(Controller)

コード

分からないところ

コンストラクタの一部の引数の依存解決を Injector に任せて、残りの一部の引数を自分で決めた値に注入したいような関係を bind に登録するためにはどうしたらよいのだろうか?

たとえば以下では「ConfigRepository インターフェースが要求される個所で ConfigRepositoryImpl を渡せ」と登録している。

binder.bind(
    ConfigRepository,  # type: ignore[type-abstract]
    lambda: ConfigRepositoryImpl(get_atcoder_helper_config_filepath()),
)

そのうえで、以下のように「AtCoderHelperConfigUsecase が用いられる箇所で AtCoderHelperConfigInteractor のインスタンスを渡せ」と登録したい。
AtCoderHelperConfigInteractor は、 ConfigRepository interfaceの値を2つ要求している。

コード箇所

binder.bind(
    AtCoderHelperConfigUsecase,  # type: ignore[type-abstract]
    lambda: AtCoderHelperConfigInteractor(
        config_repo=ConfigRepositoryImpl(get_atcoder_helper_config_filepath()),
        default_config_repo=ConfigRepositoryImpl(default_config_filename),
    ),
)

AtCoderHelperInteractror のコンストラクタの2つの引数のうち、 default_config_repo の方はここだけ特別な値を注入しなきゃならないのだが、 config_repo の方は binderに登録した値をそのまま使いまわしたいのだが無理なのだろうか?無理だと、依存がさらに深く連鎖していくときに大変なことになる気がするから何か回避法があると思うのだけれど...

まとめ

  • python の 依存性注入ライブラリ Injector を用いてみて、使い方などを理解した。
  • しかし一部のよい書き方がわからない

詳しい方がいらっしゃいましたらコメントを残していただけると幸いです。

GitHubで編集を提案

Discussion

ログインするとコメントできます