【程式學習】 TeDDDy DDD 入門班 — 一些小心得 240906 ~ 240908

三天的 DDD 實作班


重點

Domain Model ?

第一天早上都在講這個,這門課就是在講 Domain Driven Design,意指「領域」驅動設計,那當然沒有搞清楚領域為何,後幾天學的都白搭了。

Teddy 畫一張「文氏圖」,畫了兩個圈圈,如下圖所示

image.png

大家可以想一想,Domain 是哪一個圈,而 Domain Model 又會位於哪一個圈?

1…

2…

3…

公佈答案囉!

Domain 指的是左邊這部分,說詳細一點,是「Problem Domain」,翻成白話文是說「問題領域」,也就是今天業務問題所在的領域。

至於 Domain Model 也是在左邊嗎? 不是喔!是在右邊這一塊,是右邊技術實踐這裡。

為什麼? 你可能會想說 Domain Model 不就是在 Domain 這邊嗎? 但不是這樣,雖然我一開始也是這麼想的… 😂

但經過 Tedddy 的解釋過後,才清楚 Model 的定義,是基於真實世界的問題而建立的模型。所以 Domain Model 是基於左邊「業務領域」而設計的模型,像是常見的 UML、時序圖…等模型,都是用來解決業務問題的模型。

UML 示意圖

UML 示意圖

上課當然不是教 UML 怎麼畫,而是用另外一套方法:Event Storming 的方式,來畫出 Domain Model。這部分等等會介紹一下。

Domain 的形塑與邊界

Force 形塑 Form(形狀),決定問題的邊界在哪。

那 Force 是指什麼? 邊界又是什麼?

首先要講的是 Force,是問題領域的「應力」,決定問題解法長什麼樣的力量。

image.png

很抽象對吧? 其實 Force 就是指現實條件中的「各種限制條件」,像是最常遇到的就是時間、金錢等各種資源限制。

比方說,晚餐要吃什麼? 這時候就會列出各種限制條件:預算 $100 ~ $200、快速出餐、乾淨衛生、距離住家騎車 10 分鐘以內、不想吃麥當勞…等,列出以上條件之後,就可以從各種限制的 Force ,擠出「晚餐吃什麼」的 Form 。

這時候會發現,能吃的就那幾間,但也只能吃了 XDDD。問題確實有被解決,只是可能沒這麼滿意罷了。

順帶一提,如果限制過多,取得的交集(Form)就是不存在,像是常見的下面這張圖所示 😂

image.png

所以反過來說,沒有限制(沒有 Force)就代表沒有問題;而沒有問題,當然就不用解決囉。

常言道:「晚餐吃什麼?」「隨便。」 → 吃什麼都可以。

至於上課是以「看板系統」做為形塑 Form 的範例,就是透過看板系統業務條件,以及敏捷開發法的種種限制條件(Force),將其限縮成看板業務邏輯的 Form。

Domain Event — Event Storming

事件的腦力激蕩,找出 Domain Event

這裡值得一提的是,本來 DDD (藍皮書)中沒有提到 Domain 怎麼找,而是後來有本書在講 Event Storming ,才把這兩者給「串」起來的。

簡單來說,會以「實際上」會做什麼任務的方式,來發想事件會怎麼產生,而需要什麼樣的事件,才有辦法達成需求(剛剛已經被 Force 形塑的 Form)

一組 Domain Event 長這樣:

Snipaste_2024-09-11_15-53-53.jpg

而發想的 Domain Events,則只需要「完成的動作」,格式像是這樣:主詞+動詞(過去完成式)

以看板系統為例,我們發想了許多跟看板系統「會需要做的事情」有關的事件,且根據「業務核心領域」,切成了:Accounting Management, Team Management 和 Board Management。

其中的 Board Management 就是看板系統的「核心領域」,產品中最具有競爭力的領域。當然最有競爭力的部分,就需要最多的資源關注。

image.png

在事件腦力激盪完之後,收斂出「最必要」的事件,來將其詳細列舉事件的資訊。

image.png

最後我們得到了一些最關鍵的核心領域事件,就可以按照這些事件來寫 Code 囉!

一點小 mur mur:若不是有「現成產品」可以參考,而且對於這種看板系統還算熟悉,會對於 Event Storming 要發想的事件,會有點沒頭緒呢,而且會比較挫折。頗為仰賴「領域專家」的提點與帶領,藉由熟悉產品的操作與概念,發想 Event 才會比較順利。

圖怎麼看?

DDD 的「圖原來是這樣看」,令我 龐然大物 恍然大悟。

是這兩張圖:

  • Tactical Design 圖

    • 先借簡中版的 DDD 圖來用

      此圖在解釋物件之間的互動是沒什麼問題,但「左依賴到右的方向」,就不是很好理解了

      此圖在解釋物件之間的互動是沒什麼問題,但「左依賴到右的方向」,就不是很好理解了

    • 在經過 TeDDDy 的解說之後,才知道這張圖可以由上(Model Driven Design)往下(VO, Repo, Aggregate…等)這樣來看,可以更清楚物件之間的依賴關係。以及層級之間的關係

      Snipaste_2024-10-11_18-46-13.jpg

    • 搭配加上這張圖更清楚些,更了解物件如何在「單層之間」互動:

      image.png

  • Strategy Design 策略設計

    Snipaste_2024-10-12_10-03-30.jpg

    • 藉由 TeDDDy 的手把手講解,比較知道策略設計中的「每一塊」大概在做什麼
    • 也比較知道,該怎麼樣根據專案情境,去規劃比較適合的程式模組互動方式。(e.g. 要怎麼去切微服務、專案資料夾)

實作

最後一天,才寫 Code

實作部分比較少,只佔上課時間的 2 成而已。實作部分,套用了 Clean Architecture 的架構,把先前學到的東西,Event Storming 得到的 Event,以 TDD 的方式來撰寫出程式碼(將最少最需要的邏輯逼出來)。

上課時練習寫的順序大概是這樣:

  • 根據 Event 如下圖所示,將 Event 所需「規格」的程式碼先訂下來,接著把測試準備好。
    • given, when: CreateBoardController → 參數傳入 board name
    • then: 應該要有 board 被創建、BoardCreated 事件
  • 一小部分需求 → 寫測試 🔴 → 寫程式碼 → 測試通過!🟢
  • → 疊加需求 → 寫測試 🔴 → 寫 Code …

image.png

實作程式流程範例

以建築工程來比喻,比較有畫面

  1. 寫驗收書(測試):
    1. 將整體測試流程創建起來
    2. 把「尚未存在」的 class 建起來(IDE / 請 AI 幫忙實作也蠻快的)
    • code

      public class CreateMyWorkflowUseCaseTest { @Test public void create_myworkflow_use_case(){ CreateMyWorkflowUseCase createMyWorkflowUseCase = CreateMyWorkflowUseCase(); CreateMyWorkflowInput input = new CreateMyWorkflowInput(); input.setBoardId(UUID.randomUUID().toString()); input.setWorkflowId(UUID.randomUUID().toString()); input.setName("dev"); CqrsOutput output = createMyWorkflowUseCase.execute(input); assertNotNull(output.getId()); } }
  2. 打基做鷹架(功能先做一點)
    1. 根據測試最少要做的內容,先把最少需實作弄起來
    2. 像是先把 介面開出來 → 實作 → 裡面邏輯塞進去
    • code

      package ntut.csie.sslab.kanban.myworkflow.usecase.create; import ntut.csie.sslab.ddd.usecase.cqrs.CqrsOutput; import ntut.csie.sslab.kanban.myworkflow.entity.MyWorkflow; public class CreateMyWorkflowUseCase { public CqrsOutput execute(CreateMyWorkflowInput input) { MyWorkflow myWorkflow = new MyWorkflow(input.getBoardId(), input.getWorkflowId(), input.getName()); return CqrsOutput.create().setId(myWorkflow.getWorkflowId()); } }
  3. 填水泥(真的有存進去)
    1. 上一步都還是「測自己」的邏輯,球員和裁判都是自己人。
    2. 把 Repo 給加進來,確保真的有將資料給「塞進去」
    • code
      • test

        @Test public void create_myworkflow_use_case(){ MyWorkflowRepository repository = new MyWorkflowRepository(); CreateMyWorkflowUseCase createMyWorkflowUseCase = new CreateMyWorkflowUseCase(repository); //... assertTrue(repository.findById(output.getId()).isPresent()); }
      • impl

        //... public class MyWorkflowRepository { private final List<MyWorkflow> store; public Optional<MyWorkflow> findById(String id) { return Optional.empty(); public MyWorkflowRepository() { this.store = new ArrayList<>(); } public Optional<MyWorkflow> findById(String workflowId) { return store.stream().filter(x -> x.getWorkflowId().equals(workflowId)).findAny(); } public void save(MyWorkflow myWorkflow) { store.add(myWorkflow); } }

        usecase

        public CqrsOutput execute(CreateMyWorkflowInput input) { MyWorkflow myWorkflow = new MyWorkflow(input.getBoardId(), input.getWorkflowId(), input.getName()); repository.save(myWorkflow); // 存起來 return CqrsOutput.create().setId(myWorkflow.getWorkflowId()); }
  4. 搞內裝(重構調整)
    1. 將寫好的重構美化一番
      1. 抽介面
      2. 根據介面去實作(其實就是把原本實作改名,加上介面的程式,並 implement 之)
    • code
      • usecase → service;原 usecase 變成介面

        @@ -1,19 +1,7 @@ package ntut.csie.sslab.kanban.myworkflow.usecase.create; import ntut.csie.sslab.ddd.usecase.cqrs.Command; import ntut.csie.sslab.ddd.usecase.cqrs.CqrsOutput; public interface CreateMyWorkflowUseCase extends Command<CreateMyWorkflowInput, CqrsOutput> { }
        public class CreateMyWorkflowService implements CreateMyWorkflowUseCase { private final MyWorkflowRepository repository; public CreateMyWorkflowService(MyWorkflowRepository repository) { this.repository = repository; } @Override public CqrsOutput execute(CreateMyWorkflowInput input) { MyWorkflow myWorkflow = new MyWorkflow(input.getBoardId(), input.getWorkflowId(), input.getName()); repository.save(myWorkflow); return CqrsOutput.create().setId(myWorkflow.getWorkflowId()); } }
      • Repo 抽介面;「測試專用」的化為 InMemoryRepo

        public interface WorkflowRepository { void save(Workflow workflow); // 塞進去存 Workflow findById(int id); // 要找出來看是不是同一ㄍ }
        public class WorkflowMockRepository implements WorkflowRepository { private List<Workflow> workflows = new ArrayList<>(); @Override public void save(Workflow workflow) { workflows.add(workflow); } @Override public Workflow findById(int id) { return workflows.stream().filter(workflow -> workflow.getId() == id).findFirst().orElse(null); } }
  5. 慶祝落成,廣而告之!(建立 Event)
    1. 是時候建 Event 了
    2. 在 Aggregate 身上自帶 Event
    • code
      • 測試:測 event 真的有存在

        import static org.junit.jupiter.api.Assertions.assertEquals; public class MyWorkflowTestCase { @Test public void create_a_myworkflow_generates_a_myworkflow_created_domain_event(){ MyWorkflow workflow = new MyWorkflow("", "", ""); assertEquals(1, workflow.getDomainEvents().size()); assertEquals(MyWorkflowCreated.class, workflow.getDomainEvents().get(0).getClass()); // 測同個 class 即可 } }
      • Event 塞進去 Aggregate

        @Data public class Workflow extends AggregateRoot { private int id; private String name; private int boardId; public Workflow(int id, String name, int boardId) { super(); this.id = id; this.name = name; this.boardId = boardId; addDomainEvent(new MyWorkflowCreated(id, name, boardId)); } }
      • Event 拿需要的資訊

        @Data @AllArgsConstructor public class MyWorkflowCreated extends DomainEvent { private int workflowId; private String name; private int boardId; }
  6. 發送廣告信,詔告天下!(發送 Event)
    1. 確認有發 Event 出去
    2. 請接收者「確認」真的有收到
    • code
      • 測試

        public class CreateMyWorkflowServiceTest { @Test public void create_myworkflow_use_case() { DomainEventBus domainEventBus = new GoogleEventBusAdapter(); FakeListener fakeListener = new FakeListener(); domainEventBus.register(fakeListener); MyWorkflowRepository repository = new MyWorkflowInMemoryRepository(); CreateMyWorkflowUseCase createMyWorkflowService = new CreateMyWorkflowService(repository, domainEventBus); CreateMyWorkflowInput input = new CreateMyWorkflowInput(); input.setBoardId(UUID.randomUUID().toString()); input.setWorkflowId(UUID.randomUUID().toString()); input.setName("dev"); CqrsOutput output = createMyWorkflowService.execute(input); assertNotNull(output.getId()); assertTrue(repository.findById(output.getId()).isPresent()); assertEquals(1, fakeListener.counter); } // 測試用 Listener public class FakeListener { public int counter = 0; @Subscribe public void whenMyWorkflowCreated(MyWorkflowCreated event) { counter++; } } }
      • service 確認「真的有」創建,就把 event 送出去 👈

        public class CreateMyWorkflowService implements CreateMyWorkflowUseCase { //... @Override public CqrsOutput execute(CreateMyWorkflowInput input) { MyWorkflow myWorkflow = new MyWorkflow(input.getBoardId(), input.getWorkflowId(), input.getName()); repository.save(myWorkflow); domainEventBus.postAll(myWorkflow); // 👈 return CqrsOutput.create().setId(myWorkflow.getWorkflowId()); } }

與公司現有專案的異同處

  • 異:
    • 事件會存在 Aggregate 身上。
    • Aggregate 很明確就可以透過繼承或是實作,可以從物件本身清楚知道,「我」就是個 Aggregate
  • 同:
    • Clean Architecture 的架構基本上是一樣的,畢竟師出同門
    • 物件之間的設計原則,諸如跨層原則、相依性原則,都是有嚴格遵守的。

REF