PythonのDIフレームワーク「Injector」がいい感じ
GitHubのレポジトリはこちら:
はじめに
もう一つPythonのDIフレームワークに「Dependency Injector」というものもあるんですが、個人的にこちらは「Injector」に比べて使いにくかったです。理由はコンテナオブジェクトの定義が面倒だったことと、注入時にノーコンテキストでSegmentation Faultを起こしてしまうのでデバッグしづらかったことです。「Injector」はドキュメントこそ貧弱で(インスパイア元のGuiceの方も読み込めばわかるのかもしれませんが...)とっつきづらいですが、一度わかってしまえば(最低限使いこなすのは)かんたんです。この記事では理屈で最初の一歩を理解することを目的に細かく公式ドキュメントを補完してみようと思います。
どうやって「依存を注入」するのか
「Injector」を理解するためにはまず「どうやって依存が注入されるのか」を押さえておく必要があります。その上で重要な概念が3+1つ、Injector
、Binder
、Provider
、そして 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
するのかを指定していないので、通常のディクショナリであれば値を取得することができません。しかし結果的に上記のコードでは Injector
は Outer
に Inner
のインスタンスを渡してインスタンス化しています。
では Injector
はどうやって Outer
のインスタンスを作っているのか?ここで効いてくるのが @inject
デコレータです。Injector
は渡されたキーに対して何を返すべきかを指定されていない場合には get
に渡された実引数を Callable
とみなして呼びます。この時に渡した Callable
が @inject
でデコレートされていた場合、その Callable
の引数の型を Injector
から参照可能になるように実装されています。get
の中で Injector
は渡された @inject
された Callable
が依存している引数の型を参照して、その引数の型について同様に get
を呼びます。上記のケースでは get(Inner)
となるわけです。
ここで更に Inner
について同じことが起こりInner
を Callable
とみなして呼びますが、 Inner
には引数がありません。なのでこの時点で Injector
は依存項目を検索することをやめて単純に Inner()
を呼びその返り値 = Inner
のインスタンスを返します。この Inner
のインスタンスが元の get
コールに渡された Outer
を Callable
と解釈した引数として与えられて、Outer(Inner())
が返されます。
厳密にはこの時に依存項目である Inner
のインスタンスは Injector
のインスタンス内部に保存されて Injector
が破壊されるまで保持・使い回しされます。この挙動をオーバーライドしてシングルトンを実現したりスレッドローカルにインスタンスを提供する仕組みとして Scope
という概念が存在しますがこの記事では解説しません。
上記の例では Inner
が引数を取らない実装だったのでそのまま依存注入することができましたが、そうでないケースもあります。わかりやすいケースとしては依存として指定された抽象クラスの実装として具象クラスを注入する場合です。そのような場合には Injector
の持つ Binder
を用いて Inner
という名前に作り方を束縛(bind)する必要があります。
Binder
一番シンプルに束縛する方法として Injector
に configure
関数を渡す方法があります。例えば次のようなコードがあったとします。
from typing import Protocol, runtime_checkable
@runtime_checkable
class Create(Protocol):
def __call__(self, user: types.User, password: str) -> None: ...
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,
)
これをユーザのアクティベーションを行う何かしらの実装で利用するとします。
from dataclasses import dataclass
from injector import inject
@inject
@dataclass
class Activate:
create_user: dependencies.Create
...
このままでは dependencies.Create
は抽象クラス(プロトコル)なので実体化することができません。そこで次のような configure
関数を実装します。
from injector import Binder, InstanceProvider
from . import accessors, dependencies
def configure(binder: Binder) -> None:
binder.bind(dependencies.Create, to=InstanceProvider(accessors.create))
そしてこれを Injector
に渡してあげます。
from injector import Injector
from . import configure, service
def main():
injector = Injector([configure])
activate = injector.get(service.Activate)
こうすることで、Injector
が service.Activate
をインスタンス化する時に必要になる dependencies.Create
を get
した際に、事前に渡した configure
関数内で bind
した accessors.create
を使うようになります。
Provider
Provider
は「依存を提供する主体」です。上記の例ではインスタンスを提供する主体である InstanceProvider
を利用していますが、これ以外にも CallableProvider
と ClassProvider
が存在します。CallableProvider
は渡された呼び出し可能オブジェクトを呼び出した返り値を、ClassProvider
は渡されたクラスを実体化して提供し、Provider
を省略した場合は CallableProvider
が暗黙的に適用されます。必要に応じて使い分けましょう。
Module
より複雑な束縛メカニズムを提供する場合には Module
を使うことが出来ます。例えば個人のプロジェクトでE2Eテストを実行する際に特定の束縛を上書きして(乱数生成や外部API呼び出しなどを持っくに挿し替えて)実行したいという要求がありました。そこでテスト用のサーバプログラムで使用される configure
に渡される束縛を ContextLib
の使用感で上書きできるようにする Module
を実装しました。
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
に次のように記述しました。
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
で置き換えることができます。
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
デコレータを使うことで型チェックをしつつ双方向に参照できるようにできるので結構便利です。
Discussion