| title | PythonのProtocolとdependency-injectorでDIする | ||||
|---|---|---|---|---|---|
| emoji | 🐍 | ||||
| type | tech | ||||
| topics |
|
||||
| published | true |
概要
Python には他の静的型付け言語のような明確な interface キーワードがありませんが、それに代わる Protocol という概念を最近知りました。
そこで、個人的に好んで使っている「Service クラスに Repository を注入する」という DI(依存性注入)パターンを、この Protocol を使って Python でどう実現できるか試してみることにしました。
この記事では、dependency-injector も組み合わせ、その具体的な実装方法をサンプルコードと共に備忘録として残します。
今回使ったコードは以下に置いてあります。
https://gist.github.com/ara-ta3/e668cd1d90daefe52b95de36c7e29485
まずはインタフェースを Protocol を使って定義します。
Protocol とは
Python の Protocol は、Go 言語の interface に非常によく似た概念です。
Go では、ある型が特定の interface で定義されたメソッドをすべて実装していれば、その型は明示的に implements と書かなくても、その interface を満たすと見なされます。
Python の Protocol もこれと同じ考え方に基づいています。クラスが Protocol で定義されたメソッドや属性を(同じシグネチャで)持っていれば、そのクラスは Protocol を明示的に継承していなくても、その Protocol 型として扱うことができます。
このような仕組みは、いわゆる構造的部分型(structural subtyping)と呼ばれるようです。
これにより、具象クラスと、それを利用するコードとの間に疎結合な関係を築くことができ、柔軟でテストしやすい設計が可能になります。
Repository のインタフェースを定義する
今回はデータアクセス層のインタフェースとして、UserRepositoryProtocol を定義します。put メソッドは、型に応じて新規作成と更新をハンドリングする責務を持ちます。
from typing import Protocol, Optional, Union from dataclasses import dataclass @dataclass class UserDetail: name: str email: str @dataclass class User: id: int detail: UserDetail class UserRepositoryProtocol(Protocol): def fetch(self, user_id: int) -> Optional[User]: ... def put(self, data: Union[User, UserDetail]) -> User: ...
主要なコンポーネントの実装
次に、定義した Protocol を利用して、アプリケーションの主要な部品の Service と Repository を実装します。
Service クラス
UserService は、create_user と update_user の責務を持ちます。Repository の put メソッドに適切な型を渡すことで、新規作成と更新を依頼します。
class UserService: def __init__(self, user_repository: UserRepositoryProtocol): self._user_repository = user_repository def create_user(self, detail: UserDetail) -> User: return self._user_repository.put(detail) def update_user(self, user_id: int, detail: UserDetail) -> Optional[User]: user = self._user_repository.fetch(user_id) if not user: return None user.detail.name = detail.name user.detail.email = detail.email return self._user_repository.put(user)
Repository クラス
put メソッドは、渡されたオブジェクトの型を isinstance で判定し、処理を分岐します。
class UserRepositoryOnMemory: def __init__(self): self._users: dict[int, User] = {} self._next_id = 1 def fetch(self, user_id: int) -> Optional[User]: return self._users.get(user_id) def put(self, data: Union[User, UserDetail]) -> User: if isinstance(data, UserDetail): new_user = User(id=self._next_id, detail=data) self._users[new_user.id] = new_user self._next_id += 1 return new_user elif isinstance(data, User): self._users[data.id] = data return data else: raise TypeError("Unsupported type for put method")
DI によるアプリケーションの組み立て
実装したコンポーネントを dependency-injector を使って結合し、アプリケーションとして実行できるようにします。
DI コンテナの定義
dependency-injector を使って、どのインタフェース(Protocol)にどの具象クラスを注入するかを定義します。
pip install dependency-injector でインストールできます。
from dependency_injector import containers, providers class Container(containers.DeclarativeContainer): config = providers.Configuration() user_repository = providers.Singleton( UserRepositoryOnMemory ) user_service = providers.Factory( UserService, user_repository=user_repository, )
アプリケーションの実行
コンテナから UserService のインスタンスを取得して、ビジネスロジックを実行します。
def main(): container = Container() user_service = container.user_service() created_user = user_service.create_user(UserDetail(name="田中太郎", email="tanaka@example.com")) print(f"作成されたユーザー: {created_user}") updated_user = user_service.update_user( created_user.id, UserDetail(name="田中次郎", email="jiro@example.com") ) print(f"更新されたユーザー: {updated_user}")
実行結果
uv を使って依存関係のインストールとスクリプトの実行をします。
https://github.com/astral-sh/uv
# 仮想環境の作成と有効化 uv venv source .venv/bin/activate # 依存パッケージのインストール uv pip install pytest dependency_injector # main.pyの実行 uv run python main.py
実行すると、ユーザー作成、更新が成功していることがわかります。
作成されたユーザー: User(id=1, detail=UserDetail(name='田中太郎', email='tanaka@example.com'))
更新されたユーザー: User(id=1, detail=UserDetail(name='田中次郎', email='jiro@example.com'))
アプリケーションの基本的な動作を確認したら、次にテストコードも書いてみましょう。
Protocol とテスト
Protocol を使うことで、テストが非常に書きやすくなります。
Service のテストをする際に、本物の Repository の代わりにモックオブジェクトを注入できるためです。
pytest の mock を使ったユニットテスト
unittest.mock.Mock を使って UserRepositoryProtocol の振る舞いを模倣し、UserService をテストします。
import pytest from unittest.mock import Mock class TestUserService: def test_create_user(self): mock_repository = Mock(spec=UserRepositoryProtocol) detail = UserDetail(name="新規ユーザー", email="new@example.com") saved_user = User(id=1, detail=detail) mock_repository.put.return_value = saved_user user_service = UserService(mock_repository) result = user_service.create_user(detail) assert result == saved_user mock_repository.put.assert_called_once_with(detail) def test_update_user_success(self): mock_repository = Mock(spec=UserRepositoryProtocol) detail = UserDetail(name="新ユーザー", email="new@example.com") existing_user = User(id=1, detail=UserDetail(name="旧ユーザー", email="old@example.com")) mock_repository.fetch.return_value = existing_user updated_user = User(id=1, detail=detail) mock_repository.put.return_value = updated_user user_service = UserService(mock_repository) result = user_service.update_user(1, detail) assert result == updated_user mock_repository.fetch.assert_called_once_with(1) mock_repository.put.assert_called_once_with(updated_user) def test_update_user_not_found(self): mock_repository = Mock(spec=UserRepositoryProtocol) mock_repository.fetch.return_value = None user_service = UserService(mock_repository) result = user_service.update_user(99, UserDetail(name="誰か", email="darekasan@example.com")) assert result is None mock_repository.fetch.assert_called_once_with(99) mock_repository.put.assert_not_called()
実際にテストを実行してみましょう。
uv run pytest main.py ======================================================== test session starts ======================================================== platform darwin -- Python 3.13.3, pytest-8.4.1, pluggy-1.6.0 rootdir: /path/to/pythonprotocol collected 3 items main.py ... [100%] ========================================================= 3 passed in 0.04s =========================================================
テストがすべてパスすることを確認できました。
自前実装を使ったユニットテスト
mock を使わずに、テスト用の Repository を自前で実装する方法もあります。
class UserRepositoryFixture: def __init__(self): self._predefined_users: dict[int, User] = {} self._next_id = 1 self._fetch_calls: list[int] = [] self._put_calls: list[Union[User, UserDetail]] = [] def set_user(self, user: User): self._predefined_users[user.id] = user def fetch(self, user_id: int) -> Optional[User]: self._fetch_calls.append(user_id) return self._predefined_users.get(user_id) def put(self, data: Union[User, UserDetail]) -> User: self._put_calls.append(data) if isinstance(data, UserDetail): new_user = User(id=self._next_id, detail=data) self._predefined_users[new_user.id] = new_user self._next_id += 1 return new_user elif isinstance(data, User): self._predefined_users[data.id] = data return data else: raise TypeError("Unsupported type for put method") def get_fetch_calls(self) -> list[int]: return self._fetch_calls.copy() def get_put_calls(self) -> list[Union[User, UserDetail]]: return self._put_calls.copy() class TestUserServiceWithFixture: def test_create_user(self): test_repository = UserRepositoryFixture() detail = UserDetail(name="新規ユーザー", email="new@example.com") user_service = UserService(test_repository) result = user_service.create_user(detail) assert result.id == 1 assert result.detail == detail assert test_repository.get_put_calls() == [detail] def test_update_user_success(self): test_repository = UserRepositoryFixture() detail = UserDetail(name="新ユーザー", email="new@example.com") existing_user = User(id=1, detail=UserDetail(name="旧ユーザー", email="old@example.com")) test_repository.set_user(existing_user) user_service = UserService(test_repository) result = user_service.update_user(1, detail) assert result is not None assert result.id == 1 assert result.detail.name == "新ユーザー" assert result.detail.email == "new@example.com" assert test_repository.get_fetch_calls() == [1] assert len(test_repository.get_put_calls()) == 1 def test_update_user_not_found(self): test_repository = UserRepositoryFixture() user_service = UserService(test_repository) result = user_service.update_user(99, UserDetail(name="誰か", email="darekasan@example.com")) assert result is None assert test_repository.get_fetch_calls() == [99] assert test_repository.get_put_calls() == []
実際にテストを実行してみましょう。
uv run pytest main2.py ======================================================== test session starts ======================================================== platform darwin -- Python 3.13.3, pytest-8.4.1, pluggy-1.6.0 rootdir: /path/to/pythonprotocol collected 3 items main2.py ... [100%] ========================================================= 3 passed in 0.05s =========================================================
テストがすべてパスすることを確認できました。
まとめと感想
Python の Protocol と dependency-injector を使った DI パターンを試してみました。
インタフェースが Python にはなくて悲しいと思っていましたが、それ相当のものがあったので、便利に感じました。
Python をまた使う際には色々と試してみたいと思います。