Backend

99 Ngày Spring — Ngày 32: Specification & dynamic query

SSite Admin
28 tháng 08, 2026 9 phút đọc 0 lượt xem
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.

Sketchnote Ngày 32: Specification và dynamic query — bùng nổ tổ hợp derived query, mảnh Specification với root query cb, ghép and/or null-safe và các bẫy join count

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ọn2ⁿ 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 @Query vớ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ụ; Specification sinh 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 JpaSpecificationExecutor là 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.

  • Specification là functional interface một method toPredicate(root, query, cb) — viết bằng lambda: root đại diện entity (root.get("status") là cột), cbnhà 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ả null khi không áp dụng — and/or bỏ qua mảnh null, 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ận Sort, 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")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ệ: join trong spec để lọc thì ổn, nhưng đừng fetch trong spec — câu count của Page chạ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 Specification leo 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 JpaSpecificationExecutor vào TaskRepository của checkpoint Ngày 30, dựng TaskSpecs với 3 spec của bài và endpoint GET /api/tasks/search nhậ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ằng cb.and bên trong một spec — rồi tái dùng nó ở 2 chỗ: màn tìm kiếm và endpoint /overdue của bài tập Ngày 30.

  • Bật Static Metamodel (dependency hibernate-jpamodelgen), đổi một root.get("...") sang Task_ — 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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 32: List — ArrayList vs LinkedList

Mảng động nới rộng 1.5x và bí mật amortized O(1), vì sao 'LinkedList chèn giữa nhanh' là hiểu lầm, số đo thật — và duyệt/xóa an toàn với removeIf.

28 thg 8, 20269 phút0
99 Ngày Spring — Ngày 31: Pagination nâng cao

Hóa đơn count(*) sau mỗi Page, Slice với mẹo size+1, vì sao offset sâu vừa chậm vừa trôi dữ liệu — và keyset pagination bằng con trỏ (createdAt, id).

27 thg 8, 20269 phút8
99 Ngày Java — Ngày 31: Collections tổng quan

Cây phân cấp Collection/Map, lời hứa của List–Set–Queue–Map, cây quyết định 4 câu hỏi và bảng Big-O — la bàn hiệu năng cho 10 ngày Collections sắp tới.

27 thg 8, 20269 phút8