认识限界上下文
什么是限界上下文?
限界上下文英文是Bounded Context,简称BC,本篇用BC的地方皆指限界上下文。BC是业务和技术的衔接点:
- 【限定上下文与业务】当我们讲一个领域概念的时候一定有其特定的上下文。比如商品这个概念,订单上下文里的商品代表的是商品的单价、折扣。库存上下文里也有商品概念,但是代表的是商品的库存量、库存成本、仓库存放位置等等。不同的上下文有同一个概念但表达的领域知识、所属的角色不一样,这就是BC的作用。根据业务相关性,BC划出了一条边界,边界里和边界外把领域概念的含义限定住了。
- 【限定上下文与技术】BC之间的边界一旦确定了,相互之间的协作方式也能确定下来,这就对技术架构出约束。举例:下订单这个业务流程要求订单BC和库存BC、支付BC协作完成才算完整流程,而发送订单通知这个动作则可以稍后完成。这就对订单BC如何和其他BC协作产生了同步请求还是异步请求两种不同的方式。还有哪些其他协作方式参见下文章节。
如何划分BC?
所谓天下事分久必合,合久必分。如何划分BC没有标准答案,本章试图总结处一些可执行的操作步骤。简单讲就是两步:1. 根据相关性做归类 2. 再根据团队粒度做裁剪。
根据相关性做归类
归类的依据有很多,有语义上的相关性,有功能上的相关性。有业务需求上的相关性,有非业务需求的相关性。一般是优先考虑功能相关性。
- 【语义相关性】创建一个订单、支付一个订单、发货一个订单、评价一个订单、搜索一个订单,这些有”订单”语义上的相关性,但是在业务上却有很大差别,涉及的业务逻辑和角色也不太一样,因此往往把它们划分为不同的BC。
- 【功能相关性-业务】创建订单、修改订单、合并订单、拆分订单这些属于针对订单的相关性操作,往往划为一个BC。
- 【功能相关性-非业务】订单BC、库存BC、支付BC等都有分析、监控、可扩展需求,往往会把这些非功能需求单独划分为一个个BC。
领域职责的归属有时很难划清楚,需要从多个角度思考,提问。比如这个需求谁会关心?这个需求如果我不做,谁一定会做?出了问题会是哪个领域的责任?
根据团队粒度做裁剪、根据技术特点做裁剪
根据类似两个披萨的团队粒度,结合康威定律,一个BC不要跨团队维护,一个成员也不要同时维护太多个BC,这就要求针对相关性归类处的BC做一次适合团队维护的粒度裁剪。特别注意的是:如果团队小就没必要划分太多BC,人为制造太多边界,对微服务和BC的道理是类似的。中短期内的好处往往会被大家高估,但带来的副作用却经常被低估,比如最终一致性对业务连续性的牺牲,对部署资源和运维资源的浪费,业务链路太长对请求响应的延迟等等。特别是团队对于业务理解不透彻、对非业务需求的技术支持能力没跟上的情况下更是这样。
BC与微服务什么关系?
微服务的概念这里不详说,简单说微服务是包含高度相关功能的一个开发部署单元,有自己的技术自治性包括技术选型、弹性扩缩容、发布上线频率等,有自己的业务演变自治性。
- 【概念澄清】BC是根据领域逻辑的内聚情况形成一个整体。微服务是部署单元,一个微服务就是一个独立服务,一个独立进程。
- 【落地情况】一个微服务可以包含一个或多个BC,到底包含几个?需要根据团队大小、BC复杂度和技术特性来定。1)如果业务复杂度高,需要几个人一起维护,建议拆分。2)如果BC的技术复杂度高,比如需要严苛的高可用、高并发,考虑拆分成单独微服务独立部署,方便单独技术演进。推荐做法是一个微服务就是一个BC。当一个BC里有某个领域概念业务发展快,功能变多,但又没大到可以单独一个BC时,继续留在这个BC里。只要在原来的BC里做到了以聚合为单位做领域设计的话,只要时机成熟,可以在代价很小的情况下拆分独立出来。
- 【DDD怎么帮到微服务设计】微服务规划和落地时需要确定多大的业务范围是一个独立的微服务,划分好的微服务随着业务发展的拆分和合并,这些问题最终都要建立在DDD的四重边界基础上。这就是DDD和微服务的联系点。
BC间的协作关系
DDD里划分BC是为了局部业务领域的独立性,但往往一个完整的业务流程需要多个BC协同完成,BC间的协作关系有很多种、协作关系有方向。
谁说上游、谁是下游?
BC间协作关系是有方向的。在上下文映射图中用Upstream代表上游,Downstream代表下游。不要从调用关系、数据流动方向判断谁是上下游。在AB协作关系中,如果A不用关心B的细节则A是上游,B是下游。
BC间有哪些协作关系?
共享内核:多个BC共享了一个组件。这个关系通常用来解除BC间的双向依赖、循环依赖
客户方-供应方:上下游间基于协商的接口进行协作,跟开发主机服务有点相似,但开放主机服务关系下的BC间耦合度更低
防腐层:下游为了防止上游接口、协议变化而主动引入的一个抽象层次。比如对接外部系统时,BC内的调用逻辑不直接依赖外部接口,而是依赖防腐接口,让防腐接口去依赖外部接口
开放主机服务:以开放的、稳定的协议提供服务,比如rest、rpc
发布/订阅事件:引入消息队列解耦关系
如何识别BC的协作关系?
- 不要出现BC间的双向依赖、循环依赖,通过调整、合并领域职责,最后考虑引入共享模式解决
- 一个好的协作设计,各自职责一定是“分治”的,尽量少用集权的机制,少用共享模式。
- 遵守最小知识法则,不要BC内的职责和知识泄露到别的BC里,尽量用松耦合协作关系,比如防腐层、开发主机、发布订阅
数据模型
Java bean、POJO、DTO、DDD里的Value Object、Entity、Persistent Object什么关系?
java bean:只有私有字段,及对应字段的getter和setter方法的java对象 = 贫血对象
pojo:普通的java对象,不依赖jdk之外的框架,纯粹的java对象
DTO:是个java bean,只有属性没有任何行为
entity:具有数据字段和内聚的行为,不对外部产生依赖,也是一种pojo对象
value object:具有数据字段和内聚的行为,不对外部产生依赖,也是一种pojo对象。有些地方把跟UI展示数据有关的也有叫view object,建议改为UI Object简称为UO
persistent object(PO):维护与数据库表字段的映射关系,直接跟ORM框架无缝集成
贫血模型还是富血模型?
m贫血指实体对象里只有属性和属性的getter和setter。富血指除了属性外,按照职责单一原则把属于实体对象的行为定义在实体里。
DDD设计推荐富血方式,根据实际情况甚至需要把行为定义到实体的值对象里。比如实体属性的格式校验、数学运算转换。
下面是几个方面的优劣对比:
对比点 | 贫血 | 富血 |
---|---|---|
封装性 | 代码分散,需要多处修改,易出bug | 一处代码,single of truth |
测试性 | 有类似校验类的代码测试路径多 | 容易mock掉实体,不关心实体细节 |
代码整洁性 | 参数多,判断条件多 | 封装好的实体对上游而言只是单个参数 |
service实现代码膨胀 | 包含很多实体该执行的逻辑的service类,代码膨胀快,不易被应用层编排 | 实体和实体下面的值对象承担了必要的行为逻辑 |
简单实体、简单业务 | 有优势、学习门槛低 | 学习门槛高、对简单实体、业务没优势 |
整洁分层架构
与传统三层架构的对比
业务逻辑层的AbcServiceImpl类是个上帝类,事务脚本,过程式业务逻辑实现。前几行代码做validation,接下来做convert,然后是业务处理逻辑的代码,中间穿插着通过RPC或者DAO获取更多的数据,拿到数据后,又是convert代码,然后接着一段业务逻辑代码,最后可能还要落库,发消息…等。
整洁分层架构图
整洁分层架构是在整洁架构的各层次附上DDD含义的基础上,结合洋葱架构而得来。旨在保证领域模型的纯粹性,避免领域模型出现贫血模型。该架构具有以下特点:
- 共分5层interface、api层、application层、domain层、infrastructure层。每层是个jar包,bootstrap类放在interface层,单独出来也可以。
- interface层以开放主机服务方式对接BC外部请求。
- sdk层是天路接口暴露用途,如果没有天路则没有这个层。
- domain层不依赖其他层,通过adaptor包下的接口定义做到依赖倒置,adaptor接口参数也不能体现具体技术实现细节,domain里的实现逻辑只依赖接口。adaptor是很重要的防腐逻辑。
- domain层里以聚合为单位放置代码,便于以后系统拆分,以聚合为单位。
- infra层负责向BC外部发出请求。如果infra里的adaptor接口实现逻辑非常多的时候,甚至可以考虑单独出来这一层。
- 注意:interface层虚线依赖infra层不是实际代码依赖,仅仅是为了springboot工程启动时能正常打包infra层代码。
架构的存在方式
- 【骨架代码】脚手架生成上述分层代码骨架。
- 【二方库】DDD公用的东西会以ddd-framework jar包形式被各业务项目依赖。包括实体接口,抽象实体基类、领域事件接口、值对象接口等。还有为了提高开发效率的自定义注解。未来延伸方向可以包含业务通用方案实现框架如可扩展、工作流、规则引擎等。ddd-framework jar细节见后续描述。
- 【代码检查】将来可以把DDD编码规范加入到CI流程的代码检查点
命名规范
注:按照流行的各厂java开发手册,DTO、PO、UO等不用遵守驼峰命名,以全大写标识。
领域元素 | 英文 | 命名规则 | 举例 | 备注 | |
---|---|---|---|---|---|
1 | 实体 | Entity | 类名以Entity结尾 | OrderEntity | |
2 | 值对象 | Value Object | 类名以VO结尾 | AddressVO | 现存有些UI/视图DTO对象也叫xxxVO,建议改为xxxUO |
3 | 领域服务 | Domain Service | 以动词命名领域服务类和方法,并以DomainService结尾 | ValidatingOrderDomainService | |
4 | 工厂 | Factory | 类名以Factory结尾 | OrderFactory | |
5 | 资源库 | Repository | 类名以Repository结尾 | OrderRepository | |
6 | 领域事件 | Domain Event | 类名以名词+动词过去式+Event命名 | OrderCreatedEvent | |
7 | 应用服务 | Application Service | 类名以AppService结尾 | PlaceOrderAppService | |
8 | DTO | data transfer object | 类名分别以Cmd、Qry、UO、DTO结尾 | OrderAddCmd、OrderQry、OrderUO、OrderDTO | sdk里暴露的DTO可细分为:入参xxxCmd:表示写数据接口的入参入参xxxQry:表示读数据接口的入参出参xxxUO,表示面向UI/视图组装的对象出参xxxDTO,表示面向接口组装的对象不符合上述场景的用xxxDTO |
9 | 对应db表的数据对象 | PO | 类名以PO结尾 | OrderPO | |
10 | 转换类 | Convertor | app层:在DTO和entity间相互转换 | OrderDTOConvertor | |
11 | infra层:在PO和entity间相互转换 | OrderPOConvertor | |||
12 | 领域层里的函数命名 | 尽量用有领域语义化的词语命名,尽量不要用getxxx,setxxx | |||
13 | 测试类 | XXXTest | 单元测试、集成测试类 | OrderEntityTest |
CRUD接口命名推荐(仅供参考,考虑存量sdk里的命名姿势各异)
原则:interface、app、domain层尽量用业务含义的命名,infra层命名可以跟技术选型有关。
实践:大家每命名一个模块、类、接口、变量都应该多一层这方面的思考,这里没有标准答案。
CRUD | 非infra层的接口名推荐 |
---|---|
新增 | create/add |
修改 | update |
删除 | remove/delete |
单个查询 | get |
列表查询 | list |
分页 | page |
统计 | count |
举例:
意图 | 更业务化 | 太技术化 | 解释 |
---|---|---|---|
下订单 | placeOrder | createOrder、addOrder | 对于没有太多业务含义的实体新增,用add或者create亦可 |
获取、查询、搜索 | get | find、query、fetch、retrieve | 技术化是指更容易思维定势到资源的存储方式 |
包用途详解 - 具体示例可参考骨架代码
项目名 | 包名(一级+二级) | 功能说明 | |
---|---|---|---|
sdk | 聚合名 | api | 以聚合为单位分package,下面再分api和dto |
dto | 包含必要的dto和enum。<详见命名规范部分> | ||
interface | rpc | rpc api实现,是否按聚合分二级包视情况而定 | |
rest | controller类,是否按聚合分二级包视情况而定 | ||
subscriber | 外部消息订阅,一个聚合一个类即可。是否按聚合分二级包视情况而定 | ||
common | (可选)本层特有的通用类、异常、常量、枚举。通用功能沉淀到二方库 | ||
application | service | 聚合abc | 应用服务,以聚合为单位分包 |
convertor | dto和entity转换 | ||
domain | aggregate | 聚合abc | 以聚合为单位分包 |
entity | 里面有实体类、值对象 | ||
valueobject | 值对象的包,如果只有很少量的值对象也可以直接放在entity包下 | ||
repository | 资源库接口 (屏蔽存储、缓存细节) | ||
event | 消息结构,消息发送接口 | ||
adaptor | 外部服务调用的适配接口 | ||
rpc | 其他服务访问接口(rpc or http),是否按聚合分二级包视情况而定 | ||
event | event publisher事件发布接口,是否按聚合分二级包视情况而定 | ||
service | 领域服务,按聚合分包,不属于任何聚合的领域服务可以找合适名字命名包 | ||
common | (可选)本层特有的通用类、异常、常量、枚举。通用功能沉淀到二方库 | ||
infrastructure | event | 消息发送接口实现,是否按聚合分二级包视情况而定 | |
rpc | 外部rpc调用逻辑,是否按聚合分二级包视情况而定 | ||
repository | 聚合abc | 资源库实现,以聚合为单位分包,内含orm具体实现、entity和po互转convertor | |
common | (可选)本层特有的通用类、异常、常量、枚举。通用功能沉淀到二方库 |
一个典型的运行过程
各个分层对应的各种数据对象解释
原则:不同的分层里要用合适的数据对象。命名带上相应后缀。
interface、application层:入参是ui或者open api传入的DTO,消息对象。出参是DTO或者UO
domain层:只能见到领域对象 - 聚合、实体Entity、值对象VO
infra层:跟资源库相关的是持久化对象PO;跟adaptor相关的是目标rpc接口的sdk里定义的xxxDTO,建议转换为xxxVO在domain层使用,作为对外部服务xxxDTO的防腐。即domain层不依赖rpc provider的sdk包
sdk层:尽可能少的暴露信息,特别是接口的入参对象设计要遵照”最小知识”,使用方知道的越少越好。推荐在必要的时候为这类DTO定义好预置的构造功能、校验功能,便于使用方组织调用代码高效整洁。这也是设计原则一节里提到的Domain Primitive的使用场景之一。
DTO和entity/VO互转,entity和po互转,为了代码整洁,节省po.setxxx(entity.getxxx())冗余代码,建议用map struct开源框架映射sdk。示例可参考脚手架示例代码,更多使用方式详见http://www.kailing.pub/MapStruct1.3/index.html#default-values-and-constants。
架构原则详解
api/sdk层设计原则
这一层在需要提供天路rpc服务时才有必要
【tips】sdk包的依赖要尽可能的少,除了依赖天路sdk,最多再依赖tcom-xxx等公共库
interface层设计
用户接口层,负责对外暴露开放主机服务(对应DDD里的open host service),对接各种协议访问比如http、tcp。经过了接口层后最终都要转成应用服务所能认识的协议,并通过应用服务入参传入应用层。
- 暴露并实现接口:rpc、rest、mq subscriber
- 网关注册@Register,Swagger支持比如@ApiOperation
- 协议转换:请求上游如果不是直接对接前端,在gateway网关层已经做了协议转换
- 接口限流等功能
- 入参定义:天路rpc的接口入参在sdk包里已经定义。http、mq consumer接口的入参对象可以定义在app层,interface层共用。
- 入参校验(只包含判断是否null等没有领域含义的校验逻辑)
- 依赖application层
【tips】不要做本属于application层的业务逻辑
【tips】不要依赖infrastructure层,通过adaptor接口
application应用层设计
应用层类似设计模式的facade模式,把里层的领域层服务做编排,对外层提供粗粒度的接口。所以应用层主要做两件事:1. 编排用例,一个接口就对应了业务场景的用例。2. 处理一些横切面的事情。
- 编排用例:绝大部分情况下应用服务通过调用领域服务来编排业务流程
- 启动事务,目前统一用dataio提供的注解@DBTransactional
- 对入参做必要验证。注意:一定要思考哪些逻辑该在application层做,涉及到实体/值对象的属性校验肯定要下放到领域层的实体/值对象自己承担。
- DTO到entity的互转:在访问domain层之前之后
- 调用一个或者多个领域服务
- 横切点功能:事务开启在应用层、打日志、异常处理
- 依赖情况:domain层,dataio sdk
【tips】不要做本属于domain层的业务逻辑,应用服务和领域服务职责怎么区分?详见DDD的设计原则相关章节。自查问题:这段代码蕴含的知识是否与它所处的限界上下文的主要职责域直接有关?比如消息发布
domain领域层设计
- 所有业务逻辑相关的代码都应该放在这个层,不应出现任何跟具体技术相关的词汇,比如资源相关的操作只有repository,没有dataio、mybatis、redis等具体实现repository的技术词汇
- domain层除了依赖一些公共库(比如tcom-xxx、ddd-framework)之外不能依赖任何层,通过相应的adaptor接口做到和infra层的依赖倒置(对应DDD里的防腐层设计)
infrastructure基础设施层设计
本BC访问外部系统、组件需要的逻辑都放在本层。主要实现一系列adaptor接口,典型的adaptor有:
- 对资源存储的访问比如数据库、缓存
- 对内部其他系统的适配逻辑,通过rpc或者http访问。熔断逻辑可在此维护。对rpc调用得到的出参进行防腐处理详见<*各个分层对应的各种数据对象解释*>
- 对外部第三方厂商系统的适配逻辑
- 对基础框架的适配逻辑,比如消息队列、配置中心、文件等。其中rcc配置中心目前对应用逻辑无侵入,当作正常的spring配置项使用。
单元测试如何做?
DDD没有改变单测的做法,跟已有单测实现做法一样,比如结合mokito单测框架。
分层依赖清晰了单测更容易做到了。其中domain层不依赖任何外部,能轻松达到高覆盖率,infra层防腐逻辑越完善越好测。
domain层和infrastructure层做单元测试,interface、app层主要是用例粒度,适合做集成测试。
异常定义处理
domain层和infra层只管抛出异常,interface层对协议转换后,流入的rpc调用、rest调用、消息订阅流量都会进到app层。因此app层或者interface都适合统一捕获异常,做相应异常转换再抛出或者直接处理掉。
定义一个抽象基类BaseException,domain层定义一个DomainException继承自BaseException,domain里还可以按需定义具体业务异常类,加上业务错误码和错误信息。infra层定义一个InfraException继承自BaseException。