Endpoint GET /api/produk dari Chapter sebelumnya mengembalikan seluruh baris tabel — strategi yang aman untuk dua puluh data dan mulai merusak untuk dua puluh ribu. Respons membengkak, basis data bekerja ekstra mengirim semua baris, dan ponsel pengguna menanggung semuanya. Pagination memotong hasil menjadi halaman berukuran terkendali; sorting menjamin potongan itu tiba dalam urutan yang bisa diandalkan. Pada Chapter ini kita bedah Pageable, Page, dan Slice milik Spring Data, cara kerja binding parameter ?page&size&sort, lalu membuktikan mekanisme pemotongan — termasuk jebakan nilai kembar saat mengurutkan — dengan simulasi Java murni.

Pageable sebagai Permintaan #

Pageable adalah permintaan: nomor halaman, ukuran halaman, dan urutan. Klien tidak perlu membangun objek itu — Spring Data membaca parameter query secara otomatis: ?page=1&size=5&sort=harga,desc menjadi PageRequest.of(1, 5, Sort.by("harga").descending()). Nomor halaman berbasis nol (page=1 adalah halaman kedua), ukuran bawaan 20 baris, dan properti sort boleh muncul berkali-kali untuk urutan berlapis. Metode repository cukup menerima parameter itu:

java
public interface ProdukRepository extends JpaRepository<Produk, Long> {

    Page<Produk> findByNamaContaining(String nama, Pageable pageable);
}

Di balik layar, Spring Data menambahkan LIMIT/OFFSET (atau pola setara) pada query beserta ORDER BY dari Sort, lalu menjalankan query hitungan kedua untuk COUNT(*). Dua query itu adalah harga yang dibayar agar respons membawa metadata lengkap — dan itulah perbedaan pertama Page dengan Slice, sepupunya yang lebih hemat:

AspekPage<T>Slice<T>
Isibaris halaman aktifbaris halaman aktif
totalElementsada (query COUNT kedua)tidak ada
hasNext()dihitung dari totaldihitung dari LIMIT+1
Cocok untuktabel dengan navigasi halamangulir tanpa akhir (infinite scroll)

Slice mengambil satu baris ekstra untuk mengetahui keberadaan halaman berikutnya tanpa query hitungan; Page membayar satu query tambahan demi angka total yang dibutuhkan UI bernomor. Untuk umpan gulir di aplikasi seluler, Slice hampir selalu lebih tepat.

Page sebagai Respons #

Page<T> membungkus jawaban: isi halaman plus metadata — totalElements, totalPages, hasNext(), hasPrevious(), isFirst(), isLast(), dan getNumber(). Controller modern menyerahkannya langsung ke serialisasi:

java
@GetMapping
public Page<Produk> semua(Pageable pageable) {
    return repo.findAll(pageable);
}

Bentuk JSON hasilnya memuat content, totalElements, hingga pageable — klien membaca metadata untuk merender nomor halaman tanpa query tambahan. Batasi ukuran halaman di sisi server (spring.data.web.pageable.max-page-size) agar klien tidak menyelinapkan size=100000 yang membatalkan tujuan pagination.

Simulasi Pemotongan dan Pengurutan #

Mekanisme di balik Pageable layak dilihat langsung. Program Java murni berikut membangun 25 produk dengan harga siklis (tujuh nilai berulang, sehingga banyak nilai kembar), mem-parsing query string ala binding Spring Data, mengurutkan, lalu memotong per halaman dengan metadata lengkap:

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

public class HalamanProduk {

    record Produk(int id, String nama, long harga) { }

    // Registri comparator ala Spring Data Sort: nama properti -> Comparator.
    static final Map<String, Comparator<Produk>> URUT = Map.of(
            "id", Comparator.comparingInt(Produk::id),
            "nama", Comparator.comparing(Produk::nama),
            "harga", Comparator.comparingLong(Produk::harga));

    // Padanan PageRequest: hasil binding query ?page&size&sort.
    record Permintaan(int halaman, int ukuran, String properti, boolean menurun) {

        static Permintaan dariQuery(String query) {
            int halaman = 0;
            int ukuran = 5;
            String properti = "id";
            boolean menurun = false;
            for (String pasangan : query.split("&")) {
                String[] kv = pasangan.split("=", 2);
                if (kv.length != 2) {
                    continue;
                }
                switch (kv[0]) {
                    case "page" -> halaman = Integer.parseInt(kv[1]);
                    case "size" -> ukuran = Integer.parseInt(kv[1]);
                    case "sort" -> {
                        String[] bagian = kv[1].split(",");
                        properti = bagian[0];
                        menurun = bagian.length > 1
                                && bagian[1].equalsIgnoreCase("desc");
                    }
                }
            }
            return new Permintaan(halaman, ukuran, properti, menurun);
        }

        // Comparator final: urutan utama + tie-breaker id naik.
        Comparator<Produk> comparator() {
            Comparator<Produk> utama = URUT.get(properti);
            if (menurun) {
                utama = utama.reversed();
            }
            return utama.thenComparing(Produk::id);
        }
    }

    // Padanan Page<T>: isi terpotong + metadata lengkap.
    record Halaman<T>(List<T> isi, int halaman, int ukuran, long totalElemen) {

        long totalHalaman() {
            return (totalElemen + ukuran - 1) / ukuran;
        }

        boolean berikutnya() {
            return halaman + 1 < totalHalaman();
        }

        boolean sebelumnya() {
            return halaman > 0;
        }
    }

    static <T> Halaman<T> ambil(List<T> dataUrut, Permintaan m) {
        // Offset dihitung sebagai long agar page*size tidak meluap int.
        long offset = m.halaman() * (long) m.ukuran();
        int dari = (int) Math.min(offset, dataUrut.size());
        int sampai = (int) Math.min(offset + m.ukuran(), dataUrut.size());
        return new Halaman<>(List.copyOf(dataUrut.subList(dari, sampai)),
                m.halaman(), m.ukuran(), dataUrut.size());
    }

    public static void main(String[] args) {
        List<Produk> semua = new ArrayList<>();
        for (int i = 1; i <= 25; i++) {
            semua.add(new Produk(i, String.format("Produk-%02d", i),
                    10_000 + (i % 7) * 5_000L));
        }
        System.out.println("== 25 produk, harga siklis 10000..40000 (ada nilai kembar) ==");
        System.out.println();

        for (String query : new String[]{
                "page=0&size=5&sort=harga,desc",
                "page=1&size=5&sort=harga,desc",
                "page=4&size=5&sort=harga,desc"}) {
            tampilkan(semua, query);
        }
        System.out.println();

        tampilkan(semua, "page=9&size=5&sort=harga,desc");
        System.out.println();

        System.out.println("== Tanpa tie-breaker id: urutan nilai kembar mengikuti urutan data ==");
        List<Produk> tanpaTie = new ArrayList<>(semua);
        tanpaTie.sort(Comparator.comparingLong(Produk::harga)); // List.sort stabil
        List<Integer> idSenilai = tanpaTie.stream()
                .filter(p -> p.harga() == 40_000)
                .map(Produk::id)
                .toList();
        System.out.println("urutan id untuk harga 40000: " + idSenilai);
        System.out.println("List.sort stabil, namun ORDER BY pada database TIDAK dijamin");
        System.out.println("stabil tanpa kolom pembeda -> selalu tambahkan tie-breaker id");
    }

    static void tampilkan(List<Produk> data, String query) {
        Permintaan m = Permintaan.dariQuery(query);
        List<Produk> dataUrut = new ArrayList<>(data);
        dataUrut.sort(m.comparator());
        Halaman<Produk> h = ambil(dataUrut, m);

        System.out.println("Query : " + query);
        System.out.printf("offset=%d  halaman=%d/%d  isi=%s%n",
                m.halaman() * (long) m.ukuran(), h.halaman() + 1, h.totalHalaman(),
                ringkas(h.isi()));
        System.out.printf("  totalElemen=%d  berikutnya=%s  sebelumnya=%s%n",
                h.totalElemen(), h.berikutnya(), h.sebelumnya());
    }

    static List<String> ringkas(List<Produk> isi) {
        return isi.stream()
                .map(p -> p.nama() + "/" + p.harga())
                .toList();
    }
}

Output yang Dihasilkan:

text
== 25 produk, harga siklis 10000..40000 (ada nilai kembar) ==

Query : page=0&size=5&sort=harga,desc
offset=0  halaman=1/5  isi=[Produk-06/40000, Produk-13/40000, Produk-20/40000, Produk-05/35000, Produk-12/35000]
  totalElemen=25  berikutnya=true  sebelumnya=false
Query : page=1&size=5&sort=harga,desc
offset=5  halaman=2/5  isi=[Produk-19/35000, Produk-04/30000, Produk-11/30000, Produk-18/30000, Produk-25/30000]
  totalElemen=25  berikutnya=true  sebelumnya=true
Query : page=4&size=5&sort=harga,desc
offset=20  halaman=5/5  isi=[Produk-15/15000, Produk-22/15000, Produk-07/10000, Produk-14/10000, Produk-21/10000]
  totalElemen=25  berikutnya=false  sebelumnya=true

Query : page=9&size=5&sort=harga,desc
offset=45  halaman=10/5  isi=[]
  totalElemen=25  berikutnya=false  sebelumnya=true

== Tanpa tie-breaker id: urutan nilai kembar mengikuti urutan data ==
urutan id untuk harga 40000: [6, 13, 20]
List.sort stabil, namun ORDER BY pada database TIDAK dijamin
stabil tanpa kolom pembeda -> selalu tambahkan tie-breaker id

Mari kita bedah bagian kuncinya:

  1. Permintaan.dariQuery(query): padanan PageableHandlerMethodArgumentResolver. Loop memilah setiap pasangan k=v; parameter yang tidak hadir memakai nilai bawaan — halaman 0, ukuran 5, urutan id naik. Parameter sort=harga,desc dipecah menjadi properti dan arah, persis format yang diterima Spring Data.
  2. comparator(): urutan utama ditarik dari registri URUT — padanan pemetaan nama properti ke kolom yang Spring Data lakukan terhadap metadata entitas. reversed() membalik arah, lalu thenComparing(Produk::id) menambahkan tie-breaker: dua produk bernilai sama tetap punya urutan final yang pasti.
  3. ambil(dataUrut, m): inti pemotongan. offset = halaman * ukuran dihitung sebagai long sebelum dipangkas kembali ke int — penjagaan meluap untuk halaman bernomor besar. Math.min pada kedua ujung memotong jangkauan ke batas data, sehingga page=9 di atas 5 halaman menghasilkan isi=[] dengan totalElemen yang tetap jujur.
  4. Halaman.totalHalaman(): pembulatan ke atas (total + ukuran - 1) / ukuran — 25 baris pada ukuran 5 tepat 5 halaman; 26 baris akan menjadi 6. berikutnya() dan sebelumnya() diturunkan murni dari angka, bukan dari isi.
  5. Bagian terakhir (tanpa tie-breaker): List.sort bersifat stabil sehingga nilai kembar tetap diurutkan menurut posisi asal — keluaran [6, 13, 20] memang deterministik di memori. Database tidak menjanjikan hal yang sama: ORDER BY harga tanpa kolom pembeda boleh mengembalikan urutan berbeda antar-query, dan halaman yang diambil ulang bisa menampilkan atau kehilangan baris kembar secara acak. Tie-breaker id menutup celah itu.

Pagination menuntaskan urutan penyajian data, tetapi REST API modern tidak hanya menerima teks — foto produk, lampiran PDF, dan file besar datang lewat permintaan multipart. Cara menerima unggahan itu tanpa membuka pintu serangan path traversal menjadi pokok bahasan Chapter File Upload berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

HalamanProduk.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

public class HalamanProduk {

    record Produk(int id, String nama, long harga) { }

    // Registri comparator ala Spring Data Sort: nama properti -> Comparator.
    static final Map<String, Comparator<Produk>> URUT = Map.of(
            "id", Comparator.comparingInt(Produk::id),
            "nama", Comparator.comparing(Produk::nama),
            "harga", Comparator.comparingLong(Produk::harga));

    // Padanan PageRequest: hasil binding query ?page&size&sort.
    record Permintaan(int halaman, int ukuran, String properti, boolean menurun) {

        static Permintaan dariQuery(String query) {
            int halaman = 0;
            int ukuran = 5;
            String properti = "id";
            boolean menurun = false;
            for (String pasangan : query.split("&")) {
                String[] kv = pasangan.split("=", 2);
                if (kv.length != 2) {
                    continue;
                }
                switch (kv[0]) {
                    case "page" -> halaman = Integer.parseInt(kv[1]);
                    case "size" -> ukuran = Integer.parseInt(kv[1]);
                    case "sort" -> {
                        String[] bagian = kv[1].split(",");
                        properti = bagian[0];
                        menurun = bagian.length > 1
                                && bagian[1].equalsIgnoreCase("desc");
                    }
                }
            }
            return new Permintaan(halaman, ukuran, properti, menurun);
        }

        // Comparator final: urutan utama + tie-breaker id naik.
        Comparator<Produk> comparator() {
            Comparator<Produk> utama = URUT.get(properti);
            if (menurun) {
                utama = utama.reversed();
            }
            return utama.thenComparing(Produk::id);
        }
    }

    // Padanan Page<T>: isi terpotong + metadata lengkap.
    record Halaman<T>(List<T> isi, int halaman, int ukuran, long totalElemen) {

        long totalHalaman() {
            return (totalElemen + ukuran - 1) / ukuran;
        }

        boolean berikutnya() {
            return halaman + 1 < totalHalaman();
        }

        boolean sebelumnya() {
            return halaman > 0;
        }
    }

    static <T> Halaman<T> ambil(List<T> dataUrut, Permintaan m) {
        // Offset dihitung sebagai long agar page*size tidak meluap int.
        long offset = m.halaman() * (long) m.ukuran();
        int dari = (int) Math.min(offset, dataUrut.size());
        int sampai = (int) Math.min(offset + m.ukuran(), dataUrut.size());
        return new Halaman<>(List.copyOf(dataUrut.subList(dari, sampai)),
                m.halaman(), m.ukuran(), dataUrut.size());
    }

    public static void main(String[] args) {
        List<Produk> semua = new ArrayList<>();
        for (int i = 1; i <= 25; i++) {
            semua.add(new Produk(i, String.format("Produk-%02d", i),
                    10_000 + (i % 7) * 5_000L));
        }
        System.out.println("== 25 produk, harga siklis 10000..40000 (ada nilai kembar) ==");
        System.out.println();

        for (String query : new String[]{
                "page=0&size=5&sort=harga,desc",
                "page=1&size=5&sort=harga,desc",
                "page=4&size=5&sort=harga,desc"}) {
            tampilkan(semua, query);
        }
        System.out.println();

        tampilkan(semua, "page=9&size=5&sort=harga,desc");
        System.out.println();

        System.out.println("== Tanpa tie-breaker id: urutan nilai kembar mengikuti urutan data ==");
        List<Produk> tanpaTie = new ArrayList<>(semua);
        tanpaTie.sort(Comparator.comparingLong(Produk::harga)); // List.sort stabil
        List<Integer> idSenilai = tanpaTie.stream()
                .filter(p -> p.harga() == 40_000)
                .map(Produk::id)
                .toList();
        System.out.println("urutan id untuk harga 40000: " + idSenilai);
        System.out.println("List.sort stabil, namun ORDER BY pada database TIDAK dijamin");
        System.out.println("stabil tanpa kolom pembeda -> selalu tambahkan tie-breaker id");
    }

    static void tampilkan(List<Produk> data, String query) {
        Permintaan m = Permintaan.dariQuery(query);
        List<Produk> dataUrut = new ArrayList<>(data);
        dataUrut.sort(m.comparator());
        Halaman<Produk> h = ambil(dataUrut, m);

        System.out.println("Query : " + query);
        System.out.printf("offset=%d  halaman=%d/%d  isi=%s%n",
                m.halaman() * (long) m.ukuran(), h.halaman() + 1, h.totalHalaman(),
                ringkas(h.isi()));
        System.out.printf("  totalElemen=%d  berikutnya=%s  sebelumnya=%s%n",
                h.totalElemen(), h.berikutnya(), h.sebelumnya());
    }

    static List<String> ringkas(List<Produk> isi) {
        return isi.stream()
                .map(p -> p.nama() + "/" + p.harga())
                .toList();
    }
}