GraphQL ページネーション Go gqlgen
GraphQLの必須項目ページネーションの実装方法を理解する。
使用技術 Go1.13 × gqlgen v0.13.0
ページネーションとは
APIのクエリを制限する方法。
- 返却するデータの量を制限する事ができる。
- クエリの負荷を事前に計算できる。
GraphQLのページネーション
GraphQLではページネーションの方針に決まりはないが、デファクトスタンダードにRelay-style cursor pagenationという方式がある。 これはFaceBookが考案し、GitHubのGraphQL APIでも採用されている。
ページネーションの実装方法は自由だがデファクトスタンダードに乗っかることで使用者側の学習コストを抑えることができる。
Relay-style cursor pagenation
公式: relay.dev
こちらは以下の記事の画像が大変わかりやすい
ざっくり説明すると
GraphQL API Queryの引数を以下の様な形にする
{ # 次方向ページングの時に使う first: Int # 順方向に何件か after: String # afterで指定したcursorより後のedgeを取得 # 前方向ページングの時に使う last: Int # 逆方向に何件か before: String # beforeで指定したcursorより前のedgeを取得 }
上記の引数に応じて以下の形でデータを返す。
〇〇Connection { pageInfo: PageInfo! edges: [Edge!]! nodes: [データエンティティー!]! }
Connectionと明示的に宣言することでページネーションを実装しているQueryであることを示している。
登場人物一覧
// Queryの引数 first: Int after: String last: Int before: String //返却されるデータのまとまり Connection: { //取得したConnectionのページ情報 pageinfo: { startCursor: String endCursor: String hasNextPage: Boolean! hasPreviousPage: Boolean! } //crsorとnodeを含むデータ配列 edges: [edge] //nodeのデータ配列 nodes: [node] } //エンティティーオブジェクト node: {} //データの位置情報( 主にIDなどが使われる) cursor: string
実装
これまで見てきたページネーションを実装していく。
スキーマ
まずはスキーマ定義
page.graphql
type PageInfo { startCursor: String endCursor: String hasNextPage: Boolean! hasPreviousPage: Boolean! } interface Connection { pageInfo: PageInfo! edges: [Edge!]! nodes: [Node!]! } interface Edge { cursor: String! node: Node! } input PaginationInput { first: Int after: String last: Int before: String }
task.graphql
type Task implements Node { id: ID! title: String! note: String! completed: Int! # 0 or 1 created_at: Time! updated_at: Time! } input NewTask { title: String! note: String! } input UpdateTask { id: ID! title: String note: String completed: Int } type TaskConnection implements Connection { pageInfo: PageInfo! edges: [TaskEdge!]! nodes: [Task!]! } type TaskEdge implements Edge { cursor: String! node: Task! }
graphqlファイルは分割しMutation, Queryはschema.graphqlに記載
schema.graphql
schema { query: Query mutation: Mutation } type Mutation { createTask(input: NewTask!): Task! updateTask(input: UpdateTask!): Task! } type Query { tasks(input: PaginationInput!): TaskConnection! task(id: ID!): Task! } interface Node { id: ID! } scalar Time
Resolver
次はResolver
gqlgen
コマンドでresolverは自動生成される。
今回はTasks()を実装していく。 生成直後は以下の通りpanicになる。
※筆者はresolverをモデル毎に分割する為にgqlgenの自動生成を不可にした。 実装の中身は変わらない。
参考: https://qiita.com/masalennon/items/e4df77e5f58f862db644
func (r *queryResolver) Tasks(ctx context.Context, input model.PaginationInput) (*model.TaskConnection, error) { panic(fmt.Errorf("not implemented")) }
コード
上記のpanicを埋めたコードが以下。
func (r *queryResolver) Tasks(ctx context.Context, input model.PaginationInput) (*model.TaskConnection, error) { // validation if input.First == nil && input.Last == nil { return nil, errors.New("input.First or input.Last is required: input error") } if input.First != nil && input.Last != nil { return nil, errors.New("passing input.First and input.Last is not supported: input error") } if input.Before != nil && input.After != nil { return nil, errors.New("passing input.Before and input.After is not supported: input error") } var limit int if input.First != nil { limit = *input.First } else { limit = *input.Last } var tasksSizeLimit = 100 if input.First != nil && *input.First > tasksSizeLimit { return nil, errors.New("input.First exceeds tasksSizeLimit: input error ") } if input.Last != nil && *input.Last > tasksSizeLimit { return nil, errors.New("input.Last exceeds tasksSizeLimit: input error ") } db := r.DB var tasks []*model.Task if input.After != nil { db = db.Where("id > ?", *input.After) } if input.Before != nil { db = db.Where("id < ?", *input.Before).Order("id desc") } if input.Last != nil { db = db.Order("id desc") } if err := db.Limit(limit + 1).Find(&tasks).Error; err != nil { return nil, errors.New("could not find tasks: data base error ") } //検索結果0の場合 if len(tasks) == 0 { return &model.TaskConnection{ PageInfo: &model.PageInfo{ StartCursor: nil, EndCursor: nil, HasNextPage: false, HasPreviousPage: false, }, Edges: []*model.TaskEdge{}, Nodes: []*model.Task{}, }, nil } edges := make([]*model.TaskEdge, len(tasks)) nodes := make([]*model.Task, len(tasks)) // last, before 指定の時はスライスの後ろから入れていく if input.First != nil || input.After != nil { for i, task := range tasks { newEdge := &model.TaskEdge{ Cursor: strconv.Itoa(task.ID), Node: task, } nodes = tasks edges[i] = newEdge } } else { for i, task := range tasks { newEdge := &model.TaskEdge{ Cursor: strconv.Itoa(task.ID), Node: task, } nodes[len(tasks)-1-i] = tasks[i] edges[len(edges)-1-i] = newEdge } } startCursor := edges[0].Cursor endCursor := edges[len(edges)-1].Cursor var hasPreviousPage bool var hasNextPage bool // startCursorのIDより前に1件でもデータがある場合はpreviousPageはtrue startCursorInt, _ := strconv.Atoi(startCursor) endCursorInt, _ := strconv.Atoi(endCursor) var task model.Task if input.First != nil { if err := r.DB.Where("id <= ?", startCursorInt-1).First(&task).Error; err == nil { hasPreviousPage = true } } else { if err := r.DB.Where("id >= ?", endCursorInt+1).First(&task).Error; err == nil { hasNextPage = true } } // Firstが渡された場合 if limit以上 else limit以下 if input.First != nil && limit < len(nodes) { endCursor = edges[len(edges)-2].Cursor hasNextPage = true return &model.TaskConnection{ PageInfo: &model.PageInfo{ StartCursor: &startCursor, EndCursor: &endCursor, HasNextPage: hasNextPage, HasPreviousPage: hasPreviousPage, }, Edges: edges[:len(edges)-1], Nodes: nodes[:len(nodes)-1], }, nil } else if input.First != nil && limit >= len(nodes) { return &model.TaskConnection{ PageInfo: &model.PageInfo{ StartCursor: &startCursor, EndCursor: &endCursor, HasNextPage: hasNextPage, HasPreviousPage: hasPreviousPage, }, Edges: edges, Nodes: nodes, }, nil } // Lastが渡された場合 if limit以上 else limit以下 if input.Last != nil && limit < len(nodes) { startCursor = edges[len(edges)-limit].Cursor hasPreviousPage = true return &model.TaskConnection{ PageInfo: &model.PageInfo{ StartCursor: &startCursor, EndCursor: &endCursor, HasNextPage: hasNextPage, HasPreviousPage: hasPreviousPage, }, Edges: edges[len(edges)-limit:], Nodes: nodes[len(nodes)-limit:], }, nil } else if input.Last != nil && limit >= len(nodes) { return &model.TaskConnection{ PageInfo: &model.PageInfo{ StartCursor: &startCursor, EndCursor: &endCursor, HasNextPage: hasNextPage, HasPreviousPage: hasPreviousPage, }, Edges: edges, Nodes: nodes, }, nil } return nil, nil }
解説
基本的にはRelayライブラリのアルゴリズムを満たしていった。(途中からかなり我流になった。)
問題はRelayでは全てをSELECTする仕様になっているのでRDBではレコード数が大量になった時に負荷がかかるということ。
筆者は以下のようにした
if input.First != nil { if err := r.DB.Where("id <= ?", startCursorInt-1).First(&task).Error; err == nil { hasPreviousPage = true } } else { if err := r.DB.Where("id >= ?", endCursorInt+1).First(&task).Error; err == nil { hasNextPage = true } }
取得してきたレコードの前後にデータが存在しているかをチェックしてpageinfoを判定した。 もっといいやり方がありそう...
考察
今回はConnectionの全ての要件を満たすように実装したが、実際のサービスでは 前方方向(first, after)か後方方向(last, before)どちらかだけ実装するのが無難。 実際に筆者の現場や他の記事がそうだった。
検索用の引数などカスタマイズする事も可能
- 開発者によって実装にバラつきがある。
このISSUEはアルゴリズムの実装を議論している
https://github.com/graphql/graphql-relay-js/issues/94
gqlgenではプラグインで追加される可能性もあるみたいなので車輪の開発が待ち遠しい。
以上