Backend

99 Ngày Spring — Ngày 18: Phân trang & sắp xếp API

SSite Admin
14 tháng 08, 2026 5 phút đọc 2 lượt xem
99 Ngày Spring — Ngày 18: Phân trang & sắp xếp API

API danh sách của ta hiện trả toàn bộ bảng trong một response — chấp nhận được với dữ liệu mẫu, nhưng là khoản nợ hiệu năng lớn dần theo từng bản ghi. Chuẩn mực của REST API là phân trang: client hỏi từng trang, server trả đúng trang đó kèm thông tin tổng quan. Spring cung cấp sẵn bộ ba PageablePageSort, tự bind từ query param; việc của ta là dùng đúng convention và cài hai chốt an toàn.

Sketchnote Ngày 18: phân trang & sắp xếp API — Pageable, PageResponse DTO, convention query param và hai chốt an toàn

Vấn đề: trả cả bảng trong một response

// Trả cả bảng — khoản nợ hiệu năng lớn dần theo dữ liệu:
@GetMapping
public List<PostResponse> list() {
    return mapper.toResponses(postService.findAll());
}
// 10 bản ghi: hoạt động tốt · 100.000 bản ghi: sự cố vận hành

// Ba chi phí cùng tăng theo kích thước bảng:
// 1. DB đọc toàn bộ bảng, network truyền toàn bộ kết quả
// 2. Heap giữ toàn bộ entity + DTO trong MỘT request
// 3. Client nhận payload lớn — thiết bị di động parse hàng MB JSON
  • Điểm nguy hiểm của mẫu này: nó hoạt động hoàn hảo trong môi trường dev — dữ liệu mẫu vài chục bản ghi không bộc lộ vấn đề; sự cố chỉ xuất hiện sau nhiều tháng vận hành, khi dữ liệu thật tích lũy.

  • Phân trang chuyển hợp đồng API từ "trả tất cả" sang "trả một trang + thông tin tổng quan" — chi phí mỗi request trở thành hằng số, không phụ thuộc kích thước bảng.

Pageable — Spring bind query param thành đối tượng

@GetMapping
public PageResponse<PostResponse> list(
        @PageableDefault(size = 20, sort = "createdAt",
                         direction = Sort.Direction.DESC)
        Pageable pageable) {                  // Spring tự bind từ query param:
    return postService.list(pageable);        // ?page=0&size=20&sort=createdAt,desc
}

// Tầng dữ liệu: JpaRepository hỗ trợ sẵn (chi tiết ở Giai đoạn 3 — Ngày 23)
public interface PostRepository extends JpaRepository<Post, Long> {
    Page<Post> findByStatus(Status status, Pageable pageable);
}

// SQL sinh ra dùng LIMIT/OFFSET, kèm một câu COUNT để tính tổng số bản ghi —
// hai truy vấn này chính là chi phí của Page (so với Slice: Ngày 31)
  • Khai báo tham số Pageable trong controller là đủ — Spring đọc page, size, sort từ query string và dựng đối tượng hoàn chỉnh; @PageableDefault ấn định giá trị khi client không truyền.

  • Page<T> mang cả dữ liệu lẫn siêu dữ liệu (tổng bản ghi, tổng trang) — đổi lại một câu COUNT mỗi request; khi không cần tổng (infinite scroll), Slice rẻ hơn — so sánh kỹ ở Ngày 31.

  • Tầng repository (JpaRepository) xuất hiện ở đây như một bản xem trước — toàn bộ Giai đoạn 3 (từ Ngày 21) dành riêng cho Spring Data JPA.

PageResponse — hợp đồng phân trang của riêng API

// Không trả Page<T> trực tiếp: cấu trúc JSON của PageImpl không phải
// hợp đồng ổn định — Spring in cảnh báo và khuyến nghị bọc lại.
// Định nghĩa DTO phân trang của riêng API (record — bài Java Ngày 20):
public record PageResponse<T>(
        List<T> content,
        int page,
        int size,
        long totalElements,
        int totalPages,
        boolean hasNext
) {
    public static <T> PageResponse<T> from(Page<T> p) {
        return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(),
                p.getTotalElements(), p.getTotalPages(), p.hasNext());
    }
}

// Response mẫu:
// {
//   "content": [ ... ],
//   "page": 0, "size": 20,
//   "totalElements": 135, "totalPages": 7, "hasNext": true
// }
  • Nguyên tắc quen thuộc từ Ngày 13 và 17 áp dụng cho cả phân trang: Page/PageImpl là kiểu nội bộ của Spring Data — không phải hợp đồng công khai; bọc lại bằng DTO để cấu trúc response thuộc quyền kiểm soát của bạn.

  • Sáu field trong PageResponse là bộ tối thiểu đủ cho mọi UI phân trang: danh sách, vị trí trang, kích thước, tổng bản ghi, tổng trang và cờ hasNext cho nút "Tải thêm".

  • Method from(Page) đặt cạnh record là đủ gọn ở quy mô này — hoặc giao luôn cho MapStruct (Ngày 17) nếu muốn thống nhất một cơ chế mapping.

Thư viện với các kệ sách xếp tầng — phân trang là cách thư viện phục vụ: đưa đúng kệ người đọc cần thay vì chuyển cả kho

Sắp xếp & hai chốt an toàn

// Convention query param — thống nhất trên toàn API:
//   ?page=0&size=20                 trang (0-based) & kích thước trang
//   ?sort=createdAt,desc            một tiêu chí sắp xếp
//   ?sort=status,asc&sort=id,desc   nhiều tiêu chí — ưu tiên từ trái sang phải

// Chốt an toàn 1 — chặn size tùy tiện (?size=1000000):
# application.yml
spring.data.web.pageable.max-page-size: 100

// Chốt an toàn 2 — whitelist field được phép sort:
private static final Set<String> SORTABLE = Set.of("createdAt", "title", "viewCount");

Sort safeSort = Sort.by(pageable.getSort().stream()
        .filter(o -> SORTABLE.contains(o.getProperty()))
        .toList());
// Sort theo tên thuộc tính tùy ý từ client = lỗi 500 chờ sẵn
// (PropertyReferenceException) và lộ cấu trúc cột của DB
  • Convention ?sort=field,direction (lặp lại để thêm tiêu chí) là chuẩn phổ biến nhất — giữ nguyên nó trên toàn API để client chỉ phải học một lần.

  • Chốt 1 — giới hạn size: thiếu max-page-size, một request ?size=1000000 tái tạo đúng sự cố "trả cả bảng" mà phân trang sinh ra để tránh.

  • Chốt 2 — whitelist sort: sort đi thẳng vào ORDER BY; tên thuộc tính không tồn tại gây PropertyReferenceException (lỗi 500 — hoặc bài Ngày 16 dịch thành 400 có kiểm soát), và việc chấp nhận mọi tên field vô tình công khai cấu trúc cột của DB.

Bài tập nhỏ

  • Chuyển GET /api/books sang Pageable + PageResponse — gọi lần lượt ?page=0&size=5, ?page=1&size=5 và đối chiếu totalPages.

  • Thử ?sort=title,asc&sort=id,desc — xác nhận thứ tự ưu tiên của hai tiêu chí trong kết quả.

  • Đặt spring.data.web.pageable.max-page-size=50 rồi gọi ?size=500 — quan sát size thực tế trong response.

  • Gọi ?sort=matKhau,desc (field không cho phép) — trước và sau khi thêm whitelist: so sánh 500 mặc định với hành vi có kiểm soát.

Kết luận

API danh sách giờ có chi phí không đổi theo kích thước dữ liệu: Pageable bind convention page/size/sort từ query param, PageResponse giữ hợp đồng phân trang trong tầm kiểm soát của API thay vì phụ thuộc kiểu nội bộ của Spring Data, và hai chốt an toàn (giới hạn size, whitelist sort) chặn các request bất thường từ gốc. Ngày 19 ta xử lý loại dữ liệu không phải JSON đầu tiên của series: upload và download fileMultipartFile, giới hạn kích thước và stream download an toàn. 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 18: static & final

Ngày 18 của 99 Ngày Java: static — thành viên thuộc về lớp thay vì đối tượng, static block và static import, ba cấp độ của final (biến, method, lớp), mẫu hằng số public static final, và ranh giới quan trọng: static bất biến thì an toàn, static có thể ghi là trạng thái toàn cục nguy hiểm.

14 thg 8, 20266 phút2
99 Ngày Spring — Ngày 17: Mapping DTO với MapStruct

Ngày 17 của 99 Ngày Spring: vì sao không trả thẳng entity, chi phí ẩn của map thủ công, MapStruct sinh code mapping lúc biên dịch từ interface khai báo, tùy biến với @Mapping và @MappingTarget, cùng unmappedTargetPolicy = ERROR biến field bỏ sót thành lỗi build.

13 thg 8, 20266 phút31
99 Ngày Java — Ngày 17: Interface

Ngày 17 của 99 Ngày Java: interface — hợp đồng thuần túy tách khỏi cây kế thừa, implement nhiều interface cùng lúc, default & static method cho phép API tiến hóa không phá vỡ mã cũ, và functional interface — cửa ngõ sang lambda và lập trình hàm.

13 thg 8, 20266 phút12