DDD 這樣落地

DDD 這個主題已經寫了好多篇文章了,結合最近的思考實踐是時候總結一下,對於戰略部分有點宏大,現在都是在微服務劃分中起着重要作用,暫且總結戰術部分

本想搞場 chat,可失敗了,那就失敗吧,也許現在 DDD 的熱度涼了,眼球都到低代碼了,對於低代碼,我現在只有使用權,還沒有發言權,也許明年能寫寫

DDD 意義

每種理論的誕生都是站在前人的基礎之上,總得要解決一些痛點;DDD 自己標榜的是解決複雜軟件系統,對於複雜怎麼理解,至少在 DDD 本身理論中並沒有給出定義,所以是否需要使用 DDD 並沒有規定,事務腳本式編程也有用武之地,DDD 也不是放之四海皆準,也就是常說的沒有銀彈

但重點是每種方法論都得落地,必須要以降低代碼複雜度爲目標,因此對於 “統一語言”、“界限上下文” 對於一線碼農有點遠,那戰術絕對是一把利劍

回顧一下,在沒有深入 DDD 之前,基本上就是事務腳本式編程,當然還會重構,怎麼重構呢?基本也是大方法變小方法 + 公共方法

隨着業務需求越來越多,代碼自然伴隨增長,就算重構常相伴,後期再去維護時也是力不從心,要麼小方法太多,要麼方法太大,老人也只能匍匐前行,新人是看得懂語法卻不知道語義,這也是程序員常面對的挑戰,不是在編寫代碼,而是在摸索業務領域知識

那怎麼辦呢?有沒有其它模式,把代碼寫漂亮,降低代碼複雜度,真正的可擴展、可維護、可測試呢?

很多人會說面向對象啊,可誰沒在使用面嚮對象語言呢?可又怎樣。事實是不能簡單的使用面嚮對象語言,得要有面向對象思維,還得再加上一些原則,如 SOLID

但雖然有了 OOP,SOLID,設計模式,還是逃不脫事務腳本編程,這裏面有客觀原因,業務系統太簡單了,OO 化不值得,不能有了錘子哪裏都是釘子;主觀原因,長時間的事務腳本思維實踐,留在了舒適區,缺乏跳出的勇氣

DDD 戰術部分給了基於面向對象更向前一步的範式,這就是它的意義

在實踐 DDD 過程中,我也一直在尋找基於完美理論的落地方案,追求心中的那個 DDD,常常在理論與實踐的落差間掙扎,在此過程中掌握了一些套路,心中也釋然了對理論的追求,最近關注到業務架構,看到一張 PPT,更是減少了心中的偏執,這份偏執也是一種對銀彈的追求,雖然嘴大多數時候說沒有,但身體很誠信

在這張方法融合論裏面,DDD 只是一小塊,爲什麼要心中充滿 DDD 呢,不都是進階路上的墊腳石。想起牛人的話,站到更高的維度讓問題不再是問題纔是最牛的解決問題之道

事務腳本式

@RestController
@RequestMapping("/")
public class CheckoutController {
    @Resource
    private ItemService itemService;
    @Resource
    private InventoryService inventoryService;
    @Resource
    private OrderRepository orderRepository;
    @PostMapping("checkout")
    public Result<OrderDO> checkout(Long itemId, Integer quantity) {
        // 1) Session管理
        Long userId = SessionUtils.getLoggedInUserId();
        if (userId <= 0) {
            return Result.fail("Not Logged In");
        }
        // 2)參數校驗
        if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
            return Result.fail("Invalid Args");
        }
        // 3)外部數據補全
        ItemDO item = itemService.getItem(itemId);
        if (item == null) {
            return Result.fail("Item Not Found");
        }
        // 4)調用外部服務
        boolean withholdSuccess = inventoryService.withhold(itemId, quantity);
        if (!withholdSuccess) {
            return Result.fail("Inventory not enough");
        }
        // 5)領域計算
        Long cost = item.getPriceInCents() * quantity;
        // 6)領域對象操作
        OrderDO order = new OrderDO();
        order.setItemId(itemId);
        order.setBuyerId(userId);
        order.setSellerId(item.getSellerId());
        order.setCount(quantity);
        order.setTotalCost(cost);
        // 7)數據持久化
        orderRepository.createOrder(order);
        // 8)返回
        return Result.success(order);
    }
}

這是經典式編程,入參校驗、獲取數據、邏輯計算、數據存儲、返回結果,每一個 use case 基本都是這樣處理的,套路就是取數據、計算數據、存數據;當然,有時我們常把中間的一塊放到 service 中。隨着 use case 越來越多,會把一些重複代碼提取出來,比如 util, 或者公共的 service method,但這些仍然是一堆代碼,可讀性、可理解性還是很差,這兩個很差,那可維護性就沒法保證,更不用提可擴展性,爲什麼?因爲這些代碼缺少了靈魂。何爲靈魂,業務模型。

對於事務腳本式也有模型,單隻有數據模型,而沒有對象模型。模型是對業務的表達,沒有了業務表達能力的代碼,人怎麼能讀懂

而 DDD 在領域模型方式就有很強的表達能力,當然在編碼時也不會以數據流向爲指導。先寫 Domain 層的業務邏輯,然後再寫 Application 層的組件編排,最後才寫每個外部依賴的具體實現,這就是 Domain-Driven Design,其實這類似於 TDD,誰驅動誰就得先行

反 DDD

任何事物都是過猶不及,如文章開頭所述,沒有銀彈,千萬別因爲 DDD 的火熱而一股腦全身心投入 DDD,不管場景是否適合,都要 DDD;猶如設計模式,後面出現了大量的反模式。

錯誤的抽象比沒有抽象傷害力更大

DDD 分層

Interface 層

對於這一層的作用就是接受外部請求,主要是 HTTP 和 RPC,那也就依賴於具體的使用技術,是 spring mvc、還是 dubble

在 DDD 正統分層裏面是有這一層的,但實踐時,像我們的 controller 卻有好幾種歸類

一、User Interface 歸屬於大前端,不在後端服務,後端服務從 application 層開始

二、正統理論,就是放在 interface 層

三、controller 畢竟是基於具體框架實現,在六邊形架構中就是是個 adapter,歸於 Infrastructure 層

對於以上三種歸類,都有實踐,都可以,但不管怎麼歸屬,他的屬性依然是 Interface

對於 Interface 落地時指導方針:

  1. 統一返回值,interface 是對外,這樣可以統一風格,降低外部認知成本 2. 全局異常攔截,通過 aop 攔截,對外形成良好提示,也防止內部異常外溢,減少異常棧序列化開銷 3. 日誌,打印調用日誌,用於統計或問題定位 4. 遵循 ISP,SRP 原則,獨立業務獨立接口,職責清晰,輕便應對需求變更,也方便服務治理,不用擔心接口的邏輯重複,知識沉澱放在 application 層,interface 只是協議,要薄,厚度體現在 application 層
@Data
public class Result<T> {
    /** 錯誤碼 */
    private Integer code;
    /** 提示信息 */
    private String msg;
    /** 具體的內容 */
    private T data;
}

Application 層

應用層主要作用就是編排業務, 只負責業務流程串聯,不負責業務邏輯

application 層其實是有固定套路的,在之前的文章有過闡述,大致流程:

application service method(Command command) {
    //參數檢驗
    check(command);
    Aggregate aggregate = repository.findAggregate(command);
    //複雜的需要domain service
    aggregate.operate(command);
    repository.saveOrUpdate(aggregate);
    publish(event);
    return DTOAssembler.to(aggregate);
}

業務流程 VS 業務規則

對於這兩者怎麼區分,也就是 application service 與 domain service 的區分,最簡單的方式:業務規則是有 if/else 的,業務流程沒有

現在都是防禦性編程,在 check(command) 部分,會做很多的 precondition

比如轉帳業務中,對於餘額的前提判斷:

這算是業務規則還是業務流程呢?這一段代碼可以算是 precondition,但也是業務規則的一部分,頗有爭議,但沒有正確答案,只是看你代碼是否有複用性,目前我個人傾向於放在業務規則中,也就是 domain 層

厚與薄

常人講,application service 是很薄的一層,要把 domain 做厚,但從最開始的示例,發現其實 application service 特別多,而 domain 只有一行代碼,這不是 application 厚了,domain 薄了

對於薄與厚不再於代碼的多與少,application 層不是厚,而是編排多而已,邏輯很簡單,一般厚的 domain 大多都是有比較複雜的業務邏輯,比如大量的分支條件。一個例子就是遊戲裏的傷害計算邏輯。另一種厚一點的就是 Entity 有比較複雜的狀態機,比如訂單

出入參數

先講一個代碼示例:

從 controller 接受到請求,傳入 application service 中,需要做一層轉換,controller 層

示例一段創建目錄功能的對象轉換:

@Data
public class DirectoryDto extends BaseRequest {
    private long id;
    @NotBlank
    @ApiModelProperty("目錄編號")
    private String directoryNo;
    @NotBlank
    @ApiModelProperty("目錄名稱")
    private String directoryName;
    private String directoryOrder;
    private String use;
    private Long parentId;
}
com.jjk.application.dto.directory.DirectoryDto to(com.jjk.controller.dto.DirectoryDto directoryDto);

創建目錄,入參只需要 directoryNo,directoryName,爲了少寫代碼,把編輯目錄 (directoryDto 中帶了 id 屬性),response(directoryDto 包含了目錄所有信息) 都揉合在一個 dto 中了

這樣就會有幾個問題:

  1. 違背 SRP,創建與編輯兩個業務功能卻混雜在了一個 dto 中 2. 相對 SRP,更大的問題是業務語義不明確,DDD 中一個優勢就是要業務語義顯示化

怎麼解決呢?

引入 CQRS 元素:

•Command 指令:指調用方明確想讓系統操作的指令,其預期是對一個系統有影響,也就是寫操作。通常來講指令需要有一個明確的返回值(如同步的操作結果,或異步的指令已經被接受)•Query 查詢:指調用方明確想查詢的東西,包括查詢參數、過濾、分頁等條件,其預期是對一個系統的數據完全不影響的,也就是隻讀操作

這樣把創建與編輯拆分,CreateDirectoryCommand、EditDirectoryCommand,這樣有了明確的” 意圖 “,業務語義也相當明顯;其次就是這些入參的正確性,之前事務腳本代碼中大量的非業務代碼混雜在業務代碼中,違背 SRP;可以利用 java 標準 JSR303 或 JSR380 的 Bean Validation 來前置這個校驗邏輯,或者使用 Domain Primitive,既能保證意圖的正確性,又能讓 application service 代碼清爽

而出參,則使用 DTO,如果有異常情況則直接拋出異常,如果不需要特殊處理,由 interface 層兜底處理

對於異常設計,可根據具體情況處理,整體由業務異常 BusinessException 派生,想細化可以派生出 DirectoryNameExistException,讓 interface 來定製 exception message, 若無需定製使用默認 message

Domain 層

domain 層是業務規則的集合,application service 編排業務,domain service 編排領域;

domain 體現在業務語義顯現化,不僅僅是一堆代碼,代碼即文檔、代碼即業務;要達到高內聚就得充分發揮 domain 層的優勢,domain 層不單單是 domain service,還有 entity、vo、aggregate

domain 層是最最需要擁抱變化的一層,爲什麼?domain 代表了業務規則,業務規則來自於需求,日常開發中,需求是經常變化的

我們需要逆向思維,以往我們去封裝第三方服務,解耦外部依賴,大多數時候是考慮外部的變化不要影響自身,而現實中,更多的變化來自內部:需求變了,所以我們應該更多關注一個業務架構的目標:獨立性,不因外部變化而變化,更要不因自身變化影響外部服務的適應性

在《DDD 之 Repository》中指出 Domain Service 是業務規則的集合,不是業務流程,所以 Domain Service 不應該有需要調用到 Repo 的地方。如果需要從另一個地方拿數據,最好作爲入參,而不是在內部調用。DomainService 需要是無狀態的,加了 Repo 就有狀態了。domainService 是規則引擎,appService 纔是流程引擎。Repo 跟規則無關

也就是 domain 層應該是一個純內存操作,不依賴外部任何服務,這樣提高了 domain 層的可測試性,擁抱變化的底氣也來自於完整的 UT,而 application 層 UT 全部得 mock

Infrastructure 層

Infrastructure 層是基礎實施層,爲其他層提供通用的技術能力:業務平臺,編程框架,持久化機制,消息機制,第三方庫的封裝,通用算法,等等

Martin Fowler 將 “封裝訪問外部系統或資源行爲的對象” 定義爲網關(Gateway),在限界上下文的內部架構中,它代表了領域層與外部環境之間交互的出入口,即:

gateway = port + adapter

這一點契合了六邊形架構

在實際落地時,碰到的問題就是 DIP 問題,Repository 在 DDD 中是在 Domain 層,但具體實現,如 DB 具體實現是在 Infrastructure 層,這也是符合整潔架構,但 DDD 限界上下文可能不僅限於訪問數據庫,還可能訪問同樣屬於外部設備的文件、網絡與消息隊列。爲了隔離領域模型與外部設備,同樣需要爲它們定義抽象的出口端口,這些出口端口該放在哪裏呢?如果依然放在領域層,就很難自圓其說。例如,出口端口 EventPublisher 支持將事件消息發佈到消息隊列,要將這樣的接口放在領域層,就顯得不倫不類了。倘若不放在位於內部核心的領域層,就只能放在領域層外部,這又違背了整潔架構思想

這個問題張逸老師提出了菱形架構,後面的章節中再論述

再次比較 interface 與 infrastructure,在前面講述到 controller 的歸屬,其實就隱含了 interface 與 infra 的關聯,這兩者都與具體框架或外部實現相關,在六邊形架構中,都歸屬爲 port 與 adapter

我一般的理解:從外部收到的,屬於 interface 層,比如 RPC 接口、HTTP 接口、消息裏面的消費者、定時任務等,這些需要轉化爲 Command、Query,然後給到 App 層。

App 主動能去調用到的,比如 DB、Message 的 Publisher、緩存、文件、搜索這些,屬於 infra 層

所以消息相關代碼可能會同時存在 2 層裏。這個主要還是看信息的流轉方式,都是從 interface -> Application -> infra

整潔架構

一個好的架構應該需要實現以下幾個目標:

  1. 獨立於框架:架構不應該依賴某個外部的庫或框架,不應該被框架的結構所束縛 2. 獨立於 UI:前臺展示的樣式可能會隨時發生變化 3. 獨立於底層數據源:無論使用什麼數據庫,軟件架構不應該因不同的底層數據儲存方式而產生巨大改變 4. 獨立於外部依賴:無論外部依賴如何變更、升級,業務的核心邏輯不應該隨之而大幅變化 5. 可測試:無論外部依賴什麼樣的數據庫、硬件、UI 或服務,業務的邏輯應該都能夠快速被驗證正確性

這幾項目標,也對應我們對 domain 的要求:獨立性和可測試;我們的依賴方向必須是由外向內

DIP 與 Maven

要想實現整潔架構目標,那必須遵循面向接口編程,達到 DIP

<modules>
    <module>assist-controller</module> <!-- controller -->
    <module>assist-application</module> <!-- application -->
    <module>assist-domain</module> <!-- domain -->
    <module>assist-infrastructure</module> <!-- infrastructure -->
    <module>assist-common</module> <!-- 基礎common -->
    <module>starter</module> <!-- 啓動入口及test -->
</modules>

在使用 maven 構建項目時,整個依賴關係是:starter -> assist-controller -> assist-application -> assist-domain -> assit-infrastructure

domain 層並不是中心層,爲什麼呢?爲什麼 domain 不在最中心?

主要是存在一個循環依賴問題:repository 接口在 domain 層,但現實在 infra 層,可從 maven module 依賴講,domain 又是依賴 infra 模塊,domain 依賴 infra 的原由是因爲前文所述

DDD 限界上下文可能不僅限於訪問數據庫,還可能訪問同樣屬於外部設備的文件、網絡與消息隊列。爲了隔離領域模型與外部設備,同樣需要爲它們定義抽象的出口端口,這些出口端口該放在哪裏呢

按此劃分 module,這些出口端口都放在了 infra 層,當 domain 需要外部服務時,不得不依賴 infra module

對此問題的困惑持續很久,一直認爲菱形架構是個好的解決方案,但今年跟阿里大佬的交流中,又得到些新的啓發

EventPublisher 接口就是放在 Domain 層,只不過 namespace 不是 xxx.domain,而是 xxx.messaging 之類的

像 repsoitory 是在 Domain 層,但是從理論上是 infra 層,混淆了兩個概念一個是 maven module 怎麼搞,一個是什麼是 Domain 層

以 namespace 區分後,得到的依賴關係就是 DIP 後的 DDD

菱形架構

上文中多次提到菱形架構,這是張逸老師發明的,去年項目中,我一直使用此架構

一是解決了上文中的 DIP 問題,二是整個架構結構清晰職責明確

簡單概述一下:

把六邊形架構與分層架構整合時,發現六邊形架構與領域驅動設計的分層架構存在設計概念上的衝突

出口端口用於抽象領域模型對外部環境的訪問,位於領域六邊形的邊線之上。根據分層架構的定義,領域六邊形的內部屬於領域層,介於領域六邊形與應用六邊形的中間區域屬於基礎設施層,那麼,位於六邊形邊線之上的出口端口就應該既不屬於領域層,又不屬於基礎設施層。它的職責與屬於應用層的入口端口也不同,因爲應用層的應用服務是對外部請求的封裝,相當於是一個業務用例的外觀。

根據六邊形架構的協作原則,領域模型若要訪問外部設備,需要調用出口端口。依據整潔架構遵循的 “穩定依賴原則”,領域層不能依賴於外層。因此,出口端口只能放在領域層。事實上,領域驅動設計也是如此要求的,它在領域模型中定義了資源庫(Repository),用於管理聚合的生命週期,同時,它也將作爲抽象的訪問外部數據庫的出口端口。

將資源庫放在領域層確有論據佐證,畢竟,在抹掉數據庫技術的實現細節後,資源庫的接口方法就是對聚合領域模型對象的管理,包括查詢、修改、增加與刪除行爲,這些行爲也可視爲領域邏輯的一部分。

然而,限界上下文可能不僅限於訪問數據庫,還可能訪問同樣屬於外部設備的文件、網絡與消息隊列。爲了隔離領域模型與外部設備,同樣需要爲它們定義抽象的出口端口,這些出口端口該放在哪裏呢?如果依然放在領域層,就很難自圓其說。例如,出口端口 EventPublisher 支持將事件消息發佈到消息隊列,要將這樣的接口放在領域層,就顯得不倫不類了。倘若不放在位於內部核心的領域層,就只能放在領域層外部,這又違背了整潔架構思想。

如果我們將六邊形架構看作是一個對稱的架構,以領域爲軸心,入口適配器和入口端口就應該與出口適配器和出口端口是對稱的;同時,適配器又需和端口相對應,如此方可保證架構的松耦合。

<modules>
 <module>assist-ohs</module> <!-- ohs -->
 <module>assist-service</module> <!-- domain -->
 <module>assist-acl</module> <!-- acl -->
 <module>starter</module> <!-- 啓動入口及test -->
</modules>

這有點類似《DDD 之形》中提到的端口模式,把資源庫 Repository 從 domain 層轉移到端口層和其它端口元素統一管理,原來的四層架構變成了三層架構,對 repository 的位置從物理與邏輯上一致,相當於擴大了 ACL 範圍

這個架構結構清晰,算是六邊形架構與分層架構的融合體,至於怎麼選擇看個人喜愛

Event

相對 Event Source,這兒更關注一下 event 的發起,是不是需要區分應用事件和領域事件

根據 application 的套路,會 publish event,那在 domain service 中要不要 publish event 呢?

Domain Event 更多是領域內的事件,所以應該域內處理,甚至不需要是異步的。Application 層去調用消息中間件發消息,或調用三方服務,這個是跨域的。

從目前的實踐來看,直接拋 Domain Event 做跨域處理這件事,不是很成熟,特別是容易把 Domain 層的邊界捅破,帶來完全不可控的副作用

所以結合 application,除了 Command、Query 入參,還需要 Event 入參,處理事件

總結

本文主要是按 DDD 分層,介紹各層落地時的具體措施,以及各層相應的規範,引入 CQRS 使代碼語義顯現化,通過 DIP 達到整潔架構的目標

對於 domain 層,有個重要的 aggregate, 涉及模型的構建,千人千模,但 domain 層的落地是一樣的

在業務代碼中有幾個比較核心的東西:抽象領域對象合併簡單單實體邏輯,將多實體複雜業務規則放到 DomainService 裏、封裝 CRUD 爲 Repository,通過 App 串聯業務流程,通過 interface 提供對外接口,或者接收外部消息

其實不論使用 DDD,還是事務腳本,合適的纔是最好的,任何方法論都得以降低代碼複雜度爲目的

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Q0xuZ83gdzTEDG9uKejRhg