Cài đặt phân trang
Cập nhật lúc 1709794551000Tổ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:
- 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.
- 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.
- 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:

- XxxFilter: Chứa thông tin về điều kiện lọc.
- XxxPaginationParameter: Chứa thông tin về điều kiện so sánh để phân trang.
- XxxPaginationParameterConverter: Dùng để chuyển đổi qua lại giữa text và đối tượng PaginationParameter.
- PaginationXxxRepository: Giao tiếp với cơ sở dữ liệu.
- 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:
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.countField
: Trường sẽ dùng để đếm trong câu truy vấncount
.decorateQueryStringBeforeWhere
: Bổ sung vào câu truy vấn trước từ khoáwhere
, bạn có thể sử dụngjoin
ở đây.matchingCondition
: Điều kiện lọc cho câu truy vấn sau từ khoáwhere
.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:
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.decorateQueryStringBeforeWhere
: Bổ sung vào câu truy vấn trước từ khoáwhere
, bạn có thể sử dụngjoin
ở đây.paginationCondition
: Điều kiện phân trang cho câu truy vấn sau từ khoáwhere
.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.orderBy
: Là các trường và chiều sắp xếp sau từ khoáorder by
.sortOrder
: Là chiều sắp xếp dùng để chuyển đổi từ text sang lớpPaginationParameter
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ì next
và prev
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 next
và prev
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:
- Kiểu Id là
Long
. - Lớp entity là
ProductBook
. - 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ụ:
- Sử dụng lớp PaginationParameterConverter để chuyển đổi qua lại dữ liệu text và đối tượng PaginationParameter.
- Gọi đến Repository để truy vấn dữ liệu.
- Chuyển dữ liệu từ dạng Entity về dạng Model.
Bạn sẽ cần cài đặt:
serializeToPageToken
: Hàm chuyển đổi đối tượng PaginationParameter sang dạng text.deserializePageToken
: Hàm chuyển đổi dữ liệu dạng text sang đối tượng PaginationParameter.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.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ó:
- Kiểu Id là
Long
. - Lớp Entity là
ProductBook
. - Lớp filter là
BookFilter
. - Lớp PaginationParameter là
BookPaginationParameter
. - 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:
- Bước một: Tạo đối tượng filter.
- 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ế.

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:
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.PaginationParameters.makePaginationConditionDesc
: Hàm này giúp cài đặt hàmpaginationCondition
dễ dàng hơn.PaginationParameters.makeOrderByDesc
: Hàm này giúp cài đặt hàmorderBy
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.