欢迎大家关注公众号「JAVA前线」查看更多精彩分享文章,主要包括源码分析、实际应用、架构思维、职场分享、产品思考等等,同时欢迎大家加我个人微信「java_front」一起交流学习
1 六大原则
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, 100, new 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_front」一起交流学习