0%

建立 GraphQL API (7)

分頁 Pagination

使用 GraphQL,幾乎可以肯定會遇到一個含有項目列表 (lists of items) 的應用程序,稱為分頁 (Pagination) 功能。

在應用程序中存儲的用戶照片會變成很長的列表,並且當用戶端應用程序請求顯示照片時,立即從資料庫中檢索所有照片可能會導致嚴重的性能瓶頸。分頁允許您將項目列表拆分為多個列表,稱為頁面 (pages)。 通常用限制 (limit) 和偏移量 (offset) 定義頁面。 這樣,您可以以項目頁面 (one page of items) 進行請求,而當用戶希望查看更多項目時,可以請求另一頁項目。

有兩種不同的分頁方法可在 GraphQL 中實現分頁。 第一種方法是較簡單的方法,稱為 Offset/Limit-based Pagination 的分頁方法。 另一種進階的方法是 Cursor-based pagination 的分頁方法,這是在應用程序中允許分頁比較複雜的方法之一。

Offset/Limit Pagination

Offset/Limit-based pagination 並不是很難實現。 limit 說明您要從整個列表中檢索多少個項目,offset 則說明要從整個列表中開始的位置。 使用不同的偏移量 (offset) ,可以在整個項目列表中移動並檢索具有限制 (limit) 數量的子列表(頁面)。

看起來似乎很簡單,但這也需要資料庫端的實作,以下是 PostgreSQL SQL:

PostgreSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
db01=> select empno, ename from emp
db01-> order by empno;
empno | ename
-------+--------
7369 | SMITH
7499 | ALLEN
7566 | 陳賜珉
7607 | バック
7608 | 馬小九
7609 | 蔡大一
7654 | 葉習堃
7698 | BLAKE
7782 | 陳瑞
7788 | SCOTT
7839 | KING
7844 | 하찮고
7876 | ADAMS
7900 | JAMES
7902 | FORD
7934 | 楊喆
8907 | 牸祢
9006 | 李逸君
9011 | 文英蔡

加上 offset 與 limit,注意這裡的 order by 是必須的。offset 是從哪個位置開始,limit 則是限制筆數。

1
2
3
4
5
6
7
8
9
10
11
db01=> select empno, ename from emp
db01-> order by empno
db01-> offset 5 limit 5;
empno | ename
-------+--------
7609 | 蔡大一
7654 | 葉習堃
7698 | BLAKE
7782 | 陳瑞
7788 | SCOTT
(5 rows)

PostgreSQL 一直都有支援 SQL 分頁功能,Oracle 則直到 Oracle 12c 才有支援,在 12c 之前則需使用一些技巧。如果未來是以 Web App 為策略,則 Oracle Database 升級要盡早規畫。以下在 YK11 的 SQL:

Oracle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
YK11> select empno, ename from apexdemo.emp
2 order by empno;

EMPNO ENAME
---------- ------------------------------
7369 史密斯
7499 ALLEN
7521 WARD
7566 JONES
7654 馬丁
7698 布萊克
7782 陳瑞
7788 SCOTT
7839 KING
7844 TURNER
7876 ADAMS
7900 陳瑞
7902 福特
7934 米勒

14 rows selected.

Oracle 的 limit 有一些不同:

1
2
3
4
5
6
7
8
9
10
11
YK11> select empno, ename from apexdemo.emp
2 order by empno
3 offset 5 rows fetch next 5 rows only;

EMPNO ENAME
---------- ------------------------------
7698 布萊克
7782 陳瑞
7788 SCOTT
7839 KING
7844 TURNER

有了資料庫端的配合,GraphQL 就可以加入分頁的功能,例如:

Pagination Example
1
2
3
4
5
6
7
8
...
module.exports = gql`
extend type Query {
totalPhotos: Int!
allPhotos(offset: Int, limit: Int): [Photo!]!
}
...
...

即使這種方法比較簡單,它也有一些缺點。當偏移量變得很大時,資料庫查詢將花費更長的時間,這可能會導致 UI 等待下一頁數據時客戶端性能下降。 另外,Offset/Limit-based pagination 無法處理兩次查詢之間的已刪除項目。例如,如果您查詢某一頁並且有其他人刪除了你已查詢過的某個項目,則下一頁上的偏移量將是錯誤的,因為該項目的數量減少了一個。 使用 Offset/Limit-based pagination 無法輕鬆克服此問題,這就是為什麼可能需要 Cursor-based pagination 的原因。

Cursor-based Pagination

在 Cursor-based pagination 分頁中,offset 被一個稱為 cursor 的標識符所取代,而不像 Offset/Limit pagination 的項目進行計數。Cursor 可以用來表示 “從 Cursor Y 開始給我 limit 個項目的列表”。 所以 cursor 儲存的是一個欄位的值,用來取代 offset,當要求下一個分頁時使用 cursor 當為起使值,你可以使用 Primary Key,或使用日期(例如,資料庫中某個項目的創建日期)來標識列表中項目,這都是常用的方法。這裡要注意,資料庫 SQL 返回的資料不能保證其順序,所以 order by 通常都是必須的。

在我們的例子,照片的 ID 是按時間建立的,所以就使用它當成 cursor 的值。如果你的 ID 是使用 uuid 亂碼產生的,你則需要新增一個例如 createdAt 的時間欄位當作是 cursor 的值。在資料庫中記得加個 index 在 createdAt 欄位上。

這裡不管使用 ID 或 createdAt,有一個重要的特性,兩個欄位的值都不會改變,所以 cursor 具有穩定的位置,不像 offset 的不定性,有可能會有不同的起始值。

GraphQL 伺服器以標準化的方式公開它的連接 (Connections)。 在查詢中,連接模型 (connection model) 提供了用於對結果集進行切片和分頁的標準機制。 在響應回應中,連接模型提供了一種提供 cursor 的標準方法,以及一種在出現更多結果時告訴客戶端的方法。

連接模型有一些規範讓我們可以遵循,細節可以參考 Relay Cursor Connections Specification,裡面有詳盡的介紹。

這裡我們要來新增一個可以有分頁功能的照片清單。首先我們看一下資料庫模組抽象層程式,如何用來支援前端的分頁功能。

models/database.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
Database.prototype.findAll = function( opts = {}) {
return new Promise((resolve, reject) => {
const options = { ...opts };
let _this = this;
let results = [];
try {
_this._db
.createValueStream( options )
.on("data", value => results.push(value))
.on("end", () => resolve(results))
.on("error", err => reject(err));
} catch (err) {
reject(err);
}
});
};
...

findAll( ) 函式我們預留了可選擇性參數的物件 opts,在第 9 行呼叫時會使用到此選擇性參數物件,這個選擇性參數物件可以包含以下的特性:

  • gt(大於),gte(大於或等於):定義資料流傳輸範圍的下限。 此範圍內僅包含鍵 (Key) 大於(或等於)此選項的項目。 當 reverse = true 時,順序將顛倒,但 stream 傳輸的項目將相同。
  • lt(小於),lte(小於或等於):定義資料流傳輸範圍的上限。 該範圍內僅包含鍵小於(或等於)此選項的項目。 當 reverse = true 時,順序將顛倒,但資料流傳輸的項目將相同。
  • reverse (boolean, default: false):以相反的順序讀取資料流項目。 注意,由於像 LevelDB 這樣的存儲方式,反向搜索可能比正向搜索要慢。
  • limit(number,default:-1):限制此資料流的項目數。 此數字代表最大項目數,如果您先到達範圍末尾,則返回的項目可能少於 limit 數。 值 -1 表示沒有限制。 當 reverse = true 時,將返回具有最高鍵的項目,而不是最低鍵。

我們可修改原來的 Query Schema 的 allPhotos,但是我們先保留它,以免這裡出了問題無法回復,尤其如果是線上系統,可能會造成問題。所以我們要新增一個新的 Query Schema,這樣可以較無壓力的轉換。在 schema 子目錄下新增 photoConnection.js :

schema/photoConnection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
allPhotosWithPagination(
after: String
first: Int
before: String
last: Int
): PhotoConnection!
}

type PhotoConnection {
pageInfo: PageInfo!
edges: [PhotoEdge!]!
totalCount: Int
photos: [Photo!]!
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

type PhotoEdge {
node: Photo!
cursor: String!
}
`;

這裡的 Query allPhotosWithPagination 提供了 4 個參數 after、first、before 與 last。

  • after 與 first 是一對的,支援下一頁的功能。
  • before 與 last 是一對的,支援上一頁的功能。

因此 first 與 last 不能同時用。而它會返回一個 Connection 模型物件 PhotoConnection。

Connection 模型中一個分頁稱為一個 Connection,對於 Connection 返回的資料有一些規範,一定要包含某些特性,需要包含:

  • pageInfo:
    • startCursor
    • endCursor
    • hasPreviousPage
    • hasNextPage
  • edges : 這是一個陣列,包含所返回的項目,也就是目前這一頁(Connection)的項目。
    • node : 實際返回的資料項目,這裡就是實際照片的資料。
    • cursor : cursor 的值,在這個例子就是照片的 ID key 值,通常會做 base64 編譯,這樣對資料隱匿性比較好,也比較不容易讓前端誤會資料的用途。

除了規範的特性,你也可加入任何的資訊,這裡我們加入了 totalCount 與 photos。totalCount 是資料庫中照片的總數,photos 的資料與 edges 中的 node 相同,如果你不需要分頁功能則可以直接使用這個 photos 即可。

將它併接到 schema/index.js

schema/index.js
1
2
3
4
...
const photoConnectionSchema = require('./photoConnection');
...
module.exports = [linkSchema, userSchema, photoSchema, photoConnectionSchema, subscriptionSchema];

再來我們要實作 allPhotosWithPagination 的解析函式,這是分頁功能複雜的地方:

resolvers/photoConnection.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
const toCursorHash = string => Buffer.from(string).toString('base64');
const fromCursorHash = string => Buffer.from(string, 'base64').toString('ascii');

module.exports = {
Query: {
allPhotosWithPagination: async (parent, { after, first, before, last }, { db: { photos } }) => {
let result, allCount, unlimitedCount;
const opts = {};

if (typeof after === 'string' && typeof before === 'string' ) {
throw new Error('after and before cannot use same time');
}
if (typeof first === 'number' && typeof last === 'number') {
throw new Error('first and last cannot use same time');
}
if (typeof after === 'string' && typeof last === 'number') {
throw new Error('after must be with first');
}
if (typeof before === 'string' && typeof first === 'number') {
throw new Error('before must be with last');
}

// Forward pagination
if (typeof first === 'number') {
if (first < 0 ) throw new Error('first cannot be less than 0');
opts.limit = first
};
if (typeof after === 'string') opts.gt = fromCursorHash(after);

// Backward pagination
if (typeof last === 'number') {
if (last < 0 ) throw new Error('last cannot be less than 0');
opts.limit = last;
}
if (typeof before === 'string') opts.lt = fromCursorHash(before);
if (typeof last === 'number' || typeof before === 'string') opts.reverse = true;

try {
allCount = await photos.allCount();
unlimitedCount = await photos.allCount({ ...opts, limit: undefined });
result = await photos.findAll( opts );
} catch (error) {
throw error;
}

if (opts && opts.reverse) result.reverse();

const edges = result.map(value => {
return {
node: { ...value },
cursor: toCursorHash( value.id )
};
});

return {
pageInfo: {
startCursor: edges.length > 0 ? edges[0].cursor : undefined,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : undefined,
hasPreviousPage:
typeof last === 'number'
? last < unlimitedCount
: ( typeof before === 'string' ? false : allCount > unlimitedCount),
hasNextPage:
typeof first === 'number'
? first < unlimitedCount
: ( typeof after === 'string' ? false : allCount > unlimitedCount)
},
edges,
totalCount: allCount,
photos: result
};
}
}
};
  • 第 1、2 行是 base64 編譯與解意函式。
  • 第 6 行傳入了 4 個參數 after、first、before 與 last。after 與 first 是一對的,支援下一頁的功能,before 與 last 是一對的,支援上一頁的功能,因此 first 與 last 不能同時用。 第 10 到 21 行就是在檢查這些關聯。
  • 第 24 到 28 行設定下一頁的資料庫執行參數。
  • 第 30 到 36 行設定上一頁的資料庫執行參數。注意 36 行的 opts.reverse 設定為 true。所以會以相反的順序讀取資料流項目
  • 第 39 行是資料庫中所有照片的筆數。
  • 第 40 行可以知道還剩多少筆數,可讓我們知道是不是還有下一頁或上一頁。
  • 第 41 行是實際返回的資料項目。
  • 第 46 行如果使用 last,它是相反方向搜尋的,返回的順序也是相反的,將它迴轉,以免客戶端顯示的順序一團亂。
  • 第 55 行返回 PhotoConnection 型態的物件。

將解析函式併接到 resolvers/index.js

resolvers/index.js
1
2
3
4
...
const photoConnectionResolvers = require('./photoConnection');

module.exports = [userResolvers, photoResolvers, subscriptionResolvers, photoConnectionResolvers];

現在可以由 GraphQL Playground 測試一下,仔細觀察一下返回的資料:

Playground
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
query allPhotos {
allPhotosWithPagination (
first: 5
) {
totalCount,
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
edges {
node {
id
created
postedBy {
appLogin
}
}
cursor
}
photos {
id
}
}
}

這個查詢我們只用了一個引數 first: 5,所以會返回第一筆到第 5 筆的資料,資料就類似如下:

GraphQL Query result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"data": {
"allPhotosWithPagination": {
"totalCount": 16,
"pageInfo": {
"startCursor": "MQ==",
"endCursor": "MTU3NjEwOTIxNTk3Nw==",
"hasNextPage": true,
"hasPreviousPage": false
},
"edges": [
{
"node": {
"id": "1",
"created": "2018-03-27T16:00:00.000Z",
"postedBy": {
"appLogin": "blake"
}
},
"cursor": "MQ=="
},
...

如果需要下一頁的資料就必須加入 after 引數,它的值則是上一個查詢中 pageInfo.endCursor 的值。hasNextPage 與 hasPreviousPage 則可讓你控制應用程式的上一頁與下一頁的按鈕。

Playground
1
2
3
4
5
6
query allPhotos {
allPhotosWithPagination (
first: 5
after: "MTU3NjEwOTIxNTk3Nw=="
) {
...

使用分頁功能時一定會有一些效能上的損失,這不只會出現在客戶端,資料庫端的效能耗損也需注意,使用上須衡量一下。

接續下一篇 建立 GraphQL API (8)