我最初是用俄语写这篇文章的。因此,如果您的母语是英语,您可以通过此链接阅读。
在过去一年左右的时间里,我看到一些文章和演讲表明 JOOQ 是 Hibernate 的现代且优越的替代品。参数通常包括:
首先声明,我认为 JOOQ 是一个优秀的库(具体来说是一个库,而不是像 Hibernate 这样的框架)。它擅长完成自己的任务——以静态类型的方式使用 SQL,在编译时捕获大多数错误。
但是,当我听到 Hibernate 的时代已经过去,我们现在应该使用 JOOQ 编写所有内容时,在我看来,这听起来像是在说关系数据库的时代已经结束,我们现在应该只使用 NoSQL。听起来很有趣吗?然而,不久前,这样的讨论还相当严肃。
我认为问题在于对这两个工具解决的核心问题的误解。在这篇文章中,我的目的是澄清这些问题。我们将探索:
使用数据库最简单、最直观的方法是事务脚本模式。简而言之,您将所有业务逻辑组织为组合成单个事务的一组 SQL 命令。通常,类中的每个方法代表一个业务操作,并且仅限于一个事务。
假设我们正在开发一个应用程序,允许演讲者向会议提交演讲(为简单起见,我们只记录演讲的标题)。按照事务脚本模式,提交演讲的方法可能如下所示(使用 JDBI for SQL):
@Service @RequiredArgsConstructor public class TalkService { private final Jdbi jdbi; public TalkSubmittedResult submitTalk(Long speakerId, String title) { var talkId = jdbi.inTransaction(handle -> { // Count the number of accepted talks by the speaker var acceptedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // Check if the speaker is experienced var experienced = acceptedTalksCount >= 10; // Determine the maximum allowable number of submitted talks var maxSubmittedTalksCount = experienced ? 5 : 3; var submittedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // If the maximum number of submitted talks is exceeded, throw an exception if (submittedTalksCount >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount); } return handle.createUpdate( "INSERT INTO talk (speaker_id, status, title) " + "VALUES (:id, 'SUBMITTED', :title)" ).bind("id", speakerId) .bind("title", title) .executeAndReturnGeneratedKeys("id") .mapTo(Long.class) .one(); }); return new TalkSubmittedResult(talkId); } }
在此代码中:
这里存在潜在的竞争条件,但为了简单起见,我们不会关注它。
这种方法的优点:
缺点:
如果您的服务具有非常简单的逻辑并且预计不会随着时间的推移变得更加复杂,那么这种方法是有效的并且有意义的。然而,域通常更大。因此,我们需要一个替代方案。
领域模型模式的想法是我们不再将业务逻辑直接与 SQL 命令绑定。相反,我们创建域对象(在 Java 上下文中为类)来描述行为并存储有关域实体的数据。
在本文中,我们不会讨论贫血模型和丰富模型之间的区别。如果您有兴趣,我已经写了一篇关于该主题的详细文章。
业务场景(服务)应该仅使用这些对象,并避免与特定的数据库查询绑定。
当然,在现实中,我们可能会混合使用与域对象的交互和直接数据库查询来满足性能要求。在这里,我们讨论实现领域模型的经典方法,其中不违反封装和隔离。
例如,如果我们正在讨论实体“Speaker”和“Talk”,如前所述,域对象可能如下所示:
@Service @RequiredArgsConstructor public class TalkService { private final Jdbi jdbi; public TalkSubmittedResult submitTalk(Long speakerId, String title) { var talkId = jdbi.inTransaction(handle -> { // Count the number of accepted talks by the speaker var acceptedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // Check if the speaker is experienced var experienced = acceptedTalksCount >= 10; // Determine the maximum allowable number of submitted talks var maxSubmittedTalksCount = experienced ? 5 : 3; var submittedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // If the maximum number of submitted talks is exceeded, throw an exception if (submittedTalksCount >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount); } return handle.createUpdate( "INSERT INTO talk (speaker_id, status, title) " + "VALUES (:id, 'SUBMITTED', :title)" ).bind("id", speakerId) .bind("title", title) .executeAndReturnGeneratedKeys("id") .mapTo(Long.class) .one(); }); return new TalkSubmittedResult(talkId); } }
这里,Speaker 类包含提交演讲的业务逻辑。数据库交互被抽象出来,让领域模型专注于业务规则。
假设这个存储库接口:
@AllArgsConstructor public class Speaker { private Long id; private String firstName; private String lastName; private List<Talk> talks; public Talk submitTalk(String title) { boolean experienced = countTalksByStatus(Status.ACCEPTED) >= 10; int maxSubmittedTalksCount = experienced ? 3 : 5; if (countTalksByStatus(Status.SUBMITTED) >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException( "Submitted talks count is maximum: " + maxSubmittedTalksCount); } Talk talk = Talk.newTalk(this, Status.SUBMITTED, title); talks.add(talk); return talk; } private long countTalksByStatus(Talk.Status status) { return talks.stream().filter(t -> t.getStatus().equals(status)).count(); } } @AllArgsConstructor public class Talk { private Long id; private Speaker speaker; private Status status; private String title; private int talkNumber; void setStatus(Function<Status, Status> fnStatus) { this.status = fnStatus.apply(this.status); } public enum Status { SUBMITTED, ACCEPTED, REJECTED } }
那么SpeakerService可以这样实现:
public interface SpeakerRepository { Speaker findById(Long id); void save(Speaker speaker); }
领域模型的优点:
总之,优点很多。然而,有一个重要的挑战。有趣的是,在经常宣扬领域模型模式的领域驱动设计书籍中,这个问题要么根本没有提到,要么只是简单提及。
问题是如何将域对象保存到数据库,然后读取它们?换句话说,如何实现存储库?
现在,答案是显而易见的。只需使用 Hibernate(或者更好的是 Spring Data JPA)即可省去麻烦。但让我们想象一下,我们正处于一个 ORM 框架尚未发明的世界。我们该如何解决这个问题呢?
为了实现SpeakerRepository,我还使用JDBI:
@Service @RequiredArgsConstructor public class TalkService { private final Jdbi jdbi; public TalkSubmittedResult submitTalk(Long speakerId, String title) { var talkId = jdbi.inTransaction(handle -> { // Count the number of accepted talks by the speaker var acceptedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'ACCEPTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // Check if the speaker is experienced var experienced = acceptedTalksCount >= 10; // Determine the maximum allowable number of submitted talks var maxSubmittedTalksCount = experienced ? 5 : 3; var submittedTalksCount = handle.select("SELECT count(*) FROM talk WHERE speaker_id = :id AND status = 'SUBMITTED'") .bind("id", speakerId) .mapTo(Long.class) .one(); // If the maximum number of submitted talks is exceeded, throw an exception if (submittedTalksCount >= maxSubmittedTalksCount) { throw new CannotSubmitTalkException("Submitted talks count is maximum: " + maxSubmittedTalksCount); } return handle.createUpdate( "INSERT INTO talk (speaker_id, status, title) " + "VALUES (:id, 'SUBMITTED', :title)" ).bind("id", speakerId) .bind("title", title) .executeAndReturnGeneratedKeys("id") .mapTo(Long.class) .one(); }); return new TalkSubmittedResult(talkId); } }
方法很简单。对于每个存储库,我们编写一个单独的实现,该实现可以使用任何 SQL 库(如 JOOQ 或 JDBI)与数据库配合使用。
乍一看(甚至可能是第二眼),这个解决方案可能看起来相当不错。考虑一下:
现实世界中的事情会变得更加有趣,你可能会遇到这样的场景:
最重要的是,随着业务逻辑和域对象的发展,您需要维护映射代码。
如果您尝试自己处理这些要点,您最终会发现自己(惊讶!)正在编写类似 Hibernate 的框架 - 或者更可能是一个更简单的版本。
JOOQ 解决了编写 SQL 查询时缺乏静态类型的问题。这有助于减少编译阶段的错误数量。通过直接从数据库模式生成代码,对模式的任何更新都将立即显示代码需要修复的位置(它根本无法编译)。
Hibernate 解决了域对象映射到关系数据库的问题,反之亦然(从数据库读取数据并将其映射到域对象)。
因此,争论 Hibernate 更差或 JOOQ 更好是没有意义的。这些工具是为不同的目的而设计的。如果您的应用程序是围绕事务脚本范例构建的,那么 JOOQ 无疑是理想的选择。但是,如果您想使用域模型模式并避免使用 Hibernate,则必须在自定义存储库实现中享受手动映射的乐趣。当然,如果您的雇主付钱让您构建另一个 Hibernate 杀手,那就没有问题了。但最有可能的是,他们希望您专注于业务逻辑,而不是对象到数据库映射的基础设施代码。
顺便说一句,我相信 Hibernate 和 JOOQ 的组合对于 CQRS 效果很好。您有一个执行命令的应用程序(或其逻辑部分),例如 CREATE/UPDATE/DELETE 操作 - 这就是 Hibernate 非常适合的地方。另一方面,您有一个读取数据的查询服务。在这里,JOOQ 表现出色。与 Hibernate 相比,它使得构建复杂查询和优化它们变得更加容易。
这是真的。 JOOQ 允许您生成包含用于从数据库获取实体的标准查询的 DAO。您甚至可以用您的方法扩展这些 DAO。此外,JOOQ 将生成可以使用 setter 填充的实体(类似于 Hibernate),并传递给 DAO 中的插入或更新方法。这不是像Spring Data吗?
对于简单的情况,这确实可行。然而,它与手动实现存储库没有太大区别。问题类似:
所以,如果你想构建一个复杂的领域模型,你必须手动完成。如果没有 Hibernate,映射的责任将完全落在您身上。当然,使用 JOOQ 比 JDBI 更愉快,但这个过程仍然是劳动密集型的。
甚至 JOOQ 的创建者 Lukas Eder 在他的博客中也提到,DAO 被添加到库中是因为它是一种流行的模式,而不是因为他一定推荐使用它们。
感谢您阅读这篇文章。我是 Hibernate 的忠实粉丝,认为它是一个优秀的框架。不过,我知道有些人可能会觉得 JOOQ 更方便。我文章的要点是 Hibernate 和 JOOQ 不是竞争对手。如果这些工具带来价值,甚至可以在同一产品中共存。
如果您对内容有任何意见或反馈,我很乐意讨论。祝你度过富有成效的一天!
以上是JOOQ 不是 Hibernate 的替代品。他们解决不同的问题的详细内容。更多信息请关注PHP中文网其他相关文章!