阿里技術專家詳解 DDD 系列 第三講 - Repository 模式

作者 | 殷浩

出品 | 阿里巴巴新零售淘系技術部

DDD 的宏觀理念其實並不難懂,但是如同 REST 一樣,DDD 也只是一個設計思想,缺少一套完整的規範,導致 DDD 新手落地困難。

我之前的架構篇主要從頂層設計往下看,從這一篇開始我希望能填補上一些 DDD 的代碼落地規範,幫助同學在日常工作中落地 DDD 思想,並且希望能通過一整套規範,讓不同的業務之間的同學能夠更快的看懂、掌握對方的代碼。但是規則是死的、人是活的,各位同學需要根據自己業務的實際情況去有選擇的去落地規範,DDD 的規範不可能覆蓋所有場景,但我希望能通過解釋,讓同學們瞭解 DDD 背後的一些思考和取捨。

爲什麼要用 Repository

▐  實體模型 vs. 貧血模型

Entity(實體)這個詞在計算機領域的最初應用可能是來自於 Peter Chen 在 1976 年的 “The Entity-Relationship Model - Toward a Unified View of Data"(ER 模型),用來描述實體之間的關係,而 ER 模型後來逐漸的演變成爲一個數據模型,在關係型數據庫中代表了數據的儲存方式。

而 2006 年的 JPA 標準,通過 @Entity 等註解,以及 Hibernate 等 ORM 框架的實現,讓很多 Java 開發對 Entity 的理解停留在了數據映射層面,忽略了 Entity 實體的本身行爲,造成今天很多的模型僅包含了實體的數據和屬性,而所有的業務邏輯都被分散在多個服務、Controller、Utils 工具類中,這個就是 Martin Fowler 所說的的 Anemic Domain Model(貧血領域模型)。

如何知道你的模型是貧血的呢?可以看一下你代碼中是否有以下的幾個特徵:

  1. **有大量的 XxxDO 對象:**這裏 DO 雖然有時候代表了 Domain Object,但實際上僅僅是數據庫表結構的映射,裏面沒有包含(或包含了很少的)業務邏輯;

  2. **服務和 Controller 裏有大量的業務邏輯:**比如校驗邏輯、計算邏輯、格式轉化邏輯、對象關係邏輯、數據存儲邏輯等;

  3. 大量的 Utils 工具類等。

而貧血模型的缺陷是非常明顯的:

  1. **無法保護模型對象的完整性和一致性:**因爲對象的所有屬性都是公開的,只能由調用方來維護模型的一致性,而這個是沒有保障的;之前曾經出現的案例就是調用方沒有能維護模型數據的一致性,導致髒數據使用時出現 bug,這一類的 bug 還特別隱蔽,很難排查到。

  2. **對象操作的可發現性極差:**單純從對象的屬性上很難看出來都有哪些業務邏輯,什麼時候可以被調用,以及可以賦值的邊界是什麼;比如說,Long 類型的值是否可以是 0 或者負數?

  3. **代碼邏輯重複:**比如校驗邏輯、計算邏輯,都很容易出現在多個服務、多個代碼塊裏,提升維護成本和 bug 出現的概率;一類常見的 bug 就是當貧血模型變更後,校驗邏輯由於出現在多個地方,沒有能跟着變,導致校驗失敗或失效。

  4. **代碼的健壯性差:**比如一個數據模型的變化可能導致從上到下的所有代碼的變更。

  5. **強依賴底層實現:**業務代碼裏強依賴了底層數據庫、網絡 / 中間件協議、第三方服務等,造成核心邏輯代碼的僵化且維護成本高。

雖然貧血模型有很大的缺陷,但是在我們日常的代碼中,我見過的 99% 的代碼都是基於貧血模型,爲什麼呢?我總結了以下幾點:

  1. **數據庫思維:**從有了數據庫的那一天起,開發人員的思考方式就逐漸從 “寫業務邏輯“轉變爲了” 寫數據庫邏輯”,也就是我們經常說的在寫 CRUD 代碼。

  2. **貧血模型 “簡單”:**貧血模型的優勢在於 “簡單”,僅僅是對數據庫表的字段映射,所以可以從前到後用統一格式串通。這裏簡單打了引號,是因爲它只是表面上的簡單,實際上當未來有模型變更時,你會發現其實並不簡單,每次變更都是非常複雜的事情

  3. **腳本思維:**很多常見的代碼都屬於 “腳本” 或“膠水代碼”,也就是流程式代碼。腳本代碼的好處就是比較容易理解,但長久來看缺乏健壯性,維護成本會越來越高。

但是可能最核心的原因在於,實際上我們在日常開發中,混淆了兩個概念:

所以,解決這個問題的根本方案,就是要在代碼裏嚴格區分 Data Model 和 Domain Model,具體的規範會在後文詳細描述。在真實代碼結構中,Data Model 和 Domain Model 實際上會分別在不同的層裏,Data Model 只存在於數據層,而 Domain Model 在領域層,而鏈接了這兩層的關鍵對象,就是 Repository。

▐  Repository 的價值

在傳統的數據庫驅動開發中,我們會對數據庫操作做一個封裝,一般叫做 Data Access Object(DAO)。DAO 的核心價值是封裝了拼接 SQL、維護數據庫連接、事務等瑣碎的底層邏輯,讓業務開發可以專注於寫代碼。但是在本質上,DAO 的操作還是數據庫操作,DAO 的某個方法還是在直接操作數據庫和數據模型,只是少寫了部分代碼。在 Uncle Bob 的《代碼整潔之道》一書裏,作者用了一個非常形象的描述:

從上面的描述我們能看出來,數據庫在本質上屬於”硬件 “,DAO 在本質上屬於” 固件 “,而我們自己的代碼希望是屬於” 軟件“。但是,固件有個非常不好的特性,那就是會傳播,也就是說當一個軟件強依賴了固件時,由於固件的限制,會導致軟件也變得難以變更,最終讓軟件變得跟固件一樣難以變更。

舉個軟件很容易被 “固化” 的例子:

在上面的這段簡單代碼裏,該對象依賴了 DAO,也就是依賴了 DB。雖然乍一看感覺並沒什麼毛病,但是假設未來要加一個緩存邏輯,代碼則需要改爲如下:

所以,我們需要一個模式,能夠隔離我們的軟件(業務邏輯)和固件 / 硬件(DAO、DB),讓我們的軟件變得更加健壯,而這個就是 Repository 的核心價值。

模型對象代碼規範

▐  對象類型

在講 Repository 規範之前,我們需要先講清楚 3 種模型的區別,Entity、Data Object (DO) 和 Data Transfer Object (DTO):

▐  模型對象之間的關係

在實際開發中 DO、Entity 和 DTO 不一定是 1:1:1 的關係。一些常見的非 1:1 關係如下:

複雜的 Entity 拆分多張數據庫表:常見的原因在於字段過多,導致查詢性能降低,需要將非檢索、大字段等單獨存爲一張表,提升基礎信息表的檢索效率。常見的案例如商品模型,將商品詳細描述等大字段單獨保存,提升查詢性能:

多個關聯的 Entity 合併一張數據庫表:這種情況通常出現在擁有複雜的 Aggregate Root - Entity 關係的情況下,且需要分庫分表,爲了避免多次查詢和分庫分錶帶來的不一致性,犧牲了單表的簡潔性,提升查詢和插入性能。常見的案例如主子訂單模型:

從複雜 Entity 裏抽取部分信息形成多個 DTO:這種情況通常在 Entity 複雜,但是調用方只需要部分核心信息的情況下,通過一個小的 DTO 降低信息傳輸成本。同樣拿商品模型舉例,基礎 DTO 可能出現在商品列表裏,這個時候不需要複雜詳情:

合併多個 Entity 爲一個 DTO:這種情況通常爲了降低網絡傳輸成本,降低服務端請求次數,將多個 Entity、DP 等對象合併序列化,並且讓 DTO 可以嵌套其他 DTO。同樣常見的案例是在訂單詳情裏需要展示商品信息:

▐  模型所在模塊和轉化器

由於現在從一個對象變爲 3 + 個對象,對象間需要通過轉化器(Converter/Mapper)來互相轉化。而這三種對象在代碼中所在的位置也不一樣,簡單總結如下:

DTO Assembler:在 Application 層,Entity 到 DTO 的轉化器有一個標準的名稱叫 DTO Assembler。Martin Fowler 在 P of EAA 一書裏對於 DTO 和 Assembler 的描述:Data Transfer Object。DTO Assembler 的核心作用就是將 1 個或多個相關聯的 Entity 轉化爲 1 個或多個 DTO。

Data Converter:在 Infrastructure 層,Entity 到 DO 的轉化器沒有一個標準名稱,但是爲了區分 Data Mapper,我們叫這種轉化器 Data Converter。這裏要注意 Data Mapper 通常情況下指的是 DAO,比如 Mybatis 的 Mapper。Data Mapper 的出處也在 P of EAA 一書裏:Data Mapper

如果是手寫一個 Assembler,通常我們會去實現 2 種類型的方法,如下;Data Converter 的邏輯和此類似,略過。

在調用方使用時是非常方便的(請忽略各種異常邏輯):

雖然 Assembler/Converter 是非常好用的對象,但是當業務複雜時,手寫 Assembler/Converter 是一件耗時且容易出 bug 的事情,所以業界會有多種 Bean Mapping 的解決方案,從本質上分爲動態和靜態映射。

動態映射方案包括比較原始的 BeanUtils.copyProperties、能通過 xml 配置的 Dozer 等,其核心是在運行時根據反射動態賦值。動態方案的缺陷在於大量的反射調用,性能比較差,內存佔用多,不適合特別高併發的應用場景。

所以在這裏我給用 Java 的同學推薦一個庫叫 MapStruct(MapStruct 官網)。MapStruct 通過註解,在編譯時靜態生成映射代碼,其最終編譯出來的代碼和手寫的代碼在性能上完全一致,且有強大的註解等能力。如果你的 IDE 支持,甚至可以在編譯後看到編譯出來的映射代碼,用來做 check。在這裏我就不細講 MapStruct 的用法了,具體細節請見官網。

用了 MapStruct 之後,會節省大量的成本,讓代碼變得簡潔如下:

▐  模型規範總結

Repository 代碼規範

▐  接口規範

  1. **接口名稱不應該使用底層實現的語法:**我們常見的 insert、select、update、delete 都屬於 SQL 語法,使用這幾個詞相當於和 DB 底層實現做了綁定。相反,我們應該把 Repository 當成一箇中性的類 似 Collection 的接口,使用語法如 find、save、remove。在這裏特別需要指出的是區分 insert/add 和 update 本身也是一種和底層強綁定的邏輯,一些儲存如緩存實際上不存在 insert 和 update 的差異,在這個 case 裏,使用中性的 save 接口,然後在具體實現上根據情況調用 DAO 的 insert 或 update 接口。

  2. **出參入參不應該使用底層數據格式:**需要記得的是 Repository 操作的是  Entity 對象(實際上應該是 Aggregate Root),而不應該直接操作底層的 DO 。更近一步,Repository 接口實際上應該存在於 Domain 層,根本看不到 DO 的實現。這個也是爲了避免底層實現邏輯滲透到業務代碼中的強保障。

  3. **應該避免所謂的 “通用”Repository 模式:**很多 ORM 框架都提供一個 “通用” 的 Repository 接口,然後框架通過註解自動實現接口,比較典型的例子是 Spring Data、Entity Framework 等,這種框架的好處是在簡單場景下很容易通過配置實現,但是壞處是基本上無擴展的可能性(比如加定製緩存邏輯),在未來有可能還是會被推翻重做。當然,這裏避免通用不代表不能有基礎接口和通用的幫助類,具體如下。

我們先定義一個基礎的 Repository 基礎接口類,以及一些 Marker 接口類:

這裏需要再次強調的是 Repository 的接口是在 Domain 層,但是實現類是在 Infrastructure 層。

▐  Repository 基礎實現

先舉個 Repository 的最簡單實現的例子。注意 OrderRepositoryImpl 在 Infrastructure 層:

從上面的實現能看出來一些套路:所有的 Entity/Aggregate 會被轉化爲 DO,然後根據業務場景,調用相應的 DAO 方法進行操作,事後如果需要則把 DO 轉換回 Entity。代碼基本很簡單,唯一需要注意的是 save 方法,需要根據 Aggregate 的 ID 是否存在且大於 0 來判斷一個 Aggregate 是否需要更新還是插入。

▐  Repository 複雜實現

針對單一 Entity 的 Repository 實現一般比較簡單,但是當涉及到多 Entity 的 Aggregate Root 時,就會比較麻煩,最主要的原因是在一次操作中,並不是所有 Aggregate 裏的 Entity 都需要變更,但是如果用簡單的寫法,會導致大量的無用 DB 操作。

舉一個常見的例子,在主子訂單的場景下,一個主訂單 Order 會包含多個子訂單 LineItem,假設有個改某個子訂單價格的操作,會同時改變主訂單價格,但是對其他子訂單無影響:

如果用一個非常 naive 的實現來完成,會導致多出來兩個無用的更新操作,如下:

在這個情況下,會導致 4 個 UPDATE 操作,但實際上只需要 2 個。在絕大部分情況下,這個成本不高,可以接受,但是在極端情況下(當非 Aggregate Root 的 Entity 非常多時),會導致大量的無用寫操作。

▐  Change-Tracking 變更追蹤

在上面那個案例裏,核心的問題是由於 Repository 接口規範的限制,讓調用方僅能操作 Aggregate Root,而無法單獨針對某個非 Aggregate Root 的 Entity 直接操作。這個和直接調用 DAO 的方式很不一樣。

這個的解決方案是需要能識別到底哪些 Entity 有變更,並且只針對那些變更過的 Entity 做操作,就需要加上變更追蹤的能力。換一句話說就是原來很多人爲判斷的代碼邏輯,現在可以通過變更追蹤來自動實現,讓使用方真正只關心 Aggregate 的操作。在上一個案例裏,通過變更追蹤,系統可以判斷出來只有 LineItem2 和 Order 有變更,所以只需要生成兩個 UPDATE 即可。

業界有兩個主流的變更追蹤方案:

  1. **基於 Snapshot 的方案:**當數據從 DB 裏取出來後,在內存中保存一份 snapshot,然後在數據寫入時和 snapshot 比較。常見的實現如 Hibernate

  2. **基於 Proxy 的方案:**當數據從 DB 裏取出來後,通過 weaving 的方式將所有 setter 都增加一個切面來判斷 setter 是否被調用以及值是否變更,如果變更則標記爲 Dirty。在保存時根據 Dirty 判斷是否需要更新。常見的實現如 Entity Framework。

Snapshot 方案的好處是比較簡單,成本在於每次保存時全量 Diff 的操作(一般用 Reflection),以及保存 Snapshot 的內存消耗。

Proxy 方案的好處是性能很高,幾乎沒有增加的成本,但是壞處是實現起來比較困難,且當有嵌套關係存在時不容易發現嵌套對象的變化(比如子 List 的增加和刪除等),有可能導致 bug。

由於 Proxy 方案的複雜度,業界主流(包括 EF Core)都在使用 Snapshot 方案。這裏面還有另一個好處就是通過 Diff 可以發現哪些字段有變更,然後只更新變更過的字段,再一次降低 UPDATE 的成本。

在這裏我簡單貼一下我們自己 Snapshot 的實現,代碼並不複雜,每個團隊自己實現起來也很簡單,部分代碼僅供參考:

DbRepositorySupport

使用方只需要繼承 DbRepositorySupport:

AggregateManager 實現,主要是通過 ThreadLocal 避免多線程公用同一個 Entity 的情況

class ThreadLocalAggregateManager<T extends Aggregate<ID>, ID extends Identifier> implements AggregateManager<T, ID> {
    private ThreadLocal<DbContext<T, ID>> context;
    private Class<? extends T> targetClass;
    public ThreadLocalAggregateManager(Class<? extends T> targetClass) {
        this.targetClass = targetClass;
        this.context = ThreadLocal.withInitial(() -> new DbContext<>(targetClass));
    }
    public void attach(T aggregate) {
        context.get().attach(aggregate);
    }
    @Override
    public void attach(T aggregate, ID id) {
        context.get().setId(aggregate, id);
        context.get().attach(aggregate);
    }
    @Override
    public void detach(T aggregate) {
        context.get().detach(aggregate);
    }
    @Override
    public T find(ID id) {
        return context.get().find(id);
    }
    @Override
    public EntityDiff detectChanges(T aggregate) {
        return context.get().detectChanges(aggregate);
    }
    public void merge(T aggregate) {
        context.get().merge(aggregate);
    }
}

跑個單測(注意在這個 case 裏我把 Order 和 LineItem 合併單表了):

單測結果:

併發樂觀鎖

在高併發情況下,如果使用上面的 Change-Tracking 方法,由於 Snapshot 在本地內存的數據 * 有可能 * 和 DB 數據不一致,會導致併發衝突的問題,這個時候需要在更新時加入樂觀鎖。當然,正常數據庫操作的 Best Practice 應該也要有樂觀鎖,只不過在這個 case 裏,需要在樂觀鎖衝突後,記得更新本地 Snapshot 裏的值。

一個可能的 BUG

這個其實算不上 bug,但是單獨指出來希望大家能注意一下,使用 Snapshot 的一個副作用就是如果沒更新 Entity 然後調用了 save 方法,這時候實際上是不會去更新 DB 的。這個邏輯跟 Hibernate 的邏輯一致,是 Snapshot 方法的天生特性。如果要強制更新到 DB,建議手動更改一個字段如 gmtModified,然後再調用 save。

Repository 遷移路徑

在我們日常的代碼中,使用 Repository 模式是一個很簡單,但是又能得到很多收益的事情。最大的收益就是可以徹底和底層實現解耦,讓上層業務可以快速自發展。

我們假設現有的傳統代碼包含了以下幾個類(還是用訂單舉例):

可以通過以下幾個步驟逐漸的實現 Repository 模式:

  1. 生成 Order 實體類,初期字段可以和 OrderDO 保持一致

  2. 生成 OrderDataConverter,通過 MapStruct 基本上 2 行代碼就能完成

  3. 寫單元測試,確保 Order 和 OrderDO 之間的轉化 100% 正確

  4. 生成 OrderRepository 接口和實現,通過單測確保 OrderRepository 的正確性

  5. 將原有代碼裏使用了 OrderDO 的地方改爲 Order

  6. 將原有代碼裏使用了 OrderDAO 的地方都改爲用 OrderRepository

  7. 通過單測確保業務邏輯的一致性。

恭喜你!從現在開始 Order 實體類和其業務邏輯可以隨意更改,每次修改你唯一需要做的就是變更一下 Converter,已經和底層實現完全解藕了。

寫在後面

感謝你,能有耐心看到這裏的都是 DDD 真愛。一個問題,你是否在日常工作中能大量的利用 DDD 的架構來推進你的業務?你是否有一個環境能把你的所學用到真正實戰中去?

鏈接:

1、https://martinfowler.com/eaaCatalog/dataTransferObject.html?spm=ata.13261165.0.0.590a62fcaM6bCk

2、https://martinfowler.com/eaaCatalog/dataMapper.html?spm=ata.13261165.0.0.590a62fcaM6bCk

3、https://mapstruct.org/?spm=ata.13261165.0.0.590a62fcaM6bCk

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s?__biz=MzAxNDEwNjk5OQ==&amp;mid=2650406692&amp;idx=1&amp;sn=4a4ac4168299d8ca1905a4f457ae4c59&amp;chksm=8395373cb4e2be2a2d066a5ea4e631fd6270e969ce61883b488f61c1ce33fbc0b362ec9cbf7b&amp;scene=132#wechat_redirect