JavaRush /Курсы /Spring Data JPA /Пагинация: Pageable...

Пагинация: Pageable и PageRequest

Spring Data JPA
10 уровень , 3 лекция
Открыта

1. Пагинация как обязанность data-layer

Когда вы впервые пишете метод репозитория и получаете List<Product>, всё кажется прекрасным: вот список — бери и радуйся. Но через пару дней проект начинает вести себя как добрый человек на шведском столе: «я возьму всё». И вот вы случайно читаете из базы тысячу товаров, потом десять тысяч, потом «ну ладно, просто на локалке медленно». Пагинация нужна не потому, что кто-то любит кнопки «Следующая страница», а потому что чтение данных всегда должно быть ограниченным и предсказуемым.

Самый важный сдвиг в мышлении тут такой: ограничение результата — часть контракта репозитория. Это не косметика сверху и не «ну потом в контроллере обрежем». Если вы заранее говорите репозиторию: «дай мне только 20 элементов», вы экономите память JVM, время сети, ресурсы БД и, что особенно приятно, нервы человека, который будет это отлаживать.

Чтобы не говорить на уровне абстракций, давайте вспомним SQL из первых дней курса. Пагинация в реляционном мире — это очень приземлённые LIMIT и OFFSET (плюс ORDER BY, иначе это лотерея). Spring Data просто даёт удобную Java-форму для передачи этих намерений внутрь запроса.

Pageable: как читать список

Если вы ещё не сталкивались с Pageable, он может показаться «ещё одной штукой, которую надо запомнить». Но на самом деле это один из самых дружелюбных API-объектов Spring Data: он говорит репозиторию три вещи — какую страницу, какого размера, в каком порядке. И всё. Никаких SQL-строк, никаких ручных LIMIT/OFFSET, никаких плясок с «обрежем список после чтения».

Важно, что Pageable — это именно описание запроса к данным, а не «контейнер результата». То есть Pageable отвечает на вопрос «как читать», а не «что мы прочитали». Именно поэтому он появляется в параметрах метода репозитория.

Полезно держать в голове простую таблицу, связывающую Pageable с SQL-эквивалентом:

Что хотим контролировать Что хранится в Pageable На что похоже в SQL
Номер страницы pageNumber OFFSET pageNumber * pageSize
Размер страницы pageSize LIMIT pageSize
Порядок Sort внутри
Pageable
ORDER BY ...

Это всего три пункта, но они превращают «дай всё» в «дай аккуратный кусочек, пожалуйста». И, да, если вы знакомы с массивами в Java, вам будет приятно (или наоборот): нумерация страниц в Pageable начинается с нуля. Первая страница — это 0. Как и обычно в программировании: ноль — это не ошибка, а образ жизни.

2. PageRequest: создаём Pageable

С интерфейсом Pageable напрямую вы обычно не возитесь: вы просто создаёте его реализацию. Самая частая реализация для «классической» пагинации (через offset) — это PageRequest. Он создаётся через статический метод of(...), и в этом есть приятная инженерная честность: вы явно задаёте page и size, а при желании добавляете сортировку.

Ниже — самый простой пример, без сортировки. Он полезен для первых шагов, но в реальных списках порядок обычно всё-таки нужен.

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

// Pageable — это "как читать", а не "что прочитали"
Pageable pageable = PageRequest.of(0, 20); // 0 = первая страница, 20 = размер страницы (кол-во сущностей)

Обратите внимание на две вещи. Во-первых, 0 — это «первая страница». Во-вторых, 20 — это «20 строк результата», а не «20 килобайт» и не «20 секунд терпения». Spring Data воспринимает size как количество сущностей в ответе.

Чуть более жизненный пример — с сортировкой. И тут мы аккуратно используем то, что обсуждали в прошлой лекции: стабильный порядок, где второе поле (id) помогает зафиксировать результат, если основное поле не уникально.

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

// Важно: при пагинации почти всегда нужна сортировка, иначе страницы будут "гулять"
Pageable pageable = PageRequest.of(
        0, 12,
        // Стабилизируем порядок: сначала по createdAt, а затем по id (на случай одинаковых дат)
        Sort.by("createdAt").descending().and(Sort.by("id").descending())
);

Этот объект теперь можно передать в репозиторий, и репозиторий уже будет читать данные «кусочком», а не всем набором. И да: вы снова пишете имена полей entity (createdAt, id), а не created_at и не product_id — мы всё ещё живём в мире Spring Data JPA, который смотрит на Java-модель.

3. Pageable в репозитории

Чтобы пагинация реально заработала, Pageable должен появиться в сигнатуре метода репозитория. Это выглядит скучно, но именно скучные вещи чаще всего и спасают проекты: сигнатура метода становится контрактом, который нельзя «забыть», если вы не хотите снова читать всё.

Пусть у нас в mini-shop есть типичный сценарий каталога: «получить товары в категории, только активные, постранично». На уровне derived query это можно выразить очень естественно: фильтр по category.code + status, а сверху добавить Pageable.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Возвращаем Page<Product>, чтобы получить не только список, но и метаданные страницы
    Page<Product> findByCategoryCodeAndStatus(
            String categoryCode,
            ProductStatus status,
            // Pageable передаёт в запрос размер/номер страницы и сортировку
            Pageable pageable
    );
}

Здесь важно несколько практических деталей, которые новички обычно пропускают.

Во‑первых, Pageable почти всегда логичнее ставить последним параметром: так сигнатура читается как «условия → управление чтением». Spring Data это понимает и так, но людям читать удобнее именно в таком порядке.

Во‑вторых, обратите внимание: мы возвращаем Page<Product>. Это означает, что мы хотим получить не только список, но и «обёртку результата», которая умеет хранить информацию о странице. В рамках этой лекции нам важнее сам факт, что тип результата меняется, когда мы говорим про постраничное чтение. Детали того, какие бывают варианты «обёрток» результата, мы пока не превращаем в отдельную философию — нам нужно просто научиться правильно передавать Pageable.

В‑третьих, метод остаётся читаемым. Это прямо хорошая новость: пагинация сама по себе не делает derived query «монстром». Монстром его делает попытка впихнуть 7 фильтров и 3 сортировки в одно имя — а не Pageable.

4. Pageable в SQL: ORDER BY, LIMIT, OFFSET

Когда вы вызываете repository-метод с Pageable, под капотом происходит то, что вы уже знаете по SQL, просто в «обёртке фреймворка». Hibernate и Spring Data строят запрос, в котором появятся ORDER BY, LIMIT и OFFSET. Даже если вы не пишете SQL руками, полезно мысленно «видеть» его, чтобы не относиться к пагинации как к магии.

Представим, что мы запросили страницу 0, размер 12, сортировка createdAt desc, id desc, категория "TEA", статус ACTIVE. В голове это можно держать примерно так:

select ...
from product p
join category c on p.category_id = c.id
where c.code = 'TEA'
  and p.status = 'ACTIVE'
-- ORDER BY обязателен для стабильных страниц (иначе порядок не гарантирован)
order by p.created_at desc, p.id desc
-- LIMIT = размер страницы, OFFSET = pageNumber * pageSize
limit 12 offset 0;

А если бы мы запросили вторую страницу (pageNumber = 1), оффсет стал бы 12:

select ...
from product p
join category c on p.category_id = c.id
where c.code = 'TEA'
  and p.status = 'ACTIVE'
order by p.created_at desc, p.id desc
limit 12 offset 12;

И тут появляется момент, который очень важно прочувствовать: пагинация без сортировки — это «дай мне случайные 12 строк, потом другие случайные 12 строк». База данных не обязана возвращать строки в одном и том же порядке, если вы не указали ORDER BY. Иногда кажется, что «оно и так по id», но это просто случайное совпадение вашего окружения и данных. Как только таблица станет больше, появятся обновления, план запроса поменяется — порядок «поплывёт», и страницы начнут пересекаться или пропускать элементы.

Поэтому практический вывод простой: если вы делаете пагинацию, делайте сортировку. В реальном проекте это правило обычно живёт как негласный закон команды, примерно на уровне «не пушим в main код, который падает на старте».

5. Пагинация в CatalogService

Очень легко сделать репозиторий «умным», а сервис оставить «просто проксей». Но на практике удобнее наоборот: репозиторий даёт возможность читать, а сервис формирует use case. Это означает, что именно сервис решает, какой размер страницы нормальный по умолчанию, какой порядок считать разумным, и как обработать номер страницы, который пришёл из внешнего мира (или даже просто из вашего CommandLineRunner).

Возьмём наш CatalogService (который мы уже начали собирать раньше) и добавим туда метод, который читает страницу каталога. Здесь мы не трогаем web-слой и не обсуждаем DTO: просто показываем, как сервис превращает «дай страницу» в PageRequest.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

public Page<Product> getActiveCatalogPage(String categoryCode, int page, int size) {
    // Фиксируем "канонический" порядок каталога: сначала новые
    Sort sort = Sort.by("createdAt").descending().and(Sort.by("id").descending());

    // Собираем Pageable прямо в сервисе: это часть сценария (use case), а не "где-то потом"
    PageRequest pageable = PageRequest.of(page, size, sort);

    // Репозиторий получает и фильтры, и правила чтения (страница/размер/сортировка)
    return productRepository.findByCategoryCodeAndStatus(categoryCode, ProductStatus.ACTIVE, pageable);
}

Тут сразу несколько вещей происходят «правильно», и они очень полезны именно на уровне привычки.

Сервис сам задаёт сортировку и не перекладывает это решение на вызывающий код. Это удобно, потому что use case «каталог» обычно предполагает фиксированный порядок: например, «сначала новые», «сначала дешёвые», «сначала по имени». Если порядок действительно должен быть динамическим — тогда да, вы вынесете Sort наружу. Но по умолчанию порядок лучше фиксировать как часть сценария.

Второй момент — PageRequest создаётся в сервисе, а не «где-то потом». Это важно методически: пагинация — часть use case, а не случайная примочка, которая живёт то в репозитории, то в контроллере, то в утилитке.

И третий момент — это место, где обычно всплывает ошибка нумерации страниц. Человек любит «страница 1», а PageRequest любит «страница 0». Чтобы не устраивать вечную путаницу, можно договориться: внутри приложения всегда zero-based, а если вам когда-нибудь придётся принимать номер страницы «как человек», вы сделаете преобразование явно, одним местом.

Пример такого преобразования (просто как кусок безопасной арифметики):

int requestedPage = 1;                      // как сказал человек: «первая страница»
int page = Math.max(0, requestedPage - 1);  // как понимает PageRequest: 0 (и защита от отрицательных значений)

Да, это выглядит банально. Но как раз такие банальные две строки экономят часы отладки, когда кто-то начинает жаловаться: «почему первая страница пустая?».

6. Мини-демо: читаем страницу

Когда мы изучаем persistence layer, очень хочется «пощупать» результат. Пока у нас нет web-слоя (и это нормально), можно сделать учебный мини-демо через CommandLineRunner: запуск приложения, один вызов сервиса, печать нескольких строк. Это не архитектура века, но для обучения работает отлично — как фонарик в подвале, где вы ещё не включили свет.

Например, печатаем SKU и имя товаров из первой страницы:

import org.springframework.data.domain.Page;

// Запрашиваем 0-ю страницу (то есть "первую"), размером 3 элемента
Page<Product> page = catalogService.getActiveCatalogPage("TEA", 0, 3);

// getSize() — это размер страницы, который вы запросили (а не фактическое кол-во элементов в таблице)
System.out.println("Page size = " + page.getSize()); // Page size = 3

// getContent() — сами элементы текущей страницы
page.getContent().forEach(p ->
        System.out.println(p.getSku() + " -> " + p.getName())
);

// Пример ожидаемого вывода (будет зависеть от ваших данных и сортировки):
// TEA-001 -> Green Tea
// TEA-002 -> Black Tea
// TEA-003 -> Oolong Tea

Здесь важен именно эффект: вы видите, что сервис читает три элемента, хотя в таблице их может быть 300. И это ощущение «мы управляем чтением» — одно из главных в работе с репозиториями. Не база командует вам «держи всё», а вы просите ровно столько, сколько нужно сценарию.

7. Типичные ошибки при работе с Pageable

Ошибка №1: считать, что первая страница — это 1, и получать странные «пустые» результаты.
PageRequest живёт в zero-based мире: PageRequest.of(0, 20) — это первая страница. Если вы передадите 1, вы честно попросите вторую. Эта ошибка особенно коварна, потому что код компилируется, работает, и только потом кто-то замечает, что «первую страницу мы вообще никогда не показываем».

Ошибка №2: пытаться передать null вместо Pageable, чтобы «пусть вернёт всё».
Такой стиль быстро превращает репозиторий в непредсказуемую штуку: то читаем по 20, то читаем «всё», а потом удивляемся памяти и времени ответа. Если вам нужно чтение без ограничений, лучше сделать отдельный метод репозитория без Pageable, чтобы контракт был виден глазами. Но в каталоге и админских списках обычно лучше не делать «всё» вообще.

Ошибка №3: менять сортировку между соседними страницами одного и того же списка.
Постраничное чтение подразумевает, что вы листаете одну и ту же упорядоченную последовательность. Если на первой странице сортировка по createdAt desc, а на второй вдруг по name asc, вы фактически читаете уже другой список. Результат будет «рваным»: дубликаты, пропуски, ощущение, что база вас троллит.

Ошибка №4: делать пагинацию без ORDER BY, надеясь, что «и так по id».
База данных не обязана возвращать строки в стабильном порядке без ORDER BY. Иногда кажется, что всё стабильно, потому что таблица маленькая, план запроса простой, а жизнь ещё не успела вас научить. Как только данных станет больше или появятся обновления, страницы начнут гулять. Если вы делаете пагинацию, порядок должен быть явным.

Ошибка №5: брать слишком большой размер страницы «на всякий случай».
Фраза «давайте размер 10_000, чтобы точно хватило» обычно означает «давайте отменим смысл пагинации, но назовём это пагинацией». Размер страницы — часть проектного решения. Для каталога часто достаточно 1050, иногда 100, но огромные размеры почти всегда приводят к лишней нагрузке, даже если запрос формально «всё ещё Page».

1
Задача
Spring Data JPA, 10 уровень, 3 лекция
Недоступна
Первая страница доступных книг
Первая страница доступных книг
1
Задача
Spring Data JPA, 10 уровень, 3 лекция
Недоступна
Вторая страница сотрудников по коду отдела
Вторая страница сотрудников по коду отдела
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ