Spring REST API & Data JPA
32/46
Bài 32 / 46~12 phútRepository & QueriesLộ trình · chặng 16/21Miễn phí… lượt xem

Specification và JPA Auditing: Truy Vấn Động và Nhật Ký

Xây dựng dynamic filter với Specification Criteria API và tự động ghi nhận thời gian createdAt, updatedAt qua cơ chế JPA Auditing.

TL;DR: Specification<T> bọc quanh JPA Criteria API cho phép kết hợp các điều kiện lọc động (and/or) tại runtime mà không làm bùng nổ số lượng method repository. Trong khi đó, JPA Auditing tự động ghi nhận createdAt, updatedAt, createdBy bằng cách can thiệp vào lifecycle callbacks (@PrePersist, @PreUpdate) thông qua AuditingEntityListener.

Vì sao bộ lọc động không thể viết bằng Derived Query?

Giả sử màn hình quản trị cung cấp 5 tiêu chí lọc tùy chọn (optional): status, createdAfter, createdBefore, minPriority, keyword. Người dùng có thể nhập bất kỳ trường nào hoặc bỏ trống:

  • Nếu dùng Derived Query, số lượng method cần viết theo tổ hợp là $2^5 = 32$ method — hoàn toàn bất khả thi.
  • Nếu dùng @Query JPQL tĩnh, chuỗi truy vấn không thể linh hoạt tự gọt bớt các mệnh đề AND field = :param khi tham số truyền vào bằng null.

Giải pháp: Sử dụng Specification<T> để dựng cây điều kiện tại thời điểm runtime.

Interface Specification hoạt động như thế nào?

Specification<T> là một functional interface chuẩn của Spring Data JPA:

Java
@FunctionalInterface
public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
}

Ba tham số nòng cốt:

  1. Root<T> root: Đại diện cho Entity gốc, dùng để trỏ tới các thuộc tính (root.get("status")).
  2. CriteriaQuery<?> query: Đại diện cho toàn bộ câu lệnh (hỗ trợ DISTINCT hoặc subquery).
  3. CriteriaBuilder cb: Nhà máy sản xuất điều kiện Predicate (cb.equal(), cb.like(), cb.greaterThan()).

Khi tham số đầu vào bằng null, hàm trả về null. Phương thức and() nội bộ của Spring Data sẽ tự động bỏ qua các nhánh null này:

Ba filter đi vào ba factory method: status và createdAfter trả về Predicate, còn nameContains null thì trả về null; and bỏ qua operand null nên WHERE cuối cùng chỉ còn hai mệnh đề, không có mệnh đề nào cho name

Triển khai Specification trong thực tế ra sao?

Bước 1: Mở rộng JpaSpecificationExecutor trong Repository

Java
public interface ProjectRepository extends JpaRepository<Project, Long>,
                                           JpaSpecificationExecutor<Project> {
    // Ke thua findAll(Specification, Pageable)
}

Bước 2: Tạo lớp Factory chứa các Specification tái sử dụng

Java
public class ProjectSpecs {
    public static Specification<Project> hasStatus(ProjectStatus status) {
        return status == null ? null : (root, q, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<Project> createdAfter(Instant from) {
        return from == null ? null : (root, q, cb) -> cb.greaterThan(root.get("createdAt"), from);
    }

    public static Specification<Project> nameContains(String fragment) {
        return fragment == null ? null : (root, q, cb) ->
            cb.like(cb.lower(root.get("name")), "%" + fragment.toLowerCase() + "%");
    }
}

Bước 3: Kết hợp các điều kiện tại tầng Service

Java
@Service
@RequiredArgsConstructor
public class ProjectService {
    private final ProjectRepository repo;

    public Page<Project> search(ProjectFilter filter, Pageable pageable) {
        Specification<Project> spec = Specification.where(ProjectSpecs.hasStatus(filter.status()))
            .and(ProjectSpecs.createdAfter(filter.createdAfter()))
            .and(ProjectSpecs.nameContains(filter.nameContains()));

        return repo.findAll(spec, pageable);
    }
}

Câu lệnh SQL phát sinh sẽ tự động co giãn: chỉ những trường có giá trị mới xuất hiện trong mệnh đề WHERE.

Cơ chế JPA Auditing tự động lưu vết thời gian như thế nào?

Mọi hệ thống chuyên nghiệp đều yêu cầu hai mốc thời gian: createdAt (thời điểm tạo, bất biến) và updatedAt (thời điểm cập nhật mới nhất). Thay vì viết setter thủ công rải rác khắp các service, ta sử dụng JPA Auditing.

Khi kích hoạt @EnableJpaAuditing, Spring Data đăng ký AuditingEntityListener hook trực tiếp vào các JPA Lifecycle Callbacks:

Cùng một repo.save rẽ hai nhánh theo việc entity đã có id chưa: nhánh chưa có id chạy touchForCreate set cả createdAt lẫn updatedAt rồi sinh INSERT; nhánh đã có id chạy touchForUpdate chỉ set updatedAt nên câu UPDATE không nhắc created_at

  • Khi lưu Entity mới (@PrePersist): Tự động điền cả createdAt và updatedAt.
  • Khi cập nhật Entity (@PreUpdate): Chỉ cập nhật lại trường updatedAt.

Cấu hình Base Entity chuẩn mực

Java
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseAuditableEntity {
    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;
}
Java
@Configuration
@EnableJpaAuditing
public class JpaConfig {
    // Kich hoat bo lang nghe Auditing cua Spring Data JPA
}
Bảo vệ hai lớp với updatable = false

Thuộc tính updatable = false trên @Column(createdAt) ngăn chặn triệt để Hibernate sinh lệnh UPDATE ... SET created_at = ?, bảo đảm mốc thời gian khởi tạo không bao giờ bị ghi đè do nhầm lẫn mã nguồn.

Những cạm bẫy phổ biến cần tránh là gì?

  1. Quên kế thừa JpaSpecificationExecutor: Khiến Repository không có các phương thức nạp Specification, gây lỗi biên dịch.
  2. Tìm kiếm LIKE không tận dụng được Index: Biểu thức cb.like(..., "%" + keyword + "%") chứa ký tự đại diện ở đầu sẽ gây full-table scan trên database. Với dữ liệu lớn, cần cân nhắc Full-Text Search.
  3. Quên @EntityListeners trên Base Class: Làm cho createdAt và updatedAt luôn bị null sau khi gọi lệnh lưu dữ liệu.

Tự kiểm tra kiến thức

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao Specification lại giải quyết hiệu quả bài toán lọc dữ liệu với nhiều trường tùy chọn (optional filters)?
  2. Q2
    Interface nào bắt buộc phải được khai báo thêm trong Repository để sử dụng được Specification với phân trang?
  3. Q3
    Cơ chế nào bên dưới giúp JPA Auditing tự động điền giá trị cho các trường @CreatedDate và @LastModifiedDate?
  4. Q4
    Tại sao nên thêm thuộc tính 'updatable = false' vào annotation @Column của trường createdAt?
  5. Q5
    Nếu ứng dụng có @EnableJpaAuditing nhưng trường createdAt vẫn bị null sau khi lưu bản ghi, nguyên nhân phổ biến nhất là gì?

Bài tiếp theo: Pageable, Sort & Cú Pháp SQL Phân Trang

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 16/21 · Spring Data JPA — bài 11/20 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Pageable và Sort: Phân Trang và Sắp Xếp Dữ Liệu Chuẩn Mực