首页 > Java > java教程 > JOOQ 不是 Hibernate 的替代品。他们解决不同的问题

JOOQ 不是 Hibernate 的替代品。他们解决不同的问题

DDD
发布: 2025-01-11 20:10:41
原创
518 人浏览过

我最初是用俄语写这篇文章的。因此,如果您的母语是英语,您可以通过此链接阅读。

在过去一年左右的时间里,我看到一些文章和演讲表明 JOOQ 是 Hibernate 的现代且优越的替代品。参数通常包括:

  1. JOOQ 允许您在编译时验证所有内容,而 Hibernate 则不能!
  2. Hibernate 生成奇怪且并不总是最佳的查询,而使用 JOOQ,一切都是透明的!
  3. Hibernate 实体是可变的,这很糟糕。 JOOQ 允许所有实体都是不可变的(你好,函数式编程)!
  4. JOOQ 不涉及任何带有注释的“魔法”!

首先声明,我认为 JOOQ 是一个优秀的库(具体来说是一个库,而不是像 Hibernate 这样的框架)。它擅长完成自己的任务——以静态类型的方式使用 SQL,在编译时捕获大多数错误。

但是,当我听到 Hibernate 的时代已经过去,我们现在应该使用 JOOQ 编写所有内容时,在我看来,这听起来像是在说关系数据库的时代已经结束,我们现在应该只使用 NoSQL。听起来很有趣吗?然而,不久前,这样的讨论还相当严肃。

我认为问题在于对这两个工具解决的核心问题的误解。在这篇文章中,我的目的是澄清这些问题。我们将探索:

  1. 什么是交易脚本?
  2. 什么是领域模型模式?
  3. Hibernate 和 JOOQ 解决哪些具体问题?
  4. 为什么其中一个不能替代另一个,它们如何共存?

JOOQ Is Not a Replacement for Hibernate. They Solve Different Problems

交易脚本

使用数据库最简单、最直观的方法是事务脚本模式。简而言之,您将所有业务逻辑组织为组合成单个事务的一组 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);
    }
}
登录后复制
登录后复制
登录后复制

在此代码中:

  1. 我们计算演讲者已提交的演讲数量。
  2. 我们检查是否超出了提交演讲的最大允许数量。
  3. 如果一切正常,我们将创建一个状态为“已提交”的新对话。

这里存在潜在的竞争条件,但为了简单起见,我们不会关注它。

这种方法的优点:

  1. 正在执行的 SQL 是简单且可预测的。如果需要,可以轻松调整它以提高性能。
  2. 我们只从数据库中获取必要的数据。
  3. 使用 JOOQ,这段代码可以写得更简单、简洁,并且可以使用静态类型!

缺点:

  1. 仅通过单元测试来测试业务逻辑是不可能的。您将需要集成测试(而且是相当多的测试)。
  2. 如果领域很复杂,这种方法很快就会导致意大利面条式代码。
  3. 存在代码重复的风险,随着系统的发展,这可能会导致意外的错误。

如果您的服务具有非常简单的逻辑并且预计不会随着时间的推移变得更加复杂,那么这种方法是有效的并且有意义的。然而,域通常更大。因此,我们需要一个替代方案。

领域模型

领域模型模式的想法是我们不再将业务逻辑直接与 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);
}
登录后复制

领域模型的优点:

  1. 域对象与实现细节(即数据库)完全解耦。这使得它们可以轻松地通过常规单元测试进行测试。
  2. 业务逻辑集中在域对象内。与事务脚本方法不同,这大大降低了逻辑在应用程序中传播的风险。
  3. 如果需要,域对象可以完全不可变,这可以提高使用它们时的安全性(您可以将它们传递给任何方法,而不必担心意外修改)。
  4. 领域对象中的字段可以替换为值对象,这不仅提高了可读性,而且保证了赋值时字段的有效性(不能创建内容无效的值对象)。

总之,优点很多。然而,有一个重要的挑战。有趣的是,在经常宣扬领域模型模式的领域驱动设计书籍中,这个问题要么根本没有提到,要么只是简单提及。

问题是如何将域对象保存到数据库,然后读取它们?换句话说,如何实现存储库?

现在,答案是显而易见的。只需使用 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)与数据库配合使用。

乍一看(甚至可能是第二眼),这个解决方案可能看起来相当不错。考虑一下:

  1. 代码保持高度透明,就像事务脚本方法一样。
  2. 仅通过集成测试来测试业务逻辑不再有问题。这些仅用于存储库实现(可能还有一些 E2E 场景)。
  3. 映射代码就在我们面前。不涉及 Hibernate 魔法。发现错误?找到正确的线并修复它。

休眠的必要性

现实世界中的事情会变得更加有趣,你可能会遇到这样的场景:

  1. 域对象可能需要支持继承。
  2. 一组字段可以组合成一个单独的值对象(嵌入在 JPA/Hibernate 中)。
  3. 某些字段不应在每次获取域对象时加载,而应仅在访问时加载,以提高性能(延迟加载)。
  4. 对象之间可能存在复杂的关系(一对多、多对多等)。
  5. 您只需要在 UPDATE 语句中包含已更改的字段,因为其他字段很少更改,并且通过网络发送它们是没有意义的(DynamicUpdate 注释)。

最重要的是,随着业务逻辑和域对象的发展,您需要维护映射代码。

如果您尝试自己处理这些要点,您最终会发现自己(惊讶!)正在编写类似 Hibernate 的框架 - 或者更可能是一个更简单的版本。

JOOQ 和 Hibernate 的目标

JOOQ 解决了编写 SQL 查询时缺乏静态类型的问题。这有助于减少编译阶段的错误数量。通过直接从数据库模式生成代码,对模式的任何更新都将立即显示代码需要修复的位置(它根本无法编译)。

Hibernate 解决了域对象映射到关系数据库的问题,反之亦然(从数据库读取数据并将其映射到域对象)。

因此,争论 Hibernate 更差或 JOOQ 更好是没有意义的。这些工具是为不同的目的而设计的。如果您的应用程序是围绕事务脚本范例构建的,那么 JOOQ 无疑是理想的选择。但是,如果您想使用域模型模式并避免使用 Hibernate,则必须在自定义存储库实现中享受手动映射的乐趣。当然,如果您的雇主付钱让您构建另一个 Hibernate 杀手,那就没有问题了。但最有可能的是,他们希望您专注于业务逻辑,而不是对象到数据库映射的基础设施代码。

顺便说一句,我相信 Hibernate 和 JOOQ 的组合对于 CQRS 效果很好。您有一个执行命令的应用程序(或其逻辑部分),例如 CREATE/UPDATE/DELETE 操作 - 这就是 Hibernate 非常适合的地方。另一方面,您有一个读取数据的查询服务。在这里,JOOQ 表现出色。与 Hibernate 相比,它使得构建复杂查询和优化它们变得更加容易。

JOOQ 中的 DAO 怎么样?

这是真的。 JOOQ 允许您生成包含用于从数据库获取实体的标准查询的 DAO。您甚至可以用您的方法扩展这些 DAO。此外,JOOQ 将生成可以使用 setter 填充的实体(类似于 Hibernate),并传递给 DAO 中的插入或更新方法。这不是像Spring Data吗?

对于简单的情况,这确实可行。然而,它与手动实现存储库没有太大区别。问题类似:

  1. 实体不会有任何关系:没有ManyToOne,没有OneToMany。只是数据库列,这使得编写业务逻辑变得更加困难。
  2. 实体是单独生成的。您无法将它们组织成继承层次结构。
  3. 实体与 DAO 一起生成的事实意味着您无法按照自己的意愿修改它们。例如,用值对象替换字段、添加与另一个实体的关系或将字段分组到可嵌入中是不可能的,因为重新生成实体将覆盖您的更改。是的,您可以配置生成器以稍微不同的方式创建实体,但自定义选项是有限的(并且不如自己编写代码那么方便)。

所以,如果你想构建一个复杂的领域模型,你必须手动完成。如果没有 Hibernate,映射的责任将完全落在您身上。当然,使用 JOOQ 比 JDBI 更愉快,但这个过程仍然是劳动密集型的。

甚至 JOOQ 的创建者 Lukas Eder 在他的博客中也提到,DAO 被添加到库中是因为它是一种流行的模式,而不是因为他一定推荐使用它们。

结论

感谢您阅读这篇文章。我是 Hibernate 的忠实粉丝,认为它是一个优秀的框架。不过,我知道有些人可能会觉得 JOOQ 更方便。我文章的要点是 Hibernate 和 JOOQ 不是竞争对手。如果这些工具带来价值,甚至可以在同一产品中共存。

如果您对内容有任何意见或反馈,我很乐意讨论。祝你度过富有成效的一天!

资源

  1. JDBI
  2. 交易脚本
  3. 领域模型
  4. 我的文章 – 使用 Spring Boot 和 Hibernate 的丰富域模型
  5. 存储库模式
  6. 值对象
  7. JPA 嵌入式
  8. JPA 动态更新
  9. CQRS
  10. Lukas Eder:去 DAO 还是不去 DAO

以上是JOOQ 不是 Hibernate 的替代品。他们解决不同的问题的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板