🤖

【Go+GraphQL】カーソルページネーションを実装してみる

2024/12/09に公開

背景

無限スクロールの一覧検索画面を実装してみたいなーと思ったものの、今までカーソルページネーションを実装した経験がなく、 Relay Connectionの概念の理解から始まり諸々苦労しました。
ただ、カーソル形式のページネーションの考え方などを学ぶ良い機会となったので、備忘録として残します。

実装したリポジトリ

https://github.com/ichi-2049/filmie-server

ページネーションの種類

オフセットページネーション

オフセットページネーションは、ページ数や表示件数を指定してデータを取得する最もシンプルな方法です。
例えば、以下のようなクエリでデータを取得します。

SELECT * FROM articles ORDER BY created_at DESC LIMIT 10 OFFSET 20;

この方法は実装が比較的簡単で直感的ですが、以下のデメリットがあります。

  • データ量が多い場合、オフセットを増やすほどクエリの実行速度が低下する。
  • データの挿入や削除が発生すると、ページの内容がずれる(リアルタイム性が求められる場合に問題となる)。

カーソルページネーション

カーソルページネーションは、データの特定の位置(カーソル)を基準にして次のデータを取得する方式です。
以下のようなクエリを使用します。

SELECT * FROM articles WHERE created_at < '2023-12-01T00:00:00Z' ORDER BY created_at DESC LIMIT 10;

カーソルページネーションの利点は以下の通りです。

  • データが大量でもスケーラブルでパフォーマンスが安定している。
  • データの挿入や削除があっても、ページの内容がずれにくい。

ただし、カーソル情報を保持する必要があるため、少し複雑になるデメリットがあります。

Relay Connectionについて

GraphQLでカーソルページネーションを実装する際、Relay Connection仕様に基づく実装がベストプラクティスとされています。

Relay Connectionは、以下のようなデータ構造を持ちます。

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type MovieEdge {
  cursor: String!
  node: Movie!
}

type MovieConnection {
  edges: [MovieEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

edges: カーソルとデータ本体(node)をペアにしたリスト。
pageInfo: ページングのための情報を格納(次のページがあるか、カーソルの範囲など)。
この構造により、クライアントは以下のようなフローでデータを取得できます。

  1. 初回リクエストでデータを取得
  2. pageInfo.endCursor を次のカーソルとして利用し、次ページのデータをリクエスト
  3. 必要に応じて hasNextPage を確認してリクエストを繰り返す

GoでのRelay Connectionの実装

今回はカーソルページネーションを使用して映画作品情報を取得する形で実装しました。タイトルでの検索条件、および人気順(降順)と映画ID(昇順)の複合ソートを行います。

下記が実際に実装したページネーション取得関数と、カーソルのエンコード・デコード関数です。

GetMovieConnection
/*
映画作品情報をカーソルページネーションで取得する関数
タイトル(optional)で検索をかけ、人気順(降順)と映画ID(昇順)でソートする
*/
func (r *MovieRepositoryImpl) GetMovieConnection(first int, after *string, title *string) (*domain.MovieConnection, error) {
	// クエリのベースを作成
	query := r.db.Model(&dao.Movie{})

	// タイトル検索条件を追加
	if title != nil && *title != "" {
		query = query.Where("title LIKE ?", *title+"%")
	}

	// 総件数を取得
	var totalCount int64
	if err := query.Count(&totalCount).Error; err != nil {
		return nil, err
	}

	// afterカーソルが指定されている場合、その位置以降のデータを取得
	var lastPopularity float32
	var lastMovieID int
	if after != nil {
		decodedCursor, err := decodeCursor(*after)
		if err != nil {
			return nil, err
		}
		lastPopularity = decodedCursor.Popularity
		lastMovieID = decodedCursor.MovieID

		// 人気度と映画IDで複合的にフィルタリング
		query = query.Where(
			"(popularity < ?) OR (popularity = ? AND movie_id > ?)",
			lastPopularity,
			lastPopularity,
			lastMovieID,
		)
	}

	// 次ページの有無を確認するため、要求された件数+1を取得
	limit := first + 1
	var movieDao []*dao.Movie
	if err := query.
		Order("popularity DESC, movie_id ASC"). // 人気度降順、映画ID昇順でソート
		Limit(limit).
		Find(&movieDao).Error; err != nil {
		return nil, err
	}

	// 次ページの有無を判定
	hasNextPage := len(movieDao) > first
	if hasNextPage {
		// 次ページ判定用の余分なデータを削除
		movieDao = movieDao[:first]
	}

	// MovieEdgeの配列を作成
	edges := make([]*domain.MovieEdge, len(movieDao))
	for i, movie := range movieDao {
		edges[i] = &domain.MovieEdge{
			Cursor: encodeCursor(movie.Popularity, movie.MovieID), // 人気度と映画IDを組み合わせたカーソル
			Node:   movie.ToModel(),
		}
	}

	// 最後のカーソルを取得
	var endCursor *string
	if len(edges) > 0 {
		cursor := edges[len(edges)-1].Cursor
		endCursor = &cursor
	}

	// 結果を格納して返却
	return &domain.MovieConnection{
		Edges: edges,
		PageInfo: &domain.PageInfo{
			HasNextPage: hasNextPage,
			EndCursor:   endCursor,
		},
		TotalCount: totalCount,
	}, nil
}
encodeCursor
// 人気度と映画IDをカーソル文字列にエンコードする関数
func encodeCursor(popularity float32, movieID int) string {
	// "popularity:{popularity}:movie:{id}" の形式でbase32エンコード
	return base32.StdEncoding.EncodeToString(
		[]byte(fmt.Sprintf("popularity:%.6f:movie:%d", popularity, movieID)),
	)
}
decodeCursor
// カーソル文字列を人気度と映画IDにデコードする関数
func decodeCursor(cursor string) (Cursor, error) {
	// base32デコード
	decoded, err := base32.StdEncoding.DecodeString(cursor)
	if err != nil {
		return Cursor{}, err
	}

	// "popularity:{popularity}:movie:{id}" 形式から値を抽出
	var popularity float32
	var movieID int
	_, err = fmt.Sscanf(
		string(decoded),
		"popularity:%f:movie:%d",
		&popularity,
		&movieID,
	)
	if err != nil {
		return Cursor{}, err
	}

	return Cursor{
		Popularity: popularity,
		MovieID:    movieID,
	}, nil
}

構造の解説

それぞれの箇所について解説していきます。
カーソルページネーションでは、次のデータを取得する基準となるカーソル(位置情報)を使います。本実装では、人気度(popularity)と映画ID(movie_id)を基準にカーソルを生成し、データを取得しています。

1. 映画情報の取得クエリの構築

クエリを動的に構築することで、以下の条件を反映しています。

タイトル検索条件

if title != nil && *title != "" {
    query = query.Where("title LIKE ?", *title+"%")
}

タイトル検索が指定されている場合、WHERE条件を動的に追加します。

カーソルによる位置指定

カーソルをデコードして、人気度と映画IDを基準にフィルタリングを行います。

query = query.Where(
    "(popularity < ?) OR (popularity = ? AND movie_id > ?)",
    lastPopularity,
    lastPopularity,
    lastMovieID,
)

この条件により、「現在のカーソル以降のデータ」を取得します。

2. ソートとページング

クエリ結果は以下の順序でソートされます。

  1. 人気度(popularity):降順
  2. 映画ID(movie_id):昇順
    これにより、人気度が同じ場合は映画IDの昇順で順番を確保します。
    また、要求された件数(first)に対して+1件取得することで、次ページの有無を判定しています。

3. カーソルの生成とエンコード

カーソルは「人気度と映画ID」を組み合わせた文字列をBase32でエンコードして生成します。
※人気度の型がfloat32だったためそちらに併せてBase32で実装しましたが、深い意味はありません。

カーソルのエンコード

func encodeCursor(popularity float32, movieID int) string {
    return base32.StdEncoding.EncodeToString(
        []byte(fmt.Sprintf("popularity:%.6f:movie:%d", popularity, movieID)),
    )
}

カーソル文字列の形式例:

"popularity:89.123456:movie:101"

カーソルのデコード
デコード時には、エンコード時と同じフォーマットでデータを抽出します。

func decodeCursor(cursor string) (Cursor, error) {
    decoded, err := base32.StdEncoding.DecodeString(cursor)
    // ...
    fmt.Sscanf(string(decoded), "popularity:%f:movie:%d", &popularity, &movieID)
    return Cursor{Popularity: popularity, MovieID: movieID}, nil
}

4. PageInfoとレスポンスの構築

取得したデータから PageInfo を構築します。

hasNextPage: 次のページが存在するか。
endCursor: 最後のエッジのカーソル。

return &domain.MovieConnection{
    Edges: edges,
    PageInfo: &domain.PageInfo{
        HasNextPage: hasNextPage,
        EndCursor:   endCursor,
    },
    TotalCount: totalCount,
}, nil

API(query)のレスポンス

上記関数をresolverで呼び出すように実装し、映画作品一覧取得クエリmoviesを実装しました。

movie_query.graphql
extend type Query {
    movies(input: MovieConnectionInput): MovieConnection!
}

input MovieConnectionInput {
  first: Int!
  after: String
  title: String
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type MovieEdge {
  cursor: String!
  node: Movie!
}

type MovieConnection {
  edges: [MovieEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

playgroundでのリクエストとレスポンスの例を記載します。

タイトルによる条件未指定のリクエストの例:

request
query movies {
  movies(input: { 
    first: 10,
    title: ""
  }) {
    edges {
      cursor
      node { title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

レスポンスの例:

response
{
  "data": {
    "movies": {
      "edges": [
        {
          "cursor": "OBXXA5LMMFZGS5DZHIZTSNZZFY4DKMZQGI3TU3LPOZUWKORZGEZDMNBZ",
          "node": {
            "title": "ヴェノム:ザ・ラストダンス"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIZDQMZZFYZTCMBQGU4TU3LPOZUWKORRGAZTINJUGE======",
          "node": {
            "title": "テリファー 聖夜の悪夢"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIZDGNZWFY2TKMRQGAZDU3LPOZUWKORRGE4DIOJRHA======",
          "node": {
            "title": "野生の島のロズ"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIZDEMRWFYYDINJYHE4DU3LPOZUWKORRGEYTQMBTGE======",
          "node": {
            "title": "アポカリプスZ ~終末の始まり~"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIZDCOBVFYZDOMZZGI3DU3LPOZUWKORVGU4DINBZ",
          "node": {
            "title": "グラディエーターII 英雄を呼ぶ声"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIZDAMZTFYZTONJZG43TU3LPOZUWKORVGMZTKMZV",
          "node": {
            "title": "デッドプール&ウルヴァリン"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIYTKNJVFY4DMOBQGQZDU3LPOZUWKORZGMZTENRQ",
          "node": {
            "title": "The Substance"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIYTKMJVFYYDQMJQGU2TU3LPOZUWKORYGQ2TOOBR",
          "node": {
            "title": "レッド・ワン"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIYTIMJVFY2TGNBQGU4DU3LPOZUWKORWHE4DMOBX",
          "node": {
            "title": "トランスフォーマー/ONE"
          }
        },
        {
          "cursor": "OBXXA5LMMFZGS5DZHIYTEMBYFY4TEMZZGUYDU3LPOZUWKORRGEZDINRUGE======",
          "node": {
            "title": "Classified"
          }
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "OBXXA5LMMFZGS5DZHIYTEMBYFY4TEMZZGUYDU3LPOZUWKORRGEZDINRUGE======"
      },
      "totalCount": 478236
    }
  }
}

フロント画面実装

フロント側(Next.js)で適当な一覧検索画面を実装し、先ほどのクエリを呼び出すようにした結果のGifが下記になります。無事無限スクロールを実装することができました。
なおフロントについてはあまり実装経験がなかったため、最低限の構成でclaude等のAIを駆使してササっと実装しました。

一応フロント側のリポジトリも記載しておきます。本当にただ無限スクロールの一覧画面を実装しただけですが…😅
https://github.com/ichi-2049/filmie-web

まとめ

カーソルベースのページネーションの考え方やConnectionの仕様の理解に時間がかかってしまいましたが、とても良い勉強になりました。
これまで実務でページネーションを実装する際は基本的にオフセット形式を採用していましたが、今後はカーソルベースのページネーションも選択肢に入れていきたいです。

実装した内容についてのざっくりな解説になってしまいましたが、最後に参考にさせていただいた記事を記載しておきます。(ChatGPT・claude等のAIにも大変お世話になりました。)
https://qiita.com/tmmhri/items/aeae9d591d21a29d837f
https://qiita.com/gipcompany/items/ffee8cf0b1522a741e12

Discussion