Backend

99 Ngày Spring — Ngày 33: Projection — chỉ lấy cột cần

SSite Admin
29 tháng 08, 2026 8 phút đọc 0 lượt xem
99 Ngày Spring — Ngày 33: Projection — chỉ lấy cột cần

Ngày 31 tối ưu số dòng (phân trang), Ngày 32 tối ưu điều kiện lọc (Specification) — hôm nay tối ưu chiều còn lại: số cột. Màn hình danh sách chỉ hiện 3–4 trường nhưng findAll quen tay kéo cả entity 15 cột kèm mô tả 2000 ký tự về rồi… vứt. Projection của Spring Data đẩy ranh giới DTO (Ngày 30) xuống tận câu SQL: interface projection cho gọn, record projection cho chuẩn, dynamic projection cho linh hoạt — và một bẫy open projection khiến mọi công sức thành "gọn giả".

Sketchnote Ngày 33: Projection — chi phí SELECT cả entity, interface projection, record projection với constructor expression, dynamic projection và bẫy open projection

Vấn đề: SELECT cả entity cho màn hình 3 cột

// Màn hình DANH SÁCH task: bảng chỉ hiện tiêu đề + trạng thái + hạn chót
// Nhưng repository quen tay:
Page<Task> page = repo.findAll(pageable);

// SQL thật (soi log — Ngày 27):
// SELECT id, tieu_de, mo_ta, status, han_chot,
//        created_at, updated_at, created_by, updated_by, ...
// → kéo CẢ mo_ta 2000 ký tự × 20 dòng — màn list không bao giờ hiển thị!

// Cái giá của "SELECT cả entity":
// 1. Băng thông + RAM: cột to (mô tả, JSON, blob) đi theo mỗi dòng
// 2. Hibernate phải dựng entity ĐẦY ĐỦ + theo dõi dirty checking (Ngày 25)
//    — cho dữ liệu CHỈ ĐỌC rồi vứt
// 3. Entity kéo theo quan hệ LAZY — chạm nhầm một getter trong vòng lặp
//    là N+1 nổ ngay giữa màn list (Ngày 27)

// Ngày 30 đã tách DTO khỏi entity Ở TẦNG API.
// Hôm nay đẩy ranh giới xuống TẬN SQL: chỉ SELECT đúng cột màn hình cần.
  • Ba khoản phí của entity đầy đủ cho dữ liệu chỉ-đọc: cột to đi theo mỗi dòng (mô tả, JSON), chi phí dựng entity + dirty checking (Ngày 25) cho thứ sẽ vứt ngay, và quan hệ lazy chờ nổ N+1 (Ngày 27) khi ai đó chạm nhầm getter trong vòng lặp.

  • Ngày 30 đã có nguyên tắc "entity không rời service" — nhưng đó là ranh giới ở tầng API: DB vẫn trả đủ cột. Projection kéo ranh giới xuống tầng SQL — đúng nghĩa chỉ lấy thứ cần.

  • Quy tắc chẩn đoán quen thuộc: soi SQL log (Ngày 27) — thấy SELECT dài dằng dặc cho màn list là thấy chỗ tối ưu; đo trước, tin sau.

Interface projection — đổi mỗi kiểu trả về

// Interface projection — khai báo "tôi chỉ cần các cột này"
public interface TaskSummary {
    Long getId();
    String getTieuDe();
    TaskStatus getStatus();
    LocalDate getHanChot();
}

// Repository: đổi KIỂU TRẢ VỀ — Spring Data lo phần còn lại
public interface TaskRepository extends JpaRepository<Task, Long> {
    Page<TaskSummary> findByStatus(TaskStatus status, Pageable pageable);
    List<TaskSummary> findByHanChotBefore(LocalDate moc);
}

// SQL sinh ra (soi log): SELECT id, tieu_de, status, han_chot FROM tasks ...
// → đúng 4 cột — mo_ta 2000 ký tự Ở YÊN dưới DB ✅

// Quy tắc khớp tên: getTieuDe() ↔ thuộc tính tieuDe của entity
// Sai tên là lỗi NGAY LÚC KHỞI ĐỘNG — dễ bắt hơn lỗi runtime lẻ tẻ

// Cùng interface dùng được nhiều query — "hợp đồng cột" tái sử dụng:
Page<TaskSummary> timKiem(Specification<Task> spec, Pageable pageable); // ❌ KHÔNG chạy
// Lưu ý: findAll(Specification) KHÔNG hỗ trợ projection trực tiếp (bẫy phổ biến!)
// → với Specification: dùng findBy(spec, q -> q.as(TaskSummary.class).page(pageable))
  • Cơ chế gọn kỳ lạ: khai báo interface toàn getter, đổi kiểu trả về của method repository — Spring Data tự sinh SELECT đúng các cột khớp tên và tạo proxy đằng sau; sai tên thuộc tính vỡ ngay lúc khởi động, không âm thầm.

  • Một interface là một "hợp đồng cột" tái sử dụng cho nhiều query — TaskSummary dùng cho list, cho search, cho export; ba màn cùng một định nghĩa cột.

  • Bẫy phổ biến nhất của chương: findAll(Specification) không nhận projection trực tiếp — kết hợp Specification (Ngày 32) phải đi qua fluent API findBy(spec, q -> q.as(TaskSummary.class)); biết trước đỡ mất một buổi chiều.

Record projection — DTO thật sự từ tầng SQL

// Class/record projection — DTO thật sự, bất biến (Ngày 29 vẫy tay)
public record TaskRow(Long id, String tieuDe, TaskStatus status, LocalDate hanChot) { }

// Cách 1 — constructor expression trong JPQL:
@Query("""
    select new vn.motdev.blog.task.dto.TaskRow(
        t.id, t.tieuDe, t.status, t.hanChot)
    from Task t
    where t.status = :status
    """)
List<TaskRow> danhSachTheoTrangThai(TaskStatus status);

// Cách 2 — derived query cũng nhận record (Spring Data khớp theo TÊN component):
List<TaskRow> findByStatusOrderByHanChotAsc(TaskStatus status);

// Interface vs record — chọn thế nào?
//                         Interface                 Record
// Khai báo                gọn, chỉ getter           1 dòng record
// Bản chất               proxy Spring tạo          object thật, bất biến
// JPQL tường minh        không cần                  new ...() dài dòng
// Dùng ngoài tầng data    ổn                        TỐT — DTO chuẩn chỉnh
// Nested (task.nguoiTao)  hỗ trợ (cẩn thận N+1)     tự map thủ công

// Thực dụng: interface cho nhanh gọn nội bộ — record khi DTO đi xa (API, cache)
  • Record (Ngày 20, 29) làm projection là cú bắt tay đẹp: bất biến, equals/hashCode chuẩn, và là object thật — không phải proxy — nên tự tin đem đi xa: trả API, bỏ cache, ghi log.

  • Hai đường sinh: constructor expression select new ...() trong JPQL (tường minh, dài) hoặc derived query khớp theo tên component (gọn, đủ dùng cho đa số).

  • Chọn thực dụng: interface projection cho đọc nhanh nội bộ tầng data; record khi DTO vượt ranh giới — cùng triết lý DTO của checkpoint Ngày 30, giờ sinh thẳng từ SQL.

Dynamic projection và bẫy open projection

// Dynamic projection — MỘT method, nhiều hình dạng kết quả
public interface TaskRepository extends JpaRepository<Task, Long> {
    <T> List<T> findByStatus(TaskStatus status, Class<T> type);
}

List<TaskSummary> gon  = repo.findByStatus(TODO, TaskSummary.class); // 4 cột
List<TaskRow>     row  = repo.findByStatus(TODO, TaskRow.class);     // record
List<Task>        day  = repo.findByStatus(TODO, Task.class);        // cả entity (khi cần sửa)

// Ba lớp dùng cùng một query — màn list lấy gọn, màn edit lấy đủ.

// Kết hợp đồ nghề các ngày trước:
// - Pageable (Ngày 18, 31): Page<TaskSummary> — phân trang trên cột gọn ✅
// - Specification (Ngày 32): qua fluent API findBy(spec, q -> q.as(...))
// - @EntityGraph: KHÔNG cần nữa cho màn list — không entity, không lazy, không N+1

// Bẫy cuối — OPEN projection với @Value/SpEL:
public interface TaskBad {
    @Value("#{target.tieuDe + ' (' + target.status + ')'}")
    String getLabel();     // ❌ SpEL cần "target" = CẢ entity
}
// → Hibernate buộc phải SELECT đủ cột — projection "giả": nhìn gọn, chạy phí như cũ
// Format chuỗi để tầng service/DTO lo — đừng nhét vào projection
  • Dynamic projection — thêm tham số Class<T> — cho một method phục vụ cả màn list (gọn) lẫn màn edit (đủ entity): bớt trùng lặp method mà vẫn giữ từng màn đúng khẩu phần.

  • Phần thưởng kín đáo cho màn list: không entity thì không lazy — nguy cơ N+1 (Ngày 27) biến mất theo định nghĩa, khỏi cần @EntityGraph cho đường đọc này.

  • Bẫy open projection: getter gắn @Value/SpEL cần target là cả entity → Hibernate buộc SELECT đủ cột — nhìn như projection, chạy phí như findAll; format chuỗi là việc của service/DTO, đừng nhét vào hợp đồng cột.

Checklist dùng projection

  • Màn danh sách / export / báo cáo (đọc rồi vứt) → projection; form edit (đọc để sửa) → entity đầy đủ + dirty checking.

  • Soi SQL log xác nhận SELECT chỉ còn cột khai báo — projection mà log vẫn SELECT đủ cột là dính open projection hoặc nested kéo quan hệ.

  • Nested projection (getNguoiTao().getUsername()) tiện nhưng dễ kéo JOIN/entity con — màn list lớn thì flatten thành cột phẳng (nguoiTaoUsername) cho chắc.

  • Projection là đọc: không dirty checking, không save ngược — cần sửa thì tải entity; đúng phân vai với @Transactional(readOnly = true) (Ngày 28, 30).

  • Kết hợp: Page<TaskSummary> + Pageable chạy thẳng; với Specification dùng findBy(spec, q -> q.as(...)).

Bài tập nhỏ

  • Thêm TaskSummary (interface) cho checkpoint Ngày 30, đổi endpoint list sang Page<TaskSummary> — soi SQL log trước/sau và đếm cột.

  • Tạo record TaskRow + constructor expression; so sánh JSON trả về với bản interface — có khác gì không, vì sao?

  • Viết dynamic projection findByStatus(status, Class<T>) và dùng ở 2 chỗ: list (Summary) và edit (Task).

  • Cố tình tạo open projection với @Value — soi log chứng minh SELECT đủ cột quay lại, rồi chuyển format sang DTO.

  • Kết hợp Ngày 32: endpoint search dùng findBy(spec, q -> q.as(TaskSummary.class).page(pageable)) — xác nhận WHERE động + SELECT gọn cùng lúc.

Kết luận

Bốn ý gói bài: entity đầy đủ cho dữ liệu chỉ-đọc là lãng phí ba tầng — cột thừa, dựng object + dirty checking, và lazy chờ N+1; interface projection chỉ cần đổi kiểu trả về — hợp đồng cột khớp tên, vỡ sớm lúc khởi động; record projection cho DTO bất biến đi xa được; dynamic projection một method nhiều khẩu phần — và tránh open projection kẻo gọn giả phí thật. Ngày 34 ta rời tầng query đến tầng schema: Flyway migration — vì sao ddl-auto là thảm họa production và cách version hóa schema như version hóa code. 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 33: Set — HashSet, LinkedHashSet, TreeSet

Lời hứa duy nhất qua ba anh em khác nhau ở thứ tự, hợp đồng equals/hashCode và phần tử bất biến, TreeSet với compareTo — cùng phép toán tập hợp ba dòng.

29 thg 8, 202610 phút0
99 Ngày Spring — Ngày 32: Specification & dynamic query

Bộ lọc tùy chọn làm derived query bùng nổ 2ⁿ — Specification biến mỗi điều kiện thành mảnh LEGO ghép and/or lúc chạy, mẹo null-safe, 4 bẫy và ranh giới QueryDSL.

28 thg 8, 20269 phút8
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út6