← Назад к вопросам

Как реализовать пагинацию?

1.3 Junior🔥 251 комментариев
#Spring Boot и Spring Data#Базы данных и SQL

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Ответ

Пагинация — критическая функция при работе с большими наборами данных. Это не просто UI компонент, а комплексная архитектура. Рассмотрю все аспекты.

1. Offset/Limit пагинация (классический подход)

// REST контроллер
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping
    public Page<UserDTO> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size
    ) {
        // Spring Data Page из коробки
        Pageable pageable = PageRequest.of(page, size);
        Page<User> users = userService.getUsers(pageable);
        return users.map(this::convertToDTO);
    }
}

// Service слой
@Service
public class UserService {
    
    @Autowired
    private UserRepository repository;
    
    public Page<User> getUsers(Pageable pageable) {
        // автоматически добавит LIMIT и OFFSET
        return repository.findAll(pageable);
    }
}

// Repository
public interface UserRepository extends PagingAndSortingRepository<User, Long> {
    // Готовые методы: findAll(Pageable), findAll(Page), etc.
}

Использование:

# Первая страница, 10 элементов
GET /api/users?page=0&size=10

# Вторая страница, 20 элементов
GET /api/users?page=1&size=20

# Ответ
{
  "content": [{user1}, {user2}, ...],
  "pageable": {
    "pageNumber": 0,
    "pageSize": 10,
    "offset": 0
  },
  "totalElements": 1000,
  "totalPages": 100,
  "number": 0,
  "size": 10,
  "hasNext": true,
  "hasPrevious": false,
  "first": true,
  "last": false
}

2. Cursor-based пагинация (для больших наборов)

Лучше для перформанса на больших данных:

@RestController
public class ProductController {
    
    @GetMapping("/products")
    public CursorPage<ProductDTO> getProducts(
        @RequestParam(required = false) String cursor,
        @RequestParam(defaultValue = "20") int pageSize
    ) {
        CursorPageable pageable = new CursorPageable(cursor, pageSize);
        return productService.getProducts(pageable);
    }
}

@Service
public class ProductService {
    
    public CursorPage<Product> getProducts(CursorPageable pageable) {
        String cursor = pageable.getCursor();
        List<Product> items;
        
        if (cursor == null) {
            // Первая страница
            items = repository.findFirst(pageable.getPageSize() + 1);
        } else {
            // Следующая страница, начиная после cursor (ID)
            Long cursorId = decodeCursor(cursor);
            items = repository.findByIdGreaterThan(
                cursorId, 
                pageable.getPageSize() + 1
            );
        }
        
        // Проверяем есть ли ещё данные
        boolean hasMore = items.size() > pageable.getPageSize();
        if (hasMore) {
            items = items.subList(0, pageable.getPageSize());
        }
        
        // Следующий cursor — ID последнего элемента
        String nextCursor = hasMore ? 
            encodeCursor(items.get(items.size() - 1).getId()) : null;
        
        return new CursorPage<>(items, nextCursor, hasMore);
    }
    
    private String encodeCursor(Long id) {
        return Base64.getEncoder().encodeToString(id.toString().getBytes());
    }
    
    private Long decodeCursor(String cursor) {
        String decoded = new String(Base64.getDecoder().decode(cursor));
        return Long.parseLong(decoded);
    }
}

Использование:

# Первая страница
GET /products?pageSize=20

# Ответ
{
  "items": [{product1}, {product2}, ...],
  "nextCursor": "ZTU=",  # Base64("e5")
  "hasMore": true
}

# Следующая страница
GET /products?cursor=ZTU=&pageSize=20

3. Сортировка при пагинации

@GetMapping("/users")
public Page<UserDTO> getUsers(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "10") int size,
    @RequestParam(required = false) String sortBy,
    @RequestParam(defaultValue = "ASC") String direction
) {
    Sort.Direction dir = Sort.Direction.fromString(direction);
    Sort sort = Sort.by(dir, sortBy != null ? sortBy : "id");
    
    Pageable pageable = PageRequest.of(page, size, sort);
    return userService.getUsers(pageable)
        .map(this::convertToDTO);
}

Использование:

# Сортировка по имени (возрастание)
GET /users?page=0&size=10&sortBy=name&direction=ASC

# Сортировка по дате (убывание)
GET /users?page=0&size=10&sortBy=createdAt&direction=DESC

4. Фильтрация + пагинация

@GetMapping("/users")
public Page<UserDTO> getUsers(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "10") int size,
    @RequestParam(required = false) String status,
    @RequestParam(required = false) String department
) {
    Pageable pageable = PageRequest.of(page, size);
    
    Page<User> users = userService.findUsers(
        status, 
        department, 
        pageable
    );
    
    return users.map(this::convertToDTO);
}

@Service
public class UserService {
    
    public Page<User> findUsers(
        String status, 
        String department, 
        Pageable pageable
    ) {
        // Использование Specification для сложных фильтров
        Specification<User> spec = Specification
            .where(statusEquals(status))
            .and(departmentEquals(department));
        
        return repository.findAll(spec, pageable);
    }
    
    private Specification<User> statusEquals(String status) {
        return status == null ? null : (root, query, cb) -> 
            cb.equal(root.get("status"), status);
    }
    
    private Specification<User> departmentEquals(String dept) {
        return dept == null ? null : (root, query, cb) -> 
            cb.equal(root.get("department"), dept);
    }
}

5. Оптимизация query для пагинации

// ❌ Плохо: N+1 query problem
@Query("SELECT u FROM User u WHERE u.department = :dept")
Page<User> findByDepartment(@Param("dept") String dept, Pageable p);

// ✓ Хорошо: Fetch join
@Query("""
    SELECT DISTINCT u FROM User u 
    LEFT JOIN FETCH u.roles 
    LEFT JOIN FETCH u.permissions
    WHERE u.department = :dept
""")
Page<User> findByDepartmentWithRelations(
    @Param("dept") String dept, 
    Pageable p
);

// ✓ Хорошо: Entity graphs
@EntityGraph(attributePaths = {"roles", "permissions"})
@Query("SELECT u FROM User u WHERE u.department = :dept")
Page<User> findByDepartmentWithGraph(
    @Param("dept") String dept, 
    Pageable p
);

6. Кэширование пагинированных результатов

@Service
public class CachedUserService {
    
    @Cacheable(
        value = "userPage",
        key = "#page + ':' + #size + ':' + #status"
    )
    public Page<User> getUsersPage(
        int page, 
        int size, 
        String status
    ) {
        Pageable pageable = PageRequest.of(page, size);
        return repository.findByStatus(status, pageable);
    }
}

// application.properties
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m

7. Frontend пагинация (React пример)

import { useState, useEffect } from 'react';

export function UserList() {
  const [users, setUsers] = useState([]);
  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(0);
  const pageSize = 10;

  useEffect(() => {
    fetchUsers(page);
  }, [page]);

  const fetchUsers = async (pageNum) => {
    const response = await fetch(
      `/api/users?page=${pageNum}&size=${pageSize}`
    );
    const data = await response.json();
    setUsers(data.content);
    setTotalPages(data.totalPages);
  };

  return (
    <div>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>

      <div className="pagination">
        <button 
          onClick={() => setPage(page - 1)} 
          disabled={page === 0}
        >
          Previous
        </button>

        <span>Page {page + 1} of {totalPages}</span>

        <button 
          onClick={() => setPage(page + 1)} 
          disabled={page >= totalPages - 1}
        >
          Next
        </button>
      </div>
    </div>
  );
}

8. Infinite scroll (вместо пагинации)

// Cursor-based для infinite scroll
@GetMapping("/feed")
public InfiniteScrollResponse<PostDTO> getFeed(
    @RequestParam(required = false) String cursor
) {
    List<Post> posts = postService.getNextPosts(cursor, 20);
    
    String nextCursor = !posts.isEmpty() ? 
        String.valueOf(posts.get(posts.size() - 1).getId()) : null;
    
    return new InfiniteScrollResponse<>(
        posts.stream().map(this::convertToDTO).toList(),
        nextCursor
    );
}

9. Лучшие практики

✓ Limit результатов (максимум 1000 элементов)
✓ Выбирать между offset/limit и cursor
✓ Индексировать поля сортировки
✓ Кэшировать популярные страницы
✓ Указывать размер страницы по умолчанию
✓ Валидировать параметры (page >= 0, size <= 1000)
✓ Использовать Pageable Spring Data
✓ Lazy loading для relacionships
✓ Distinct при FETCH JOIN

10. Тесты пагинации

@SpringBootTest
public class UserPaginationTest {
    
    @Autowired
    private UserRepository repository;
    
    @Test
    void testFirstPage() {
        Pageable pageable = PageRequest.of(0, 10);
        Page<User> page = repository.findAll(pageable);
        
        assertTrue(page.isFirst());
        assertTrue(page.hasNext());
        assertEquals(10, page.getSize());
    }
    
    @Test
    void testWithSort() {
        Pageable pageable = PageRequest.of(
            0, 
            10, 
            Sort.by("name").ascending()
        );
        Page<User> page = repository.findAll(pageable);
        
        assertEquals("Alice", page.getContent().get(0).getName());
    }
}

Сравнение подходов

ПодходПлюсыМинусы
Offset/LimitПросто, flexibleМедленно на больших offset
CursorБыстро, consistentСложнее, нет "перейти на 10 страницу"
Infinite scrollUX friendlyСложнее реализовать
KeysetОптимальноТребует unique ordering

Рекомендация: Offset/Limit для admin интерфейсов, Cursor для мобильных приложений и infinite scroll.