肉球でキーボード

MLエンジニアの技術ブログです

「現場で役立つシステム設計の原則」サンプルコードをPythonで書く

はじめに

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法~
Javaで書かれたサンプルコードをPythonで書いてみました。

本書はシステム設計の名著として有名で、コード・システムを綺麗に維持し続けるためのテクニックが、オブジェクト指向プログラミングの思想に基づいてまとめられています。

本記事では具体的なコードの書き方に関する部分だけ扱い、Pythonで書く場合はどうすればいいのかに注目します。
本書の中では実際の現場での設計方法や、コード設計の背景にあるドメインモデルなどの概念も紹介されていますが、本記事では触れません。
より設計について学びたい方は、本書を読むことをお勧めします。

本文中コード:https://github.com/nsakki55/system-architecture-principles-python

1章

点在してるロジックを1つのクラスにまとめる

「送料を計算する」ような、複数の箇所で呼び出される計算は、ロジックがあちこちに点在しがちです。
共通のロジックの置き場として「送料」クラスを作成することで、コードの再利用性が増し、コードの複製や重複を防ぐことができます。

class ShippingCost:
    minimum_for_free: int = 3000
    cost: int = 500

    def __init__(self, base_price: int) -> None:
        self.base_price = base_price

    @property
    def amount(self) -> int:
        if self.base_price < self.minimum_for_free:
            return self.cost
        return 0


def shipping_cost(base_price: int) -> int:
    cost = ShippingCost(base_price)
    return cost.amount

独自型で値の範囲を制限する

業務アプリケーションでint, floatなどの基本データ型を使用すると、業務の関心とはかけ離れた値を許容する危険性があります。
業務上関心のある値を扱うときは、値を格納するための専用クラス(ドメイン駆動設計における値オブジェクト)を作成することが推奨されてます。

from __future__ import annotations


class Quantity:
    MIN: int = 1
    MAX: int = 100

    def __init__(self, value: int) -> None:
        if value < self.MIN:
            raise ValueError(f"Illegal: value is under {self.MIN}.")
        if value > self.MAX:
            raise ValueError(f"Illegal: value is over {self.MAX}.")

        self.value = value

    def can_add(self, other: Quantity) -> bool:
        added = self._add_value(other)
        return added <= self.MAX

    def add(self, other: Quantity) -> Quantity:
        if not self.can_add(other):
            raise ValueError(f"Illegal: total amount is over {self.MAX}.")
        added: int = self._add_value(other)
        return Quantity(added)

    def _add_value(self, other: Quantity) -> int:
        return self.value + other.value

アノテーションにクラス自身を指定する際の方法はPython3.7前後で異なります。

独自クラス同士の数値演算を実装する方法は後述します。

独自型を受け取るようにする

「金額」や「数量」などの業務上の値を、呼び出し先の関数で基本データ型(int ,floatなど)で受け取ると、想定外の値を受けとる危険があります。
独自型を引数に渡すことで、値を制限し、コードの意図を分かりやすくすることができます。

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Quantity:
    value: int
    discount: int

    @property
    def is_discountable(self) -> bool:
        return True


@dataclass(frozen=True)
class Money:
    value: int

    def multiple(self, multiple_value: int) -> Money:
        return Money(self.value * multiple_value)


def discount(unit_price: Money, quantity: Quantity) -> Money:
    discount_value = unit_price.value - quantity.discount
    return Money(discount_value)


def amount(unit_price: Money, quantity: Quantity) -> Money:
    if quantity.is_discountable:
        discount(unit_price, quantity)

    return unit_price.multiple(quantity.value)

Pythonで独自型(値オブジェクト)を作成する場合、dataclassを使用します。

コレクションオブジェクト(ファーストクラスコレクション)を扱う

複数の要素を持つコレクション型を扱うコードは複雑になります。

  • for文などのループ処理
  • コレクションの要素数の変化
  • コレクションの要素の変化
  • ゼロ件の場合の処理
  • 素数の制限

コレクション型のデータを1つだけ持ち、データ操作をまとめたクラスをコレクションオブジェクト・ファーストクラスコレクションと言います。コレクションオブジェクトによりコレクション型への操作の記述がコードのあちこちに散らばらず、ロジックを一箇所に集約することができます。

from __future__ import annotations

from dataclasses import dataclass
from typing import List, Tuple


@dataclass(frozen=True)
class Customer:
    id: int


@dataclass(frozen=True)
class Customers:
    customers: List[Customer]

    def add(self, customer: Customer) -> Customers:
        return Customers(self.customers.append(customer))

    def as_list(self) -> Tuple[Customer]:
        return tuple(self.customers)

コレクションを外部から参照する際は、immutableなTuple型で取得するようにします。

2章

判断や処理のロジックをメソッドに独立させる

if文の書かれた判断条件や、計算式をメソッドに抽出することで、変更箇所と影響範囲を特定のメソッドに閉じ込めることができます。
場合分けのコードを整理するために、else区を使用せずに早期にリターンする書き方をガード節と呼びます。ガード節を使用することでif文同士が疎結合になり、コードの変更を楽にできます。

from dataclasses import dataclass


@dataclass(frozen=True)
class Yen(object):
    value: float


class Customer:
    base_fee: Yen = Yen(100)

    def __init__(self, customer_type: str) -> None:
        self.customer_type = customer_type

    def fee(self) -> Yen:
        if self._is_child():
            return self._child_fee
        if self._is_senior():
            return self._senior_fee

        return self.base_fee

    @property
    def _child_fee(self) -> Yen:
        return Yen(self.base_fee.value * 0.5)

    @property
    def _senior_fee(self) -> Yen:
        return Yen(self.base_fee.value * 0.8)

    def _is_child(self) -> bool:
        return self.customer_type == "child"

    def _is_adult(self) -> bool:
        return self.customer_type == "adult"

    def _is_senior(self) -> bool:
        return self.customer_type == "senior"

Javaサンプルコードでは if return を1行で書いていますが、PEP8では複合文(一行に複数の文を入れること)は推奨されていません。

インターフェイスを使って異なるクラスを同じ型として扱う

「大人料金」「子供料金」のように、区分ごとにクラスを作成することで、区分ごとのロジックが整理されてどこに何が書かれているか分かりやすくなります。
区分ごとのクラスを共通のインターフェイスを継承し、区分ごとに異なるクラスオブジェクトを「同じ型」として扱う仕組みを多態(ポリモーフィズム)と呼びます。

インターフェイスを使用するコード。

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List


@dataclass(frozen=True)
class Yen(object):
    value: int

    def __add__(self, other: Yen) -> Yen:
        return Yen(self.value + other.value)


class Fee(ABC):
    @property
    @abstractmethod
    def yen(self) -> Yen:
        pass

    @property
    @abstractmethod
    def label(self) -> str:
        pass


class AdultFee(Fee):
    _yen: Yen = Yen(100)
    _label: str = "adult"

    @property
    def yen(self) -> Yen:
        return self._yen

    @property
    def label(self):
        return self._label


class ChildFee(Fee):
    _yen: Yen = Yen(50)
    _label: str = "child"

    @property
    def yen(self) -> Yen:
        return self._yen

    @property
    def label(self):
        return self._label

使用する側のコード

class Charge:
    def __init__(self, fee: Fee):
        self.fee = fee

    @property
    def yen(self):
        return self.fee.yen


class Reservation:
    def __init__(self, fees: List[Fee]):
        self.fees = fees

    def add_fee(self, fee: Fee) -> None:
        return self.fees.append(fee)

    @property
    def fee_total(self) -> Yen:
        total: Yen = Yen(0)
        for each in self.fees:
            total += each.yen
        return total

Pythonインターフェイスを実装する場合、ABC基底クラスを継承します。

クラス変数を不変とするためにpropertyとabstractmethodを組み合わせています。
呼び出し側ではインターフェイスの型を使用し、区分ごとのクラスに依存しないようにしています。

区分ごとのインスタンスを生成するサブクラスを作る

多態は利用する側のコードをシンプルにしますが、区分ごとのインスタンスを生成するときはif文で場合分けを書くことになりがちです。
Mapを使用すると、区分ごとのクラスのインスタンスをif文で場合分けせずに取得できます。区分の名前をキー、区分ごとのインスタンスを値として保持することで取得します。インスタンスを作成するためのサブクラスを作成する方法をFactory Methodパターンと呼びます。

class FeeFactory:
    types: Dict[str, Fee] = {"adult": AdultFee(), "child": ChildFee()}

    @classmethod
    def fee_by_name(cls, name: str) -> Fee:
        return cls.types.get(name)

区分名をキー、区分ごとのインスタンスを値とする辞書型を持つクラスを作成します。

列挙型でif文/switch文をなくす

列挙型を使って、区分ごとのロジックを整理する方法を区分オブジェクトと呼びます。
区分オブジェクトを使用することで、区分ごとのif文/swtich文で複雑になりやすいコードを整理することができます。

from enum import Enum


class FeeType(Enum):
    adult = AdultFee()
    child = ChildFee()
    senior = SeniorFee()

    @property
    def yen(self) -> Yen:
        return self.value.yen

    @property
    def label(self) -> str:
        return self.value.label


def fee_for(fee_type_name: str) -> Yen:
    return FeeType[fee_type_name].yen

Pythonで列挙型を使用するにはEnumクラスを使用します。

列挙型のメンバー値は self.value として、クラス内部で使用することができます。

状態の遷移ルールを列挙型で記述する

列挙型とMapを組み合わせることである状態から遷移可能な状態を宣言的に記述できます。
この方法で状態遷移のロジック判定にif文/switch文を使用せずに済みます。

from enum import Enum, auto
from typing import Dict, Set


class State(Enum):
    EXAMINATION = auto()  # 審査中
    APPROVED = auto()  # 承認済
    GOING = auto()  # 実施中
    END = auto()  # 終了
    REMAND = auto()  # 差し戻し中
    TERMINATION = auto()  # 中断中


class StateTransitions:
    allowed: Dict[State, Set[State]] = {
        State.EXAMINATION: {State.APPROVED, State.REMAND},
        State.REMAND: {State.EXAMINATION, State.END},
        State.APPROVED: {State.GOING, State.END},
        State.GOING: {State.TERMINATION, State.END},
        State.TERMINATION: {State.GOING, State.END},
    }

    @classmethod
    def can_transit(cls, from_state: State, to_state: State) -> bool:
        allowed_state = cls.allowed.get(from_state)
        return to_state in allowed_state

列挙型の状態遷移を記述した辞書型をインスタンス変数に持つクラスを作成します。
遷移前と遷移後を渡して、遷移可能か判定するクラスメソッドを用意します。

4章

業務ルールをまとめたコレクションオブジェクトを作る

業務ルールをまとめたクラスを作成することで、コードの重複や点在を防ぐ方法を方針(Policy)パターンと本文中で紹介されています。
1つのルールごとにRuleインターフェイスを持ったオブジェクトを作り、ルールに対する判定をPolicyクラスで行います。

from abc import ABC
from dataclasses import dataclass
from typing import Set


@dataclass(frozen=True)
class Value:
    number: int


@dataclass(frozen=True)
class Rule(ABC):
    def ok(self, value: Value) -> bool:
        pass

    def ng(self, value: Value) -> bool:
        pass


@dataclass(frozen=True)
class PositiveNumberRule(Rule):
    def ok(self, value: Value) -> bool:
        return value.number % 2 == 0

    def ng(self, value: Value) -> bool:
        return value.number % 2 == 1


class Policy:
    def __init__(self):
        self.rules: Set[Rule] = set()

    def comply_with_all(self, value: Value) -> bool:
        for each in self.rules:
            if each.ng(value):
                return False
        return True

    def comply_with_some(self, value: Value) -> bool:
        for each in self.rules:
            if each.ok(value):
                return True

    def add_rule(self, rule: Rule) -> None:
        self.rules.add(rule)

5章

登録・参照サービスを分離する

業務ロジックの処理をまとめたサービスクラスを小さく分けることが推奨されています。登録系のサービスと参照系のサービスに分けることが、サービスクラスを小さく分ける基本と本文中で説明されています。
データソース層(Repository)を抽象化し、呼び出し側はデータソース層内部の実装に依存しないようにする方法をRepositoryパターンと呼びます。

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any, Dict


class Amount:
    MAX: int = 100

    def __init__(self, value: int) -> None:
        self.value = value

    def __sub__(self, other) -> Amount:
        return self.value - other.value

    def has(self, amount: Amount) -> bool:
        return self.value + amount.value < self.MAX


class IBankAccountRepository(ABC):
    @abstractmethod
    def balance(self) -> Amount:
        pass

    @abstractmethod
    def withdraw(self, amount: Amount) -> bool:
        pass


class BankAccountRepositoryImpl(IBankAccountRepository):
    def __init__(self, amount: Amount) -> None:
        self.total_amount = amount

    def balance(self) -> Amount:
        return self.total_amount

    def withdraw(self, amount: Amount):
        self.total_amount - amount


class BankAccountService:
    def __init__(self, repository: IBankAccountRepository) -> None:
        self.repository = repository

    def balance(self) -> Amount:
        return self.repository.balance()

    def can_withdraw(self, amount: Amount) -> bool:
        balance: Amount = self.balance()
        return balance.has(amount)


class BankAccountUpdateService:
    def __init__(self, repository: IBankAccountRepository) -> None:
        self.repository = repository

    def withdraw(self, amount: Amount) -> None:
        self.repository.withdraw(amount)

Repositoryの抽象クラスを作成し、実装先のクラスで継承します。 サービスクラスでRepository抽象クラスの型を指定をすることで、具体的なRepositoryの実装に依存しないようにしています。 サービスクラスのインスタンス変数にRepositoryを渡すことで、具体的な実装を注入するようにしました。

プレゼンテーション層のコントローラーからサービスを組み合わせる

class Model:
    def __init__(self) -> None:
        self.attribute: Dict[str, Any] = {}

    def add_attribute(self, name: str, attribute: Any):
        self.attribute[name] = attribute


class BankAccountController:
    query_service = BankAccountService(BankAccountRepositoryImpl(amount=Amount(50)))
    update_service = BankAccountUpdateService(BankAccountRepositoryImpl(amount=Amount(50)))

    def withdraw(self, amount: Amount, model: Model):
        if not self.query_service.can_withdraw(amount):
            return "insufficient funds page"
        self.update_service.withdraw(amount)
        balance: Amount = self.query_service.balance()
        model.add_attribute("balance", balance)
        return "withdrawal completed page"

ControllerでServiceインスタンスを作成するときに、Repositoryの実装クラスを渡しています。

7章

アプリケーション画面とドメインオブジェクトを対応させる

アプリケーション画面の項目と、ドメインオブジェクトの要素を対応させることで、利用者の関心事とコードを一致させることができます。
ドメインオブジェクトの要素の型に、基本データ型ではなく自作型を指定します。

from dataclasses import dataclass
from datetime import datetime


@dataclass(frozen=True)
class Title:
    value: str


@dataclass(frozen=True)
class Price:
    value: int


@dataclass(frozen=True)
class LocalDate:
    value: datetime


@dataclass(frozen=True)
class Author:
    value: str


@dataclass(frozen=True)
class BookType:
    value: str


@dataclass(frozen=True)
class BookSummary:
    title: Title
    unit_price: Price
    published: LocalDate

    author: Author
    type: BookType

まとめ

「現場で役立つシステム設計の原則」のサンプルコードをJavaからPythonに書き直しました。
本文中のコードに関する部分のみに触れたため、背後にある思想や、システム設計の方法を詳しく学びたい方は実際に読んでみることをお勧めします。

本文中コード:https://github.com/nsakki55/system-architecture-principles-python

参考