在DDD:战术领域驱动设计中,我们了解了价值对象是什么以及它有什么用。我们从来没有真正研究过如何在实际项目中使用它。现在是时候our起袖子,仔细看看一些实际的代码了!
值对象是域驱动设计中最简单,最有用的构建块之一,因此让我们从研究将JPA使用值对象的不同方式开始。为此,我们将从XML Schema规范中窃取简单类型和复杂类型的概念。
简单值对象是一个值对象,它仅包含某种类型的值,例如单个字符串或整数。复杂值对象是一个包含多个类型的多个值的值对象,例如带有街道名称,数字,邮政编码,城市,州,国家等的邮政地址。
因为我们要将价值对象持久化到关系数据库中,所以在实现它们时必须区别对待这两种类型。但是,这些实现细节与实际使用值对象的代码无关紧要。
简单值对象:属性转换器
简单值对象非常容易持久,并且对于最终字段和所有字段而言都是真正不可变的。为了持久保留它们,您必须编写一个AttributeConverter(标准的JPA接口)知道如何在已知类型的数据库列和值对象之间进行转换。
让我们从一个示例值对象开始:
public class EmailAddress implements ValueObject { // <1>
private final String email; // <2>
public EmailAddress(@NotNull String email) {
this.email = validate(email); // <3>
}
@Override
public @NotNull String toString() { // <4>
return email;
}
@Override
public boolean equals(Object o) { // <5>
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EmailAddress that = (EmailAddress) o;
return email.equals(that.email);
}
@Override
public int hashCode() { // <6>
return email.hashCode();
}
public static @NotNull String validate(@NotNull String email) { // <7>
if (!isValid(email)) {
throw new IllegalArgumentException("Invalid email: " + email);
}
return email;
}
public static boolean isValid(@NotNull String email) { // <8>
// Validate the input string, return true or false depending on whether it is a valid e-mail address or not
}
}
- ValueObject是空标记界面。它仅用于文档目的,没有功能意义。如果需要,可以将其省略。
- 包含电子邮件地址的字符串标记为final。由于这是类中的唯一字段,因此它使该类真正不可变。
- 输入字符串在构造函数中经过验证,因此无法使其实例EmailAddress包含无效数据。
- 电子邮件地址字符串可通过该toString()方法访问。如果要将此方法用于调试目的,则可以使用另一个选择的getter方法(我有时使用一个unwrap()方法,因为简单值对象本质上是其他值的包装器)。
- 具有相同值的两个值对象被视为相等,因此我们必须相应地实现该equals()方法。
- 我们改变了,equals()所以现在我们也必须改变hashCode()。
- 这是一种静态方法,构造函数使用它来验证输入,但也可以从外部使用它来验证包含电子邮件地址的字符串。如果电子邮件地址无效,则此版本引发异常。
- 验证电子邮件地址字符串的另一种静态方法,但是该方法仅返回布尔值。也可以从外部使用。
现在,相应的属性转换器将如下所示:
@Converter // <1>
public class EmailAddressAttributeConverter implements AttributeConverter<String, EmailAddress> { // <2>
@Override
@Contract("null -> null")
public String convertToDatabaseColumn(EmailAddress attribute) {
return attribute == null ? null : attribute.toString(); // <3>
}
@Override
@Contract("null -> null")
public EmailAddress convertToEntityAttribute(String dbData) {
return dbData == null ? null : new EmailAddress(dbData); // <4>
}
}
- @Converter是标准的JPA批注。如果要让Hibernate自动将转换器应用于所有EmailAddress属性,请将autoApply参数设置为true(在此示例中为false,这是默认值)。
- AttributeConverter 是一个采用两个通用参数的标准JPA接口:数据库列类型和属性类型。
- 此方法将转换EmailAddress为字符串。请注意,输入参数可以是null。
- 此方法将字符串转换为EmailAddress。同样,请注意输入参数可以是null。
您可以将转换器存储在与value对象相同的包中,也可以存储在子包中(例如.converters),如果您想保持域包的美观和整洁。
最后,您可以在JPA实体中使用此值对象,如下所示:
@Entity
public class Contact {
@Convert(converter = EmailAddressAttributeConverter.class) // <1>
private EmailAddress emailAddress;
// ...
}
- 该注释告知您的JPA实现使用哪个转换器。没有它,例如,Hibernate将尝试将电子邮件地址存储为序列化的POJO,而不是字符串。如果您已将转换器标记为自动应用,则无需@Convert注释。但是,我发现显式声明要使用的转换器不太容易出错。我遇到过应该自动应用转换器的情况,但是由于某种原因,Hibernate无法检测到该值,因此该值对象作为序列化的POJO被持久化,并且由于使用嵌入式H2数据库并通过了Hibernate而通过了集成测试生成模式。
现在,我们几乎完成了简单的值对象。但是,我们错过了两个警告,一旦我们投入生产,这些警告可能会重新出现并咬住我们。它们都与数据库有关。
长度很重要
第一个警告与数据库列的长度有关。默认情况下,JPA将所有数据库字符串(varchar)列的长度限制为255个字符。电子邮件地址的长度可以为320个字符,因此,如果用户在系统中输入的电子邮件地址超过255个字符,则在尝试保存值对象时会出现异常。要解决此问题,您需要执行以下操作:
- 确保您的数据库列足够宽以包含有效的电子邮件地址。
- 确保您的验证方法包括输入的长度检查。创建EmailAddress无法成功持久保存的实例应该是不可能的。
当然,这也适用于其他字符串值对象。根据使用情况,您可以拒绝接受太长的字符串,也可以仅以静默方式截断它们。
不要对遗留数据进行假设
第二个警告与遗留数据有关。假设您有一个现有的数据库,该数据库的电子邮件地址以前是作为简单字符串处理的,现在您引入了一个漂亮的纯净EmailAddress值对象。如果这些旧电子邮件地址中的任何一个无效,那么每次尝试加载具有无效电子邮件地址的实体时,您都会得到一个异常:您的属性转换器使用构造函数创建新EmailAddress实例,并且该构造函数验证输入。要解决此问题,您可以执行以下任一操作:
- 清理数据库并修复或删除所有无效的电子邮件地址。
- 创建仅由属性转换器使用的第二个构造函数,该构造函数绕过验证并invalid在value对象内设置标志。这样就可EmailAddress以为现有的旧有数据创建无效的对象,同时强制新的电子邮件地址正确无误。代码看起来像这样:
public class EmailAddress implements ValueObject {
private final String email;
private final boolean invalid; // <1>
public EmailAddress(@NotNull String email) {
this(email, true);
}
EmailAddress(@NotNull String email, boolean validate) { // <2>
if (validate) {
this.email = validate(email);
this.invalid = false;
} else {
this.email = email;
this.invalid = !isValid(email);
}
}
public boolean isInvalid() { // <3>
return invalid;
}
// The rest of the methods omitted
}
- 该布尔标志仅在值对象内部使用,并且永远不会存储在数据库中。
- 在此示例中,构造函数具有程序包可见性,以防止外部代码使用它(我们希望所有新的电子邮件对象均有效)。但是,这还要求属性转换器位于同一程序包中。
- 可以将此标志传递给UI,以向用户指示电子邮件地址错误,需要更正。
那里!我们涵盖了所有案例,并提供了一种鲁棒,整洁的策略来实现和保留简单的价值对象。但是,从根本上讲,我们的价值对象根本不需要关心的基础数据库技术已经设法将自己潜入到实现过程中(即使它在代码中并不真正可见)。如果要利用JPA提供的所有功能,这是我们必须做出的权衡。当我们开始处理复杂的价值对象时,这种权衡会更大。让我们看看如何。
复杂值对象:可嵌入对象
在关系数据库中保留复杂值对象涉及将多个字段映射到多个数据库列。在JPA中,用于此目的的主要工具是可嵌入对象(带有@Embeddable注释)。可嵌入对象既可以作为单个字段(通过注释进行@Embedded注释),也可以作为集合(通过注释进行@ElementCollection注释)进行持久化。
但是,JPA对可嵌入对象施加了某些限制,以防止它们真正地不可变。可嵌入对象不能包含任何final字段,并且应具有默认的无参数构造函数。尽管如此,我们仍希望使我们的价值对象出现并表现出来,就好像它们对外部世界是不可变的。我们该怎么做?
让我们从一个或多个构造函数开始,因为我们将需要两个。第一个构造函数是初始化构造函数,它将是公共的。该构造函数是在代码中构造值对象的新实例的唯一允许方式。
第二个构造函数是默认构造函数,它将仅由Hibernate使用。它不需要是公共的,因此为了防止在代码中使用它,可以将其设置为受保护,受包保护甚至是私有(它与Hibernate兼容,但例如IntelliJ IDEA会抱怨)。有时,我还会做一个自定义注解@UsedByHibernateOnly或类似的注解,以标记这些构造函数。然后,您可以将IDE配置为在查找未使用的代码时忽略那些构造函数。
至于字段,这很简单:不要将字段标记为final,只能在初始化构造函数中设置字段值,并且不声明任何setter方法或其他写入字段的方法。您可能还必须将IDE配置为不建议您输入这些字段final。
最后,您需要重写equals,hashCode以便它们基于值而不是基于对象标识进行比较。
这是一个成品的复杂值对象的示例:
@Embeddable
public class PersonName implements ValueObject { // <1>
private String firstname; // <2>
private String middlename;
private String lastname;
@SuppressWarnings("unused")
PersonName() { // <3>
}
public PersonName(@NotNull String firstname, @NotNull String middlename, @NotNull String lastname) { // <4>
this.firstname = Objects.requireNonNull(firstname);
this.middlename = Objects.requireNonNull(middlename);
this.lastname = Objects.requireNonNull(lastname);
}
public PersonName(@NotNull String firstname, @NotNull String lastname) { // <5>
this(firstname, "", lastname);
}
public @NotNull String getFirstname() { // <6>
return firstname;
}
public @NotNull String getMiddlename() {
return middlename;
}
public @NotNull String getLastname() {
return lastname;
}
@Override
public boolean equals(Object o) { // <7>
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PersonName that = (PersonName) o;
return firstname.equals(that.firstname)
&& middlename.equals(that.middlename)
&& lastname.equals(that.lastname);
}
@Override
public int hashCode() { // <8>
return Objects.hash(firstname, middlename, lastname);
}
}
- 我们使用与ValueObject用于简单值对象相同的标记接口。同样,如果需要,可以将其省略。
- 没有字段标记为final。
- 默认构造函数受程序包保护,根本不被任何代码使用。
- 初始化构造函数将由代码使用。
- 如果不是所有字段都是必需的,则使重载的构造函数或使用生成器或本质模式。强迫调用代码传递null或默认参数是丑陋的(我个人认为)。
- 外部世界只能从吸气剂访问字段。完全没有二传手。
- 具有相同值的两个值对象被视为相等,因此我们必须相应地实现该equals()方法。
- 我们改变了,equals()所以现在我们也必须改变hashCode()。
然后可以在以下实体中使用此值对象:
@Entity
public class Contact {
@Embedded
private PersonName name;
// ...
}
只是一件东西(或四件)
细心的读者现在会注意到我们再次错过了一些东西:关于数据库列宽的长度检查。就像我们必须处理简单值对象一样,我们也必须在这里处理。我将把它作为练习留给读者。
说到数据库,在处理@Embeddable值对象时还需要考虑一些其他事项:列名和可空性。
通常,您使用@Column注释在可嵌入对象内部指定列名称。如果不加说明,则列名称是从字段名称派生的。这对您来说足够了,但是在某些情况下,您可能会发现自己在不同实体中使用相同的值对象,并且具有不同的名称的列。在这种情况下,您必须依靠@AttributeOverride注释(如果您不熟悉它,请检查它)。
可空性与如何保持值对象为null的状态有关。对于简单的简单值对象,只需将NULL存储在数据库列中。对于将复杂值对象存储在集合中,这也很容易-只需将值对象留在外面即可。对于存储在字段中的复杂值对象,您必须检查您的JPA实现。
默认情况下,如果该字段为空,则Hibernate会将NULL写入所有列。同样,从数据库中读取时,如果所有列均为NULL,则Hibernate会将字段设置为null。如果您实际上不希望将其值字段都设置为null的值对象实例,这通常很好。这也意味着,即使您的值对象可能要求其一个或多个字段不为空,但如果整个值对象可以为空,则数据库表必须允许该列中的空值。
最后,如果最终有一个@Embeddable扩展了另一个@Embeddable类的类,请记住将@MappedSuperclass注释添加到父类。如果将其忽略,则父类中的所有内容都将被忽略。这将导致某些奇怪的行为和丢失的数据,这些数据在调试时并不明显。
如您所见,与简单值对象相比,底层数据库和持久性技术在我们的复杂值对象的实现中甚至更多。从生产率的角度来看,我认为这是可以接受的折衷方案。可以完全不知道域对象的持久性来编写域对象,但这将需要在存储库中进行更多工作-您必须自己做。除非您有充分的理由,否则通常不值得付出努力(尽管这是一次有趣的学习经历,所以如果您有兴趣和时间,那么一定要花点时间)。