首頁 >Java >java教程 >JOOQ 不是 Hibernate 的替代品。他們解決不同的問題

JOOQ 不是 Hibernate 的替代品。他們解決不同的問題

DDD
DDD原創
2025-01-11 20:10:41474瀏覽

我原本是用俄文寫這篇文章的。因此,如果您的母語是英語,您可以透過此連結閱讀。

在過去一年左右的時間裡,我看到一些文章和演講表明 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中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn