Chuyển đến nội dung chính

第 8 課:實體關係、分頁與規範

@OneToMany、@ManyToOne、@ManyToMany、@OneToOne。取得策略(LAZY 與 EAGER),N+1 問題。可分頁、排序​​、切片。動態查詢的 JPA 規範。

💻 程式設計 — 第 7 課 第 8 課:實體關係、分頁和 規格

Spring Boot 4:從基礎到高級

第 2 部分:建立 REST API

亞洲開發網

簡介

在實際應用中,實體之間存在著複雜的關係。了解如何映射關係、避免N+1問題以及實現分頁是後端開發人員所需的技能。本文深入探討 JPA 關係和動態查詢。


1. 實體關係

1.1 @ManyToOne 和 @OneToMany

// Category (1) ←→ (N) Product
@Entity
@Table(name = "categories")
public class Category extends BaseEntity {

    @Column(nullable = false, unique = true)
    private String name;

    private String slug;

    @OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
    private List<Product> products = new ArrayList<>();

    // Convenience method
    public void addProduct(Product product) {
        products.add(product);
        product.setCategory(this);
    }
}

@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    private String name;
    private BigDecimal price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
}

1.2 @ManyToMany

// Product (N) ←→ (N) Tag
@Entity
@Table(name = "products")
public class Product extends BaseEntity {

    // ...other fields

    @ManyToMany
    @JoinTable(
        name = "product_tags",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<Tag> tags = new HashSet<>();

    public void addTag(Tag tag) {
        tags.add(tag);
        tag.getProducts().add(this);
    }

    public void removeTag(Tag tag) {
        tags.remove(tag);
        tag.getProducts().remove(this);
    }
}

@Entity
@Table(name = "tags")
public class Tag extends BaseEntity {

    @Column(nullable = false, unique = true)
    private String name;

    @ManyToMany(mappedBy = "tags")
    private Set<Product> products = new HashSet<>();
}

1.3 @OneToOne

@Entity
@Table(name = "users")
public class User extends BaseEntity {
    private String name;
    private String email;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
              fetch = FetchType.LAZY, optional = false)
    private UserProfile profile;
}

@Entity
@Table(name = "user_profiles")
public class UserProfile extends BaseEntity {
    private String bio;
    private String avatarUrl;
    private String phone;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", unique = true)
    private User user;
}

2. 取得策略與N+1問題

2.1 懶惰與渴望

// LAZY (Default cho @OneToMany, @ManyToMany)
// → Query phụ chỉ chạy khi access field
@OneToMany(fetch = FetchType.LAZY)
private List<Product> products;

// EAGER (Default cho @ManyToOne, @OneToOne)
// → Luôn JOIN khi query entity
@ManyToOne(fetch = FetchType.EAGER)
private Category category;

最佳實務:永遠使用 FetchType.LAZY 對於所有關係,然後根據需要使用 JOIN FETCH 進行取得。

2.2 N+1問題

// ⚠️ N+1 Problem
List<Product> products = productRepository.findAll();
// Query 1: SELECT * FROM products → 100 products
for (Product p : products) {
    System.out.println(p.getCategory().getName());
    // Query 2..101: SELECT * FROM categories WHERE id = ?
}
// Total: 1 + 100 = 101 queries! 💀

2.3 N+1解決方案

// Solution 1: JOIN FETCH
@Query("SELECT p FROM Product p JOIN FETCH p.category")
List<Product> findAllWithCategory();

// Solution 2: @EntityGraph
@EntityGraph(attributePaths = {"category", "tags"})
List<Product> findAll();

// Solution 3: Batch fetching
@Entity
public class Category {
    @OneToMany(mappedBy = "category")
    @BatchSize(size = 20) // Fetch 20 categories per query
    private List<Product> products;
}

// Solution 4: DTO Projection (tốt nhất cho read-only)
@Query("""
    SELECT new com.example.dto.ProductWithCategory(
        p.id, p.name, p.price, c.name)
    FROM Product p JOIN p.category c
    """)
List<ProductWithCategory> findAllProjected();

3. 分頁與排序

3.1 在儲存庫中可分頁

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Tự động hỗ trợ pagination
    Page<Product> findByActiveTrue(Pageable pageable);

    Page<Product> findByCategorySlug(String slug, Pageable pageable);

    @Query("SELECT p FROM Product p WHERE p.price > :minPrice")
    Page<Product> findExpensive(@Param("minPrice") BigDecimal minPrice,
                                 Pageable pageable);
}

3.2 有分頁的控制器

@GetMapping
public ResponseEntity<Page<ProductResponse>> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @RequestParam(defaultValue = "desc") String sortDir) {

    Sort sort = sortDir.equalsIgnoreCase("asc")
        ? Sort.by(sortBy).ascending()
        : Sort.by(sortBy).descending();

    Pageable pageable = PageRequest.of(page, size, sort);
    Page<Product> products = productRepository.findByActiveTrue(pageable);

    Page<ProductResponse> response = products.map(ProductResponse::from);
    return ResponseEntity.ok(response);
}

3.3 頁面回應

{
    "content": [
        {"id": 1, "name": "Product A", "price": 100},
        {"id": 2, "name": "Product B", "price": 200}
    ],
    "pageable": {
        "pageNumber": 0,
        "pageSize": 20,
        "sort": {"sorted": true, "direction": "DESC"}
    },
    "totalElements": 150,
    "totalPages": 8,
    "first": true,
    "last": false,
    "numberOfElements": 20
}

3.4 切片與頁面

// Page: Chạy COUNT query → biết total elements (tốn performance)
Page<Product> findByActiveTrue(Pageable pageable);

// Slice: Không chạy COUNT → chỉ biết hasNext (tốt cho infinite scroll)
Slice<Product> findByActiveTrue(Pageable pageable);

4. JPA 規格 — 動態查詢

4.1 我們什麼時候需要規範?

當API有多個過濾參數可選時:

GET /api/products?category=electronics&minPrice=100&maxPrice=500&status=ACTIVE&keyword=phone

為每個組合編寫派生查詢方法是不可行的。規範允許動態建構查詢。

4.2 實施

// Repository extends JpaSpecificationExecutor
public interface ProductRepository extends
        JpaRepository<Product, Long>,
        JpaSpecificationExecutor<Product> {
}

// Specification builder
public class ProductSpecification {

    public static Specification<Product> hasCategory(String categorySlug) {
        return (root, query, cb) -> {
            if (categorySlug == null) return null;
            Join<Product, Category> category = root.join("category");
            return cb.equal(category.get("slug"), categorySlug);
        };
    }

    public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return null;
            if (min != null && max != null) {
                return cb.between(root.get("price"), min, max);
            }
            if (min != null) {
                return cb.greaterThanOrEqualTo(root.get("price"), min);
            }
            return cb.lessThanOrEqualTo(root.get("price"), max);
        };
    }

    public static Specification<Product> hasStatus(ProductStatus status) {
        return (root, query, cb) -> {
            if (status == null) return null;
            return cb.equal(root.get("status"), status);
        };
    }

    public static Specification<Product> nameContains(String keyword) {
        return (root, query, cb) -> {
            if (keyword == null || keyword.isBlank()) return null;
            return cb.like(
                cb.lower(root.get("name")),
                "%" + keyword.toLowerCase() + "%"
            );
        };
    }
}

4.3 在服務中使用

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public Page<ProductResponse> search(
            String category, BigDecimal minPrice, BigDecimal maxPrice,
            ProductStatus status, String keyword, Pageable pageable) {

        Specification<Product> spec = Specification
            .where(ProductSpecification.hasCategory(category))
            .and(ProductSpecification.priceBetween(minPrice, maxPrice))
            .and(ProductSpecification.hasStatus(status))
            .and(ProductSpecification.nameContains(keyword));

        return productRepository.findAll(spec, pageable)
            .map(ProductResponse::from);
    }
}

4.4 控制器

@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(required = false) BigDecimal maxPrice,
        @RequestParam(required = false) ProductStatus status,
        @RequestParam(required = false) String keyword,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {

    Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
    Page<ProductResponse> results = productService.search(
        category, minPrice, maxPrice, status, keyword, pageable);

    return ResponseEntity.ok(results);
}

5. 級聯與孤兒移除

@Entity
public class Order extends BaseEntity {

    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,    // Cascade tất cả operations
               orphanRemoval = true)          // Xóa item khi remove khỏi list
    private List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
        // orphanRemoval = true → item bị DELETE khỏi DB
    }
}

總結

  • JPA 關係(@OneToMany、@ManyToOne、@ManyToMany)映射實體之間的關係,並始終使用 FetchType.LAZY
  • 透過 JOIN FETCH、@EntityGraph、@BatchSize 或 DTO 投影解決 N+1 問題
  • Spring Data Pagination(Page、Slice、Pageable)支援自動分頁和排序
  • JPA 規範允許使用許多可選的過濾器參數來建立動態查詢

練習

  1. 建立 3 個實體:作者 (1-N) 圖書 (N-N) 標籤。用便捷的方法充分實現關係
  2. 寫一個查詢來取得所有有作者的書籍,無需 N+1(使用 @EntityGraph 或 JOIN FETCH)。透過日誌中的查詢數量進行驗證
  3. 實作具有 JPA 規範的書籍搜尋 API:按作者、標籤、價格範圍、標題中的關鍵字過濾。與分頁結合