99 Ngày Spring — Ngày 32: Specification & dynamic query
Ngày 23 ta khen derived query "đặt tên đúng là có query" — hôm nay gặp giới hạn của nó: màn hình tìm kiếm với nhiều bộ lọc tùy chọn, nơi số method cần viết bùng nổ theo lũy thừa của số bộ lọc. Lời giải của Spring Data JPA là Specification — biến mỗi điều kiện WHERE thành một mảnh LEGO độc lập, ghép tự do lúc chạy bằng and/or, chạy chung với Pageable của Ngày 31. Cuối bài: bốn bẫy thực chiến và ranh giới khi nào nên bước sang QueryDSL.

Vấn đề: bộ lọc tùy chọn và bùng nổ tổ hợp
// Màn hình tìm kiếm task: 4 bộ lọc TÙY CHỌN — status, từ khóa, hạn chót, người tạo
// Người dùng có thể điền BẤT KỲ tổ hợp nào. Derived query (Ngày 23) thì sao?
findByStatus(status)
findByStatusAndTieuDeContaining(status, q)
findByStatusAndTieuDeContainingAndHanChotBefore(status, q, truoc)
findByTieuDeContainingAndHanChotBefore(q, truoc)
// ... 4 bộ lọc tùy chọn = 2⁴ = 16 method. Thêm bộ lọc thứ 5? 32. BÙNG NỔ TỔ HỢP.
// Chống chế bằng một @Query "null thì bỏ qua":
@Query("""
select t from Task t
where (:status is null or t.status = :status)
and (:q is null or lower(t.tieuDe) like lower(concat('%', :q, '%')))
and (:truoc is null or t.hanChot < :truoc)
""")
List<Task> timKiem(TaskStatus status, String q, LocalDate truoc);
// Chạy được — nhưng: JPQL phình theo bộ lọc, plan khó tối ưu (mọi điều kiện
// luôn có mặt), null-check rải khắp, và KHÔNG tái sử dụng được từng mảnh.
// Điều ta thật sự muốn: mỗi bộ lọc là MỘT MẢNH độc lập, ghép tự do lúc chạy
// → đó chính xác là Specification.Toán học chống lại derived query: n bộ lọc tùy chọn là
2ⁿtổ hợp — 4 bộ lọc đã 16 method, và mỗi method mới lại dài hơn tên cũ; đây không phải lười viết, mà là sai công cụ cho bài toán.Mẹo
@Queryvới:param is null or ...là bước chống chế quen thuộc — chạy được nhưng JPQL phình một cục, mọi điều kiện luôn hiện diện làm planner khó tối ưu, và từng mảnh điều kiện không tái sử dụng được ở query khác.Phát biểu lại nhu cầu cho đúng: mỗi bộ lọc nên là một đơn vị độc lập — có tên, test được, ghép tự do lúc chạy — rồi mới đi tìm công cụ;
Specificationsinh ra chính xác cho phát biểu đó.
Specification — mỗi điều kiện một mảnh
// Bước 1 — repository "biết nhận" Specification:
public interface TaskRepository
extends JpaRepository<Task, Long>,
JpaSpecificationExecutor<Task> { } // thêm interface này là xong
// Bước 2 — mỗi bộ lọc = một Specification NHỎ, đặt trong lớp factory:
public final class TaskSpecs {
private TaskSpecs() { }
public static Specification<Task> coTrangThai(TaskStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Task> chuaTuKhoa(String q) {
return (root, query, cb) ->
cb.like(cb.lower(root.get("tieuDe")), "%" + q.toLowerCase() + "%");
}
public static Specification<Task> hanTruoc(LocalDate moc) {
return (root, query, cb) -> cb.lessThan(root.get("hanChot"), moc);
}
}
// Đọc bộ ba tham số một lần cho quen:
// root — "bàn" Task đang truy vấn: root.get("status") = cột status
// query — câu truy vấn đang dựng (ít dùng — distinct, orderBy đặc biệt)
// cb — CriteriaBuilder: nhà máy điều kiện (equal, like, lessThan, and, or...)
// Mỗi mảnh tự nói tên mình, test riêng được, và SẮP ghép được với nhau —
// đúng nghĩa "điều kiện WHERE như LEGO".Hạ tầng chỉ một dòng: repository extends thêm
JpaSpecificationExecutorlà có họfindAll(spec)/findAll(spec, pageable)/count(spec)— không đụng gì vào các method sẵn có của checkpoint Ngày 30.Specificationlà functional interface một methodtoPredicate(root, query, cb)— viết bằng lambda:rootđại diện entity (root.get("status")là cột),cblà nhà máy điều kiện (equal,like,lessThan...) — bộ ba này là Criteria API được thuần hóa cho dễ dùng.Đặt spec trong lớp factory tĩnh (
TaskSpecs) như bài: mỗi mảnh có tên nghiệp vụ tự tài liệu (coTrangThai,hanTruoc), tái dùng ở nhiều màn hình, và test đơn lẻ được từng mảnh.
Ghép lúc chạy — and, or và mẹo null-safe
// Bước 3 — GHÉP lúc chạy theo đúng bộ lọc người dùng gửi:
public Page<TaskResponse> timKiem(TaskStatus status, String q,
LocalDate truoc, Pageable pageable) {
Specification<Task> spec = Specification.where(null); // điểm xuất phát rỗng
if (status != null) spec = spec.and(TaskSpecs.coTrangThai(status));
if (q != null && !q.isBlank()) spec = spec.and(TaskSpecs.chuaTuKhoa(q));
if (truoc != null) spec = spec.and(TaskSpecs.hanTruoc(truoc));
return repo.findAll(spec, pageable).map(TaskMapper::toResponse);
}
// 3 bộ lọc, 7 tổ hợp — MỘT method. Thêm bộ lọc mới = 1 spec + 1 dòng if.
// SQL sinh ra CHỈ chứa điều kiện thật sự có mặt (soi log Ngày 27):
// status + q → where status = ? and lower(tieu_de) like ?
// chỉ truoc → where han_chot < ?
// không gì cả → không có WHERE — trả tất (có phân trang đỡ lưng)
// or() và not() cũng có mặt:
var canChuY = TaskSpecs.hanTruoc(LocalDate.now().plusDays(3))
.or(TaskSpecs.coTrangThai(TaskStatus.TODO));
// Mẹo null-safe gọn hơn nữa: spec tự trả null khi không áp dụng —
// and/or BỎ QUA spec null, khỏi cần if bên ngoài:
public static Specification<Task> chuaTuKhoa(String q) {
return (q == null || q.isBlank())
? null // null = "bỏ qua mảnh này"
: (root, query, cb) -> cb.like(cb.lower(root.get("tieuDe")),
"%" + q.toLowerCase() + "%");
}
// → spec = Specification.where(coTrangThai(status)).and(chuaTuKhoa(q)).and(hanTruoc(truoc));
// một dòng — mảnh nào null tự biến mất. Kết hợp Pageable + Sort chạy bình thường.Sức mạnh nằm ở phép nhân: 3 bộ lọc 7 tổ hợp — một method; SQL sinh ra chỉ chứa điều kiện thật sự có mặt (soi log mà xem — Ngày 27), thêm bộ lọc mới là một spec + một dòng.
Mẹo đáng giá nhất bài: spec tự trả
nullkhi không áp dụng —and/orbỏ qua mảnhnull, chuỗi ghép thành một dòng phẳng không if nào; logic "khi nào áp dụng" sống ngay trong spec thay vì rải ở nơi gọi.Mọi thứ của Ngày 18 + 31 chạy chung:
findAll(spec, pageable)nhậnSort, trảPage(kèm câu count cùng spec) — bộ lọc động + phân trang + sắp xếp là bộ ba chuẩn của mọi màn hình danh sách.
Bốn bẫy thực chiến
// Bốn bẫy phải biết trước khi ship
// 1. root.get("tieuDe") — CHUỖI: đổi tên field là vỡ LÚC CHẠY, compiler im lặng
// Giảm rủi ro: hằng số tên field, test tích hợp phủ mọi spec
// Giải triệt để: JPA Static Metamodel (sinh lớp Task_) → root.get(Task_.tieuDe)
root.get(Task_.tieuDe); // ✅ compile-safe — sai tên là lỗi biên dịch
// 2. Join thì DỄ — nhưng cẩn thận count query của Page:
public static Specification<Task> cuaNguoi(String username) {
return (root, query, cb) ->
cb.equal(root.join("nguoiTao").get("username"), username); // JOIN nguoi_tao
}
// findAll(spec, pageable) chạy CẢ câu count với cùng spec —
// join + distinct trong count từng là ổ bug: giữ spec chỉ-lọc, đừng fetch trong spec.
// 3. Muốn tránh N+1 (Ngày 27)? KHÔNG fetch join trong Specification —
// dùng @EntityGraph đặt ở method repository:
@EntityGraph(attributePaths = "nguoiTao")
Page<Task> findAll(Specification<Task> spec, Pageable pageable);
// 4. Specification là API của Spring Data JPA — service dùng nó vẫn ổn
// (trừu tượng của tầng data), nhưng CONTROLLER thì không: controller gửi
// tham số thô, service dịch sang spec — đúng phân tầng Ngày 30.Bẫy lớn nhất:
root.get("tieuDe")là chuỗi — refactor đổi tên field xong compiler vẫn xanh, chạy mới vỡ; phòng bằng test phủ spec, chữa triệt để bằng Static Metamodel (Task_.tieuDe— sai tên là lỗi biên dịch).Với quan hệ:
jointrong spec để lọc thì ổn, nhưng đừng fetch trong spec — câucountcủaPagechạy cùng spec và fetch-join trong count là ổ lỗi kinh điển; cần nạp quan hệ để tránh N+1 (Ngày 27) thì dùng@EntityGraphđặt ở method.Giữ phân tầng Ngày 30: controller gửi tham số thô, service dịch sang spec — đừng để kiểu
Specificationleo lên tầng web; và nhớ spec chỉ là một cách sinh WHERE: index vẫn quyết định nhanh chậm như mọi query khác.
Khi nào cần QueryDSL?
Specificationđủ cho đa số: bộ lọc động vừa phải, đội đã quen Spring Data, không muốn thêm bước sinh code.QueryDSL đáng giá khi: query phức tạp nhiều join/subquery/group by, muốn type-safe toàn phần qua Q-class sinh sẵn (
QTask.task.tieuDe.contains(q)), codebase query động rất lớn cần refactor an toàn.Cái giá của QueryDSL: thêm bước annotation processing vào build (từng gập ghềnh giữa các bản Java/Gradle) — với dự án của series,
Specification+ Metamodel là điểm cân bằng; biết ranh giới để nâng cấp có chủ đích, không nâng cấp theo trào lưu.Dù chọn gì: nguyên tắc không đổi — mảnh điều kiện có tên, độc lập, test được; công cụ chỉ là cú pháp.
Bài tập nhỏ
Thêm
JpaSpecificationExecutorvàoTaskRepositorycủa checkpoint Ngày 30, dựngTaskSpecsvới 3 spec của bài và endpointGET /api/tasks/searchnhận 3 tham số tùy chọn.Soi SQL log với 4 tổ hợp bộ lọc khác nhau — xác nhận WHERE chỉ chứa điều kiện có mặt.
Viết bản null-safe cho cả 3 spec rồi rút chuỗi ghép về một dòng không if.
Thêm spec
quaHan()(hanChot < hôm nay và status != DONE) bằngcb.andbên trong một spec — rồi tái dùng nó ở 2 chỗ: màn tìm kiếm và endpoint/overduecủa bài tập Ngày 30.Bật Static Metamodel (dependency
hibernate-jpamodelgen), đổi mộtroot.get("...")sangTask_— rồi thử đổi tên field để thấy lỗi bắt được lúc biên dịch.
Kết luận
Bốn ý gói bài: bộ lọc tùy chọn làm derived query bùng nổ 2ⁿ — dấu hiệu đổi công cụ; Specification biến mỗi điều kiện thành mảnh có tên, test được qua bộ ba root/query/cb, hạ tầng chỉ là một interface thêm vào repository; ghép bằng and/or với mẹo null = bỏ qua cho chuỗi phẳng, chạy chung Pageable; và bốn bẫy — chuỗi tên field (Metamodel), fetch trong spec (dùng @EntityGraph), count với join, spec leo lên controller. Ngày 33 ta tối ưu chiều ngược lại — bớt cột thay vì bớt dòng: Projection — interface/class projection và vì sao "select cả entity" là lãng phí thầm lặng. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


