返回文章列表

領域模型驅動設計Python實作

本文探討領域驅動設計(DDD)在 Python 中的實作,包含值物件、實體、領域服務等核心概念,並以庫存分配案例示範如何運用 DDD 建構可維護的軟體系統。文章涵蓋領域模型的建立、單元測試的編寫以及程式碼範例,闡述如何使用 Python 的 dataclass、NamedTuple 等特性來實作 DDD

軟體工程 後端開發

領域驅動設計(DDD)是一種以領域模型為核心的軟體開發方法,它強調對業務領域的深入理解和模型的準確性。在 Python 中實作 DDD 可以有效提升程式碼的可讀性、可維護性和可測試性。本文將以庫存分配案例為例,逐步講解如何使用 Python 的特性,如 dataclass 和 NamedTuple,來實作 DDD 的核心概念,包括值物件、實體和領域服務。同時,也將示範如何運用測試驅動開發(TDD)來確保模型的正確性。透過本文,讀者可以學習如何將 DDD 的原則應用於實際的 Python 專案中,建構更具彈性且易於演進的軟體系統。

內容解密:

此圖示顯示了應用程式的不同元件之間的互動關係。應用程式首先與Service Layer互動,由Service Layer來控制業務邏輯的流程。Service Layer會與Repository互動,以進行資料存取操作。同時,Service Layer也會使用Unit of Work來確保操作的原子性。此外,應用程式也會直接與領域模型互動,領域模型代表了業務邏輯的核心。領域模型的變更會影響到Service Layer的實作。

程式碼範例

class Repository:
    def __init__(self, session):
        self.session = session

    def get(self, id):
        return self.session.query(MyModel).get(id)

    def add(self, model):
        self.session.add(model)

#### 內容解密:
這段程式碼定義了一個Repository類別用於封裝資料存取邏輯它使用了SQLAlchemy的session物件來進行資料函式庫操作get方法用於根據id檢索特定的MyModel例項而add方法用於將新的MyModel例項新增到session中這樣可以將資料存取邏輯與業務邏輯分離提高程式碼的可維護性和可測試性

領域建模

本章節將探討如何以程式碼建模業務流程,並與測試驅動開發(TDD)高度相容。我們將討論領域建模的重要性,並介紹一些關鍵的建模模式,包括實體(Entity)、值物件(Value Object)和領域服務(Domain Service)。

什麼是領域模型?

在前言中,我們使用「業務邏輯層」來描述三層架構中的核心層。本文將使用「領域模型」這個術語,它更好地捕捉了我們的預期含義(有關DDD的更多資訊,請參閱下一個側邊欄)。

領域是指你試圖解決的問題。作者目前在一家線上傢具零售商工作。根據所討論的系統,領域可能涉及採購、產品設計或物流和配送。大多數程式設計師每天都在嘗試改善或自動化業務流程;領域是這些流程所支援的活動集合。

模型是捕捉某個過程或現象中有用屬性的地圖。人類非常擅長在腦海中建立事物的模型。例如,當有人朝你扔球時,你幾乎無意識地就能預測它的運動軌跡,因為你有一個關於物體在空間中運動的模型。你的模型並不完美,人類對於接近光速或在真空中的物體行為幾乎沒有直覺,因為我們的模型從未被設計來涵蓋這些情況。這並不意味著模型是錯誤的,而是意味著某些預測超出了其領域範圍。

領域模型是業務所有者對其業務的心理地圖。所有業務人員都有這些心理地圖——這是人類思考複雜過程的方式。

當他們瀏覽這些地圖時,你可以從他們使用的業務術語中看出來。術語在協作處理複雜系統的人群中自然產生。

想像一下,你和你的朋友、家人突然被運送到離地球很遠的某個外星飛船上,需要從頭開始弄清楚如何傳回家鄉。在最初的幾天裡,你可能會隨機按按鈕,但很快你就會學會哪些按鈕控制什麼,這樣你就可以給對方下達指令。「按下閃爍裝置附近的紅色按鈕,然後拉下雷達裝置旁邊的大槓桿,」你可能會這樣說。

幾周後,你們會變得更加精確,因為你們採用了描述飛船功能的詞彙:「增加三號貨艙的氧氣含量」或「開啟小型推進器」。幾個月後,你們就會採用描述整個複雜過程的語言:「開始著陸程式」或「準備進入曲速狀態」。這個過程會很自然地發生,無需正式努力建立分享詞彙表。

內容解密:

  • 領域模型的概念源自領域驅動設計(DDD),強調軟體應當提供對問題的有用模型。
  • 正確的模型能夠為業務帶來價值並實作新的可能性。
  • DDD涵蓋了許多架構模式,如實體、聚合、值物件和倉儲等。

為什麼領域建模很重要?

領域建模至關重要,因為它能夠讓開發者深入理解業務流程和規則。透過建立一個準確反映業務領域的模型,軟體系統能夠更好地支援業務運作,並隨著業務需求的變化而靈活調整。

圖 1-1:領域模型的簡單視覺化表示

補充說明:

此圖示展示了領域模型的核心組成部分,包括實體、值物件和領域服務之間的關係。

內容解密:

  • 圖表展示了領域模型的基礎架構。
  • 實體代表具有唯一識別符的物件。
  • 值物件是不可變的,用於描述某些屬性。
  • 領域服務封裝了業務邏輯,用於協調實體和值物件之間的互動作用。

庫存分配機制的創新與領域建模

業務場景與創新點

傳統的庫存管理方式僅考慮倉函式庫中的實物庫存,當倉庫存貨不足時,產品會被標記為「缺貨」。本案例中的企業決定採用一種新的庫存分配機制,將運輸中的貨物視為可用庫存的一部分,以減少缺貨情況並降低倉庫存貨成本。

領域語言的探索

透過與業務專家的深入討論,建立了領域模型的初步詞彙表和規則。為確保模型的準確性,使用具體的業務案例來說明每條規則,並採用業務專家熟悉的術語來表達這些規則。

庫存分配的關鍵規則

  • 產品識別:使用SKU(庫存量單位)來識別產品。
  • 訂單結構:客戶訂單包含多個訂單明細,每個明細包含SKU和數量。
  • 批次管理:採購部門訂購小批次的庫存,每個批次有唯一的參考編號、SKU和數量。
  • 分配邏輯
    • 將訂單明細分配給特定的批次,並從該批次的可用數量中扣除相應數量。
    • 不能將訂單明細分配給可用數量不足的批次。
    • 不能重複將同一訂單明細分配給同一批次。
  • 優先分配順序:優先分配給倉函式庫庫存,其次是運輸中的批次,並按照預計到達時間(ETA)的先後順序進行分配。

領域模型的單元測試

透過編寫單元測試來驗證領域模型的正確性。測試案例涵蓋了分配邏輯的主要規則,例如:

def test_allocating_to_a_batch_reduces_the_available_quantity():
    batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
    line = OrderLine('order-ref', "SMALL-TABLE", 2)
    batch.allocate(line)
    assert batch.available_quantity == 18

初步的領域模型實作

定義了OrderLineBatch兩個類別,分別代表訂單明細和批次。

@dataclass(frozen=True)
class OrderLine:
    orderid: str
    sku: str
    qty: int

class Batch:
    def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
        self.reference = ref
        self.sku = sku
        self.eta = eta
        self.available_quantity = qty

    def allocate(self, line: OrderLine):
        self.available_quantity -= line.qty

程式碼解析:

  1. OrderLine類別:使用@dataclass(frozen=True)裝飾器定義了一個不可變的資料類別,包含orderidskuqty三個屬性,分別代表訂單ID、產品SKU和數量。
  2. Batch類別:定義了批次的基本屬性,包括參考編號、SKU、預計到達時間(ETA)和可用數量。allocate方法用於將訂單明細分配給批次,並更新批次的可用數量。
  3. 型別提示:使用型別提示(如strintOptional[date])來提高程式碼的可讀性和維護性。

未來改進方向

目前的實作相對簡單,未來可以考慮增加更多的業務規則和邏輯,例如處理多個批次之間的分配優先順序、批次合併等情況,以進一步完善領域模型。

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title 領域驅動設計 DDD 架構

package "應用層" {
    component [Service Layer] as service
    component [Unit of Work] as uow
}

package "領域層" {
    package "領域模型" {
        component [Entity 實體] as entity
        component [Value Object 值物件] as vo
        component [Domain Service] as ds
    }

    package "聚合" {
        component [Aggregate Root] as ar
        component [聚合邊界] as boundary
    }
}

package "基礎設施層" {
    component [Repository 倉儲] as repo
    component [ORM 映射] as orm
    component [資料庫] as db
}

package "測試驅動開發" {
    component [單元測試] as unit_test
    component [整合測試] as int_test
}

service --> uow : 交易管理
service --> ds : 業務邏輯

entity --> vo : 組合使用
entity --> ar : 聚合根

ar --> boundary : 一致性邊界

repo --> orm : 資料存取
orm --> db : 持久化

unit_test --> entity : 測試領域邏輯
int_test --> repo : 測試資料存取

note right of entity
  Entity 特性:
  - 具有唯一識別
  - 生命週期追蹤
end note

note right of vo
  Value Object 特性:
  - 無識別,以值定義
  - 不可變 (Immutable)
end note

note right of repo
  Repository 模式:
  - 封裝資料存取
  - 隔離領域與基礎設施
end note

@enduml

補充說明:

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title 補充:庫存分配領域模型

package "庫存分配領域" {
    package "領域建模" {
        component [模型設計] as select
        component [超參數調優] as tune
        component [交叉驗證] as cv
    }

    package "評估部署" {
        component [模型評估] as eval
        component [模型部署] as deploy
        component [監控維護] as monitor
    }
}

collect --> clean : 原始資料
clean --> feature : 乾淨資料
feature --> select : 特徵向量
select --> tune : 基礎模型
tune --> cv : 最佳參數
cv --> eval : 訓練模型
eval --> deploy : 驗證模型
deploy --> monitor : 生產模型

note right of feature
  特徵工程包含:
  - 特徵選擇
  - 特徵轉換
  - 降維處理
end note

note right of eval
  評估指標:
  - 準確率/召回率
  - F1 Score
  - AUC-ROC
end note

@enduml

此圖示展示了客戶訂單、訂單明細和批次之間的關係,以及分配邏輯的基本流程。

圖示解析:

  • 客戶訂單包含多個訂單明細。
  • 訂單明細被分配給特定的批次。
  • 分配後,批次的可用數量會被更新。
  • 系統會檢查批次的可用數量是否足夠,若不足則不允許分配。

領域模型驅動設計:批次管理系統實作

在批次管理系統的開發過程中,我們採用領域驅動設計(DDD)原則來建立一個強壯且可維護的模型。以下將介紹如何實作批次(Batch)與訂單明細(OrderLine)之間的分配邏輯。

測試驅動開發

首先,我們透過測試驅動開發(TDD)來確保我們的模型行為正確。我們定義了多個測試案例來驗證 can_allocate 方法的正確性。

# test_batches.py
def make_batch_and_line(sku, batch_qty, line_qty):
    return (
        Batch("batch-001", sku, batch_qty, eta=date.today()),
        OrderLine("order-123", sku, line_qty)
    )

def test_can_allocate_if_available_greater_than_required():
    large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
    assert large_batch.can_allocate(small_line)

def test_cannot_allocate_if_available_smaller_than_required():
    small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
    assert not small_batch.can_allocate(large_line)

def test_can_allocate_if_available_equal_to_required():
    batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2)
    assert batch.can_allocate(line)

def test_cannot_allocate_if_skus_do_not_match():
    batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None)
    different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10)
    assert not batch.can_allocate(different_sku_line)

內容解密:

  1. make_batch_and_line 函式用於建立 BatchOrderLine 物件,簡化測試資料的建立。
  2. 多個測試函式驗證了 can_allocate 方法在不同場景下的行為,包括庫存足夠、不足、相等以及 SKU 不匹配的情況。
  3. 這些測試案例確保了 can_allocate 方法的正確性。

領域模型實作

接下來,我們實作了 Batch 類別的 can_allocate 方法。

# model.py
class Batch:
    def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
        self.reference = ref
        self.sku = sku
        self.eta = eta
        self._purchased_quantity = qty
        self._allocations = set()  # type: Set[OrderLine]

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

    @property
    def available_quantity(self) -> int:
        return self._purchased_quantity - self.allocated_quantity

    @property
    def allocated_quantity(self) -> int:
        return sum(line.qty for line in self._allocations)

內容解密:

  1. can_allocate 方法檢查 SKU 是否匹配以及可用數量是否足夠。
  2. available_quantity 屬性計算可用數量,即採購數量減去已分配數量。
  3. allocated_quantity 屬性計算已分配的總數量。

UML 模型

我們的領域模型可以用 UML 圖表示,如下所示:

此圖示呈現了 BatchOrderLine 之間的關係。

更多型別提示

為了提高程式碼的可讀性和安全性,我們可以使用 typing.NewType 來建立更具體的型別。

# model.py
from typing import NewType

Quantity = NewType("Quantity", int)
Sku = NewType("Sku", str)
Reference = NewType("Reference", str)

class Batch:
    def __init__(self, ref: Reference, sku: Sku, qty: Quantity):
        self.sku = sku
        self.reference = ref
        self._purchased_quantity = qty

內容解密:

  1. 使用 NewType 建立了 QuantitySkuReference 等新型別。
  2. 這些新型別可以用於提高函式引數和屬性的型別安全性。

領域驅動設計中的值物件與實體

在領域驅動設計(Domain-Driven Design, DDD)中,我們經常需要決定如何表示業務概念。有些概念具有唯一的身份,而有些則僅由其資料定義。在本篇文章中,我們將探討值物件(Value Object)和實體(Entity)的區別,以及如何在 Python 中實作它們。

值物件

值物件是由其資料唯一標識的領域物件。它們通常是不可變的,並且具有值相等性(value equality)。也就是說,如果兩個值物件具有相同的資料,則它們被視為相等。

OrderLine 作為值物件

from dataclasses import dataclass

@dataclass(frozen=True)
class OrderLine:
    orderid: str
    sku: str
    qty: int

值物件的例子

from dataclasses import dataclass
from typing import NamedTuple

@dataclass(frozen=True)
class Name:
    first_name: str
    surname: str

class Money(NamedTuple):
    currency: str
    value: int

Line = NamedTuple('Line', ['sku', 'qty'])

def test_equality():
    assert Money('gbp', 10) == Money('gbp', 10)
    assert Name('Harry', 'Percival') != Name('Bob', 'Gregory')
    assert Line('RED-CHAIR', 5) == Line('RED-CHAIR', 5)

值物件上的數學運算

fiver = Money('gbp', 5)
tenner = Money('gbp', 10)

def can_add_money_values_for_the_same_currency():
    assert fiver + fiver == tenner

def can_subtract_money_values():
    assert tenner - fiver == fiver

def adding_different_currencies_fails():
    with pytest.raises(ValueError):
        Money('usd', 10) + Money('gbp', 10)

def can_multiply_money_by_a_number():
    assert fiver * 5 == Money('gbp', 25)

def multiplying_two_money_values_is_an_error():
    with pytest.raises(TypeError):
        tenner * fiver

實體

實體具有長期的身份,並且可以更改其值。即使其值發生變化,它仍然是相同的實體。

Batch 作為實體

class Batch:
    def __init__(self, reference, sku, qty, eta):
        self.reference = reference
        self.sku = sku
        self.qty = qty
        self.eta = eta

    def __eq__(self, other):
        if not isinstance(other, Batch):
            return False
        return other.reference == self.reference

    def __hash__(self):
        return hash(self.reference)

值物件與實體的區別

值物件由其資料定義,而實體具有獨立於其值的身份。

Person 作為實體的例子

class Person:
    def __init__(self, name: Name):
        self.name = name

def test_barry_is_harry():
    harry = Person(Name("Harry", "Percival"))
    barry = harry
    barry.name = Name("Barry", "Percival")
    assert harry is barry and barry is harry

領域服務函式

有時,我們需要執行一些不自然地屬於某個實體或值物件的操作。在這種情況下,我們可以使用領域服務函式。

allocate 函式

def allocate(line: OrderLine, batches: List[Batch]):
    # implementation details
    pass

def test_prefers_current_stock_batches_to_shipments():
    in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
    shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
    line = OrderLine("oref", "RETRO-CLOCK", 10)
    allocate(line, [in_stock_batch, shipment_batch])
    assert in_stock_batch.available_quantity == 90
    assert shipment_batch.available_quantity == 100

在本文中,我們探討了值物件和實體在領域驅動設計中的作用,以及如何在 Python 中實作它們。我們還討論了領域服務函式的使用場景。透過這些概念,我們可以更好地建模業務領域,並實作更健壯、更易於維護的軟體系統。

詳細解說
  1. 值物件:主要由其資料定義,具有不可變性。它們通常用於表示一些簡單的概念,如金額、姓名等。
  2. 實體:具有獨立於其值的長期身份。它們可以更改其屬性,但仍然保持相同的身份。
  3. 領域服務函式:用於處理那些不自然地屬於某個特定實體或值物件的操作。它們提供了一種靈活的方式來組織程式碼,使得業務邏輯更加清晰。