在DDD:战术领域驱动设计中,我们了解到,我们应该通过ID引用其他集合,并使用值对象来区分不同的集合类型(第二个集合设计指南)。
使用JPA和Hibernate完全可以做到这一点,但是由于它违反了JPA的设计原则,因此您无需做任何事情,您完全不必关心ID,而可以直接使用实体对象,因此需要做一些额外的工作。让我们看看如何。我们将以我在帖子中列出的有关值对象和聚合的代码和原则为基础,因此,如果您尚未阅读这些内容,请先阅读这些内容。
如果您使用的是Hibernate以外的其他JPA实现,则必须查看该实现的文档以了解如何创建自定义类型
属性转换器不会
首先想到的可能是使用简单值对象和属性转换器。不幸的是,这是不可能的,因为JPA不支持对@Id字段使用属性转换器。您可以做出妥协,并为@Id字段和简单值对象使用“原始” ID从其他聚合中引用它们,但我个人并不喜欢这种方法,因为您必须在值对象及其包装的原始对象之间来回移动ID,使编写查询更加困难。更好,更一致的方法是创建自定义的Hibernate类型。
创建自定义休眠类型
当为ID值对象创建自定义的Hibernate类型时,它们可以在整个持久性上下文中使用,而无需在任何地方添加任何注释。这涉及以下步骤:
- 确定你要什么样的原始ID类型的使用你的值对象中:UUID,String或Long
- 为您的值对象创建一个类型描述符。该描述符知道如何将另一个值转换为值对象的一个实例(包装),反之亦然(打开包装)。
- 创建一个自定义类型,将您的类型描述符与您要用于ID的JDBC列类型联系在一起。
- 在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.
}
- 您必须实现该Serializable接口,因为Persistable假定ID类型是可持久的。有时,我会创建一个名为DomainObjectId扩展ValueObject和的新标记接口Serializable。
- 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>
}
- AbstractTypeDescriptor是驻留在org.hibernate.type.descriptor.java包中的Hibernate基类。
- 此方法将我们的value对象转换为字符串。我们使用Hibernate内置的帮助程序类UUIDTypeDescriptor(也来自org.hibernate.type.descriptor.java软件包)来执行转换。
- 此方法从字符串构造一个值对象。同样,我们使用来自的帮助程序类UUIDTypeDescriptor。
- 此方法将值对象转换为UUID,字符串或字节数组。同样,我们使用来自的帮助程序类UUIDTypeDescriptor。
- 此方法将UUID,字符串或字节数组转换为值对象。这里也使用辅助类。
- 由于它不包含任何可更改的状态,因此我们可以将其作为单例访问。
到目前为止,我们仅涉及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>
}
}
- AbstractSingleColumnStandardBasicType是驻留在org.hibernate.type包中的Hibernate基类。
- 为了使自定义类型在@Id字段中正常工作,我们必须从org.hibernate.id包中实现此额外的接口。
- 在这里,我们传入SQL类型描述符(在本例中为二进制,因为我们将UUID存储在16字节的二进制列中)和Java类型描述符。
- 在这里,我们从JDBC结果集中以字节数组的形式检索ID。
- ...并CustomerId使用我们的Java类型描述符将其转换为。
- 定制类型需要一个名称,因此我们使用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;
- 这个Hibernate注释告诉HibernateCustomerIdType每当遇到时都要使用CustomerId。
- 请注意,导入是在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();
}
}
- 我们不再扩展,AbstractPersistable但我们确实实现了该Persistable接口。
- 此方法来自Persistable接口,必须由子类实现。
- 此方法也来自Persistable接口。
- 由于我们不再扩展AbstractPersistable,因此必须重写toString自己才能返回有用的东西。有时,我还会包含对象标识哈希码,以明确我们是否要处理同一实体的不同实例。
- 我们还必须重写equals。请记住,具有相同ID的两个相同类型的实体被视为同一实体。
- ProxyUtils是Spring实用程序类,在JPA实现对实体类进行字节码更改的情况下很有用,导致getClass()不一定返回您认为可能返回的内容。
- 由于我们已覆盖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;
}
}
- 我们扩展BaseAggregateRoot,这反过来又扩展了我们的重构BaseEntity类。
- 我们用常量声明ID生成器的名称。当我们向Hibernate注册自定义生成器时,我们将使用它。
- 现在,我们不再受制于映射超类中使用的任何注释。
- 我们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>
}
}
- IdentifierGenerator是驻留在org.hibernate.id包中的接口。
- 由于在Hibernate中注册了新的生成器,因此我们需要类的全名作为字符串。我们将其存储在一个常量中,以使将来的重构更加容易-并将错别字引起的错误风险降至最低。
- 在此示例中,我们用于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;
- 这个注释告诉HibernateCustomerIdGenerator每当遇到名为的生成器时就使用customer-id-generator。
现在,我们的域模型应该可以按照我们期望的那样工作,并使用自动生成的值对象作为ID。
关于组合键的注意事项
在我们离开ID主题之前,我只想提及一件事。通过将ID字段从映射的超类(BaseEntity)移到具体的实体类(Customer在上面的示例中),我们还打开了在实体中使用复合键的可能性(使用@EmbeddedId或@IdClass)。例如,您可能遇到一种情况,其中组合键由另一个聚合根的ID和枚举常量组成。