💉

PythonのDIフレームワーク「Injector」がいい感じ

2023/06/16に公開

GitHubのレポジトリはこちら:
https://github.com/python-injector/injector

はじめに

もう一つPythonのDIフレームワークに「Dependency Injector」というものもあるんですが、個人的にこちらは「Injector」に比べて使いにくかったです。理由はコンテナオブジェクトの定義が面倒だったことと、注入時にノーコンテキストでSegmentation Faultを起こしてしまうのでデバッグしづらかったことです。「Injector」はドキュメントこそ貧弱で(インスパイア元のGuiceの方も読み込めばわかるのかもしれませんが...)とっつきづらいですが、一度わかってしまえば(最低限使いこなすのは)かんたんです。この記事では理屈で最初の一歩を理解することを目的に細かく公式ドキュメントを補完してみようと思います。

どうやって「依存を注入」するのか

「Injector」を理解するためにはまず「どうやって依存が注入されるのか」を押さえておく必要があります。その上で重要な概念が3+1つ、InjectorBinderProvider、そして Module があります。

Injector

Injector はその名の通り依存注入を行う仕組みです。最も省略された用法であるGitHubのREADMEにあるサンプルをまず見ていきます。

from injector import Injector, inject


class Inner:
    def __init__(self):
	    pass


class Outer:
    @inject
    def __init__(self, inner: Inner):
        self.inner = inner
	
	
# どこかで注入する
def main():
    injector = Injector()
    outer = injector.get(Outer)

わけがわからないと感じたのはおそらくあなただけではありません。少なくとも私はわけがわかりませんでした。injector.get(Outer) とするだけで Outer(Inner()) が呼ばれるのですが、この挙動は全く自明ではありません。

ここでポイントなのは、概念として Injector は「型をキーとして値を提供するディクショナリのような仕組み」であると捉えることです。すると上記の例は「Outer というキーで get することを試みている」と読み解くことができます。ですがこのコードの中では明示的に何を get するのかを指定していないので、通常のディクショナリであれば値を取得することができません。しかし結果的に上記のコードでは InjectorOuterInner のインスタンスを渡してインスタンス化しています。

では Injector はどうやって Outer のインスタンスを作っているのか?ここで効いてくるのが @inject デコレータです。Injector は渡されたキーに対して何を返すべきかを指定されていない場合には get に渡された実引数を Callable とみなして呼びます。この時に渡した Callable@inject でデコレートされていた場合、その Callable の引数の型を Injector から参照可能になるように実装されています。get の中で Injector は渡された @inject された Callable が依存している引数の型を参照して、その引数の型について同様に get を呼びます。上記のケースでは get(Inner) となるわけです。

ここで更に Inner について同じことが起こりInnerCallable とみなして呼びますが、 Inner には引数がありません。なのでこの時点で Injector は依存項目を検索することをやめて単純に Inner() を呼びその返り値 = Inner のインスタンスを返します。この Inner のインスタンスが元の get コールに渡された OuterCallable と解釈した引数として与えられて、Outer(Inner()) が返されます。

厳密にはこの時に依存項目である Inner のインスタンスは Injector のインスタンス内部に保存されて Injector が破壊されるまで保持・使い回しされます。この挙動をオーバーライドしてシングルトンを実現したりスレッドローカルにインスタンスを提供する仕組みとして Scope という概念が存在しますがこの記事では解説しません。

上記の例では Inner が引数を取らない実装だったのでそのまま依存注入することができましたが、そうでないケースもあります。わかりやすいケースとしては依存として指定された抽象クラスの実装として具象クラスを注入する場合です。そのような場合には Injector の持つ Binder を用いて Inner という名前に作り方を束縛(bind)する必要があります。

Binder

一番シンプルに束縛する方法として Injectorconfigure 関数を渡す方法があります。例えば次のようなコードがあったとします。

dependencies.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class Create(Protocol):
    def __call__(self, user: types.User, password: str) -> None: ...
accessors.py
def create(user: types.User, password: str) -> None:
    with transaction.atomic():
        user_model = User.objects.create_user(
            email=user.email,
            identifier_name=user.identifier.name,
            identifier_tag=user.identifier.tag,
            password=password,
        )
        Profile.objects.create(
            user=user_model,
            name=user.profile.name,
            avatar=user.profile.avatar,
        )

これをユーザのアクティベーションを行う何かしらの実装で利用するとします。

service.py
from dataclasses import dataclass

from injector import inject


@inject
@dataclass
class Activate:
    create_user: dependencies.Create
    
    ...

このままでは dependencies.Create は抽象クラス(プロトコル)なので実体化することができません。そこで次のような configure 関数を実装します。

__init__.py
from injector import Binder, InstanceProvider

from . import accessors, dependencies


def configure(binder: Binder) -> None:
    binder.bind(dependencies.Create, to=InstanceProvider(accessors.create))

そしてこれを Injector に渡してあげます。

main.py
from injector import Injector

from . import configure, service


def main():
    injector = Injector([configure])
    activate = injector.get(service.Activate)

こうすることで、Injectorservice.Activate をインスタンス化する時に必要になる dependencies.Createget した際に、事前に渡した configure 関数内で bind した accessors.create を使うようになります。

Provider

Provider は「依存を提供する主体」です。上記の例ではインスタンスを提供する主体である InstanceProvider を利用していますが、これ以外にも CallableProviderClassProvider が存在します。CallableProvider は渡された呼び出し可能オブジェクトを呼び出した返り値を、ClassProvider は渡されたクラスを実体化して提供し、Provider を省略した場合は CallableProvider が暗黙的に適用されます。必要に応じて使い分けましょう。

Module

より複雑な束縛メカニズムを提供する場合には Module を使うことが出来ます。例えば個人のプロジェクトでE2Eテストを実行する際に特定の束縛を上書きして(乱数生成や外部API呼び出しなどを持っくに挿し替えて)実行したいという要求がありました。そこでテスト用のサーバプログラムで使用される configure に渡される束縛を ContextLib の使用感で上書きできるようにする Module を実装しました。

configurator.py
from __future__ import annotations

import contextlib
from typing import Callable, Generator, Type

from injector import Binder, Module

__all__ = ['ModuleSet', 'configurator']


InstallableModuleType = Callable[[Binder], None] | Module | Type[Module]


class ModuleSet(Module):
    modules: list[InstallableModuleType]

    def __init__(self, *modules: InstallableModuleType) -> None:
        self.modules = list(modules)

    def add(self, *modules: InstallableModuleType) -> ModuleSet:
        self.modules.append(ModuleSet(*modules))
        return self

    @contextlib.contextmanager
    def use(self, *modules: InstallableModuleType) -> Generator[ModuleSet, None, None]:
        try:
            self.add(*modules)
            yield self
        finally:
            self.modules.pop()

    def configure(self, binder: Binder) -> None:
        for module in self.modules:
            binder.install(module)


configurator = ModuleSet()

このプロジェクトはDjangoを使用しているので apps.py に次のように記述しました。

apps.py
from django.apps import AppConfig


class APIControllerConfig(AppConfig):
    name = 'app.modules.api_ctrl'
    verbose_name = 'APIController'
    label = 'api_ctrl'

    def ready(self) -> None:
        from app import configurator
        from app.modules import (auth_cmpt, core_cmpt, file_repo, image_repo,
                                 mail_repo, sheet_repo, user_repo)

        configurator.add(
            auth_cmpt.accessors.configure,
            core_cmpt.accessors.configure,
            image_repo.accessors.configure,
            file_repo.accessors.configure,
            mail_repo.accessors.configure,
            user_repo.accessors.configure,
            sheet_repo.accessors.configure,
        )

これによってimportしたモジュールごとに指定の configure 関数に従って初期の束縛が設定されます。実際に View の中で使われるインスタンスは View の中で Injector.get しているので後から束縛を上書きしても想定通りの動作をします。ただし Injector がある名前を初めて get する時にはインスタンス作成が走るのでパフォーマンスの観点からは最適ではありません。個人プロジェクトなのでインスタンス生成に使われるマイクロ秒からミリ秒単位のレスポンス遅延は気にならないという判断でこのような実装になっています。

E2Eテストは以下のように書くことで作った mock.Mock で置き換えることができます。

user_tests.py
import json
import logging
from unittest import mock

import pytest
from django.test import Client
from faker import Faker
from injector import Injector, InstanceProvider

from app import configurator
from lib import secret_token
from src import Config, core_pkg, factories, mail_pkg

logging.getLogger('injector').setLevel(logging.DEBUG)


@pytest.mark.django_db
def test_user_actions() -> None:
    fake = Faker()
    user = factories.fake_user(fake)
    password = fake.unique.password()

    registration_token = secret_token.generate()
    password_reset_token = secret_token.generate()

    manager = mock.Mock()
    manager.generate_secret = mock.Mock(spec=core_pkg.services.token.GenerateSecret, side_effect=[
        registration_token,
        password_reset_token,
    ])
    manager.generate_id = mock.Mock(spec=core_pkg.services.token.GenerateID, side_effect=[user.id])
    manager.send_mail = mock.Mock(spec=mail_pkg.services.SendMail)

    with configurator.use(
        lambda binder: binder.bind(core_pkg.services.token.GenerateSecret, to=InstanceProvider(manager.generate_secret)),
        lambda binder: binder.bind(core_pkg.services.token.GenerateID, to=InstanceProvider(manager.generate_id)),
        lambda binder: binder.bind(mail_pkg.services.SendMail, to=InstanceProvider(manager.send_mail)),
    ):
        config = Injector(configurator).get(Config)
        client = Client()

        response = client.get(f'/api/user/name_taken?key={user.name.key}&tag={user.name.tag}')
        assert response.status_code == 200
        assert not json.loads(response.getvalue().decode())
        assert manager.mock_calls == []

冗長にも思えるかもしれませんが、テスト実行時の状態を詳細に制御できるためモックしているオブジェクトの振る舞いがきちんと単体テストされている前提においてはかなりテストが書きやすくなります。

まとめ

InjectorはPythonの言語仕様をうまく利用して制御の反転を容易に実現可能にしてくれています。最初こそとっつきづらい印象ですがある程度理屈がわかってしまえばお手軽にDIできます。途中にProtocol(やABC)を挟むとそのままでは関数の定義と実装の間の参照が途切れてしまいますがそこは以前記事にした @implements デコレータを使うことで型チェックをしつつ双方向に参照できるようにできるので結構便利です。
https://zenn.dev/ktnyt/articles/414c01264eeff8

Discussion