DDD:使用Spring数据构建聚合

2,561 阅读7分钟

翻译自:vaadin.com/learn/tutor…

上一篇文章中,我们学习了如何构建可持久保存在JPA中的价值对象。现在是时候继续研究实际上将包含您的值对象的对象:实体和聚合。

JPA有其自己的@Entity概念,但它比DDD中的实体概念的限制要少得多。这既是优点也是缺点。优点是,使用JPA实施实体和聚合非常容易。缺点是做DDD不允许做的事情同样容易。如果您与以前广泛使用JPA但不熟悉DDD的开发人员一起工作,则这可能会特别成问题。

值对象刚刚实现了一个空的标记接口,而实体和聚合根将需要更广泛的基类。从一开始就正确设置基类很重要,因为以后很难更改它们,尤其是在域模型变大的情况下。为了帮助我们完成此任务,我们将使用Spring Data。 Spring Data开箱即用地提供了一些基础类,您可以根据需要使用它们,因此让我们从查看它们开始。

使用Persistable,AbstractPersistable 和 AbstractAggregateRoot

Spring Data提供了一个现成的名为的接口Persistable。此接口有两种方法,一种用于获取实体的ID,另一种用于检查实体是新实体还是持久实体。如果一个实体实现了这个接口,Spring Data将使用它来决定在保存时是调用persist(新实体)还是merge(持久实体)。但是,您不需要实现此接口。Spring Data还可以使用乐观锁定版本来确定实体是否是新的:如果没有,那就是新的。在决定如何生成实体ID时,您需要意识到这一点。

春天的数据也提供了一个抽象基类,实现了Persistable接口:AbstractPersistable。它是一个通用类,将ID的类型作为其单个通用参数。ID字段带有注释,@GeneratedValue这意味着在首次保留实体时,诸如Hibernate之类的JPA实现将尝试自动生成ID。该类将具有非空ID的实体视为已持久,将具有空ID的实体视为新的。最后,它会覆盖equals和,hashCode以便在检查相等性时仅考虑类和ID。这与DDD一致-如果两个实体具有相同的ID,则认为它们是相同的。

如果您可以使用普通的Java类型(例如Long或UUID)作为实体ID,并让JPA实现在实体首次保留时为您生成它们,那么此基类是您的实体和聚合根的理想起点。但是,等等,还有更多。

Spring Data还提供了一个称为的抽象基类AbstractAggregateRoot。您猜到了,这是一个旨在由聚合根扩展的类。但是,它并不能延长AbstractPersistable,也没有实现Persistable接口。那为什么要使用这个类呢?好的,它提供了一些方法,这些方法使您的聚合可以注册域事件,然后在实体保存后发布这些事件。这确实很有用,我们将在以后的文章中再次讨论该主题。同样,在基类中不声明ID字段并让聚合根声明自己的ID也有一些好处。我们还将在以后的文章中再次讨论该主题。

在实践中,您希望聚合根成为根Persistable,因此最终要实现自己的基类中的方法AbstractAggregteRoot或AbstractPersistable在自己的基类中实现方法。让我们看看下一步如何做。

建立自己的基类

在我工作的几乎所有项目中,无论是在工作中还是在私人项目中,我都从创建自己的基类开始。我的大多数领域模型都是基于聚合根和值对象构建的;我很少使用所谓的本地实体(属于集合但不是根的实体)。

我通常从称为的基类开始,BaseEntity它看起来像这样:

@MappedSuperclass // <1>
public abstract class BaseEntity<Id extends Serializable> extends AbstractPersistable<Id> { // <2>

    @Version // <3>
    private Long version;

    public @NotNull Optional<Long> getVersion() {
        return Optional.ofNullable(version);
    }

    protected void setVersion(@Nullable version) { // <4>
        this.version = version;
    }
}
  1. 即使该类已命名BaseEntity,它也不是JPA@Entity而是一个@MappedSuperclass。
  2. 范围Serializable直接来自AbstractPersistable。
  3. 我对所有实体使用乐观锁定。在这篇文章的后面,我们将回到这一点。
  4. 在极少数情况下,您想手动设置开放式锁定版本。但是,为了安全起见,我提供了一种受保护的方法来使之成为可能。我认为大多数有几年工作经验的Java开发人员都经历过这样的情况,他们实际上确实需要在超类中设置属性或调用方法,只是发现它是私有的。

上完BaseEntity课程后,我将继续学习BaseAggregateRoot。这本质上是Spring Data的副本AbstractAggregateRoot,但是它扩展了BaseEntity:

@MappedSuperclass // <1>
public abstract class BaseAggregateRoot<Id extends Serializable> extends BaseEntity<Id> {

    private final @Transient List<Object> domainEvents = new ArrayList<>(); // <2>

    protected void registerEvent(@NotNull Object event) { // <3>
        domainEvents.add(Objects.requireNonNull(event));
    }

    @AfterDomainEventPublication // <4>
    protected void clearDomainEvents() {
        this.domainEvents.clear();
    }

    @DomainEvents // <5>
    protected Collection<Object> domainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }
}
  1. 此基类也是@MappedSuperclass。
  2. 此列表将包含保存聚合时要发布的所有域事件。这是@Transient因为我们不想将它们存储在数据库中。
  3. 如果要从聚合中发布域事件,请使用此受保护的方法注册它。在本文的后面,我们将对此进行更仔细的研究。
  4. 这是一个Spring Data注解。域事件发布后,Spring Data将调用此方法。
  5. 这也是一个Spring Data注解。Spring Data将调用此方法来获取要发布的域事件。

就像我说的那样,我很少使用本地实体。但是,当需要时,我经常创建一个BaseLocalEntity扩展BaseEntity但不提供任何其他功能的类(可能是对拥有它的聚合根的引用除外)。我会将其作为练习留给读者。

乐观锁

我们已经@Version为乐观锁定添加了一个字段,BaseEntity但是我们尚未讨论原因。在DDD:战术领域驱动设计中,聚合设计的第四条准则是使用乐观锁定。但是,为什么我们将@Version字段添加到BaseEntity而不是BaseAggregateRoot?毕竟,不是负责始终维护聚合完整性的聚合根吗?

这个问题的答案是肯定的,但是在这里,底层的持久性技术(JPA及其实现)再次潜入了我们的领域设计。假设我们使用Hibernate作为我们的JPA实现。

Hibernate不知道聚合根是什么-它仅处理实体和可嵌入对象。Hibernate还会跟踪实际更改了哪些实体,并且仅将这些更改刷新到数据库中。实际上,这意味着即使您明确要求Hibernate保存实体,实际上也不会将任何更改写入数据库,并且乐观版本号可能保持不变。

只要您只处理集合的根和值对象,这不是问题。对于Hibernate,对可嵌入对象的更改始终是对其拥有实体的更改,因此,该实体的乐观版本(在这种情况下为总根)将按预期增加。但是,一旦将本地实体添加到组合中,情况就会发生变化。例如:

@Entity
public class Invoice extends BaseAggregateRoot<InvoiceId> { // <1>

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<InvoiceItem> items; // <2>

    // The rest of the methods and fields are omitted
}
  1. Invoice是聚合根,因此它扩展了BaseAggregateRoot类。
  2. InvoiceItem是一个本地实体,因此它可以扩展BaseEntity类,也可以BaseLocalEntity根据您的基类层次结构扩展类。此类的实现并不重要,因此我们将其省略,但是请注意@OneToMany批注中的级联选项。

本地实体归其聚合根所有,因此通过级联保留。但是,如果仅更改了本地实体而不更改了聚合根,则保存聚合根只会导致将本地实体刷新到数据库。在上面的示例中,如果我们仅对发票项目进行了更改,然后保存了整个发票,则发票版本号将保持不变。如果在保存发票之前另一个用户对同一项目进行了更改,我们将以无声方式覆盖其他用户的更改。

通过向中添加开放式锁定版本字段BaseEntity,我们可以防止出现此类情况。聚合根和本地实体都将被乐观地锁定,并且不可能意外覆盖其他人的更改。