MENU

オフセットベース・カーソルベースのページネーションの比較& Laravelでの実装例

目次

概要

offsetとlimitを使ったオフセットベースのページネーションしか知らなかったが、

Laravelのdocument > ページネーションの項目を見ていて、以下2種類あることを知った。

  • オフセットベース
  • カーソルベース

今回は、それらについて調査して、Laravelにおける実装方法をまとめる。

1. オフセットベース

最も一般的に使われるページネーション。

UI

以下の様な番号付きのページネーションが一般的。

Laravel10 のオフセットページネーションのデフォルトの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の数が大きくなるので影響大。
    • 書き込み(insert/delete)が頻繁なアプリケーションの場合、データの重複/不足が発生する可能性がある
      • 例)以下②の様にページネーション途中でレコードが削除されると、1ページ目と2ページ目で重複してレコードが取得される。

使い所

上記特徴を踏まえ、以下の様なケースで採用される。

  • ページネーション対象が大規模なデータセットではない
  • ページ番号を指定したページネーションを導入したい

2. カーソルベース

UI

ページ送りに関しては「Previous(前へ)」「Next(次へ)」ボタンのみの様なシンプルなUIになる。

Laravel10 のカーソルページネーションのデフォルトの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の適切な設定が必要
    • ページ番号付きのページネーションができない
      • ページ番号が欲しい場合は、オフセットベースを使う必要がある。

使い所

以下の様なケースで採用される。

  • ページネーション対象が大規模なデータセットである場合
  • ページ番号を指定したページネーションを導入しない場合
  • 無限スクロールを採用する場合

Laravelでの実装方法

ついでに、ページネーションの種類毎のLaravelでの実装方法をまとめておく。

実装したソースは以下に置いておく。

Github url

前提

Laravel:10.4
php:8.3

1. オフセットベース

公式を見れば基本的な使い方は分かる。

URL

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'));
});

/routes/web.php#L24-L35

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は以下の通り幾つかの代替手段を提供しています。

ペジネータの手作業生成

場合によっては、ペジネーションインスタンスを手作業で作成し、メモリ内にすでにあるアイテムの配列を渡すことができます。 必要に応じて、IlluminatePaginationPaginatorIlluminatePaginationLengthAwarePaginatorIlluminatePaginationCursorPaginatorインスタンスを生成することでこれが行えます。

引用元:Laravel 10.x データベース:ペジネーション

オフセットベースのページネーションの場合は、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'));
});

/routes/web.php#L37-L69

「1. 」でページネーションのインスタンスを作成するために使ったpaginate()のレスポンスが実はLengthAwarePaginatorそのものであり、

手動でページネートとする場合には、上記の通り、LengthAwarePaginatorをnewしてフロントに返す必要がある。

2)サーバー側で作ったページネーションのインスタンスをレンダリング

「1. paginate()関数を使ったページネーション」と同じテンプレートで良いので省略

これで、「1. 」と同等のページネーションと実装できた。

2. カーソルベース

こちらも公式を見れば基本的な使い方は分かる。

URL

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'));
});

/routes/web.php#L77-L88

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'));
});

/routes/web.php#L90-L146

(TODO 細かい解説書こうと思ったが、力尽きた…気が向いたら書き足す…)

「オフセットベース」の場合と同じくだが、

「1. 」でページネーションのインスタンスを作成するために使ったcurosrpaginate()のレスポンスがCursorPaginator.phpであり、

手動でページネートする場合にはCursorPaginatorをnewしてフロントに返す必要がある。

手動の場合、以下辺りを自前で実装するのが面倒でした。

  • Cursor.php(カーソルベースのページネーションにおけるカーソルの役割を担うクラス)の生成
  • where句が一筋縄ではいかない

大人しくcursorPaginate()を使う方が基本的にはいい感じがします。

それで事足りなければ、自前で実装するしかない、という感じですかね。

2)サーバー側で作ったページネーションのインスタンスをレンダリング

「1. paginate()関数を使ったページネーション」と同じテンプレートで良いので省略


自前で実装し切るのは大変な気もするので、

便利な機能は使えるだけ使った方が良さそうだと思いました。

(改めて処理を追ってみて勉強にはなりましたが…)

終わりに

オフセットベース・カーソルベースは、メリット・デメリットを把握した上で、要件に従って使い分ける必要がある、と言うことが分かった。

ページネーションは奥が深い。

参考

オフセット・ページネーションとカーソル・ページネーションの比較|tomocito

Offset vs Cursor Pagination in Laravel [In-Depth Guide]

ページネーション – kawasima

私と2つのページング物語

綺麗なAPI速習会 – Qiita

ページネーション | AppMaster

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次