Paginasi

Paginasi berbasis offset dan berbasis kursor untuk daftar data besar.

Daftar isi

Daftar data yang panjang tidak dapat dikirim sekaligus dalam satu respons, sehingga API membaginya menjadi halaman-halaman kecil. Topik ini membahas dua strategi paginasi pada aplikasi catatan, yaitu paginasi offset yang menggunakan nomor halaman dan paginasi kursor (cursor) yang menggunakan penanda posisi baris terakhir. Mekanisme query, bentuk JSON respons, dan kriteria pemilihan antara keduanya dibahas berurutan. Seluruh skenario diuji pada dua belas catatan pembacaan suhu sehingga perbedaan kedua strategi terlihat dari keluaran nyata.

Menyiapkan data daftar#

Kedua strategi dibandingkan pada kumpulan data yang sama, yaitu dua belas catatan pembacaan suhu pada kategori Sensor. Baris catatan dibuat dengan firstOrCreate, sehingga eksekusi ulang blok yang sama tidak menduplikasi data dan keluaran tetap identik. Kategorinya sendiri juga dibuat dengan firstOrCreate terlebih dahulu, sehingga id-nya tersedia sebelum baris catatan ditambahkan. Jumlah dua belas baris dipilih agar halaman berukuran lima menghasilkan tiga halaman penuh, cukup untuk mengamati perpindahan antarhalaman.

bash
# menyiapkan 12 catatan sensor sebagai data paginasi
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
$sensor = Category::firstOrCreate(["nama" => "Sensor"]);
$data = [
    ["Suhu kantor pukul 08:00", 24],
    ["Suhu kantor pukul 09:00", 25],
    ["Suhu kantor pukul 10:00", 27],
    ["Suhu kantor pukul 11:00", 28],
    ["Suhu kantor pukul 12:00", 29],
    ["Suhu kantor pukul 13:00", 30],
    ["Suhu kantor pukul 14:00", 30],
    ["Suhu kantor pukul 15:00", 29],
    ["Suhu kantor pukul 16:00", 28],
    ["Suhu kantor pukul 17:00", 27],
    ["Suhu kantor pukul 18:00", 26],
    ["Suhu kantor pukul 19:00", 25],
];
foreach ($data as [$judul, $suhu]) {
    Note::firstOrCreate(["judul" => $judul], ["isi" => "Sensor terbaca ".$suhu." derajat Celsius.", "category_id" => $sensor->id]);
}
echo "catatan kategori Sensor: ".Note::where("category_id", $sensor->id)->count().PHP_EOL;
'
text Keluaran
catatan kategori Sensor: 12

Data paginasi difilter pada satu kategori agar totalnya tidak berubah ketika catatan kategori lain bertambah. Rute daftar yang dibangun pada topik ini juga memilih kolom judul dan isi saja, sehingga payload tetap kecil dan tidak menyeret kolom waktu. Penyaringan seperti ini mengikuti pola yang lazim pada API nyata, yaitu daftar yang selalu dibatasi cakupannya.

Paginasi offset dengan paginate()#

Paginasi offset menerjemahkan nomor halaman menjadi dua klausa SQL, yaitu LIMIT yang menyatakan jumlah baris per halaman dan OFFSET yang menyatakan berapa baris yang dilewati dari awal daftar. Halaman kedua berukuran lima baris diterjemahkan menjadi LIMIT 5 OFFSET 5, sehingga database mulai membaca dari baris keenam. Metode paginate() menambahkan satu query COUNT untuk menghitung total baris, sehingga respons memuat total, nomor halaman terakhir, dan tautan halaman sebelumnya atau berikutnya.

Rute daftar aplikasi catatan diganti menjadi rute yang dipaginasi, dan satu rute kursor ditambahkan di sampingnya untuk dibahas pada bagian berikut. Argumen pertama paginate() menyatakan jumlah baris per halaman, sedangkan argumen kedua membatasi kolom yang diambil. Rute GET /catatan/kursor diletakkan sebelum rute GET /catatan/{id} agar kata kursor tidak tertelan sebagai nilai parameter id.

bash
# menimpa routes/api.php dengan rute daftar yang dipaginasi
cd ~/aplikasi-catatan
cat > routes/api.php <<'PHP'
<?php

use App\Http\Resources\NoteResource;
use App\Http\ResponsApi;
use App\Models\Category;
use App\Models\Note;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

Route::prefix('v1')->group(function () {
    Route::get('/catatan', function () {
        return Note::where('category_id', Category::where('nama', 'Sensor')->value('id'))
            ->orderBy('judul')
            ->paginate(5, ['judul', 'isi']);
    });

    Route::get('/catatan/kursor', function () {
        return Note::where('category_id', Category::where('nama', 'Sensor')->value('id'))
            ->orderBy('judul')
            ->cursorPaginate(4, ['judul', 'isi']);
    });

    Route::get('/catatan/{id}', function (string $id) {
        $note = Note::with('category')->find($id);

        if ($note === null) {
            return ResponsApi::gagal('Catatan tidak ditemukan.', [], 404);
        }

        return new NoteResource($note);
    });

    Route::post('/catatan', function (Request $request) {
        $data = $request->validate([
            'judul' => ['required', 'string', 'max:100'],
            'isi' => ['required', 'string'],
            'kategori' => ['required', 'string'],
        ]);

        $kategori = Category::firstOrCreate(['nama' => $data['kategori']]);
        $note = Note::firstOrCreate(
            ['judul' => $data['judul']],
            ['isi' => $data['isi'], 'category_id' => $kategori->id]
        );

        return ResponsApi::sukses(new NoteResource($note), 'Catatan tersimpan.', 201);
    });

    Route::middleware(EnsureFrontendRequestsAreStateful::class)->group(function () {
        Route::post('/login', function (Request $request) {
            $data = $request->validate([
                'email' => ['required', 'email'],
                'password' => ['required', 'string'],
            ]);

            if (! Auth::attempt($data)) {
                return response()->json(['pesan' => 'Kredensial tidak cocok.'], 401);
            }

            return response()->json([
                'pesan' => 'Login berhasil.',
                'nama' => $request->user()->name,
            ]);
        });

        Route::get('/pengguna', function (Request $request) {
            if (! $request->user()->tokenCan('profil:lihat')) {
                return response()->json([
                    'pesan' => 'Token tidak memiliki izin profil:lihat.',
                ], 403);
            }

            return response()->json($request->user()->only(['name', 'email']));
        })->middleware('auth:sanctum');
    });
});
PHP

Perilakunya diuji dengan skrip berikut yang meminta halaman pertama dan halaman kedua. Data contoh dibangun kembali di awal skrip dengan firstOrCreate agar skrip tetap menghasilkan keluaran yang sama apabila dijalankan sendiri.

php uji-offset.php
<?php

use App\Models\Category;
use App\Models\Note;
use Illuminate\Http\Request;

$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
    $root = getenv('HOME').'/aplikasi-catatan';
}

require $root.'/vendor/autoload.php';

$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// data contoh kategori Sensor disiapkan secara deterministik
$sensor = Category::firstOrCreate(['nama' => 'Sensor']);
$data = [
    ['Suhu kantor pukul 08:00', 24],
    ['Suhu kantor pukul 09:00', 25],
    ['Suhu kantor pukul 10:00', 27],
    ['Suhu kantor pukul 11:00', 28],
    ['Suhu kantor pukul 12:00', 29],
    ['Suhu kantor pukul 13:00', 30],
    ['Suhu kantor pukul 14:00', 30],
    ['Suhu kantor pukul 15:00', 29],
    ['Suhu kantor pukul 16:00', 28],
    ['Suhu kantor pukul 17:00', 27],
    ['Suhu kantor pukul 18:00', 26],
    ['Suhu kantor pukul 19:00', 25],
];
foreach ($data as [$judul, $suhu]) {
    Note::firstOrCreate(
        ['judul' => $judul],
        ['isi' => 'Sensor terbaca '.$suhu.' derajat Celsius.', 'category_id' => $sensor->id]
    );
}

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

// skenario 1: halaman pertama paginasi offset
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan?page=1', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '1. GET /api/v1/catatan?page=1: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 2: halaman kedua paginasi offset
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan?page=2', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '2. GET /api/v1/catatan?page=2: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;
text Keluaran
1. GET /api/v1/catatan?page=1: status 200
{"current_page":1,"data":[{"judul":"Suhu kantor pukul 08:00","isi":"Sensor terbaca 24 derajat Celsius."},{"judul":"Suhu kantor pukul 09:00","isi":"Sensor terbaca 25 derajat Celsius."},{"judul":"Suhu kantor pukul 10:00","isi":"Sensor terbaca 27 derajat Celsius."},{"judul":"Suhu kantor pukul 11:00","isi":"Sensor terbaca 28 derajat Celsius."},{"judul":"Suhu kantor pukul 12:00","isi":"Sensor terbaca 29 derajat Celsius."}],"first_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=1","from":1,"last_page":3,"last_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=3","links":[{"url":null,"label":"&laquo; Previous","page":null,"active":false},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=1","label":"1","page":1,"active":true},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=2","label":"2","page":2,"active":false},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=3","label":"3","page":3,"active":false},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=2","label":"Next &raquo;","page":2,"active":false}],"next_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=2","path":"http:\/\/localhost\/api\/v1\/catatan","per_page":5,"prev_page_url":null,"to":5,"total":12}
2. GET /api/v1/catatan?page=2: status 200
{"current_page":2,"data":[{"judul":"Suhu kantor pukul 13:00","isi":"Sensor terbaca 30 derajat Celsius."},{"judul":"Suhu kantor pukul 14:00","isi":"Sensor terbaca 30 derajat Celsius."},{"judul":"Suhu kantor pukul 15:00","isi":"Sensor terbaca 29 derajat Celsius."},{"judul":"Suhu kantor pukul 16:00","isi":"Sensor terbaca 28 derajat Celsius."},{"judul":"Suhu kantor pukul 17:00","isi":"Sensor terbaca 27 derajat Celsius."}],"first_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=1","from":6,"last_page":3,"last_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=3","links":[{"url":"http:\/\/localhost\/api\/v1\/catatan?page=1","label":"&laquo; Previous","page":1,"active":false},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=1","label":"1","page":1,"active":false},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=2","label":"2","page":2,"active":true},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=3","label":"3","page":3,"active":false},{"url":"http:\/\/localhost\/api\/v1\/catatan?page=3","label":"Next &raquo;","page":3,"active":false}],"next_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=3","path":"http:\/\/localhost\/api\/v1\/catatan","per_page":5,"prev_page_url":"http:\/\/localhost\/api\/v1\/catatan?page=1","to":10,"total":12}

Keluaran skenario pertama memuat lima baris pada data beserta kunci keadaan, yaitu current_page bernilai 1, from dan to yang menyatakan rentang baris, per_page, total 12, dan last_page 3. Kunci next_page_url berisi URL halaman berikutnya, sedangkan prev_page_url bernilai null karena halaman pertama adalah yang paling awal. Skenario kedua menampilkan baris keenam sampai kesepuluh, dan prev_page_url kini terisi menuju halaman satu. Kunci links memuat daftar tautan navigasi bernomor yang siap dirender oleh frontend.

Paginasi kursor dengan cursorPaginate()#

Paginasi kursor tidak bekerja dengan nomor halaman. Setiap respons menyertakan kursor, yaitu penanda posisi baris terakhir halaman yang sedang dilihat, dan permintaan berikutnya mengirim kursor tersebut agar halaman baru dimulai tepat setelah posisi itu. Query-nya berbentuk kondisi WHERE yang mencari baris setelah nilai urutan terakhir, sehingga database tidak perlu membaca dan membuang baris-baris sebelumnya. Metode cursorPaginate() membentuk kursor dari kolom urutan, yaitu judul pada rute ini, lalu mengenkodekannya dalam base64.

Skrip berikut mengambil halaman pertama, membaca tautan next_page_url dari responsnya, lalu mengirim permintaan kedua dengan kursor yang diberikan. Isi kursor didekode kembali di akhir skrip agar posisinya terbaca.

php uji-kursor.php
<?php

use App\Models\Category;
use App\Models\Note;
use Illuminate\Http\Request;

$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
    $root = getenv('HOME').'/aplikasi-catatan';
}

require $root.'/vendor/autoload.php';

$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// data contoh kategori Sensor disiapkan secara deterministik
$sensor = Category::firstOrCreate(['nama' => 'Sensor']);
$data = [
    ['Suhu kantor pukul 08:00', 24],
    ['Suhu kantor pukul 09:00', 25],
    ['Suhu kantor pukul 10:00', 27],
    ['Suhu kantor pukul 11:00', 28],
    ['Suhu kantor pukul 12:00', 29],
    ['Suhu kantor pukul 13:00', 30],
    ['Suhu kantor pukul 14:00', 30],
    ['Suhu kantor pukul 15:00', 29],
    ['Suhu kantor pukul 16:00', 28],
    ['Suhu kantor pukul 17:00', 27],
    ['Suhu kantor pukul 18:00', 26],
    ['Suhu kantor pukul 19:00', 25],
];
foreach ($data as [$judul, $suhu]) {
    Note::firstOrCreate(
        ['judul' => $judul],
        ['isi' => 'Sensor terbaca '.$suhu.' derajat Celsius.', 'category_id' => $sensor->id]
    );
}

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

// skenario 1: halaman pertama paginasi kursor
$resp = $kernel->handle(Request::create('http://localhost/api/v1/catatan/kursor', 'GET', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
]));
echo '1. GET /api/v1/catatan/kursor: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// skenario 2: halaman berikutnya mengikuti tautan next_page_url
$body = json_decode($resp->getContent(), true);
parse_str(parse_url($body['next_page_url'], PHP_URL_QUERY), $kueri);
$resp = $kernel->handle(Request::create(
    'http://localhost/api/v1/catatan/kursor?cursor='.urlencode($kueri['cursor']),
    'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']
));
echo '2. halaman berikutnya dengan cursor: status '.$resp->getStatusCode().PHP_EOL;
echo $resp->getContent().PHP_EOL;

// isi cursor didekode agar posisinya terbaca
echo '   isi kursor: '.base64_decode($kueri['cursor']).PHP_EOL;
text Keluaran
1. GET /api/v1/catatan/kursor: status 200
{"data":[{"judul":"Suhu kantor pukul 08:00","isi":"Sensor terbaca 24 derajat Celsius."},{"judul":"Suhu kantor pukul 09:00","isi":"Sensor terbaca 25 derajat Celsius."},{"judul":"Suhu kantor pukul 10:00","isi":"Sensor terbaca 27 derajat Celsius."},{"judul":"Suhu kantor pukul 11:00","isi":"Sensor terbaca 28 derajat Celsius."}],"path":"http:\/\/localhost\/api\/v1\/catatan\/kursor","per_page":4,"next_cursor":"eyJqdWR1bCI6IlN1aHUga2FudG9yIHB1a3VsIDExOjAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ","next_page_url":"http:\/\/localhost\/api\/v1\/catatan\/kursor?cursor=eyJqdWR1bCI6IlN1aHUga2FudG9yIHB1a3VsIDExOjAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ","prev_cursor":null,"prev_page_url":null}
2. halaman berikutnya dengan cursor: status 200
{"data":[{"judul":"Suhu kantor pukul 12:00","isi":"Sensor terbaca 29 derajat Celsius."},{"judul":"Suhu kantor pukul 13:00","isi":"Sensor terbaca 30 derajat Celsius."},{"judul":"Suhu kantor pukul 14:00","isi":"Sensor terbaca 30 derajat Celsius."},{"judul":"Suhu kantor pukul 15:00","isi":"Sensor terbaca 29 derajat Celsius."}],"path":"http:\/\/localhost\/api\/v1\/catatan\/kursor","per_page":4,"next_cursor":"eyJqdWR1bCI6IlN1aHUga2FudG9yIHB1a3VsIDE1OjAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ","next_page_url":"http:\/\/localhost\/api\/v1\/catatan\/kursor?cursor=eyJqdWR1bCI6IlN1aHUga2FudG9yIHB1a3VsIDE1OjAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ","prev_cursor":"eyJqdWR1bCI6IlN1aHUga2FudG9yIHB1a3VsIDEyOjAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjpmYWxzZX0","prev_page_url":"http:\/\/localhost\/api\/v1\/catatan\/kursor?cursor=eyJqdWR1bCI6IlN1aHUga2FudG9yIHB1a3VsIDEyOjAwIiwiX3BvaW50c1RvTmV4dEl0ZW1zIjpmYWxzZX0"}
   isi kursor: {"judul":"Suhu kantor pukul 11:00","_pointsToNextItems":true}

Respons kursor memuat data empat baris, next_cursor, dan next_page_url yang siap digunakan klien tanpa perlu menyusun URL sendiri. Halaman kedua menambahkan prev_cursor dan prev_page_url, sehingga mundur ke halaman sebelumnya juga dimungkinkan. Isi kursor yang didekode berupa {"judul":"Suhu kantor pukul 11:00","_pointsToNextItems":true}, yaitu judul baris terakhir halaman pertama beserta arah pencariannya. Halaman berikutnya pun dimulai dari baris pukul 12:00, tepat setelah posisi yang ditandai. Perbedaan kedua strategi pada data yang sama digambarkan sebagai berikut.

Offset123456789101112page=2 berarti LIMIT 5 OFFSET 5Kursor123456789101112lanjut tepat setelah posisi terakhirOffset menghitung posisi dari awal daftar, sedangkan kursor mencari dari baris terakhir halaman sebelumnya.
Perbedaan paginasi offset dan paginasi kursor

Memilih antara offset dan kursor#

Kedua strategi melayani kebutuhan yang berbeda. Offset menyediakan nomor halaman dan total, sehingga cocok untuk daftar administratif yang pembacanya perlu melompat ke halaman tertentu. Kursor tidak menyediakan keduanya, tetapi biayanya tetap rendah pada daftar yang sangat besar karena database langsung mencari dari posisi terakhir tanpa membaca baris-baris sebelumnya.

AspekOffsetKursor
Nomor halaman dan totaltersediatidak ada
Lompat ke halaman sembarangdidukungtidak didukung
Biaya pada daftar sangat besarmembaca ulang baris sebelumnyamencari langsung dari posisi
Baris baru menyisip antar halamanposisi bergesertidak terpengaruh

Offset juga rentan terhadap daftar yang berubah cepat. Baris baru yang disisipkan di antara dua permintaan menggeser posisi baris lain, sehingga halaman kedua dapat menampilkan ulang baris yang sudah dilihat pada halaman pertama. Kursor tidak terpengaruh pergeseran seperti itu karena posisinya terikat pada nilai baris, bukan pada hitungan urutan.

Kesalahan umum#

Kesalahan pertama adalah memanggil paginate() tanpa orderBy(). SQL tidak menjamin urutan baris tanpa klausa ORDER BY, sehingga baris yang sama dapat muncul di dua halaman berbeda atau terlewat sama sekali. Perbaikannya adalah menetapkan urutan yang stabil, misalnya orderBy('judul'), sebelum paginate() atau cursorPaginate() dipanggil.

Kesalahan kedua adalah mengambil seluruh baris lalu memotongnya di PHP dengan get() dan slice(). Seluruh baris tetap dibaca ke memori meskipun hanya lima baris yang dikirim, sehingga daftar besar menghabiskan memori. Perbaikannya adalah memindahkan pemotongan ke level query melalui paginate() atau cursorPaginate().

Kesalahan ketiga adalah mengubah isi kursor atau menggunakannya pada rute yang berbeda. Pada proyek uji, kursor yang tidak sah diabaikan dan permintaan dijawab sebagai halaman pertama tanpa peringatan, sehingga kekeliruan ini sulit terlihat. Perbaikannya adalah mengirim nilai next_cursor atau URL next_page_url persis sebagaimana respons sebelumnya memberikannya.

Ringkasan#

  • paginate() menerjemahkan nomor halaman menjadi LIMIT dan OFFSET beserta query COUNT, sehingga respons memuat total, last_page, dan tautan antarhalaman.
  • cursorPaginate() menggunakan penanda posisi baris terakhir yang dienkode dalam base64, sehingga halaman berikutnya dicari langsung tanpa total dan tanpa nomor halaman.
  • Urutan yang stabil melalui orderBy() menjadi syarat bagi kedua strategi agar baris tidak terlewat atau terduplikasi antarhalaman.
  • Offset dipilih apabila klien membutuhkan total dan lompatan halaman, sedangkan kursor dipilih untuk daftar besar yang berubah cepat.

Referensi#