
Cursor Pagination - phân trang dạng con trỏ
Hôm nay, dọn dẹp đống bài viết cũ trong máy, có mấy bài viết cũ từ mấy năm trước, thực ra nó là tài liệu viết cho dự án cá nhân, nay lôi ra đăng lên blog cho đỡ mốc.
Tài liệu này mô tả phương thức phân trang dạng cursor, đây là một hình thức phân trang được áp dụng khá phổ biến đối với những ứng dụng cần xử lý phân trang dạng một chiều như chat, feed, tiến trình xử lý tác vụ… nhằm tối ưu query dữ liệu với kích thước rất lớn và giải quyết vấn đề đứt quãng dữ liệu nếu dữ liệu có tính cập nhật thường xuyên.
Đây là cách triển khai đã được những ứng dụng lớn như facebook, instagram, slack, twitter… áp dụng. Có nhiều cách triển khai khác nhau nhưng hiện tại cách làm này sử dụng mô hình của Relay Connections. Chi tiết tìm hiểu tại đây:
GraphQL Cursor Connections Specification
Tóm lược
Mô hình cursor cung cấp cơ chế cho phép cắt phân trang theo từng nhóm kết quả khi truy vấn. Khi dữ liệu trả về, nó cung cấp thông số cho client nhận biết thông tin dữ liệu lấy được và có dữ liệu tiếp theo để tiếp tục truy vấn.
allComments(first: 2, after: "Y3Vyc29yMQ==") {
totalCount
edges {
cursor
node {
message
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
Dữ liệu trả về
"comment": {
"totalCount": 3, // Tổng số document trong collection (không bắt buộc)
"edges": [
{
"node": {
"message": "Hello message 1"
},
"cursor": "Y3Vyc29yMg=="
},
{
"node": {
"name": "Hello message 2"
},
"cursor": "Y3Vyc29yMw=="
}
],
"pageInfo": {
"endCursor": "Y3Vyc29yMw==",
"hasNextPage": false
}
Minh họa dưới đây biểu diễn cho chúng ta xem, xét về mặt trải nghiệm người dùng, khi lần đầu truy cập, page đầu tiên nó chính là page cuối cùng với dữ liệu mới nhất.

Lưu ý: Trình tự mới nhất hay cũ nhất được thống nhất từ trên xuống để tránh nhầm lẫn.
Thông số
Dữ liệu trả về
Edges
Edges là dữ liệu chính khi server trả về phía client
- Cursor: Việc phân trang sử dụng cursor để phân thành các đoạn khác nhau, vấn đề là cursor là một chuỗi không rõ ràng, nên ta phải tạo ra một dữ liệu cursor không có trong DB. Thông thường thì ta có thể sử dụng
id,name,createdAtđể encode thành chuỗi base64 cho cursor.
Với mongodb thì ta có thể lấy _id để tạo cursor như sau:
func ObjectIDToCursor(id *primitive.ObjectID) string {
cursor := []byte(id.Hex())
return base64.StdEncoding.EncodeToString(cursor)
}
- Node: Dữ liệu chính cần lấy.
Edge orders
Trình tự (order) của các trang có thể được thiết lập tùy theo yêu cầu của business, nhưng bắt buộc phải theo đúng trình tự của nó và đồng nhất từ trang này đến trang khác. Bất kể khi dùng first/before hay last/after, nó cũng phải nhất quát trình tự với nhau.
- Khi
before: cursorđược truy vấn thì edge gần nhất vớicursorđó phải là ở cuối cùng trong kết quảedgestrả về. - Khi
after:cursorđược truy vấn thì edge gần nhất vớicursorđó phải ở đầu tiên trong kết quảedgestrả về.
PageInfo
PageInfo chứa các thông tin thể hiện thông số trang hiện tại, bao gồm:
- HasPreviousPage: Cho biết trước cụm dữ liệu
edgeshiện tại có còn dữ liệu nữa không. - HasNextPage: Cho biết kế tiếp cụm dữ liệu
edgeshiện tại còn dữ liệu nữa hay không. - StartCursor: Cursor bắt đầu trong cụm edges
- EndCursor: Cursor kết thúc trong cụm edges
Truy vấn chiều tới (Forward)
Khi truy vấn theo chiều đi tới, có 2 thông số cần được gọi:
firstlà số lượng dữ liệu cần lấy từ truy vấn, nó tương tự vớilimittrong phân trang dạng offset.afterlà cursor được truyền vào khi truy vấn. Tham số này là cursor của edge cuối cùng ở trang trước đó.
Dữ liệu từ phía server trả về bao gồm các edges với số lượng dữ liệu bằng first tính từ after trở về sau.
Xem hình minh họa dưới đây (lưu ý là mũi tên xuống thể hiện chiều cuộn màn hình, tương tự hành vi vuốt xuống khi xem điện thoại).

Lưu ý:
- Lần truy vấn đầu tiên 1
after=nullnên kết quả là trang đầu tiên - Để xác định tồn tại trang tiếp theo hay không, có thể phải query nhiều hơn
first, sau đó lọc lại kết quả.
Truy vấn chiều ngược (backward)
Khi truy vấn theo chiều ngược, có 2 thông số cần được gọi:
lastlà số lượng dữ liệu cần lấy từ truy vấn.beforelà cursor truyền vào khi truy vấn, tham số này là cursor của edge đầu tiên ở trang tiếp theo.
Dữ liệu từ phía server trả về bao gồm các edges với số lượng dữ liệu bằng last tính từ before trở về trước.
Xem hình minh họa dưới đây (lưu ý là mũi tên lên thể hiện chiều cuộn màn hình, tương tự hành vi vuốt lên khi xem điện thoại)

PageInfo
PageInfo chứa các thông tin thể hiện thông số trang hiện tại, bao gồm:
- HasPreviousPage: Cho biết trước cụm dữ liệu
edgeshiện tại có còn dữ liệu nữa không. - Khi client truy vấn bằng
last/before, nếuedgestrước nó tồn tại, server sẽ trả thông tintruevà ngược lại. - Khi client truy vấn bằng
first/after, server sẽ trả thông tintruenếu edges trướcaftertồn tại, và ngược lại. - HasNextPage: Cho biết kế tiếp cụm dữ liệu
edgeshiện tại còn dữ liệu nữa hay không. - Khi client truy vấn bằng
first/after, nếuedgestiếp theo tồn tại, thì nó sẽ trả làtruevà ngược lại. - Khi client truy vấn bằng
last/before, nếuedgestiếp theo - StartCursor: Cursor bắt đầu trong cụm egdes
- EndCursor: Cursor kết thúc trong cụm egdes
Triển khai
Thuật toán thực hiện để lấy các edges trả về phía client. Việc cùng lúc sử dụng first và last cần phải tránh, nếu không kết quả có thể sẽ không như mong muốn.
EdgesToReturn(allEdges, before, after, first, last)
- Ta có
edgeslà kết quả khi gọi ApplyCursorsToEdges (allEdges, before, after). - Nếu
firstđược thiết lập: - Nếu
firstnhỏ hơn0: - Throw an error.
- Nếu
edgescó length lớn hơnfirst: - Phân chia
edgestheo length củafirstbằng cách loại bỏ các edges từ cuốiedges. - Nếu
lastđược thiết lập: - Nếu
lastnhỏ hơn0: - Throw an error.
- Nếu
edgescó length lớn hơnlast: - Phân chia
edgestheo length củalastbằng cách loại bỏ các edges từ đầuedges. - Return
edges.
ApplyCursorsToEdges(allEdges, before, after) - Không bắt buộc
- Khởi tạo
edgeslàallEdges. - Nếu
afterđược thiết lập: - Let
afterEdgebe the edge inedgeswhosecursoris equal to theafterargument. - Nếu
afterEdgetồn tại: - Loại bỏ mọi thành phần của
edgesbefore and includingafterEdge. - Nếu
befoređược thiết lập: - Let
beforeEdgebe the edge in edges whose cursor is equal to thebeforeargument. - If
beforeEdgeexists: - Remove all elements of
edgesafter and includingbeforeEdge. - Return edges.
Những vấn đề khác
Các vấn đề khác chúng ta phải xử lý, đối với vấn đề phân trang là:
Cập nhật dữ liệu mới
Khi dữ liệu được cập nhật (tạo, xóa) và ta đang ở trang thứ n cần đưa ra giải pháp cập nhật trang sao cho không ảnh hưởng đến trải nghiệm người dùng.
Cache
- Khoảng trống khi dữ liệu cache nằm ở trang
n
Tham khảo
https://graphql.org/learn/pagination/
https://slack.engineering/evolving-api-pagination-at-slack/
https://jacobruiz.com/blog/2019/6/19/graphql-pagination-opaque-cursors
https://hackernoon.com/guys-were-doing-pagination-wrong-f6c18a91b232
https://www.sitepoint.com/paginating-real-time-data-cursor-based-pagination/