概要
offsetとlimitを使ったオフセットベースのページネーションしか知らなかったが、
Laravelのdocument > ページネーションの項目を見ていて、以下2種類あることを知った。
- オフセットベース
- カーソルベース
今回は、それらについて調査して、Laravelにおける実装方法をまとめる。
1. オフセットベース
最も一般的に使われるページネーション。
UI
以下の様な番号付きのページネーションが一般的。
上記において、表示するレコードを取得するために発行しているSqlの具体例は以下の通り。
発行されるSql
/**
* 1ページ目のレコードを取得
*/
select * from "posts" order by "posts_id" asc limit 5 offset 0
/**
* 2ページ目のレコードを取得
*/
select * from "posts" order by "posts_id" asc limit 5 offset 5
/**
* 最後のページのレコードを取得
*/
select * from "posts" order by "posts_id" asc limit 5 offset 995
- order
- ページネーションで取得するレコードに一貫性を持たせるために明示的に指定。
- limit
- 1ページ当たりのレコード数を指定するために使用。
- offset
- 何ページ目のレコードを取得するか指定するために使用。
- limitの値とページ番号(page_num)を使って、以下の式で計算可能。
- offset = (page_num – 1) * limit
特徴
- メリット
- ページ番号付きのページネーションができる
- 後述するように「2. カーソルベース」ではページ番号付きのページネーションができない。
- そのため、ページ番号が必要な場合、こちらを採用する必要がある。
- ページ番号付きのページネーションができる
- デメリット
- offsetの件数分の読み込みが必要になるため、大規模なデータセットの場合だとパフォーマンスが低下する
- 特に「1万件のレコードの最後のページを取得」の様な場合、
offset 9995
等、offsetの数が大きくなるので影響大。
- 特に「1万件のレコードの最後のページを取得」の様な場合、
- 書き込み(insert/delete)が頻繁なアプリケーションの場合、データの重複/不足が発生する可能性がある
- 例)以下②の様にページネーション途中でレコードが削除されると、1ページ目と2ページ目で重複してレコードが取得される。
- offsetの件数分の読み込みが必要になるため、大規模なデータセットの場合だとパフォーマンスが低下する
使い所
上記特徴を踏まえ、以下の様なケースで採用される。
- ページネーション対象が大規模なデータセットではない
- ページ番号を指定したページネーションを導入したい
2. カーソルベース
UI
ページ送りに関しては「Previous(前へ)」「Next(次へ)」ボタンのみの様なシンプルなUIになる。
上記において、表示するレコードを取得するために発行しているSqlの具体例は以下の通り。
発行されるSql
/**
* 初回のレコードを取得
*/
select * from "posts" order by "posts_id" asc limit 5
/**
* 初回ページから、「Next」ボタンクリックで、次のページに進む場合
*/
select * from "posts" where ("posts_id" > 5) order by "posts_id" asc limit 5
/**
* 初回ページから、「Prev」ボタンクリックで、前のページに戻る場合
*/
select * from "posts" where ("posts_id" < 6) order by "posts_id" asc limit 5
- order
- ページネーションで取得するレコードに一貫性を持たせるために明示的に指定。
- limit
- 1ページ当たりのレコード数を指定するために使用。
- where
- 取得開始位置を指定するために使用。
特徴
- メリット
- オフセットベースと比べて、パフォーマンスが良い
- オフセットベース > デメリットに記載したような「offset 件数分の読み込み」が発生しないため
- オフセットベースと比べて、パフォーマンスが良い
- デメリット
- indexの適切な設定が必要
- order_byで指定するカラムにindexを張る必要がある
- indexを張ると書き込み性能に影響があるため、注意が必要。
- ページ番号付きのページネーションができない
- ページ番号が欲しい場合は、オフセットベースを使う必要がある。
- indexの適切な設定が必要
使い所
以下の様なケースで採用される。
- ページネーション対象が大規模なデータセットである場合
- ページ番号を指定したページネーションを導入しない場合
- 無限スクロールを採用する場合
Laravelでの実装方法
ついでに、ページネーションの種類毎のLaravelでの実装方法をまとめておく。
実装したソースは以下に置いておく。
Github url
前提
Laravel:10.4
php:8.3
1. オフセットベース
公式を見れば基本的な使い方は分かる。
paginate()
関数を使ったページネーション
Eloquentに紐づいたテーブルのレコードをページネーションしたい場合、以下の様にpaginate()
を使って簡単に実装可能。
1)サーバー側でページネーションのインスタンスを作る
Route::get('/offset', function(DatabaseManager $databaseManager) {
// paginate()でページネーション
$postsList = Posts::query()->orderBy('posts_id', 'asc')->paginate(5);
// ページネーションのインスタンスごと、フロント(今回はblade)に渡す
return view('posts.index', compact('postsList'));
});
2)サーバー側で作ったページネーションのインスタンスをレンダリング
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Laravel</title>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
<style type="text/css">
tr {
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div class="container m-2">
<table>
<thead>
<tr>
<th>No.</th>
<th>primary_key</th>
<th>title</th>
<th>content</th>
</tr>
</thead>
<tbody>
// ここは取得したレコードのレンダリング
@foreach ($postsList as $key => $post)
<tr>
<td>{{ ++$key }}</td>
<td>{{ $post->posts_id }}</td>
<td>{{ $post->title }}</td>
<td>{{ $post->content }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="col-md-12 mt-2">
// これだけ
{{ $postsList->links() }}
</div>
</div>
</div>
</body>
</html>
resources/views/posts/index.blade.php
links()
を書くだけで、
ページネーションに必要なボタン等のUIをレンダリングしてくれる。
(UIについては、「1. オフセットベース」> 「UI」を参照)
ちなみに、
各ページ番号ボタン1には、以下の通り、
表示中のページのurlにページネーション用のクエリ(?page=n)を付加したaタグが設定されているので、
クリックするだけで該当のボタンに同期的に遷移することが可能。(js等の実装は不要)
また、 bladeについては、オフセットベースとカーソルベースの場合で、(特に拘らない様であれば)同じtemplateで書ける。
(links()
が、いい感じに「オフセットベース用 or カーソルベース用のボタンを用意する」所も考慮した上で、レンダリングしてくれる)
またまた、paginate()
ではなく、simplePaginate()
を使えば、
UI上はページ番号が表示されず「prev」「next」ボタンだけ表示される。
ページネータを手動で生成する場合のページネーション
上述のpaginate()
によるページネーションの場合、
paginate()
を呼ぶだけで、裏側でLaravelがページネーションに必要な諸々をいい感じに処理してくれますが、
それでは表現しきれない場合、Laravelは以下の通り幾つかの代替手段を提供しています。
場合によっては、ペジネーションインスタンスを手作業で作成し、メモリ内にすでにあるアイテムの配列を渡すことができます。 必要に応じて、
引用元:Laravel 10.x データベース:ペジネーションIlluminatePaginationPaginator
、IlluminatePaginationLengthAwarePaginator
、IlluminatePaginationCursorPaginator
インスタンスを生成することでこれが行えます。
オフセットベースのページネーションの場合は、LengthAwarePaginator.phpを使うと良さそう。
1)サーバー側でページネーションのインスタンスを作る
Route::get('/offset/manual', function(Request $request) {
// get base query
$query = Posts::query()->orderBy('posts_id', 'asc');
// get total(for pagination)
$total = $query->count('posts_id');
// perPage
$perPage = 5;
$query->limit($perPage);
// offset
$pageNum = match ($pN = $request->query('page')) {
null => 1,
default => $pN,
};
$query->offset(($pageNum - 1) * $perPage);
// execute
$postsList = new LengthAwarePaginator(
items: $query->get(),
total: $total,
perPage: $perPage,
currentPage: $pageNum,
options: [
// これを設定しないと、ページネーションのリンクが正しく生成されない(固定で「/?page=*」へのアクセスになってしまう)
'path' => LengthAwarePaginator::resolveCurrentPath(),
]
);
return view('posts.index', compact('postsList'));
});
「1. 」でページネーションのインスタンスを作成するために使ったpaginate()
のレスポンスが実はLengthAwarePaginator
そのものであり、
手動でページネートとする場合には、上記の通り、LengthAwarePaginator
をnewしてフロントに返す必要がある。
2)サーバー側で作ったページネーションのインスタンスをレンダリング
「1. paginate()
関数を使ったページネーション」と同じテンプレートで良いので省略
—
これで、「1. 」と同等のページネーションと実装できた。
2. カーソルベース
こちらも公式を見れば基本的な使い方は分かる。
cursorPaginate()
関数を使ったページネーション
「1. オフセットベース」と異なる点は、
「呼び出す関数がpaginate()
ではなく、cursorPaginate()
である、」という点のみ。
1)サーバー側でページネーションのインスタンスを作る
Route::get('/cursor', function(DatabaseManager $databaseManager) {
$postsList = Posts::query()->orderBy('posts_id', 'asc')->cursorPaginate(5);
return view('posts.index', compact('postsList'));
});
2)サーバー側で作ったページネーションのインスタンスをレンダリング
「1. paginate()
関数を使ったページネーション」と同じテンプレートで良いので省略
ちなみに、
カーソルベースの場合「Previous」「Next」ボタンのaタグには、
以下の様に前後どちらのページに遷移するかを示すカーソル情報を、
「?cursor=***」
というパラメータで持っている。
このcursor
パラメータは、Laravelが裏側の仕組みで、ページ中の最初・最後のレコードの値をこんな感じで、encodeして生成した値となっている。
で、「Previous」「Next」ボタンクリック時には、そのcursor
の値がリクエストで送られて、
この辺りでdecodeされた結果、次のページに表示するレコードの開始位置を決定するために使われている。
ページネータを手動で生成する場合のページネーション
カーソルベースのページネーションの場合は、CursorPaginator.phpを使う。
1)サーバー側でページネーションのインスタンスを作る
Route::get('/cursor/manual', function(Request $request) {
// get base query
$query = Posts::query();
// perPage(limit)
$perPage = 5;
$query->limit($perPage + 1);
// where、orderBy(cursor)
$cursorParam = $request->input('cursor');
if ($cursorParam === null) {
$query->orderBy('posts_id', 'asc');
} else {
// cursor
$cursorEncoded = Cursor::fromEncoded($cursorParam);
$cursor = new Cursor(
parameters: ['posts_id' => $cursorEncoded->parameter('posts_id')],
pointsToNextItems: $cursorEncoded->pointsToNextItems(),
);
// where、orderBy
if ($cursor->pointsToNextItems()) {
$orderBy = 'asc';
$operator = '>';
} else {
$orderBy = 'desc';
$operator = '<';
}
$query->orderBy('posts_id', $orderBy);
$query->where('posts_id', $operator, $cursor->parameter('posts_id'));
}
// execute
$items = $query->get();
// generate paginator
$postsList = new CursorPaginator(
items: $items,
perPage: $perPage,
cursor: $cursor ?? null,
options: [
'path' => Paginator::resolveCurrentPath(),
'corsorName' => 'cursor',
'parameters' => [
'posts_id',
],
]
);
return view('posts.index', compact('postsList'));
});
(TODO 細かい解説書こうと思ったが、力尽きた…気が向いたら書き足す…)
「オフセットベース」の場合と同じくだが、
「1. 」でページネーションのインスタンスを作成するために使ったcurosrpaginate()
のレスポンスがCursorPaginator.php
であり、
手動でページネートする場合にはCursorPaginator
をnewしてフロントに返す必要がある。
手動の場合、以下辺りを自前で実装するのが面倒でした。
Cursor.php
(カーソルベースのページネーションにおけるカーソルの役割を担うクラス)の生成- where句が一筋縄ではいかない
大人しくcursorPaginate()を使う方が基本的にはいい感じがします。
それで事足りなければ、自前で実装するしかない、という感じですかね。
2)サーバー側で作ったページネーションのインスタンスをレンダリング
「1. paginate()
関数を使ったページネーション」と同じテンプレートで良いので省略
自前で実装し切るのは大変な気もするので、
便利な機能は使えるだけ使った方が良さそうだと思いました。
(改めて処理を追ってみて勉強にはなりましたが…)
終わりに
オフセットベース・カーソルベースは、メリット・デメリットを把握した上で、要件に従って使い分ける必要がある、と言うことが分かった。
ページネーションは奥が深い。
参考
オフセット・ページネーションとカーソル・ページネーションの比較|tomocito
コメント