【巨人的肩膀】领域驱动设计(DDD)编码实践

Martin Fowler 在《企业应用架构模式》一书中写道:

I found this(business logic) a curious term because there are few things that are less logical than business logic.

初略翻译过来可以理解为:业务逻辑是很没有逻辑的逻辑

的确,很多时候软件的业务逻辑是无法通过推理而得到的,有时甚至是被臆想出来的。这样的结果使得原本已经很复杂的业务变得更加复杂而难以理解。而在具体编码实现时,除了应付业务上的复杂性,技术上的复杂性也不能忽略,比如我们要讲究技术上的分层,要遵循软件开发的基本原则,又比如要考虑到性能和安全等等

在很多项目中,技术复杂度与业务复杂度相互交错纠缠不清,这种火上浇油的做法成为不少软件项目无法继续往下演进的原因。然而,在合理的设计下,技术和业务是可以分离开来或者至少它们之间的耦合度是可以降低的。在不同的软件建模方法中,领域驱动设计(Domain Driven Design,DDD)尝试通过其自有的原则与套路来解决软件的复杂性问题,它将研发者的目光首先聚焦在业务本身上,使技术架构和代码实现成为软件建模过程中的“副产品”

DDD总览

DDD 分为战略设计和战术设计

在战略设计中,我们讲求的是子域和限界上下文(Bounded Context,BC)的划分,以及各个限界上下文之间的上下游关系。当前如此火热的 “在微服务中使用DDD” 这个命题,究其最初的逻辑无外乎是 “DDD中的限界上下文可以用于指导微服务中的服务划分”。事实上,限界上下文依然是软件模块化的一种体现,与我们一直以来追求的模块化原则的驱动力是相同的,即通过一定的手段使软件系统在人的大脑中更加有条理地呈现,让作为 “目的” 的人能够更简单地了解进而掌控软件系统

如果说战略设计更偏向于软件架构,那么战术设计便更偏向于编码实现。DDD 战术设计的目的是使得业务能够从技术中分离并突显出来,让代码直接表达业务的本身,其中包含了聚合根、应用服务、资源库、工厂等概念。虽然 DDD 不一定通过面向对象(OO)来实现,但是通常情况下在实践 DDD 时我们采用的是 OO 编程范式,行业中甚至有种说法是 “DDD是OO进阶”,意思是面向对象中的基本原则(比如SOLID)在DDD 中依然成立。本文主要讲解 DDD 的战术设计

本文以一个简单的电商订单系统为例,通过以下方式可以获取源代码:
在这里插入图片描述

实现业务的3种常见方式

在讲解 DDD 之前,让我们先来看一下实现业务代码的几种常见方式,在示例项目中有个 “修改Order中Product的数量” 的业务需求如下:

可以修改Order中Product的数量,但前提是Order处于未支付状态,Product数量变更后Order的总价(totalPrice)应该随之更新
  1. 基于“Service + 贫血模型”的实现
    这种方式当前被很多软件项目所采用,主要的特点是:存在一个贫血的“领域对象”,业务逻辑通过一个 Service 类实现,然后通过 setter 方法更新领域对象,最后通过 DAO(多数情况下可能使用诸如Hibernate之类的ORM框架)保存到数据库中。实现一个 OrderService 类如下
@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
  Order order = DAO.findById(id);
  
  if (order.getStatus() == PAID) {
    throw new OrderCannotBeModifiedException(id);
  }

  OrderItem orderItem = order.getOrderItem(command.getProductId());
  orderItem.setCount(command.getCount());
  order.setTotalPrice(calculateTotalPrice(order));
  DAO.saveOrUpdate(order);
}

这种方式依然是一种面向过程的编程范式,违背了最基本的 OO 原则。另外的问题在于职责划分模糊不清,使本应该内聚在 Order 中的业务逻辑泄露到了其他地方(OrderService),导致 Order 成为一个只是充当数据容器的贫血模型(Anemic Model),而非真正意义上的领域模型。在项目持续演进的过程中,这些业务逻辑会分散在不同的 Service 类中,最终的结果是代码变得越来越难以理解进而逐渐丧失扩展能力

  1. 基于事务脚本的实现
    在上一种实现方式中,我们会发现领域对象(Order)存在的唯一目的其实是为了让 ORM 这样的工具能够一次性地持久化,在不使用 ORM 的情况下,领域对象甚至都没有必要存在。于是,此时的代码实现便退化成了事务脚本(Transaction Script),也就是直接将 Service 类中计算出的结果直接保存到数据库(或者有时都没有Service类,直接通过 SQL 实现业务逻辑):
@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {

  OrderStatus orderStatus = DAO.getOrderStatus(id);
  if (orderStatus == PAID) {
    throw new OrderCannotBeModifiedException(id);
  }

  DAO.updateProductCount(id, command.getProductId(), command.getCount());
  DAO.updateTotalPrice(id);
}

可以看到,DAO 中多出了很多方法,此时的 DAO 不再只是对持久化的封装,而是也会包含业务逻辑

另外,DAO.updateTotalPrice(id) 方法的实现中将直接调用 SQL 来实现 Order 总价的更新。与“Service+贫血模型”方式相似,事务脚本也存在业务逻辑分散的问题

事实上,事务脚本并不是一种全然的反模式,在系统足够简单的情况下完全可以采用。但是:一方面“简单”这个度其实并不容易把握;另一方面软件系统通常会在不断的演进中加入更多的功能,使得原本简单的代码逐渐变得复杂。因此,事务脚本在实际的应用中使用得并不多

  1. 基于领域对象的实现
    在这种方式中,核心的业务逻辑被内聚在行为饱满的领域对象(Order)中,实现 Order 类如下
public void changeProductCount(ProductId productId, int count) {

  if (this.status == PAID) {
    throw new OrderCannotBeModifiedException(this.id);
    }

  OrderItem orderItem = retrieveItem(productId);
  orderItem.updateCount(count);
}

然后在 Controller 或者 Service 中,调用 Order.changeProductCount()

@PostMapping("/order/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {

  Order order = DAO.byId(orderId(id));
  order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
  order.updateTotalPrice();
  DAO.saveOrUpdate(order);

}

可以看到,所有业务(“检查Order状态”、“修改Product数量”以及“更新Order总价”)都被包含在了 Order 对象中,这些正是 Order 应该具有的职责。(不过示例代码中有个地方明显违背了内聚性原则,下文会讲到,作为悬念读者可以先行尝试着找一找)

事实上,这种方式与本文要讲的 DDD 战术模式已经很相近了,只是 DDD 抽象出了更多的概念与原则

基于业务的分包

在本系列的上一篇:Spring Boot 项目模板文章中,其实我已经讲到了基于业务的分包,结合 DDD 的场景,这里再简要讨论一下。所谓基于业务分包即通过软件所实现的业务功能进行模块化划分,而不是从技术的角度划分(比如首先划分出 service 和 infrastruture 等包)。在 DDD 的战略设计中,我们关注于从一个宏观的视角俯视整个软件系统,然后通过一定的原则对系统进行子域和限界上下文的划分。在战术实践中,我们也通过类似的提纲挈领的方法进行整体的代码结构的规划,所采用的原则依然逃离不了“内聚性”和“职责分离”等基本原则。此时,首先映入眼帘的便是软件的分包

在 DDD 中,聚合根(下文会讲到)是主要业务逻辑的承载体,也是“内聚性”原则的典型代表,因此通常的做法便是基于聚合根进行顶层包的划分。在示例电商项目中,有两个聚合根对象 Order 和 Product,分别创建 order 包和 product 包,然后在各自的顶层包下再根据代码结构的复杂程度划分子包,比如对于 product 包:

└── product
    ├── CreateProductCommand.java
    ├── Product.java
    ├── ProductApplicationService.java
    ├── ProductController.java
    ├── ProductId.java
    ├── ProductNotFoundException.java
    ├── ProductRepository.java
    └── representation
        ├── ProductRepresentationService.java
        └── ProductSummaryRepresentation.java

可以看到,ProductRepository 和 ProductController 等多数类都直接放在了 product 包下,而没有单独分包;但是展现类 ProductSummaryRepresentation 却做了单独分包。这里的原则是:在所有类已经被内聚在了 product 包下的情况下,如果代码结构足够的简单,那么没有必要再次进行子包的划分,ProductRepository 和 ProductController 便是这种情况;而如果多个类需要做再次的内聚,那么需要另行分包,比如通过 REST API 接口返回 Product 数据时,代码中涉及到了两个对象 ProductRepresentationService 和 ProductSummaryRepresentation,这两个对象是紧密关联的,因此将他们放在 representation 子包下。而对于更加复杂的 Order,分包如下:

├── order
│   ├── OrderApplicationService.java
│   ├── OrderController.java
│   ├── OrderPaymentProxy.java
│   ├── OrderPaymentService.java
│   ├── OrderRepository.java
│   ├── command
│   │   ├── ChangeAddressDetailCommand.java
│   │   ├── CreateOrderCommand.java
│   │   ├── OrderItemCommand.java
│   │   ├── PayOrderCommand.java
│   │   └── UpdateProductCountCommand.java
│   ├── exception
│   │   ├── OrderCannotBeModifiedException.java
│   │   ├── OrderNotFoundException.java
│   │   ├── PaidPriceNotSameWithOrderPriceException.java
│   │   └── ProductNotInOrderException.java
│   ├── model
│   │   ├── Order.java
│   │   ├── OrderFactory.java
│   │   ├── OrderId.java
│   │   ├── OrderIdGenerator.java
│   │   ├── OrderItem.java
│   │   └── OrderStatus.java
│   └── representation
│       ├── OrderItemRepresentation.java
│       ├── OrderRepresentation.java
│       └── OrderRepresentationService.java

可以看到,我们专门创建了一个 model 包用于放置所有与 Order 聚合根相关的领域对象;另外,基于同类型相聚原则,创建 command 包和 exception 包分别用于放置请求类和异常类

领域模型的门面——应用服务

UML 中有用例(Use Case)的概念,表示的是软件向外提供业务功能的基本逻辑单元。在 DDD 中,由于业务被提到了第一优先级,那么自然地我们希望对业务的处理能够显现出来,为了达到这样的目的,DDD 专门提供了一个名为应用服务(ApplicationService)的抽象层。 ApplicationService 采用了门面模式,作为领域模型向外提供业务功能的总出入口,就像酒店的前台处理客户的不同需求一样

在编码实现业务功能时,通常用 2 种工作流程:
1. 自底向上:先设计数据模型,比如关系型数据库的表结构,再实现业务逻辑。我在与不同的程序员结对编程的时候,总会是听到这么一句话:“让我先把数据库表的字段设计出来吧”。这种方式将关注点优先放在了技术性的数据模型上,而不是代表业务的领域模型,是 DDD 之反
2. 自顶向下:拿到一个业务需求,先与客户方确定好请求数据格式,再实现 Controller 和 ApplicationService,然后实现领域模型(此时的领域模型通常已经被识别出来),最后实现持久化

在 DDD 实践中,自然应该采用自顶向下的实现方式。ApplicationService 的实现遵循一个很简单的原则,即一个业务用例对应 ApplicationService 上的一个业务方法。比如,对于上文提到的“修改Order中Product的数量”业务需求实现如下:

实现OrderApplicationService

@Transactional
public void changeProductCount(String id, ChangeProductCountCommand command) {
  Order order = orderRepository.byId(orderId(id));
  order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
  orderRepository.save(order);
}

OrderController 调用 OrderApplicationService:

@PostMapping("/{id}/products")
public void changeProductCount(@PathVariable(name = "id") String id, @RequestBody @Valid ChangeProductCountCommand command) {
  orderApplicationService.changeProductCount(id, command);
}

此时,order.changeProductCount() 和 orderRepository.save() 都没有必要实现,但是由 OrderController 和 OrderApplicationService 所构成的业务处理的架子已经搭建好了

可以看到,“修改Order中Product的数量” 用例中的,
OrderApplicationService.changeProductCount() 方法实现中只有不多的 3 行代码,然而,如此简单的 ApplicationService 却存在很多讲究

ApplicationService 需要遵循以下原则:
  1. 业务方法与业务用例一一对应:前面已经讲到,不再赘述
  2. 业务方法与事务一一对应:也即每一个业务方法均构成了独立的事务边界,在本例中,OrderApplicationService.changeProductCount()
    方法标记有 Spring 的 @Transactional 注解,表示整个方法被封装到了一个事务中
  3. 本身不应该包含业务逻辑:业务逻辑应该放在领域模型中实现,更准确的说是放在聚合根中实现,在本例中,order.changeProductCount() 方法才是真正实现业务逻辑的地方,而 ApplicationService 只是作为代理调用 order.changeProductCount() 方法,因此,ApplicationService 应该是很薄的一层
  4. 与 UI 或通信协议无关:ApplicationService 的定位并不是整个软件系统的门面,而是领域模型的门面,这意味着 ApplicationService 不应该处理诸如 UI 交互或者通信协议之类的技术细节。在本例中,Controller 作为 ApplicationService 的调用者负责处理通信协议(HTTP)以及与客户端的直接交互。这种处理方式使得 ApplicationService 具有普适性,也即无论最终的调用方是 HTTP 的客户端,还是 RPC 的客户端,甚至一个 Main 函数,最终都统一通过 ApplicationService 才能访问到领域模型
  5. 接受原始数据类型:ApplicationService 作为领域模型的调用方,领域模型的实现细节对其来说应该是个黑盒子,因此 ApplicationService 不应该引用领域模型中的对象。此外,ApplicationService 接受的请求对象中的数据仅仅用于描述本次业务请求本身,在能够满足业务需求的条件下应该尽量的简单。因此,ApplicationService 通常处理一些比较原始的数据类型。在本例中,OrderApplicationService 所接受的 Order ID 是 Java 原始的 String 类型,在调用领域模型中的 Repository 时,才被封装为 OrderId 对象
    在这里插入图片描述
    (应用服务(ApplicationService)是领域模型的门面)
业务的载体——聚合根

接地气一点地讲,聚合根(Aggreate Root, AR)就是软件模型中那些最重要的以名词形式存在的领域对象,比如本文示例项目中的 Order 和 Product。又比如,对于一个会员管理系统,会员(Member)便是一个聚合根;对于报销系统,报销单(Expense)便是一个聚合根;对于保险系统,保单(Policy)便是一个聚合根。聚合根是主要的业务逻辑载体,DDD 中所有的战术实现都围绕着聚合根展开

然而,并不是说领域模型中的所有名词都可以建模为聚合根。所谓“聚合”,顾名思义,即需要将领域中高度内聚的概念放到一起组成一个整体。至于哪些概念才能聚到一起,需要我们对业务本身有很深刻的认识,这也是为什么 DDD 强调开发团队需要和领域专家一起工作的原因。近年来流行起来的事件风暴建模活动,究其本意也是通过罗列出领域中发生的所有事件可以让我们全面的了解领域中的业务,进而识别出聚合根

对于“更新Order中Product数量”用例,聚合根Order的实现如下

public void changeProductCount(ProductId productId, int count) {
  if (this.status == PAID) {
    throw new OrderCannotBeModifiedException(this.id);
  }
  OrderItem orderItem = retrieveItem(productId);
  orderItem.updateCount(count);
  this.totalPrice = calculateTotalPrice();
}

private BigDecimal calculateTotalPrice() {
  return items.stream()
          .map(OrderItem::totalPrice)
          .reduce(ZERO, BigDecimal::add);
}

private OrderItem retrieveItem(ProductId productId) {
  return items.stream()
          .filter(item -> item.getProductId().equals(productId))
          .findFirst()
          .orElseThrow(() -> new ProductNotInOrderException(productId, id));
}

在本例中,Order 中的品项(orderItems)和总价(totalPrice)是密切相关的,orderItems 的变化会直接导致 totalPrice 的变化,因此,这二者自然应该内聚在 Order 下。此外,totalPrice 的变化是 orderItems 变化的必然结果,这种因果关系是业务驱动出来的,为了保证这种“必然”,我们需要在 Order.changeProductCount() 方法中同时实现“因”和“果”,也即聚合根应该保证业务上的一致性。在 DDD 中,业务上的一致性被称为不变条件(Invariants)

还记得上文中提到的“违背内聚性的悬念”吗?当时调用 Order 上的业务方式如下

 order.changeProductCount(ProductId.productId(command.getProductId()), command.getCount());
 order.updateTotalPrice();

为了实现 “更新Order中Product数量” 业务功能,这里先后调用了 Order 上的两个 public 方法 changeProductCount() 和 updateTotalPrice()。虽然这种做法也能正确地实现业务逻辑,但是它将保证业务一致性的职责交给了 Order 的调用方(上文中的Controller)而不是 Order 自身,此时调用方需要确保在调用了 changeProductCount() 之后必须调用 updateTotalPrice() 方法,这一方面是 Order 中业务逻辑的泄露,另一方面调用方并不承担这样的职责,而 Order 才最应该承担这样的职责

对内聚性的追求会自然地延伸出聚合根的边界。在 DDD 的战略设计中,我们已经通过限界上下文的划分将一个大的软件系统拆分为了不同的“模块”,在这样的前提下,再在某个限界上下文中来讨论内聚性将比在大泥球系统中讨论变得简单得多

对聚合根的设计需要提防上帝对象(God Object),也即用一个大而全的领域对象来实现所有的业务功能。上帝对象的背后存在着一种表面上看似合理的逻辑:既然要内聚,那么让我们把所有相关的东西都聚到一起吧,比如用一个 Product 类来应付所有的业务场景,包括订单、物流、发票等等。这种机械的方式看似内聚,实则恰恰是内聚性的反面。要解决这样的问题依然需要求助于限界上下文,不同限界上下文使用各自的通用语言(Ubiquitous Language),通用语言要求一个业务概念不应该有二义性,在这样的原则下,不同的限界上下文可能都有自己的 Product 类,虽然名字相同,却体现着不同的业务
在这里插入图片描述
除了内聚性和一致性,聚合根还有以下特征:

  1. 聚合根的实现应该与框架无关:既然 DDD 讲求业务复杂度和技术复杂度的分离,那么作为业务主要载体的聚合根应该尽量少地引用技术框架级别的设施,最好是 POJO。试想一下,如果你的项目哪天需要从 Spring 迁移到 Play,而你可以自信地给老板说,直接将核心 Java 代码拷贝过去即可,这将是一种多么美妙的体验。又或者说,很多时候技术框架会有“大步”的升级,这种升级会导致框架中 API 的变化并且不再支持向后兼容,此时如果我们的领域模与框架无关,那么便可做到在框架升级的过程中幸免于难
  2. 聚合根之间的引用通过 ID 完成:在聚合根边界设计合理的情况下,一次业务用例只会更新一个聚合根,此时你在该聚合根中去引用另外聚合根的整体有什么好处呢?在本文示例中,一个 Order 下的 OrderItem 引用了 ProductId,而不是整个 Product
  3. 聚合根内部的所有变更都必须通过聚合根完成:为了保证聚合根的一致性,同时避免聚合根内部逻辑向外泄露,客户方只能将整个聚合根作为统一调用入口
  4. 如果一个事务需要更新多个聚合根,首先思考一下自己的聚合根边界处理是否出了问题,因为在设计合理的情况下通常不会出现一个事务更新多个聚合根的场景。如果这种情况的确是业务所需,那么考虑引入消息机制和事件驱动架构,保证一个事务只更新一个聚合根,然后通过消息机制异步更新其他聚合根
  5. 聚合根不应该引用基础设施
  6. 外界不应该持有聚合根内部的数据结构
  7. 尽量使用小聚合
实体 vs 值对象

软件模型中存在实体对象(Entity)和值对象(Value Object)之说,这种划分方式事实上并不是 DDD 的专属,但是在 DDD 中我们非常强调这两者之间的区别

实体对象表示的是具有一定生命周期并且拥有全局唯一标识(ID)的对象,比如本文中的 Order 和 Product,而值对象表示用于起描述性作用的,没有唯一标识的对象,比如 Address 对象

聚合根一定是实体对象,但是并不是所有实体对象都是聚合根,同时聚合根还可以拥有其他子实体对象。聚合根的 ID 在整个软件系统中全局唯一,而其下的子实体对象的 ID 只需在单个聚合根下唯一即可。在本文示例项目中,OrderItem 是聚合根 Order 下的子实体对象

public class OrderItem {
  private ProductId productId;
  private int count;
  private BigDecimal itemPrice;
}

可以看到,虽然 OrderItem 使用了 ProductID 作为 ID,但是此时我们并没有享受 ProductID 的全局唯一性,事实上多个 Order 可以包含相同 ProductID 的 OrderItem,也即多个订单可以包含相同的产品

区分实体和值对象的一个很重要的原则便是根据相等性来判断,实体对象的相等性是通过 ID 来完成的,对于两个实体,如果他们的所有属性均相同,但是 ID 不同,那么他们依然两个不同的实体,就像一对长得一模一样的双胞胎,他们依然是两个不同的自然人。对于值对象来说,相等性的判断是通过属性字段来完成的。比如,订单下的送货地址 Address 对象便是一个典型的值对象:

public class Address  {

  private String province;
  private String city;
  private String detail;

  @Override
  public boolean equals(Object o) {
      if (this == o) {
          return true;
      }

      if (o == null || getClass() != o.getClass()) {
          return false;
      }
      Address address = (Address) o;

      return province.equals(address.province) &&
              city.equals(address.city) && detail.equals(address.detail);
  }

  @Override
  public int hashCode() {
      return Objects.hash(province, city, detail);
  }
}

在 Address 的 equals() 方法中,通过判断 Address 所包含的所有属性(province,city,detail)来决定两个 Address 的相等性

值对象还有一个特点是不变的(Immutable),也就说一个值对象一旦被创建出来了便不能对其进行变更,如果要变更,必须重新创建一个新的值对象整体替换原有的。比如,示例项目有一个业务需求:

在订单未支付的情况下,可以修改订单送货地址的详细地址(detail)

由于 Address 是 Order 聚合根中的一个对象,对 Address 的更改只能通过 Order 完成,在 Order 中实现 changeAddressDetail() 方法:

public void changeAddressDetail(String detail) {
  if (this.status == PAID) {
      throw new OrderCannotBeModifiedException(this.id);
  }
  this.address = this.address.changeDetailTo(detail);
}

可以看到,通过调用 address.changeDetailTo() 方法,我们获取到了一个全新的 Address 对象,然后将新的 Address 对象整体赋值给 address 属性。此时 Address.changeDetailTo() 的实现如下:

public Address changeDetailTo(String detail) {
  return new Address(this.province, this.city, detail);
}

这里的 changeDetailTo() 方法使用了新的详细地址 detail 和未发生变更的 province、city 重新创建出了一个 Address 对象

值对象的不变性使得程序的逻辑变得更加简单,你不用去维护复杂的状态信息,需要的时候创建,不要的时候直接扔掉即可,使得值对象就像程序中的过客一样。在 DDD 建模中,一种受推崇的做法便是将业务概念尽量建模为值对象。

对于 OrderItem 来说,由于我们的业务需要对 OrderItem 的数量进行修改,也即拥有生命周期的意味,因此本文将 OrderItem 建模为了实体对象。但是,如果没有这样的业务需求,那么将 OrderItem 建模为值对象应该更合适一些

另外,需要指明的是,实体和值对象的划分并不是一成不变的,而应该根据所处的限界上下文来界定,相同一个业务名词,在一个限界上下文中可能是实体,在另外的限界上下文中可能是值对象。比如,订单 Order 在采购上下文中应该建模为一个实体,但是在物流上下文中便可建模为一个值对象

聚合根的家——资源库

通俗点讲,资源库(Repository)就是用来持久化聚合根的。从技术上讲,Repository 和 DAO 所扮演的角色相似,不过 DAO 的设计初衷只是对数据库的一层很薄的封装,而 Repository 是更偏向于领域模型。另外,在所有的领域对象中,只有聚合根才“配得上”拥有 Repository,而 DAO 没有这种约束

实现 Order 的资源库 OrderRepository 如下:

public void save(Order order) {
  String sql = "INSERT INTO ORDERS (ID, JSON_CONTENT) VALUES (:id, :json) ON DUPLICATE KEY UPDATE JSON_CONTENT=:json;";
  Map<String, String> paramMap = of("id", order.getId().toString(), "json", objectMapper.writeValueAsString(order));
  jdbcTemplate.update(sql, paramMap);
}

public Order byId(OrderId id) {
  try {
      String sql = "SELECT JSON_CONTENT FROM ORDERS WHERE ID=:id;";
      return jdbcTemplate.queryForObject(sql, of("id", id.toString()), mapper());
  } catch (EmptyResultDataAccessException e) {
      throw new OrderNotFoundException(id);
  }
}

在 OrderRepository 中,我们只定义了 save() 和 byId() 方法,分别用于保存/更新聚合根和通过 ID 获取聚合根。这两个方法是 Repository 中最常见的方法,有的 DDD 实践者甚至认为一个纯粹的 Repository 只应该包含这两个方法

读到这里,你可能会有些疑问:为什么 OrderRepository 中没有更新和查询等方法?事实上,Repository 所扮演的角色只是向领域模型提供聚合根而已,就像一个聚合根的“容器”一样,这个“容器”本身并不关心客户端对聚合根的操作到底是新增还是更新,你给一个聚合根对象,Repository 只是负责将其状态从计算机的内存同步到持久化机制中,从这个角度讲,Repository 只需要一个类似 save() 的方法便可完成同步操作。当然,这个是从概念的出发点得出的设计结果,在技术层面,新增和更新还是需要区别对待,比如 SQL 语句有 insert 和 update 之分,只是我们将这样的技术细节隐藏在了 save() 方法中,客户方并无需知道这些细节。在本例中,我们通过 MySQL 的 ON DUPLICATE KEY UPDATE 特性同时处理对数据库的新增和更新操作。当然,我们也可以通过编程判断聚合根在数据库中是否已经存在,如果存在则 update,否则 insert。另外,诸如 Hibernate 这样的持久化框架自动提供 saveOrUpate() 方法可以直接用于对聚合根的持久化

对于查询功能来说,在 Repository 中实现查询本无不合理之处,然而项目的演进可能导致 Repository 中充斥着大量的查询代码“喧宾夺主”似的掩盖了 Repository 原本的目的。事实上,DDD 中读操作和写操作是两种很不一样的过程,笔者的建议是尽量将此二者分开实现,由此查询功能将从 Repository 中分离出去,在下文中我将详细讲到

在本例中,我们在技术实现上使用到了 Spring 的 JdbcTemplate 和 JSON 格式持久化 Order 聚合根,其实 Repository 并不与某种持久化机制绑定,一个被抽象出来的 Repository 向外暴露的功能“接口”始终是向领域模型提供聚合根对象,就像“聚合根的家”一样

好了,至此让我们来做个回顾,上文中我们以 “更新Order中的Product数量” 业务需求为例,讲到了应用服务、聚合根和资源库,对该业务需求的处理流程体现了 DDD 处理业务需求的最常见最典型的形式:

应用服务作为总体协调者,先通过资源库获取到聚合根,然后调用聚合根中的业务方法,最后再次调用资源库保存聚合根。

流程示意图如下:
在这里插入图片描述

创生之柱——工厂

稍微提炼一下,我们便知道软件里面的写操作要么是修改既有数据,要么是新建数据。对于前者,DDD 给出的答案已经在上文中讲到,接下来我们讲讲在 DDD 中如何新建聚合根

创建聚合根通常通过设计模式中的工厂(Factory)模式完成,这一方面可以享受到工厂模式本身的好处,另一方面,DDD 中的 Factory 还具有将“聚合根的创建逻辑”显现出来的效果

聚合根的创建过程可简单可复杂,有时可能直接调用构造函数即可,而有时却存在一个复杂的构造流程,比如需要调用其他系统获取数据等。通常来讲,Factory 有两种实现方式:

  1. 直接在聚合根中实现 Factory 方法,常用于简单的创建过程
  2. 独立的 Factory 类,用于有一定复杂度的创建过程,或者创建逻辑不适合放在聚合根上

让我们先演示一下简单的 Factory 方法,在示例订单系统中,有个业务用例是“创建Product”:

创建Product,属性包括名称(name),描述(description)和单价(price),ProductId为UUID

在 Product 类中实现工厂方法 create():

public static Product create(String name, String description, BigDecimal price) {
  return new Product(name, description, price);
}

private Product(String name, String description, BigDecimal price) {
  this.id = ProductId.newProductId()this.name = name;
  this.description = description;
  this.price = price;
  this.createdAt = Instant.now();
}

这里,Product 中的 create() 方法并不包含创建逻辑,而是将创建过程直接代理给了 Product 的构造函数。你可能觉得这个 create() 方法有些多此一举,然而这种做法的初衷依然是:我们希望将聚合根的创建逻辑突显出来。构造函数本身是一个非常技术的东西,任何地方只要涉及到在计算机内存中新建对象都需要使用构造函数,无论创建的初始原因是业务需要,还是从数据库加载,亦或是从JSON数据反序列化。因此程序中往往存在多个构造函数用于不同的场景,而为了将业务上的创建与技术上的创建区别开来,我们引入了 create() 方法用于表示业务上的创建过程。

“创建Product”所设计到的Factory的确简单,让我们再来看看另外一个例子:“创建Order”:

创建Order,包含用户选择的Product及其数量,OrderId必须调用第三方的OrderIdGenerator获取

这里的 OrderIdGenerator 是具有服务性质的对象(即下文中的领域服务),在 DDD 中,聚合根通常不会引用其他服务类。另外,调用 OrderIdGenerator 生成 ID 应该是一个业务细节,如前文所讲,这种细节不应该放在 ApplicationService 中。此时,可以通过 Factory 类来完成 Order 的创建

@Component
public class OrderFactory {
  private final OrderIdGenerator idGenerator;

  public OrderFactory(OrderIdGenerator idGenerator) {
    this.idGenerator = idGenerator;
    }

  public Order create(List<OrderItem> items, Address address) {
      OrderId orderId = idGenerator.generate();
      return Order.create(orderId, items, address);
    }
}
Command对象

通常来说,DDD 中的写操作并不需要向客户端返回数据,在某些情况下(比如新建聚合根)可以返回一个聚合根的 ID,这意味着 ApplicationService 或者聚合根中的写操作方法通常返回 void 即可。比如,对于 OrderApplicationService,各个方法签名如下:

public OrderId createOrder(CreateOrderCommand command) ;

public void changeProductCount(String id, ChangeProductCountCommand command) ;

public void pay(String id, PayOrderCommand command) ;

public void changeAddressDetail(String id, String detail) ;

可以看到,在多数情况下我们使用了后缀为 Command 的对象传给 ApplicationService,比如 CreateOrderCommand 和 ChangeProductCountCommand。Command 即命令的意思,也即写操作表示的是外部向领域模型发起的一次命令操作。事实上,从技术上讲,Command 对象只是一种类型的 DTO 对象,它封装了客户端发过来的请求数据。在 Controller 中所接收的所有写操作都需要通过 Command 进行包装,在 Command 比较简单(比如只有1-2个字段)的情况下 Controller 可以将 Command 解开之后,将其中的数据直接传递给 ApplicationService,比如 changeAddressDetail() 便是如此;而在 Command 中数据字段比较多时,可以直接将 Command 对象传递给ApplicationService。当然,这并不是 DDD 中需要严格遵循的一个原则,比如无论 Command 的简繁程度,统一将所有 Command 从 Controller 传递给 ApplicationService,也不存在太大的问题,更多的只是一个编码习惯上的选择。不过有一点需要强调,即前文提到的“ApplicationService需要接受原始数据类型而不是领域模型中的对象”,在这里意味着 Command 对象中也应该包含原始的数据类型

统一使用 Command 对象还有个好处是,我们通过查找所有后缀为 Command 的对象,便可以概览性地了解软件系统向外提供的业务功能

阶段性小结一下,以上我们主要围绕着软件的 “写操作” 在 DDD 中的实现进行讨论,并且讲到了3种场景,分别是:

  1. 通过聚合根完成业务请求
  2. 通过 Factory 完成聚合根的创建
  3. 通过 DomainService 完成业务请求

以上3种场景大致上涵盖了 DDD 完成业务写操作的基本方面,总结下来3句话:创建聚合根通过 Factory 完成;业务逻辑优先在聚合根边界内完成;聚合根中不合适放置的业务逻辑才考虑放到 DomainService 中
在这里插入图片描述

DDD中的读操作

软件中的读模型和写模型是很不一样的,我们通常所讲的业务逻辑更多的时候是在写操作过程中需要关注的东西,而读操作更多关注的是如何向客户方返回恰当的数据展现

在 DDD 的写操作中,我们需要严格地按照“应用服务 -> 聚合根 -> 资源库”的结构进行编码,而在读操作中,采用与写操作相同的结构有时不但得不到好处,反而使整个过程变得冗繁。这里介绍3种读操作的方式:

  1. 基于领域模型的读操作
  2. 基于数据模型的读操作
  3. CQRS

首先,无论哪种读操作方式,都需要遵循一个原则:领域模型中的对象不能直接返回给客户端,因为这样领域模型的内部便暴露给了外界,而对领域模型的修改将直接影响到客户端。因此,在 DDD 中我们通常为读操作专门创建相应的模型用于数据展现。在写操作中,我们通过 Command 后缀进行请求数据的统一,在读操作中,我们通过 Representation 后缀进行展现数据的统一,这里的 Representation 也即 REST 中的“R”

基于领域模型的读操作

这种方式将读模型和写模型糅合到一起,先通过资源库获取到领域模型,然后将其转换为 Representation 对象,这也是当前被大量使用的方式,比如对于 “获取Order详情的接口”,OrderApplicationService 实现如下:

@Transactional(readOnly = true)
public OrderRepresentation byId(String id) {
  Order order = orderRepository.byId(orderId(id));
  return orderRepresentationService.toRepresentation(order);
}

我们先通过 orderRepository.byId() 获取到 Order 聚合根对象,然后调用 orderRepresentationService.toRepresentation() 将 Order 转换为展现对象 OrderRepresentation,OrderRepresentationService.toRepresentation() 实现如下:

public OrderRepresentation toRepresentation(Order order) {

  List<OrderItemRepresentation> itemRepresentations = order.getItems().stream()
        .map(orderItem -> new OrderItemRepresentation(orderItem.getProductId().toString(),
              orderItem.getCount(),
              orderItem.getItemPrice()))
        .collect(Collectors.toList());

  return new OrderRepresentation(order.getId().toString(),
        itemRepresentations,
        order.getTotalPrice(),
        order.getStatus(),
        order.getCreatedAt());
}

这种方式的优点是非常直接明了,也不用创建新的数据读取机制,直接使用 Repository 读取数据即可。然而缺点也很明显:一是读操作完全束缚于聚合根的边界划分,比如,如果客户端需要同时获取 Order 及其所包含的 Product,那么我们需要同时将 Order 聚合根和 Product 聚合根加载到内存再做转换操作,这种方式既繁琐又低效;二是在读操作中,通常需要基于不同的查询条件返回数据,比如通过 Order 的日期进行查询或者通过 Product 的名称进行查询等,这样导致的结果是 Repository 上处理了太多的查询逻辑,变得越来越复杂,也逐渐偏离了 Repository 本应该承担的职责

基于数据模型的读操作

这种方式绕开了资源库和聚合,直接从数据库中读取客户端所需要的数据,此时写操作和读操作共享的只是数据库。比如,对于 “获取Product列表” 接口,通过一个专门的 ProductRepresentationService 直接从数据库中读取数据:

@Transactional(readOnly = true)
public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) {
  MapSqlParameterSource parameters = new MapSqlParameterSource();
  parameters.addValue("limit", pageSize);
  parameters.addValue("offset", (pageIndex - 1) * pageSize);

 List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters,
          (rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"),
                rs.getString("NAME"),
                rs.getBigDecimal("PRICE")));
  int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class);
  return PagedResource.of(total, pageIndex, products);
}

然后在 Controller 中直接返回:

@GetMapping
public PagedResource<ProductSummaryRepresentation> pagedProducts(@RequestParam(required = false, defaultValue = "1") int pageIndex,
                                                                @RequestParam(required = false, defaultValue = "10") int pageSize){
  return productRepresentationService.listProducts(pageIndex, pageSize);
}

可以看到,真个过程并没有使用到 ProductRepository 和 Product,而是将 SQL 获取到的数据直接新建为 ProductSummaryRepresentation对象

这种方式的优点是读操作的过程不用囿于领域模型,而是基于读操作本身的需求直接获取需要的数据即可,一方面简化了整个流程,另一方面大大提升了性能。但是,由于读操作和写操作共享了数据库,而此时的数据库主要是对应于聚合根的结构创建的,因此读操作依然会受到写操作的数据模型的牵制。不过这种方式是一种很好的折中,微软也提倡过这种方式,更多细节请参考微软官网

CQRS

CQRS(Command Query Responsibility Segregation),即命令查询职责分离,这里的命令可以理解为写操作,而查询可以理解为读操作。与 “基于数据模型的读操作” 不同的是,在 CQRS 中写操作和读操作使用了不同的数据库,数据从写模型数据库同步到读模型数据库,通常通过领域事件的形式同步变更信息
在这里插入图片描述
这样一来,读操作便可以根据自身所需独立设计数据结构,而不用受写模型数据结构的牵制。CQRS 本身是一个很大的话题,已经超出了本文的范围,读者可以自行研究

到此,DDD 中的读操作可以大致分为3种实现方式:
在这里插入图片描述

总结

本文主要介绍了 DDD 中的应用服务、聚合、资源库和工厂等概念以及与它们相关的编码实践,然后着重讲到了软件的读写操作在 DDD 中的实现方式,其中写操作的3种场景为:

  1. 通过聚合根完成业务请求,这是 DDD 完成业务请求的典型方式
  2. 通过 Factory 完成聚合根的创建,用于创建聚合根
  3. 通过 DomainService 完成业务请求,当业务放在聚合根中不合适时才考虑放在 DomainService 中

对于读操作,同样给出了3种方式:

  1. 基于领域模型的读操作(读写操作糅合在了一起,不推荐)
  2. 基于数据模型的读操作(绕过聚合根和资源库,直接返回数据,推荐)
  3. CQRS(读写操作分别使用不同的数据库)

以上“3读3写”基本上涵盖了程序员完成业务功能的日常开发之所需,原来 DDD 就这么简单,不是吗?

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值