先日のこと
>「次の5件」リンクを押しても中身が表示されません。
ページネーション実装がバグっていた
システム全体で同じ実装だと思っていたが、よく見てみると数種類あった
どれが正しいのかわからない
- 実装おかしいけどユーザーに影響がない
- バグではないが効率が悪い
- 特殊ケースでバグになる
今日は、この謎を解く過程で調べたことを発表します。
ページネーションには 2 種類ある
- ページ数を意識するページネーション
- ページ数を意識しないページネーション
(無限スクロール)
今回はページ数を意識しないページネーション(無限スクロール型)だった
スクロールではないが、「次のN件を表示する」をクリックするとページが下方向に伸びていく。
無限スクロール型ページネーション
- ページ番号を指定して移動しない
- ページをスキップしない
- ページを戻る場合はブラウザをスクロールする
Rails でよく使われるライブラリ
- kaminari
https://github.com/kaminari/kaminari - will_paginate
https://github.com/mislav/will_paginate - pagy
https://github.com/ddnexus/pagy
これらのライブラリの特徴
- ページ番号を指定する
- OFFSET/LIMIT を使う
- ページ番号は OFFSET/LIMIT 句のない
SELECT COUNT(*)
との比較で算出 - DB が RDBMS でない場合も概要は同じ
- 暗黙的な仕様として、結果セットにソート順が指定されている必要がある
余談
- ソート順が指定されていないのになんとなく動いている実装がたまにありますよね…。
- MySQL が主キー(PK)や外部キー(FK) の順で表示されやすい特性
OFFSET/LIMIT 方式のページネーションの問題点
- 頻繁にページネーション対象の総数が変わるような更新があると、歯抜けや重複が発生する
- 1ページ目に新規追加が多いと2ページ目に同じものが表示される、歯抜けができる、中が飛ばされる、など。
- 強い一貫性の機能(ポイントインタイムスナップショット)をサポートするDBなら問題ないがそこまでするかというと…
- ページの境目となるレコードを指さずにページ番号を指定するから
『APIデザイン・パターン』では「避けるべきパターン」となっている
- 実装の詳細が API に現れ出てしまう
- 分散システムでは OFFSET を見つけるのに計算コストが高くなる
では、『APIデザイン・パターン』のオススメは?
以下の3つのフィールドを使用
- pageToken
- maxPageSize
- nextPageToken
pageToken
- カーソルとして使用されるもの。ページングを続けるべきかどうかを伝える。要はページではなく、レコードを指すもの。
- 『APIデザイン・パターン』では、利用者(クライアント)にとって意味のない値にするべき等が語られているが、実用的には ID で不都合ないと思う。
- 担当したアプリでは
max_id
だった。
- 担当したアプリでは
- 『Web API: The Good Parts』ではこのパラメータ名について有名サービスではどうなっているかが表になっているので一読オススメ
maxPageSize
- いわゆるページサイズ
- なぜ
max~
かについて、『APIデザイン・パターン』では、いかにも Google の人らしい注意点が語られている
nextPageToken
- 次のページのカーソルを示すもの。pageToken が現在のページならはこちらが次のページ。
- 実は
pageToken
がなくともこちらがあればよい
ActiveRecord での実装
説明のため、以下の仕様とする。
- nextPageToken ActiveRecord の
id
列 をmax_id
パラメータとして渡す - ソートの基準 ActiveRecord の
created_at
列を使う - maxPageSize 10 とする
max_id_item = Item.find(params[:max_id])
@items = Item.where(...).where('items.created_at > ?', max_id_item.created_at).limit(10)
nextPageToken max_id
をどう渡すか
REST API でハイパーメディア(要はリンク)のメタデータを提供する方法
-
href
,links
,_links
といった名前で埋め込む- 自分のプロジェクトでは _links だった。
{ _links: [ { rel: "next", href: "https://example.com/items?max_id=100" }, ], items: [ { id: 123, name: "アイテム名", ... }, ... ] }
標準仕様はないが、『Web APIの設計』によると、広く知られたものは以下の仕様があるらしい。
- HAL
- Collection+JSON
- JSON API
- JSON-LD
- Hydra
- Siren
何がバグになっていたか?
max_id_item = Item.find(params[:max_id])
@items = Item.where(...).where('items.created_at > ?', max_id_item.created_at).limit(10)
if @items.size == MAX_PAGE_SIZE
@link[:next] = @items.last.id # これは次のページのレコードではなく現在のページの最後のレコード
end
のようになっていて、次のページが存在するかどうかが、ページサイズの件数と取得レコードの件数が同じかどうかで判定されていた
-
@links
変数が API で_links
になりフロントエンドはこの有無で判断していた)。 - ページサイズが10件だったときにレコード総数がちょうど 10 でも次のページがあるようになっていた。
- レコード総数がページサイズの倍数だと発生する。
「次のN件を表示する」
-> クリックして API を叩く
-> API は 0 件を返す
-> フロントエンドは終端の表示に切り替わる
-> 「クリックしても何も表示されない」
修正方法
OFFSET/LIMIT 方式のように対象全件を SELECT COUNT(*)
する
以下の件数を比較して次のページがあるか判定する
SELECT * FROM items
WHERE items.created_at > max_id レコード.created_at ORDER BY created_at LIMIT 10
SELECT COUNT(*) FROM items
WHERE items.created_at > max_id レコード.created_at ORDER BY created_at
似た SQL を常に 2 回発行するのが、 SQL 発行抑止マニアとしては許せない
My Answer
My
LIMIT 句でページサイズより 1 件多く取得する。
- ページサイズより多かった場合はフロントエンドに返す前に1件捨てる
- 捨てる1件を max_id として
_links
に指定する。
- LIMIT page_size
+ LIMIT page_size + 1
- WHERE items.created_at > max_id レコード.created_at ORDER BY created_at
+ WHERE items.created_at >= max_id レコード.created_at ORDER BY created_at
if @items.size > MAX_PAGE_SIZE
@link[:next] = @items.to_a.pop.id # これは次のページの1件目のレコード
end
めでたしめでたし
未解決の問題
- アクセス制御
- pageToken の消失
アクセス制御
- 実は、DB から取得後フロントエンドに返す前にアクセス制御でレコードをフィルタリングしている
- Twitter のブロック、ミュート
- 公開範囲
- DB から取得した件数より減ってしまうので、おかしくなる可能性がある
- 極端な状況を想定すると、次のページがあるが、現在のページは0件
- DB のクエリにすべての条件を含めることが難しい
- アクセス制御まで含めてページを作るようにすればできるが SQL をいくつも発行したり、そこまでする必要があるかというと…
- レアケースなので断念
pageToken の消失
次のページを取得するまでに max_id のレコードが削除されてしまうケース
- 論理削除(soft delete) なら OK だが、物理削除だと、max_id に該当するレコードが取得できず、ソートの基準に当たる値が手に入らない
- 想定外の結果セットが帰ってきたり、インデックスや全件を舐めるようなクエリが発生する
- 論理削除方式なので助かった
- フラグ方式ではなくてアクティブな状態を示すテーブルとの結合可否で判断