簡介
在實際應用中,實體之間存在著複雜的關係。了解如何映射關係、避免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 規範允許使用許多可選的過濾器參數來建立動態查詢
練習
- 建立 3 個實體:作者 (1-N) 圖書 (N-N) 標籤。用便捷的方法充分實現關係
- 寫一個查詢來取得所有有作者的書籍,無需 N+1(使用 @EntityGraph 或 JOIN FETCH)。透過日誌中的查詢數量進行驗證
- 實作具有 JPA 規範的書籍搜尋 API:按作者、標籤、價格範圍、標題中的關鍵字過濾。與分頁結合