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 Pageable – Page – Sort, tự bind từ query param; việc của ta là dùng đúng convention và cài 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ố
Pageabletrong controller là đủ — Spring đọcpage,size,sorttừ 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âuCOUNTmỗi request; khi không cần tổng (infinite scroll),Slicerẻ 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/PageImpllà 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
PageResponselà 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ờhasNextcho 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.
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 DBConvention
?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=1000000tá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àoORDER BY; tên thuộc tính không tồn tại gâyPropertyReferenceException(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/bookssangPageable+PageResponse— gọi lần lượt?page=0&size=5,?page=1&size=5và đối chiếutotalPages.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=50rồ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 file — MultipartFile, giới hạn kích thước và stream download an toàn. 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.


