在解决现实问题时,我们会将问题映射到脑海中的概念模型,在模型中解决问题,再将解决方案转换为实际的代码。上述问题在于我们解决了设计到代码之间的重构,但提炼出来的设计模型,并不具有实际的业务含义,这就导致在开发新需求时,其他同学并不能很自然地将业务问题映射到该设计模型。设计似乎变成了重构者的自娱自乐,代码继续腐败,重新重构……无休止的循环。
用DDD则可以很好地解决领域模型到设计模型的同步、演化,最后再将反映了领域的设计模型转为实际的代码。
软件系统复杂性应对-
解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。
-
分治 把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。即更容易设计各部分的协作方式。评判什么是分治得好,即高内聚低耦合。
-
抽象 使用抽象能够精简问题空间,而且问题越小越容易理解。举个例子,从北京到上海出差,可以先理解为使用交通工具前往,但不需要一开始就想清楚到底是高铁还是飞机,以及乘坐他们需要注意什么。
-
知识 顾名思义,DDD可以认为是知识的一种。
微服务架构众所周知,此处不做赘述。我们创建微服务时,需要创建一个高内聚、低耦合的微服务。而DDD中的限界上下文则完美匹配微服务要求,可以将该限界上下文理解为一个微服务进程。
在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。
微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。 如果两者在追求的目标(业务维度)达到了上下文的统一,那么在具体做法上有什么联系和不同呢?
我们将架构设计活动精简为以下三个层面:
- 业务架构——根据业务需求设计业务模块及其关系
- 系统架构——设计系统和子系统的模块
- 技术架构——决定采用的技术及框架
DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。
设计领域模型的一般步骤如下:
- 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
- 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
- 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
- 为聚合根设计仓储,并思考实体或值对象的创建方式;
- 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
- 领域:现实世界中,领域包含了问题域和解系统。一般认为软件是对现实世界的部分模拟。在DDD中,解系统可以映射为一个个限界上下文,限界上下文就是软件对于问题域的一个特定的、有限的解决方案。
- 限界上下文:一个由显示边界限定的特定职责。领域模型便存在于这个边界之内。在边界内,每一个模型概念,包括它的属性和操作,都具有特殊的含义。一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。
- 划分限界上下文:我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。
运营对抽奖活动的配置十分复杂但相对低频。用户对这些抽奖活动配置的使用是高频次且无感知的。根据这样的业务特点,我们首先将抽奖平台划分为C端抽奖和M端抽奖管理平台两个子域,让两者完全解耦。
下面我们用C端进行举例。根据产品的需求,我们提取了一些关键性的概念作为子域,形成我们的限界上下文。
产品的需求概述如下:
- 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;
- 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;
- 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;
- 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;
- 活动具有风控配置,能够限制用户参与抽奖的频率。
限界上下文之间的映射关系
- 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
- 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
- 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
- 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
- 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
- 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
- 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
- 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
- 另谋他路(SeparateWay):两个完全没有任何联系的上下文。
- 独立于框架:架构不应该依赖某个外部的库或框架,不应该被框架的结构所束缚。
- 独立于UI:前台展示的样式可能会随时发生变化(今天可能是网页、明天可能变成console、后天是独立app),但是底层架构不应该随之而变化。
- 独立于底层数据源:无论今天你用MySQL、Oracle还是MongoDB、CouchDB,甚至使用文件系统,软件架构不应该因为不同的底层数据储存方式而产生巨大改变。
- 独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
- 可测试:无论外部依赖了什么数据库、硬件、UI或者服务,业务的逻辑应该都能够快速被验证正确性。
- 实体:当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。
- 值对象:当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。
- 聚合根:Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。
- 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
- 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
- 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。
- 领域服务:一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。一切领域逻辑的对外暴露均需要通过领域服务来进行。如原本由聚合根暴露的业务逻辑也需要依托于领域服务。
- 领域事件:领域事件是对领域内发生的活动进行的建模。
抽奖平台的核心上下文是抽奖上下文,接下来介绍下我们对抽奖上下文的建模
- Data Object (DO、数据对象):实际上是我们在日常工作中最常见的数据模型。但是在DDD的规范里,DO应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。为了简单明了,DO的字段类型和名称应该和数据库物理表格的字段类型和名称一一对应,这样我们不需要去跑到数据库上去查一个字段的类型和名称。(当然,实际上也没必要一摸一样,只要你在Mapper那一层做到字段映射)
- Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity和DO很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity的生命周期应该仅存在于内存中,不需要可序列化和可持久化。
- DTO(传输对象):主要作为Application层的入参和出参,比如CQRS里的Command、Query、Event,以及Request、Response等都属于DTO的范畴。DTO的价值在于适配不同的业务场景的入参和出参,避免让业务对象变成一个万能大对象
Service封装所有逻辑,Entity只是POJO的贫血模型
Order order = orderService.makeOrder(user, item);
public Order makeOrder(User user, Item item){
Order order = new Order();
order.setXxx(user.getXxx);
// ....
return order
}
Service仅做简单逻辑,Entity中封装具体逻辑的富血模型
Order order = orderService.makeOrder(user, item);
public Order makeOrder(User user, Item item){
Order order = user.makeOrder(item);
// ...
return order;
}
封装业务逻辑
- 用Domain Primitive(Value Object)封装跟实体无关的无状态计算逻辑
- 用Entity封装单对象的有状态的行为,包括业务校验
- 用Domain Service封装多对象逻辑(跨域对象的行为)
- 接口名称不应该使用底层实现的语法:我们常见的insert、select、update、delete都属于SQL语法,使用这几个词相当于和DB底层实现做了绑定。相反,我们应该把 Repository 当成一个中性的类 似Collection 的接口,使用语法如 find、save、remove。在这里特别需要指出的是区分 insert/add 和 update 本身也是一种和底层强绑定的逻辑,一些储存如缓存实际上不存在insert和update的差异,在这个 case 里,使用中性的 save 接口,然后在具体实现上根据情况调用 DAO 的 insert 或 update 接口。
- 出参入参不应该使用底层数据格式:需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO 。更近一步,Repository 接口实际上应该存在于Domain层,根本看不到 DO 的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
- 应该避免所谓的“通用”Repository模式:很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。当然,这里避免通用不代表不能有基础接口和通用的帮助类,具体如下。
// 代码在Infrastructure层
@Repository // Spring的注解
public class OrderRepositoryImpl implements OrderRepository {
private final OrderDAO dao; // 具体的DAO接口
private final OrderDataConverter converter; // 转化器
public OrderRepositoryImpl(OrderDAO dao) {
this.dao = dao;
this.converter = OrderDataConverter.INSTANCE;
}
@Override
public Order find(OrderId orderId) {
OrderDO orderDO = dao.findById(orderId.getValue());
return converter.fromData(orderDO);
}
@Override
public void remove(Order aggregate) {
OrderDO orderDO = converter.toData(aggregate);
dao.delete(orderDO);
}
@Override
public void save(Order aggregate) {
if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
// update
OrderDO orderDO = converter.toData(aggregate);
dao.update(orderDO);
} else {
// insert
OrderDO orderDO = converter.toData(aggregate);
dao.insert(orderDO);
aggregate.setId(converter.fromData(orderDO).getId());
}
}
@Override
public Page query(OrderQuery query) {
List orderDOS = dao.queryPaged(query);
long count = dao.count(query);
List result = orderDOS.stream().map(converter::fromData).collect(Collectors.toList());
return Page.with(result, query, count);
}
@Override
public Order findInStore(OrderId id, StoreId storeId) {
OrderDO orderDO = dao.findInStore(id.getValue(), storeId.getValue());
return converter.fromData(orderDO);
}
}
public class OrderRepositoryImpl extends implements OrderRepository {
private OrderDAO orderDAO;
private LineItemDAO lineItemDAO;
private OrderDataConverter orderConverter;
private LineItemDataConverter lineItemConverter;
// 其他逻辑省略
@Override
public void save(Order aggregate) {
if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
// 每次都将Order和所有LineItem全量更新
OrderDO orderDO = orderConverter.toData(aggregate);
orderDAO.update(orderDO);
for (LineItem lineItem: aggregate.getLineItems()) {
save(lineItem);
}
} else {
// 插入逻辑省略
}
}
private void save(LineItem lineItem) {
if (lineItem.getId() != null && lineItem.getId().getValue() > 0) {
LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
lineItemDAO.update(lineItemDO);
} else {
LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
lineItemDAO.insert(lineItemDO);
lineItem.setId(lineItemConverter.fromData(lineItemDO).getId());
}
}
}
DDD如何持久化
对于富血模型的Entity对象如何执行order.save(), 而不是传统的repository.save(order)来保证模型的完整性。DDD中给出Factory模式作为解决方案
public class Order{
private OrderRepository orderRepo;
public Order(OrderRepository orderRepo){this.orderRepo= orderRepo;}
public void save(){
orderRepo.save(this);
}
}
@Component
public class OrderFactory{
@Autowired
private OrderRepository orderRepo;
public Order createOrder(){
return new Order(orderRepo);
}
}
防腐层(ACL)
参考
阿里技术专家详解 DDD 系列- Domain Primitive
阿里技术专家详解DDD系列 第二弹 - 应用架构
阿里技术专家详解DDD系列 第三讲 - Repository模式
阿里盒马领域驱动设计实践
领域驱动设计在互联网业务开发中的实践



