《结合DDD讲清楚编写技术方案的七大维度》再讨论

共 11566字,需浏览 24分钟

 ·

2022-06-25 11:43


JAVA前线 


欢迎大家关注公众号「JAVA前线」查看更多精彩分享,主要内容包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时也非常欢迎大家加我微信「java_front」一起交流学习




1 前文回顾

我在之前文章《结合DDD讲清楚编写技术方案七大维度》介绍了从零到一使用DDD方法论搭建项目的七个步骤:

  • 四色分领域
  • 用例看功能
  • 流程三剑客
  • 领域与数据
  • 纵横做设计
  • 分层看架构
  • 接口看对接

四色分领域介绍了使用四色分析法将一个整体需求拆分为不同领域,这是DDD方法论核心思想。四色分析法同样可以用在子域或者限界上下文中,直到拆分出可以得心应手处理之边界为止。

用例看功能介绍了当领域划分完成之后,使用用例图描述系统功能。用例图不关心实现细节,而是从外部视角描述系统功能,即使不了解实现细节的人,通过用例图也可以快速了解系统功能。

流程三剑客介绍了使用活动图、顺序图、状态机图三种流程类型的图示描述系统,三种图各有特点:活动图着重描述逻辑分支,顺序图着重描述时间线索,状态机图着重描述状态流转。

领域与数据介绍了如何区分领域模型和数据模型。二者重要区别是值对象存储方式。领域模型在包含值对象同时,也保留了值对象的业务含义,数据模型可以使用更加松散的结构保存值对象,简化数据库设计。

纵横做设计介绍了纵向做隔离,横向做编排。复杂业务之所以复杂,一个重要原因是涉及角色或者类型较多,很难平铺直叙地进行设计,所以我们需要增加分析维度。其中最常见的是增加横向和纵向两个维度。

分层看架构介绍了系统架构分为两个层次,第一种层次指本项目在整个公司位于哪一层。持久层、缓存层、中间件、业务中台、服务层、网关层、客户端和代理层是常见分层架构。第二种层次指项目代码结构,一般可以分为接口层,访问层,业务层,领域层,整合层和基础层。

接口看对接介绍了一个接口代码编写完成后,这个接口如何调用,输入和输出参数是什么,这些问题需要在接口文档中得到回答。

本文沿用上文中足球运动员管理系统,主要从两个维度对上文进行扩充,第一个维度是将DDD中一些概念与上文进行映射,例如领域、子域、限界上下文、实体、值对象、聚合与领域事件。第二个维度是展示DDD项目结构层次。


2 领域、子域与限界上下文

2.1 核心概念

这三个词虽然不同但是实际上都是在描述范围这个概念。正如牛顿三定律有其适用范围,程序中变量有其作用域一样,DDD方法论也会将整体业务拆分成不同范围,在同一个范围内进行才可以进行分析和处理。

上文实例中领域是足球,子域包括合同、医疗、训练、比赛、采访,合同子域可以分为两个限界上下文:转会和签约,医疗子域可以分为两个限界上下文:体检和伤病。



领域可以划分子领域,子域可以再划分子子域,限界上下文本质上是一种子子域,那么在业务分解时一个业务模块到底是领域、子域还是限界上下文?

这取决于看待这个模块的角度。你认为整体可能是别人的局部,你认为的局部可能是别人的整体,叫什么名字不重要,最重要的是按照高内聚原则将业务高度相关的模块收敛。


2.2 限界上下文

限界上下文(Bounded contenxt)比较难理解,我们可以四个维度分析:

第一个维度是限界上下文本身含义。限界表示了规定一个边界,上下文表示在这个边界内使用相同语义对象。例如goods这个词,在商品边界内被称为商品,但是快递边界内被称为货物。

第二个维度是子域与限界上下文关系。子域可以对应一个,也可以对应多个限界上下文。如果子域划分足够小,那么就是限界上下文。如果子域可以再细分,那么可以划分多个限界上下文。

第三维度是服务如何划分。子域和限界上下文都可以作为微服务,这里微服务是指独立部署的程序进程,具体拆分到什么维度是根据业务需要、开发资源、维护成本、技术实力等因素综合考量。如果按照子域进行微服务划分可以拆分为:

  • 基础服务:player-core-service
  • 合同服务:contract-core-service
  • 医疗服务:medical-core-service
  • 训练服务:training-core-service
  • 比赛服务:game-core-service
  • 采访服务:interview-core-service

如果按照限界上下文进行微服务划分,合同和医疗服务可以再拆分:

  • 基础合同服务:contract-base-service
  • 转会合同服务:contract-transfer-service
  • 签约合同服务:contract-signing-service
  • 基础医疗服务:medical-base-service
  • 伤病医疗服务:medical-injury-service
  • 体检医疗服务:medical-exam-service

第四个维度是交互维度。在同一个限界上下文中实体对象和值对象可以自由交流,在不同限界上下文中必须通过聚合根进行交流。聚合根可以理解为一个按照业务聚合的代理对象。

例如产品经理作为需求收口人,任何需求应该先提给产品经理,通过产品经理整合后再提给程序员,而不是直接提给开发人员。


3 实体、值对象与聚合

领域模型分为三类:实体、值对象和聚合。实体是具有唯一标识的对象,唯一标识会伴随实体对象整个生命周期并且不可更。值对象本质上是属性的集合,没有唯一标识。

聚合包括聚合根和聚合边界两个概念,聚合根可以理解为一个按照业务聚合的代理对象,一个限界上下文企图访问另一个限界上下文内部对象,必须通过聚合根进行访问。


3.1 数据维度

领域模型与数据模型一个重要的区别是值对象存储方式。领域对象在包含值对象的同时也保留了值对象的业务含义,而数据对象可以使用更加松散的结构保存值对象,简化数据库设计。

如果需要管理足球运动员基本信息和比赛数据,对应领域模型和数据模型应该如何设计?姓名、身高、体重是一名运动员本质属性,加上唯一编号可以对应实体对象。

跑动距离,传球成功率,进球数是运动员比赛表现,这些属性的集合可以对应值对象。




3.2 代码维度

3.2.1 数据对象

PO(Persistent Object)直接与数据库交互:

public class FootballPlayerPO {
    // 运动员ID
    private Long id;
    // 运动员姓名
    private String name;
    // 运动员身高
    private Integer height;
    // 运动员体重
    private Integer weight;
    // 比赛表现(JSON)
    private String gamePerformance;
    // 创建人
    private String creator;
    // 修改人
    private String updator;
    // 创建时间
    private Date createTime;
    // 修改时间
    private Date updateTime;
}

3.2.2 值对象

VO(Value Object)本质上是属性之集合,其不具有唯一标识:

public class GamePerformanceVO {
    // 跑动距离
    private Double runDistance;
    // 传球成功率
    private Double passSuccess;
    // 进球数
    private Integer scoreNum;
}

public class MaintainVO {
    // 创建人
    private String creator;
    // 修改人
    private String updator;
    // 创建时间
    private Date createTime;
    // 修改时间
    private Date updateTime;
}

3.2.3 实体对象

Entity具有唯一标识,这个唯一标识会伴随实体对象整个生命周期:

public class FootballPlayerEntity {
    // 运动员ID
    private Long id;
    // 运动员姓名
    private String name;
    // 运动员身高
    private Integer height;
    // 运动员体重
    private Integer weight;
    // 比赛表现值对象
    private GamePerformanceVO gamePerformanceVO;
}

3.2.4 聚合对象

Agg(Aggregate)可以理解为一个按照业务聚合的代理对象,任何访问本限界上下文对象必须经过聚合。实践维度可以理解为充血模型版本BO,聚合对象中可以编写业务逻辑:

public class FootballPlayerSimpleResultAgg {
    // 运动员ID
    private Long playerId;
    // 运动员姓名
    private String playerName;
}

public class FootballPlayerReadAgg implements BizValidator {
    // 运动员ID
    private Long playerId;
    // 页数
    private Integer pageNum;
    // 条数
    private Integer size;

    @Override
    public void validate() {
        AssertUtil.notNull(playerId, new BizError);
        AssertUtil.notBigger(size, 100new BizError);
    }
}

public class FootballPlayerWriteAgg implements BizValidator {
    // 操作类型
    private Integer maintainType;
    // 维护信息
    private MaintainVO maintainInfo;
    // 运动员信息
    private FootballPlayerEntity playInfo;

    @Override
    public void validate() {
        AssertUtil.notNull(maintainType, new BizError);
        AssertUtil.notNull(maintainInfo, new BizError);
        AssertUtil.notNull(playInfo, new BizError);
        if(maintainType == MaintainEnum.CREATE.getType()) {
            AssertUtil.notNull(maintainInfo.getCreator(), new BizError);
            AssertUtil.notNull(maintainInfo.getCreateTime(), new BizError);
        }
        if(maintainType == MaintainEnum.UPADTE.getType()) {
            AssertUtil.notNull(maintainInfo.getUpdator(), new BizError);
            AssertUtil.notNull(maintainInfo.getUpdateTime(), new BizError);
        }
    }
}

3.2.5 数据传输对象

DTO(Data Transfer Object)用于接收或传输外部数据,只应该暴露必要信息:

public class FootballPlayerCreateDTO {
    // 运动员姓名
    private String name;
    // 运动员身高
    private Integer height;
    // 运动员体重
    private Integer weight;
    // 跑动距离
    private Double runDistance;
    // 传球成功率
    private Double passSuccess;
    // 进球数
    private Integer scoreNum;
    // 创建人
    private String creator;
    // 创建时间
    private Date createTime;
}

public class FootballPlayerUpdateDTO {
    // 运动员ID
    private Long id;
    // 运动员姓名
    private String name;
    // 运动员身高
    private Integer height;
    // 运动员体重
    private Integer weight;
    // 跑动距离
    private Double runDistance;
    // 传球成功率
    private Double passSuccess;
    // 进球数
    private Integer scoreNum;
    // 修改人
    private String updator;
    // 修改时间
    private Date updateTime;
}

public class FootballPlayerQueryDTO {
    // 运动员ID
    private Long playerId;
    // 页数
    private Integer pageNum;
    // 条数
    private Integer size;
}

public class FootballPlayerSimpleResultDTO {
    // 运动员ID
    private Long playerId;
    // 运动员姓名
    private String playerName;
}

4 领域事件

当某个领域发生一件事情时,如果其它领域有后续动作跟进,我们把这件事情称为领域事件,这个事件需要被感知。

球员比赛受伤,这是比赛域事件,但是医疗和训练域是需要感知的,那么比赛域发出一个事件,医疗和训练域会订阅。球员比赛取得进球,这也是比赛域事件,但是训练和合同域也会关注这个事件,所以比赛域也会发出一个比赛进球事件,训练和合同域会订阅。

通过事件交互有一个问题需要注意,通过事件订阅实现业务只能采用最终一致性,需要放弃强一致性,可能会引入新的复杂度需要权衡。

同一个进程间事件交互可以用EventBus,跨进程事件交互可以用RocketMQ等消息中间件。


5 代码结构

5.1 六层结构

DDD代码实现方案不尽相同,我认为不能为使用DDD而是使用DDD,而是应该根据实际情况选择当前最合适的方案。但是无论是什么方案都需要遵循合理分层这个原则:




(1)  API

接口层:提供面向外部接口声明、DTO

(2)  controller

访问层:提供HTTP访问入口

(3)  service

业务层:领域层和业务层都包含业务,业务层可以组合不同领域业务,并且可以实现流控、监控、日志、权限功能,相较于领域层更丰富

(4)  domain

领域层:提供Entity、VO、Agg、事件,聚合对象使用充血模型

(5) integration

整合层:访问外部限界上下文服务,解析为本限界上下文聚合对象

(6) infrastructure

基础层:提供PO、持久化能力


5.2 代码实例

如果player-core-service作为maven parent,那么其具有以下maven module和分包:

> player-core-service
> player-core-api
> dto
> facade
> player-core-controller
> controller
> adapter1 (DTO > Agg)
> player-core-service
> bizService
> adapter2 (Agg > PO)
> facadeService
> adapter3 (Agg > DTO)
> player-core-domain
> vo
> entity
> agg
> event
> player-core-integration
> proxy
> adapter4 (DTO > Agg)
> player-core-infrastructure
> po
> mapper




5.3 如何取舍

上述项目有六层结构,那么必然带来层次间调用对象互相转换这个问题:

adapter1接收外部请求(DTO)需要转换成(Agg)
adapter2处于业务层(操作数据库)(Agg)需要转换成(PO)
adapter3处于对外业务层(暴露RPC)(Agg)需要转换成(DTO)
adapter4处于整合层(访问外部RPC)(DTO)需要转换成(Agg)

对象转换会带来两个问题:第一个是代码复杂度增加,第二个是有一定性能损耗。这也是分层结构必须要付出之代价。

因为每层对象看似相同(具有相同属性或者结构)但是语义和角色完全不同,每一层可以为对象新增本层之特性,相较于使用一个对象贯穿始终,可扩展性显著提升。


6 文章总结

第一章节回顾《结合DDD讲清楚编写技术方案七大维度》这篇文章并且提出扩展两个维度:概念映射与代码结构,第二三四章节对应扩展第一个维度概念映射,第五章节对应扩展第二个维度代码结构,希望本文对大家有所帮助。



JAVA前线 


欢迎大家关注公众号「JAVA前线」查看更多精彩分享,主要内容包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时也非常欢迎大家加我微信「java_front」一起交流学习


浏览 35
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报