使用 GraphQL,幾乎可以肯定會遇到一個含有項目列表 (lists of items) 的應用程序,稱為分頁 (Pagination) 功能。
在應用程序中存儲的用戶照片會變成很長的列表,並且當用戶端應用程序請求顯示照片時,立即從資料庫中檢索所有照片可能會導致嚴重的性能瓶頸。分頁允許您將項目列表拆分為多個列表,稱為頁面 (pages)。 通常用限制 (limit) 和偏移量 (offset) 定義頁面。 這樣,您可以以項目頁面 (one page of items) 進行請求,而當用戶希望查看更多項目時,可以請求另一頁項目。
有兩種不同的分頁方法可在 GraphQL 中實現分頁。 第一種方法是較簡單的方法,稱為 Offset/Limit-based Pagination 的分頁方法。 另一種進階的方法是 Cursor-based 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 分頁中,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' ); } 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); 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)