【Spring Boot】第8.4課-使用 JPA 配置資料表關聯(以一對一關聯為例)


https://unsplash.com/photos/LmyPLbbUWhA

在上一篇,我們已經知道如何對單一資料表進行 CRUD,而本文將以「學生」與「聯繫方式」為情境,解說如何把兩張資料表關聯起來。並且也會撰寫 RESTful API 進行測試。

剛開始使用 JPA 進行配置時,會遇到許多新觀念。包含各種 annotation、加載策略、級聯,以及潛在的 N + 1 問題。本文會以最簡單的一對一關聯做為起點,往後學習其他關聯方式,將會更順利。

一、程式專案準備

(一)實體類別介紹

以下的「Student」類別,描述了學生資料,包含 id 與名字這 2 個欄位。

以下的「Contact」類別,描述了聯繫方式,包含信箱與電話這 2 個欄位。

它們在資料庫中,都會有自己的資料表(table)。

(二)JPA Repository

上述的 Student 和 Contact 都是要儲存到資料庫的實體類別,因此需建立各自的 repository。

以下的「StudentRepository」宣告了一個方法,用來查詢 name 欄位包含某段字串的資料(不分大小寫)。

以下是「ContactRepository」,無自定義方法。

(三)準備測試資料

啟動程式後,JPA 便會在資料庫中建立 table。

若讀者有需要,可執行以下 SQL 指令,產生測試資料。


二、設計一對一關聯

(一)設計原則

所謂的一對一關聯,指的是其中一張 table 的 1 筆資料,只會對應到另一張 table 的 1 筆資料,反之亦然。

現在我們有「學生」與「聯繫方式」這兩張 table。在聯繫表中,有信箱和電話這 2 個欄位,每組資料都屬於一位學生的。而每位學生都有對應的一組聯繫方式。

兩張 table 的關聯方式,通常是在其中一張 table 中添加額外的欄位做為外鍵(Foreign Key,FK),指向另一張 table 的主鍵(Primary Key,PK)。此時有兩種選擇:

  • 在學生表添加 FK 欄位,指向聯繫表的 PK。代表每位學生擁有一組聯繫方式。
  • 在聯繫表添加 FK 欄位,指向學生表的 PK。代表每組聯繫方式都有它的擁有者。

那麼要在哪一張 table 添加 FK 欄位呢?原則上會選擇商業邏輯中,更重要的 table,也就是學生表。因為學生資料的存在不會依賴於聯繫方式,而聯繫方式是學生資料的一部份。

one-to-one-relationship-student-contact

(二)程式配置

回到程式專案,以下是在 Student 實體類別配置一對一關聯。

此處在實體類別添加了 Contact 欄位,並冠上 @OneToOne 注解,表明一對一關聯。

這個 Contact 欄位會以 FK 欄位的形式儲存在 table 中。為此,需使用 @JoinColumn 注解來取名。在 name 參數可傳入欄位名稱;而 referencedColumnName 參數,則定義該欄位要關聯到聯繫表的 id 欄位,也就是 PK。

若聯繫方式為必填資料,那麼可在 nullable 參數傳入 false,將 FK 設成「NOT NULL」。另外,聯繫方式是每位學生自己專屬的,不會與人共用,因此設為 unique。

當呼叫 StudentRepository 進行查詢時,每筆學生資料所關聯到的聯繫方式,會被載入到 Contact 欄位中。讓我們在程式碼中進行運用。

(三)測試用 API

為了確認一對一關聯的效果,我們會在 Controller 設計 RESTful API,透過 repository 存取資料庫。

以下提供了一支 API,用途是透過學生名字的關鍵字來查詢。

邏輯上是先呼叫 repository 查詢資料,接著將結果包裝成如下的 response body。

在迴圈中進行包裝的過程,從 Student 實體取出了 Contact 物件。要注意的是,該物件實際上是 Hibernate 的「代理物件」(proxy),本身並不支援在 API 回傳時,被序列化成 JSON。

三、加載策略

上一節最後有提到,查詢到的 Student 實體,其所關聯的 Contact 實體,是 Hibernate 的「代理物件」。在查詢 Student 時,Hibernate 會根據我們的配置,決定是否要馬上將關聯的 Contact 一起查詢回來。

透過在實體類別的 @OneToOne 注解設定「加載策略」,可達到這種控制。

此處傳入了 fetch 參數。FetchType.EAGER 代表立即查詢回來,為 @OneToOne 注解的預設值。而 FetchType.LAZY 代表需要用到時,才會進行查詢。

為了觀察兩者的差別,請讀者在 application.properties 配置檔添加以下兩個參數。用途是讓 JPA 存取資料庫時,將執行的 SQL 指令印在 console 中,並做排版。

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

在上一節的 API 中,首先呼叫了 StudentRepository,再假設查詢到的學生資料有 2 筆。若加載策略設為 FetchType.EAGER,JPA 會「立即」印出類似下面的內容:

Hibernate:
    SELECT s.id, s.name, s.contact_id
    FROM student s
    WHERE UPPER (s.name) LIKE UPPER (?)
Hibernate:
    SELECT c.id, c.email, c.phone
    FROM contact c
    WHERE c.id = ?
Hibernate:
    SELECT c.id, c.email, c.phone
    FROM contact c
    WHERE c.id = ?

第一條指令是查詢學生資料,並取得 FK,也就是 contact_id 欄位值。第二、三條指令則是分別查詢聯繫表,將 Contact 實體加載到 Student 實體中。

若加載策略設為 FetchType.LAZY,則直到呼叫「getContact()」方法取得關聯的實體時,JPA 才會實際查詢聯繫表。特別的是,當直接以「getContact().getId()」的形式做鏈式呼叫,是不會引發查詢的。相對地,會取得學生表的 FK 欄位值。


四、認識 N + 1 問題

如果在商業邏輯中查詢學生,並非每次都要取得聯繫資料,那麼當查詢結果有 N 位學生,可能會無意間引發 N 次對聯繫表的查詢。再加上最一開始對學生表的 1 次查詢,總共 N + 1 次。這就是知名的「N + 1」問題,嚴重的話,會導致應用程式的效能低落。

該現象是一般程式設計不良所造成的問題。不一定只會在使用 Spring Data JPA 時發生;不一定只存在於一對一關聯;也可能是人本來寫的程式已有瑕疵;或者是框架造成的,因為它封裝了實作細節,我們一時察覺不到。

對於 N + 1 問題,大方向可以解釋成:一開始先查詢主要的資料實體(如 Student),接著進一步查詢所關聯的次要實體(如 Contact)。但主要實體和次要實體,分別都可能有多個。若查詢次要實體時是「逐一查詢」,則存取資料庫的次數會倍增。

要解決此問題,讀者可視情況,使用 FetchType.LAZY 做為加載策略。甚至還能調整程式碼,將取得聯繫方式的部份,改為對 ContactRepository 進行單次查詢。

以上的範例程式,會在查詢到學生後,將學生與 FK 的值整理成 Map 資料結構。即便加載策略為 FetchType.LAZY,但鏈式呼叫 Student.getContact().getId() 方法時,並不會引發查詢。

接著將多個 FK 傳入 ContactRepository 的 findAllById 方法,可一次查詢到所有聯繫方式。此處將 PK 與查詢到的 Contact 實體,也整理成 Map 資料結構。

如此一來,我們便可透過程式碼,在應用程式的層級,將兩種實體關聯起來了。重新存取 API 後,在 console 印出的 SQL 指令,示意如下:

Hibernate:
    SELECT s.id, s.name, s.contact_id
    FROM student s
    WHERE UPPER (s.name) LIKE UPPER (?)
Hibernate:
    SELECT c.id, c.email, c.phone
    FROM contact c
    WHERE c.id IN (?, ?)

可看出只對資料庫進行 2 次查詢而已。第一個指令是查詢學生資料,在此假設查到 2 筆。而第二個指令的 WHERE 子句會有 2 個問號,是因為要透過這些 FK,一次在聯繫表中查詢所有資料。

五、級聯(Cascade)

(一)前言

在目前的設計中,Student 實體持有了 Contact 物件,而學生表也有 FK 欄位去指向聯繫表的 PK 欄位。此時我們稱學生表為關聯的「維護方」,而聯繫表為「被維護方」。

JPA 提供一種叫做「級聯」的機制,它讓我們在插入、更新或刪除維護方的實體物件時,能自動對被維護方的資料產生連鎖反應。

@OneToOnecascade 參數,可定義哪些操作需要對被維護方的實體套用級聯。CascadeType.PERSIST 代表呼叫 repository 的 save 方法,插入或更新維護方的實體時會生效。CascadeType.REMOVE 代表刪除維護方的實體時會生效。

(二)級聯插入

以下是在 Controller 提供建立學生資料的 API。

範例程式中,雖然只有將 Student 物件傳入 StudentRepository 的 save 方法進行儲存,但 JPA 也會自動在聯繫表也插入其內部 Contact 物件的資料。

JPA 在 console 印出的指令內容,示意如下。可看到會先插入聯繫資料,再插入學生資料,並填入 FK 欄位的值。

Hibernate:
    INSERT INTO contact (email, phone)
    VALUES (?, ?)
Hibernate:
    INSERT INTO student (name, contact_id)
    VALUES (?, ?)

(三)級聯刪除

以下的 API 是刪除學生資料。

JPA 在 console 印出的指令內容,示意如下:

Hibernate:
    SELECT s.id, s.name, s.contact_id
    FROM student s
    WHERE s.id = ?
Hibernate:
    DELETE FROM student
    WHERE id = ? 
Hibernate:
    DELETE FROM contact
    WHERE id= ?

可看到第一步是根據學生 id 查詢資料,藉此取得 FK 的值,也就是聯繫表的 PK。接著在刪除過程中,為避免 FK 關聯不到 PK(違反約束),因此會先刪除學生,才刪除聯繫方式。

(四)級聯更新

以下的 API 是更新學生的聯繫方式。

範例程式中,在查詢出學生資料後,取出了 Contact 物件,但它其實是 Hibernate 的代理物件。為了不影響級聯的運作,因此實作上是將 request body 的內容更新上去,而不是覆蓋掉 Student 實體的 Contact 內容。

JPA 在 console 印出的指令內容,示意如下:

Hibernate:
    SELECT s.id, s.name, s.contact_id
    FROM student s
    WHERE s.id = ?
Hibernate:
    SELECT c.id, c.email, c.phone
    FROM contact c 
    WHERE c.id = ?
Hibernate:
    UPDATE contact
    SET email = ?,
        phone = ?
    WHERE id = ?

可看到第一步是查詢學生資料。接著由於呼叫「getContact()」方法的緣故,又引發了對聯繫表的查詢。最後儲存 Student 實體時,才級聯更新 Contact。

然而,「對聯繫表的查詢」看起來是多餘的步驟。畢竟查詢學生後,已經知道 FK 的值。而 Student 實體所持有的 Contact 實體,也已經設置好要更新的內容了。

針對這一點,我們可在 repository 宣告新的查詢方法,並冠上 @EntityGraph 注解。如此一來,查詢時便可指定要 JOIN 哪些關聯的資料。

調整後,JPA 在 console 印出的指令內容,示意如下:

Hibernate:
    SELECT s.id, s.name, c.id, c.email, c.phone
    FROM student s
    LEFT JOIN contact c ON c.id = s.contact_id
    WHERE s.id = ?
Hibernate:
    UPDATE contact
    SET email = ?,
        phone = ?
    WHERE id = ?

可看到加上 @EntityGraph 注解後,查詢學生時將會一併帶回聯繫資料,省下一次對資料庫的查詢。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch8-4

上一篇:【Spring Boot】第8.3課-使用 JPA Repository 存取 MySQL 資料庫

下一篇:【Spring Boot】第8.5課-使用 JPA 建立一對多關聯,並配置雙向關聯

留言