iT邦幫忙

2025 iThome 鐵人賽

DAY 26
1
Software Development

spring boot 3 學習筆記系列 第 26

Day26 - Spring Data JPA 關聯對映實戰:從一對多到多層級關聯

  • 分享至 

  • xImage
  •  

什麼是實體關聯?

在先前的單元中,我們已經學會如何定義單一的 Customer 實體 (Entity)。然而,真實世界的應用程式中,資料很少是獨立存在的:

  • 👤 客戶 會有 📋 訂單
  • 📋 訂單 會有多個 📦 品項
  • 📦 產品 可能屬於不同 🏷️ 類別

這些資料之間的連結,就是所謂的關聯 (Relationship)

今天,我們將深入探討 Spring Data JPA 的核心精髓:實體關聯對映 (Entity Relationship Mapping, ERM)。我們將學習如何使用簡單的註解 (Annotation) 來定義實體間的關係,並讓 JPA (Java Persistence API) 自動為我們處理複雜的資料庫操作。

💡 小提醒:實體關聯就像是在告訴電腦「這些資料表之間是如何連接的」,就像建立家族關係圖一樣!

學習目標

完成今日學習後,你將能夠:

  1. 掌握核心關聯:建立一對一 (@OneToOne)、一對多 (@OneToMany) 與多對多 (@ManyToMany) 的實體關聯
  2. 精通外鍵設計:學會使用 @JoinColumn 來定義外鍵 (Foreign Key),並徹底理解 mappedBy雙向關聯 (Bidirectional Relationship) 中的關鍵作用
  3. 理解資料庫層面:瞭解 JPA 設定如何在 PostgreSQL 中產生外鍵約束 (Foreign Key Constraint),以及它如何確保資料的一致性 (Data Consistency)
  4. 具備實戰能力:能夠從無到有,建立一個包含多層關聯 (Customer => Order => OrderItem) 的應用程式,並撰寫對應的 API

🎓 學習小技巧:不用一次全部記住,先理解概念,再透過實作加深印象!

核心概念:擁有方 (Owning Side) 與 mappedBy

在深入探討各種關聯之前,我們必須先理解一個最重要的概念:關聯的擁有方 (Owning Side)

🤔 為什麼需要擁有方?

想像一下,當兩個人要建立朋友關係時:

  • 在現實世界中,友誼是雜方的 - A 是 B 的朋友,B 也是 A 的朋友
  • 但在資料庫中,我們需要有人「負責」維護這個關係

在資料庫中,兩張資料表的關聯是透過其中一張資料表的外鍵 (Foreign Key) 欄位來維護的。例如:

customers 表        allowed_domains 表
┌─────────┐        ┌─────────────┐
│ id (PK) │   ←────│ customer_id │  ← 這就是外鍵
│ name    │        │ domain_name │
│ email   │        │ id (PK)     │
└─────────┘        └─────────────┘

JPA (Java Persistence API) 沿用了這個概念。在一個雙向關聯 (Bidirectional Relationship) 中,JPA 需要知道由哪一個實體來負責維護這個外鍵欄位。

擁有方 vs 反向方

  • 擁有方 (Owning Side)

    • 這個實體對應的資料表包含外鍵
    • 在程式碼中,我們會使用 @JoinColumn 註解來明確指定外鍵欄位
    • 就像是拿著遙控器的人,負責控制這段關係
  • 被擁有方 / 反向方 (Inverse Side)

    • 這個實體不包含外鍵
    • 它會使用 mappedBy 屬性,告訴 JPA:「這段關係的控制權已經交給對方了,去對方那裡找 @JoinColumn 的設定」
    • mappedBy 的值必須是「擁有方」實體中,代表此實體的那個屬性名稱

重要原則

⚠️ 記住這個原則:@JoinColumnmappedBy 總是成對出現,一個在擁有方,一個在反向方,而且它們永遠不會出現在同一個實體中。

簡單記憶法:

  • 有外鍵 = 擁有方 = 用 @JoinColumn
  • 沒外鍵 = 反向方 = 用 mappedBy

一對多 (@OneToMany) / 多對一 (@ManyToOne) 關聯

這是在應用程式中最常見的關聯。我們將以「一個 Customer 可以擁有多個 AllowedDomain」這個範例來實作。

實際應用場景

想像一個企業客戶管理系統:

  • 一個客戶公司 (Customer) 可能有多個允許的網域 (AllowedDomain)
  • 例如:Google 公司可能有 google.comgmail.comyoutube.com 等網域

資料庫設計分析

在資料庫層面,這意味著 allowed_domains 資料表中會有一個 customer_id 欄位作為外鍵:

customers 表                    allowed_domains 表
┌─────────────┐                ┌─────────────────┐
│ id (PK)     │←──────────────│ customer_id (FK)│
│ name        │                │ domain_name     │
│ email       │                │ id (PK)         │
│ status      │                └─────────────────┘
└─────────────┘
    1                              多

因此,AllowedDomain 實體理所當然地成為了這段關係的擁有方 (Owning Side)

程式碼實作

AllowedDomain.java (多方,擁有方)

import jakarta.persistence.*;
import java.util.UUID;

@Entity
@Table(name = "allowed_domains")
public class AllowedDomain {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "domain_name", nullable = false)
    private String domainName;

    /**
     * 多對一關聯 (Many-to-One Relationship)
     * 
     * 這裡的多方 (AllowedDomain) 是關聯的擁有方 (Owning Side)
     * 
     * @ManyToOne: 標示多個 AllowedDomain 對應到一個 Customer
     * @JoinColumn: 指定外鍵欄位
     *   - name = "customer_id": 在 allowed_domains 資料表中建立一個名為 customer_id 的外鍵欄位
     *   - nullable = false: 這個外鍵不能是空的,確保每個 domain 都必須屬於一個 customer
     * 
     * FetchType.LAZY: 延遲載入 (Lazy Loading) - 只有在真正需要時才載入 Customer 資料
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    // 建構子 (Constructors)
    public AllowedDomain() {
    }

    public AllowedDomain(String domainName) {
        this.domainName = domainName;
    }

    // Getters and Setters...
    public UUID getId() {
        return id;
    }

    public void setId(UUID id) {
        this.id = id;
    }

    public String getDomainName() {
        return domainName;
    }

    public void setDomainName(String domainName) {
        this.domainName = domainName;
    }

    public Customer getCustomer() {
        return customer;
    }

    public void setCustomer(Customer customer) {
        this.customer = customer;
    }
}

Customer.java (一方,反向方)

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // ... 其他 name, email, status 欄位 ...

    /**
     * 一對多關聯 (One-to-Many Relationship)
     * 
     * 這一方 (Customer) 是關聯的反向方 (Inverse Side)
     * 
     * mappedBy = "customer": 
     *   告訴 JPA,這段關係的設定由 AllowedDomain 實體中的 "customer" 屬性負責
     *   JPA 會去 AllowedDomain.java 裡找那個 customer 欄位上的 @JoinColumn 設定
     * 
     * cascade = CascadeType.ALL: 
     *   級聯操作 (Cascade Operations) - 當我們對 Customer 執行操作 (儲存、刪除) 時,
     *   相關的 AllowedDomain 也會一併被操作。這在新增時非常方便!
     * 
     * orphanRemoval = true: 
     *   "孤兒移除" (Orphan Removal) - 如果一個 AllowedDomain 從這個 Set 集合中被移除,
     *   那麼這個 AllowedDomain 在資料庫中也應該被刪除
     * 
     * 使用 Set 而不是 List 可以避免重複資料,且效能較好
     */
    @OneToMany(
        mappedBy = "customer",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private Set<AllowedDomain> allowedDomains = new HashSet<>();
    
    /**
     * 🔧 Helper method 輔助方法 - 用來同步雙向關聯
     * 這個方法確保當我們建立關聯時,雙方都能正確設定
     */
    public void addDomain(AllowedDomain domain) {
        allowedDomains.add(domain);      // 將 domain 加入到 Customer 的集合中
        domain.setCustomer(this);        // 設定 domain 的 customer 屬性
    }

    public void removeDomain(AllowedDomain domain) {
        allowedDomains.remove(domain);   // 從 Customer 的集合中移除 domain
        domain.setCustomer(null);        // 清除 domain 的 customer 屬性
    }

    // Getters and Setters...
    public Set<AllowedDomain> getAllowedDomains() {
        return allowedDomains;
    }

    public void setAllowedDomains(Set<AllowedDomain> allowedDomains) {
        this.allowedDomains = allowedDomains;
    }
}

重點說明

  1. mappedBy 的值"customer" 必須與 AllowedDomain 實體中的屬性名稱完全一致
  2. 級聯操作 (Cascade Operations):當儲存 Customer 時,相關的 AllowedDomain 也會自動儲存
  3. 孤兒移除 (Orphan Removal):當從集合中移除 AllowedDomain 時,它會在資料庫中被刪除
  4. Helper Methods:確保雙向關聯的一致性,避免資料不同步的問題

💡 為什麼需要 Helper Methods?
在雙向關聯中,我們需要確保兩邊的資料都保持同步。如果只設定一邊,可能會造成資料不一致的問題。

範例 1: 建立 Customer 與 AllowedDomain

現在,讓我們將理論付諸實踐!

步驟 1: 建立 AllowedDomain Entity

請建立 com.example.demo.entities.AllowedDomain.java 檔案,內容如上面的程式碼範例。

💡 專案結構提醒:確保檔案放在正確的套件路徑下

步驟 2: 更新 Customer Entity

請更新您原有的 Customer.java,加入 @OneToManyallowedDomains 集合,內容如上面的程式碼範例。

步驟 3: 建立 AllowedDomainRepository

建立資料存取層 (Data Access Layer):

package com.example.demo.repositories;

import com.example.demo.entities.AllowedDomain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;

@Repository
public interface AllowedDomainRepository extends JpaRepository<AllowedDomain, UUID> {
    // JpaRepository 已經提供了基本的 CRUD 操作
    // 如需要自訂查詢方法,可以在這裡添加
}

步驟 4: 測試新增功能

在您的應用程式啟動類別中加入 CommandLineRunner 來測試:

package com.example.demo;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import com.example.demo.enums.CustomerStatus;
import com.example.demo.repositories.CustomerRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    /**
     * 🧪 測試資料建立
     * CommandLineRunner 會在應用程式啟動後自動執行
     */
    @Bean
    public CommandLineRunner commandLineRunner(CustomerRepository customerRepository) {
        return args -> {
            // 建立一個新客戶
            Customer customer = new Customer();
            customer.setName("Gogoro Taiwan");
            customer.setEmail("contact@gogoro.com");
            customer.setStatus(CustomerStatus.ACTIVE);

            // 建立允許的網域,並加入到客戶中
            AllowedDomain domain1 = new AllowedDomain();
            domain1.setDomainName("gogoro.com");
            customer.addDomain(domain1); // 🔧 使用 helper method 來確保雙向關聯同步

            AllowedDomain domain2 = new AllowedDomain();
            domain2.setDomainName("gogoro.tw");
            customer.addDomain(domain2);

            // 儲存客戶
            // 由於設定了 CascadeType.ALL,JPA 會自動一併儲存兩個 AllowedDomain
            customerRepository.save(customer);
            
            System.out.println("客戶與允許的網域已成功儲存!");
            System.out.println("客戶名稱: " + customer.getName());
            System.out.println("網域數量: " + customer.getAllowedDomains().size());
        };
    }
}

測試結果

啟動應用程式後,您會看到:

  1. customersallowed_domains 兩張資料表被自動建立
  2. 測試資料已成功寫入資料庫
  3. 外鍵約束 (Foreign Key Constraint) 自動建立

🔍 檢查方式:可以透過資料庫管理工具查看資料表結構和資料

建立三層關聯:Customer => Order => OrderItem

真實世界的關聯往往是多層的。讓我們擴充專案,模擬一個電商客戶下訂單的場景。

業務邏輯分析

想像一個線上購物系統:

  • 👤 一個 客戶 (Customer) 可以有多個 📋 訂單 (Order)
  • 📋 一個 訂單 (Order) 可以有多個 📦 訂單品項 (OrderItem)

關聯結構圖

Customer (客戶)
    ↓ 一對多
Order (訂單)
    ↓ 一對多  
OrderItem (訂單品項)

資料表關係:

customers          orders              order_items
┌─────────┐       ┌─────────────┐      ┌─────────────┐
│ id (PK) │←─────│ customer_id │      │ order_id    │
│ name    │       │ id (PK)     │←────│ product_name│
│ email   │       │ order_date  │      │ quantity    │
│ status  │       │ total_amount│      │ price       │
└─────────┘       └─────────────┘      │ id (PK)     │
                                       └─────────────┘

程式碼實作

Order.java (訂單實體)

package com.example.demo.entities;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false)
    private LocalDateTime orderDate;

    @Column(name = "total_amount", precision = 10, scale = 2)
    private BigDecimal totalAmount;

    /**
     * 多對一關聯 - Order 與 Customer
     * 擁有方:Order 擁有 customer_id 外鍵
     * 
     * 一個訂單只能屬於一個客戶,但一個客戶可以有多個訂單
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    /**
     * 一對多關聯 - Order 與 OrderItem  
     * 反向方:OrderItem 擁有 order_id 外鍵
     * 
     * 一個訂單可以有多個品項
     */
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<OrderItem> items = new HashSet<>();

    // 建構子
    public Order() {
        this.orderDate = LocalDateTime.now();
        this.totalAmount = BigDecimal.ZERO;
    }

    // Helper methods 輔助方法
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
        calculateTotalAmount(); // 重新計算總金額
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
        calculateTotalAmount(); // 重新計算總金額
    }

    /**
     * 計算訂單總金額
     * 根據所有訂單品項的小計來計算
     */
    public void calculateTotalAmount() {
        this.totalAmount = items.stream()
                .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    // Getters and Setters...
}

OrderItem.java (訂單品項實體)

package com.example.demo.entities;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;

@Entity
@Table(name = "order_items")
public class OrderItem {
    
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "product_name", nullable = false)
    private String productName;

    @Column(nullable = false)
    private int quantity;

    @Column(precision = 10, scale = 2, nullable = false)
    private BigDecimal price;
    
    /**
     * 多對一關聯 - OrderItem 與 Order
     * 擁有方:OrderItem 擁有 order_id 外鍵
     * 
     * 一個訂單品項只能屬於一個訂單
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    // 建構子
    public OrderItem() {
    }

    public OrderItem(String productName, int quantity, BigDecimal price) {
        this.productName = productName;
        this.quantity = quantity;
        this.price = price;
    }

    /**
     * 計算此品項的小計
     * @return 價格 × 數量
     */
    public BigDecimal getSubtotal() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }

    // Getters and Setters...
}

完整的關聯鏈

透過這種方式,我們就建立了一個清晰的三層結構:

// 從客戶查詢所有訂單
Customer customer = customerRepository.findById(customerId).orElse(null);
Set<Order> customerOrders = customer.getOrders(); // 取得客戶的所有訂單

// 從訂單查詢所有品項  
Order order = orderRepository.findById(orderId).orElse(null);
Set<OrderItem> orderItems = order.getItems(); // 取得訂單的所有品項

// 一次性查詢:從客戶到訂單品項
customer.getOrders()                    // 取得所有訂單
        .stream()
        .flatMap(o -> o.getItems().stream()) // 取得所有訂單的所有品項
        .forEach(System.out::println);       // 印出每個品項

重點說明

  1. 層級關聯:Customer → Order → OrderItem 形成了一個清晰的階層
  2. 雙向關聯:每個關聯都是雙向的,可以從任一方查詢到另一方
  3. 級聯操作:儲存 Customer 時,相關的 Order 和 OrderItem 都會自動儲存
  4. 資料完整性:透過外鍵約束確保資料的一致性

💡 實務應用:這種設計在電商、訂單管理、庫存系統中非常常見!

其他關聯類型簡介

一對一 (@OneToOne) 關聯

🎯 應用情境:一個 User (使用者) 只能有一個 UserProfile (使用者檔案)。

這與一對多的實作非常相似,通常也是使用外鍵策略 (Foreign Key Strategy)

User.java (反向方 - Inverse Side)

@Entity
public class User {
    @Id
    private Long id;
    
    private String username;
    
    /**
     * 一對一關聯 - User 與 UserProfile
     * 反向方:UserProfile 擁有 user_id 外鍵
     */
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
    private UserProfile userProfile;
    
    // Getters and Setters...
}

UserProfile.java (擁有方 - Owning Side)

@Entity
public class UserProfile {
    @Id
    private Long id;
    
    private String firstName;
    private String lastName;
    private String phoneNumber;
    
    /**
     * 一對一關聯 - UserProfile 與 User
     * 擁有方:UserProfile 擁有 user_id 外鍵
     */
    @OneToOne
    @JoinColumn(name = "user_id")
    private User user;
    
    // Getters and Setters...
}

多對多 (@ManyToMany) 關聯 - 最佳實踐

🎯 應用情境:一個 Product (產品) 可以有多個 Tag (標籤),一個 Tag 也可以被用於多個 Product

❌ 不推薦的做法

雖然 JPA 提供了 @ManyToMany 註解,但它會隱藏中介資料表 (Join Table)

// ❌ 不建議使用,因為無法擴充
@Entity
public class Product {
    @ManyToMany
    @JoinTable(
        name = "product_tags",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();
}

❌ 問題

  • 無法為關聯增加額外屬性 (例如:標籤是誰加的、什麼時候加的)
  • 難以擴充和維護
  • 查詢複雜度較高

✅ 推薦的做法:手動拆解

業界最佳實踐是:將多對多關係,手動拆解成兩個「一對多」關係。

  1. 建立一個中介實體 (Junction Entity),例如 ProductTag
  2. ProductTagProduct 建立多對一關聯
  3. ProductTagTag 建立多對一關聯

Product.java

@Entity
public class Product {
    @Id
    private Long id;
    private String name;
    
    // 一對多:Product → ProductTag
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private Set<ProductTag> productTags = new HashSet<>();
}

Tag.java

@Entity  
public class Tag {
    @Id
    private Long id;
    private String name;
    
    // 一對多:Tag → ProductTag
    @OneToMany(mappedBy = "tag", cascade = CascadeType.ALL)
    private Set<ProductTag> productTags = new HashSet<>();
}

中介實體:ProductTag.java
```java
@Entity
@Table(name = "product_tags")
public class ProductTag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 多對一:ProductTag → Product (擁有方)
    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;

    // 多對一:ProductTag → Tag (擁有方)
    @ManyToOne
    @JoinColumn(name = "tag_id")
    private Tag tag;

    // 額外屬性 - 這就是使用中介實體的優勢!
    @Column(name = "tagged_at")
    private LocalDateTime taggedAt;
    
    @Column(name = "tagged_by")
    private String taggedBy;
    
    // 建構子
    public ProductTag(Product product, Tag tag, String taggedBy) {
        this.product = product;
        this.tag = tag;
        this.taggedBy = taggedBy;
        this.taggedAt = LocalDateTime.now();
    }
}

✅ 優勢

  1. 可擴充性:可以隨時新增額外屬性
  2. 清晰度:資料模型更清楚,易於理解
  3. 查詢彈性:可以輕鬆查詢關聯的詳細資訊
  4. 維護性:未來修改和維護更容易

💡 記住:在企業級應用中,幾乎所有的多對多關係最終都會需要額外的屬性,所以一開始就使用中介實體是明智的選擇!

深入 PostgreSQL:外鍵與資料一致性

外鍵約束 (Foreign Key Constraint) 如何運作?

當我們使用 @JoinColumn 時,JPA (Java Persistence API) 透過 Hibernate 會在產生 DDL (Data Definition Language) 語法時,為 PostgreSQL 資料庫加上外鍵約束 (Foreign Key Constraint)

實際範例

以我們的 allowed_domainscustomers 資料表為例:

-- customers 資料表
CREATE TABLE customers (
    id UUID PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    status VARCHAR(20) NOT NULL
);

-- allowed_domains 資料表
CREATE TABLE allowed_domains (
    id UUID PRIMARY KEY,
    domain_name VARCHAR(255) NOT NULL,
    customer_id UUID NOT NULL,
    
    -- 外鍵約束
    CONSTRAINT fk_allowed_domains_customer 
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

這個約束會建立一個從 allowed_domains.customer_id 指向 customers.id 的外鍵關係。

資料庫層級的保護機制

這個約束由資料庫層級來強制執行,帶來了以下重要好處:

1. 參照完整性 (Referential Integrity)

PostgreSQL 會確保 allowed_domains.customer_id 的值,必須customers 資料表中一個真實存在的 id

-- ✅ 這個操作會成功 (假設 customer_id 存在)
INSERT INTO allowed_domains (id, domain_name, customer_id) 
VALUES ('550e8400-e29b-41d4-a716-446655440000', 'example.com', '已存在的客戶ID');

-- ❌ 這個操作會失敗
INSERT INTO allowed_domains (id, domain_name, customer_id) 
VALUES ('550e8400-e29b-41d4-a716-446655440001', 'fake.com', '不存在的客戶ID');
-- 錯誤: 違反外鍵約束 "fk_allowed_domains_customer"

好處:從根本上杜絕了「孤兒資料 (Orphaned Data)」的產生!

2. 資料一致性 (Data Consistency)

如果有人試圖直接在資料庫刪除一個底下還有訂單的客戶,PostgreSQL 會拒絕這個操作:

-- ❌ 這個操作會失敗 (如果該客戶有相關的 allowed_domains)
DELETE FROM customers WHERE id = '有相關資料的客戶ID';
-- 錯誤: 違反外鍵約束,無法刪除被參照的資料

好處:有效保護您的資料,避免因為誤操作而導致資料不一致!

應用程式層級 vs 資料庫層級

應用程式層級:JPA 的 CascadeType

@OneToMany(
    mappedBy = "customer",
    cascade = CascadeType.ALL,  // 🔧 應用程式層級的級聯操作
    orphanRemoval = true
)
private Set<AllowedDomain> allowedDomains = new HashSet<>();
  • 作用範圍:只在透過 JPA 操作時生效
  • 功能:告訴 JPA 在執行某個操作時,要連帶執行哪些其他操作
  • 例如:儲存 Customer 時,自動儲存相關的 AllowedDomain

資料庫層級:PostgreSQL 的外鍵約束

CONSTRAINT fk_allowed_domains_customer 
FOREIGN KEY (customer_id) REFERENCES customers(id)
  • 作用範圍:所有對資料庫的操作 (無論來源)
  • 功能:確保資料的參照完整性和一致性
  • 例如:防止插入不存在客戶的網域資料

雙重保護的重要性

應用程式層級 (JPA)
        ↓
最後防線 (PostgreSQL 外鍵約束)  
        ↓
資料庫

這種雙重保護機制確保了:

  1. 開發便利性:透過 JPA 的級聯操作,開發更加便利
  2. 資料安全性:透過資料庫約束,確保資料完整性
  3. 錯誤預防:即使程式有 bug,資料庫層級也會阻止不當操作

💡 最佳實踐:永遠同時使用 JPA 的級聯操作和資料庫的外鍵約束,這樣可以獲得最好的開發體驗和資料安全性!

範例 2: 撰寫完整的 API

最後,我們來完成「查詢某客戶的所有 AllowedDomain」的 REST API

步驟 1: 在 CustomerRepository 新增查詢方法

雖然我們可以透過 findById 拿到 Customer 再 getAllowedDomains,但直接查詢能更精確地表達意圖。

package com.example.demo.repositories;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Set;
import java.util.UUID;

@Repository
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
    
    /**
     * 使用 JPQL 查詢特定客戶的所有允許網域
     * 
     * @param customerId 客戶 ID
     * @return 該客戶的所有允許網域
     */
    @Query("SELECT c.allowedDomains FROM Customer c WHERE c.id = :customerId")
    Set<AllowedDomain> findAllowedDomainsByCustomerId(@Param("customerId") UUID customerId);
}

💡 JPQL 說明:這是 Java Persistence Query Language,是 JPA 的查詢語言,語法類似 SQL 但操作的是實體物件而不是資料表。

步驟 2: 建立 Service 層

package com.example.demo.services;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import com.example.demo.repositories.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Optional;
import java.util.Set;
import java.util.UUID;

/**
 * Service 層:業務邏輯處理
 * 負責處理複雜的業務邏輯,並協調多個 Repository
 */
@Service
public class CustomerService {
    
    @Autowired
    private CustomerRepository customerRepository;

    /**
     * 取得特定客戶的所有允許網域
     * 
     * @param customerId 客戶 ID
     * @return 該客戶的所有允許網域
     */
    public Set<AllowedDomain> getDomainsForCustomer(UUID customerId) {
        return customerRepository.findAllowedDomainsByCustomerId(customerId);
    }

    /**
     * 根據 ID 查詢客戶
     * 
     * @param customerId 客戶 ID
     * @return 客戶資料 (如果存在)
     */
    public Optional<Customer> findCustomerById(UUID customerId) {
        return customerRepository.findById(customerId);
    }

    /**
     * 儲存客戶
     * 
     * @param customer 要儲存的客戶
     * @return 儲存後的客戶
     */
    public Customer saveCustomer(Customer customer) {
        return customerRepository.save(customer);
    }
}

步驟 3: 建立 Controller 層

package com.example.demo.controllers;

import com.example.demo.entities.AllowedDomain;
import com.example.demo.entities.Customer;
import com.example.demo.services.CustomerService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Optional;
import java.util.Set;
import java.util.UUID;

/**
 * Controller 層:API 端點
 * 負責處理 HTTP 請求和回應
 */
@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    @Autowired
    private CustomerService customerService;

    /**
     * 取得特定客戶的所有允許網域
     * 
     * GET /api/customers/{customerId}/domains
     * 
     * @param customerId 客戶 ID (從 URL 路徑中取得)
     * @return 該客戶的所有允許網域
     */
    @GetMapping("/{customerId}/domains")
    public ResponseEntity<Set<AllowedDomain>> getCustomerDomains(@PathVariable UUID customerId) {
        // 先檢查客戶是否存在
        Optional<Customer> customer = customerService.findCustomerById(customerId);
        if (customer.isEmpty()) {
            return ResponseEntity.notFound().build(); // 404 Not Found
        }

        // 取得客戶的允許網域
        Set<AllowedDomain> domains = customerService.getDomainsForCustomer(customerId);
        
        if (domains.isEmpty()) {
            return ResponseEntity.noContent().build(); // 204 No Content
        }
        
        return ResponseEntity.ok(domains); // 200 OK
    }

    /**
     * 根據 ID 取得客戶資料
     * 
     * GET /api/customers/{customerId}
     * 
     * @param customerId 客戶 ID
     * @return 客戶資料
     */
    @GetMapping("/{customerId}")
    public ResponseEntity<Customer> getCustomer(@PathVariable UUID customerId) {
        Optional<Customer> customer = customerService.findCustomerById(customerId);
        
        return customer.map(ResponseEntity::ok)
                      .orElse(ResponseEntity.notFound().build());
    }
}

測試 API

現在,啟動您的應用程式,並使用以下方式測試:

1. 使用瀏覽器

http://localhost:8080/api/customers/{您儲存的客戶ID}/domains

2. 使用 Postman

GET http://localhost:8080/api/customers/{客戶ID}/domains
Accept: application/json

3. 使用 curl

curl -X GET "http://localhost:8080/api/customers/{客戶ID}/domains" \
     -H "Accept: application/json"

預期結果

您應該會看到類似以下的 JSON 回應:

[
    {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "domainName": "gogoro.com"
    },
    {
        "id": "550e8400-e29b-41d4-a716-446655440001", 
        "domainName": "gogoro.tw"
    }
]

恭喜! 現在已經成功建立了一個包含完整關聯對映的 Spring Data JPA 應用程式,並提供了對應的 REST API!

總結

核心概念回顧

  1. 實體關聯 (Entity Relationship):描述資料表之間的連接關係
  2. 擁有方 vs 反向方
    • 擁有方 (Owning Side):有外鍵,用 @JoinColumn
    • 反向方 (Inverse Side):沒外鍵,用 mappedBy
  3. 關聯類型
    • @OneToOne:一對一
    • @OneToMany / @ManyToOne:一對多 / 多對一
    • @ManyToMany:多對多 (建議拆解為兩個一對多)

重要註解 (Annotations)

註解 用途 範例
@ManyToOne 多對一關聯 (擁有方) AllowedDomain -> Customer
@OneToMany 一對多關聯 (反向方) Customer -> AllowedDomain
@JoinColumn 指定外鍵欄位 @JoinColumn(name = "customer_id")
mappedBy 指定關聯的屬性名 mappedBy = "customer"
CascadeType.ALL 級聯所有操作 儲存、更新、刪除一併執行
orphanRemoval 孤兒移除 從集合移除時自動刪除
FetchType.LAZY 延遲載入 只有在需要時才載入關聯資料

相關資料來源


上一篇
Day25 - Spring Data JPA Repository 實戰:從 CRUD 到進階查詢
下一篇
Day27 - Spring Data JPA 效能優化與生命週期管理:從批次處理到安全刪除
系列文
spring boot 3 學習筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言