使用 Hibernate 的丰富域模型

使用 Hibernate 的丰富域模型

首页角色扮演魔法之域更新时间:2024-07-09

这篇文章读起来很长。 我建议您提前浏览一下目录。 也许某些部分比其他部分更有趣。

我喜欢休眠。 尽管它很复杂(有时容易出错),但我认为它是一个非常有用的框架。 如果你知道如何烹饪它,它就会闪闪发光。

虽然 Hibernate 是 JPA 的实现,但每次我指 JPA 时都会说 Hibernate。 只是为了简单起见。

许多 Java 开发人员在他们的项目中使用 Hibernate。 然而,我注意到一个奇怪的趋势。 Java 开发人员几乎每次都会应用贫血领域模型模式。 每当我询问采用该策略的原因时,我都会得到如下答案:

我们以前一直这样做,而且效果很好。

我们的开发人员已经习惯了这样的架构。

我们还有其他选择吗?

这就是我决定写这篇文章的原因。 我想重新考虑 Hibernate 中贫血领域模型的使用现状,并向您提出不同的建议。 这就是富域模型模式。

在这篇文章中,我告诉你:

什么是富域模型?

贫血领域模型出了什么问题,富人如何解决这些问题?

一步步解决。

富域模型有什么缺点?

目录

贫乏领域模型的问题过于智能的服务可能违反不变量缺乏封装测试更加困难

丰富领域模型原理

不要添加 setter、getter 和公共无参数构造函数无参数构造函数允许无成本的对象实例化Setters 破坏封装Getters 破坏封装当前结果

聚合和聚合根

要求的演变每个口袋必须拥有至少一个电子宠物每个口袋里的电子宠物名称必须是唯一的如果用户删除电子宠物,他们可以通过名称恢复它

查询数据手动查询引入toDto方法

单元测试实体口袋中必须始终拥有至少一个电子宠物如果删除了电子宠物,可以通过名称恢复它如果删除多个同名电子宠物,则只能恢复最后一个

集成测试创建 Pocket创建 Tamagotchi 更新 Tamagotchi

性能影响查询优化精确定位优化检查

数据库生成ID手动填写id引入业务key

丰富的领域模型总是值得吗?

结论

资源

贫血领域模型的问题

首先,我们来讨论一下域。 我们将开发 Tamagotchi 应用程序。 Pocket 可能有许多电子宠物实例,但每个电子宠物都属于一个 Pocket。 因此,关系是 Pocket --one-to-many-> Tamagotchi。

贫血领域模型解决方案很可能会以这种方式实现:

我敢打赌您已经见过很多类似的 Java 代码。 但该解决方案存在很多问题。 我们来一一讨论。

服务过于智能

贫血领域模型要求服务层拥有所有业务逻辑。 而实体充当虚拟数据结构。 但实体并不是静态的。 他们在这段时间里发展。 我们可能会添加一些字段并删除其他字段。 或者我们可以组合 Embeddable 对象中的现有字段。

在这里,服务必须了解与其合作的实体的每一个细节。 因为任何操作都可能需要访问不同的字段。 这意味着,即使是实体的微小变化也可能导致许多服务的重大*。 实际上,这违反了开闭原则。 代码不再是面向对象的,而是过程化的。 我们没有利用 OOP 范式的好处。 相反,我们带来了额外的困难。

可能违反不变量

不变性是一种业务规则,仅允许对实体进行某些更改。 它保证我们不会将实体传输到错误的状态。 例如,假设 Pocket 默认情况下仅包含三个电子宠物。 如果您想拥有更多,则需要购买高级订阅。 这是一个不变量。 如果用户不购买附加功能,则代码必须禁止将第四个电子宠物添加到 Pocket。

如果我们选择贫血域模型方法,则意味着服务必须检查不变量并在需要时取消操作。 但不变量也不是静态的。 想象一下,Pocket 中的三个电子宠物的规则从项目一开始就没有被引入。 但我们现在想添加它。 这意味着我们必须检查可能创建新电子宠物的每个方法和函数,并添加相应的检查。

如果变化范围更广,情况会变得更糟。 假设电子宠物已经成为 Saga 模式的一部分。 现在它包含值为 PENDING 的状态字段。 如果电子宠物处于待处理状态,您既不能删除也不能更新它。 你知道我要去哪里吗? 您必须检查更新或删除电子宠物的每一段代码,并确保您不会错过任何对 PENDING 状态的检查。

缺乏封装

OOP 中的封装是一种限制对某些数据直接访问的机制。 这就说得通了。 实体可能有多个字段,但这并不意味着我们希望允许更改每个字段。 我们可能只会同时改变具体的领域。 仅当实体传输到特定状态时才允许更新其他状态。

贫乏的领域模型迫使我们放弃封装,并在不考虑后果的情况下放置来自 Lombok 的 @Getter 和 @Setter 注解。

违反封装的最大问题是代码使用起来会变得更加危险。 您不能只调用 setName 或 setStatus 方法。 但您必须确保提前检查具体条件。 再次强调,不变量不是静态的。 因此,对实体的每次突变调用就像一个地雷。 如果您错过了一次条件检查,您将不知道接下来会发生什么问题。

测试更难

大多数开发人员将 Hibernate 与 Spring Boot 结合使用。 这意味着服务是带有 @Transactional 注释的常规 Spring bean。 通常这些服务包含实体、存储库和其他服务调用的意大利面条代码。 在测试方面,我看到开发人员选择以下选项之一:

集成测试。

嘲笑一切。

别误会我的意思。 我认为集成测试至关重要。 Tescontainers 库尤其有助于使该过程顺利进行。 但是,我认为集成测试的数量应该尽可能少。 如果您可以通过简单的单元测试来验证某些内容,请这样做。 在项目中引入过多的集成测试也会带来一定的困难:

集成测试更难维护。

始终存在共享资源(在本例中为数据库)。 因此,测试可能会意外地变得相互依赖。 测试可能会变得不稳定。

并行运行集成测试很困难。

这些测试要慢得多。 如果您的项目足够老并且有很多集成测试,则常规 CI 构建可以在 30 分钟甚至更长时间内运行。

嘲笑呢? 我认为这样的测试几乎没有用。 我并不是说嘲笑一般来说是一个坏主意。 但是,如果您尝试模拟对 Spring Data JPA 存储库和其他服务的每次调用,则可能会出现您没有测试该行为的情况。 您只需验证模拟调用的正确顺序即可。 因此,测试变得脆弱并且维护起来成为巨大的负担。

富领域模型原理

相反,富域模型模式提出了一种不同的方法。 看下图。

正如您所看到的,实体拥有所需的业务逻辑。 而服务就像一个薄层,将调用委托给存储库和实体。

丰富的领域模型与领域驱动设计的战术模式相关。 我们感兴趣的是聚合。

聚合是域对象的集群,您可以将其视为一个整体单元。 例如,Pocket 有很多电子宠物。 这意味着 Pocket 和 Tamagotchi 可以是一个聚合。 聚合根是允许直接访问聚合并保证不变量正确性的实体。 因此,如果我们想改变 Tamagotchi 中的某些内容,我们应该只与 Pocket 交互。

通过引入Rich Domain Model,我想解决这些问题:

代码应该变得更加面向业务。 如果逻辑被划分在很多服务之间,那么很难理解实际的操作流程(特别是对于新手来说)。

让编译器验证您的代码。 如果实体的每个字段都有一个 setter,则每次调用它时都应该进行额外的检查。 但是,如果一个实体仅提供一定数量的改变其状态的方法,则意味着由于编译器检查,不可能发生错误的转换。 换句话说,如果方法不存在,则无法调用它。 因此,最好只提供那些需要的操作。

减少集成测试的数量。 如果可能的话,最好通过简单的单元测试来测试业务逻辑。 因此,我想在不违反质量保证水平的情况下用单元测试替换一些集成测试。

让我们从将 Pocket 和 Tamagotchi 重构为富域模型开始我们的旅程。

不要添加 setter、getter 和公共无参数构造函数

首先,看看按照贫血领域模型设计 Pocket 和 Tamagotchi 实体的初始方法:

@Entity @NoArgsConstructor @Setter @Getter public class Pocket { @Id private UUID id; private String name; @OneToMany(mappedBy = "pocket") private List<Tamagotchi> tamagotchis = new ArrayList<>(); } @Entity @NoArgsConstructor @Setter @Getter public class Tamagotchi { @Id private UUID id; private String name; @ManyToOne(fetch = LAZY) @JoinColumn(name = "pocket_id") private Pocket pocket; }

这里我使用 UUID 作为主键。 我知道这会对性能产生一些影响。 但现在客户端生成的 ID 对于顺利过渡到富域模型至关重要。 不管怎样,稍后我会给你一些其他 ID 类型的例子。

我敢打赌这看起来很熟悉。 也许您当前的项目包含许多类似的声明。 存在哪些问题?

无参数构造函数允许无成本的对象实例化

Hibernate 要求每个实体提供一个无参数构造函数。 否则,框架将无法正常工作。 这是一种尖锐的情况,会让你的代码不那么直白,而且更容易出现错误。

值得庆幸的是,有一个解决方案。 Hibernate 不需要实体的公共构造函数。 相反,它可以受到保护。 因此,我们可以添加一个公共静态方法来实例化实体,并为 Hibernate 专门保留受保护的构造函数。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) @Setter @Getter public class Pocket { @Id private UUID id; private String name; @OneToMany(mappedBy = "pocket") private List<Tamagotchi> tamagotchis = new ArrayList<>(); public static Pocket newPocket(String name) { Pocket pocket = new Pocket(); pocket.setId(UUID.randomUUID()); pocket.setName(name); return pocket; } } @Entity @NoArgsConstructor(access = PROTECTED) @Setter @Getter public class Tamagotchi { @Id private UUID id; private String name; @ManyToOne(fetch = LAZY) @JoinColumn(name = "pocket_id") private Pocket pocket; public static Tamagotchi newTamagotchi(String name, Pocket pocket) { Tamagotchi tamagotchi = new Tamagotchi(); tamagotchi.setId(UUID.randomUUID()); tamagotchi.setName(name); tamagotchi.setPocket(pocket); return tamagotchi; } }

正如您所看到的,业务代码(可能位于不同的包中)无法使用无参数构造函数实例化 Tamagotchi 或 Pocket。 它必须调用接受特定数量参数的专用方法 newTamagotchi 和 newPocket。

Setter 打破封装

我认为公共设置器与常规公共领域没有太大区别。 好吧,您可以在 setter 中进行一些检查,因为它是一种方法。 但实际上,人们往往不会走这条路。 通常我们只是将 Lombok 库中的 @Setter 注释放在类的顶部,仅此而已。

由于以下原因,我认为在实体中使用设置器是一种不好的方法:

可能违反不变量。 某些字段无法更新。 仅当实体正在传输到特定状态时才能更新其他状态。 纯粹的设置者迫使开发人员将所有这些检查放入服务中。

如果 Tamagotchi.name 是 String,并不意味着每个 String 值都是允许的。 因此,您还必须在实体外部执行这些检查。

字段可以是实现细节的一部分。 也许直接更新是被禁止的。 但公共设置器允许此操作。

要点是公共设置器破坏了我之前提到的编译器验证原则。 您只是提供了太多可以以不同方式调用的选项。

还有什么选择呢? 我建议为特定行为添加changeXXX方法。 此外,这些方法应包含验证逻辑并在需要时抛出异常。

假设 Tamagotchi 实体有一个状态字段,其值为 PENDING。 如果电子宠物处于待处理状态,则无法修改。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) @Getter public class Tamagotchi { @Id private UUID id; private String name; @ManyToOne(fetch = LAZY) @JoinColumn(name = "pocket_id") private Pocket pocket; @Enumerated(STRING) private Status status; public void changeName(String name) { if (status == PENDING) { throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING"); } if !(nameIsValid(name)) { throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " name); } this.name = name; } public static Tamagotchi newTamagotchi(String name, Pocket pocket) { /* entity creation */ } }

Tamagotchi.changeName 方法保证您在违反某些先决条件时无法更改名称。 调用该方法的代码不需要了解特定规则。 你只需要处理异常。

Getter 破坏封装

嗯,前面关于 setter 的段落或多或少是显而易见的。 互联网上有数十篇关于 setter 问题的文章和观点。 不管怎样,消除吸气剂听起来很荒谬,不是吗? 它们不会改变实体的状态。 那么,到底是怎么回事呢?

getter 的问题在于它们还允许破坏封装并执行不必要或错误的检查。 假设我们还想限制更新 Tamagotchi 的名称(如果其状态为 ERROR)。 这是您在代码审查期间可能看到的可能解决方案:

@Service @RequiredArgsConstructor public class TamagotchiService { private final TamagotchiRepository repo; @Transactional public void changeName(UUID id, String name) { Tamagotchi tamagotchi = repo.findById(id).orElseThrow(); if (tamagotchi.getStatus() == ERROR) { throw new TamagotchiStatusException("Tamagotchi cannot be modified because its status is ERROR"); } tamagotchi.changeName(name); } }

尽管Tamagotchi提供了专用的方法changeName,但检查仍然在服务层实现。 我注意到,即使有经验的高级开发人员在有可能的情况下也容易陷入贫血模型思维模式。 因为他们多年来一直致力于不同的项目,并且很可能每个项目都应用了贫血领域模型模式。 所以,开发者只能选择更简单、更明显的方式。

然而,一个决定会产生一些后果。 首先,逻辑分为 Tamagotchi 实体和 TamagotchiService(这是我们想要避免的一件事)。 其次,检查可能会重复,并且您可能在代码审查期间错过它。 最后,一些检查可能会及时过时。 例如,这种对 ERROR 状态的验证可能会在以后变得过时。 如果您忘记在这里消除它,您的代码将无法按预期运行。

正如我之前提到的,如果您不需要方法,就不要添加它。 Getter 不需要执行业务逻辑。 您可以将验证放入 Tamagotchi.changeName 方法中。 如果 getter 不存在,则无法调用它,这样的场景就不会发生。

那么查询呢? 通常我们使用Hibernate实体来SELECT数据,将其转换为DTO,并将结果返回给用户。 没有吸气剂我们怎么能做到呢? 别担心,我们将在本文后面讨论这个主题。

这一规则也有一个例外。 您可以为 ID 添加 getter。 有时需要在运行时知道实体 ID。 稍后你会看到一个例子。

当前结果

我们已经讨论了三点:

无参数构造函数。

二传手。

吸气剂。

如果我们删除这些部分,代码将如下所示:

@Entity @NoArgsConstructor(access = PROTECTED) public class Pocket { @Id private UUID id; private String name; @OneToMany(mappedBy = "pocket") private List<Tamagotchi> tamagotchis = new ArrayList<>(); public static Pocket newPocket(String name) { Pocket pocket = new Pocket(); pocket.setId(UUID.randomUUID()); pocket.setName(name); return pocket; } } @Entity @NoArgsConstructor(access = PROTECTED) public class Tamagotchi { @Id private UUID id; private String name; @ManyToOne(fetch = LAZY) @JoinColumn(name = "pocket_id") private Pocket pocket; @Enumerated(STRING) private Status status; public void changeName(String name) { if (status == PENDING) { throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING"); } if (!nameIsValid(name)) { throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " name); } this.name = name; } public static Tamagotchi newTamagotchi(String name, Pocket pocket) { Tamagotchi tamagotchi = new Tamagotchi(); tamagotchi.setId(UUID.randomUUID()); tamagotchi.setName(name); tamagotchi.setPocket(pocket); tamagotchi.setStatus(CREATED); return tamagotchi; } } 聚合和聚合根

之前我提到过聚合模式。 说到我们的域,Pocket 实体应该是聚合根。 然而,现有的 API 允许我们直接访问 Tamagotchi 实体。 让我们解决这个问题。

首先,我们添加简单的 CREATE/UPDATE/DELETE 操作。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) public class Pocket { @Id private UUID id; private String name; @OneToMany(mappedBy = "pocket", cascade = PERSIST, orphanRemoval = true) private List<Tamagotchi> tamagotchis = new ArrayList<>(); public UUID createTamagotchi(TamagotchiCreateRequest request) { Tamagotchi tamagotchi = Tamagotchi.newTamagotchi(request.name(), this); tamagotchis.add(tamagotchi); return tamagotchi.getId(); } public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { Tamagotchi tamagotchi = tamagotchiById(tamagotchiId); tamagotchi.changeName(request.name()); } public void deleteTamagotchi(UUID tamagotchiId) { Tamagotchi tamagotchi = tamagotchiById(tamagotchiId); tamagotchis.remove(tamagotchi); } private Tamagotchi tamagotchiById(UUID tamagotchiId) { return tamagotchis .stream() .filter(t -> t.getId().equals(tamagotchiId)) .findFirst() .orElseThrow(() -> new TamagotchiNotFoundException("Cannot find Tamagotchi by ID=" tamagotchiId)); } public static Pocket newPocket(String name) { Pocket pocket = new Pocket(); pocket.setId(UUID.randomUUID()); pocket.setName(name); return pocket; } } @Entity @NoArgsConstructor(access = PROTECTED) class Tamagotchi { @Id @Getter private UUID id; private String name; @ManyToOne(fetch = LAZY) @JoinColumn(name = "pocket_id") private Pocket pocket; @Enumerated(STRING) private Status status; public void changeName(String name) { if (status == PENDING) { throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING"); } if (!nameIsValid(name)) { throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " name); } this.name = name; } public static Tamagotchi newTamagotchi(String name, Pocket pocket) { Tamagotchi tamagotchi = new Tamagotchi(); tamagotchi.setId(UUID.randomUUID()); tamagotchi.setName(name); tamagotchi.setPocket(pocket); tamagotchi.setStatus(CREATED); return tamagotchi; } }

有很多细微差别。 因此,我将一一指出它们。 首先,Pocket实体按原样提供createTamagotchi、updateTamagotchi和deleteTamagotchi方法。 您不会从 Tamagotchi 或 Pocket 检索任何信息。 您只需调用所需的功能即可。

我知道这种技术也会带来性能损失。 稍后我们还将讨论一些克服这些问题的方法。

然后是电子宠物实体。 我想让您注意的第一件事是该实体是包私有的。 这意味着没有人可以访问包外的电子宠物。 因此,直接调用Pocket是唯一的方法。

现在你可能会认为它的利润并不那么明显。 但很快我们将讨论聚合的演变,您会看到好处。

Pocket 和 Tamagotchi 实体都不提供常规的 setter 或 getter。 只能调用 Pocket 实体的公共方法。

需求的演变

正如我之前所说,实体不是静态的。 需求在变化,但不变性也在变化。 那么,让我们看一下实施新需求的假设过程,看看它是如何进行的。

每个口袋必须拥有至少一个电子宠物

这意味着当实例化一个新的 Pocket 时,我们应该创建一个 Tamagotchi。 另外,如果你想删除一只电子宠物,你必须检查它是否不是口袋里的单一电子宠物。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) public class Pocket { /* fields and other methods */ public void deleteTamagotchi(UUID tamagotchiId) { Tamagotchi tamagotchi = tamagotchiById(tamagotchiId); if (tamagothis.size() == 1) { throw new TamagotchiDeleteException("Cannot delete Tamagotchi because it's the single one"); } tamagotchis.remove(tamagotchi); } public static Pocket newPocket(String name) { Pocket pocket = new Pocket(); pocket.setId(UUID.randomUUID()); pocket.setName(name); pocket.createTamagotchi(new TamagotchiCreateRequest("Default")); // creating default tamagotchi return pocket; } }

正如您所看到的,不变量的正确性在聚合内得到保证。 即使您愿意,您也无法创建一个带有零个电子宠物的口袋,也无法删除只有一个电子宠物的口袋。 我认为这很棒。 代码变得不易出错并且更易于维护。

Pocket 中的每个电子宠物名字都必须是唯一的

为了实现这个要求,我们需要稍微改变 createTamagotchi 和 updateTamagotchi 方法。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) public class Pocket { /* fields and other methods */ public UUID createTamagotchi(TamagotchiCreateRequest request) { Tamagotchi tamagotchi = Tamagotchi.newTamagotchi(request.name(), this); tamagotchis.add(tamagotchi); validateTamagotchiNamesUniqueness(); return tamagotchi.getId(); } public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { Tamagotchi tamagotchi = tamagotchiById(tamagotchiId); tamagotchi.changeName(request.name()); validateTamagotchiNamesUniqueness(); } private void validateTamagotchiNamesUniqueness() { Set<String> names = new HashSet<>(); for (Tamagotchi tamagotchi : tamagotchis) { if (!names.add(tamagotchi.getName()) { throw new TamagotchiNameInvalidException("Tamagotchi name is not unique: " tamagotchi.getName()); } } } }

您可能已经注意到,我为 Tamagotchi.name 字段添加了一个 getter。 因为 Pocket 和 Tamagotchi 形成一个聚合体,所以提供 getter 就可以了。 因为 Pocket 可能需要这些信息。 然而,电子宠物不应该向 Pocket 请求任何东西。 最好将此 getter 标记为包私有的。 因此,没有人可以在包之外访问它。

我知道 validateTamagotchiNamesUniqueness 表现不佳。 别担心,我们稍后将在性能影响部分讨论解决方法。

域模型再次保证每个电子宠物名称在 Pocket 中都是唯一的。 有趣的是 API 没有发生任何变化。 调用这些公共方法(可能是域服务)的代码不必更改逻辑。

如果用户删除了电子宠物,他们可以通过名称恢复它

这个问题很棘手,涉及到软删除。 它还具有以下附加点:

如果同名电子宠物已存在,则用户无法恢复已删除的电子宠物。

如果用户删除了多个同名的电子宠物,则只能恢复最后一个。

由于多种原因,我不喜欢涉及添加 isDeleted 列的软删除。 相反,我将引入一个新实体DeletedTamagotchi,其中包含已删除Tamagotchi 的状态。 看下面的代码示例。

@Entity @NoArgsConstructor(access = PROTECTED) @Getter class DeletedTamagotchi { @Id private UUID id; private String name; @ManyToOne(fetch = LAZY) @JoinColumn(name = "pocket_id") private Pocket pocket; @Enumerated(STRING) private Status status; public static DeletedTamagotchi newDeletedTamagotchi(Tamagotchi tamagotchi) { DeletedTamagotchi deletedTamagotchi = new DeletedTamagotchi(); deletedTamagotchi.setId(UUID.randomUUID()); deletedTamagotchi.setName(tamagotchi.getName()); deletedTamagotchi.setPocket(tamagotchi.getPocket()); deletedTamagotchi.setStatus(tamagotchi.getStatus()); return deletedTamagotchi; } }

Tamagotchi 实体相当简单,因此DeletedTamagotchi 包含相同的字段。 但是,如果原始实体更复杂的话,情况就不可能了。 例如,您可以将 Tamagotchi 的状态保存在 Map<String, Object> 字段中,该字段会转换为数据库中的 JSONB。

此外,DeletedTamagotchi 实体与 Tamagotchi 一样是包私有的。 因此,该实体的存在是一个实现细节。 代码的其他部分不需要知道这一点并直接与DeletedTamagotchi交互。 相反,最好提供一个没有其他详细信息的单一方法 Pocket.restoreTamagotchi。

现在让我们根据新的要求更改 Pocket 实体。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) public class Pocket { /* fields and other methods */ @OneToMany(mappedBy = "pocket", cascade = PERSIST, orphanRemoval = true) private List<DeletedTamagotchi> deletedTamagotchis = new ArrayList<>(); public void deleteTamagotchi(UUID tamagotchiId) { Tamagotchi tamagotchi = tamagotchiById(tamagotchiId); if (tamagothis.size() == 1) { throw new TamagotchiDeleteException("Cannot delete Tamagotchi because it's the single one"); } tamagotchis.remove(tamagotchi); addDeletedTamagotchi(tamagotchi); } private void addDeletedTamagotchi(Tamagotchi tamagotchi) { Iterator<DeletedTamagotchi> iterator = deletedTamagotchis.iterator(); // if Tamagotchi with the same has been deleted, // remove information about it while (iterator.hasNext()) { DeletedTamagotchi deletedTamagotchi = iterator.next(); if (deletedTamagotchi.getName().equals(tamagotchi.getName()) { iterator.remove(); break; } } deletedTamagotchis.add( newDeletedTamagotchi(tamagotchi) ); } public UUID restoreTamagotchi(String name) { DeletedTamagotchi deletedTamagotchi = deletedTamagotchiByName(name); return createTamagotchi(new TamagotchiCreateRequest(deletedTamagotchi.getName())); } }

deleteTamagotchi 方法还创建或替换DeletedTamagotchi 记录。 这意味着无论出于何种原因调用该方法的所有其他代码都不会违反有关软删除的新要求,因为它是在内部实现的。

要执行所需的业务操作,您应该只调用 Pocket.restoreTamagotchi。 我们将所有复杂的细节隐藏在幕后。 更好的是,DeletedTamagotchi 不是公共 API 的一部分。 这意味着如果不再需要它,可以轻松修改甚至删除它。

正如您所看到的,将业务逻辑放在聚合中具有显着的好处。 然而,这并不是故事的结局。 我们仍然需要处理一些问题。 接下来是查询数据。

查询数据

当我们处理Hibernate时,通常我们使用公共getter将实体转换为DTO并将其返回给用户。 然而,现在只有 Pocket 实体是公开的,并且它不提供任何 getter(除了 Pocket.getId() 之外)。 在这种情况下我们如何执行查询? 我可以建议几种方法。

手动查询

显而易见的解决方案就是编写常规 JPQL 或 SQL 语句。 Hibernate 使用反射并且不需要字段的公共 getter。 如果您从头开始一个项目,这可能会起作用。 但是,如果您已经依赖 getter 从实体检索信息并将其放入 DTO 中,那么转换可能会很困难。 这就是为什么我们有第二个选择。

toDto方法介绍

实体可以提供 toDto 或类似的方法,将其内部表示形式返回为单独的数据结构。 它类似于 Memento 设计模式。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) @Setter(PRIVATE) public class Pocket { /* other fields and methods */ public PocketDto toDto() { return new PocketDto( id, name, tamagotchis.stream() .map(Tamagotchi::toDto) .toList() ); } } @Entity @NoArgsConstructor(access = PROTECTED) @Setter(PRIVATE) @Getter class Tamagotchi { /* other fields and methods */ public TamagotchiDto toDto() { return new TamagotchiDto(id, name, status); } }

返回的 DTO 是一个不可变的对象,不会影响实体的状态。 此外,该方法也有助于单元测试。 让我们继续这部分。

单元测试实体

我们将测试这些场景:

Pocket 必须始终拥有至少一只电子宠物。

如果您删除了电子宠物,您可以通过名称将其恢复。

如果删除多个同名电子宠物,则只能恢复最后一个。

整个测试套件可通过此链接获得。

口袋里必须始终拥有至少一只电子宠物

查看下面的单元测试。

class PocketTest { @Test void shouldCreatePocketWithTamagotchi() { Pocket pocket = Pocket.newPocket("My pocket"); PocketDto dto = pocket.toDto(); assertEquals(1, dto.tamagotchis().size()); } @Test void shouldForbidDeletionOfASingleTamagotchi() { Pocket pocket = Pocket.newPocket("My pocket"); PocketDto dto = pocket.toDto(); UUID tamagotchiId = dto.tamagotchis().get(0).id(); assertThrows( TamagotchiDeleteException.class, () -> pocket.deleteTamagotchi(tamagotchiId) ); } }

第一个检查 Pocket 是否是用单个电子宠物创建的。 而第二个则验证您无法删除单个电子宠物。

我喜欢这些测试的原因是它们是单元测试。 没有数据库,没有测试容器,只有常规 JUnit,我们已经成功验证了业务逻辑。 凉爽的! 让我们继续前进。

如果您删除了电子宠物,您可以通过名称将其恢复

这个有点复杂。 看下面的代码示例。

class PocketTest { @Test void shouldDeleteTamagotchiById() { Pocket pocket = Pocket.newPocket("My pocket"); UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED)); pocket.deleteTamagotchi(tamagotchiId); PocketDto dto = pocket.toDto(); assertThat(dto.tamagotchis()) .noneMatch(t -> t.name().equals("My tamagotchi")); } @Test void shouldRestoreTamagotchiById() { Pocket pocket = Pocket.newPocket("My pocket"); UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED)); pocket.deleteTamagotchi(tamagotchiId); pocket.restoreTamagotchi("My tamagotchi"); PocketDto dto = pocket.toDto(); assertThat(dto.tamagotchis()) .anyMatch(t -> t.name().equals("My tamagotchi")); } }

shouldDeleteTamagotchiById 检查删除是否按预期进行。 另一种方法验证 RestoreTamagotchi 方法的行为。

如果删除多个同名电子宠物,则只能恢复最后一个

这是最具挑战性的。 看下面的代码示例。

class PocketTest { @Test void shouldRestoreTheLastTamagotchi() { Pocket pocket = Pocket.newPocket("My pocket"); UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED)); pocket.deleteTamagotchi(tamagotchiId); tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", PENDING)); pocket.deleteTamagotchi(tamagotchiId); pocket.restoreTamagotchi("My tamagotchi"); PocketDto dto = pocket.toDto(); assertThat(dto.tamagotchis()) .anyMatch(t -> t.name().equals("My tamagotchi") && t.status().equals(PENDING) ); } }

在这里我们执行以下步骤:

创建口袋。

创建名称为 My Tamagotchi 且状态为 CREATED 的 Tamagotchi。

删除电子宠物。

创建名称为 My Tamagotchi 且状态为 PENDING 的 Tamagotchi。

以“我的电子宠物”的名称恢复电子宠物。

验证最后一个电子宠物是否已恢复(状态为 PENDING)。

以下是测试的运行结果:

丰富的域模型模式允许我们通过简单的单元测试来测试复杂的业务场景。 我认为这很出色。 然而,集成测试也很重要,因为我们需要将数据存储在 DB 中而不是 RAM 中。 让我们讨论方程的这一部分。

集成测试

我们将实体与存储库(通常是 Spring Data 的)结合使用。 让我们编写一些用例并测试它们:

创建口袋。

创建电子宠物蛋。

更新电子宠物蛋。

整个测试套件可通过此链接获得。

创建口袋

看下面的服务示例:

@Service @RequiredArgsConstructor public class PocketService { private final EntityManager em; @Transactional public UUID createPocket(String name) { Pocket pocket = Pocket.newPocket(name); em.persist(pocket); return pocket.getId(); } }

是时候编写一些集成测试了。 看下面的代码片段:

@Testcontainers @DataJpaTest @AutoConfigureTestDatabase(replace = NONE) @Transactional(propagation = NOT_SUPPORTED) @Import(PocketService.class) class PocketServiceIntegrationTest { @Container @ServiceConnection public static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:13"); @Autowired private TransactionTemplate transactionTemplate; @Autowired private TestEntityManager em; @Autowired private PocketService pocketService; @BeforeEach void cleanDatabase() { // there is cascade constraint in the database deleting tamagotchis and deleted_tamagotchis transactionTemplate.executeWithoutResult( s -> em.getEntityManager().createQuery("DELETE FROM Pocket ").executeUpdate() ); } @Test void shouldCreateNewPocket() { UUID pocketId = pocketService.createPocket("New pocket"); PocketDto dto = transactionTemplate.execute( s -> em.find(Pocket.class, pocketId).toDto() ); assertEquals("New pocket", dto.name()); } }

我使用 Testcontainers 库在 Docker 中启动 PosgtreSQL。 Flyway 迁移工具在测试运行之前创建表。

您可以通过此链接查看迁移情况。

我想这个片段并没有那么复杂。 那么,我们接下来吧。

创建电子宠物

看下面的代码服务实现:

@Service @RequiredArgsConstructor public class PocketService { /* other fields and methods */ @Transactional public UUID createTamagotchi(UUID pocketId, TamagotchiCreateRequest request) { Pocket pocket = em.find(Pocket.class, pocketId); return pocket.createTamagotchi(request); } }

正如您所看到的,富域模型模式要求将服务声明为易于理解和测试的薄层。 这是测试本身:

/* same Java annotations */ class PocketServiceIntegrationTest { /* initialization... */ @Test void shouldCreateTamagotchi() { UUID pocketId = pocketService.createPocket("New pocket"); UUID tamagotchiId = pocketService.createTamagotchi( pocketId, new TamagotchiCreateRequest("my tamagotchi", CREATED) ); PocketDto dto = transactionTemplate.execute( s -> em.find(Pocket.class, pocketId).toDto() ); assertThat(dto.tamagotchis()) .anyMatch(t -> t.name().equals("my tamagotchi") && t.status().equals(CREATED) && t.id().equals(tamagotchiId) ); } }

这个有点有趣。 首先,我们创建一个 Pocket,然后在其中添加一个 Tamogotchi。 断言检查结果 DTO 中是否存在预期的电子宠物。

更新电子蛋

这是最耐人寻味的。 查看下面的实现:

@Service @RequiredArgsConstructor public class PocketService { /* other fields and methods */ @Transactional public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { UUID pocketId = em.createQuery( "SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId", UUID.class ) .setParameter("tamagotchiId", tamagotchiId) .getSingleResult(); Pocket pocket = em.find(Pocket.class, pocketId); pocket.updateTamagotchi(tamagotchiId, request); } }

API 要求传递 TamagotchiId。 但领域模型允许我们只能通过 Pocket 来更新 Tamagotchi,因为后者是聚合根。 因此,我们通过对数据库的附加查询来确定 pocketId,然后通过其 id 选择 Pocket 聚合。 测试也很有趣:

/* same Java annotations */ class PocketServiceIntegrationTest { /* other fields and methods */ @Test void shouldUpdateTamagotchi() { UUID pocketId = pocketService.createPocket("New pocket"); UUID tamagotchiId = pocketService.createTamagotchi( pocketId, new TamagotchiCreateRequest("my tamagotchi", CREATED) ); pocketService.updateTamagotchi( tamagotchiId, new TamagotchiUpdateRequest("another tamagotchi", PENDING) ); PocketDto dto = transactionTemplate.execute( s -> em.find(Pocket.class, pocketId).toDto() ); assertThat(dto.tamagotchis()) .anyMatch(t -> t.name().equals("another tamagotchi") && t.status().equals(PENDING) && t.id().equals(tamagotchiId) ); } }

步骤是:

创建口袋。

创建电子宠物蛋。

更新电子宠物蛋。

验证结果 DTO。

以下是所有集成测试的执行结果:

性能影响

丰富的域模型肯定会带来开销。 然而,有一些解决方法可以达成妥协。

查询优化

首先,我们再看一下 PocketService.updateTamagotchi 方法:

@Service @RequiredArgsConstructor public class PocketService { /* other fields and methods */ @Transactional public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { UUID pocketId = em.createQuery( "SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId", UUID.class ) .setParameter("tamagotchiId", tamagotchiId) .getSingleResult(); Pocket pocket = em.find(Pocket.class, pocketId); pocket.updateTamagotchi(tamagotchiId, request); } }

问题是,当我们实际上想要更新单个 Pocket 时,我们会检索指定 Pocket 的所有现有 Tamagotchi 实例。 看下面的日志:

select t1_0.pocket_id from tamagotchi t1_0 where t1_0.id=? select p1_0.id,p1_0.name from pocket p1_0 where p1_0.id=? select t1_0.pocket_id,t1_0.id,t1_0.name,t1_0.status from tamagotchi t1_0 where t1_0.pocket_id=?

我们可以更改查询来限制不必要数据的传输。 看下面的代码示例:

@Service @RequiredArgsConstructor public class PocketService { /* other fields and methods */ @Transactional public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { Pocket pocket = em.createQuery( """ SELECT p FROM Pocket p LEFT JOIN FETCH p.tamagotchis t WHERE t.id = :tamagotchiId """, Pocket.class ).setParameter("tamagotchiId", tamagotchiId) .getSingleResult(); pocket.updateTamagotchi(tamagotchiId, request); } }

我们不是为指定的 Pocket 选择所有现有的 Tamagotchi 实例,而是通过指定的 id 检索 Pocket 和唯一关联的 Tamagotchi 实例。 日志看起来也不同:

select p1_0.id, p1_0.name, t1_0.pocket_id, t1_0.id, t1_0.name, t1_0.status from pocket p1_0 left join tamagotchi t1_0 on p1_0.id=t1_0.pocket_id where t1_0.id=?

即使 Pocket 包含数千个电子宠物,也不会影响应用程序的性能。 因为它只会检索一个。 如果您运行上一段中的测试用例,它们也将成功通过。

精确定位优化检查

然而,先前的技术有局限性。 为了理解这一点,让我们编写另一个测试。 正如我们已经讨论过的,业务规则要求每个电子宠物在 Pocket 中必须有一个唯一的名称。 让我们测试一下这个行为。 看下面的代码片段:

@Test void shouldUpdateTamagotchiIfThereAreMultipleOnes() { UUID pocketId = pocketService.createPocket("New pocket"); UUID tamagotchiId = pocketService.createTamagotchi( pocketId, new TamagotchiCreateRequest("Cat", CREATED) ); pocketService.createTamagotchi( pocketId, new TamagotchiCreateRequest("Dog", CREATED) ); assertThrows( TamagotchiNameInvalidException.class, () -> pocketService.updateTamagotchi(tamagotchiId, new TamagotchiUpdateRequest("Dog", CREATED)) ); }

有两只电子宠物,名字分别是“猫”和“狗”。 我们尝试将 Cat 重命名为 Dog。 在这里,我们期望得到 TamagotchiNameInvalidException。 因为业务规则应该验证这个场景。 但如果你运行测试,你会得到这样的结果:

Expected com.example.demo.domain.exception.TamagotchiNameInvalidException to be thrown, but nothing was thrown.

这是为什么? 再看一下 Pocket.update Tamagotchi 方法声明:

public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { Tamagotchi tamagotchi = tamagotchiById(tamagotchiId); tamagotchi.changeName(request.name()); tamagotchi.changeStatus(request.status()); validateTamagotchiNamesUniqueness(); } private void validateTamagotchiNamesUniqueness() { Set<String> names = new HashSet<>(); for (Tamagotchi tamagotchi : tamagotchis) { if (!names.add(tamagotchi.getName())) { throw new TamagotchiNameInvalidException( "Tamagotchi name is not unique: " tamagotchi.getName()); } } }

正如您所看到的,Pocket聚合希望能够访问所有电子宠物以验证业务规则。 但我们更改了查询以仅选择一个电子宠物(我们要更新的电子宠物)。 这就是为什么不引发异常的原因。 因为列表中总是有一个电子宠物,我们不能违反其唯一性。

我看到人们试图从聚合中删除此类验证。 但我认为你不应该这样做。 相反,最好提前对服务级别进行另一次优化检查。 要理解这种方法,请查看下面的架构:

聚合应该始终有效。 您无法预测未来所有可能的结果。 也许您会在另一种情况下调用 Pocket。 因此,如果您完全从聚合中删除支票,则可能会意外地违反业务规则。

然而,我们生活在一个性能至关重要的现实世界中。 最好执行单个存在的 SQL 语句,然后从数据库中检索所有 Tamagotchi 实例。 因此,您可以在需要的地方专门进行优化检查。 但你也不会改变总量。

看一下 PocketService.updateTamagotchi 方法的最终代码片段:

@Transactional public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) { boolean nameIsNotUnique = em.createQuery( """ SELECT COUNT(t) > 0 FROM Tamagotchi t WHERE t.id <> :tamagotchiId AND t.name = :newName """, boolean.class ).setParameter("tamagotchiId", tamagotchiId) .setParameter("newName", request.name()) .getSingleResult(); if (nameIsNotUnique) { throw new TamagotchiNameInvalidException("Tamagotchi name is not unique: " request.name()); } UUID pocketId = em.createQuery( "SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId", UUID.class ) .setParameter("tamagotchiId", tamagotchiId) .getSingleResult(); Pocket pocket = em.find(Pocket.class, pocketId); pocket.updateTamagotchi(tamagotchiId, request); }

首先,我们检查是否有任何其他电子宠物(除了我们要更新的电子宠物)已经具有相同的名称。 如果这是真的,我们会抛出异常。 如果您再次运行之前的测试并检查日志,您将看到仅调用了 COUNT 查询:

select count(t1_0.id)>0 from tamagotchi t1_0 where t1_0.id!=? and t1_0.name=?

无论如何,我不建议您过度使用这种方法。 您应该将其视为精确固定的补丁。 换句话说,只把它放在需要的地方。 否则,我更愿意依赖领域逻辑并让服务中的代码尽可能简单。

数据库生成的ID

之前我已经提到过,我将向您展示客户端生成的 ID 的示例。 然而,有时我们想使用其他ID类型。 例如,序列生成的。 此丰富域模型模式是否也适用于这些 ID 类型? 确实如此,但也存在一些担忧。

首先,看一下使用 IDENTITY 生成策略的 Pocket 和 Tamagotchi 实体:

@Entity @NoArgsConstructor(access = PROTECTED) @Setter(PRIVATE) public class Pocket { @Id @GeneratedValue(strategy = IDENTITY) private Long id; /* other fields aren't important */ public Long createTamagotchi(TamagotchiCreateRequest request) { Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(request.name(), request.status(), this); tamagotchis.add(newTamagotchi); validateTamagotchiNamesUniqueness(); // always returns null return newTamagotchi.getId(); } /* other methods aren't important */ public static Pocket newPocket(String name) { Pocket pocket = new Pocket(); pocket.setName(name); pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED)); return pocket; } } @Entity @NoArgsConstructor(access = PROTECTED) @Setter(PRIVATE) @Getter class Tamagotchi { @Id @GeneratedValue(strategy = IDENTITY) private Long id; /* other fields and methods aren't important */ public static Tamagotchi newTamagotchi(String name, Status status, Pocket pocket) { Tamagotchi tamagotchi = new Tamagotchi(); tamagotchi.setName(name); tamagotchi.setPocket(pocket); tamagotchi.setStatus(status); return tamagotchi; } }

正如你所看到的,我们不再直接分配ID。 相反,我们将该字段保留为空值,并让 Hibernate 稍后填充它。 不幸的是,这个决定破坏了 Pocket.createTamagotchi 方法的逻辑。 我们在创建 Tamagotchi 对象时不设置 ID。 因此,Tamagotchi.getId 的调用始终返回 null(直到将更改刷新到数据库)。

有多种方法可以解决此问题。

手动填写id

您可以消除 @GenerateValue 注释的使用并直接在构造函数中传递 ID 值。 在这种情况下,您必须调用 SELECT nextval('mysequence') 语句并将其结果传递给实体。 看下面的代码示例:

@Entity @NoArgsConstructor(access = PROTECTED) @Setter(PRIVATE) public class Pocket { @Id private Long id; /* other fields aren't important */ public Long createTamagotchi(long tamagotchiId, TamagotchiCreateRequest request) { Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(tamagotchiId, request.name(), request.status(), this); tamagotchis.add(newTamagotchi); validateTamagotchiNamesUniqueness(); // always returns null return newTamagotchi.getId(); } /* other methods aren't important */ public static Pocket newPocket(long id, String name) { Pocket pocket = new Pocket(); pocket.setId(id); pocket.setName(name); pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED)); return pocket; } } @Entity @NoArgsConstructor(access = PROTECTED) @Setter(PRIVATE) @Getter class Tamagotchi { @Id private Long id; /* other fields and methods aren't important */ public static Tamagotchi newTamagotchi(long id, String name, Status status, Pocket pocket) { Tamagotchi tamagotchi = new Tamagotchi(); tamagotchi.setId(id); tamagotchi.setName(name); tamagotchi.setPocket(pocket); tamagotchi.setStatus(status); return tamagotchi; } }

优点是您的实体类不依赖于某些 Hibernate 魔法,并且您仍然可以通过常规单元测试来验证业务案例。 但你也会让你的代码变得更加冗长。 因为你要手动通过 ID。

不管怎样,这个方法值得考虑。

我在这篇文章中找到了这个选项。 事实上,作者要求完全停止使用Hibernate。 尽管我喜欢 Hibernate,但我发现一些争论很有趣。

介绍业务密钥

有时手动传递 ID 几乎是不可能的。 也许需要太多的重构而难以忍受。 或者您的应用程序可能使用 MySQL,它不支持序列,但仅支持自动增量列。

虽然您可以通过创建常规表来模拟 MySQL 中的序列,但这种方法的性能不佳。

在这种情况下,您可以引入业务密钥。 这是一个可以唯一标识一个实体的单独值。 但这并不意味着业务密钥必须是全球唯一的。 例如,如果您通过名称指向 Tamagotchi,并且它仅在 Pocket 内是唯一的,那么您可以通过 (pocket_business_key, Tamagotic_name) 的组合来识别 Tamagotchi。

尽管如此,每个业务密钥都应该是不可修改的。 否则,您可能会泄露实体的身份。 所以,要好好注意这一点。

此外,业务密钥的一个很好的例子是 slug。 看一下这篇文章的网址。 您看到它包含它的名称和一些哈希值了吗? 那就是蛞蝓。 它仅在创建文章时分配一次,但永远不会更改(即使我更改文章的名称)。 因此,如果您的实体没有明显的业务密钥候选者,那么引入 slug 可能是一种选择。

丰富的领域模型总是值得吗?

软件开发没有最终决定。 每种方法都只是一种妥协。 丰富域模型模式也不例外。

我在文章的开头向您解释了贫血领域模型的问题。 它们都是有效且有道理的。 但这并不意味着Rich Domain模型没有缺点。 我能想到这些:

如果您使用 Hibernate,那么富域模型模式就不那么流行了。 这就是现实。 互联网上有几十篇关于 Hibernate 示例的文章,但完全没有丰富域模型。 人们已经习惯了贫血领域模型,你必须考虑到这一点。

丰富域模型模式也可能带来一些性能损失。 其中一些可以轻松修复。 但其他人可能会变得头疼。 如果您的应用程序应该是高负载的,您必须确保不变量的检查不会过多地减慢响应时间。

丰富的领域模型的使用通常会导致神对象实体。 当然,这也增加了维护的难度。 有一些方法可以解决这个问题。 例如,Vaughn Vernon 写了 3 篇关于有效聚合设计的文章。 然而,如果你的实体已经是一个上帝对象,那么重构它就会很困难。

结论

最后,我可以说,我认为富领域模型比贫血模型表现得更好。 但不要盲目应用。 您还应该考虑可能的后果并明智地做出决定。

非常感谢您阅读这篇长文。 我希望你学到了新东西。 如果您觉得有趣,请与您的朋友和同事分享,按“赞”按钮,并在下面留下您的评论。 我很高兴听到您的意见并讨论问题。 祝你今天过得愉快!

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved