JPA 有自己的@Entity概念,但远没有 DDD 中的实体概念那么严格。这既是优点也是缺点。优点是使用 JPA 实现实体和聚合非常容易。缺点是同样容易做 DDD 不允许的事情。如果您与以前广泛使用过 JPA 但不熟悉 DDD 的开发人员一起工作,这可能会特别成问题。

而值对象只是实现了一个空的标记接口,实体和聚合根将需要更广泛的基类。从一开始就正确设置基类很重要,因为以后更改它们将非常困难,尤其是当域模型变得很大时。为了帮助我们完成这项任务,我们将使用Spring Data

Spring Data 提供了一些开箱即用的基类,您可以根据需要使用它们,因此让我们从查看它们开始。

使用Persistable,AbstractPersistableAbstractAggregateRoot

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

PersistableSpring Data 还提供了实现接口的抽象基类: AbstractPersistable. 它是一个泛型类,将 ID 的类型作为其单个泛型参数。ID 字段带有注释,@GeneratedValue这意味着 Hibernate 等 JPA 实现将在实体首次持久化时尝试自动生成 ID。该类将具有非空 ID 的实体视为持久实体,将具有空 ID 的实体视为新实体。最后,它覆盖equalsandhashCode以便在检查相等性时只考虑类和 ID。这与 DDD 一致 - 如果两个实体具有相同的 ID,则它们被视为相同。

如果您可以在实体 ID 中使用普通 Java 类型(例如LongUUID)并让 JPA 实现在实体首次持久化时为您生成它们,那么这个基类是实体和聚合根的绝佳起点。但是等等,还有更多。

Spring Data 还提供了一个抽象基类,称为AbstractAggregateRoot. 这是一个类 - 你猜对了 - 旨在通过聚合根进行扩展。但是,它既不扩展AbstractPersistable也不实现Persistable接口。那你为什么要使用这个类?好吧,它提供了允许聚合注册域事件的方法,然后在保存实体后发布这些事件。这真的很有用,我们将在以后的文章中回到这个主题。此外,不在基类中声明 ID 字段并让聚合根声明它们自己的 ID 也有一些好处。我们还将在稍后的帖子中回到这个主题。

在实践中,您希望聚合根是Persistable,因此您最终实现了自己的基类的方法AbstractAggregteRootAbstractPersistable在您自己的基类中。接下来让我们看看如何做到这一点。

建立自己的基类

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

我经常从一个名为的基类开始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 Long 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但我们还没有讨论原因。在战术领域驱动设计中,聚合设计的第四条准则是使用乐观锁定。但是为什么我们将@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,我们可以防止出现这种情况。聚合根和本地实体都将被乐观锁定,并且不可能意外覆盖其他人的更改。