Technology

Apollo-Serverにおけるページネーション

目次

Apollo-Server を使った GraphQL サーバのハンズオン実装シリーズ。今回はページネーション(Pagination)を扱います。

エンティティの数が増えると、findAll による Fetch のコストがかかってきます。これをすべて操作したり、ダウンロードしたりすると、パフォーマンスが低下し、ひいては UX の低下につながってしまいます。

ページネーションは、データの一部を切り取ってアクセスできる仕組みです。必要な情報のみを操作することができ、パフォーマンスを高い状態で維持することができます。この仕組みはあとから導入することもできますが、はじめから導入しておくほうが移行コストが安いため、積極的に導入することをおすすめします。

今回も、こちらのチュートリアルをなぞって進めています; https://www.robinwieruch.de/graphql-apollo-server-tutorial

今回実装したリポジトリはこちらです;
https://github.com/suzukalight/study-graphql-apollo-server/tree/master/src/08-pagination

Offset/Limit 形式のページネーション

「要素の M 番目から N 個を取り出す」という形式のページネーションです。素朴な実装で大きな効果を挙げられるため、はじめに提供すべきページネーションと言えます。

Message スキーマの messages クエリに offset/limit を追加してみます;

src/08-pagination/src/schema/message.ts
const schema = gql`
  extend type Query {
    messages(offset: Int, limit: Int): [Message!]!  }
`;

Sequelize の findAll は、offset/limit 形式のページネーション処理に対応していますので、これをオプションとして指定します;

src/08-pagination/src/resolvers/message.ts
const resolvers: IResolvers<Message, ResolverContext> = {
  Query: {
    messages: async (parent, { offset = 0, limit = 100 }, { models }) =>      models.Message.findAll({ offset, limit }),  },
};

messages クエリを実行してみましょう。引数に offset: 1, limit: 2 を与えて実行してみます;

query
{
  messages(offset: 1, limit: 2) {
    id
    text
    user {
      username
    }
  }
}
response
{
  "data": {
    "messages": [
      {
        "id": "2",
        "text": "message #2",
        "user": {
          "username": "suzuka light"
        }
      },
      {
        "id": "3",
        "text": "message #3",
        "user": {
          "username": "suzuka light"
        }
      }
    ]
  }
}

offset が 1 つずれて、id=2 の Message から取得が開始しています。

カーソルベースページネーション(Cursor-based)

オフセットベースのページネーションは簡便ですが、ページネーション中にデータの更新があった場合、オフセットがずれてしまい、正しいページネーションを行うことができなくなってしまいます。

カーソルベースのページネーションは、カーソルと呼ばれる識別子を用いて、「どこまで情報を取得したか」を管理する手法です。オフセットだと「情報の何番目か」を指定するためズレが生じますが、カーソルだと「どの情報まで取得したか」が管理できるため、ページネーションにズレが生じません。

edges と PageInfo

新たにカーソルを返すために、PageInfo 型を加えます。もとの message 情報は edges という「情報の断片」を表すフィールドに詰めて返すようにします。これらの情報を MessageConnection という型で表現します;

src/08-pagination/src/schema/message.ts
const schema = gql`
  extend type Query {
    messages(cursor: String, limit: Int): MessageConnection!  }

  type MessageConnection {    edges: [Message!]!    pageInfo: PageInfo!  }  type PageInfo {    endCursor: Date!  }`;

カーソルは createdAt で管理します。findAll の where 条件にカーソルとして受け取った createdAt を渡すことで、それより古い情報だけを fetch するように実装します。

次のカーソルは、fetch した情報のなかの、末尾のエレメントの createdAt を返します。これによって次のページネーションは、末尾の次の情報であることを特定できます;

src/08-pagination/src/resolvers/message.ts
const resolvers: IResolvers<Message, ResolverContext> = {
  Query: {
    messages: async (parent, { cursor, limit = 100 }, { models }) => {      const cursorOptions = cursor        ? {            where: { createdAt: { [Op.lt]: cursor } },          }        : {};      const messages = await models.Message.findAll({        order: [['createdAt', 'DESC']],        limit,        ...cursorOptions,      });      return {        edges: messages,        pageInfo: { endCursor: messages[messages.length - 1].createdAt },      };    },  },
};

Fetch を実行してみます;

query
{
  messages(limit: 2) {
    edges {
      text
    }
    pageInfo {
      endCursor
    }
  }
}
response
{
  "data": {
    "messages": {
      "edges": [
        {
          "text": "message #5"
        },
        {
          "text": "message #4"
        }
      ],
      "pageInfo": {
        "endCursor": "2019-12-15T10:29:52.026Z"
      }
    }
  }
}

edges として最初の 2 件が、pageInfo として 2 件目の createdAt が返されました。

つづけてこのカーソルを指定してみましょう;

query
{
  messages(limit: 2, cursor: "2019-12-15T10:29:52.026Z") {
    edges {
      text
    }
    pageInfo {
      endCursor
    }
  }
}
response
{
  "data": {
    "messages": {
      "edges": [
        {
          "text": "message #3"
        },
        {
          "text": "message #2"
        }
      ],
      "pageInfo": {
        "endCursor": "2019-12-15T10:29:50.026Z"
      }
    }
  }
}

#4 より 1 つ古い、#3 からデータが返ってきました、成功です!

次のページがあるかフラグの追加

現在のページネーションは、情報をすべて Fetch し終わったかの判別はできないため、ユーザに対して「まだ情報があるぞ」と誤った情報を与えてしまいます。これを防ぐための hasNextPage フラグを付けてみましょう;

src/08-pagination/src/schema/message.ts
const schema = gql`
  type PageInfo {
    hasNextPage: Boolean!    endCursor: Date!
  }
`;

hasNextPage は、limit+1 まで fetch してみた情報が、limit 以下しか返ってこなかったかで判定できます。これを計算して、pageInfo に詰めて返します;

src/08-pagination/src/resolvers/message.ts
const resolvers: IResolvers<Message, ResolverContext> = {
  Query: {
    messages: async (parent, { cursor, limit = 100 }, { models }) => {
      const cursorOptions = cursor
        ? {
            where: { createdAt: { [Op.lt]: cursor } },
          }
        : {};
      const messages = await models.Message.findAll({
        order: [['createdAt', 'DESC']],
        limit: limit + 1,        ...cursorOptions,
      });
      const hasNextPage = messages.length > limit;      const edges = hasNextPage ? messages.slice(0, -1) : messages;
      return {
        edges,        pageInfo: { hasNextPage, endCursor: edges[edges.length - 1].createdAt },      };
    },
  },
};

fetch してみましょう;

query
{
  messages(limit: 2) {
    edges {
      text
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
response
{
  "data": {
    "messages": {
      "edges": [
        {
          "text": "message #5"
        },
        {
          "text": "message #4"
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "2019-12-15T10:37:48.245Z"
      }
    }
  }
}

hasNextPage が返され、次の情報があることがわかるようになりました、成功です!

cursor のハッシュ化

現状だとカーソルは生の日付情報を返していますが、ユーザに対して「作成日がカーソルですよ」と悟られる必要はありません。簡単に base64 でハッシュ化しておきましょう;

src/08-pagination/src/resolvers/message.ts
const toCursorHash = (string: string) => Buffer.from(string).toString('base64');const fromCursorHash = (string: string) => Buffer.from(string, 'base64').toString('ascii');
const resolvers: IResolvers<Message, ResolverContext> = {
  Query: {
    messages: async (parent, { cursor, limit = 100 }, { models }) => {
      const cursorOptions = cursor
        ? {
            where: { createdAt: { [Op.lt]: fromCursorHash(cursor) } },          }
        : {};
      const messages = await models.Message.findAll({
        order: [['createdAt', 'DESC']],
        limit: limit + 1,
        ...cursorOptions,
      });
      const hasNextPage = messages.length > limit;
      const edges = hasNextPage ? messages.slice(0, -1) : messages;

      return {
        edges,
        endCursor: toCursorHash(edges[edges.length - 1].createdAt.toString()),      };
    },
  },
};
src/08-pagination/src/schema/message.ts
const schema = gql`
  type PageInfo {
    hasNextPage: Boolean!
    endCursor: String!  }
`;

カーソルとして base64 文字列が返されるかを試してみましょう;

query
{
  messages(limit: 2) {    edges {
      text
      user {
        id
        username
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
response
{
  "data": {
    "messages": {
      "edges": [
        {
          "text": "message #5",
          "user": {
            "id": "2",
            "username": "suzuka light"
          }
        },
        {
          "text": "message #4",
          "user": {
            "id": "2",
            "username": "suzuka light"
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "VHVlIERlYyAzMSAyMDE5IDE5OjQ5OjE3IEdNVCswOTAwIChHTVQrMDk6MDAp"      }
    }
  }
}

成功です。この情報を使ってページネーションを実行し、さらに hasNextPage が false になるような量の limit を指定してみましょう;

query
{
  messages(limit: 3, cursor: "VHVlIERlYyAzMSAyMDE5IDE5OjQ5OjE3IEdNVCswOTAwIChHTVQrMDk6MDAp") {    edges {
      text
      user {
        id
        username
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
response
{
  "data": {
    "messages": {
      "edges": [
        {
          "text": "message #3",          "user": {
            "id": "2",
            "username": "suzuka light"
          }
        },
        {
          "text": "message #2",
          "user": {
            "id": "2",
            "username": "suzuka light"
          }
        },
        {
          "text": "message #1",
          "user": {
            "id": "2",
            "username": "suzuka light"
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": false,        "endCursor": "VHVlIERlYyAzMSAyMDE5IDE5OjQ5OjE0IEdNVCswOTAwIChHTVQrMDk6MDAp"      }
    }
  }
}

base64 のカーソルでもページネーションに成功し、さらに hasNextPage が false になることを確認できました、成功です!

完成品

実装したリポジトリはこちらです;
https://github.com/suzukalight/study-graphql-apollo-server/tree/master/src/08-pagination

suzukalight

Written by suzukalight.