33
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PythonでLayered ArchitectureとClean Architectureを使ってカレーと肉じゃがを作ってみた

Last updated at Posted at 2022-07-06

概要

突然ですがClean Architectureについて皆さん理解できていますか?
この記事を書くまで僕の理解度はこれくらいでした。

「Clean Architecture?ああ、有名なドーナツの図のアレだよね。」
layered_architecture.png
(ref: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

Clean Architectureに関する記事をネットで調べると正直上記のような「有名なドーナツの図のアレ。」と書いてあるだけの記事も散見します。

そこでこの記事ではClean Architectureを実際のコードの例を用いてじっくり解説していこうと思います!
流れとしてLayered Architectureでまずは作成し、SOLID原則を用いてClean Architectureに移行していく過程を通じてClean Architectureの理解を深めようといった内容になっております。

今回は作りたいプロダクトとして、カレーと肉じゃがを作れる(擬似)スクリプトを作成していこうかと思います!

作りたいもの

  • 作り方
    • 共通: 野菜・肉を切る→フライパンで炒める→水を沸かす→炒めた野菜・肉を入れる
    • カレーの場合: カレー粉を入れる
    • 肉じゃがの場合: 醤油とお酒を入れる
  • 選べるオプション: 何を作るか(カレー or 肉じゃが)
  • アウトプット: 作った料理のカロリー、料理名

Layered Architectureで作る

Layered Architectureとは?

Layered Architectureとはアプリケーションを階層化し、依存関係を上から下の層のみにしてコードを設計するアーキテクチャー(設計思想)です。

これは幅広いフレームワークでサポートされている設計方針であり、MVCやMVVMといったアーキテクチャーもLayered Architectureの仲間です。

様々なLayered Architectureがありますが今回は「ドメイン駆動設計(DDD)」と呼ばれる、2003年にエリック・エヴァンス氏が『Domain-driven design』という書籍にて提唱したソフトウェア開発手法に基づいたLayered Architectureを採用して、上記の図のように4層に分けます。

4層の役割は以下の通りです。

  • Presentation
    • 表示部分の担当: 作った料理のカロリー、料理名を表示する
  • Application
    • 指示担当: 他のレイヤーを呼び出して料理を作るための処理を順番に呼ぶ
  • Domain
    • ドメイン担当: 料理の作り方、カロリーの計算方法などビジナスロジックに紐づいた箇所を担当
  • Infrastructure
    • データ取得担当: 料理を作るのに必要なデータを取得してくる

それでは実際にコードを書いていきましょう!

まずは依存関係の一番下の部分である、「Infrastructure」のレイヤーから実装していきます。

Infrastructure

今回はデータをdictで保持していると仮定します。以下のようなデータです。

data = {"food_name": "onion", "food_calorie": 20}

このデータを取得する Infrastructureクラスを実装していきます。

infrastructure.py
# 食材の種別
class FoodType(enum.Enum):
    seasoning = "seasoning"
    ingredient = "ingredient"


# 食材データのデータ構造
@dataclass()
class FoodData:
    name: str
    calorie: int
    type: FoodType


class Infrastructure:
    @staticmethod
    def get_onion() -> FoodData:
        data = {"food_name": "onion", "food_calorie": 20}
        return FoodData(
            name=data["food_name"],
            calorie=data["food_calorie"],
            type=FoodType.ingredient,
        )
    ...

食材データとしては食材名(name)、カロリー(calorie)、種別(type: 調味料もしくは食材)を定義しておきます。

次にこのLayerを用いて実際に料理をしていくDomain Layerを作成していきます。

Domain

ここの層で作成したいのは料理を作るためのデータ構造やビジネスロジックをコードで表現することです。
まずは作成する料理を定義したデータ構造を定義してみましょう。

domain.py
# 食材データクラス
@dataclass()
class Ingredient:
    name: str
    calorie: int
    is_cut: bool = False
    is_cooked: bool = False


# 調味料データクラス
@dataclass()
class Seasoning:
    name: str
    calorie: int
    is_added: bool = False


# カレーデータクラス
@dataclass()
class Curry:
    name = "curry"
    calorie: int
    ingredints: List[Ingredient]
    seasonings: List[Seasoning]


# 肉じゃがデータクラス
@dataclass()
class Nikujyaga:
    name = "nikujyaga"
    calorie: int
    ingredints: List[Ingredient]
    seasonings: List[Seasoning]

食材(野菜・肉)は切って炒める必要があるので、is_cutis_cooked といったattributeを持っており、調味料(醤油・酒・カレー粉)は最後に加える必要があるのでis_addedといったattributeを持っています。

CurryNikujyagaclassはそれぞれ料理名(name)、合計カロリー(calorie)、使った食材(ingredients)、使った調味料(seasonings)を保持しております。

次に先ほど作成したInfrastructure Layerを用いて食材情報を取得してくるclass、DataFetcherclassを定義していきます。

domain.py
from layered_architecture.infrastructure import FoodData, FoodType, Infrastructure


# 選択可能なメニュー
class MenuOption(enum.Enum):
    curry = "curry"
    nikujyaga = "nikujyaga"


# データ取得クラス
@dataclass()
class DataFetcher:
    infrastructure: Infrastructure = Infrastructure()
    ingredients: List[Ingredient] = field(default_factory=list)
    seasonings: List[Seasoning] = field(default_factory=list)

    # 呼び出し関数
    def fetch(self, menu: MenuOption) -> Tuple[List[Ingredient], List[Seasoning]]:
        if menu == MenuOption.curry:
            self._fetch_curry_ingredient()
        elif menu == MenuOption.nikujyaga:
            self._fetch_nikujyaga_ingredient()
        else:
            raise ValueError(f"Unsupported Menu {menu}")
        return self.ingredients, self.seasonings

    # カレーデータ取得
    def _fetch_curry_ingredient(self):
        self._fetch_common_ingredient()
        self._fetch_data(self.infrastructure.get_curry_powder)

    # 肉じゃがデータ取得
    def _fetch_nikujyaga_ingredient(self):
        self._fetch_common_ingredient()
        self._fetch_data(self.infrastructure.get_sake)
        self._fetch_data(self.infrastructure.get_soy_sauce)

    # 共通データ取得
    def _fetch_common_ingredient(self):
        self._fetch_data(self.infrastructure.get_carrot)
        self._fetch_data(self.infrastructure.get_potato)
        self._fetch_data(self.infrastructure.get_onion)
        self._fetch_data(self.infrastructure.get_chicken)

    # データ取得共通処理
    def _fetch_data(self, get_func: Callable[[], FoodData]):
        data = get_func()
        if data.type == FoodType.ingredient:
            self.ingredients.append(Ingredient(name=data.name, calorie=data.calorie))
        elif data.type == FoodType.seasoning:
            self.seasonings.append(Seasoning(name=data.name, calorie=data.calorie))
        else:
            raise ValueError(f"Invalid food_type of {data.type}")

DataFetcherクラスでは選択したメニュー(MenuOption)に応じて必要なデータを取得してくる処理を書きます。
publicな関数であるfetchが呼ばれると料理に応じた食材と調味料データをTupleで返却します。

次に実際に食材を調理していくChefクラスを定義していきます!

domain.py
@dataclass()
class Chef:
    is_water_boiled: bool = False

    # 料理をする
    def cook_menu(
        self,
        ingredients: List[Ingredient],
        seasonings: List[Seasoning],
        menu: MenuOption,
    ):
        self._cut_ingredient(ingredients)
        self._cook_ingredient(ingredients)
        self._boil_water()
        self._add_seasoning(seasonings)
        total_calorie = self._calc_total_calorie(ingredients, seasonings)

        if menu == MenuOption.curry:
            return Curry(
                calorie=total_calorie, ingredints=ingredients, seasonings=seasonings
            )
        elif menu == MenuOption.nikujyaga:
            return Nikujyaga(
                calorie=total_calorie, ingredints=ingredients, seasonings=seasonings
            )
        else:
            raise ValueError(f"Unsupported Menu: {menu}")

    # 食材を切る
    @staticmethod
    def _cut_ingredient(ingredients: List[Ingredient]):
        for ingredient in ingredients:
            ingredient.is_cut = True

    # 食材を炒める
    @staticmethod
    def _cook_ingredient(ingredients: List[Ingredient]):
        for ingredient in ingredients:
            ingredient.is_cooked = True

    # 調味料を加える
    @staticmethod
    def _add_seasoning(seasonings: List[Seasoning]):
        for seasoning in seasonings:
            seasoning.is_added = True

    # カロリーを計算する
    @staticmethod
    def _calc_total_calorie(ingredients, seasonings):
        return sum([i.calorie for i in ingredients + seasonings])

    # 水を加える
    def _boil_water(self):
        self.is_water_boiled = True

Chefクラスは料理と調味料を受け取り、「野菜・肉を切る→フライパンで炒める→水を沸かす→炒めた野菜・肉を入れる→料理に合った調味料を入れる」調理の流れを一連の順番で行い、選択された料理MenuOptionに応じてCurryまたはNikujyagaオブジェクトを返します。

これで1.「料理に応じたデータ取得」、2.「料理を行う」クラスの実装が完了いたしました。

次に実際にこれらのクラスを呼び出すApplicationレイヤーを作成します!

Application

application.py
from dataclasses import dataclass

from layered_architecture.domain import Chef, DataFetcher, MenuOption
from layered_architecture.infrastructure import Infrastructure


@dataclass()
class CreateMenuApplication:
    def handle(self, menu_input: str):
        if menu_input == MenuOption.curry.value:
            menu = MenuOption.curry
        elif menu_input == MenuOption.nikujyaga.value:
            menu = MenuOption.nikujyaga
        else:
            raise ValueError(f"invalid menu: {menu_input}")

        ingredients, seasonings = DataFetcher().fetch(menu)
        return Chef().cook_menu(
            ingredients=ingredients, seasonings=seasonings, menu=menu
        )

このapplicationレイヤーではmenu_inputを文字列で受け取り、先ほど作成したDataFetcherクラスでデータを取得、Chefクラスに渡しできた料理を返却します。

ポイントはこのレイヤーでは具体的な処理は一切書かず、Domainレイヤーで作成した処理を呼び出すことに徹することです。

そして最後に表示を担当するPresentationレイヤーを作成していきます!

Presentation

presentation.py
from layered_architecture.application import CreateMenuApplication


@dataclass()
class CreateMenuPresentation:
    application: CreateMenuApplication = CreateMenuApplication()

    def show_result(self, menu_input: str):
        result = self.application.handle(menu_input)
        print(f"menu: {result.name} is cooked!")
        print(f"calorie: {result.calorie}")

Presentationレイヤーでは表示に関わる処理を書いていくので今回の要件である「作った料理のカロリー、料理名を表示する」処理を書いていきます。

applicationレイヤーを実行することで出力結果が変わるのでapplicationに依存があります。

MVCフレームワークでよくみられるパターンだと、
1.ユーザーがエンドポイントにアクセス→2.モデルで具体的な処理を実行→3.結果をViewに反映させる
といった内容になるかと思います。3が今回のPresentationレイヤーと同じ役割を果たしています。

最後にこの一連の処理を呼び出すmain.pyを作成して実際に実行してみましょう!

main.py
import argparse

from layered_architecture.presentation import CreateMenuPresentation

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--menu", choices=["curry", "nikujyaga"])
    args = parser.parse_args()
    CreateMenuPresentation().show_result(args.menu)

argparseを用いて以下のようなコマンドで料理を選べるようにします。

$ python -m layered_architecture.main --menu "curry" 

このコマンドを実行すると無事以下のような結果を取得できました!

menu: curry is cooked!
calorie: 210

これでLayered Architectureでのプログラムが完成いたしました。

Layered Architectureの問題点

完成したアーキテクチャー

上記のアーキテクチャーで特段問題なさそうに見えますが実は以下のような問題がアーキテクチャーとしてあります。

  1. 依存関係が正しくない
  2. 一つのレイヤーが複数責務を持ってしまっている。

この2点をもとに上記アーキテクチャーを修正していくことにします。

Layered Architectureを修正していく

DomainとInfrastructureの依存関係を逆転

まずはDomainと Infrastructureに注目していきます。

Layered ArchitectureだとDomainがInfrastructureに依存があるため変更の際影響を受けます。

Domainはビジネスモデルなので特定のデータの値に依存すべきではありません。

こちらの問題を解消していくためにまずはSOLID原則の一つ依存性逆転の原則(Dependency Inversion)を用いてDomainとInfrastructureの依存関係を逆転していきます。

もしSOLID原則についておさらいしたい人がいましたらこちらの記事が分かりやすくてとても参考になりました。

Domainの依存をInfrastructureではなくDomainが利用したいInfrastructureのインターフェイスを別途定義します。

このような具体的な永続化を隠蔽するためのデザインパターンをRepositoryパターンと呼ぶのでInfrastructureをRepositoryにRenameします。

i_repository.py
from abc import ABC, abstractmethod

from clean_architecture.entity import Ingredient, Seasoning


class IRepository(ABC):
    @abstractmethod
    def get_onion() -> Ingredient:
        pass

このRepositoryの抽象クラスを継承したRepositoryクラスは以下のようになります。

repository.py
from clean_architecture.entity import Ingredient, Seasoning
from clean_architecture.i_repository import IRepository


class Repository(IRepository):
    @staticmethod
    def get_onion() -> Ingredient:
        data = {"food_name": "onion", "food_calorie": 20}
        return Ingredient(name=data["food_name"], calorie=data["food_calorie"])

I_RepositoryはDomainのために定義したinterfaceなどでRepositoryがI_Repositoryを継承することでRepository→IRepositoryの依存が発生し依存関係が以下のように逆になりました!
(継承の線は点線で表すことにします)

Domainの責務を分離

次にDomainレイヤーに目を向けます。

Domainレイヤーは上記の図だと二つの責務が存在しています。

  1. Repositoryからデータを取得
  2. ビジネスロジックを定義、実行

責務を分けるために「1. Repositoryからデータを取得」を担当するUseCase Interactorレイヤーと「2. ビジネスロジックを定義、実行」を担当するEntityレイヤーに分けます。

なので Chefクラスや食材モデルなどがEntityレイヤー、DataFetcherがUseCaseInteractorレイヤーに移動します。

entity.py
@dataclass()
class Ingredient:
    name: str
    calorie: int
    is_cut: bool = False
    is_cooked: bool = False

@dataclass()
class Chef:
    is_water_boiled: bool = False

    # 料理をする
    def cook_menu(
        self,
        ingredients: List[Ingredient],
        seasonings: List[Seasoning],
        menu: MenuOption,
    ):
    ...
usecase_interactor.py
@dataclass()
class CreateMenuInteractor(ICreateMenuUseCase):
    repository: IRepository
    presenter: ICreateMenuPresenter

    def handle(self, input_data: CreateMenuInputData):
        ingredients, seasonings = DataFetcher(self.repository).fetch(input_data)
        return Chef().cook_menu(
            ingredients=ingredients, seasonings=seasonings, input_data=input_data
        )

Presentationの責務を分離

次にPresentation(UI部分)に目を向けます。

このPresentation、表示だけを担当としているのですが実際には

  1. 結果を出力する
  2. ユーザー入力値を受け取る
  3. 表示のためのデータを受け取る

という3つの役割を担ってしまっています。ここではPresentationの役割を「出力結果を受け取るレイヤー」と定義してそれ以外の機能をSOLID原則のS: 単一責任の原則 (SRP:Single Responsibility Principle)をもとに分割していきます。

まず「1. 結果を出力する」ですが、Viewレイヤーを作成しここに「結果を出力する」役割を譲ることで責務を分割していきます。

次に「2. ユーザー入力値を受け取る」ですが、これは指示役として振る舞うApplicationレイヤーにお願いするのが正しそうです。
ですが結果をViewに反映するには、ApplicationレイヤーをcallしてViewが変更されるような仕組みを作る必要があります。

そこでViewModelといったレイヤーを取り入れApplicationの実行結果をもとにViewを変更できるよう作り替えます。

このように変更することで今までPresentation→Application→Domain→Application→Presentationとなっていたことで各レイヤーに責務が複数存在していたのを、Application→UseCaseInteractor→Presentation→ViewModelと一方向にすることで各レイヤーの責務を切り分けしやすくなりました。

ViewModelはView表示のためのデータを保持するレイヤーで、変更されたことをViewに伝えるために今回はSingletonを用いて作成しました。

view_model.py
class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]


@dataclass()
class CreateMenuViewModel(metaclass=Singleton):
    result_menu_name: str = ""
    result_calorie: int = 0

Singletonを使うことでViewModelはグローバルステートとなり、Applicationの実行結果をViewを直接呼んで参照できるようになります。

このようにしたことでPresentationレイヤーの役割は「3. 出力結果のデータを受け取る」のみになりました!

UseCaseInteractorの依存関係を考える

ここでUseCaseInteractorの依存関係を見てみましょう。

UseCaseInteractorはPresentationの変更の影響を受けるべきではないのでこちらも依存関係逆転の法則を用い、UseCaseInteractor用のPresentationインターフェイスを定義して依存の向きを逆転します。

またApplication→UseCaseInteractorへの入力データの受け渡しを入力データの構造を定義して行います。
そうすることで具体的なApplicationから渡されるinput_dataをUseCaseInteractorは意識しなくて良くなります。
同様にoutput_dataについても行いPresentationはUsecaseInteractorから返却される値を意識することなく実装できます。

ApplicationにはUseCaseInteractorを呼ぶ処理が必要なのでここもUsecaseInteractorを直接参照するのではなく、インターフェイスを定義することでApplicationはUseCaseInteractorの中身を意識することなく実装できます。

コードは以下のようになります。

application.py
@dataclass()
class CreateMenuApplication:
    create_menu_usecase: ICreateMenuUseCase

    def create_menu(self, input_menu: str):
        input_data = CreateMenuInputDataSeries(input_menu)
        self.create_menu_usecase.handle(input_data)
input_data_series.py
@dataclass()
class CreateMenuInputDataSeries:
    menu: str
i_usecase.py
from clean_architecture.input_data_series import CreateMenuInputData


class ICreateMenuUseCase(ABC):
    @abstractmethod
    def handle(self, input_data: CreateMenuInputData):
        pass
interactor.py
@dataclass()
class CreateMenuInteractor(ICreateMenuUseCase):
    repository: IRepository
    presenter: ICreateMenuPresenter

    def handle(self, input_data: CreateMenuInputData):
        ingredients, seasonings = DataFetcher(self.repository).fetch(input_data)
        menu = Chef().cook_menu(
            ingredients=ingredients, seasonings=seasonings, input_data=input_data
        )

        output_data = CreateMenuOutputData(menu_name=menu.name, calorie=menu.calorie)
        self.presenter.complete(output_data)
output_data_series.py
@dataclass()
class CreateMenuOutputDataSeries:
    menu_name: str
    calorie: int
presenter.py
class CreateMenuPresenter(ICreateMenuPresenter):
    @staticmethod
    def complete(output_data: CreateMenuOutputDataSeries):
        menu_name = output_data.menu_name
        calorie = output_data.calorie
        CreateMenuViewModel(result_menu_name=menu_name, result_calorie=calorie)

Clean Architectureの図と比較してみる

これで完成しました!SOLIDを適応して各レイヤーの責務が一つになり、依存関係も正しく設計できたかと思います。

処理の流れを見ると

  1. Applicationに情報が入力
  2. InputDataSeriesを介してUseCaseInteractorに情報を受け渡す
  3. UseCaseInteractorがビジネスロジックを実行
  4. OutputDataSeriesを介してPresenterに情報を流す
  5. PresenterがViewModelを更新
  6. Viewが更新される

といった流れになっております。

作成された図と処理の流れをClean Architectureの図と比較してみます。

layered_architecture.png layered_architecture.png

(ref: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)

どうでしょうか!
処理の流れは一枚目の画像の「Flow of Control」に沿ったものなり、依存関係は二枚目の画像の通りになりました。

でも、こんなことしてClean Architectureのメリットは?

Clean Architecture実装してみるとかなり大変だったかと思います。
なんでわざわざこんなに大変な分割をするんだろう、3層レイヤードアーキテクチャーくらいが自分にはちょうどいいかな。。と思っている方もいるかもしれません。

ですがClean Architectureの真価は

  1. 簡単にDIができる
  2. 開放閉鎖の原則 (OCP:Open/Closed Principle)に基づいているためコードの修正が他のレイヤーに影響を与えない

という2点だと思っております。

簡単にDIができる。

DIコンテナ(依存関係を定義したクラス)を作成することにより簡単に各レイヤーを差し替えることができます。

pythonではこちらのパッケージを使用しました。

使い方は簡単です、まずはDIコンテナクラスDependencyを定義します。

dependency.py
from injector import Injector, Module


class Dependency(Module):
    def __init__(self) -> None:
        self.injector = Injector(self.__class__.config)

    @classmethod
    def config(cls, binder):
        binder.bind(ICreateMenuPresenter, to=CreateMenuPresenter)
        binder.bind(IRepository, to=Repository)
        binder.bind(ICreateMenuUseCase, to=CreateMenuInteractor)

    def resolve(self, cls):
        return self.injector.get(cls)

そしてDIしたいクラスに@injectデコレータを付与します。

interactor.py
@inject
@dataclass()
class CreateMenuInteractor(ICreateMenuUseCase):
    repository: IRepository
    presenter: ICreateMenuPresenter

    def handle(self, input_data: CreateMenuInputData):

そしてDIコンテナを介し処理を呼ぶことでDIコンテナで紐付けたクラスが呼ばれることになります。

main.py
def run(dependency):
    controller = dependency.resolve(CreateMenuController)
    controller.create_menu(args.menu)
    CreateMenuView().show_result()


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--menu", choices=["curry", "nikujyaga"])
    args = parser.parse_args()

    dev_dependency = DevDependency()
    run(dev_dependency)

ですのでRepositoryを差し替えてテスト用Repositoryを使いたい、といった場合は

binder.bind(IRepository, to=TestRepository)

このようにするだけで済みます。
DIコンテナのおかげでテストがしやすくなのはとても大きいメリットですね。

コードの修正が他のレイヤーに影響を与えない

これは開発する上で心理的安全性及び開発速度の観点から非常に重要なポイントかと思います。

例えばカレー、肉じゃがに加えシチューを作りたくなったとします。
その際Clean Architectureに基づき実装するとentityにシチュークラスを定義してシチューに必要な材料をRepository層に定義し、InteractorのDataFetcherにシチュー用の処理を追加するだけで済みます。

Layered Architectureの場合Domainレイヤーに修正を加えたら依存関係があるApplicationとPresentationレイヤーの検証もするしかなく安全性の高いアーキテクチャーとは言えませんね。

pythonの場合コンパイルする必要はないのでメリットを感じづらいかもしれませんが、javaなどのコンパイラ言語だと依存が発生していなければそもそも再コンパイルされません。

Clean Architectureを実装してみた感想

上記の通りClean Architectureにはとても強力なメリットがありますが同時にコード量は膨れ上がります。
作りたいプロダクトに応じて抽象化したい部分(Repositoryなど)からClean Architectureを取り入れていくのがいいのかもしれません。

Pythonでわかりやすく取り上げている記事があまりなかったので書いてみました、質問疑問などどしどしいただけるとありがたいです!

Clean Architectureで皆さんも華麗(:curry:)なエンジニアライフを!

参考

今回使用したコードです。(一部記事と命名が異なる箇所があります)
https://github.com/c0ridrew/python-gof

以下の記事には大変お世話になりました :bow:

33
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?