大數據

殷浩詳解DDD系列 第三講 – Repository模式

第三講 - Repository模式

寫在前面

這篇文章和上一篇隔了比較久,一方面是工作比較忙,另一方面是在講Repository之前其實應該先講Entity(實體)、Aggregate Root(聚合根)、Bounded Context(限界上下文)等概念。但在實際寫的過程中,發現單純講Entity相關的東西會比較抽象,很難落地。所以本文被推倒重來,從Repository開始入手,先把可以落地的、能形成規範的東西先確定下來,最後再嘗試落地Entity。這個當然也是我們可以在日常按照DDD重構時嘗試的路徑。提前預告,接下來的一篇文章將覆蓋Anti-Corruption Layer(防腐層)的邏輯,但是你會發現跟Repository模式的理念非常接近。等所有周邊的東西都覆蓋之後,再詳細講Entity也許會變得不那麼抽象。

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

1. 為什麼要用Repository

1.1 - 實體模型 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):指業務數據該如何持久化,以及數據之間的關係,也就是傳統的ER模型;
  • 業務模型/領域模型(Domain Model):指業務邏輯中,相關聯的數據該如何聯動。

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

1.2 - Repository的價值

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

  • 硬件(Hardware):指創造了之後不可(或者很難)變更的東西。數據庫對於開發來說,就屬於”硬件“,數據庫選型後基本上後面不會再變,比如:用了MySQL就很難再改為MongoDB,改造成本過高。
  • 軟件(Software):指創造了之後可以隨時修改的東西。對於開發來說,業務代碼應該追求做”軟件“,因為業務流程、規則在不停的變化,我們的代碼也應該能隨時變化。
  • 固件(Firmware):即那些強烈依賴了硬件的軟件。我們常見的是路由器裡的固件或安卓的固件等等。固件的特點是對硬件做了抽象,但僅能適配某款硬件,不能通用。所以今天不存在所謂的通用安卓固件,而是每個手機都需要有自己的固件。

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

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

private OrderDAO orderDAO;

public Long addOrder(RequestDTO request) {
    // 此處省略很多拼裝邏輯
    OrderDO orderDO = new OrderDO();
    orderDAO.insertOrder(orderDO);
    return orderDO.getId();
}

public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
    orderDO.setXXX(XXX); // 省略很多
    orderDAO.updateOrder(orderDO);
}

public void doSomeBusiness(Long id) {
    OrderDO orderDO = orderDAO.getOrderById(id);
    // 此處省略很多業務邏輯
}

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

private OrderDAO orderDAO;
private Cache cache;

public Long addOrder(RequestDTO request) {
    // 此處省略很多拼裝邏輯
    OrderDO orderDO = new OrderDO();
    orderDAO.insertOrder(orderDO);
    cache.put(orderDO.getId(), orderDO);
    return orderDO.getId();
}

public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
    orderDO.setXXX(XXX); // 省略很多
    orderDAO.updateOrder(orderDO);
    cache.put(orderDO.getId(), orderDO);
}

public void doSomeBusiness(Long id) {
    OrderDO orderDO = cache.get(id);
    if (orderDO == null) {
        orderDO = orderDAO.getOrderById(id);
    }
    // 此處省略很多業務邏輯
}

這時,你會發現因為插入的邏輯變化了,導致在所有的使用數據的地方,都需要從1行代碼改為至少3行。而當你的代碼量變得比較大,然後如果在某個地方你忘記了查緩存,或者在某個地方忘記了更新緩存,輕則需要查數據庫,重則是緩存和數據庫不一致,導致bug。當你的代碼量變得越來越多,直接調用DAO、緩存的地方越來越多時,每次底層變更都會變得越來越難,越來越容易導致bug。這就是軟件被“固化”的後果。

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

2. 模型對象代碼規範

2.1 - 對象類型

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

  • Data Object (DO、數據對象): 實際上是我們在日常工作中最常見的數據模型。但是在DDD的規範裡,DO應該僅僅作為數據庫物理表格的映射,不能參與到業務邏輯中。為了簡單明瞭,DO的字段類型和名稱應該和數據庫物理表格的字段類型和名稱一一對應,這樣我們不需要去跑到數據庫上去查一個字段的類型和名稱。(當然,實際上也沒必要一摸一樣,只要你在Mapper那一層做到字段映射)
  • Entity(實體對象):實體對象是我們正常業務應該用的業務模型,它的字段和方法應該和業務語言保持一致,和持久化方式無關。也就是說,Entity和DO很可能有著完全不一樣的字段命名和字段類型,甚至嵌套關係。Entity的生命週期應該僅存在於內存中,不需要可序列化和可持久化
  • DTO(傳輸對象):主要作為Application層的入參和出參,比如CQRS裡的Command、Query、Event,以及Request、Response等都屬於DTO的範疇。DTO的價值在於適配不同的業務場景的入參和出參,避免讓業務對象變成一個萬能大對象。

2.2 - 模型對象之間的關係

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

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

image-20200425153554910.png

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

image-20200425160051761.png

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

image-20200425155614816.png

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

image-20200425160841303.png

2.3 - 模型所在模塊和轉化器

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

image-20200425164148148.png

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的邏輯和此類似,略過。

public class DtoAssembler {
    // 通過各種實體,生成DTO
    public OrderDTO toDTO(Order order, Item item) {
        OrderDTO dto = new OrderDTO();
        dto.setId(order.getId());
        dto.setItemTitle(item.getTitle()); // 從多個對象裡取值,且字段名稱不一樣
        dto.setDetailAddress(order.getAddress.getDetail()); // 可以讀取複雜嵌套字段
        // 省略N行
        return dto;
    }

    // 通過DTO,生成實體
    public Item toEntity(ItemDTO itemDTO) {
        Item entity = new Item();
        entity.setId(itemDTO.getId());
        // 省略N行
        return entity;
    }
}

我們能看出來通過抽象出一個Assembler/Converter對象,我們能把複雜的轉化邏輯都收斂到一個對象中,並且可以很好的單元測試。這個也很好的收斂了常見代碼裡的轉化邏輯。

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

public class Application {
    private DtoAssembler assembler;
    private OrderRepository orderRepository;
    private ItemRepository itemRepository;
  
    public OrderDTO getOrderDetail(Long orderId) {
        Order order = orderRepository.find(orderId);
        Item item = itemRepository.find(order.getItemId());
        return assembler.toDTO(order, item); // 原來的很多複雜轉化邏輯都收斂到一行代碼了
    }
}

雖然Assembler/Converter是非常好用的對象,但是當業務複雜時,手寫Assembler/Converter是一件耗時且容易出bug的事情,所以業界會有多種Bean Mapping的解決方案,從本質上分為動態和靜態映射。動態映射方案包括比較原始的BeanUtils.copyProperties、能通過xml配置的Dozer等,其核心是在運行時根據反射動態賦值。動態方案的缺陷在於大量的反射調用,性能比較差,內存佔用多,不適合特別高併發的應用場景。所以在這裡我給用Java的同學推薦一個庫叫MapStruct(MapStruct官網)。MapStruct通過註解,在編譯時靜態生成映射代碼,其最終編譯出來的代碼和手寫的代碼在性能上完全一致,且有強大的註解等能力。如果你的IDE支持,甚至可以在編譯後看到編譯出來的映射代碼,用來做check。在這裡我就不細講MapStruct的用法了,具體細節請見官網。

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

@org.mapstruct.Mapper
public interface DtoAssembler { // 注意這裡變成了一個接口,MapStruct會生成實現類
    DtoAssembler INSTANCE = Mappers.getMapper(DtoAssembler.class);

    // 在這裡只需要指出字段不一致的情況,支持複雜嵌套
    @Mapping(target = "itemTitle", source = "item.title")
    @Mapping(target = "detailAddress", source = "order.address.detail")
    OrderDTO toDTO(Order order, Item item);
    
    // 如果字段沒有不一致,一行註解都不需要
    Item toEntity(ItemDTO itemDTO);
}

在使用了MapStruct後,你只需要標註出字段不一致的情況,其他的情況都通過Convention over Configuration幫你解決了。還有很多複雜的用法我就不一一指出了。

2.4 - 模型規範總結

DO Entity DTO
目的 數據庫表映射 業務邏輯 適配業務場景
代碼層級 Infrastructure Domain Application
命名規範 XxxDO Xxx XxxDTO
XxxCommand
XxxRequest等
字段名稱標準 數據庫表字段名 業務語言 和調用方商定
字段數據類型 數據庫字段類型 儘量是有業務含義的類型,比如DP 和調用方商定
是否需要序列化 不需要 不需要 需要
轉化器 Data Converter Data Converter
DTO Assembler
DTO Assembler

從使用複雜度角度來看,區分了DO、Entity、DTO帶來了代碼量的膨脹(從1個變成了3+2+N個)。但是在實際複雜業務場景下,通過功能來區分模型帶來的價值是功能性的單一和可測試、可預期,最終反而是邏輯複雜性的降低。

3. Repository代碼規範

3.1 - 接口規範

上文曾經講過,傳統Data Mapper(DAO)屬於“固件”,和底層實現(DB、Cache、文件系統等)強綁定,如果直接使用會導致代碼“固化”。所以為了在Repository的設計上體現出“軟件”的特性,主要需要注意以下三點:

  1. 接口名稱不應該使用底層實現的語法:我們常見的insertselectupdatedelete都屬於SQL語法,使用這幾個詞相當於和DB底層實現做了綁定。相反,我們應該把Repository當成一箇中性的類似Collection的接口,使用語法如findsaveremove。在這裡特別需要指出的是區分insert/addupdate本身也是一種和底層強綁定的邏輯,一些儲存如緩存實際上不存在insertupdate的差異,在這個case 裡,使用中性的save接口,然後在具體實現上根據情況調用DAO的insertupdate接口。
  2. 出參入參不應該使用底層數據格式:需要記得的是Repository操作的是Entity對象(實際上應該是Aggregate Root),而不應該直接操作底層的DO。更近一步,Repository接口實際上應該存在於Domain層,根本看不到DO的實現。這個也是為了避免底層實現邏輯滲透到業務代碼中的強保障。
  3. 應該避免所謂的“通用”Repository模式:很多ORM框架都提供一個“通用”的Repository接口,然後框架通過註解自動實現接口,比較典型的例子是Spring Data、Entity Framework等,這種框架的好處是在簡單場景下很容易通過配置實現,但是壞處是基本上無擴展的可能性(比如加定製緩存邏輯),在未來有可能還是會被推翻重做。當然,這裡避免通用不代表不能有基礎接口和通用的幫助類,具體如下。

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

public interface Repository<T extends Aggregate<ID>, ID extends Identifier> {

    /**
     * 將一個Aggregate附屬到一個Repository,讓它變為可追蹤。
     * Change-Tracking在下文會講,非必須
     */
    void attach(@NotNull T aggregate);

    /**
     * 解除一個Aggregate的追蹤
     * Change-Tracking在下文會講,非必須
     */
    void detach(@NotNull T aggregate);

    /**
     * 通過ID尋找Aggregate。
     * 找到的Aggregate自動是可追蹤的
     */
    T find(@NotNull ID id);

    /**
     * 將一個Aggregate從Repository移除
     * 操作後的aggregate對象自動取消追蹤
     */
    void remove(@NotNull T aggregate);

    /**
     * 保存一個Aggregate
     * 保存後自動重置追蹤條件
     */
    void save(@NotNull T aggregate);
}

// 聚合根的Marker接口
public interface Aggregate<ID extends Identifier> extends Entity<ID> {
  
}

// 實體類的Marker接口
public interface Entity<ID extends Identifier> extends Identifiable<ID> {
  
}

public interface Identifiable<ID extends Identifier> {
    ID getId();
}

// ID類型DP的Marker接口
public interface Identifier extends Serializable {

}

業務自己的接口只需要在基礎接口上進行擴展,舉個訂單的例子:

// 代碼在Domain層
public interface OrderRepository extends Repository<Order, OrderId> {
    
    // 自定義Count接口,在這裡OrderQuery是一個自定義的DTO
    Long count(OrderQuery query);
  
    // 自定義分頁查詢接口
    Page<Order> query(OrderQuery query);
  
    // 自定義有多個條件的查詢接口
    Order findInStore(OrderId id, StoreId storeId);
}

每個業務需要根據自己的業務場景來定義各種查詢邏輯。

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

3.2 - Repository基礎實現

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

// 代碼在Infrastructure層
@Repository // Spring的註解
public class OrderRepositoryImpl implements OrderRepository {
    private final OrderDAO dao; // 具體的DAO接口
    private final OrderDataConverter converter; // 轉化器

    public OrderRepositoryImpl(OrderDAO dao) {
        this.dao = dao;
        this.converter = OrderDataConverter.INSTANCE;
    }

    @Override
    public Order find(OrderId orderId) {
        OrderDO orderDO = dao.findById(orderId.getValue());
        return converter.fromData(orderDO);
    }

    @Override
    public void remove(Order aggregate) {
        OrderDO orderDO = converter.toData(aggregate);
        dao.delete(orderDO);
    }

    @Override
    public void save(Order aggregate) {
        if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
            // update
            OrderDO orderDO = converter.toData(aggregate);
            dao.update(orderDO);
        } else {
            // insert
            OrderDO orderDO = converter.toData(aggregate);
            dao.insert(orderDO);
            aggregate.setId(converter.fromData(orderDO).getId());
        }
    }

    @Override
    public Page<Order> query(OrderQuery query) {
        List<OrderDO> orderDOS = dao.queryPaged(query);
        long count = dao.count(query);
        List<Order> result = orderDOS.stream().map(converter::fromData).collect(Collectors.toList());
        return Page.with(result, query, count);
    }

    @Override
    public Order findInStore(OrderId id, StoreId storeId) {
        OrderDO orderDO = dao.findInStore(id.getValue(), storeId.getValue());
        return converter.fromData(orderDO);
    }

}

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

3.3 - Repository複雜實現

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

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

image-20200427063255054.png

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

public class OrderRepositoryImpl extends implements OrderRepository {
    private OrderDAO orderDAO;
    private LineItemDAO lineItemDAO;
    private OrderDataConverter orderConverter;
    private LineItemDataConverter lineItemConverter;

    // 其他邏輯省略
  
    @Override
    public void save(Order aggregate) {
        if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
            // 每次都將Order和所有LineItem全量更新
            OrderDO orderDO = orderConverter.toData(aggregate);
            orderDAO.update(orderDO);
            for (LineItem lineItem: aggregate.getLineItems()) {
                save(lineItem);
            }
        } else {
            // 插入邏輯省略
        }
    }

    private void save(LineItem lineItem) {
        if (lineItem.getId() != null && lineItem.getId().getValue() > 0) {
            LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
            lineItemDAO.update(lineItemDO);
        } else {
            LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
            lineItemDAO.insert(lineItemDO);
            lineItem.setId(lineItemConverter.fromData(lineItemDO).getId());
        }
    }
}

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

3.4 - 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

// 這個類是一個通用的支撐類,為了減少開發者的重複勞動。在用的時候需要繼承這個類
public abstract class DbRepositorySupport<T extends Aggregate<ID>, ID extends Identifier> implements Repository<T, ID> {

    @Getter
    private final Class<T> targetClass;

    // 讓AggregateManager去維護Snapshot
    @Getter(AccessLevel.PROTECTED)
    private AggregateManager<T, ID> aggregateManager;

    protected DbRepositorySupport(Class<T> targetClass) {
        this.targetClass = targetClass;
        this.aggregateManager = AggregateManager.newInstance(targetClass);
    }

    /**
     * 這幾個方法是繼承的子類應該去實現的
     */
    protected abstract void onInsert(T aggregate);
    protected abstract T onSelect(ID id);
    protected abstract void onUpdate(T aggregate, EntityDiff diff);
    protected abstract void onDelete(T aggregate);

    /**
     * Attach的操作就是讓Aggregate可以被追蹤
     */
    @Override
    public void attach(@NotNull T aggregate) {
        this.aggregateManager.attach(aggregate);
    }

    /**
     * Detach的操作就是讓Aggregate停止追蹤
     */
    @Override
    public void detach(@NotNull T aggregate) {
        this.aggregateManager.detach(aggregate);
    }

    @Override
    public T find(@NotNull ID id) {
        T aggregate = this.onSelect(id);
        if (aggregate != null) {
            // 這裡的就是讓查詢出來的對象能夠被追蹤。
            // 如果自己實現了一個定製查詢接口,要記得單獨調用attach。
            this.attach(aggregate);
        }
        return aggregate;
    }

    @Override
    public void remove(@NotNull T aggregate) {
        this.onDelete(aggregate);
        // 刪除停止追蹤
        this.detach(aggregate);
    }
  
    @Override
    public void save(@NotNull T aggregate) {
        // 如果沒有ID,直接插入
        if (aggregate.getId() == null) {
            this.onInsert(aggregate);
            this.attach(aggregate);
            return;
        }
        
        // 做Diff
        EntityDiff diff = aggregateManager.detectChanges(aggregate);
        if (diff.isEmpty()) {
            return;
        }

        // 調用UPDATE
        this.onUpdate(aggregate, diff);
        
        // 最終將DB帶來的變化更新回AggregateManager
        aggregateManager.merge(aggregate);
    }

}

使用方只需要繼承DbRepositorySupport:

public class OrderRepositoryImpl extends DbRepositorySupport<Order, OrderId> implements OrderRepository {
    private OrderDAO orderDAO;
    private LineItemDAO lineItemDAO;
    private OrderDataConverter orderConverter;
    private LineItemDataConverter lineItemConverter;
    
    // 部分代碼省略,見上文
  
    @Override
    protected void onUpdate(Order aggregate, EntityDiff diff) {
        if (diff.isSelfModified()) {
            OrderDO orderDO = converter.toData(aggregate);
            orderDAO.update(orderDO);
        }

        Diff lineItemDiffs = diff.getDiff("lineItems");
        if (lineItemDiffs instanceof ListDiff) {
            ListDiff diffList = (ListDiff) lineItemDiffs;
            for (Diff itemDiff : diffList) {
                if (itemDiff.getType() == DiffType.Removed) {
                    LineItem line = (LineItem) itemDiff.getOldValue();
                    LineItemDO lineDO = lineItemConverter.toData(line);
                    lineItemDAO.delete(lineDO);
                }
                if (itemDiff.getType() == DiffType.Added) {
                    LineItem line = (LineItem) itemDiff.getNewValue();
                    LineItemDO lineDO = lineItemConverter.toData(line);
                    lineItemDAO.insert(lineDO);
                }
                if (itemDiff.getType() == DiffType.Modified) {
                    LineItem line = (LineItem) itemDiff.getNewValue();
                    LineItemDO lineDO = lineItemConverter.toData(line);
                    lineItemDAO.update(lineDO);
                }
            }
        }
    }
}

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);
    }
}
class DbContext<T extends Aggregate<ID>, ID extends Identifier> {

    @Getter
    private Class<? extends T> aggregateClass;

    private Map<ID, T> aggregateMap = new HashMap<>();

    public DbContext(Class<? extends T> aggregateClass) {
        this.aggregateClass = aggregateClass;
    }

    public void attach(T aggregate) {
        if (aggregate.getId() != null) {
            if (!aggregateMap.containsKey(aggregate.getId())) {
                this.merge(aggregate);
            }
        }
    }

    public void detach(T aggregate) {
        if (aggregate.getId() != null) {
            aggregateMap.remove(aggregate.getId());
        }
    }

    public EntityDiff detectChanges(T aggregate) {
        if (aggregate.getId() == null) {
            return EntityDiff.EMPTY;
        }
        T snapshot = aggregateMap.get(aggregate.getId());
        if (snapshot == null) {
            attach(aggregate);
        }
        return DiffUtils.diff(snapshot, aggregate);
    }

    public T find(ID id) {
        return aggregateMap.get(id);
    }

    public void merge(T aggregate) {
        if (aggregate.getId() != null) {
            T snapshot = SnapshotUtils.snapshot(aggregate);
            aggregateMap.put(aggregate.getId(), snapshot);
        }
    }

    public void setId(T aggregate, ID id) {
        ReflectionUtils.writeField("id", aggregate, id);
    }
}

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

@Test
public void multiInsert() {
    OrderDAO dao = new MockOrderDAO();
    OrderRepository repo = new OrderRepositoryImpl(dao);

    Order order = new Order();
    order.setUserId(new UserId(11L));
    order.setStatus(OrderState.ENABLED);
    order.addLineItem(new ItemId(13L), new Quantity(5), new Money(4));
    order.addLineItem(new ItemId(14L), new Quantity(2), new Money(3));

    System.out.println("第一次保存前");
    System.out.println(order);

    repo.save(order);
    System.out.println("第一次保存後");
    System.out.println(order);

    order.getLineItems().get(0).setQuantity(new Quantity(3));
    order.pay();
    repo.save(order);

    System.out.println("第二次保存後");
    System.out.println(order);
}

單測結果:

第一次保存前
Order(id=null, userId=11, lineItems=[LineItem(id=null, itemId=13, quantity=5, price=4), LineItem(id=null, itemId=14, quantity=2, price=3)], status=ENABLED)

INSERT OrderDO: OrderDO(id=null, parentId=null, itemId=0, userId=11, quantity=0, price=0, status=2)
UPDATE OrderDO: OrderDO(id=1001, parentId=1001, itemId=0, userId=11, quantity=0, price=0, status=2)
INSERT OrderDO: OrderDO(id=null, parentId=1001, itemId=13, userId=11, quantity=5, price=4, status=2)
INSERT OrderDO: OrderDO(id=null, parentId=1001, itemId=14, userId=11, quantity=2, price=3, status=2)

第一次保存後
Order(id=1001, userId=11, lineItems=[LineItem(id=1002, itemId=13, quantity=5, price=4), LineItem(id=1003, itemId=14, quantity=2, price=3)], status=ENABLED)

UPDATE OrderDO: OrderDO(id=1001, parentId=1001, itemId=0, userId=11, quantity=0, price=0, status=3)
UPDATE OrderDO: OrderDO(id=1002, parentId=1001, itemId=13, userId=11, quantity=3, price=4, status=3)

第二次保存後
Order(id=1001, userId=11, lineItems=[LineItem(id=1002, itemId=13, quantity=3, price=4), LineItem(id=1003, itemId=14, quantity=2, price=3)], status=PAID)

3.5 - 其他注意事項

併發樂觀鎖

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

一個可能的BUG

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

4. Repository遷移路徑

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

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

  • OrderDO
  • OrderDAO

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

  1. 生成Order實體類,初期字段可以和OrderDO保持一致
  2. 生成OrderDataConverter,通過MapStruct基本上2行代碼就能完成
  3. 寫單元測試,確保Order和OrderDO之間的轉化100%正確
  4. 生成OrderRepository接口和實現,通過單測確保OrderRepository的正確性
  5. 將原有代碼裡使用了OrderDO的地方改為Order
  6. 將原有代碼裡使用了OrderDAO的地方都改為用OrderRepository
  7. 通過單測確保業務邏輯的一致性。

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

5. 寫在後面

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

我們是阿里巴巴淘系(淘寶+天貓)技術部的行業團隊,負責了天貓和淘寶的所有行業垂直業務,比如天貓服飾、淘寶iFashion、消費電子、大快消、企業服務等核心業務,直接對接行業的一線小二、商家和消費者。由於外部競爭環境的激烈,我們的業務也在快速的迭代,需要在保證代碼質量的前提下,能夠讓業務小步快跑、快速上線。這也是為什麼我們團隊在大量的使用DDD的思想進行開發,並且有一個橫向的架構小組來維護我們自己內部用的DDD框架(未來一年內希望能開源)。

如果你對我們的工作感興趣,或者在架構方面有好的想法和建議,歡迎把簡歷投過來,我們還有大量HC等著你

我的郵箱:[email protected]

Leave a Reply

Your email address will not be published. Required fields are marked *