DDD落地,如何持久化聚合
理解聚合
聚合是一組始終需要保持一致的業(yè)務(wù)對(duì)象。因此,我們作為一個(gè)整體保存和更新聚合,以確保業(yè)務(wù)邏輯的一致性。
聚合是 DDD 中最為重要的概念,即使你不使用 DDD 編寫(xiě)代碼也需要理解這一重要的概念 —— 部分對(duì)象的生命周期可以看做一個(gè)整體,從而簡(jiǎn)化編程。
一般來(lái)說(shuō),我們需要對(duì)聚合內(nèi)的對(duì)象使用 ACID 特性的事務(wù)。
最簡(jiǎn)單的例子就是訂單和訂單項(xiàng)目,訂單項(xiàng)目更新必須伴隨訂單的更新,否則就會(huì)有總價(jià)不一致之類(lèi)的問(wèn)題。訂單項(xiàng)目需要跟隨訂單的生命周期,我們把訂單叫做聚合根,它就像一個(gè)導(dǎo)航員一樣。
- class Order {
- private Collection<OrderItem> orderItems;
- private int totalPrice;
- }
- class OrderItem {
- private String productId;
- private int price;
- private int count;
- }
Order 的 totalPrice 必須是 OrderItem 的 price 之和,還要考慮折扣等其他問(wèn)題,總之對(duì)象的改變都需要整體更新。
理想中最好的方式就是把聚合根整體持久化,不過(guò)問(wèn)題并沒(méi)那么簡(jiǎn)單。
聚合持久化問(wèn)題
如果你使用 MySQL 等關(guān)系型數(shù)據(jù)庫(kù),集合的持久化是一個(gè)比較麻煩的事情:
- 關(guān)系的映射不好處理,層級(jí)比較深的對(duì)象不好轉(zhuǎn)換。
- 將數(shù)據(jù)轉(zhuǎn)換為聚合時(shí)會(huì)有 n+1 的問(wèn)題,不好使用關(guān)系數(shù)據(jù)庫(kù)的聯(lián)表特性。
- 全量的數(shù)據(jù)更新數(shù)據(jù)庫(kù)的事務(wù)較大,性能低下。
- 其他問(wèn)題
聚合的持久化是 DDD 美好愿景落地的最大攔路虎,這些問(wèn)題有部分可以被解決而有部分必須取舍。
聚合的持久化到關(guān)系數(shù)據(jù)庫(kù)的問(wèn)題,本質(zhì)是計(jì)算機(jī)科學(xué)的模型問(wèn)題。
聚合持久化是面向?qū)ο竽P秃完P(guān)系模型的轉(zhuǎn)換,這也是為什么 MongoDB 沒(méi)有這個(gè)問(wèn)題,但也用不了關(guān)系數(shù)據(jù)庫(kù)的特性和能力。
面向?qū)ο竽P完P(guān)心的是業(yè)務(wù)能力承載,關(guān)系模型關(guān)心的是數(shù)據(jù)的一致性、低冗余。描述關(guān)系模型的理論基礎(chǔ)是范式理論,越低的范式就越容易轉(zhuǎn)換到對(duì)象模型。
理論指導(dǎo)實(shí)踐,再來(lái)分析這幾個(gè)問(wèn)題:
“關(guān)系的映射不好處理” 如果我們不使用多對(duì)多關(guān)系,數(shù)據(jù)設(shè)計(jì)到第三范式,可以將關(guān)系網(wǎng)退化到一顆樹(shù)。
△ 網(wǎng)狀的關(guān)系
△ 樹(shù)狀的關(guān)系
"將數(shù)據(jù)轉(zhuǎn)換為聚合時(shí)會(huì)有 n+1 的問(wèn)題" 使用了聚合就不好使用集合的能力,列表查詢可以使用讀模型,直接獲取結(jié)果集,也可以利用聚合對(duì)緩存的優(yōu)勢(shì)使用緩存減輕 n+1 問(wèn)題。
"全量的數(shù)據(jù)更新數(shù)據(jù)庫(kù)的事務(wù)較大" 設(shè)計(jì)小聚合,這是業(yè)務(wù)一致性的代價(jià),基本無(wú)法避免,但是對(duì)于一般應(yīng)用來(lái)說(shuō),寫(xiě)和更新對(duì)數(shù)據(jù)庫(kù)的頻率并不高。使用讀寫(xiě)分離即可解決這個(gè)問(wèn)題。
自己實(shí)現(xiàn)一個(gè) Repository 層
如果你在使用 Mybatis 或者使用原生的 SQL 來(lái)編寫(xiě)程序,你可以自己抽象一個(gè) Repository 層,這層只提供給聚合根使用,所有的對(duì)象都需要使用聚合根來(lái)完成持久化。
一種方式是,使用 Mybatis Mapper,對(duì) Mapper 再次封裝。
- class OrderRepository {
- private OrderMapper orderMapper;
- private OrderItemMapper orderItemMapper;
- public Order get(String orderId) {
- Order order = orderMapper.findById(orderId);
- order.setOrderItems(orderItemMapper.findAllByOrderId(orderId))
- return order;
- }
- }
這種做法有一個(gè)小點(diǎn)問(wèn)題,領(lǐng)域?qū)ο?Order 中有 orderItems 這個(gè)屬性,但是數(shù)據(jù)庫(kù)中不可能有 Items,一些開(kāi)發(fā)者會(huì)認(rèn)為這里的 Order 和通常數(shù)據(jù)庫(kù)使用的 OrderEntity 不是一類(lèi)對(duì)象,于是進(jìn)行繁瑣的類(lèi)型轉(zhuǎn)換。
類(lèi)型轉(zhuǎn)換和多余的一層抽象,加大了工作量。
如果使用 Mybatis,其實(shí)更好的方式是直接使用 Mapper 作為 Repository 層,并在 XML 中使用動(dòng)態(tài) SQL 實(shí)現(xiàn)上述代碼。
還有一個(gè)問(wèn)題是,一對(duì)多的關(guān)系,發(fā)生了移除操作怎么處理呢?
比較簡(jiǎn)單的方式是直接刪除,再存入新的數(shù)組即可,也可以實(shí)現(xiàn)對(duì)象的對(duì)比,有選擇的實(shí)現(xiàn)刪除和增加。
完成了這些,恭喜你,得到了一個(gè)完整的 ORM,例如 Hibernate 。
使用 Spring Data JPA
所以我們可以使用 JPA 的級(jí)聯(lián)更新實(shí)現(xiàn)聚合根的持久化。
大家在實(shí)際操作中發(fā)現(xiàn),JPA 并不好用。
其實(shí)這不是 JPA 的問(wèn)題,是因?yàn)?JPA 做的太多了,JPA 不僅有各種狀態(tài)轉(zhuǎn)換,還有多對(duì)多關(guān)系。
如果保持克制就可以使用 JPA 實(shí)現(xiàn) DDD,嘗試遵守下面的規(guī)則:
- 不要使用 @ManyToMany 特性
- 只給聚合根配置 Repository 對(duì)象。
- 避免造成網(wǎng)狀的關(guān)系
- 讀寫(xiě)分離。關(guān)聯(lián)等復(fù)雜查詢,讀寫(xiě)分離查詢不要給 JPA 做,JPA 只做單個(gè)對(duì)象的查詢
在這些基本的規(guī)則下可以使用 @OneToMany 的 cascade 屬性來(lái)自動(dòng)保存、更新聚合。
- class Order {
- @Id
- @GeneratedValue(strategy = GenerationType.AUTO)
- private String id;
- @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
- @JoinColumn(name = "order_id")
- private Collection<OrderItem> orderItems;
- private int totalPrice;
- }
- class OrderItem {
- @Id
- @GeneratedValue(strategy = GenerationType.AUTO)
- private String id;
- private String productId;
- private int price;
- private int count;
- }
OneToMany 中的 cascade 有不同的屬性,如果需要讓更新、刪除都有效可以設(shè)置為 ALL。
使用 Spring Dat JDBC
Mybatis 就是一個(gè) SQL 模板引擎,而 JPA 做的太多,有沒(méi)有一個(gè)適中的 ORM 來(lái)持久化聚合呢?
Spring Data JDBC 就是人們?cè)O(shè)計(jì)出來(lái)持久化聚合,從名字來(lái)看他不是 JDBC,而是使用 JDBC 實(shí)現(xiàn)了部分 JPA 的規(guī)范,讓你可以繼續(xù)使用 Spring Data 的編程習(xí)慣。
Spring Dat JDBC 的一些特點(diǎn):
- 沒(méi)有 Hibernate 中 session 的概念,沒(méi)有對(duì)象的各種狀態(tài)
- 沒(méi)有懶加載,保持對(duì)象的完整性
- 除了 SPring Data 的基本功能,保持簡(jiǎn)單,只有保存方法、事務(wù)、審計(jì)注解、簡(jiǎn)單的查詢方法等。
- 可以搭配 JOOQ 或 Mybatis 實(shí)現(xiàn)復(fù)雜的查詢能力。
Spring Dat JDBC 的使用方式和 JPA 幾乎沒(méi)有區(qū)別,就不浪費(fèi)時(shí)間貼代碼了。
如果你使用 Spring Boot,可以直接使用 spring-boot-starter-data-jdbc 完成配置:
- spring-boot-starter-data-jdbc
不過(guò)需要注意的是,Spring Data JDBC 的邏輯:
- 如果聚合根是一個(gè)新的對(duì)象,Spring Data JDBC 會(huì)遞歸保存所有的關(guān)聯(lián)對(duì)象。
- 如果聚合根是一個(gè)舊的對(duì)象,Spring Data JDBC 會(huì)刪除除了聚合根之外舊的對(duì)象再插入,聚合根會(huì)被更新。因?yàn)闆](méi)有之前對(duì)象的狀態(tài),這是一種不得不做的事情。也可以按照自己策略覆蓋相關(guān)方法。
使用 Domain Service 變通處理
正是因?yàn)楹?ORM 一起時(shí)候會(huì)有各種限制,而抽象一個(gè) Repository 層會(huì)帶來(lái)大的成本,所以有一種變通的方法。
這種方法不使用充血模型、也不讓 Repository 來(lái)保證聚合的一致性,而是使用領(lǐng)域服務(wù)來(lái)實(shí)現(xiàn)相關(guān)邏輯,但會(huì)被批評(píng)為 DDD lite 或不是 “純正的 DDD”。
這種編程范式有如下規(guī)則:
- 按照 DDD 四層模型,Application Service 和 Domain Service 分開(kāi),Application Service 負(fù)責(zé)業(yè)務(wù)編排,不是必須的一層,可以由 UI 層兼任。
- 一個(gè)聚合使用 DomainService 來(lái)保持業(yè)務(wù)的一致性,一個(gè)聚合只有一個(gè) Domain Service。Domain Service 內(nèi)使用 ORM 的各種持久化技術(shù)。
- 除了 Domain Service 不允許其他地方之間使用 ORM 更新數(shù)據(jù)。
當(dāng)不被充血模型困住的時(shí)候,問(wèn)題變得更清晰。
DDD 只是手段不是目的,對(duì)一般業(yè)務(wù)系統(tǒng)而言,充血模型不是必要的,我們的目的是讓編碼和業(yè)務(wù)清晰。
這里引入兩個(gè)概念:
- 業(yè)務(wù)主體。操作領(lǐng)域模型的擬人化對(duì)象,用來(lái)承載業(yè)務(wù)規(guī)則,也就是 Domain Service,比如訂單聚合可以由一個(gè)服務(wù)來(lái)管理,保證業(yè)務(wù)的一致性。我們可以命名為:OrderManager.
- 業(yè)務(wù)客體。聚合和領(lǐng)域?qū)ο?,用?lái)承載業(yè)務(wù)屬性和數(shù)據(jù)。這些對(duì)象需要有狀態(tài)和自己的生命周期,比如 Order、OrderItem。
回歸到原始的編程哲學(xué):
程序 = 數(shù)據(jù)結(jié)構(gòu) + 算法
業(yè)務(wù)主體負(fù)責(zé)業(yè)務(wù)規(guī)則(算法),業(yè)務(wù)客體負(fù)責(zé)業(yè)務(wù)屬性和數(shù)據(jù)(數(shù)據(jù)結(jié)構(gòu)),那么用不用 DDD 都能讓代碼清晰、明白和容易處理了。
【本文是51CTO專(zhuān)欄作者“ThoughtWorks”的原創(chuàng)稿件,微信公眾號(hào):思特沃克,轉(zhuǎn)載請(qǐng)聯(lián)系原作者】