DDD 落地的思考 -- 數據容器模式

一、背景

現在我們來討論一個在 DDD 理論和實踐過程中老生常談的問題 -- 數據對象。也就是我們常說的 JavaBean。比如很多人糾結在接口層是 DTO 還是 VO,在數據庫層是 Entity 還是 POJO。但是這些實踐過程中還不是最難的問題,最難的是在複雜業務場景下可能不止這些 VO,DTO。接下來我將通過一些表象來深度闡述關於數據對象方面的新模式 -- 數據容器模式。

二、業務對象模型

2.1 業務對象模型一覽

現在我們從實際場景出發來歸納下有業務對象模型在不同層次的位置。當然這個也是之前曾經分享過的,這裏重新整理下:

flFseE

需要說明的是,我這邊的使用經驗不會單獨把 ValueObject 識別出來,而是用基本類型來表示或者枚舉,另外一方面如果 ValueObject 比較大的話一般使用 ConfigBO 來表示。其他的可能都比較熟悉沒有太多爭論。當然,大家覺得有了這些是不是已經夠了,不需要其他業務對象來承載了,並不是的。下面我們看一下幾個場景。

2.2 查詢對象

很多時候做 web 頁面會被一個分頁列表搞的頭疼,比如需要在單頁面提供 CURD+Page+Batch+Export+Import 等操作,如果是全棧那更費勁了。對於查詢場景其實也比較難處理。那麼如果查詢條件比較多,而且帶分頁的話,那麼對於這方面怎麼優化呢?是以 DTO 結尾還是以 VO 結尾呢?這裏個人建議先分場景,比如 RPC 之間的調用使用 DTO,頁面與服務器請求響應之間使用 VO。更多的還牽扯到工程架構方面,這裏不繼續深入。

回過頭來看一下,那麼對於這個查詢的話應該怎麼命名查詢請求參數對象呢,個人建議使用 QueryDTO 或者 QueryVO 來代表查詢對象。當然,也要把查詢對象參數與正常的參數對象分包管理。如下圖,是數據工廠 2.0 查詢對象分包內容:我們看一下 ApiQueryVO 的代碼內容:

import com.tianhua.datafactory.domain.bo.PageBean;
import com.tianhua.datafactory.vo.PageVO;
import lombok.Data;
import lombok.ToString;
import org.apache.commons.lang3.StringUtils;

import java.util.HashMap;
import java.util.Map;

/**
 * @Description:查詢api模型信息請求VO類
 * @Author:shenshuai
 * @version v1.0
 */
@Data
@ToString
public class ApiQueryVO  extends PageVO {
    
    /** api類型 **/
     String apiType;

    /** api簽名 **/
     String apiSign;

    /** 請求方法類型 **/
     String methodType;

    /** 所屬項目編碼 **/
     String projectCode;

    public PageBean getPageBean(){
        PageBean pageBean = super.getPageBean();
        Map<String,Object> query = new HashMap<>();
        if(StringUtils.isNotEmpty(apiType)){
            query.put("apiType",apiType);
        }
        if(StringUtils.isNotEmpty(methodType)){
            query.put("methodType",methodType);
        }
        if(StringUtils.isNotEmpty(apiSign)){
            query.put("apiSign",apiSign);
        }
        if(StringUtils.isNotEmpty(projectCode)){
            query.put("projectCode",projectCode);
        }
        pageBean.setQuery(query);
        return pageBean;
    }
}

現在我們看下下面幾個問題:

  1. 分頁查詢對象是否需要在領域層構建 QueryBO?

答: 不需要,分頁查詢對象可以通過 pageBO 帶到基礎設施層,也就是說查詢對象雖然通過領域層接口到了基礎設施層了,但是在整個領域模型中是感知不到這個查詢對象的,相當於讓領域層睜一隻眼閉一隻眼了。當然如果需要特定的感知這個對象,那麼建議在用戶接口層或者應用層先處理好。

那麼對於基礎設施層如何使用分頁對象呢?就是讓基礎設施層的 Mapper 或者 DAO 方法直接使用 PageBO 來處理分頁,這樣只有一層轉換,就是 PageVO/PageDTO<-->PageBO。那麼此時就查詢就相對簡單一點。如果極端一點的話就是構建通用的 Page 對象,不要帶 DTO 或者 VO。如下 Mapper 的接口代碼可以參考下:

 /**
  * @Description:查詢分頁數據
  * @return List<ApiModelDO>
  */
    List<ApiModelDO>  getPageList(@Param(value = "page") PageBO page );


 /**
  * @Description:查詢數量
  * @return int
  */
 int  getPageCount(@Param(value = "page") PageBO page );
   <select id="getPageList" resultMap="BaseResultMap">
        select <include refid="Base_Column_List" />  from api_model
        <where>
            <if test="page.query != null">
                <if test="page.query.projectCode != null">
                    and project_code  like concat('%',#{page.query.projectCode},'%')
                </if>
                <if test="page.query.apiType != null">
                    and api_type = #{page.query.apiType}
                </if>

                <if test="page.query.methodType != null">
                    and method_type = #{page.query.methodType}
                </if>

                <if test="page.query.apiSign != null">
                    and api_sign  like concat('%',#{page.query.apiSign},'%')
                </if>

            </if>
        </where>

        <if test="page.orderBy != null">
            ${page.orderByInfo}
        </if>
        limit #{page.startRow},#{page.endRow}
    </select>

    <select id="getPageCount" resultMap="count">
        select count(1)  as total from api_model
        <where>
            <if test="page.query != null">
                <if test="page.query.projectCode != null">
                    and project_code  like concat('%',#{page.query.projectCode},'%')
                </if>
                <if test="page.query.projectDesc != null">
                    and project_desc like concat('%',#{page.query.projectDesc},'%')
                </if>
            </if>
        </where>

    </select>

需要說明的是上面的 mapper 寫法可能會有 sql 注入的風險,這裏主要演示下參數傳遞和使用。

  1. 如果查詢使用了是對象本身的數據對象是不是好一點呢?比如 ItemVO,CURD 都是這個,那麼查詢也用這個 ItemVO 不是更好嗎?

答:是的,有好處也有壞處,個人不建議這麼用,除非確定查詢是比較固定的,一旦有了變化就不好控制了。另外查詢場景是比較多變的,通常來說用於分頁的查詢對象也可以用來進行非分頁的查詢,重點是將查詢場景的模型從其本身的模型抽離出來,注意,這裏是職責的抽離。

讓查詢對象只專注於查詢請求的封裝,雖然大部分字段都與實體業務模型對象一致,但是職責可能不一致,另外,查詢情況還可能需要增加字段,比如開始時間,結束時間等等,這反過來也可能會污染模型。

  1. 有查詢請求對象是不是也有相應的響應對象,比如 XxxResponseDTO/XxxResponseInfo?

答:有一條原則叫作如無必要,勿增實體,道理也是一樣的,有請求對象也不代表非要有響應對象在接口層面作爲對接。關於這個問題我遇到了兩種場景,第一種場景就是查詢倒是不是很複雜,但是定義了多個響應對象,如 StaffDTO,StaffAllDTO,StaffDetailDTO。

也就是說多個查詢接口返回的實際上都是 staffDTO 本身的一部分數據,那麼這其實看上去不是很好的方案。第二種場景則是在業務上,中臺系統可能會定義多套接口和參數模型來應對上層的不同業務線,防止一套接口影響底層領域能力,可能是被中臺的演變嚇到了,但是也不無道理。針對參數層面上的方案則是定義基類,然後不同業務線的請求參數繼承,這樣的話情況會簡單一點,但是冗餘情況就很嚴重。

  1. 是一旦有查詢就要用 QueryDTO 來包裝嗎?

答:不需要,有些查詢參數比較簡單,那麼就不要包裝,在分頁場景下,可以在接口層面控制一下,查詢參數超過三個則使用對象包裝,少於三個使用 map 包裝。

2.3 聚合對象

現在我們看一下另外一個數據容器 --- 聚合對象。通常來說在領域層某些對象本身就是一個聚合對象,比如 ProjectBO,從項目系統維度來說,裏面包括 ModuleBO,ApiBO,ApiBO 裏面又有 ParamBO 等等。但是有時候我們可能因爲嵌套深度的問題,

或者跨領域上下文聚合的需要來構建一個單獨的聚合根對象。這樣的話這個對象可能就僅僅是一個數據容器了,其本身應該不具有特定的業務行爲。從職責上來看,也可以承擔一些跨層和跨上下文參數傳遞的作用。在 amis4j 前端低代碼平臺中則是定義了 ProjectSnapShotBean 來做一些內容,模型如下:

/**
 * Description:項目聚合模型
 *
 * @author shenshaui
 * @version 1.0.0
 * @since JDK 1.8
 */
@Data
public class ProjectSnapShotBean {

    /**
     * 項目編碼
     */
    private String projectCode;

    /**
     * 項目配置信息
     */
    private ProjectBean projectBean;

    /**
     * 模塊配置信息
     */
    private Map<String,ModuleBean> moduleBeanMap;

    /**
     * key:moduleCode
     * value:apiBeanList
     * api配置信息
     */
    private Map<String,List<ApiBean>> apiBeanListMap;

    /**
     * api參數模型配置信息
     */
    private Map<String,ParamBean> paramBeanMap;


    /**
     * 當前項目對應的webdsl配置信息
     */
    private List<WebCodeBean> webDslConfigBeanList;

}

當然也可以定義 AggregateBO 對象,區別於模型本身的聚合對象即可。

2.4 跨層調用下的對象

現在我們看一下比較有爭議的一部分,跨層調用下的對象。在跨層調用中查詢對象可以一步請求到基礎設施層,不論從用戶接口層出發還是從應用層出發到基礎設施層都算跨層調用。那麼查詢對象上面講過了,可以直接透到基礎設施層,只要在基類上做一定處理即可實現。那麼返回對象呢,返回是直接返回數據庫實體?還是返回一個新對象呢?現在我們從以下幾個場景來回答這個問題:

  1. 單表查詢

單表查詢下無非是一些邏輯極其簡單或者單次查詢數據量比較多的接口,但是返回的對象應該用什麼承接呢,用原生的 Entity/POJO 其實還是有點怪異的。所以這就牽扯到數據容器的核心內容了,說白了在代碼中定義的 Java entity 有時候僅僅作爲只讀的數據容器,在工程架構規範下其實並不受待見。

所以這種返回有時候會令人感到困惑,那對於這個問題怎麼處理呢?個人建議可以使用繼承來實現,比如返回使用 SEntity 進行標示或者 SPOJO 或者 SDO。這麼做的一個原因在於到了應用層或者用戶接口層數據完全與原來的數據模型沒有關係了。

那麼對於 S 開頭的數據對象來說,其僅僅代表的是數據庫實體對象的快照內容。

  1. 多表聚合查詢

對於多表聚合查詢則更加明顯,在數據庫層面的單表單模型基本無法滿足,所以這種偏定製化的查詢結果對象,通常來說也不好繼承多個 Java Entity,目前有兩種方案可以選,一種是在基礎設施層定義對應的查詢結果對象,然後在接口層用 DTO 或者 VO 承接轉換。

另外一種方案就直接乾脆一點,在基礎設施層直接引用查詢結果對象的 DTO,VO。但是需要說明的是儘量讓這種查詢結果對象 DTO/VO 與本身實體對應的對象區別開來,比如 QueryResultDTO 等等,同時需要標名註釋。當然從穩定性和模型上來說個人更傾向於第一種方案。

  1. 統計查詢

現在我們看一下統計查詢,這裏統計查詢和多表聚合查詢其實都是屬於聚合維度的內容,通常來說統計出的數據在模型上與基本的數據模型是無法適配的,那到這裏其實就需要單獨構建一個統計類的查詢結果對象,一般來說統計類的對象數據可能不會很多,所以應用方法也有幾種如下:

  1. 在數據庫實體 JavaEntity 中構建統計屬性,並在 CURD 的映射中表明如何使用這些統計類的屬性,避免某些 ORM 框架不支持

  2. 第二種則是在 SEntity/SDO 中進行聲明,避免混淆原生數據模型

  3. 第三種則是單獨構建應對需求的統計類數據對象,當然如果統計類數據比較複雜也比較多的話建議使用這種方法。

  4. ES 查詢

在 ES 中也是一樣的,通常來說 ES 的數據查詢可能已經包括上面的三種場景了,所以對於 ES 來說,查詢結果模型很少能與原生的業務數據模型來對應,那麼查詢 ES 接口如果是在基礎設施層的話,可能需要單獨構建一個查詢結果對象。在另外一些工程架構下查詢 ES 的接口是在應用層發生的,所以可以直接基於接口層面的參數模型來構建查詢結果模型。

2.5 其他數據容器對象

除了上面的幾種數據容器對象之外,在實際使用中還有一些其他模型,這裏不一一介紹了,我們可以從上面的模型表格中發現一些端倪,比如 MsgBody,Event 對象這些其實算是消息裏的消息體數據模型,通常來說這些不對外暴露,所以我這邊的實踐是放在領域層,讓領域模型顯得更加豐滿。

除此之外,還有緩存數據模型,這個模型有時候是比較令人尷尬的,通常來說,偷懶省事的緩存模型是與業務模型保持一致或者與數據庫實體模型保持一致,但是,在有些特殊場景下我們不會緩存太多沒有用的數據,所以在模型上也是需要與 Redis 的數據結構相呼應,以免錯誤使用 Redis 特性。

三、數據容器模式

3.1 概念

區別於數據結構方面的容器,這裏指在工程架構中出現的所有與業務有關的模型。數據容器是持有數據的基本單位,不再以特定語言的基本數據類型爲維度區分。

3.2 數據容器在模型層次上的關係

這裏重點說明一下在模型之間是如何傳遞數據的,分兩種情況讀和寫:

  1. 寫鏈路

不帶消息的場景 DTO/VO/Request--->BO(CMD 可選)-->(Context 可選)-->(Bean 可選)-->BO-->(BO 可選)-->Entity(POJO)

帶消息的場景 DTO/VO/Request--->BO(CMD 可選)-->Event(MsgBody 可選)

  1. 讀鏈路

跨層調用 Entity(POJO/ValueObject)-->(VO/DTO/Response/Result)

非跨層調用 Entity(POJO/ValueObject)-->BO-->(VO/DTO/Response/Result)

讀請求 Query-->BaseQuery-->Map

也就是說在一定場景下,核心工程業務模型可以往外延伸一些,比如 DTO->CMD, 雖然 CMD 用的不多但是在一定場景下可以在應用層使用。另外的基於 BO 的業務操作可以引申出 Event(也可以命名爲 xxxResultBO) 和 MsgBody,所以這些額外的引申轉換路徑可以幫助我們更好的應對模型轉換的複雜度,從數據容器層面來看,很容理解這些轉換就是數據流。

3.3 數據容器的作用

  1. 能良好地表達核心業務模型

  2. 按場景定義數據存取模型

  3. 參考模型設計規範,以更高層次的抽象來表達數據模型,更好地理解業務模型代碼。

3.4 數據容器與實體等的區別

從層次上來講數據容器是更抽象的一層,可能並沒有特別的特徵,比如實體對象是數據容器,DTO / 數據庫 Entity 也是數據容器。更通俗的講數據容器是廣泛意義上的 Java Bean/Entity。但是實體或者值對象從領域上來講各自有各自的特徵。

從作用上來說,數據容器本身就是承載數據模型的,同時當作數據的容器,更類似於值對象的概念,但是比較特別的是數據容器本身具有的行爲如果拋開業務來談的話,就是各種數據操作,對於實體中的行爲而言,這種行爲不僅僅代表業務行爲,也代表了數據的變化。

從職責上來說,不同的數據容器需要在不同的包中,不能亂放,所以有些數據容器就很簡單,沒有任何數據操作方法,但是在領域層和應用層中的數據容器的數據行爲則豐富得多。

3.5 因爲數據容器所產生的困惑答疑

很多時候大家討論的問題都跟數據容器有關,相關的問題列表如下:

  1. 查詢對象應該怎麼定義怎麼封裝?在查詢過程中是跨層呢還是規規矩矩。

  2. VO/DTO 怎麼用,多個接口返回的 VO/DTO 能否定義多份

  3. 領域層的實體和值對象如何命名,BO=biz Object,VO =value Object,DO=domain Object

  4. 聚合對象是單獨定義還是在讓已有實體作爲聚合對象?

  5. 查詢返回的話是走領域模型還是直接使用接口層定義的模型?

  6. 統計查詢相關的統計數據怎麼返回,怎麼放,放在哪裏?

  7. CMD 等不常用的對象怎麼用?當 Client DTO 用?

  8. 數據對象轉換從層次上幾層最好?

  9. 數據庫映射問題?定義 json 容器如何與領域模型呼應?

  10. 查詢 Redis,ES 返回的對象怎麼定義?如何結合工程架構做業務?

以上這些問題,其實如果懂了數據容器模式的話,這些問題都很好理解,在數據容器模式下,各種命名下的對象都是平等的,只需要在規約或者規範下要求的那樣應用即可。

四、總結

4.1 數據容器模式思維導圖

數據容器模式思維導圖. png

4.2 映射偏移模式思維導圖

上一篇文章沒有增加思維導圖,這裏補充一下

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