DDD-四、在 JPA 中使用值对象
在战术设计中,我们了解了值对象是什么以及它有什么用处。我们从未真正研究过如何在实际项目中使用它。现在是时候卷起袖子,仔细看看一些实际的代码了!
值对象是领域驱动设计中最简单和最有用的构建块之一,因此让我们从了解在 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 接口,它采用两个通用参数:数据库列类型和属性类型。- 此方法将 an 转换
EmailAddress
为字符串。请注意,输入参数可以是null
. - 此方法将字符串转换为
EmailAddress
. 再次请注意,输入参数可以是null
.
.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
在值对象内设置一个标志。这使得可以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 或默认参数是丑陋的(我个人的看法)。
- 外部世界仅从 getter 访问字段。根本没有二传手。
- 具有相同值的两个值对象被认为是相等的,因此我们必须
equals()
相应地实现该方法。 - 我们改变了
equals()
,所以现在我们也必须改变hashCode()
。
然后可以在这样的实体中使用此值对象:
@Entity
public class Contact {
@Embedded
private PersonName name;
// ...
}
再多一件事(或四件事)
细心的读者现在会注意到我们又错过了一些东西:关于数据库列宽的长度检查。就像我们必须处理简单的值对象一样,我们必须在这里处理它。我将把它作为练习留给读者。
说到数据库,在处理@Embeddable
值对象时还要考虑一些事情:列名和可空性。
@Column
通常,您可以使用注释在可嵌入对象中指定列名。如果将其省略,则列名是从字段名派生的。这对您来说可能已经足够了,但在某些情况下,您可能会发现自己在不同的实体中使用相同的值对象,并且列具有不同的名称。在这种情况下,您必须依赖@AttributeOverride
注解(如果您不熟悉它,请查看它)。
可空性与您将如何保持值对象为空的状态有关。对于很简单的简单值对象 - 只需将 NULL 存储在数据库列中。对于存储在集合中的复杂值对象,这也很容易——只需将值对象排除在外。对于存储在字段中的复杂值对象,您必须检查您的 JPA 实现。
默认情况下,如果字段为空,Hibernate 会将 NULL 写入所有列。同样,当从数据库读取时,如果所有列都为 NULL,Hibernate 会将字段设置为 null。这通常很好,前提是您实际上不希望有一个其字段都设置为 null 的值对象实例。这也意味着即使您的值对象可能要求其一个或多个字段*不*为空,但如果整个值对象可以为空,则数据库表必须允许该列或列中的空值。
最后,如果您最终让一个@Embeddable
类扩展了另一个@Embeddable
类,请记住将@MappedSuperclass
注释添加到父类。如果你忽略它,你的父类中的所有内容都将被忽略。这将导致一些奇怪的行为和丢失的数据,调试起来并不明显。
如您所见,底层数据库和持久性技术在我们复杂值对象的实现中比在简单值对象中出现的更多。从生产力的角度来看,我认为这是一个可以接受的权衡。可以在完全不知道它们是如何持久化的情况下编写域对象,但这需要在存储库中做更多的工作——你必须自己做这些工作。除非你有充分的理由,否则通常不值得付出努力(不过,这是一次有趣的学习经历,所以如果你有兴趣和时间,那么一定要试一试)。
- 原文作者:知识铺
- 原文链接:https://geek.zshipu.com/post/DDD-%E5%9B%9B%E5%9C%A8-JPA-%E4%B8%AD%E4%BD%BF%E7%94%A8%E5%80%BC%E5%AF%B9%E8%B1%A1/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com