こんにちは。crispyのエンジニアの馬塲です。こちらの記事では弊社で運営しているHEIMアプリで使用しているカーソルページネーションについて話していきたいと思います。
実装の経緯
HEIMアプリで使用する新着投稿の一覧を返すAPIを実装してリリースしたところ、ページ遷移中に同じ投稿が重複して表示されてしまうという事象が発生しました。
原因を調査したところ、ページ遷移中に別のユーザーが新規投稿を行ったタイミングで重複してしまうことが分かり、ページネーション処理の改修が必要になりました。そして実装したのがカーソルページネーションによるページネーション処理です。
カーソルページネーションとは
カーソルページネーションとはタイムスタンプなど、ユニークな値を基点としてページネーションを行うものです。
一般的に使用されるオフセットページネーションは対象のデータを表示させる数で区切って抽出するのに対し、カーソルページネーションは対象のレコードの前N件、後ろN件のような取得方法になります。
例として、SpotifyのAPIを見てみるとタイムスタンプを使用してカーソルページネーションを使用していることがわかると思います。
webサイトでよく使われるgemは基本的にオフセットページネーションです、Ruby gemで代表的なものだとkaminariやpagyがあります。
オフセットページネーションで問題が発生するパターン
基本的にはオフセットページネーションを使用すること多いですが、新着一覧を表示させるとページ遷移した際に重複してデータが表示されてしまうことがあります。
オフセットページネーションの場合、特定の件数ごとに区切ってページ分けをするため、途中でデータが入るとページ分けがずれてしまいます。 図で見た方が分かりやすいと思います。
このように既に表示させたデータが新規データ挿入が原因で次のページにも表示されてしまう、といった事象が起きてしまいます。
新着順での表示など、途中でデータが最後尾以外に追加されることがある場合はオフセットページネーションの使用はお勧めできません。
カーソルページネーションのgemの例
カーソルページネーションを利用できるRuby gemも存在します。
activerecord-cursor github.com
pagy-cursor github.com
これらのgemを使用するのが一番手軽なのですが、弊社では別テーブルのカラムを参照してページネーションを行うパターンの対応が必要だったため、自作することにしました。
カーソルページネーション実装
APIは以下のようなパラメータ、レスポンスを用意しました。
パラメータはcursor
、direction
を用意。
GET /items?cursor=1234567&direction=next
レスポンスはlinks内にprev
、next
を用意してページ遷移を可能にしました。
{ data: [ { id: 1, ... }, { id: 2, ... }, { id: 3, ... }, { id: 4, ... } ], links: { prev: '/items?cursor=1111111&direction=prev', next: '/items?cursor=222222&direction=next' } }
処理部分は前述した通り他テーブルのカラムを参照できる必要があったため、基本となる処理を抽象クラスとして実装しました。
抽象クラスを用いることで、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
上のコードがcursorの設定処理と対象のレコードの取得処理を行っている箇所です。activerecord-cursorのcursorの値はキーとなるカラムの値をエンコードした文字列としています。
処理は大きく分けて3つです。
- パラメータとして送られてきたcursorをデコードして基点となる値を取得
- cursorの値を用いて対象のレコードを取得
- レスポンスに設定する次の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
次のレコードがあるかどうかを確認することで、レスポンスにcursorがある場合は次のページが存在し、ない場合はレスポンスデータが最後のページであることが分かります。
以上のような処理を参考にして無事実装が完了しました。カーソルページネーション処理をリリース後、新着一覧でデータが重複する問題が解消されました。
さいごに
カーソルページネーションの自作方法についてまとめてみました。ぜひ参考にしてみてください。
crispyでは、HEIM、3rdmallの開発を一緒に盛り上げてくれるエンジニアを募集しています!興味がある方はぜひ一度お話をしましょう。お待ちしております。