「Clean Architectures in Python」を読み解いてクリーンアーキテクチャを具体的に理解する
はじめに
ソフトウェアアーキテクチャーの一種として、クリーンアーキテクチャがあります。クリーンアーキテクチャは、Robert C. Martin氏(「ボブおじさん」と呼ばれているらしい)が提唱したもので、以下のブログの説明が有名なようです。
私もこのブログの内容をざっと読んでみたのですが、具体的にはどんな実装になるのかがさっぱりわかりませんでした😇。そこで、いつも利用しているPythonでの実装例を探してみたところ、「Clean Architectures in Python」という英語の書籍を見つけました。以下のページから無料で閲覧できます。
この書籍では、物件情報を閲覧するためのアプリをPythonで実装することで、クリーンアーキテクチャの実例やメリットなどを説明しています。また、テスト駆動開発、エラー処理、本番環境へのデプロイなど、実業務における開発に役立ちそうな情報も記載されています。
この記事では、自身の知識の整理のために、クリーンアーキテクチャに関係する部分に絞って、実装手順をおさらいします。また、実装したアプリケーションに改修を加えることで、クリーンアーキテクチャのメリットを整理します。
つくるもの
この書籍では、「Rent-o-Matic」という物件情報の管理システムを開発します。外部ストレージに保存した物件情報を、CLIやREST API経由で閲覧するシンプルなアプリです。すべてPythonで実装します。
初期実装時のシステム構成は以下のようなイメージです。各部の詳細は後述します。
アーキテクチャのイメージ
最初は、Pythonのソースコード上に物件情報を直接記載し、それをCLIで閲覧するシンプルなアプリを実装します。書籍では「Chapter 3 - A basic example」で説明されています。
作成したアプリのソースコードは、以下のリポジトリにあります。
ドメイン
まずはドメインモデルの定義から始めます。クリーンアーキテクチャにおけるエンティティ層に該当します。
今回は、物件情報をモデル化したRoom
データクラスを定義します。このクラスは今回の業務システム固有の情報を保持し、アプリ全体で利用します。
物件情報として、以下のような情報を管理することにします。
- UUID
- 部屋のサイズ(平方メートル)
- 賃料(ユーロ/日)
- 経緯度
Pythonのdataclass
を活用して、以下のようにRoom
クラスを実装します。
import dataclasses
import uuid
@dataclasses.dataclass
class Room:
code: uuid.UUID
size: int
price: int
longitude: float
latitude: float
@classmethod
def from_dict(cls, d):
return cls(**d)
def to_dict(self):
return dataclasses.asdict(self)
なお、Python上でのデータの操作を簡単にするため、ディクショナリと相互に変換するためのメソッドも定義しています。データのCRUDに関する処理は、このクラスに記載せず、後述するリポジトリやユースケースに記載します。
リポジトリ
次に、Room
クラスのデータを含み、データへのアクセスを可能にするクラスを作成します。書籍ではこれを「リポジトリ」と呼んでいます。Gitのリポジトリとは関係がありません。意味通りに、データの「収納庫」のようなものだと理解しておくと良いと思います。
先ほど定義したドメインモデルに対して、シンプルなCRUD機能を提供します。
リポジトリのインターフェイス
まずはリポジトリのインターフェイスを定義します。(書籍では実装していませんが、わかりやすさのために追加しました)
Pythonでは言語機能としてのインターフェイスは存在しません。しかし、標準ライブラリのabc
を利用してインターフェイス的な機能を実現できます。
Room
を扱うリポジトリのインターフェイスを以下のように定義します。Room
の一覧を取得するlist()
メソッドとadd()
メソッドを持っています。書籍ではlist()
だけ実装していますが、シンプルすぎるためadd()
を追加しています。
import abc
from rentomatic.domain.room import Room
class IRoomRepo(metaclass=abc.ABCMeta):
@abc.abstractmethod
def list(self) -> list[Room]:
"""Get list of all Rooms"""
pass
@abc.abstractmethod
def add(self, room: Room) -> int:
"""Add a new Room"""
pass
IRoomRepo
を継承したクラスは、@abc.abstractmethod
でデコレートされたメソッド(今回はlist()
とadd()
)を実装しないと、インスタンス化できなくなります。これにより、インターフェイスの実装漏れを防ぐことができます。
なお、より実用的なアプリの場合、update()
、delete()
、search()
といったメソッドも必要になりそうです。今回はシンプルな実装のため、これらは省略します。
リポジトリの実装(インメモリ)
つぎに、IRoomRepo
を継承したクラスを実装します。これがリポジトリの実体になります。
リポジトリの機能を実現するためには、Room
のデータを保持するための外部ストレージが必要となります。これはテキストファイルやデータベースなど、なんでもOKです。
ここでは実装をシンプルにするため、ソースコード上に直接記載されたディクショナリを扱う、メモリ内ストレージシステムMemRepo
を実装します。(この記事の後半ではデータベースを利用する例を紹介します。)
先ほど作成したIRoomRepo
を継承したMemRepo
クラスを以下のように実装します。このクラスは、物件情報が記載されたディクショナリを初期化時にロードします。
from rentomatic.domain.room import Room
from rentomatic.repository.iroomrepo import IRoomRepo
class MemRepo(IRoomRepo):
def __init__(self, data: list[dict]):
self.data = data
def list(self) -> list[Room]:
return [Room.from_dict(i) for i in self.data]
def add(self, room: dict) -> int:
self.data.append(room)
return 0
リポジトリの実体には、外部ストレージ固有の処理を記載することになります。MemRepo
では、Pythonのディクショナリとリストに関する操作を記載しています。
リポジトリの単体テスト
単体テストを記載しやすいこともクリーンアーキテクチャの特徴です。他の層から切り離して、リポジトリだけをテストできます。ここではMemRepo
のテストを書きます。
アーキテクチャ図では以下の箇所に該当します。(書籍ではテスト駆動開発で実装しているため、先にテストを書いています)
リポジトリの単体テスト
テストコードは以下のようになります。pytest
を利用しています。
import pytest
from rentomatic.domain.room import Room
from rentomatic.repository.memrepo import MemRepo
@pytest.fixture
def room_dicts():
return [
{
"code": "f853578c-fc0f-4e65-81b8-566c5dffa35a",
"size": 215,
"price": 39,
"longitude": -0.09998975,
"latitude": 51.75436293,
},
# 省略
]
def test_repository_list_without_parameters(room_dicts):
repo = MemRepo(room_dicts)
rooms = [Room.from_dict(i) for i in room_dicts]
assert repo.list() == rooms
def test_repository_add(room_dicts):
repo = MemRepo(room_dicts[0:3])
rooms = [Room.from_dict(i) for i in room_dicts]
repo.add(room_dicts[3])
assert repo.list() == rooms
テストを実行すると、どちらもパスします。これでMemRepo
が正しく動作することを確認できました。
ユースケース
続いて、ユースケース層を実装します。ユースケースには、アプリで実行するビジネスロジックを記載します。このアプリの場合、指定した条件で物件情報を検索し、必要な形に加工して出力するような処理が該当します。
ユースケースの実装
ここでは、すべての物件情報を取得するユースケースと、物件の合計賃料を取得するユースケースを関数として作成します。(書籍では前者のみ実装しています。)
各ユースケースでは、リポジトリに実装されているlist()
メソッドを利用してRoom
の一覧を取得します。なお、各ユースケースにおいて、抽象クラスで型ヒントをつけることで、リポジトリに未実装のメソッドを利用することを防止しています。(こちらの記事を参考にしました。)
from rentomatic.domain.room import Room
from rentomatic.repository.iroomrepo import IRoomRepo
def room_list_use_case(repo: IRoomRepo) -> list[Room]:
"""Get list of all Rooms"""
return repo.list()
def room_price_all_use_case(repo: IRoomRepo) -> int:
"""Get sum of all room prices"""
total = 0
rooms = repo.list()
for r in rooms:
total += r.price
return total
ここでは、ユースケースの実装内容が、外部ストレージに依存していないことがポイントです。以下の図のように、ユースケースはリポジトリのインターフェイスに依存しているからです。
インターフェイスへの依存
これにより、ビジネスロジックが外部システムの実装に依存しないという、クリーンアーキテクチャの重要な原則を満たすことができます。
ユースケースの単体テスト
リポジトリの場合と同様に、ユースケースも単体テストを記載します。ここでは、リポジトリをモックに置き換えています。リポジトリのテストは先ほど実施済みのため、切り離しても問題ありません。これにより、もしリポジトリにデータベースを利用している場合でも、ユースケースのテストではデータベースが不要になります。
ユースケースの単体テスト
テストコードは以下のようになります。unittest
のmock
を利用して、list()
メソッドが固定値を返すようにしています。
import uuid
from unittest import mock
import pytest
from rentomatic.domain.room import Room
from rentomatic.use_cases.room_list import room_list_use_case, room_price_all_use_case
@pytest.fixture
def domain_rooms():
room_1 = Room(
code=uuid.uuid4(),
size=215,
price=39,
longitude=-0.09998975,
latitude=51.75436293,
)
# 省略
return [room_1, room_2, room_3, room_4]
def test_room_list_without_parameters(domain_rooms):
repo = mock.Mock()
repo.list.return_value = domain_rooms
result = room_list_use_case(repo)
repo.list.assert_called_with()
assert result == domain_rooms
def test_room_price_all(domain_rooms):
repo = mock.Mock()
repo.list.return_value = domain_rooms
result = room_price_all_use_case(repo)
repo.list.assert_called_with()
assert result == 213
こちらのテストをパスすることで、ユースケースを正しく実装できたことを確認できます。このように、ビジネスロジックを外部ストレージと切り離してテストできることは、クリーンアーキテクチャの大きなメリットです。
アプリケーション(CLI)
最後に、ユーザーがアプリを実際に利用するためのアプリケーション(ここではUIに該当)を実装します。まずはもっとも簡単なUIとして、コマンドラインツールを作成します。
このツールは、リポジトリに4件の物件情報を読み込みます。その後、ユースケースを実行して物件情報を全件取得して表示します。
#!/usr/bin/env python
from rentomatic.repository.memrepo import MemRepo
from rentomatic.use_cases.room_list import room_list_use_case
rooms = [
{
"code": "f853578c-fc0f-4e65-81b8-566c5dffa35a",
"size": 215,
"price": 39,
"longitude": -0.09998975,
"latitude": 51.75436293,
},
# 省略 合計4件の物件情報を定義
]
repo = MemRepo(rooms)
result = room_list_use_case(repo)
print([room.to_dict() for room in result])
total_price = room_price_all_use_case(repo)
print(f"total: {total_price}")
このツールを実行すると、すべての物件情報を取得できます。
❯ poetry run python ./cli.py
[{'code': 'f853578c-fc0f-4e65-81b8-566c5dffa35a', 'size': 215, 'price': 39, 'longitude': -0.09998975, 'latitude': 51.75436293},
{'code': 'fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a', 'size': 405, 'price': 66, 'longitude': 0.18228006, 'latitude': 51.74640997},
{'code': '913694c6-435a-4366-ba0d-da5334a611b2', 'size': 56, 'price': 60, 'longitude': 0.27891577, 'latitude': 51.45994069},
{'code': 'eed76e77-55c1-41ce-985d-ca49bf6c0585', 'size': 93, 'price': 48, 'longitude': 0.33894476, 'latitude': 51.39916678}]
total: 213
このスクリプトで重要な部分は以下の2行です。リポジトリを初期化し、ユースケースを実行しています。アプリケーション層では、外部ストレージやビジネスロジックの詳細を記述する必要はありません。
repo = MemRepo(rooms)
result = room_list_use_case(repo)
これでクリーンアーキテクチャのコアとなる機能を実装できました。
クリーンアーキテクチャの利点
今回は物件情報を取得するだけのアプリを作成しました。現時点では、実現した機能に対してソースコードの記述が非常に冗長になっています。クリーンアーキテクチャはアプリの機能を追加・変更する際に真価を発揮します。
ここからは、先ほどのアプリに変更を加えることで、以下のようなクリーンアーキテクチャの利点を検証します。
- アプリケーションを変更可能
- 外部ストレージを変更可能
- テストを実行しやすい
アプリケーションを変更可能
クリーンアーキテクチャの特性の1つとして、アプリケーション独立であることが挙げられています。今回はUIをCLIで構築しましたが、これを容易に他のUIに置き換えることができます。
書籍では軽量WEBフレームワークのFlaskを用いて、REST APIを構築しています。詳細な実装方法は「Chapter 4 - Add a web application」を参照してください。コンフィグの記載方法なども含めて、Flaskの使い方が丁寧に説明されています。
ここではAPIの主要な部分に着目して説明します。GETメソッドで物件情報の一覧を呼び出すAPIを実装します。
import json
from flask import Blueprint, Response
from rentomatic.repository.memrepo import MemRepo
from rentomatic.serializers.room import RoomJsonEncoder
from rentomatic.use_cases.room_list import room_list_use_case
blueprint = Blueprint("room", __name__)
rooms = [
{
"code": "f853578c-fc0f-4e65-81b8-566c5dffa35a",
"size": 215,
"price": 39,
"longitude": -0.09998975,
"latitude": 51.75436293,
},
# 省略
]
@blueprint.route("/rooms", methods=["GET"])
def room_list():
repo = MemRepo(rooms)
result = room_list_use_case(repo)
return Response(
json.dumps(result, cls=RoomJsonEncoder),
mimetype="application/json",
status=200,
)
ここでもCLIの場合と同様に、以下の2行でデータを取得しています。
repo = MemRepo(rooms)
result = room_list_use_case(repo)
CLIの時のように、ビジネスロジックの処理はユースケースに任せることができます。アプリケーション側では、アプリ固有のデータの加工などを実施するだけです。ここではREST APIのレスポンスに適しているJSONへのデータ変換を行なっています。
ローカルサーバーを起動してAPIをコールすると、以下のようにレスポンスを取得できます。
curl http://127.0.0.1:5000/rooms
[{"code": "f853578c-fc0f-4e65-81b8-566c5dffa35a", "size": 215, "price": 39, "latitude": 51.75436293, "longitude": -0.09998975},
{"code": "fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a", "size": 405, "price": 66, "latitude": 51.74640997, "longitude": 0.18228006},
{"code": "913694c6-435a-4366-ba0d-da5334a611b2", "size": 56, "price": 60, "latitude": 51.45994069, "longitude": 0.27891577},
{"code": "eed76e77-55c1-41ce-985d-ca49bf6c0585", "size": 93, "price": 48, "latitude": 51.39916678, "longitude": 0.33894476}]
このように、クリーンアーキテクチャではUIを容易に変更できます。また、今回はWEBフレームワークとしてFlaskを利用しましたが、Streamlitなどを利用してGUIを構築することも容易にに実施できそうです。
リポジトリを変更可能
クリーンアーキテクチャでは、リポジトリで利用する外部ストレージを簡単に変更できます。
「Chapter 6 - Integration with a real external system - Postgres」では、ストレージをインメモリからPostgreSQLに変更する例が紹介されています。ただし、PostgreSQL自体の設定が大掛かりになっているので、ここではSQLiteでの実装に置き換えてみます。
IRoomRepo
を継承したSQliteRepo
クラスを以下のように実装します。このクラスにはlist()
とadd()
メソッドを実装する必要があります。(SQLiteのテーブルはすでに作成されているものとします)
import sqlite3
from rentomatic.domain.room import Room
from rentomatic.repository.iroomrepo import IRoomRepo
class SqliteRepo(IRoomRepo):
def __init__(self, dbname: str):
conn = sqlite3.connect(dbname)
self.cur = conn.cursor()
def list(self) -> list[Room]:
self.cur.execute("SELECT code, size, price, longitude, latitude FROM room")
rooms = []
for row in self.cur:
rooms.append(Room(str(row[0]), row[1], row[2], row[3], row[4]))
return rooms
def add(self, room: dict) -> int:
self.cur.execute(
f"""
INSERT INTO room VALUES(
"{room.code}",
{room.size},
{room.price},
{room.longitude},
{room.latitude}
)
"""
)
return 0
MemRepo
ではディクショナリのリストに対してデータを取得・保存していましたが、SqliteRepo
ではSQLiteのテーブルに対する操作に置き換わります。
SqliteRepo
を利用するCLIツールを実装してみます。変更箇所は、リポジトリの初期化時にSqliteRepo
を指定するだけです。データベースの操作はリポジトリの中に独立しているため、ユースケースは影響を受けていません。
from rentomatic.repository.sqliterepo import SqliteRepo
from rentomatic.use_cases.room_list import room_list_use_case
repo = SqliteRepo("rentomatic.db")
result = room_list_use_case(repo)
print([room.to_dict() for room in result])
実行すると、データベースに保存した物件情報の一覧を取得できます。
ここまでの改修の結果、アーキテクチャ図は以下のようになりました。
改修後のアーキテクチャ図
UIと外部ストレージを大きく変更しましたが、ユースケースは影響を受けていません。クリーンアーキテクチャにおける関心の分離の効果が現れていますね。
テストを実行しやすい
単体テストの実行が容易であることも、クリーンアーキテクチャの利点として挙げられています。
先ほど作成したSqliteRepo
のテストを書いてみます。サンプルとして、list()
のテスト部分を以下に示します。
def test_repository_list_without_parameters(room_dicts):
repo = SqliteRepo("rentomatic.db")
rooms = [Room.from_dict(i) for i in room_dicts]
assert repo.list() == rooms
ここでは、ユースケースのテストは修正不要です。ユースケースはリポジトリの実装に依存していないためです。もし、モノシリックなアーキテクチャを採用していた場合、ユースケースのテストを書き直す必要が出てきたかもしれません。
おわりに
今回は、物件情報の閲覧システムを開発することで、クリーンアーキテクチャの具体的な実装例を確認することができました。
ここで、最初に紹介したブログを読み返してみます。ここに記載されている、クリーンアーキテクチャの5つの特徴を理解できるようになりました。
- フレームワーク独立。アーキテクチャは、機能満載のソフトウェアのライブラリが手に入ることには依存しない。これは、そういったフレームワークを道具として使うことを可能にし、システムをフレームワークの限定された制約に押し込めなければならないようなことにはさせない。
今回の実装では、Pythonの標準ライブラリのみを利用してクリーンアーキテクチャを実現しています。特定のフレームワークは不要でした。
- テスト可能。ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできる。
ビジネスルールをユースケースとして実装し、リポジトリをモック化することで、テストが容易になりました。実用的なシステムにおいてはビジネスルールが複雑になるため、テストを容易に実施できることは品質向上に寄与しそうです。
- UI独立。UIは、容易に変更できる。システムの残りの部分を変更する必要はない。たとえば、ウェブUIは、ビジネスルールの変更なしに、コンソールUIと置き換えられる。
ユースケースを変更せずに、CLIとREST APIの両方のUIを実装できました。これにより、アプリの提供方法を柔軟に変更できます。(そのようなニーズがどれだけあるかは不明ですが。)
- データベース独立。OracleあるいはSQL Serverを、Mongo, BigTable, CoucheDBあるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
データの保存先を、リストからSQLiteに変更することができました。ここでもユースケースは変更していません。
5.外部機能独立。実際のところ、ビジネスルールは、単に外側についてなにも知らない。
ユースケースは外側の層、すなわちUIや外部ストレージに依存していません。ビジネスルールは、アプリの改修時に頻繁に変更されるため、この特性は重要です。
今回の実装を通じ、文字通りクリーンなアーキテクチャを実現できたことが実感できました。その一方で、今回のようなシンプルなアプリにおいては、コードの記述が少し冗長であるとも感じました。今後は、アプリの特性や変更可能性なども考慮した上で、適切なアーキテクチャ選定を実施したいと思います。
参考文献
Discussion