DDD:使用值对象作为Hibernate的聚合标识符

924 阅读9分钟

DDD:战术领域驱动设计中,我们了解到,我们应该通过ID引用其他集合,并使用值对象来区分不同的集合类型(第二个集合设计指南)。

使用JPA和Hibernate完全可以做到这一点,但是由于它违反了JPA的设计原则,因此您无需做任何事情,您完全不必关心ID,而可以直接使用实体对象,因此需要做一些额外的工作。让我们看看如何。我们将以我在帖子中列出的有关值对象聚合的代码和原则为基础,因此,如果您尚未阅读这些内容,请先阅读这些内容。

如果您使用的是Hibernate以外的其他JPA实现,则必须查看该实现的文档以了解如何创建自定义类型

属性转换器不会

首先想到的可能是使用简单值对象和属性转换器。不幸的是,这是不可能的,因为JPA不支持对@Id字段使用属性转换器。您可以做出妥协,并为@Id字段和简单值对象使用“原始” ID从其他聚合中引用它们,但我个人并不喜欢这种方法,因为您必须在值对象及其包装的原始对象之间来回移动ID,使编写查询更加困难。更好,更一致的方法是创建自定义的Hibernate类型。

创建自定义休眠类型

当为ID值对象创建自定义的Hibernate类型时,它们可以在整个持久性上下文中使用,而无需在任何地方添加任何注释。这涉及以下步骤:

  1. 确定你要什么样的原始ID类型的使用你的值对象中:UUID,String或Long
  2. 为您的值对象创建一个类型描述符。该描述符知道如何将另一个值转换为值对象的一个​​实例(包装),反之亦然(打开包装)。
  3. 创建一个自定义类型,将您的类型描述符与您要用于ID的JDBC列类型联系在一起。
  4. 在Hibernate中注册您的自定义类型。

让我们看一个代码示例,以更好地说明这一点。我们将创建一个名为的值对象ID CustomerId,该ID包装了UUID。值对象如下所示:

package foo.bar.domain.model;

// Imports omitted

public class CustomerId implements ValueObject, Serializable { // <1>

    private final UUID uuid;

    public CustomerId(@NotNull UUID uuid) {
        this.uuid = Objects.requireNonNull(uuid);
    }

    public @NotNull UUID unwrap() { // <2>
        return uuid;
    }

    // Implementation of equals() and hashCode() omitted.
}
  1. 您必须实现该Serializable接口,因为Persistable假定ID类型是可持久的。有时,我会创建一个名为DomainObjectId扩展ValueObject和的新标记接口Serializable。
  2. UUID实现类型描述符时,您需要一种获取基础结构的方法。

接下来,我们将创建类型描述符。我通常将其放在一个子包中,.hibernate以保持域模型本身的美观和整洁。

package foo.bar.domain.model.hibernate;

// Imports omitted

public class CustomerIdTypeDescriptor extends AbstractTypeDescriptor<CustomerId> { // <1>

    public CustomerIdTypeDescriptor() {
        super(CustomerId.class);
    }

    @Override
    public String toString(CustomerId value) { // <2>
        return UUIDTypeDescriptor.ToStringTransformer.INSTANCE.transform(value.unwrap()); 
    }

    @Override
    public ID fromString(String string) { // <3>
        return new CustomerId(UUIDTypeDescriptor.ToStringTransformer.INSTANCE.parse(string)); 
    }

    @Override
    @SuppressWarnings("unchecked")
    public <X> X unwrap(CustomerId value, Class<X> type, WrapperOptions options) { // <4>
        if (value == null) {
            return null;
        }
        if (getJavaType().isAssignableFrom(type)) {
            return (X) value;
        }
        if (UUID.class.isAssignableFrom(type)) {
            return (X) UUIDTypeDescriptor.PassThroughTransformer.INSTANCE.transform(value.unwrap());
        }
        if (String.class.isAssignableFrom(type)) {
            return (X) UUIDTypeDescriptor.ToStringTransformer.INSTANCE.transform(value.unwrap());
        }
        if (byte[].class.isAssignableFrom(type)) {
            return (X) UUIDTypeDescriptor.ToBytesTransformer.INSTANCE.transform(value.unwrap());
        }
        throw unknownUnwrap(type);
    }

    @Override
    public <X> CustomerId wrap(X value, WrapperOptions options) { // <5>
        if (value == null) {
            return null;
        }
        if (getJavaType().isInstance(value)) {
            return getJavaType().cast(value);
        }
        if (value instanceof UUID) {
            return new CustomerId(UUIDTypeDescriptor.PassThroughTransformer.INSTANCE.parse(value));
        }
        if (value instanceof String) {
            return new CustomerId(UUIDTypeDescriptor.ToStringTransformer.INSTANCE.parse(value));
        }
        if (value instanceof byte[]) {
            return new CustomerId(UUIDTypeDescriptor.ToBytesTransformer.INSTANCE.parse(value));
        }
        throw unknownWrap(value.getClass());
    }

    public static final CustomerIdTypeDescriptor INSTANCE = new CustomerIdTypeDescriptor(); // <6>
}
  1. AbstractTypeDescriptor是驻留在org.hibernate.type.descriptor.java包中的Hibernate基类。
  2. 此方法将我们的value对象转换为字符串。我们使用Hibernate内置的帮助程序类UUIDTypeDescriptor(也来自org.hibernate.type.descriptor.java软件包)来执行转换。
  3. 此方法从字符串构造一个值对象。同样,我们使用来自的帮助程序类UUIDTypeDescriptor。
  4. 此方法将值对象转换为UUID,字符串或字节数组。同样,我们使用来自的帮助程序类UUIDTypeDescriptor。
  5. 此方法将UUID,字符串或字节数组转换为值对象。这里也使用辅助类。
  6. 由于它不包含任何可更改的状态,因此我们可以将其作为单例访问。

到目前为止,我们仅涉及Java类型。现在是时候将SQL和JDBC结合起来并创建我们的自定义类型了:

package foo.bar.domain.model.hibernate;

// Imports omitted

public class CustomerIdType extends AbstractSingleColumnStandardBasicType<CustomerId> // <1>
    implements ResultSetIdentifierConsumer { // <2>

    public CustomerIdType() {
        super(BinaryTypeDescriptor.INSTANCE, CustomerIdTypeDescriptor.INSTANCE); // <3>
    }

    @Override
    public Serializable consumeIdentifier(ResultSet resultSet) {
        try {
            var id = resultSet.getBytes(1); // <4>
            return getJavaTypeDescriptor().wrap(id, null); // <5>
        } catch (SQLException ex) {
            throw new IllegalStateException("Could not extract ID from ResultSet", ex);
        }
    }

    @Override
    public String getName() {
        return getJavaTypeDescriptor().getJavaType().getSimpleName(); // <6>
    }
}
  1. AbstractSingleColumnStandardBasicType是驻留在org.hibernate.type包中的Hibernate基类。
  2. 为了使自定义类型在@Id字段中正常工作,我们必须从org.hibernate.id包中实现此额外的接口。
  3. 在这里,我们传入SQL类型描述符(在本例中为二进制,因为我们将UUID存储在16字节的二进制列中)和Java类型描述符。
  4. 在这里,我们从JDBC结果集中以字节数组的形式检索ID。
  5. ...并CustomerId使用我们的Java类型描述符将其转换为。
  6. 定制类型需要一个名称,因此我们使用Java类型的名称。

最后,我们只需要在Hibernate中注册我们的新类型。我们将package-info.java在与CustomerId类位于同一包中的文件中执行此操作:

@TypeDef(defaultForType = CustomerId.class, typeClass = CustomerIdType.class) // <1>
package foo.bar.domain.model;

import org.hibernate.annotations.TypeDef; // <2>
import foo.bar.domain.model.hibernate.CustomerIdType;
  1. 这个Hibernate注释告诉HibernateCustomerIdType每当遇到时都要使用CustomerId。
  2. 请注意,导入是在package-info.java文件中的注释之后,而不是在类文件中的注释之前。

现在,我们既可以CustomerId用来标识Customer集合,也可以从其他集合中引用它们。但是请记住,如果让Hibernate为您生成SQL模式,并且使用ID来引用聚合而不是@ManyToOne关联,则Hibernate将不会创建外键约束。您将必须自己执行操作,例如使用Flyway。

如果您有许多不同的ID值对象类型,则需要为类型描述符和自定义类型创建抽象基类,以避免重复自己。我将把它作为练习留给读者。

但是,等等,我们不是忘记了什么吗?CustomerID当我们持久保存新创建的Customer聚合根时,我们实际上将如何生成新实例?让我们找出答案。

生成值对象ID

一旦有了ID值对象和自定义类型,就需要一种生成新ID的方法。您可以在保留实体之前创建ID并手动分配它们(如果使用UUID,这确实很容易),或者可以将Hibernate配置为在需要它们时自动为您生成ID。后一种方法设置起来比较困难,但是一旦完成就更容易使用,因此让我们来看看。

重构您的基类

JPA支持不同的ID生成器。如果查看@GeneratedValue批注,则可以指定generator要使用的的名称。在这里,我们遇到第一个警告。如果您在映射的超类(例如AbstractPersistable)内声明ID字段,则无法覆盖该@GeneratedValue字段的注释。换句话说,您将为扩展该基类的所有聚合根和实体使用相同的ID生成器。如果发现自己处于这种情况,则必须从基类中删除ID字段,并让每个聚合根和实体声明其自己的ID字段。

因此,BaseEntity该类(我们最初在此处定义了该类)变为如下所示:

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

    @Version
    private Long version;

    @Override
    @Transient 
    public abstract @Nullable ID getId(); // <2>

    @Override
    @Transient 
    public boolean isNew() { // <3>
        return getId() == null;
    }

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

    protected void setVersion(@Nullable version) {
        this.version = version;
    }

    @Override
    public String toString() { // <4>
        return String.format("%s{id=%s}", getClass().getSimpleName(), getId());
    }

    @Override
    public boolean equals(Object obj) { // <5>
        if (null == obj) {
            return false;
        }
        if (this == obj) {
            return true;
        }
        if (!getClass().equals(ProxyUtils.getUserClass(obj))) { // <6>
            return false;
        }

        var that = (BaseEntity<?>) obj;
        var id = getId();
        return id != null && id.equals(that.getId());
    }

    @Override
    public int hashCode() { // <7>
        var id = getId();
        return id == null ? super.hashCode() : id.hashCode();
    }
}
  1. 我们不再扩展,AbstractPersistable但我们确实实现了该Persistable接口。
  2. 此方法来自Persistable接口,必须由子类实现。
  3. 此方法也来自Persistable接口。
  4. 由于我们不再扩展AbstractPersistable,因此必须重写toString自己才能返回有用的东西。有时,我还会包含对象标识哈希码,以明确我们是否要处理同一实体的不同实例。
  5. 我们还必须重写equals。请记住,具有相同ID的两个相同类型的实体被视为同一实体。
  6. ProxyUtils是Spring实用程序类,在JPA实现对实体类进行字节码更改的情况下很有用,导致getClass()不一定返回您认为可能返回的内容。
  7. 由于我们已覆盖equals,因此我们还必须以hashCode相同的方式覆盖。

现在,我们对进行了必要的更改后BaseEntity,可以将ID字段添加到我们的聚合根目录中:

@Entity
public class Customer extends BaseAggregateRoot<CustomerId> { // <1>

    public static final String ID_GENERATOR_NAME = "customer-id-generator"; // <2>

    @Id
    @GeneratedValue(generator = ID_GENERATOR_NAME) // <3>
    private CustomerId id;

    @Override
    public @Nullable CustomerId getId() { // <4>
        return id;
    }
}
  1. 我们扩展BaseAggregateRoot,这反过来又扩展了我们的重构BaseEntity类。
  2. 我们用常量声明ID生成器的名称。当我们向Hibernate注册自定义生成器时,我们将使用它。
  3. 现在,我们不再受制于映射超类中使用的任何注释。
  4. 我们getId()从实现了abstract方法Persistable。

实施ID生成器

接下来,我们必须实现我们的自定义ID生成器。由于我们使用的是UUID,因此这几乎是微不足道的。对于其他ID生成策略,建议您选择一个现有的Hibernate生成器并以此为基础(在此处开始查找)。ID生成器将如下所示:

package foo.bar.domain.model.hibernate;

public class CustomerIdGenerator implements IdentifierGenerator { // <1>

    public static final String STRATEGY = "foo.bar.domain.model.hibernate.CustomerIdGenerator"; // <2>

    @Override
    public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
        return new CustomerId(UUID.randomUUID()); // <3>
    }
}
  1. IdentifierGenerator是驻留在org.hibernate.id包中的接口。
  2. 由于在Hibernate中注册了新的生成器,因此我们需要类的全名作为字符串。我们将其存储在一个常量中,以使将来的重构更加容易-并将错别字引起的错误风险降至最低。
  3. 在此示例中,我们用于UUID.randomUUID()创建新的UUID。请注意,如果需要执行更高级的操作(例如从数据库序列中检索数值),则可以访问Hibernate会话。

最后,我们必须在Hibernate中注册新的ID生成器。与自定义类型一样,这种情况发生在中package-info.java,它变为:

@TypeDef(defaultForType = CustomerId.class, typeClass = CustomerIdType.class)
@GenericGenerator(name = Customer.ID_GENERATOR_NAME, strategy = CustomerIdGenerator.STRATEGY) // <1>
package foo.bar.domain.model;

import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.TypeDef;
import foo.bar.domain.model.hibernate.CustomerIdType;
import foo.bar.domain.model.hibernate.CustomerIdGenerator;
  1. 这个注释告诉HibernateCustomerIdGenerator每当遇到名为的生成器时就使用customer-id-generator。

现在,我们的域模型应该可以按照我们期望的那样工作,并使用自动生成的值对象作为ID。

关于组合键的注意事项

在我们离开ID主题之前,我只想提及一件事。通过将ID字段从映射的超类(BaseEntity)移到具体的实体类(Customer在上面的示例中),我们还打开了在实体中使用复合键的可能性(使用@EmbeddedId或@IdClass)。例如,您可能遇到一种情况,其中组合键由另一个聚合根的ID和枚举常量组成。