DDD 落地之倉儲

一. 前言

hello,everyone。從評論與粉絲私下的聯繫來看,大家對於 DDD 架構的熱情都比較高。但是因爲抽象化的概念較多,因此理解上就很困難。

週末媳婦兒生病了在醫院,她掛點滴的時候,我也沒閒下來,抓緊時間做出了 DDD 的第一版 demo,就衝這點,

這個項目我會持續維護,針對讀者提出的 issue 與相關功能點的增加,我都會持續的補充。

查看 demo,點這裏,如果你覺得對你有幫助,歡迎 star[1]

本文將給大家介紹的同樣是 DDD 中的一個比較好理解與落地的知識點 - 倉儲

本系列爲 MVC 框架遷移至 DDD,考慮到國內各大公司內還是以 mybatis 作爲主流進行業務開發。因此,demo 中的遷移與本文的相關實例均以 mybatis 進行演示。至於應用倉儲選型是 mybatis 還是 jpa,文中會進行分析,請各位仔細閱讀本文。

二. 倉儲

2.1. 倉儲是什麼

原著《領域驅動設計:軟件核心複雜性應對之道 [6]》 中對倉儲的有關解釋:

爲每種需要全局訪問的對象類型創建一個對象,這個對象就相當於該類型的所有對象在內存中的一個集合的 “替身”。通過一個衆所周知的接口來提供訪問。提供添加和刪除對象的方法,用這些方法來封裝在數據存儲中實際插入或刪除數據的操作。提供根據具體標準來挑選對象的方法,並返回屬性值滿足查詢標準的對象或對象集合(所返回的對象是完全實例化的),從而將實際的存儲和查詢技術封裝起來。只爲那些確實需要直接訪問的 Aggregate 提供 Repository。讓客戶始終聚焦於型,而將所有對象存儲和訪問操作交給 Repository 來完成。

上文通俗的講,當領域模型一旦建立之後,你不應該關心領域模型的存取方式。倉儲就相當於一個功能強大的倉庫,你告訴他唯一標識:例如訂單id,它就能把所有你想要數據按照設置的領域模型一口氣組裝返回給你。存儲時也一樣,你把整塊訂單數據給他,至於它怎麼拆分,放到什麼存儲介質【DB,Redis,ES等等】, 這都不是你業務應該關心的事。你完全信任它能幫助你完成數據管理工作。

2.2. 爲什麼要用倉儲

先說貧血模型的缺點:

有小夥伴之前提出過不知道貧血模型的定義,這裏做一下解釋。貧血模型:PO,DTO,VO 這種常見的業務 POJO,都是數據 java 裏面的數據載體,內部沒有任何的業務邏輯。所有業務邏輯都被定義在各種 service 裏面,service 做了各種模型之間的各種邏輯處理,臃腫且邏輯不清晰。充血模型:建立領域模型形成聚合根,在聚合根即表示業務,在聚合內部定義當前領域內的業務處理方法與邏輯。將散落的邏輯進行收緊。

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。

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

三. 落地

3.1. 落地概念圖

DTO Assembler: 在 Application 層 【應用服務層】 ,EntityDTO的轉化器有一個標準的名稱叫DTO Assembler 【彙編器】 。

DTO Assembler 的核心作用就是將 1 個或多個相關聯的 Entity 轉化爲 1 個或多個 DTO。

Data Converter: 在 Infrastructure 層 【基礎設施層】 ,EntityDO的轉化器沒有一個標準名稱,但是爲了區分 Data Mapper,我們叫這種轉化器Data Converter。這裏要注意 Data Mapper 通常情況下指的是 DAO,比如 Mybatis 的 Mapper。

3.2.Repository 規範

首先聚合倉儲之間是一一對應的關係。倉儲只是一種持久化的手段,不應該包含任何業務操作。

  1. 接口名稱不應該使用底層實現的語法 定義倉儲接口,接口中有 save 類似的方法,與面向集合的倉儲的不同點:面向集合的倉儲只有在新增時調用 add 即可,面向持久化的無論是新增還是修改都要調用 save2. 出參入參不應該使用底層數據格式: 需要記得的是 Repository 操作的是 Entity 對象(實際上應該是 Aggregate Root),而不應該直接操作底層的 DO 。更近一步,Repository 接口實際上應該存在於 Domain 層,根本看不到 DO 的實現。這個也是爲了避免底層實現邏輯滲透到業務代碼中的強保障。3. 應該避免所謂的 “通用”Repository 模式 很多 ORM 框架都提供一個 “通用” 的 Repository 接口,然後框架通過註解自動實現接口,比較典型的例子是 Spring Data、Entity Framework 等,這種框架的好處是在簡單場景下很容易通過配置實現,但是壞處是基本上無擴展的可能性(比如加定製緩存邏輯),在未來有可能還是會被推翻重做。當然,這裏避免通用不代表不能有基礎接口和通用的幫助類 4. 不要在倉儲裏面編寫業務邏輯 首先要清楚的是,倉儲是存在基礎設施層的,並不會去依賴上層的應用服務,領域服務等。

倉儲內部僅能依賴 mapper,es,redis 這種存儲介質包裝框架的工具類。save 動作,僅對傳入的聚合根進行解析放入不同的存儲介質,你想放入 redis,數據庫還是 es,由 converter 來完成聚合根的轉換解析。同樣,從不同的存儲介質中查詢得到的數據,交給 converter 來組裝。

  1. 不要在倉儲內控制事務 你的倉儲用於管理的是單個聚合,事務的控制應該取決於業務邏輯的完成情況,而不是數據存儲與更新情況。

3.3.CQRS 倉儲

回顧一下這張圖,可以發現增刪改數據模型走了 DDD 模型。而查詢則從應用服務層直接穿透到了基礎設施層。

這就是 CQRS 模型,從數據角度來看,增刪改數據非冪等操作,任何一個動作都能對數據進行改動,稱爲危險行爲。而查詢,不會因爲你查詢次數的改變,而去修改到數據,稱爲安全行爲。而往往功能迭代過程中,數據修改的邏輯還是複雜的,因此建模也都是針對於增刪改數據而言的。

那麼查詢數據有什麼原則嗎?

  1. 構建獨立倉儲 查詢的倉儲與 DDD 中的倉儲應該是兩個方法,互相獨立。DDD 中的倉儲方法嚴格意義上只有三個:save,delete,byId,內部沒有業務邏輯,僅對數據做拆分組合。查詢倉儲方法可以根據用戶需求,研發需求來自定義倉儲返回的數據結構,不限制返回的數據結構爲聚合,可以是限界範圍內的任意自定義結構。2. 不要越權 不要再查詢倉儲內做太多的 sql 邏輯,數據查詢組裝交給 assember。3. 利用好 assember 類似於首頁,一個接口可能返回的數據來源於不同的領域,甚至有可能不是自己本身業務服務內部的。這種複雜的結果集,交給 assember 來完成最終結果集的組裝與返回。結構足夠簡單的情況下,用戶交互層【controller,mq,rpc】甚至可以直接查詢倉儲的結果進行返回。當然還有很多其他博文中會說,如果查詢結果足夠簡單,甚至可以直接在 controller 層調用 mapper 查詢結果返回。除非你是一個固定的字典服務或者規則表,否則哪怕業務再簡單,你的業務也會迭代,後續查詢模型變化了,dao 層裏面的查詢邏輯就外溢到用戶交互層,顯然得不償失。

3.4.ORM 框架選型

目前主流使用的 orm 框架就是 mybatis 與 jpa。國內使用 mybatis 多,國外使用 jpa 多。兩者框架上的比較本文不做展開,不清楚兩個框架實現差異的,可以自行百度。

那麼我們如果做 DDD 建模的話到底選擇哪一種 orm 框架更好呢?

mybatis 是一個半自動框架(當然現在有mybatis-plus的存在,mybatis也可以說是躋身到全自動框架裏面了),國內使用它作爲 orm 框架是主流。爲什麼它是主流,因爲它足夠簡單,設計完表結構之後,映射好字段就可以進行開發了,業務邏輯可以用膠水一個個粘起來。而且在架構支持上,mybatis 不支持實體嵌套實體,這個在領域模型建模結束後的應用上就優於 mybatis。

當然我們今天討論的是架構,任何時候,技術選型不是決定我們技術架構的關鍵性因素

jpa 天生就具備做 DDD 的優勢。但是這並不意味着 mybatis 就做不了 DDD 了,我們完全可以將領域模型的定義與 orm 框架的應用分離,單獨定義 converter 去實現領域模型與數據模型之間的轉換,demo 中我也是這麼給大家演示的。

當然,如果是新系統或者遷移時間足夠多,我還是推薦使用 JPA 的,紅紅火火恍恍惚惚~

四. demo 演示

需求描述,用戶領域有四個業務場景

1. 新增用戶 2. 修改用戶 3. 刪除用戶 4. 用戶數據在列表頁分頁展示

核心實現演示,不貼全部代碼,完整 demo 可從文章開頭的 github 倉庫獲取

4.1. 領域模型

/**
 * 用戶聚合根
 *
 * @author baiyan
 */
@Getter
@NoArgsConstructor
public class User extends BaseUuidEntity implements AggregateRoot {
    /**
     * 用戶名
     */
    private String userName;
    /**
     * 用戶真實名稱
     */
    private String realName;
    /**
     * 用戶手機號
     */
    private String phone;
    /**
     * 用戶密碼
     */
    private String password;
    /**
     * 用戶地址
     */
    private Address address;
    /**
     * 用戶單位
     */
    private Unit unit;
    /**
     * 角色
     */
    private List<Role> roles;
    /**
     * 新建用戶
     *
     * @param command 新建用戶指令
     */
    public User(CreateUserCommand command){
        this.userName = command.getUserName();
        this.realName = command.getRealName();
        this.phone = command.getPhone();
        this.password = command.getPassword();
        this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
        this.relativeRoleByRoleId(command.getRoles());
    }
    /**
     * 修改用戶
     *
     * @param command 修改用戶指令
     */
    public User(UpdateUserCommand command){
        this.setId(command.getUserId());
        this.userName = command.getUserName();
        this.realName = command.getRealName();
        this.phone = command.getPhone();
        this.setAddress(command.getProvince(),command.getCity(),command.getCounty());
        this.relativeRoleByRoleId(command.getRoles());
    }
    /**
     * 組裝聚合
     *
     * @param userPO
     * @param roles
     */
    public User(UserPO userPO, List<RolePO> roles){
        this.setId(userPO.getId());
        this.setDeleted(userPO.getDeleted());
        this.setGmtCreate(userPO.getGmtCreate());
        this.setGmtModified(userPO.getGmtModified());
        this.userName = userPO.getUserName();
        this.realName = userPO.getRealName();
        this.phone = userPO.getPhone();
        this.password = userPO.getPassword();
        this.setAddress(userPO.getProvince(),userPO.getCity(),userPO.getCounty());
        this.relativeRoleByRolePO(roles);
        this.setUnit(userPO.getUnitId(),userPO.getUnitName());
    }
    /**
     * 根據角色id設置角色信息
     *
     * @param roleIds 角色id
     */
    public void relativeRoleByRoleId(List<Long> roleIds){
        this.roles = roleIds.stream()
                .map(roleId->new Role(roleId,null,null))
                .collect(Collectors.toList());
    }
    /**
     * 設置角色信息
     *
     * @param roles
     */
    public void relativeRoleByRolePO(List<RolePO> roles){
        if(CollUtil.isEmpty(roles)){
            return;
        }
        this.roles = roles.stream()
                .map(e->new Role(e.getId(),e.getCode(),e.getName()))
                .collect(Collectors.toList());
    }
    /**
     * 設置用戶地址信息
     *
     * @param province 省
     * @param city 市
     * @param county 區
     */
    public void setAddress(String province,String city,String county){
        this.address = new Address(province,city,county);
    }
    /**
     * 設置用戶單位信息
     *
     * @param unitId
     * @param unitName
     */
    public void setUnit(Long unitId,String unitName){
        this.unit = new Unit(unitId,unitName);
    }
}

4.2.DDD 倉儲實現

/**
 *
 * 用戶領域倉儲
 *
 * @author baiyan
 */
@Repository
public class UserRepositoryImpl implements UserRepository {
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private RoleMapper roleMapper;
    @Autowired
    private UserRoleMapper userRoleMapper;
    @Override
    public void delete(Long id){
        userRoleMapper.delete(Wrappers.<UserRolePO>lambdaQuery().eq(UserRolePO::getUserId,id));
        userMapper.deleteById(id);
    }
    @Override
    public User byId(Long id){
        UserPO user = userMapper.selectById(id);
        if(Objects.isNull(user)){
            return null;
        }
        List<UserRolePO> userRoles = userRoleMapper.selectList(Wrappers.<UserRolePO>lambdaQuery()
                .eq(UserRolePO::getUserId, id).select(UserRolePO::getRoleId));
        List<Long> roleIds = CollUtil.isEmpty(userRoles) ? new ArrayList<>() : userRoles.stream()
                .map(UserRolePO::getRoleId)
                .collect(Collectors.toList());
        List<RolePO> roles = roleMapper.selectBatchIds(roleIds);
        return UserConverter.deserialize(user,roles);
    }
    @Override
    public User save(User user){
        UserPO userPo = UserConverter.serializeUser(user);
        if(Objects.isNull(user.getId())){
            userMapper.insert(userPo);
            user.setId(userPo.getId());
        }else {
            userMapper.updateById(userPo);
            userRoleMapper.delete(Wrappers.<UserRolePO>lambdaQuery().eq(UserRolePO::getUserId,user.getId()));
        }
        List<UserRolePO> userRolePos = UserConverter.serializeRole(user);
        userRolePos.forEach(userRoleMapper::insert);
        return this.byId(user.getId());
    }
}

4.3. 查詢倉儲

/**
 *
 * 用戶信息查詢倉儲
 *
 * @author baiyan
 */
@Repository
public class UserQueryRepositoryImpl implements UserQueryRepository {
    @Autowired
    private UserMapper userMapper;
    @Override
    public Page<UserPageDTO> userPage(KeywordQuery query){
        Page<UserPO> userPos = userMapper.userPage(query);
        return UserConverter.serializeUserPage(userPos);
    }
}

五. mybatis 遷移方案

以 OrderDO 與 OrderDAO 的業務場景爲例

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

六. 總結

1. 數據模型與領域模型需要正確區分,倉儲是它們互相轉換的抽象實現。2. 倉儲對業務層屏蔽實現,即領域層不需要關注領域對象如何持久化。3. 倉儲是一個契約,而不是數據訪問層。它明確表明聚合所必需的數據操作。4. 倉儲用於管理單個聚合,它不應該控制事務。5. ORM 框架選型在遷移過程中不可決定性因此,可以嫁接轉換器,但是還是優先推薦 JPA。6. 查詢倉儲可以突破 DDD 邊界,用戶交互層可以直接進行查詢。

七. 特別鳴謝

lilpilot[7]

八. 聯繫我

文中如有不正確之處,歡迎指正,寫文不易,點個贊吧,麼麼噠~

釘釘:louyanfeng25

微信:baiyan_lou

公衆號:柏炎大叔

引用鏈接

[1] 查看 demo,點這裏,如果你覺得對你有幫助,歡迎 star: https://github.com/louyanfeng25/ddd-demo
[2] 一文帶你落地 DDD: https://juejin.cn/post/7004002483601145863
[3] DDD 落地之事件驅動模型: https://juejin.cn/post/7005175434555949092
[4] DDD 落地之倉儲: https://juejin.cn/post/7006595886646034463
[5] DDD 落地之架構分層: https://juejin.cn/post/7007382308667785253
[6] 領域驅動設計:軟件核心複雜性應對之道: https://book.douban.com/subject/5344973/
[7] lilpilot: https://juejin.cn/user/1978776663097704

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