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ảedges
trả 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ảedges
trả 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
edges
hiệ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
edges
hiệ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:
first
là số lượng dữ liệu cần lấy từ truy vấn, nó tương tự vớilimit
trong phân trang dạng offset.after
là 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=null
nê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:
last
là số lượng dữ liệu cần lấy từ truy vấn.before
là 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
edges
hiện tại có còn dữ liệu nữa không. - Khi client truy vấn bằng
last/before
, nếuedges
trước nó tồn tại, server sẽ trả thông tintrue
và ngược lại. - Khi client truy vấn bằng
first/after
, server sẽ trả thông tintrue
nếu edges trướcafter
tồn tại, và ngược lại. - HasNextPage: Cho biết kế tiếp cụm dữ liệu
edges
hiện tại còn dữ liệu nữa hay không. - Khi client truy vấn bằng
first/after
, nếuedges
tiếp theo tồn tại, thì nó sẽ trả làtrue
và ngược lại. - Khi client truy vấn bằng
last/before
, nếuedges
tiế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ó
edges
là kết quả khi gọi ApplyCursorsToEdges (allEdges, before, after). - Nếu
first
được thiết lập: - Nếu
first
nhỏ hơn0
: - Throw an error.
- Nếu
edges
có length lớn hơnfirst
: - Phân chia
edges
theo length củafirst
bằng cách loại bỏ các edges từ cuốiedges
. - Nếu
last
được thiết lập: - Nếu
last
nhỏ hơn0
: - Throw an error.
- Nếu
edges
có length lớn hơnlast
: - Phân chia
edges
theo length củalast
bằ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
edges
làallEdges
. - Nếu
after
được thiết lập: - Let
afterEdge
be the edge inedges
whosecursor
is equal to theafter
argument. - Nếu
afterEdge
tồn tại: - Loại bỏ mọi thành phần của
edges
before and includingafterEdge
. - Nếu
before
được thiết lập: - Let
beforeEdge
be the edge in edges whose cursor is equal to thebefore
argument. - If
beforeEdge
exists: - Remove all elements of
edges
after 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/