Cơ Chế Phân Trang MyBatis Qua Plugin PageHelper

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:

  1. Thêm dependency của PageHelper vào file pom.xml.
  2. Cấu hình plugin trong file cấu hình MyBatis hoặc Spring Boot.
  3. Á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.

Thẻ: mybatis PageHelper Java Spring Boot threadlocal

Đăng vào ngày 13 tháng 10 lúc 21:26