Yêu Cầu Phân Trang Và Thiết Kế Dữ Liệu
Trong các ứng dụng thực tế, việc tải toàn bộ dữ liệu từ cơ sở dữ liệu lên giao diện là điều không khả thi. Do đó, phân trang là kỹ thuật bắt buộc. Một luồng phân trang tiêu chuẩn thường bao gồm hai dạng: truy vấn không điều kiện (lấy dữ liệu mặc định) và truy vấn có điều kiện (lọc theo từ khóa người dùng nhập).
Để chuẩn hóa dữ liệu đầu vào, chúng ta cần một đối tượng DTO (Data Transfer Object) để đóng gói các tham số phân trang:
public class PaginationRequest implements Serializable {
private int pageIndex; // Số trang hiện tại
private int pageSize; // Số lượng bản ghi mỗi trang
private String keyword; // Từ khóa tìm kiếm
// Constructors, Getters and Setters
}
Tương tự, kết quả trả về cần chứa tổng số bản ghi và danh sách dữ liệu của trang hiện tại:
public class PaginationResponse<T> implements Serializable {
private long totalRecords;
private List<T> items;
// Constructors, Getters and Setters
}
Tích Hợp Và Sử Dụng PageHelper
Thay vì tự viết các câu lệnh SQL với LIMIT và OFFSET, chúng ta có thể sử dụng plugin PageHelper để tự động hóa quá trình này. Các bước cơ bản bao gồm:
- Thêm dependency của PageHelper vào file
pom.xml. - Cấu hình plugin trong file cấu hình MyBatis hoặc Spring Boot.
- Áp dụng vào tầng Service.
Implement Code
Dưới đây là cách áp dụng PageHelper vào Controller, Service và Mapper với ngữ cảnh quản lý nhân viên (Employee).
Controller:
@RestController
@RequestMapping("/api/employees")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@PostMapping("/search")
public PaginationResponse<Employee> searchEmployees(@RequestBody PaginationRequest request) {
return employeeService.getEmployeesWithPagination(
request.getPageIndex(),
request.getPageSize(),
request.getKeyword()
);
}
}
Service:
@Service
public class EmployeeServiceImpl implements EmployeeService {
@Autowired
private EmployeeMapper employeeMapper;
@Override
public PaginationResponse<Employee> getEmployeesWithPagination(int pageIndex, int pageSize, String keyword) {
// Khởi tạo phân trang
PageHelper.startPage(pageIndex, pageSize);
// Thực hiện truy vấn
Page<Employee> employeePage = employeeMapper.findByKeyword(keyword);
// Đóng gói kết quả
return new PaginationResponse<>(employeePage.getTotal(), employeePage.getResult());
}
}
Mapper Interface:
@Mapper
public interface EmployeeMapper {
Page<Employee> findByKeyword(@Param("keyword") String keyword);
}
Mapper XML:
<select id="findByKeyword" resultType="com.example.entity.Employee">
SELECT id, full_name, department, email
FROM tbl_employees
<where>
<if test="keyword != null and keyword != ''">
AND (full_name LIKE CONCAT('%', #{keyword}, '%')
OR email LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
</select>
Phân Tích Cơ Chế Hoạt Động Của PageHelper
Một nguyên tắc tối quan trọng khi dùng PageHelper là: Câu lệnh PageHelper.startPage() phải được đặt ngay sát trước câu truy vấn MyBatis. Không được chèn bất kỳ câu truy vấn SQL nào khác ở giữa chúng.
Để hiểu rõ lý do, hãy đi sâu vào mã nguồn của PageHelper. Phương thức startPage thực chất sẽ gọi đến SqlUtil.setLocalPage(page):
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<>(pageNum, pageSize, count);
// ... thiết lập các thuộc tính khác
SqlUtil.setLocalPage(page);
return page;
}
Bên trong SqlUtil, biến phân trang được lưu trữ thông qua ThreadLocal:
private static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>();
public static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}
public static void clearLocalPage() {
LOCAL_PAGE.remove();
}
Plugin MyBatis của PageHelper hoạt động dựa trên cơ chế Interceptor. Nó sẽ chặn câu lệnh SQL sắp được thực thi, kiểm tra xem trong ThreadLocal có tồn tại đối tượng Page hay không. Nếu có, nó sẽ tự động viết lại câu SQL (thêm LIMIT, OFFSET hoặc các cú pháp phân trang tương ứng của từng loại database) và ngay lập tức gọi clearLocalPage() để xóa biến khỏi ThreadLocal nhằm tránh rò rỉ bộ nhớ.
Hệ Quả Của Việc Đặt Sai Vị Trí
Xét một ví dụ sai lầm sau:
@Override
public PaginationResponse<Employee> getEmployeesWithPagination(int pageIndex, int pageSize, String keyword) {
PageHelper.startPage(pageIndex, pageSize);
// Một câu truy vấn khác vô tình được chèn vào đây
List<Department> departments = departmentMapper.findAll();
// Câu truy vấn chính
Page<Employee> employeePage = employeeMapper.findByKeyword(keyword);
return new PaginationResponse<>(employeePage.getTotal(), employeePage.getResult());
}
Trong trường hợp này, khi departmentMapper.findAll() được thực thi, Interceptor của PageHelper sẽ bắt được sự kiện, lấy đối tượng Page từ ThreadLocal để áp dụng phân trang cho câu lệnh findAll, sau đó xóa sạch ThreadLocal. Kết quả là khi đến lượt employeeMapper.findByKeyword(keyword) chạy, ThreadLocal đã trống, dẫn đến việc câu lệnh này sẽ tải toàn bộ dữ liệu mà không hề có phân trang, gây ra lỗi OutOfMemoryError hoặc trả về sai dữ liệu.