深入淺出 GraphQL Pagination 實作

Dec 06, 2019

嗨,好久不見!今年到現在的文章產量真的是低到可憐,有些東西一直很想要寫都拖了許久甚至懶得寫,但覺得還是要秉持自己當初寫部落格的初衷,為自己遇到問題留下一些腳印,幫助自己說不定可以幫助到和我遇到一樣問題的人。

從 GraphQL 問世之後,對於它一直處於一個似懂非懂的狀態,以前只有寫過一些簡單的應用感受 GraphQL,並沒有在真實的產品上做到任何的應用,剛好現在公司很早就導入了 GraphQL,產品都大量的採用 GraphQL,讓我有這個機會可以更進一步認識。

前陣子被分配到一個任務是關於實作分頁(Pagination)的任務,理所當然是利用 GraphQL 來實作,一開始以為產品中應該已經有人實作過相關的功能了,但一查之下發現都沒有 😂,所以必須自己從頭開始研究,接下來想分享一些我在研究和實作的一些心得。

分頁的類型

Offsets

以前自己在學習寫網頁時,關於如何實作分頁這件事,只知道使用 OFFSET + LIMIT 的方式來完成,但那時候對於效能並沒有什麼很好的概念,後來才知道 OFFSET + LIMIT 在資料量大的時候會非常地緩慢,也會造成資料庫的極大負擔。

這個原因主要是當資料量大時,你所設定的 OFFSET 實際上資料庫還是會一筆一筆的去讀取,直到讀到你所設定的「偏移量」,但前面所有讀取的根本就用不到,這些都造成了計算的浪費。以下是一個範例:

SELECT
*
FROM
artists
WHERE
country = 'USA'
ORDER BY
id DESC
LIMIT 5 OFFSET 5;

假設我有一個 artists 的 table,我需要查詢藝人的 countryUSA 的,每一次只搜尋 5 筆(LIMIT 5)並根據 id 作為排序,而目前在第二頁(OFFSET 5):

如上圖,假設 B 使用者目前位置是第 2 頁,當 A 使用者同時在某處新增 1 位藝人時,B 使用者有可會在下一頁(第 3 頁)又看到重複的藝人資料 Artist J,這是因為新增的資料會被排序到更前面(可能會在第一頁),所以會發現 OFFSET + LIMIT 不能有效地處理這類的情況。

當然 offsets 的方式還是有它的優點存在,例如:計算資料的總數量、目前頁數,或者是可以跳到指定的頁數。

Cursors

基於 Cursor 的分頁(Cursor-based pagination)是透過指定明確的起始點(Pointer)來回傳資料,這個方法解決了 OFFSET 方式的一些缺點,但是這需要一些取捨:

  • Cursor 必須基於一個「唯一」或是「有序」的欄位(例如:id 或是 created_at
  • 它沒有「總和」和「頁數」的概念

在資料表中,這個「唯一」不一定是指單一的欄位,也可以是兩個欄位作為一個唯一。

將先前的範例改寫為使用 cursor-based 的分頁方式,以 id 作為 cursor 來取得資料,以下是取得第 1 頁的資料:

SELECT
*
FROM
artists
WHERE
country = 'USA'
ORDER BY
id DESC
LIMIT 5 + 1;

應該注意到了在 LIMIT 的部分有一點不一樣,先前的範例中我們一頁想要取得 5 筆資料,但為什麼要加 1 呢?

💡 主要為了確定是否還有上(下)一頁的資料,但這一筆「多取得」的資料並不會回傳給 client 端

當處理完資料之後,這時候 server 會回傳類似如下的 response 給 client:

{
"data": "[...]",
"cursor": "bd66b4d5c168b85676f38eeb9a4b0678"
}

如上所述,cursor-based 它沒有「總和」和「頁數」的概念,因為每一次回來都是一個資料的集合。

Client 在收到 response 後,就可以在每一次的 request 中,藉由設定 cursorlimit 來繼續取得資料:

SELECT
*
FROM
artists
WHERE
country = 'USA'
AND id <= $cursor
ORDER BY
id DESC
LIMIT $limit + 1;

與 offsets 的方式做比較,可以發現 cursors 解決了 offsets 的一些缺點:

  • 藉由 cursors 可以很明確直接的指定資料的範圍從哪開始;相較於 offsets 需要讀取每一行直到設定的偏移量,這在資料量大時可以減少 database 的負荷。
  • 對於 database 可能會被頻繁寫入資料的時候,offsets 可能會因為資料的新增刪除造成資料的排序錯誤。

利用下圖作為一個情境,來解釋 cursors 是如何確保資料正確的排序:

例如某 A 使用者剛進入到頁面(Page 1),他點擊了下一頁的按鈕,這時候會發出一個 request 透過 Artist E 的 cursor 去取得下一頁的資料,與此同時,某 B 使用者在別處新增了一個 Artist G_g,而它的 id 剛好位於 Artist G 以及 Artist H 之間,這時候從 Page 1 往下一頁拿 5 筆資料時,的排序應該會是:

Artist F | Artist G | Artist G_g | Artist H | Artist I

而 Artist J 則會因為新增的 Artist G_g 的關係,將會出現在第 3 頁。

Cursor Connections

根據 GraphQL 官方文件,可以得知在 GraphQL 中實作分頁會透過 cursor-based 的方式,通常會遵循 Relay Cursor Connections spec 來定義 GraphQL schema,以下幾個是必要的欄位:

Connection Type

  • edges
  • pageInfo

Edge Type

  • node
  • cursor

PageInfo

  • hasNextPage
  • hasPreviousPage

我用一個 Artist 的 schema 來作為一個簡單的範例:

type Artist {
id: String!
name: String!
avatar: String
createdAt: DateTime!
updatedAt: DateTime!
}
type ArtistsConnection {
edges: [ArtistEdge!]!
pageInfo: PageInfo!
}
type ArtistEdge {
node: Artist!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}

每一個分頁都是一個 ConnectionConnection 底下會有許多的 Edge,而每個 Edge 都會有一個 Node,而這個 Node 也就是我們實際的資料,在這裡指的是 Artist,而 cursor 則是用來辨識 Edge,通常會將 cursor 做 encode,而 PageInfo 則是每一頁的資訊。

Pagination

Query String

以往在實作分頁,都會在網址上設計 query string(例如: https://foobar.com/?page=1)來方便的換頁,只要修改 page 後的數字就可以跳到指定的頁數。如前面所提到,這個實作通常是透過 offsets 的方式,所以你可以讓你隨心所欲換到想要的頁數,這裡推薦一下 Laravel Pagination 的文件,有興趣可以了解一下。

在 GraphQL 中,你不一定會將參數顯示在網址上,因為所有的參數很有可能都透過 GraphQL 的 query 一起被送出去(使用 POST 方式),不過還是要看 routing 是怎麼設計的,也許也有些參數需要在網址上,這一切都要看需求而去設計,沒有所謂的正確。

GitHub GraphQL API

GitHub 提供一個 GraphQL API Explorer,只要你有 GitHub 帳號,登入授權後就可以使用。例如你可以使用 curl 取得 GitHub 的個人資料:

$ curl \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: bearer GithubAccessToken" \
--data '{ "query": "{ viewer { name } " }' \
https://api.github.com/graphql
  • -X 代表的是 http 的 method
  • -H 代表的是 http 的 header
  • --data 代表的是要傳送的資料

下圖則是 GraphiQL 的互動介面:

💡 這裡就不闡述如何操作 GraphQL 查詢,有興趣可以到 GraphQL 官方教學HOW TO GRAPHQL

透過 GraphiQL 可以知道有哪些欄位是可以被查詢到的,本篇文章以 Pagination(分頁)作為主題,所以選了 repositories 來作為說明的範例,並且簡單分析 GitHub 如何實作 GraphQL Pagination。

首先,透過 Document Exploer 從 viewer 開始看起,你會發現 viewer 下有許多欄位,請往下捲動找到 repositories 的欄位:

Viewer Repositories Connection

可以看到 repositories 提供了許多參數讓你可以輸入,而回傳的 type 是 RepositoryConnection,這符合了上方我們所提到的 Connection 的 schema 定義,點進去後可以看到:

Repository Connection

眼尖的你不知道有沒有注意到 edges 它並不是 required?這是不是跟上面所提到的規範好像不太一樣?實際在上實作的時候這些都是有彈性的,不一定要完整的遵循規範,所以像 GitHub 在實作這裡就採用了不同的方式。

除了上述規範提到的 edgespageInfo 是必須的之外,其餘的欄位可以根據需求自行增加,如上圖可以看到還有其他像是 totalCount 等其他欄位。

在 GraphQL Pagination 中,會透過 firstlast 來設定要取得的資料筆數,以 GitHub GraphQL API 作為範例:

{
viewer {
name
repositories(first: 3) {
edges {
node {
name
createdAt
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
}
}
}

執行查詢後,可以拿回如下的資料:

{
"data": {
"viewer": {
"name": "Peng Jie",
"repositories": {
"edges": [
{
"node": {
"name": "Kaku",
"createdAt": "2015-02-17T04:07:29Z"
},
"cursor": "Y3Vyc29yOnYyOpHOAdeHCg=="
},
{
"node": {
"name": "neighborhood999.github.io",
"createdAt": "2015-06-03T03:35:51Z"
},
"cursor": "Y3Vyc29yOnYyOpHOAjEwKg=="
},
{
"node": {
"name": "redux",
"createdAt": "2015-07-14T15:57:27Z"
},
"cursor": "Y3Vyc29yOnYyOpHOAlRj9g=="
}
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"endCursor": "Y3Vyc29yOnYyOpHOAlRj9g=="
}
}
}
}
}

請仔細觀察資料的排序,是按照時間由舊至新,所以在這邊可以合理推測 repositories 的 first 實作可能是:

SELECT
*
FROM
artists
ORDER BY
created_at ASC
LIMIT $first;

💡 $first 用來代表參數的意思。

反之如果使用 last 作為參數資料的排序則是按照時間由新至舊

SELECT
*
FROM
artists
ORDER BY
created_at DESC
LIMIT $last;

在實作上對於資料的排序可以根據自己的需求做調整,例如你可能希望是由新到舊,那 first 的排序就應該是 DESC,而 last 的排序則是 ASC

另外,GitHub GraphQL API 也提供了 orderBy 參數讓你可以自行調整資料的排序方式,這些在你實作時都可以考慮提供這些參數讓資料排序可以更加地彈性。

實作 GraphQL Pagination 後端

在開始前,先釐清一些基礎的觀念:

  1. 至少提供 firstlast 其中一個參數在查詢之中,不建議同時提供兩個參數進行查詢,這容易造成資料排序的混淆。
  2. 下一頁(Next Page):需要透過 first + after 這兩個參數作為搭配,其中 first 需要為正整數,而 after 是接受一個 cursor type 的參數並使用最後一個 edge 的 cursor 來作為 after
  3. 上一頁(Previous Page):需要透過 last + before 這兩個參數作為搭配,其中 last 需要為正整數,而 before 是接受一個 cursor type 的參數並使用第一個 edge 的 cursor 來作為 before

以上圖為例,一開始取得第一頁資料的時候會是:

{
artists(first: 5) {
edges {
node {
name
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
}
}

若要取得第二頁就必須透過 Artist E 的 cursor:

{
artists(first: 5, cursor: $artistECursor) {
edges {
node {
name
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
}
}

首先,在每次查詢的時候,至少都要提供 firstlast 其中一個參數,作為你要取得資料的筆數,而官方不建議同時提供 first 以及 last 參數,因為它會造成導致混亂的查詢結果,當然它們所搭配的參數 afterbefore,也要避免同時存在。

所以在查詢時,先檢查這些相關的參數,並給予對應的錯誤處理或回應:

const resolver = {
Query: {
artists: async (root, args, context, info) => {
const { first, last, after, before } = args;
if (!first && !last) {
/* Handling errors */
}
if (first && last) {
/* Handling errors */
}
if (after && before) {
/* Handling errors */
}
}
}
};

🔶 實作 first 的查詢

首先新增了三個參數:artistshasNextPagehasPreivousPage,它們是需要用到且是會變動的。

let artists;
let hasNextPage;
let hasPreviousPage;
if (first && !after && !before) {
const data = await knex('artists')
.orderBy('id', 'DESC')
.limit(first + 1);
hasPreviousPage = false;
hasNextPage = data.length > first;
artists = hasNextPage ? data.slice(0, -1) : data;
}

請注意到 SQL query builder 的 limit,前面已經提過,主要原因是為了確認是否還有下一頁(hasNextPage)的資料,接著看到程式下半部分:

hasPreviousPage = false;
hasNextPage = data.length > first;
artists = hasNextPage ? data.slice(0, -1) : data;

因為目前只有提供 first 的參數,所以可以很明確的知道目前就是在首頁,所以先將 hasPreviousPage 設定為 false,而取得的資料長度若大於給定的 first,則可以確定有下一頁,而實際上的資料則是要移除掉這一筆多取得的資料,first 部分簡單初步的實作就完成了。

🔶 實作 first + after 的查詢

if (first && after) {
const data = await knex('artists')
.where('id', '<', decodeCursor(after))
.orderBy('id', 'DESC')
.limit(first + 1);
hasPreviousPage = true;
hasNextPage = data.length > first;
artists = hasNextPage ? data.slice(0, -1) : data;
}

為了要取得下一頁的資料,將會需要 after cursor。通常 cursor 不會是一個 readable 的資料,前端看到的是被 encode 過的 cursor,直到傳到後端之後才被 decode。

仔細看可以發現實作和 first 相去不遠,差異在查詢以及頁面狀態的 flag

在範例中是使用 id 作為排序的根據,從前端傳的 cursor 實際上到後端會被 decode,所以在這邊需要一個 decodeCursor 的 function 來 decode 傳入的 cursor。

當拿到實際的 cursor 後,將它放入在 where 的條件句,就可以指定明確的資料範圍了。

🔶 實作 last 的查詢

if (last && !before && !after) {
const data = await knex('artists')
.orderBy('id', 'ASC')
.limit(last + 1);
}

在實作 last 部分需要注意到資料排序的問題,因為 last 這邊是使用 ASC 對資料進行排序,也就是舊到新。

這裡牽扯到了「後端要回傳怎麼樣的資料給前端」,以 GitHub GraphQL API 為例:如果使用 firstASC 排序,使用 last 會是 DESC 排序,以設計 API 來說沒有什麼問題;但以我上面的範例來說,如果 last 的排序沒有再做一層處理,而前端在接收到資料也沒有進行處理排序的話,那麼資料順序就會變得非常奇怪。

這麼說可能有點抽象,我用以下圖片來做解釋:

當我從 Page 1 到 Page 2 時,利用上方 first + after 的實作,可以拿到正確的資料排序:

當我要從 Page 2 再回到 Page 1 時,如果是用 ASC 的話,得到的排序結果會變成:

資料完全反了過來!

這裡的處理方式就是要看這個資料的排序要從後端處理或者是由前端拿到資料後再進行排序的選擇,我自己是選擇前者來實作,也就是從後端直接處理,所以上面的查詢方式應該修改為:

if (last && !before && !after) {
const subQuery = knex('artists')
.orderBy('id', 'ASC')
.limit(last + 1);
const data = await knex
.from(knex.raw(`(${subQuery}) AS artists`))
.orderBy('artists.id', 'DESC');
hasNextPage = false;
hasPreviousPage = data.length > last;
artists = hasPreviousPage ? data.slice(1) : data;
}

可以看到我先寫了一個查詢式 subQuery,將它作為一個子查詢,概念上和 first 一樣,會多取得一筆來確定是否有前一頁(hasPreviousPage),接著再做一次資料的翻轉(DESC),最後再把這一筆多取得的資料給去除掉,這時候多餘的資料因為翻轉後會在第一個,所以要寫成 data.slice(1) 取得第一筆之後的資料。

🔶 實作 last + before 的查詢

if (last && before) {
const subQuery = builder
.where('id', '>', decodeCursor(before))
.orderBy('id', 'ASC')
.limit(last + 1);
const data = await knex
.from(knex.raw(`(${subQuery}) AS artists`))
.orderBy('artists.id', 'DESC');
hasNextPage = true;
hasPreviousPage = data.length > last;
artists = hasPreviousPage ? data.slice(1) : data;
}

上一頁查詢的部分,其實和上面 last 的概念一樣,只是多了 cursor 來指定資料範圍,可以參考 first + after 部分,就不再多作闡述。

整理一下以上的實作:

const resolver = {
Query: {
artists: async (root, args, context, info) => {
const { first, last, after, before } = args;
if (!first && !last) {
/* Handling errors */
}
if (first && last) {
/* Handling errors */
}
if (after && before) {
/* Handling errors */
}
let artists;
let hasNextPage;
let hasPreviousPage;
if (first && !after && !before) {
const data = await knex('artists')
.orderBy('id', 'DESC')
.limit(first + 1);
hasPreviousPage = false;
hasNextPage = data.length > first;
artists = hasNextPage ? data.slice(0, -1) : data;
}
if (first && after) {
const data = await knex('artists')
.where('id', '<', decodeCursor(after))
.orderBy('id', 'DESC')
.limit(first + 1);
hasPreviousPage = true;
hasNextPage = data.length > first;
artists = hasNextPage ? data.slice(0, -1) : data;
}
if (last && !before && !after) {
const subQuery = knex('artists')
.orderBy('id', 'ASC')
.limit(last + 1);
const data = await knex
.from(knex.raw(`(${subQuery}) AS artists`))
.orderBy('artists.id', 'DESC');
hasNextPage = false;
hasPreviousPage = data.length > last;
artists = hasPreviousPage ? data.slice(1) : data;
}
if (last && before) {
const subQuery = builder
.where('id', '>', decodeCursor(before))
.orderBy('id', 'ASC')
.limit(last + 1);
const data = await knex
.from(knex.raw(`(${subQuery}) AS artists`))
.orderBy('artists.id', 'DESC');
hasNextPage = true;
hasPreviousPage = data.length > last;
artists = hasPreviousPage ? data.slice(1) : data;
}
return {
edges: artists,
pageInfo: {
hasNextPage,
hasPreviousPage
}
};
}
}
};

最後記得要回傳 ArtistsConnection 所要求的 edgespageInfo 的資料。

前端整合

如果是使用 apollograhql 的話,可以透過 fetchMore 的 function 可以讓你輕鬆的取得上(下)頁的資料,例如取得下一頁:

fetchMore({
query: YOUR_QUERY,
variables: {
first: 5,
after: 'first cursor'
},
updateQuery: (previousResult, { fetchMoreResult }) => {
/*...*/
}
});

💬 踩雷經驗

時間精度

在 JavaScript 中,要取得 ISO 的時間格式,可以透過 toISOString 的 function 取得:

console.log(new Date.toISOString()); // 2019-12-06T07:50:35.816Z

這是簡化ISO 8601 時間格式,尾綴的 Z 表示為世界標準時間,詳細可以參考 MDN - Date.prototype.toISOString() 說明。

從上面列印的結果,可以知道 JavaScript 的時間精度只能表示到毫秒(millisecond),也就是小數點後 3 位

目前公司是使用 PostgreSQL 作為資料庫,而 PostgreSQL 的 時間精度可以表示到微秒(microsecond)

一開始不了解 PostgreSQL 和 JavaScript 它們的時間精度差異,所以使用了 created_at 作為 cursor 來使用,例如前端拿回了以下的 data:

{
"data": {
"artists": {
"edges": [
{
"node": {
"name": "Artist A",
},
"cursor": "b4582d31b37192f1067ff129a3854bbb"
},
{
"node": {
"name": "Artist B"
},
"cursor": "34e6d858e1acdb3e66209178c225d3b1"
},
{
"node": {
"name": "Artist C"
},
"cursor": "d8f0c4abacdccd3719f6abf3aa7ec1e5"
}
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "b4582d31b37192f1067ff129a3854bbb",
"endCursor": "d8f0c4abacdccd3719f6abf3aa7ec1e5"
}
}
}
}

每個 cursor 實際上都是 created_at 被 encode 的結果,pageInfostartCursorendCursor 這兩個欄位分別是 artists 資料集的頭和尾 cursor。

接著可以拿其中的一個 cursor 透過 fetchMore 去取得上(下)頁的資料,這些 cursor 傳到後端後會被 decode 得到原先的資料(created_at)。

接著看到後端方面,從資料庫查詢出來的 created_at,它的時間如下:

2019-12-07 04:09:56.994393

時間精度可以到「微秒」。

通常 cursor 可以用 node 內建的 Buffer 來將時間給 encode 和 decode,例如:

Buffer.from('2019-12-07 04:09:56.994393Z').toString('base64'); // MjAxOS0xMi0wNyAwNDowOTo1Ni45OTQzOTNa

而當時我做了一個致命的動作,就是將這個時間又用 JavaScript 的 new Date 做了一次:

Buffer.from(new Date('2019-12-07 04:09:56.994393Z').toISOString()).toString('base64'); // MjAxOS0xMi0wN1QwNDowOTo1Ni45OTRa

接著,將以上這兩種方式給 decode 回來比較:

Buffer.from('MjAxOS0xMi0wNyAwNDowOTo1Ni45OTQzOTNa', 'base64').toString('ascii'); // 2019-12-07 04:09:56.994393Z
Buffer.from('MjAxOS0xMi0wNlQyMDowOTo1Ni45OTRa', 'base64').toString('ascii') // 2019-12-06T20:09:56.994Z

發現 decode 回來的結果是不同的,可以看到使用 new Date 方式的時間,時間單位只有到毫秒,這就是問題所在

如果你用後者的時間來作為 cursor,這一筆資料很有可能會重複出現,原因是 2019-12-07 04:09:56.994393Z 的時間相較於 2019-12-06T20:09:56.994Z相對較新的,後者其實可以將它看成 2019-12-06T20:09:56.994000Z,所以在資料庫查詢時,原本被作為 cursor 的這一筆資料會被重複查出來,造成資料的排序錯誤。

Reference