iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 19
4

header

隨著系統逐漸成長,資料量也逐漸上升,我們也會開始面臨資料呈現與管理的問題。過多的資料難以顯示且效能低落,因此我們開始思考如何在分批索取資料的同時,又能夠不影響使用體驗,而這就是 Pagination (分頁) 要做到的事情。而 Pagination 不只是單純的「要多少給多少」,還需要搭配額外的 Pagination information (分頁資訊) ,如分頁數、資料總數等等,那在 GraphQL 中該如何實作呢 ?

如果你會去翻翻一些知名公司釋出的 GraphQL API Explorer 如 GitHubShopify ,就可以發現在一些含有大量資料的概念在命名上都會在結尾加 Connection 以表示分頁,如 Github 要得到一個 User 的 Repository 就不是用 [Repository] 而是用 RepositoryConnection 來表示,如果點進去 RespositoryConnection 就會得到一張令人眼花撩亂的資料結構。

img

不過不用擔心,Connection 、 Edge 、 Cursor 這些用法不是 GitHub 發明的,而是遵照 Relay Connections Specification 去實作,所以跟著我底下的介紹,就能輕鬆上手 !

1. Offset/limit-based Pagination 簡單直覺的牛刀小試

說到分頁其實一開始直覺上會想使用 offset/limit-based pagination 而不是上述提到的例子。 offset/limit-based pagination 用 offset 來設立資料取得的起始點 (就是第幾個開始),再用 limit 去取得實際所需的數量。假設今天我們有個 Schema 如下:

type Post {
  id: ID!
  title: String
  body: String
  "Unix timestamp milliseconds (毫秒) 格式"
  createdAt: String
}

Query {
  posts(offset: Int = 0, limit: Int = 100)
}

還有 Resolver functins (這裏為了讓大家都能懂,因此以簡單的 sql 形式來做 demo ,並非實際的能跑的程式)

const resolvers = {
  Query: {
    posts(root, { offset, limit }, { db }) {
      return db.query('SELECT * FROM post LIMIT $1 OFFSET $2', [limit, offset]);
    }
  }
};

當我們每頁 posts 數量為 4 ,那第一頁的 query 就會如下:

query {
  posts(offset: 0, limit: 4)
}

接著就會出現前 4 筆 posts 資料。如果要拿第二頁資訊的話, query 會如下:

query {
  posts(offset: 4, limit: 4)
}

之後以此類推...是不是非常簡單呢!但是在簡單的背後也是要付出代價, offset/limit-based pagination 有以下缺點:

  1. 當 offset 越來越大時,每筆 db operation 會需要花更多的時間。

  2. offset/limit-based pagination 無法處理在換頁時被刪除或是新增的資料。

    假如如你在換下頁的前一秒有人刪除了第一筆資料,那就會導致你的下一頁資料「少了第一筆多了最後一筆」,也就是 offset 會自動加一。可見以下圖示,原本第二頁要 id 5, 6, 7, 8 結果拿到 6, 7, 8, 9。

offset-limit disadvantage

因此我們需要 Cursored-based Pagination 來確保我們拿到的每頁資料都如預期不會亂移位!

2. Cursored-based Pagination 複雜但全方位

在 Cursored-based Pagination , offset 變成 cursor 且 cursor 可以用來表示精確的起始點而非只是算數量,從 Offset/Limit-based Pagination 的「在 offset 數量後的資料取得 limit 數量的資料」變成 「從 cursor 這筆資料後取得 limit 數量的資料」

那接著就會有疑問,所以 cursor 要如何記錄資料位址呢? 使用 Cursored-based Pagination 時有一個非常重要的要求,那就是資料必須有明確且固定的排序機制,不然 cursor 就失去了紀錄位址的功能。

以 Post 來說,通常我們都會以創建順序來排序,新的在前舊的在後,所以就可以利用 createdAt 來做 cursor 紀錄點。當然也可以使用 id ,只是通常 id 會以 uuid 亂碼形式呈現,因此可以考慮與 createdAt 一起組合,但不太適合單獨撐場面。

那就讓我們看看新的 Schema:

Query {
  # 這裡的 cursor 僅能輸入相對應得 date 格式 (Unix timestamp milliseconds) 才能做正確比較
  post(cursor: String, limit: Int = 50): [Posts!]!
}

接著做 Resolver Function 時需注意,因為越新的(createdAt 越大)越前面,所以要挑出 cursor 後面的資料時, 都是選出比 cursor (這裡是 createdAt) 還要小的資料,詳情見以下程式碼:

const { UserInputError } = require('apollo-server');

const resolvers = {
  Query {
    posts: (root, { cursor, limit }, { db }) => {
      // 1. 如果有 cursor 就檢查 cursor 格式
      if (cursor && isNaN(new Date(Number(cursor)).getTime())) {
        throw new UserInputError('Incorrect Cursor Format')
      }
      // 2. 如果沒 curosr 就直接傳進 limit (第一頁); 有則加入 Where 判斷
      return cursor
        ? db.query('SELECT * FROM post WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2', [cursor, limit])
        : db.query('SELECT * FROM post LIMIT $1 ORDER BY created_at DESC', [limit]);
    }
  }
}

當我們分頁的每頁數量為 4 ,那第一頁的 query 就會如下

query {
  posts(limit: 4)
}

這時候取得最後一筆 (第 4 筆) 資料的 createdAt (假設是 1500000000004) ,那第二頁 query 則會是

query {
  posts(cursor: 1500000000004, limit: 4)
}

這樣即使在中途有人新增或是刪除資料,下一頁的資料都會是我們所期望的,如下圖所示:

img

3. Cursored-based Pagination 在 GraphQL: Connection 模式

這邊可能會有人開始質疑,這跟前面 GitHub 的範例不太一樣啊!別急,接下來讓我們為分頁添加一些資訊並調整一下資料結構,就會成為 GraphQL 的 Connection 模式 ,而這個模式可以讓我們更有組織地使用 Pagination 並且也支援前後跳頁。

首先在使用分頁時我們需要一些額外的分頁資訊 PageInfo

  1. 是否有下一頁 hasNextPage: Boolean!
  2. 是否有上一頁 hasPreviousPage: Boolean!
  3. 總頁數 totalPageCount: Int (通常非必要)

講完分頁資訊,我們會用 Edge 的概念來表達實際的資料,一個 Edge 會由

  1. 指標 cursor 與上面提到的方式雷同。

    不過因為 **cursor 本身設計並非 Huamn-Readable **,因此通常會做 base64 轉換,這樣對資料隱匿性也比較好,也比較不容易讓前端誤會資料的用途。

  2. 節點 node真正的實際資料 (終於!)

以上 PageInfoEdge 兩著結合後就會成為如下圖的結構 (非 Schema ):

PostConnection {
  edges [{
    cursor,
    node
  }, ...],
  pageInfo {
    hasNextPage,
    hasPreviousPage,
    totalPageCount
  }
}

以 Schema 來說會如下:

type PostConnection {
  "資料"
  edges: [PostEdge!]!
  "分頁資訊"
  pageInfo: PageInfo!
}

type PostEdge {
  "指標。通常為一串 base64 字元"
  cursor: String!
  "實際 Post 資料"
  node: Post!
}

type PageInfo {
  "是否有下一頁"
  hasNextPage: Boolean!
  "是否有上一頁"
  hasPreviousPage: Boolean!
  "總頁數"
  totalPageCount: Int
}

講完了 Type 形式,在參數上, Connection 模式在參數上也有一些特殊規定,因此在 Query.posts 就需要實作以下參數

  1. 下一頁用
    • first: Int 回傳開頭的前 N 筆資料
    • after: String 會回傳該 curosr 後面的資料。一定要搭配 first
    • 一頁 50 筆,第一頁 (first: 50) 、第二頁 (first: 50, after: cursor_50)
  2. 上一頁用
    • last: Int 回傳倒數的 N 筆資料。一定要搭配 before
    • before: String 會回傳該 curosr 前面的資料。一定要搭配 last
    • 一頁 50 筆,假如目前在第二頁,返回第一頁 (last: 50, before: cursor_51)

所以整個 Schema 會如下:

type Query {
  posts(
    "回傳開頭的前 N 筆資料"
    first: Int
    "會回傳該 curosr 後面的資料。一定要搭配 `first`"
    after: String
    "回傳倒數的 N 筆資料。一定要搭配 `before`"
    last: Int
    "會回傳該 curosr 前面的資料。一定要搭配 `last`"
    before: String
  ): PostConnection!
}

type PostConnection {
  "資料"
  edges: [PostEdge!]!
  "分頁資訊"
  pageInfo: PageInfo!
}

type PostEdge {
  "通常為一串 base64 字元"
  cursor: String!
  "實際 Post 資料"
  node: Post!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  totalPageCount: Int
}

接著再來看 Resolver 實作 (這邊屬於自由發揮部分,可以跳過):

const { UserInputError } = require('apollo-server');

const resolvers = {
  Query: {
    posts: async (root, { first, after, last, before, reverse }, { db }) => {
      if (!first && after) throw new UserInputError('after must be with first')
      if ((last && !before) || (!last && before)) throw new UserInputError('last and before must be used together')
      if (first && after && last && before) throw new UserInputError('Incorrect Arguments Usage.')

      let posts;
      // 取得下一頁資料
      if (first) {
        posts = after
          ? await db.query(
              'SELECT *, count(*) OVER() AS count FROM post WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2',
              [new Buffer(after, 'base64').toString(), first]
            )
          : await db.query('SELECT * FROM post ORDER BY created_at DESC LIMIT $1', [first]);
      }

      // 或是取得上一頁資料
      if (last) {
        posts = await db.query(
          `SELECT * FROM (
              SELECT *, count(*) OVER() AS count FROM post WHERE created_at > $1 ORDER BY created_at ASC LIMIT $2
           ) posts ORDER BY created_at DESC`,
          [new Buffer(before, 'base64').toString(), last]
        )
      }

      // 取得有條件 (WHERE) 但未限制數量 (LIMIT) 的真正數量
      const countWithoutLimit = posts[0].count;
      // 取得總數量
      const allCount = db.query('SELECT count(*) as number FROM post;').count;

      return {
        edges: posts.map(post => ({
          // 指標 (將 createdAt 做 base64)
          cursor: Buffer.from(post.createdAt).toString('base64')
          // 實際資料
          node: post,
        }))
        pageInfo: {
          // 檢查有無下一頁
          hasNextPage: first ? countWithoutLimit > first : allCount > countWithoutLimit,
          // 檢查有無上一頁
          hasPreviousPage: last ? countWithoutLimit > last : alCount > countWithoutLimit,
          // 總頁數
          totalPageCount: Math.ceil(allCount / (fist || last))
        }
      }
  },
}

以上就是 GraphQL 的 Connection 模式,當然可以以此基礎再新增更多資料如 GitHub 還有 totalCounttotalDiskUsage, startCursor, endCursor 等等。

使用 Pagination 時一定會有一些效能上的損失,但說實在以現在的機器水準,通常不會差太多,如果真的效能掉太多那該優化的應該是你的 sql 語法或是 table 的設計。

有興趣的可以去 Shopify StoreFront GraphQL API 上面玩玩,當初我就是在這邊邊摸邊學會這套模式的 !

進去後可以使用 shop.collection (商品) 來試試!首先先拿第一頁的前三筆:

shopify - test query 1

接著把第三筆的 cursor (eyJsYXN0X2lkIjoyNTc2OTc3MzEsImxhc3RfdmFsdWUiOiIyNTc2OTc3MzEifQ==) 複製後加進搜尋列的 after 去拿第二頁。

shopify - test query 2

再試試將搜尋列換成 beforelast 看將第一筆的 cursor (eyJsYXN0X2lkIjozODkyNDIxNzksImxhc3RfdmFsdWUiOiIzODkyNDIxNzkifQ==) 加入 before 來取得上一頁。

shopify - test query 3

登愣!又得到跟第一頁一模一樣的資料囉!

經驗談: 資料設計若牽涉到時間的話,盡量以「毫秒」為基準,以秒為基準的欄位很容易出現重複的情況,而相值的排序結果就是交給上天決定了。另外如果使用 Relational DB 的朋友也記得幫 cursor 所用的欄位打上 index 加速排序與搜尋。

如果想看更多細節可以參考 Relay Cursor Connections Specification,裡面有詳盡的介紹。


Reference


上一篇
GraphQL Design: 使用 DataLoader 提升效能 !
下一篇
GraphQL Design: 關於 Security 的二三事
系列文
Think in GraphQL30

尚未有邦友留言

立即登入留言