🏦

『アーキテクトの教科書』第2章 要点まとめ (SOLID原則、アーキテクチャパターン)

に公開

良書『アーキテクトの教科書 価値を生むソフトウェアのアーキテクチャ構築』(ISBN-10:4798184772)を読んだ。
特に「第2章 ソフトウェア設計」はソフトウェア設計の基礎基本が簡潔に載っていて有益だった。
自分にとって役立ちそうな箇所・内容をまとめる。
この本のサンプルコードはJavaだが、私はPython使いのためPythonで書く。
内容もPythonで応用できるか(そもそも応用するべきか)という前提で書く。

2.2 ソフトウェア設計の抽象レベル

四つの抽象レベル

この本では設計を以下四つの抽象レベルに分けている。上に行くほど抽象度が上がる。

  • アーキテクチャ設計
  • モジュール設計
  • コンポーネント設計
  • クラス設計

アーキテクチャ設計

全体構造・基本構造を定める。システム全体の指針や方針を定める。

例:

  • マイクロサービス or モノリス のようなシステム全体の構造
  • レイヤードアーキテクチャのようなアプリケーションの基本構造

モジュール設計

ユースケースの実行に必要な機能を提供するため、
関連するコンポーネントを集めた構造がモジュール。

例: 配下に多くのコンポーネントやクラスが存在するパッケージのツリー構造全体がモジュールのイメージ。

コンポーネント設計

この本における「コンポーネント」の定義は、
「特定の振る舞いを提供する責務を持ち、明確なインターフェースにより定義される。しばしば複数のクラスから成る」。

下記のUMLクラス図の例では、
OrderRepositoryImplはHelperA、HelperBに一部処理を委譲して処理を行う。
このような「クラスの集まり」がコンポーネント。

コンポーネントは「明確なインターフェースにより定義される」ので、置き換えが可能。
上記例で言えば、OrderRepositoryを利用するOrderServiceをテストする際、
OrderRepositoryImplの代わりにOrderRepositoryStubに置き換えることができる。

2.3 ソフトウェアの設計原則とプラクティス

SOLID原則

  • SRP: Single Responsibility Principle
  • OCP: Open-Closed Principle
  • LSP: Liskov Substitution Principle
  • ISP: Interface Segregation Principle
  • DIP: Dependency Inversion Principle

SRP: 単一責任の原則

クラスにはただ一つの責任(役割)を持たせるべきという原則。
難しいのは役割をどの粒度で捉えるか。
極論、クラスが1つの変数とメソッドだけで構成されるまで分割すればよいかというと、そうではない。
ポイントは、クラスを利用するクライアントの視点で考えること。
この利用目的がクラスに求められる役割。

以下が具体的なサンプルコード。

SRP適用前

srp_before.py
import datetime


class WorkRecord:
    """勤怠実績クラス"""

    STANDARD_WORK_HOURS = 8
    BREAK_TIME = 1

    def __init__(
        self,
        is_holiday: bool,
        clock_in: datetime.datetime,
        clock_out: datetime.datetime,
        grade: Grade,
    ):
        self.is_holiday = is_holiday
        self.clock_in = clock_in
        self.clock_out = clock_out
        self.grade = grade

    def calc_overtime_hours(self) -> int:
        """残業時間計算"""
        # 休憩時間を考慮して勤務時間を計算
        total_hours = (self.clock_out - self.clock_in).total_seconds() / 3600
        hours = int(total_hours - self.BREAK_TIME)

        # 休日はすべて残業とする
        if self.is_holiday:
            return hours

        # 標準労働時間より短い場合は残業なし
        if hours <= self.STANDARD_WORK_HOURS:
            return 0

        # 残業時間を計算
        overtime = max(hours - self.STANDARD_WORK_HOURS, 0)
        return overtime

    def calc_overtime_pay(self) -> int:
        """残業代計算"""
        return self.calc_overtime_hours() * self.grade.hourly_rate()

SRP適用前では、WorkRecordクラスは残業時間計算と残業代計算が両方定義され、責任が2つになっている。
このつくりだと、以下2つのユースケースがあった場合、

  • 給与計算のユースケース(残業時間計算と残業代計算の両方を必要とする)
  • 労働環境チェックのユースケース(残業時間計算のみを必要とする)

残業代計算の仕様変更があって、残業代計算のみに必要な情報がコンストラクタ引数に追加されると、
この仕様変更には無関係な、労働環境チェック処理にも影響が波及する可能性がある。

SRP適用後

srp_after.py
class WorkRecord:
    """勤怠実績クラス"""

    # コンストラクタ引数からGradeを削除
    # calc_overtime_payメソッドを削除
    # それ以外は修正なし
    ...


class OvertimePayCalculator:
    """残業代計算クラス"""

    def calc_overtime_pay(self, overtime_hours: int, grade: Grade) -> int:
        """残業代計算"""
        return overtime_hours * grade.hourly_rate()

残業代計算を別クラスに分離した。
このように、SRPにおいては、クラスが利用されるユースケースの観点で考えるとスッキリする。

OCP: オープンクローズド原則

ソフトウェアの構成部品が、拡張に対して開いていて、修正に対して閉じているべきという原則。
既存のコードを修正することなく(Closed)、新たな振る舞いを追加可能(Open)という設計。

OCP適用前

管理職は休日・平日関係なく残業代なしという仕様。

ocp_before.py
import enum

from .srp_after import WorkRecord


class Grade(enum.Enum):
    REGULAR = "regular"
    MANAGER = "manager"

    def hourly_rate(self) -> int:
        match self:
            case Grade.REGULAR:
                return 3000
            case Grade.MANAGER:
                return 5000
            case _:
                raise ValueError("不明なGradeです")


class OvertimePayCalculator:
    """残業代計算クラス"""

    def calc_overtime_pay(
        self,
        work_record: WorkRecord,
        grade: Grade,
    ) -> float:
        match grade:
            case Grade.REGULAR:
                return (
                    work_record.calc_overtime_hours()
                    * grade.hourly_rate()
                    * (1.2 if work_record.is_holiday else 1)
                )  # 休日は2割増
            case Grade.MANAGER:
                return 0  # 管理職は残業代なし

管理職でも休日残業代は支払う、という仕様変更があった場合、
OvertimePayCalculator.calc_overtime_pay() の条件分岐ロジックに修正を加えることになる。
OCPを適用することで、既存コードを修正することなく拡張できる。

OCP適用後

ocp_after.py
from abc import ABC, abstractmethod
from .ocp_before import Grade
from .srp_after import WorkRecord


class OvertimePayPolicy(ABC):
    """残業代ポリシーの基底クラス"""

    @abstractmethod
    def payment_rate(self) -> float:
        ...


class RegularGradeOvertimePolicy(OvertimePayPolicy):
    def __init__(self, is_holiday: bool) -> None:
        self.is_holiday = is_holiday

    def payment_rate(self):
        return 1.2 if self.is_holiday else 1  # 休日は2割増


class ManagerGradeOvertimePolicy(OvertimePayPolicy):
    def __init__(self, is_holiday: bool) -> None:
        self.is_holiday = is_holiday

    def payment_rate(self):
        ####################
        # 仕様変更時、この箇所を修正するだけで済む
        ####################
        return 1 if self.is_holiday else 0  # 管理職でも休日残業は支払われる


class OvertimePayPolicyFactory:
    """残業代ポリシーのファクトリクラス"""

    @staticmethod
    def of(is_holiday: bool, grade: Grade) -> OvertimePayPolicy:
        match grade:
            case Grade.REGULAR:
                return RegularGradeOvertimePolicy(is_holiday)
            case Grade.MANAGER:
                return ManagerGradeOvertimePolicy(is_holiday)


class OvertimePayCalculator:
    """残業代計算クラス"""

    def calc_overtime_pay(
        self,
        work_record: WorkRecord,
        grade: Grade,
    ) -> float:
        policy = OvertimePayPolicyFactory.of(
            is_holiday=work_record.is_holiday,
            grade=grade,
        )
        return (
            work_record.calc_overtime_hours()
            * grade.hourly_rate()
            * policy.payment_rate()
        )

OCPの核心は、安定的なコードと、変更が多い不安定なコードを分離すること。

言い換えると安定的なコードは、不安的な実装に直接依存するべきではない。
OCP適用前のサンプルコードで言うと、calc_overtime_pay()の中で直接match文で条件分岐するべきではない。

安定的なコードは抽象に依存するべきである。
OCP適用後のサンプルコードで言うと、ファクトリクラスを使って作成した抽象クラス(Javaのサンプルコードではinterfaceだが、Pythonにはinterfaceがないため代わりに抽象クラスを使っている)であるOvertimePayPolicyに依存している。

デザインパターンの観点では

OCP適用後のサンプルコードは、
抽象クラスを導入することで具体的なアルゴリズムをクライアントから切り離している。これによりアルゴリズムの交換可能性をもたらすのが Strategy パターン。
また、ファクトリクラスを使って具象クラスの生成をクライアントから切り離す Factory Method パターンとなっている。

LSP: リスコフの置換原則

基本型をいつでも派生型で置換可能でなければならないという原則。

下記サンプルコードは、チケット管理ツールの例。
チケットにはバグチケットとストーリーチケットがあり、
ストーリーチケットでは見積もりポイントがフィボナッチ数でないといけない条件がある。
この場合、estimate_all_tickets()において、
estimation_point = 4で、チケットがStoryTicketの場合、AssertionErrorが発生してしまう。

LSPに違反したサンプルコード

from abc import ABC, abstractmethod


class Ticket(ABC):
    @abstractmethod
    def estimate(self, point: int) -> None:
        assert point >= 1
        self.point = point


class BugTicket(Ticket):
    ...


class StoryTicket(Ticket):

    FIBONACCI_NUMBERS = {1, 2, 3, 5, 8, 13, 21}

    def estimate(self, point: int):
        assert point in self.FIBONACCI_NUMBERS, "ポイントはフィボナッチ数でなければなりません"
        super().estimate(point)


def estimate_all_tickets(tickets: list[Ticket], estimation_point: int) -> None:
    ####################
    # estimation_point = 4で、チケットがStoryTicketの場合、AssertionErrorが発生
    ####################
    for ticket in tickets:
        ticket.estimate(estimation_point)

このことから、LSPを守るためには、基本型の定める事前条件を派生型が強めてはいけない事がわかる。

ISP: インターフェース分離の原則

大きなインターフェースを、クライアントごとの小さなインターフェースに分離する原則。

ISP適用前

from abc import ABC, abstractmethod


class Ticket(ABC):
    @abstractmethod
    def start(self) -> None:
        ...

    @abstractmethod
    def close(self) -> None:
        ...

    @abstractmethod
    def assign(self, assignee: str) -> None:
        ...

    @abstractmethod
    def estimate(self, estimation_point: int) -> None:
        ...

    @abstractmethod
    def record(self, actual_point: int) -> None:
        ...


class BugTicket(Ticket):
    ...


class StoryTicket(Ticket):
    ...


class IssueTicket(Ticket):
    def estimate(self, estimation_point: int) -> None:
        raise NotImplementedError("イシューチケットは見積もりを行いません")

    def record(self, actual_point: int) -> None:
        raise NotImplementedError("イシューチケットは実績を記録しません")

仕様としてイシューチケットは見積もり・実績記録を行わないものとする。
上記ISP適用前のサンプルコードでは、それにもかかわらずestimateやrecordという邪魔なメソッドを継承してしまっている。
従って、Ticketインターフェースを利用するクライアントがIssueTicketを意識せずにestimateを呼び出すと予期せぬ例外が発生してしまう。これはLSP違反でもある。

ISP適用後

from abc import ABC, abstractmethod


class Ticket(ABC):
    @abstractmethod
    def start(self) -> None:
        ...

    @abstractmethod
    def close(self) -> None:
        ...

    @abstractmethod
    def assign(self, assignee: str) -> None:
        ...


class Estimatable(ABC):
    @abstractmethod
    def estimate(self, estimation_point: int) -> None:
        ...

    @abstractmethod
    def record(self, actual_point: int) -> None:
        ...


class BugTicket(Ticket, Estimatable):
    ...


class StoryTicket(Ticket, Estimatable):
    ...


class IssueTicket(Ticket):
    ...

インターフェースが分離されたため、
IssueTicketに邪魔なメソッドがなくなり、
Estimatableに関わる仕様変更があっても、IssueTicketは影響を受けることはなくなった。

DIP: 依存関係逆転の原則

この本におけるJavaを前提とした説明では、
interfaceを下位レイヤのパッケージに配置すると上位から下位への依存が発生してしまう。
interfaceを上位レイヤのパッケージに配置すれば、上位コンポーネントは下位コンポーネントの存在を意識することなく、独立して開発やテストができる。とのこと。

(私見) この本ではJavaで解説されているが、
パッケージやパッケージプライベートといった概念がある言語の、可視性の特性に依拠した原則という印象。
Pythonには言語仕様レベルで、パッケージプライベート的な概念は存在しない認識。
※アンダースコアのような命名規約や、ダンダー(名前マングリング)、__all__といった可視性を制御する機構は存在するが、そもそもの思想や使い勝手が異なったり、強制力の強さも異なる認識。
この原則に依拠したパターンをPythonで適用するのは難易度や認知コストが高そう。

2.4 設計パターン

アーキテクチャパターン

アーキテクチャパターンは、四つの抽象レベルで言うと、
アーキテクチャ設計 ~ モジュール設計やコンポーネント設計まで をカバーする。

ビジネスロジック層のパターン

書籍『エンタープライズアプリケーションアーキテクチャパターン』においては、
三層レイヤードアーキテクチャ(プレゼンテーション層、ビジネスロジック層、データアクセス層)
における、ビジネスロジック層について以下のパターンが挙げられている。

  • トランザクションスクリプト
  • ドメインモデル
  • テーブルモジュール
  • サービスレイヤ

トランザクションスクリプト

一連のビジネスロジックを手続き的に記述する。

  • メリット: 明快なシンプルさ
  • デメリット: ロジックの重複を生みやすい

ドメインモデル

振る舞いとデータを一体化させたオブジェクトモデルを構築して、ビジネスロジックを表現する。

  • メリット: ロジックの重複を排除することができ、複雑な業務ルールを表現しやすい
  • デメリット: 設計難易度が高いことや、シンプルな問題に対しては過剰設計となりがち

Discussion