ラクスルグループのノバセルで新卒2年目のエンジニアをしています田村(tamtam)です。 第1回では、AWS Lambda x FastAPIによるPythonモダンAPI開発を実現する上で役立つであろう1. 開発環境の構築で使用したツール、2. 開発に活用したPythonライブラリについて紹介していきました。 そして本記事ではある第2回では3. アーキテキチャ及びディレクトリ構造について紹介していきます!💪 この記事を通じて、同じような課題を抱える他の開発者の皆さんに役立つ情報を提供できればと思います。 それでは詳細について見ていきましょう! Pythonを使用して新しいプロジェクトを開始しようとしている人 Python開発に役立つリンターやフォーマッターなどの効率的なライブラリを探している人 Pythonでの開発に際して参考にしたいアーキテクチャを探している人 AWS LambdaのPython環境をサーバーレス実行プラットフォームとして検討している人 FastAPIを用いた開発を検討している人 今回の記事に際して、筆者が作成したレポジトリを共有します。 なお、ソースコードは逐次更新しているため、本書の記述内容と差異がある可能性をご理解の上読んでいただきますと幸いです。 今回の開発ではDomain層がInfrastructure層に依存させないオニオンアーキテクチャを採用しました。 また、FastAPI作者のテンプレートをはじめとするリポジトリを参考にし、各層で具体的にどういったモノを書くのかを決めていきました。 オニオンアーキテクチャはドメイン駆動設計(DDD)といった設計のなかで代表されるアーキテクチャの1つです。 レイヤードアーキテクチャから、依存関係逆転の原則を用いてドメイン層とインフラ層の依存関係を逆転させたのがオニオンアーキテクチャです。 オニオンアーキテクチャでは、特定の技術(RDBMSやNoSQLなど)に依存しない形でドメイン層を独立したレイヤーとして設計することで、技術的変更に柔軟に対応する能力を実現しています。
オニオンアーキテクチャが生まれる前の一般的なアーキテクチャは、Ruby on Railsなどで見られる「プレゼンテーション層」「ビジネスロジック層」「データアクセス層」を備えた3層アーキテクチャでした。 この3層アーキテクチャでは、ビジネスロジック層にユースケースとドメイン知識が集中し、責任過多(低凝集)となる問題に直面しました。 さらに、ドメイン知識をモデルクラスに定義してしまうと、その知識がデータアクセス層に浸透(密結合)する問題もありました。 これを改善するために、ビジネスロジック層をアプリケーション(ユースケース)層とドメイン層に分割したレイヤードアーキテクチャが生まれました。 しかし、このレイヤードアーキテクチャでは、ドメイン層がインフラ層に依存しまいます。そのため、レイヤー間の高い結合度の問題が続いていました。 特に、DDD(ドメイン駆動設計)では、ドメイン層を他の層から独立させることが推奨されます。これは特定の技術に依存せず、モデルを逐次改良するための要件です。 これらの課題を解決するために、ドメイン層をインフラ層から切り離す目的で、依存関係逆転の原則を適用したオニオンアーキテクチャが提案されました。 実践ドメイン駆動設計を引用すると依存関係逆転の原則とは以下のようになります。 上位レベルのモジュールは下位レベルのモジュールに依存してはならない。どちらのモジュールも抽象に依存すべきである 抽象は実装に依存してはならない。 実装が抽象に依存すべきである 『実践ドメイン駆動設計』P.119 オニオンアーキテクチャでは、リポジトリのインターフェースをドメイン層で定義し、その具体的な実装クラスをインフラ層で設定します。 これにより、ドメイン層がインフラ層に依存する関係性を解消し、インフラ層がドメイン層で定義されているインターフェースに依存する形となります。 ノバセルではすでにいくつかのPythonプロジェクトでクリーンアーキテクチャを試みてきました。 その経験から、基本的に同じ思想を持つオニオンアーキテクチャの導入はそれほど難しくないと感じました。 クリーンアーキテクチャの開発では、都度インターフェースを設けることによるボイラープレート過多の問題がありました。しかし今回の開発では、リポジトリをインターフェースと実装に分ける位で、その問題が軽減されると考えました。 一方で、3層アーキテクチャでも十分なのではないかという意見もありました。 しかし、このAPIは一定の拡張性が必要とされています。またシンプルな構成にすると詳細が曖昧になり、その結果、何をどこに記述すべきかが不明確になると考えました。 そのため、ある程度の方針が示され、参考にしやすいオニオンアーキテクチャが見通しを良くすると判断しました。 オニオンアーキテクチャやその基盤となるDDDの書き方については、参考となる記事などがあるため、学習しながら進めることができました。 オニオンアーキテクチャを採用する上で参考になった記事を共有します。 Pythonで実際にコーディングする上で読んだリソースは後述します。 実際のディレクトリ構造とは微妙に異なるかもしれませんが、以下に示すような形を想定しています。参考にしていただければ幸いです。 aws-lambda-fastapi-onion-architecture-template オニオンアーキテクチャとディレクトリの関係性を表すと、 となります。 そのほか、usecaseとapiとの受け渡しのデータ構造を提供するschemaやDIコンテナを実装するInjectorの制御を管理するcontainer_configなどがあります。 それぞれのディレクトリについて、データの処理の流れに沿って説明していきします。 apiディレクトリ配下では、大枠のルーティングの管理するrouterとそれをもとに呼ばれるパスオペレーション関数を提供します。 上記のような構成はFastAPI作者のテンプレートを参考にしています。 schemaの役割はapiとusecaseの間のデータ構造を提供する役割と、パスオペレーション関数のリクエストとレスポンスの構造を提供する役割を担います。 apiからusecaseへビジネスロジックに必要なパラメータ(例えば、タスク名、期日、ユーザーID)を送る際、1つのデータ構造にまとめるDTOを提供します。 その際、DTOではビジネスルールによらない基本的な制約(例えば、文字列の長さ、数値の範囲、必須フィールドの存在など)に基づいたバリデーションも行うようにします。 実際の使われ方は以下のようになります。 schemaによって定義されたDTOは、リクエストとレスポンスの構造を提供します。 FastAPIのパス操作関数は、リクエストに関してはBodyに対して、レスポンスについてはresponse_modelという引数に対して、Pydanticを継承したモデルを適用できます。 特に注目すべきは、response_modelについてです。これにより、指定されたモデルの構造に基づいてFastAPIがJSONにシリアライズして返すことが可能となります。 usecaseではドメイン層が公開するメソッドを組み合わせ、ユースケースを組み立てます。 domainではコアとなるビジネスルールをもつエンティティやリポジトリのインターフェースを提供します。 infrastructureではドメイン層で定義したリポジトリの実装を提供します。 その際リポジトリの実装は、datasource内で定義したものを使用します。 datasourceで書かれるモノは以下のようなコードを想定しています。 coreではコンフィグやロギング、認証など共通で用いられるものを提供します。 ここでは例として、コンフィグの内容を記載します。 container_configではDIコンテナを提供します。 以下のようにインターフェースと実装のバインディングをします。 その後、DIコンテナを担当するクラスを提供させます。 呼ばれ方は以下のようになります。 カスタム例外クラスやアプリケーションで起きた全体の例外処理を取得するエラー処理のミドルウェアを提供します。 基本的に以下のリポジトリのコードを参考に作成しています。興味ある方はご覧ください。
github.com Pythonで実際にコーディングする上で参考にしたリソースを共有します。 GitHub - teamhide/fastapi-layered-architecture: FastAPI Layered Architecture GitHub - iktakahiro/dddpy: Python DDD Example and Techniques Python で学ぶ実践的なドメイン駆動設計とレイヤードアーキテクチャ / DDD and Onion Architecture in Python - Speaker Deck How to get started DDD & Onion-Architecture in Python web application 第2回では、AWS Lambda x FastAPIによるPythonモダンAPI開発を実現する上で役立つであろう 3. アーキテキチャ及びディレクトリ構造について紹介していきました。 この記事を通じて、同じような課題を抱える他の開発者の皆さんに役立つ情報を提供できればと幸いです。 次回の記事としては、Snowflake ✖️ Pythonネタで何か書こうと思います! 他方、以前書いた「DWHからRDSへのデータロード処理の高速化」という記事も興味あれば見ていただけますと嬉しいです💪はじめに
対象読者
あまり説明しないこと
前提とするバージョン
参考となるレポジトリ
3. アーキテキチャ及びディレクトリ構造
オニオンアーキテクチャを採用
オニオンアーキテクチャとは
誕生の背景
依存関係逆転の原則の活用
採用理由
参考になった記事
ディレクトリ構造
全体の構成
├── api # presentation layer
│ ├── __init__.py
│ └── v1
│ ├── __init__.py
│ ├── endpoints
│ │ ├── __init__.py
│ │ └── <specific name>
│ └── router.py
├── compose.yml
├── container_config # DI container
│ ├── __init__.py
│ ├── di_container.py
│ └── module
│ ├── __init__.py
│ └── repository_module.py
├── core
│ ├── __init__.py
│ ├── config.py
│ ├── logger
│ │ ├── __init__.py
│ │ ├── api_logger.py
│ │ └── logging_context_route.py
│ └── singleton.py
├── domain # domain layer
│ └── <specific name>
│ ├── service # business logic which is not written in entity and value object
│ ├── entity # mutable object
│ ├── i_repository # interface for repository(DIP)
│ └── value_object # immutable object
├── exceptions # custom exceptions
│ ├── core.py
│ ├── error_handle_middleware.py
│ └── error_messages.py
├── infrastructure # infrastructure layer
│ ├── repository_impl # implement repository interface
│ ├── model # ORM
│ ├──datasource
├── main.py
├── schemas # DTO for using endpoint
├── scripts
│ └── compose_pytest
├── tests
├── usecase # application layer
オニオンアーキテクチャの各層
対応するディレクトリ
プレゼンテーション
api
ユースケース
usecase
ドメイン
domain
インフラ
infrastructure
api
# router.py
v1_router = APIRouter()
v1_router.include_router(
english_words.router, prefix="/english-words", tags=["english_words"]
)
# endpoints/english_words.py
router = APIRouter()
@router.get("/generate-meaning", response_model=MeaningGetResponse)
def get_meaning(word: str) -> MeaningGetResponse:
usecase = DIContainer().resolve(GetMeaningUseCase)
response = usecase.handle(meaning_get=MeaningGet(english_word=word))
return response
schema
apiとusecaseの間のデータ構造を提供する役割
from pydantic import BaseModel, Field, HttpUrl
# Shared properties
class WordBase(BaseModel):
english_word: str = Field(max_length=20)
class MeaningGet(WordBase):
pass
class MeaningGetResponse(BaseModel):
meaning: str
# endpoints/english_words.py
@router.get("/generate-meaning", response_model=MeaningGetResponse)
def get_meaning(word: str) -> MeaningGetResponse:
usecase = DIContainer().resolve(GetMeaningUseCase)
response = usecase.handle(meaning_get=MeaningGet(english_word=word)) # type: ignore
return response
# usecase/english_word/get_meaning_usecase.py
class GetMeaningUseCase:
@inject
def __init__(self, meaning_repository: IMeaningOriginRepo) -> None:
self.__meaning_repository = meaning_repository
def handle(self, meaning_get: MeaningGet) -> MeaningGetResponse:
english_word = EnglishWord(value=meaning_get.english_word)
meaning = self.__meaning_repository.get(english_word)
return MeaningGetResponse(meaning=meaning)
schemaはパスオペレーション関数のリクエストとレスポンスの構造を提供する役割
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float | None = None
tags: set[str] = set()
@app.post("/items/", response_model=Item, tags=["items"])
async def create_item(item: Item):
return item
usecase
from injector import inject
from domain.english_word.english_word import EnglishWord
from domain.english_word.i_meaning_repository import IMeaningOriginRepo
from schemas.english_word import MeaningGet, MeaningGetResponse
class GetMeaningUseCase:
@inject
def __init__(self, meaning_repository: IMeaningOriginRepo) -> None:
self.__meaning_repository = meaning_repository
def handle(self, meaning_get: MeaningGet) -> MeaningGetResponse:
english_word = EnglishWord(value=meaning_get.english_word)
meaning = self.__meaning_repository.get(english_word)
return MeaningGetResponse(meaning=meaning)
domain
# domain/entity/english_word.py
class EnglishWord(BaseModel, frozen=True): # type: ignore
value: str
@validator("value")
def is_english(cls, v: str) -> str:
if not v.isascii():
raise APIException(ErrorMessage.WORD_IS_NOT_ENGLISH)
return v
# domain/english_word/i_meaning_repository.py
class IMeaningOriginRepo(metaclass=ABCMeta):
@abstractmethod
def get(self, english_word: EnglishWord) -> str:
raise NotImplementedError()
infrastructure
# infrastructure/english_word/repository/meaning_dynamodb_repository_impl.py
from domain.english_word.english_word import EnglishWord
from domain.english_word.i_meaning_repository import IMeaningCacheRepo
class MeaningDynamodbRepositoryImpl(IMeaningCacheRepo):
def get(self, english_word: EnglishWord) -> str:
# Dynamodbから値が返る
return "ギリシア文字の第11字母"
# Snowflakeをデータソースとして利用する場合の例
class Snowflake(Singleton):
_conn = None
@contextlib.contextmanager
def cursor(self) -> Generator[SnowflakeCursor, None, None]:
conn = self.__get_conn()
cursor = conn.cursor()
try:
yield cursor
except Exception as e:
ApiLogger().error(error_obj=e)
raise e
finally:
cursor.close()
def __get_conn(self) -> SnowflakeConnection:
if self._conn is not None:
return self._conn
self._conn = snowflake.connector.connect(
user=SNOWFLAKE_USER,
password=SNOWFLAKE_PASSWORD,
account=SNOWFLAKE_ACCOUNT,
warehouse=SNOWFLAKE_WH,
database=SNOWFLAKE_DB,
schema=SNOWFLAKE_SCHEMA,
)
return self._conn
core
# core/config
import os
import yaml
from pydantic import BaseSettings, HttpUrl
# ref: https://qiita.com/ninomiyt/items/ee676d7f9b780b1d44e8
class Settings(BaseSettings):
OPEN_AI_API_KEY: str
SENTRY_DSN: HttpUrl | None
ENV: str | None = None
class Config:
case_sensitive = True
# YAMLをロードする
# https://gist.github.com/ericvenarusso/dcaefd5495230a33ef2eb2bdca262011
def read_yaml(file_path: str) -> Settings:
with open(file_path) as stream:
config = yaml.safe_load(stream)
return Settings(**config)
env = os.environ["ENV"]
settings = read_yaml(f"{os.getcwd()}/core/yaml_configs/{env}.yaml")
container_config
class RepositoryModule(Module):
def configure(self, binder: Binder) -> None:
binder.bind(IMeaningCacheRepo, to=impl.MeaningDynamodbRepositoryImpl)
binder.bind(IMeaningOriginRepo, to=impl.MeaningOpenAIRepositoryImpl)
class DIContainer(Singleton):
MODULES = [RepositoryModule]
injector: Injector = Injector(MODULES)
# container_config.get()に引数を渡すと依存関係を解決してインスタンスを生成する
def resolve(self, cls: object) -> object:
return self.injector.get(cls)
# endpoints/english_words.py
@router.get("/generate-meaning", response_model=MeaningGetResponse)
def get_meaning(word: str) -> MeaningGetResponse:
usecase = DIContainer().resolve(GetMeaningUseCase)
response = usecase.handle(meaning_get=MeaningGet(english_word=word)) # type: ignore
return response
exception
参考にしたもの
まとめ