Series: gqlgen
Tags: Go Golang GraphQL

【Go】GraphQL でページングする

GraphQL で cursor-based pagination を実装する。

開発環境

  • go 1.17
  • github.com/99designs/gqlgen v0.17.1
  • github.com/graph-gophers/dataloader v5.0.0+incompatible

本文

概要

GraphQL の仕様ではページングについては何も決まっていないが

のページによると cursor-based pagination が薦められているため、それを実装する。

上記のページからリンクされている

に仕様が定められているため、今回はおおむねその通りに愚直に実装した。Relay-style とも言うようだ。

完成品は下記で、article という query にページングを実装している。

https://gitlab.com/k1350/daybreak_sample/-/tree/graphql_paging_sample

注意点としては、Dataloader とページングは併用できない。そのことは公式 Dataloader の下記 issue で回答されている。

DataLoader is typically not responsible for pagination.

この記事は前回の続きであり、前回は type Blog の中にフィールドの一つとして articles を含め、Dataloader で取得するような実装にしたのだが、今回ページングを実装するにあたって type Blog から article を削除した。

(ついでに Dataloader を使用して取得していた Blog.links については Dataloader を使用する意味があるように実装を変更した。)

ページングのための型定義

という記事を参考にしつつ interface を記述したが、公式の仕様と全く同じになるように一部変更している。

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

interface Node {
    id: ID!
}

interface Edge {
    cursor: String!
    node: Node
}

interface Connection {
    pageInfo: PageInfo!
    edges: [Edge]
}

type Article implements Node {
    id: ID!
    content: String!
    createdAt: String!
    updatedAt: String!
}

type ArticleEdge implements Edge {
    cursor: String!
    node: Article
}

type ArticleConnection implements Connection {
    pageInfo: PageInfo!
    edges: [ArticleEdge]
}

input ArticlePaginationInput {
    first: Int
    after: String
    last: Int
    before: String
}

※完成品ソースコードでは ArticlePaginationInput に対して directive を使ったバリデーションも実装しているが、それは後日改めて書くので省略する。

Node の定義については詳しく記載がなかったが、ID を持っているものという解釈でいいらしいのでそういう風にした。

モデル

ここから gqlgen でソースコードを自動生成するが、私は type Article はカスタムモデルとしているので自動生成されない。よって自分で implements Node を満たすように変更する。

package model

import (
	"time"
)

type Article struct {
	ID        string    `json:"id"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

func (Article) IsNode() {}

一度自動生成して確認した結果、func (Article) IsNode() {} というのを書いておくと implements Node を満たすようになることがわかったのでそのようにした。

ページングアルゴリズム

ページングアルゴリズムも詳しく書いてあるのだが、そのまま実装すると「一度全件取得してから必要ない Edge を除外する」ということになってしまい、データ量が増えたときにとんでもないことになる可能性がある。

よって今回はそのような実装はせず、引数 first または last を SQL の LIMIT 句として利用するようにした。

PageInfo.hasNextPage, PageInfo.hasPreviousPage も、なるべく発行する SQL を少なくするように実装したいところだが、今回は Edge の取得とは独立して判定するようにしている。

また Edges として返す値の並び順について、今想定しているのはブログ記事であるため本来は ID 降順としたいところだが、PaginationInput の用語との兼ね合いで非常にわかりづらくなるので仕方なく ID 昇順で返している。

並べ替えのコードで

if input.Last != nil {
    // id 昇順に直す
    for i := 0; i < len(result)/2; i++ {
        result[i], result[len(result)-i-1] = result[len(result)-i-1], result[i]
    }
}

というのは参考記事 URL を保存しておくのを失念したのだが、他の方の実装を参考にした。

このように書ける言語を今まで扱ってこなかったので目から鱗だった。

次回以降

directive によるバリデーションについて書く。