Cài đặt phân trang

Cập nhật lúc 1709794551000

Tổng quan

Pagination là một trong những tính năng phổ biến và gần như được sử dụng ở bất kỳ dự án phần mềm nào, có 3 kiểu phân trang:

  1. Lấy ra tất cả rồi phân trang: tiện nhưng chậm, có khả năng hiển thị được thứ tự của bản ghi.
  2. Phân trang theo kiểu offset: khá tiện nhưng khá chậm, và dữ liệu càng lớn thì càng chậm, có khả năng hiển thị được thứ tự của bản ghi.
  3. Phân trang theo kiểu cursor: phức tạp nhưng đảm bảo hiệu năng trong mọi trường hợp nhưng không hiển thị được thứ tự của bản ghi.

EzyPlatform sẽ cung cấp code base cho phương án thứ 2 và 3.

Phân trang theo kiểu cursor

Chúng ta sẽ cần tạo ra các lớp sau:

Pagination.png
  1. XxxFilter: Chứa thông tin về điều kiện lọc.
  2. XxxPaginationParameter: Chứa thông tin về điều kiện so sánh để phân trang.
  3. XxxPaginationParameterConverter: Dùng để chuyển đổi qua lại giữa text và đối tượng PaginationParameter.
  4. PaginationXxxRepository: Giao tiếp với cơ sở dữ liệu.
  5. PaginationXxxService: Gọi đến lớp Repository và chuyển đổi Entity về Model.

1. XxxFilter

Bạn nên tạo ra một interface cơ sở thừa kế CommonStorageFilter để đặc trưng cho nghiệp vụ phân trang mà bạn muốn cài đặt, ví dụ ở đây tôi khai báo interface DefaultBookFilter để đặc trưng cho nghiệp vụ phân trang sách.

public interface BookFilter extends CommonStorageFilter {}

Tiếp theo bạn có thể khai báo một lớp cụ thể cài đặt interface cơ sở, lớp này sẽ có chứa các trường dữ liệu để làm điều kiện lọc, trường dữ liệu nào bị null, nghĩa là không có giá trị sẽ được bỏ qua.

Bạn cũng cần cài đặt một hoặc toàn bộ các hàm sau:

  1. selectionFields: Danh sách các trường được lựa chọn, mặc định là toàn bộ các trường trong bảng.
  2. countField: Trường sẽ dùng để đếm trong câu truy vấn count.
  3. decorateQueryStringBeforeWhere : Bổ sung vào câu truy vấn trước từ khoá where, bạn có thể sử dụng join ở đây.
  4. matchingCondition: Điều kiện lọc cho câu truy vấn sau từ khoá where.
  5. groupBy : Là danh sách các trường cần cho câu truy vấn tổng hợp có group by, ngăn cách nhau bởi dấu phẩy.

Ví dụ lớp DefaultBookFilter cung cấp điều kiện lọc cho nghiệp vụ phân trang sách.

@Getter
@Builder
public class DefaultBookFilter implements BookFilter {
    public final String status;
    public final String likeKeyword;
    public final Collection<String> keywords;

    @Override
    public void decorateQueryStringBeforeWhere(
        StringBuilder queryString
    ) {
        queryString.append(" INNER JOIN Product a ON e.productId = a.id");
        if (keywords != null) {
            queryString.append(" INNER JOIN DataIndex k ON e.productId = k.dataId");
        }
    }

    @Override
    public String matchingCondition() {
        EzyQueryConditionBuilder answer = new EzyQueryConditionBuilder();
        if (status != null) {
            answer.append("e.status = :status");
        }
        if (keywords != null) {
            answer
                .and("k.dataType = '" + TABLE_NAME_PRODUCT + "'")
                .and("k.keyword IN :keywords");
        }
        if (likeKeyword != null) {
            String query = new EzyQueryConditionBuilder()
                .append("(")
                .append("a.productName LIKE CONCAT('%', :likeKeyword, '%')")
                .or("a.productCode LIKE CONCAT('%', :likeKeyword, '%')")
                .or("e.author LIKE CONCAT('%', :likeKeyword, '%')")
                .or("e.affiliate LIKE CONCAT('%', :likeKeyword, '%')")
                .or("e.distributionCompany LIKE CONCAT('%', :distributionCompany, '%')")
                .or("e.publisher LIKE CONCAT('%', :publisher, '%')")
                .append(")")
                .build();
            answer.or(query);
        }
        return answer.build();
    }
}

2. XxxPaginationParameter

Tương tự như filter, bạn cũng nên tạo ra interface cơ sở thừa kế CommonPaginationParameter để đặc trưng cho nghiệp vụ phân trang mà bạn muốn cài đặt. Ví dụ ở đây tôi khai báo lớp interface AdminActivityPaginationParameter để đặc trưng cho nghiệp vụ phân trang sách.

public interface BookPaginationParameter
    extends CommonPaginationParameter {}

Tiếp theo bạn có thể khai báo một lớp cụ thể cài đặt interface cơ sở, lớp này sẽ có chứa các trường dữ liệu để làm điều kiện phân trang, trường dữ liệu nào bị null, nghĩa là không có giá trị sẽ được bỏ qua.

Bạn cũng cần cài đặt một hoặc toàn bộ các hàm sau:

  1. selectionFields: Danh sách các trường được lựa chọn, mặc định là toàn bộ các trường trong bảng.
  2. decorateQueryStringBeforeWhere : Bổ sung vào câu truy vấn trước từ khoá where, bạn có thể sử dụng join ở đây.
  3. paginationCondition: Điều kiện phân trang cho câu truy vấn sau từ khoá where.
  4. groupBy: Là danh sách các trường cần cho câu truy vấn tổng hợp có group by, ngăn cách nhau bởi dấu phẩy.
  5. orderBy: Là các trường và chiều sắp xếp sau từ khoá order by.
  6. sortOrder: Là chiều sắp xếp dùng để chuyển đổi từ text sang lớp PaginationParameter tương ứng.

Dưới đây là lớp IdDescBookPaginationParameter, cung cấp điều kiện phân trang cho nghiệp vụ phân trang sách.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class IdDescBookPaginationParameter
    implements BookPaginationParameter {

    public Long id;

    @Override
    public String paginationCondition(boolean nextPage) {
        if (id == null) {
            return null;
        }
        return nextPage
            ? "e.id < :id"
            : "e.id > :id";
    }

    @Override
    public String orderBy(boolean nextPage) {
        return nextPage
            ? "e.id DESC"
            : "e.id ASC";
    }

    @Override
    public String sortOrder() {
        return BookPaginationSortOrder.ID_DESC.toString();
    }
}

3. XxxPaginationParameterConverter

Khi chúng ta gọi API từ client thì dữ liệu chỉ có thể ở dạng text mà thôi, ví dụ câu truy vấn tìm kiếm theo từ khoá này:

https://admin.ezyplatform.com/api/v1/books-store/books
?limit=12
&nextPageToken=eyJzb3J0T3JkZXIiOiJJRF9ERVNDIiwidmFsdWUiOnsiaWQiOjQ4ODN9fQ==

Giá trị của nextPageToken sẽ là điều kiện để phân trang, nó đang ở dạng base64, khi decode ra chúng ta sẽ nhận được dữ liệu dạng text (json):

{"sortOrder":"ID_DESC","value":{"id":4883}}

Từ dữ liệu text này chúng ta sẽ cần chuyển đổi về đối tượng PaginationParameter tương ứng.

Khi API được thực thi, client cũng sẽ nhận được dữ liệu kiểu thế này:

{
    "items": [
        ... danh sách items
    ],
    "pageToken": {
        "next": "eyJzb3J0T3JkZXIiOiJJRF9ERVNDIiwidmFsdWUiOnsiaWQiOjQ4NzF9fQ==",
        "prev": "eyJzb3J0T3JkZXIiOiJJRF9ERVNDIiwidmFsdWUiOnsiaWQiOjQ4ODJ9fQ=="
    },
    "continuation": {
        "hasNext": true,
        "hasPrevious": true
    },
    "count": 12,
    "total": 4737,
    "timestamp": 1690813511661
}

Thì nextprev cũng chính là điều kiện phân trang dạng base64 cho trang kế tiếp và trang trước đó. Để có được nextprev chúng ta cũng cần chuyển đổi đối tương PaginationParameter sang dạng text (json) và sau đó chuyển thành dạng base64.

Việc chuyển đổi dữ liệu qua lại giữa json và đối tương PaginationParameter chính là vai trò của lớp PaginationParameterConverter.

Ví dụ chúng ta có lớp enum:

public enum BookPaginationSortOrder {
    ID_DESC
}

Chứa giá trị ID_DESC đại diện cho việc sắp xếp các bản ghi theo trường id với chiều giảm dần. Chúng ta có thể tạo lớp BookPaginationParameterConverter như dưới đây để chuyển đổi qua lại giữa dữ liệu dạng text (json) và đối tương PaginationParameter tương ứng, cụ thể ở đây là cặp enum BookPaginationSortOrder.ID_DESC và lớp IdDescBookPaginationParameter.

public class BookPaginationParameterConverter
    extends ComplexPaginationParameterConverter<
        String,
        ProductBookModel
    > {

    private final ClockProxy clock;

    public BookPaginationParameterConverter(
        ClockProxy clock,
        PaginationParameterConverter converter
    ) {
        super(converter);
        this.clock = clock;
    }

    @Override
    protected void mapPaginationParametersToTypes(
        Map<String, Class<?>> map
    ) {
        map.put(
            BookPaginationSortOrder.ID_DESC.toString(),
            IdDescBookPaginationParameter.class
        );
        map.put(
            BookPaginationSortOrder.RELEASED_AT_DESC_ID_DESC.toString(),
            ReleasedAtDescIdDescBookPaginationParameter.class
        );
    }

    @Override
    protected void addPaginationParameterExtractors(
        Map<String, Function<ProductBookModel, Object>> map
    ) {
        map.put(
            BookPaginationSortOrder.ID_DESC.toString(),
            model -> new IdDescBookPaginationParameter(
                model.getProductId()
            )
        );
        map.put(
            BookPaginationSortOrder.RELEASED_AT_DESC_ID_DESC.toString(),
            model -> new ReleasedAtDescIdDescBookPaginationParameter(
                clock.toLocalDateTime(model.getReleasedAt()),
                model.getProductId()
            )
        );
    }
}

4. PaginationXxxRepository

Lớp này sẽ sử dụng Filter và PaginationParameter để tạo ra query ở dạng JPQL và truy xuất cơ sở dữ liệu. Bạn sẽ cần cài đặt hàm getEntityType để trả về Entity class. Ví dụ dưới đây là lớp AdminPaginationAdminActivityRepository cho nghiệp vụ phân trang lịch sử hành động của Admin với:

  1. Kiểu Id là Long.
  2. Lớp entity là ProductBook.
  3. Lớp filter là BookFilter.

Lớp PaginationParameter là BookPaginationParameter.

public class PaginationBookRepository extends CommonPaginationRepository<
    BookFilter,
    BookPaginationParameter,
    Long,
    ProductBook> {

    @Override
    protected Class<ProductBook> getEntityType() {
        return ProductBook.class;
    }
}

5. PaginationXxxService

Lớp này có nhiệm vụ:

  1. Sử dụng lớp PaginationParameterConverter để chuyển đổi qua lại dữ liệu text và đối tượng PaginationParameter.
  2. Gọi đến Repository để truy vấn dữ liệu.
  3. Chuyển dữ liệu từ dạng Entity về dạng Model.

Bạn sẽ cần cài đặt:

  1. serializeToPageToken: Hàm chuyển đổi đối tượng PaginationParameter sang dạng text.
  2. deserializePageToken: Hàm chuyển đổi dữ liệu dạng text sang đối tượng PaginationParameter.
  3. defaultPaginationParameter: Hàm cung cấp PaginationParameter mặc định trong trường hợp không có thông tin về PaginationParameter được cung cấp.
  4. convertEntity: Hàm chuyển đổi dữ liệu từ dạng Entity sang dạng Model.

Trong lớp AdminPaginationAdminActivityService dưới đây cho nghiệp vụ phân trang hành động của Admin chúng ta sẽ có:

  1. Kiểu Id là Long.
  2. Lớp Entity là ProductBook.
  3. Lớp filter là BookFilter.
  4. Lớp PaginationParameter là BookPaginationParameter.
  5. Lớp model là ProductBookModel.
public class PaginationBookService extends CommonPaginationService<
    ProductBookModel,
    BookFilter,
    BookPaginationParameter,
    Long,
    ProductBook> {

    private final EcommerceEntityToModelConverter entityToModelConverter;

    public PaginationBookService(
        PaginationBookRepository repository,
        EcommerceEntityToModelConverter entityToModelConverter,
        BookPaginationParameterConverter paginationParameterConverter
    ) {
        super(repository, paginationParameterConverter);
        this.entityToModelConverter = entityToModelConverter;
    }


    @Override
    protected ProductBookModel convertEntity(ProductBook entity) {
        return entityToModelConverter.toModel(entity);
    }

    @Override
    protected BookPaginationParameter defaultPaginationParameter() {
        return new IdDescBookPaginationParameter();
    }
}

Sử dụng

Khi sử dụng chúng ta sẽ chỉ cần quan tâm đến lớp PaginationService và Filter mà thôi:

  1. Bước một: Tạo đối tượng filter.
  2. Bước hai: Sử dụng PaginationService để lấy về một trang dữ liệu.

Dưới đây là một ví dụ:

@Service
@AllArgsConstructor
public class AdminBookControllerService {

    private final AdminPaginationBookService paginationBookService;
    private final AdminBookModelDecorator bookModelDecorator;

    public PaginationModel<AdminBookResponse> getBooks(
        BookFilter filter,
        String nextPageToken,
        String prevPageToken,
        boolean lastPage,
        int limit,
        long currencyId,
        String currencyFormat
    ) {
        PaginationModel<ProductBookModel> pagination = getPaginationModel(
            paginationBookService,
            filter,
            nextPageToken,
            prevPageToken,
            lastPage,
            limit
        );
        return bookModelDecorator.decorate(
            pagination,
            currencyId,
            currencyFormat
        );
    }
}

Một kiểu sử dụng khác khi chúng ta không muốn sử dụng đối tượng phân trang mặc định:

public class ApiContractController {

    private final WebPaginationAddressService paginationAddressService;
    private final WebChainValidator chainValidator;
    private final WebCommonValidator webCommonValidator;
    private final WebAddressPaginationParameterConverter addressPaginationParameterConverter;

    @DoGet("/chains/{chainId}/contracts")
    public PaginationModel<ContractModel> chainsChainIdContractsGet(
        @PathVariable(value = "chainId") String chainId,
        @RequestParam(value = "nextPageToken") String nextPageToken,
        @RequestParam(value = "prevPageToken") String prevPageToken,
        @RequestParam(value = "lastPage") boolean lastPage,
        @RequestParam(value = "limit", defaultValue = "15") int limit
    ) {
        chainValidator.validateChainId(chainId);
        webCommonValidator.validatePageSize(limit);
        AddressFilter filter = new AddressTypeAddressFilter(
            chainId,
            AddressType.CONTRACT
        );
        String actualNextPageToken = nextPageToken;
        String actualPrevPageToken = prevPageToken;
        String sortOrder = AddressPaginationSortOrder
            .FIRST_BLOCK_HEIGHT_DESC_ADDRESS_DESC
            .toString();
        if (nextPageToken == null && prevPageToken == null) {
            if (lastPage) {
                actualPrevPageToken = addressPaginationParameterConverter
                    .getDefaultPageToken(sortOrder);
            } else {
                actualNextPageToken = addressPaginationParameterConverter
                    .getDefaultPageToken(sortOrder);
            }
        }
        return PaginationModelFetchers.<AddressModel>getPaginationModel(
            paginationAddressService,
            filter,
            actualNextPageToken,
            actualPrevPageToken,
            lastPage,
            limit
        ).map(AddressModel::toContractModel);
    }
}

Offset pagination

Việc phân trang theo offset sẽ đơn giản hơn rất nhiều so với cursor, vì chính offset đã đóng vai trò như một điều kiện để phân trang rồi, vậy nên số lượng lớp chúng ta cần tạo cũng sẽ ít hơn và độ phức tạp cũng giảm đi đáng kế.

Pagination Offset.png

Chỉ có duy nhất lớp XxxOffsetPaginationParameter là có sự khác biệt, còn lại tất cả các lớp sẽ đều giống với kiểu phân trang cursor.

1. Tạo lớp XxxOffsetPaginationParameter

Lớp này cung cấp offset, trường dùng để sắp xếp và chiều để sắp xếp, ví dụ:

public class YmProjectOffsetPaginationParameter
    extends OffsetPaginationParameter {

    public YmProjectOffsetPaginationParameter(
        long offset
    ) {
        super(offset, "e.priority DESC");
    }
}

2. Tạo lớp xxxPaginationRepository

Cũng tương tự như kiên phân trang cursor, ví dụ:

@EzyRepository
public class WebYmPaginationProjectRepository extends PaginationRepository<
    PostFilter,
    YmProjectOffsetPaginationParameter,
    Long,
    Post> {

    @Override
    protected Class<Post> getEntityType() {
        return Post.class;
    }
}

3. Tạo lớp xxxPaginationService

Cũng tương tự như kiên phân trang cursor, ví dụ:

@Service
public class WebYmPaginationProjectService extends OffsetPaginationService<
    PostModel,
    PostFilter,
    YmProjectOffsetPaginationParameter,
    Long,
    Post> {

    private final WebEzyArticleEntityToModelConverter entityToModelConverter;

    public WebYmPaginationProjectService(
        WebYmPaginationProjectRepository repository,
        WebEzyArticleEntityToModelConverter entityToModelConverter
    ) {
        super(
            repository,
            YmProjectOffsetPaginationParameter::new
        );
        this.entityToModelConverter = entityToModelConverter;
    }

    @Override
    protected PostModel convertEntity(Post entity) {
        return entityToModelConverter.toModel(entity);
    }
}

Sử dụng

Cũng sẽ tương tự như với kiểu phân trang cursor, ví dụ:

private final WebYmPaginationProjectService paginationProjectService;
  private final WebYmProjectModelDecorator projectModelDecorator;

  public PaginationModel<WebYmProjectResponse> getProjectPagination(
      String nextPageToken,
      int limit
  ) {
      PaginationModel<PostModel> pagination = getPaginationModel(
          paginationProjectService,
          DefaultPostFilter
              .builder()
              .postType(YmPostType.PROJECT.toString())
              .postStatus(PostStatus.PUBLISHED.toString())
              .build(),
          nextPageToken,
          null,
          false,
          limit
      );
      return projectModelDecorator.decorate(pagination);
  }

Một số hàm tiện ích

EzyPlatform cung cấp một số hàm tiện ích để giúp bạn có thể dễ dàng tạo ra lớp PaginationParameter như sau:

  1. Values.isAllNull: Hàm này giúp kiểm tra tất cả các giá trị có bằng null hay không.
  2. PaginationParameters.makePaginationConditionDesc: Hàm này giúp cài đặt hàm paginationCondition dễ dàng hơn.
  3. PaginationParameters.makeOrderByDesc: Hàm này giúp cài đặt hàm orderBy dễ dàng hơn.

Ví dụ:

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;

import static org.youngmonkeys.ezyplatform.pagination.PaginationParameters.makeOrderByDesc;
import static org.youngmonkeys.ezyplatform.pagination.PaginationParameters.makePaginationConditionDesc;
import static org.youngmonkeys.ezyplatform.util.Values.isAllNull;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReleasedAtDescIdDescBookPaginationParameter
    implements BookPaginationParameter {

    public LocalDateTime releasedAt;
    public Long id;

    @Override
    public String paginationCondition(boolean nextPage) {
        return isEmpty()
            ? null
            : makePaginationConditionDesc(
                nextPage,
                "releasedAt",
                "id"
            );
    }

    @Override
    public String orderBy(boolean nextPage) {
        return makeOrderByDesc(nextPage, "releasedAt", "id");
    }

    @Override
    public String sortOrder() {
        return BookPaginationSortOrder.RELEASED_AT_DESC_ID_DESC.toString();
    }

    @Override
    public boolean isEmpty() {
        return isAllNull(releasedAt, id);
    }
}

Mã nguồn ví dụ

Bạn có thể tham khảo tại đây.

Mục lục