DDD落地的思考–数据容器模式

一、背景

现在我们来讨论一个在DDD理论和实践过程中老生常谈的问题–数据对象。也就是我们常说的JavaBean。比如很多人纠结在接口层是DTO还是VO,在数据库层是Entity还是POJO。但是这些实践过程中还不是最难的问题,最难的是在复杂业务场景下可能不止这些VO,DTO。接下来我将通过一些表象来深度阐述关于数据对象方面的新模式–数据容器模式。

二、业务对象模型

2.1 业务对象模型一览

现在我们从实际场景出发来归纳下有业务对象模型在不同层次的位置。当然这个也是之前曾经分享过的,这里重新整理下:

用户接口层 应用层 领域层 基础设施层
DTO,VO,Request,Response,Enum,Info, Context,Bean,CMD BO,Enum,Event,MsgBody,Context,Bean,Page,ValueObject DO,Entity,POJO

需要说明的是,我这边的使用经验不会单独把ValueObject识别出来,而是用基本类型来表示或者枚举,另外一方面如果ValueObject比较大的话一般使用ConfigBO来表示。其他的可能都比较熟悉没有太多争论。当然,大家觉得有了这些是不是已经够了,不需要其他业务对象来承载了,并不是的。下面我们看一下几个场景。

2.2 查询对象

很多时候做web页面会被一个分页列表搞的头疼,比如需要在单页面提供CURD+Page+Batch+Export+Import等操作,如果是全栈那更费劲了。对于查询场景其实也比较难处理。那么如果查询条件比较多,而且带分页的话,那么对于这方面怎么优化呢?是以DTO结尾还是以VO结尾呢?这里个人建议先分场景,比如RPC之间的调用使用DTO,页面与服务器请求响应之间使用VO。更多的还牵扯到工程架构方面,这里不继续深入。

回过头来看一下,那么对于这个查询的话应该怎么命名查询请求参数对象呢,个人建议使用QueryDTO或者QueryVO来代表查询对象。当然,也要把查询对象参数与正常的参数对象分包管理。如下图,是数据工厂2.0查询对象分包内容:DDD落地的思考--数据容器模式我们看一下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.第三种则是单独构建应对需求的统计类数据对象,当然如果统计类数据比较复杂也比较多的话建议使用这种方法。

  1. ES查询

在ES中也是一样的,通常来说ES的数据查询可能已经包括上面的三种场景了,所以对于ES来说,查询结果模型很少能与原生的业务数据模型来对应,那么查询ES接口如果是在基础设施层的话,可能需要单独构建一个查询结果对象。在另外一些工程架构下查询ES的接口是在应用层发生的,所以可以直接基于接口层面的参数模型来构建查询结果模型。

2.5 其他数据容器对象

除了上面的几种数据容器对象之外,在实际使用中还有一些其他模型,这里不一一介绍了,我们可以从上面的模型表格中发现一些端倪,比如MsgBody,Event对象这些其实算是消息里的消息体数据模型,通常来说这些不对外暴露,所以我这边的实践是放在领域层,让领域模型显得更加丰满。

除此之外,还有缓存数据模型,这个模型有时候是比较令人尴尬的,通常来说,偷懒省事的缓存模型是与业务模型保持一致或者与数据库实体模型保持一致,但是,在有些特殊场景下我们不会缓存太多没有用的数据,所以在模型上也是需要与Redis的数据结构相呼应,以免错误使用Redis特性。

三、数据容器模式

3.1 概念

区别于数据结构方面的容器,这里指在工程架构中出现的所有与业务有关的模型。数据容器是持有数据的基本单位,不再以特定语言的基本数据类型为维度区分。

3.2 数据容器在模型层次上的关系

DDD落地的思考--数据容器模式这里重点说明一下在模型之间是如何传递数据的,分两种情况读和写:

  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 数据容器模式思维导图

DDD落地的思考--数据容器模式
数据容器模式思维导图.png

4.2 映射偏移模式思维导图

上一篇文章没有增加思维导图,这里补充一下DDD落地的思考--数据容器模式


原文始发于微信公众号(神帅的架构实战):DDD落地的思考–数据容器模式

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/241404.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!