返回文章列表

領域驅動設計與架構模式實踐案例

本文探討如何應用領域驅動設計和架構模式來重構和改進現有系統,涵蓋依賴注入、介面卡、服務層、聚合根、事件驅動架構和 Strangler Fig Pattern 等概念,並輔以 Python 和 C# 程式碼範例,說明如何逐步將複雜的單體架構轉變為更具彈性、可維護性和可擴充套件性的架構。

軟體工程 系統設計

隨著軟體系統規模的增長和複雜性的提升,維護和擴充套件現有系統變得越來越困難。本文透過實際案例,探討如何應用領域驅動設計(DDD)和架構模式來解決這些挑戰。從程式碼層面,我們示範瞭如何使用依賴注入和介面卡模式來解耦程式碼,提高程式碼的可測試性和可維護性。接著,我們介紹了服務層的概念,將業務邏輯從網域模型中分離出來,使程式碼結構更清晰。此外,我們還討論了聚合根的重要性,以及如何使用識別符號取代直接物件參照,以簡化物件圖的複雜性。最後,我們探討瞭如何利用事件驅動架構和 Strangler Fig Pattern,逐步將單體架構轉變為微服務架構,提高系統的彈性和可擴充套件性。

建構介面卡:一個完整的範例

為了更深入瞭解如何實作依賴注入和介面卡,我們以 send_mail 為例進行擴充套件。

定義抽象類別與實作(src/allocation/adapters/notifications.py)

class AbstractNotifications(abc.ABC):
    @abc.abstractmethod
    def send(self, destination, message):
        raise NotImplementedError

class EmailNotifications(AbstractNotifications):
    def __init__(self, smtp_host=DEFAULT_HOST, port=DEFAULT_PORT):
        self.server = smtplib.SMTP(smtp_host, port=port)
        self.server.noop()

    def send(self, destination, message):
        msg = f'Subject: allocation service notification\n{message}'
        self.server.sendmail(
            from_addr='[email protected]',
            to_addrs=[destination],
            msg=msg
        )

內容解密:

  1. AbstractNotifications 抽象類別:定義了 send 抽象方法,用於傳送通知。
  2. EmailNotifications 實作:繼承 AbstractNotifications,並實作 send 方法,使用 SMTP 傳送郵件。

修改 Bootstrap 以支援新的依賴

def bootstrap(
    start_orm: bool = True,
    uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(),
    notifications: AbstractNotifications = EmailNotifications(),
    publish: Callable = redis_eventpublisher.publish,
) -> messagebus.MessageBus:

內容解密:

  1. notifications 引數:將 send_mail 改為 notifications,並使用 AbstractNotifications 作為型別提示。
  2. EmailNotifications 預設值:預設使用 EmailNotifications 實作。

在測試中使用 FakeNotifications

class FakeNotifications(notifications.AbstractNotifications):
    def __init__(self):
        self.sent = defaultdict(list)

    def send(self, destination, message):
        self.sent[destination].append(message)

def test_sends_email_on_out_of_stock_error(self):
    fake_notifs = FakeNotifications()
    bus = bootstrap.bootstrap(
        start_orm=False,
        uow=FakeUnitOfWork(),
        notifications=fake_notifs,
        publish=lambda *args: None,
    )
    bus.handle(commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None))
    bus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10))
    assert fake_notifs.sent['[email protected]'] == [
        f"Out of stock for POPULAR-CURTAINS",
    ]

內容解密:

  1. FakeNotifications 的實作:用於在測試中捕捉傳送的通知,而不實際傳送郵件。
  2. 測試邏輯:建立批次並分配訂單,驗證是否正確觸發缺貨通知。

整合測試中的真實 EmailNotifications

在整合測試中,我們使用真實的 EmailNotifications 類別,並與 MailHog 伺服器互動:

@pytest.fixture
def bus(sqlite_session_factory):
    bus = bootstrap.bootstrap(
        start_orm=True,
        uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory),
        notifications=notifications.EmailNotifications(),
        publish=lambda *args: None,
    )
    yield bus
    clear_mappers()

內容解密:

  1. EmailNotifications 的使用:在整合測試中使用真實的郵件傳送邏輯,與 MailHog 伺服器互動。

如何在現有系統中應用領域驅動設計與架構模式

在學習了領域驅動設計(DDD)和各種架構模式之後,下一步是將這些概念應用到現有的系統中。許多開發者面臨的一個常見問題是:如何從一個已經變得難以維護的龐大系統開始進行改進?

識別問題並設定目標

首先,需要明確你試圖解決的問題。是軟體變更過於困難?是效能無法接受?還是存在奇怪且無法解釋的錯誤?

  1. 釐清目標:明確目標有助於優先處理待完成的工作,並向團隊成員溝通進行這些工作的理由。
  2. 與業務需求結合:將技術債務與功能開發結合起來,例如在推出新產品或開拓新市場時,進行基礎架構的改善。

分離混雜的職責

大型系統的一個典型特徵是同質性——系統的各個部分看起來都很相似,因為我們沒有明確每個元件的職責。

  1. 建立服務層:開始構建服務層(Service Layer),這是分離職責的第一步。
class ServiceLayer:
    def __init__(self, repository):
        self.repository = repository
    
    def handle_command(self, command):
        #  處理命令邏輯
        pass

內容解密:

  • ServiceLayer類別負責處理業務邏輯。
  • __init__方法初始化服務層所需的依賴,例如儲存函式庫(Repository)。
  • handle_command方法處理傳入的命令,執行相應的業務邏輯。
  1. 引入清晰的邊界:透過定義明確的介面和邊界來隔離不同的元件。

逐步重構

重構一個大型系統需要逐步進行。

  1. 使用依賴注入:依賴注入(Dependency Injection)有助於管理元件之間的依賴關係。
class Notifications:
    def __init__(self, email_service):
        self.email_service = email_service
    
    def send_notification(self, message):
        #  傳送通知邏輯
        self.email_service.send(message)

內容解密:

  • Notifications類別負責傳送通知。
  • __init__方法注入所需的依賴,例如電子郵件服務(Email Service)。
  • send_notification方法使用注入的電子郵件服務傳送通知。
  1. 測試與驗證:在重構過程中,確保有足夠的測試來驗證系統的功能正確性。

圖示:系統重構流程

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title 領域驅動設計與架構模式實踐案例

package "Python 應用架構" {
    package "應用層" {
        component [主程式] as main
        component [模組/套件] as modules
        component [設定檔] as config
    }

    package "框架層" {
        component [Web 框架] as web
        component [ORM] as orm
        component [非同步處理] as async
    }

    package "資料層" {
        database [資料庫] as db
        component [快取] as cache
        component [檔案系統] as fs
    }
}

main --> modules : 匯入模組
main --> config : 載入設定
modules --> web : HTTP 處理
web --> orm : 資料操作
orm --> db : 持久化
web --> cache : 快取查詢
web --> async : 背景任務
async --> fs : 檔案處理

note right of web
  Flask / FastAPI / Django
end note

@enduml

此圖示展示了系統重構的主要步驟,從識別問題到測試驗證,每一步都至關重要。

如何進一步提升

  1. 持續重構:持續不斷地重構和改進系統,保持程式碼的可維護性和可擴充套件性。
  2. 學習與實踐:持續學習新的技術和方法,並在實踐中應用,以保持技術的先進性和競爭力。

透過遵循這些步驟和方法,可以有效地將領域驅動設計和架構模式應用到現有的系統中,提升系統的可維護性和可擴充套件性。

重構過度成長的系統:一個協作平台的案例研究

許多年前,Bob曾在一家軟體公司工作,該公司曾將其應用程式的第一個版本外包出去,這是一個用於分享和處理檔案的線上協作平台。當公司將開發工作轉回內部時,經歷了幾代開發人員的更替,每一批新開發人員都為程式碼結構增加了更多的複雜性。

系統的核心問題

該系統的核心是一個ASP.NET Web Forms應用程式,使用NHibernate ORM構建。使用者可以將檔案上傳到工作空間,並邀請其他成員審閱、評論或修改他們的工作。然而,大部分應用程式的複雜性在於許可權模型,因為每個檔案都包含在一個資料夾中,而資料夾允許讀取、寫入和編輯許可權,就像Linux檔案系統一樣。此外,每個工作空間都隸屬於一個帳戶,而帳戶透過計費套餐附加了配額。因此,每次對檔案的讀取或寫入操作都需要從資料函式庫載入大量物件以測試許可權和配額。建立一個新的工作空間涉及數百個資料函式庫查詢,因為需要設定許可權結構、邀請使用者並設定範例內容。

初始狀態的問題

部分操作程式碼位於web處理程式中,當使用者點選按鈕或提交表單時執行;部分程式碼位於管理物件中,這些物件負責協調工作;還有部分程式碼位於網域模型中。模型物件會進行資料函式庫呼叫或在磁碟上複製檔案,且測試覆寫率極低。

解決方案:引入服務層

為瞭解決這個問題,首先引入了一個服務層,以便將所有建立檔案或工作空間的程式碼放在一個地方,使其更容易理解。這涉及將資料存取程式碼從網域模型中提取出來,並放入命令處理程式中。同樣,將協調程式碼從管理員和web處理程式中提取出來,並推入處理程式中。

// 使用案例:建立工作空間
public class CreateWorkspaceHandler
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IWorkspaceRepository _workspaceRepository;

    public CreateWorkspaceHandler(IUnitOfWork unitOfWork, IWorkspaceRepository workspaceRepository)
    {
        _unitOfWork = unitOfWork;
        _workspaceRepository = workspaceRepository;
    }

    public void Handle(CreateWorkspaceCommand command)
    {
        // #### 內容解密:
        // 1. 開始新的資料函式庫交易
        using var transaction = _unitOfWork.BeginTransaction();
        
        // 2. 擷取必要資料
        var workspace = new Workspace(command.Name, command.OwnerId);
        
        // 3. 檢查先決條件
        if (!CanCreateWorkspace(command.OwnerId))
        {
            throw new InvalidOperationException("無法建立工作空間");
        }
        
        // 4. 更新網域模型
        _workspaceRepository.Add(workspace);
        
        // 5. 儲存變更
        transaction.Commit();
    }

    private bool CanCreateWorkspace(Guid ownerId)
    {
        // 檢查建立工作空間的先決條件邏輯
        // 例如:檢查使用者是否有足夠的配額
        return true; // 簡化範例
    }
}

內容解密:

  1. 使用交易: 使用IUnitOfWork介面開始一個新的資料函式庫交易,確保操作的原子性。
  2. 擷取資料: 建立一個新的Workspace物件,代表要建立的工作空間。
  3. 檢查先決條件: 呼叫CanCreateWorkspace方法檢查建立工作空間的先決條件,例如檢查使用者的配額是否足夠。
  4. 更新網域模型: 將新的工作空間新增到WorkspaceRepository
  5. 儲存變更: 提交交易以儲存變更。

從單體架構到微服務:以事件驅動架構實作 strangler pattern

在我們的案例研究中,程式碼函式庫的問題之一是物件圖(object graph)高度相連。每個帳戶都有許多工作空間,每個工作空間都有許多成員,每個成員都有自己的帳戶。每個工作空間包含許多檔案,每個檔案都有多個版本。

物件圖的複雜性問題

物件圖的複雜性使得很難用類別圖(class diagram)完整表達。系統中的每個物件都是繼承階層的一部分,包括 SecureObjectVersion。這種繼承階層直接對映到資料函式庫結構,使得每個查詢都需要跨越 10 個不同的表格,並檢視鑒別器欄位(discriminator column)才能確定正在處理的物件型別。

程式碼函式庫使得透過物件圖進行「點」操作變得容易,例如:

user.account.workspaces[0].documents.versions[1].owner.account.settings[0]

雖然使用 Django ORM 或 SQLAlchemy 構建這樣的系統很容易,但應該避免這樣做。雖然很方便,但很難推斷效能,因為每個屬性都可能觸發對資料函式庫的查詢。

聚合根(Aggregates)的重要性

聚合根是保持一致性的邊界。一般來說,每個使用案例應該一次更新一個聚合根。一個處理器從儲存函式庫中取得一個聚合根,修改其狀態,並引發任何由此產生的事件。如果需要系統其他部分的資料,可以使用讀取模型,但避免在單個交易中更新多個聚合根。

程式碼範例:更新聚合根

def lock_account(user):
    for workspace in user.account.workspaces:
        workspace.archive()

這類別操作會嚴重影響效能,但修復它們意味著放棄單一物件圖。相反,我們開始識別聚合根並打破物件之間的直接連結。

使用識別符號取代直接參照

大多數情況下,我們透過用識別符號取代直接參照來實作這一點。

修改前

class Document:
    def __init__(self, folder):
        self.folder = folder

class Folder:
    def __init__(self):
        self.documents = []

修改後

class Document:
    def __init__(self, parent_folder_id):
        self.parent_folder_id = parent_folder_id

class Folder:
    def __init__(self):
        pass

內容解密:

  1. Document類別的修改:原本Document類別直接參照Folder物件,現在改為儲存parent_folder_id,這樣可以減少物件之間的直接耦合。
  2. Folder類別的修改:原本Folder類別包含documents列表,現在移除這個屬性,以避免雙向繫結導致的複雜性。
  3. 優點:這種修改使得系統更容易維護和擴充套件,因為每個物件只需要關心自己的狀態,而不需要直接操作其他物件。

事件驅動架構與 Strangler Pattern

Strangler Fig pattern 涉及在舊系統周圍建立一個新系統,同時保持舊系統執行。舊功能的某些部分逐漸被攔截和替換,直到舊系統無事可做,可以被關閉。

在構建可用性服務時,我們使用了一種稱為事件攔截(event interception)的技術,將功能從一個地方移到另一個地方。這是一個三步驟的過程:

  1. 引發事件以表示要在系統中替換的變更。
  2. 構建第二個系統,該系統使用這些事件並使用它們來構建自己的領域模型。
  3. 用新系統替換舊系統。

實作事件攔截

# Step 1: 引發事件
def lock_workspace(workspace_id):
    # 業務邏輯
    event = WorkspaceLocked(workspace_id)
    bus.handle(event)

# Step 2: 構建新系統
class WorkspaceLockedHandler:
    def handle(self, event):
        # 更新新系統的狀態
        pass

# Step 3:替換舊系統
# 使用新系統替換舊系統的邏輯

內容解密:

  1. 事件引發:當工作空間被鎖定時,引發WorkspaceLocked事件,將變更通知給其他系統。
  2. 事件處理WorkspaceLockedHandler負責處理WorkspaceLocked事件,並更新新系統的狀態。
  3. 系統替換:透過逐步替換舊系統,最終實作新舊系統的平滑過渡。