はじめに
現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法~
の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を使用します。
コレクションオブジェクト(ファーストクラスコレクション)を扱う
複数の要素を持つコレクション型を扱うコードは複雑になります。
コレクション型のデータを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