查看原文
其他

持久化DDD聚合

stphenZkang SpringForAll社区 2020-10-17

原文链接:https://www.baeldung.com/spring-persisting-ddd-aggregates

作者:Mike Wojtyna

译者:康仔

1. 概述

在本教程中,我们将探索使用不同技术持久化DDD 聚合的可能性。

2.聚合的简介

聚合是一组始终需要保持一致的业务对象。因此,我们在事务中作为一个整体保存和更新聚合。

聚合是DDD中的一个重要战术模式,它有助于保持业务对象的一致性。然而,聚合的概念在DDD上下文之外也很有用。

在许多业务案例中,这种模式都可以派上用场。根据经验,当同一个事务中有多个对象被更改时,我们应该考虑使用聚合。

让我们看看在为订单购买建模时如何应用这一点。

2.1. 采购订单示例

因此,让我们假设我们想要建模一个采购订单:

  1. class Order {

  2.    private Collection<OrderLine> orderLines;

  3.    private Money totalCost;

  4.    // ...

  5. }

  6. class OrderLine {

  7.    private Product product;

  8.    private int quantity;

  9.    // ...

  10. }

  11. class Product {

  12.    private Money price;

  13.    // ...

  14. }

这些类形成一个简单的聚合。订单的orderLinestotalCost字段必须始终保持一致,即totalCost的值应该总是等于所有orderLines的总和。

现在,我们可能都想把所有这些都变成成熟的Java bean。但是,请注意,按照顺序引入简单的getter和setter很容易打破模型的封装,并违反业务约束。

让我们看看会出什么问题。

2.2. 聚合设计

让我们想象一下,如果我们决定向Order类中的所有属性(包括setOrderTotal)添加getter和setter,会发生什么。

没有什么可以阻止我们执行以下代码:

  1. Order order = new Order();

  2. order.setOrderLines(Arrays.asList(orderLine0, orderLine1));

  3. order.setTotalCost(Money.zero(CurrencyUnit.USD)); // this doesn't look good...

在这段代码中,我们手动将 totalCost 属性设置为零,这违反了一条重要的业务规则。当然,总成本不应该是零美元!

我们需要一种方法来保护我们的业务规则。让我们看看聚合根是如何起作用的。

2.3. 聚合根

聚合根是一个作为聚合入口点的类。所有业务操作都应该通过根。这样,聚合根就可以保证聚合保持一致的状态。

它的根本是考虑所有业务不变量。

在我们的示例中, Order 类是聚合根的正确候选对象。我们只需要做一些修改,以确保聚合始终一致:

  1. class Order {

  2.    private final List<OrderLine> orderLines;

  3.    private Money totalCost;

  4.    Order(List<OrderLine> orderLines) {

  5.        checkNotNull(orderLines);

  6.        if (orderLines.isEmpty()) {

  7.            throw new IllegalArgumentException("Order must have at least one order line item");

  8.        }

  9.        this.orderLines = new ArrayList<>(orderLines);

  10.        totalCost = calculateTotalCost();

  11.    }

  12.    void addLineItem(OrderLine orderLine) {

  13.        checkNotNull(orderLine);

  14.        orderLines.add(orderLine);

  15.        totalCost = totalCost.plus(orderLine.cost());

  16.    }

  17.    void removeLineItem(int line) {

  18.        OrderLine removedLine = orderLines.remove(line);

  19.        totalCost = totalCost.minus(removedLine.cost());

  20.    }

  21.    Money totalCost() {

  22.        return totalCost;

  23.    }

  24.    // ...

  25. }

使用聚合根现在允许我们更容易地将ProductOrderLine转换为不可变对象,其中所有属性都是final的。

我们可以看到,这是一个非常简单的集合。

我们可以简单地计算出每次的总成本而不用使用字段。

但是,现在我们只讨论聚合持久性,而不是聚合设计。请继续关注,因为这个特定领域很快就会派上用场。

这在持久性技术中发挥了多大的作用?让我们来看看。最终,这将帮助我们为下一个项目选择正确的持久性工具。

3. JPA and Hibernate

在本节中,让我们尝试使用JPA和Hibernate持久化订单聚合。我们将使用Spring Boot和JPA starter:

  1. <dependency>

  2.    <groupId>org.springframework.boot</groupId>

  3.    <artifactId>spring-boot-starter-data-jpa</artifactId>

  4. </dependency>

对我们大多数人来说,这似乎是最自然的选择。毕竟,我们花了多年的时间研究关系系统,我们都知道流行的ORM框架。

在使用ORM框架时,最大的问题可能是模型设计的简化。有时也被称为 对象关系阻抗失配。让我们想想,如果我们想保持我们的订单总量:

  1. @DisplayName("given order with two line items, when persist, then order is saved")

  2. @Test

  3. public void test() throws Exception {

  4.    // given

  5.    JpaOrder order = prepareTestOrderWithTwoLineItems();

  6.    // when

  7.    JpaOrder savedOrder = repository.save(order);

  8.    // then

  9.    JpaOrder foundOrder = repository.findById(savedOrder.getId())

  10.      .get();

  11.    assertThat(foundOrder.getOrderLines()).hasSize(2);

此时,该测试将抛出一个异常:java.lang.IllegalArgumentException: Unknown entity: com.baeldung.ddd.order.Order显然,我们遗漏了一些JPA需求:

1、添加映射注释

2、OrderLineProduct类必须是实体或@Embeddable类,而不是简单的值对象

3、为每个实体@Embeddable类添加一个空的构造函数

4、用简单类型替换货币属性

嗯,我们需要修改Order aggregate的设计以便能够使用JPA。虽然添加注释不是什么大问题,但是其他需求可能会带来很多问题。

3.1. 对值对象的更改

尝试将一个聚合体放入JPA的第一个问题是,我们需要打破我们的value对象的设计:它们的属性不再是final,我们需要打破封装。

我们需要在OrderLine和 Product中添加人工ids,即使这些类从未被设计为具有标识符。我们希望它们是简单的值对象。

可以使用 @Embedded@ElementCollection注解,但这种方法在使用复杂对象图时可能会使事情变得复杂(例如,@Embeddable对象具有另一个@Embedded属性等)。

使用@Embedded注解只是向父表添加平面属性。除此之外,基本属性(例如字符串类型)仍然需要setter方法,这违反了预期的值对象设计。

空构造函数要求强制value对象属性不再是final,这打破了我们最初设计的一个重要方面。说实话,Hibernate可以使用私有的no-args构造函数,这稍微减轻了一些问题,但它还远远不够完美。

即使使用私有默认构造函数,我们也不能将属性标记为final,或者需要在默认构造函数中使用默认值(通常为空)初始化它们。

然而,如果我们想要完全兼容JPA,我们必须至少对默认构造函数使用受保护的可见性,这意味着同一包中的其他类可以在不指定属性值的情况下创建值对象。

3.2. 复杂类型

不幸的是,我们不能期望JPA自动将第三方复杂类型映射到表中。看看我们在上一节中介绍了多少变化!

例如,在处理我们的订单集合时,我们将遇到坚持Joda Money 字段的困难。

在这种情况下,我们可能结束编写JPA 2.1中可用的自定义类型@Converter 。不过,这可能需要一些额外的工作。

或者,我们也可以将货币属性分为两种基本属性。例如,货币单位的字符串和实际值的BigDecimal

虽然我们可以隐藏实现细节,并且仍然通过公共方法API使用Money类,但实践表明,大多数开发人员无法证明额外的工作是合理的,而只是将模型简化以符合JPA规范。

3.3. 结论

虽然JPA是世界上采用最多的规范之一,但它可能不是保存订单聚合的最佳选择。

如果我们想要我们的模型反映真实的业务规则,我们应该将它设计成不是底层表的简单1:1表示。

基本上,我们有三个选择:

1、创建一组简单的数据类,并使用它们来持久化和重新创建丰富的业务模型。不幸的是,这可能需要很多额外的工作。

2、接受JPA的限制并选择合适的折衷方案。

3、考虑另一个技术。

第一种选择的潜力最大。实际上,大多数项目都是使用第二种方法开发的。

现在,让我们考虑另一种持久聚合的技术。

4. 文档存储

文档存储是存储数据的另一种方式。取代使用关系和表,我们保存整个对象。这使得文档存储成为持久化聚合的理想候选对象。

为了满足本教程的需求,我们将重点介绍json类型的文档。

让我们更深入地了解一下在MongoDB这样的文档存储中,订单持久性问题是如何出现的。

4.1. 使用MongoDB持久化聚合

现在,有很多数据库可以存储JSON数据,其中最流行的是MongoDB。MongoDB实际上是以二进制形式存储BSON或JSON。

x幸亏MongoDB,我们可以按原样存储订单示例聚合。

在我们继续之前,让我们添加Spring Boot MongoDB启动器:

  1. <dependency>

  2.    <groupId>org.springframework.boot</groupId>

  3.    <artifactId>spring-boot-starter-data-mongodb</artifactId>

  4. </dependency>

现在我们可以运行一个类似于JPA示例的测试用例,但这次使用MongoDB:

  1. @DisplayName("given order with two line items, when persist using mongo repository, then order is saved")

  2. @Test

  3. void test() throws Exception {

  4.    // given

  5.    Order order = prepareTestOrderWithTwoLineItems();

  6.    // when

  7.    repo.save(order);

  8.    // then

  9.    List<Order> foundOrders = repo.findAll();

  10.    assertThat(foundOrders).hasSize(1);

  11.    List<OrderLine> foundOrderLines = foundOrders.iterator()

  12.      .next()

  13.      .getOrderLines();

  14.    assertThat(foundOrderLines).hasSize(2);

  15.    assertThat(foundOrderLines).containsOnlyElementsOf(order.getOrderLines());

  16. }

重要的是,我们没有改变原始的聚合类的顺序;不需要为货币类创建默认构造函数、设置器或自定义转换器。

下面是我们在商店里的订单总和:

  1. {

  2.  "_id": ObjectId("5bd8535c81c04529f54acd14"),

  3.  "orderLines": [

  4.    {

  5.      "product": {

  6.        "price": {

  7.          "money": {

  8.            "currency": {

  9.              "code": "USD",

  10.              "numericCode": 840,

  11.              "decimalPlaces": 2

  12.            },

  13.            "amount": "10.00"

  14.          }

  15.        }

  16.      },

  17.      "quantity": 2

  18.    },

  19.    {

  20.      "product": {

  21.        "price": {

  22.          "money": {

  23.            "currency": {

  24.              "code": "USD",

  25.              "numericCode": 840,

  26.              "decimalPlaces": 2

  27.            },

  28.            "amount": "5.00"

  29.          }

  30.        }

  31.      },

  32.      "quantity": 10

  33.    }

  34.  ],

  35.  "totalCost": {

  36.    "money": {

  37.      "currency": {

  38.        "code": "USD",

  39.        "numericCode": 840,

  40.        "decimalPlaces": 2

  41.      },

  42.      "amount": "70.00"

  43.    }

  44.  },

  45.  "_class": "com.baeldung.ddd.order.mongo.Order"

  46. }

这个简单的BSON文档将整个订单聚合在一起,与我们最初的概念一致。

注意,BSON文档中的复杂对象被简单地序列化为一组常规JSON属性。因此,即使是第三方类(比如 Joda Money)也可以轻松序列化,而无需简化模型。

4.2. 结论

使用MongoDB持久化聚合比使用JPA更简单。

这并不意味着MongoDB优于传统的数据库。在许多合法的情况下,我们甚至不应该尝试将我们的类建模为聚合,而是使用SQL数据库。

尽管如此,当我们确定了一组对象,这些对象应该根据复杂的需求始终保持一致时,那么使用文档存储可能是一个非常有吸引力的选择。

5. 结论

在DDD中,聚合通常包含系统中最复杂的对象。与大多数CRUD应用程序相比,使用它们需要一种非常不同的方法。

使用流行的ORM解决方案可能会导致过于简单或过度公开的领域模型,这通常无法表达或强制执行复杂的业务规则。

文档存储可以使持久化聚合变得更容易,而不会牺牲模型的复杂性。

所有示例的完整源代码都可以在GitHub 上找到。


推荐: 【SFA官方译】:使用Spring Security保护REST API

上一篇:抽象工厂设计模式

关注公众号

点击原文查看更多


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存