一個DDD指導下的實體類設計案例

https://www.cnkirito.moe/DDD-practice/

1 引子

項目開發中的工具類代碼總是隨着項目發展逐漸變大,在公司諸多的公用代碼中,筆者發現了一個簡單的,也是經常被使用的類:BaseDomain,引起了我的思考。
在我們公司的開發習慣中,數據庫實體類通常會繼承一個叫做BaseDomain的類,這個類很簡單,主要用來填充一些數據庫實體公用的屬性,它的設計如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@MappedSuperclass <1>
public class BaseDomain {
    
    private Boolean deleteFlag; <2>
    private Date deleteDate;
    private Date lastUpdateDate;
    private Date createDate;
    @Version <3>
    private Integer version;
    
    @PrePersist <4>
    public void init(){
        Date now = new Date();
        deleteFlag = false;
        createDate = lastUpdateDate = now;
    }
    
    @PreUpdate <4>
    public void update(){
        lastUpdateDate = new Date();
    }
    
}

 

小小的一個類其實還是蘊含了不少的知識點在裏面,至少可以包含以下幾點:

<1> 被其他類繼承後,父類的字段不會被忽略,也就意味着子類沒有必要自己寫這一堆公用的屬性了。

<2> 邏輯刪除標識,業務類的刪除必須是這種打標識的行爲,不能進行物理刪除。值得一提的是,公司原先的該字段被命名成了isDelete,這不符合變量命名的規範,會導致一些序列化框架出現問題,而delete是數據庫的保留字,所以本文中用deleteFlag。

<3> 使用version作爲樂觀鎖的實現,version的自增以及版本失效異常受@Version該註解的影響,是由框架控制的。

<4> 創建日期,更新日期等等屬性,在我們使用JPA的save方法後,框架會自動去填充相應的值。

2 發現問題與解決問題

這個基類使用的頻次是怎麼樣的呢?every class!是的,公司的每個開發者在新增一個實體類時總是優先寫上Xxx extends BaseDomain 。初級開發者總是有什麼學什麼,他們看到公司原來的代碼都是會繼承這個類,以及周圍的同事也是這麼寫着,他們甚至不知道version樂觀鎖的實現,不知道類的創建日期更新日期是在基類中被聲明的;高級開發者能夠掌握我上面所說的那些技術要點,儘管開發中因此遇到一些不適,但也是儘可能的克服。
等等,上面說到添加這個基類後,對開發造成了不適感,這引起了我的思考,下面就來談談直觀的有哪些不適感以及解決方案。

2.1 沒有物理刪除,只有邏輯刪除

真正delete操作不會再出現了,物理刪除操作被setDeleteFlag(true)代替。在列表展示中,再也不能使用findAll()操作了,而是需要使用findByDeleteFlagFalse()。更多的數據庫查詢操作,都要考慮到,deleteFlag=true的那些記錄,不應該被影響到。

解決問題:在DDD中,值得推崇的方式是使用specification模式來解決這個問題,對應到實際開發中,也就是JPA的Predicate,或者是熟悉Hibernate的人所瞭解的Criteria。但不可避免的一點是由於只有邏輯刪除,導致了我們的數據庫越來越大(解決方法不是沒有,正是EventSouring+CQRS架構,這屬於DDD的高級實踐,本文不進行討論)。從技術開發角度出發,這的確使得我們的編碼變得稍微複雜了一點,但是其業務意義遠大於這點開發工作量,所以是值得的。

2.2 級聯查詢變得麻煩

一個會員有多個通信地址,多個銀行卡。反映到實體設計,便是這樣的:

1
2
3
4
5
6
7
8
9
10
11
public class Member extends BaseDomain{
  
  private String username;

  @OneToMany
  private List<MemberAddress> memberAddresses;

  @OneToMany
  private List<BankCard> bankCards;
    
}

其中,MemberAddress及BankCard都繼承了BaseDomain。使用orm框架自帶的級聯功能,我們本可以查詢出會員信息時,順帶查出其對應的通訊地址列表和銀行卡列表。但現在不是那麼的美好了,使用級聯查詢,可能會查詢出已經被刪除的MemberAddress,BankCard,只能在應用層進行deleteFlag的判斷,從而過濾被刪除的信息,這無法避免,因爲框架不認識邏輯刪除標識!

解決問題:這個問題和2.3節的問題,恰恰是促成我寫這篇文章的初衷,這與DDD有着密不可分的關聯。DDD將對象劃分成了entity(實體)和value object(值對象)。如果仔細分析下上面的業務並且懂一點DDD,你會立刻意識到。Member對象就是一個entity,而MemberAddress以及BankCard則是value object(username也是value object)。value object的一個重要特點,就是作爲entity的修飾,從業務角度出發,MemberAddress和BankCard的確是爲了更好描述Member信息,而抽象出的一個集合。而value object的另一特性,不可變性,指導了我們,不應該讓MemberAddress,BankCard繼承BaseDomain。說了這麼多,就是想從一個理論的高度,讓那些設計一個新實體便繼承BaseDomain的人戒掉這個習慣。在value object喪失了deleteFlag,lastUpdateDate等屬性後,可能會引發一些的質疑,他們會聲稱:“數據庫裏面member_address這張表沒有lastUpdateDate字段了,我再也無法得知這條會員地址最後修改的時間了!”。是的,從邏輯意義上看,地址並沒有改變,而改變的只是會員自己的地址,這個UpdateDate字段在地址上極爲不合理,應該是會員的修改。也就是說lastUpdateDate應該反映到Member上。實際的開發經驗告訴我,從前那麼多的value object繼承了BaseDomain,99%不會使用到其中的相關屬性,如果真的需要使用,那麼請單獨爲類添加,而不是繼承BaseDomain。其次這些人犯了另一個錯誤,我們設計一個系統時,應該是entity first,而不應該database first。DDD告訴我們一個軟件開發的大忌,到現在2017年,仍然有大幫的人在問:“我要實現xxxx功能,我的數據庫應該如何設計?”這些人犯了根本性的錯誤,就是把軟件的目的搞錯了,軟件研究的是什麼?是研究如何使用計算機來解決實際(領域)問題,而不是去研究數據應該如何保存更合理。我的公司中有不少的程序員新人,希望這番話能夠幫助那些“步入歧途”的從業人員 “走上正路”。軟件設計應該從“數據庫驅動”走向“領域驅動”,而DDD的實踐經驗正是爲設計和開發大型複雜的軟件系統提供了實踐指導。

2.3 樂觀鎖的尷尬地位

再說回BaseDomain中的version字段,由於MemberAddress和BankCard這樣的value object也被賦予了樂觀鎖的行爲,這意味着加鎖的粒度變小了。DDD的指導下,改動也可以理解爲由Member這個根發出,統一由Member中的version來控制,這使鎖的粒度變大了。換言之,從技術開發角度,對value object加上version可以允許同時(操作系統級別真正的同時)修改一個用戶的地址信息和銀行卡信息,甚至是多個銀行卡中不同的銀行卡,而單獨由Member控制,則意味着,系統在同一時刻只能進行單獨一項操作。在業務併發的一般角度上考慮,一個用戶是不會出現多線程修改行爲的。而從軟件設計的角度,單獨爲value object 添加version,破壞了value object的不可變性,若要修改,應當是被整個替換。

解決方案:在一般情況下,請不要爲value object添加樂觀鎖。如果有一個場景下,你的value object需要出現版本控制,那可能有兩種情況:1 你的value object是壓根不是value object,可能是一個entity 2 聚合根劃分錯誤 ….這,要真是這樣源頭都弄錯了,壓根沒法聊了對吧

3 總結

BaseDomain這樣的設計本身並不是我想要強調的重點,但是既然出現了BaseDomain這樣的設計,那麼它究竟應該被什麼樣的實體繼承,就是需要被考慮的了。DDD下,識別aggregate root,entity,value object,是整個軟件設計的核心點,在本文中,判別是否繼承BaseDomain的前提,就是這個對象是entity,還是value object。大家都是存在數據庫中的,但是地位是不一樣的。

本文若有什麼不足之處,歡迎DDD愛好者指出。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章