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

こちらは以下の記事の画像が大変わかりやすい

f:id:shikatech:20210718150646p:plain
登場人物

https://qiita.com/gipcompany/items/ffee8cf0b1522a741e12

ざっくり説明すると

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!
  }

//crsornodeを含むデータ配列
  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.dev

問題は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ではプラグインで追加される可能性もあるみたいなので車輪の開発が待ち遠しい。

以上