カーソルページネーションによる新着投稿一覧APIの実装について

こんにちは。crispyのエンジニアの馬塲です。こちらの記事では弊社で運営しているHEIMアプリで使用しているカーソルページネーションについて話していきたいと思います。

実装の経緯

HEIMアプリで使用する新着投稿の一覧を返すAPIを実装してリリースしたところ、ページ遷移中に同じ投稿が重複して表示されてしまうという事象が発生しました。

原因を調査したところ、ページ遷移中に別のユーザーが新規投稿を行ったタイミングで重複してしまうことが分かり、ページネーション処理の改修が必要になりました。そして実装したのがカーソルページネーションによるページネーション処理です。

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

カーソルページネーションとはタイムスタンプなど、ユニークな値を基点としてページネーションを行うものです。

一般的に使用されるオフセットページネーションは対象のデータを表示させる数で区切って抽出するのに対し、カーソルページネーションは対象のレコードの前N件、後ろN件のような取得方法になります。

f:id:shun114:20220125180727p:plain
カーソルページネーションとオフセットページネーションの比較

例として、SpotifyのAPIを見てみるとタイムスタンプを使用してカーソルページネーションを使用していることがわかると思います。

f:id:shun114:20220125171609p:plain
Spotify APIでカーソルページネーションを使用している例
developer.spotify.com

webサイトでよく使われるgemは基本的にオフセットページネーションです、Ruby gemで代表的なものだとkaminariやpagyがあります。

github.com

github.com

オフセットページネーションで問題が発生するパターン

基本的にはオフセットページネーションを使用すること多いですが、新着一覧を表示させるとページ遷移した際に重複してデータが表示されてしまうことがあります。

オフセットページネーションの場合、特定の件数ごとに区切ってページ分けをするため、途中でデータが入るとページ分けがずれてしまいます。 図で見た方が分かりやすいと思います。

f:id:shun114:20220125182848p:plain
オフセットページネーションでデータが重複する例

このように既に表示させたデータが新規データ挿入が原因で次のページにも表示されてしまう、といった事象が起きてしまいます。

新着順での表示など、途中でデータが最後尾以外に追加されることがある場合はオフセットページネーションの使用はお勧めできません。

カーソルページネーションのgemの例

カーソルページネーションを利用できるRuby gemも存在します。

activerecord-cursor github.com

pagy-cursor github.com

これらのgemを使用するのが一番手軽なのですが、弊社では別テーブルのカラムを参照してページネーションを行うパターンの対応が必要だったため、自作することにしました。

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

APIは以下のようなパラメータ、レスポンスを用意しました。

パラメータはcursordirectionを用意。

GET /items?cursor=1234567&direction=next

レスポンスはlinks内にprevnextを用意してページ遷移を可能にしました。

{
  data: [
    { id: 1, ... },
    { id: 2, ... },
    { id: 3, ... },
    { id: 4, ... }
  ],
  links: {
    prev: '/items?cursor=1111111&direction=prev',
    next: '/items?cursor=222222&direction=next'
  }
}

処理部分は前述した通り他テーブルのカラムを参照できる必要があったため、基本となる処理を抽象クラスとして実装しました。

f:id:shun114:20220125202050p:plain

抽象クラスを用いることで、cursorを取得、設定する処理をそれぞれのクラスでオーバーライドすることにより、別テーブルのカラムや複数カラムをcursorに設定できるようになりました。

ページネーション処理のロジック部分はactiverecord-cursor gemのソースコードを例に説明したいと思います。

def cursor(options = {})
  @options = default_options.merge!(options).symbolize_keys!
  @options[:direction] =
    if @options.key?(:start) || @options.key?(:stop)
      @options.key?(:start) ? :start : :stop
    end
  @cursor = Params.decode(@options[@options[:direction]]).value # 1
  @records = on_cursor.in_order.limit(@options[:size] + 1) # 2
  set_cursor # 3
  @records
rescue ActiveRecord::StatementInvalid
  raise Cursor::InvalidCursor
end

https://github.com/tsuwatch/activerecord-cursor/blob/dde17f6630cb807716ae66925f52e4b280ee6ef2/lib/activerecord/cursor/model_extension.rb#L9-L21

上のコードがcursorの設定処理と対象のレコードの取得処理を行っている箇所です。activerecord-cursorのcursorの値はキーとなるカラムの値をエンコードした文字列としています。

処理は大きく分けて3つです。

  1. パラメータとして送られてきたcursorをデコードして基点となる値を取得
  2. cursorの値を用いて対象のレコードを取得
  3. レスポンスに設定する次のcursorをセット

実装のポイントは、2の手順で指定の数よりも1件多く取得していることです。

@records = on_cursor.in_order.limit(@options[:size] + 1)

これは3で次のページが存在するかどうかを確認するために行っている処理です。ソースコードを見てみると、set_cursorメソッドの中で次の1件が存在するかどうかを確認する処理が含まれています。

def set_cursor
  @next = nil
  @prev = nil
  if @options[:direction] == :start
    set_cursor_on_start
  elsif @options[:direction] == :stop
    set_cursor_on_stop
  elsif @records.size == @options[:size] + 1
    @records = @records.limit(@options[:size])
    @next = generate_cursor(@records[@records.size - 1])
  end
end

https://github.com/tsuwatch/activerecord-cursor/blob/dde17f6630cb807716ae66925f52e4b280ee6ef2/lib/activerecord/cursor/model_extension.rb#L77-L88

次のレコードがあるかどうかを確認することで、レスポンスにcursorがある場合は次のページが存在し、ない場合はレスポンスデータが最後のページであることが分かります。

以上のような処理を参考にして無事実装が完了しました。カーソルページネーション処理をリリース後、新着一覧でデータが重複する問題が解消されました。

さいごに

カーソルページネーションの自作方法についてまとめてみました。ぜひ参考にしてみてください。

crispyでは、HEIM、3rdmallの開発を一緒に盛り上げてくれるエンジニアを募集しています!興味がある方はぜひ一度お話をしましょう。お待ちしております。

https://www.wantedly.com/companies/crispy-inc/projects