Пагинация в Django REST Framework

Пагинация обязательна для API с большими объемами данных. Она улучшает производительность, снижает нагрузку на сервер и улучшает пользовательский опыт.

Настройка пагинации

Базовая настройка в settings.py:

1REST_FRAMEWORK = {
2    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
3    'PAGE_SIZE': 20,
4    'PAGE_SIZE_QUERY_PARAM': 'page_size',
5    'MAX_PAGE_SIZE': 100,
6}

Типы пагинации

PageNumberPagination - классическая пагинация по номерам страниц:

 1from rest_framework.pagination import PageNumberPagination
 2from rest_framework import viewsets
 3
 4class StandardResultsSetPagination(PageNumberPagination):
 5    page_size = 20
 6    page_size_query_param = 'page_size'
 7    max_page_size = 100
 8    page_query_param = 'page'
 9
10class BookViewSet(viewsets.ModelViewSet):
11    queryset = Book.objects.all()
12    serializer_class = BookSerializer
13    pagination_class = StandardResultsSetPagination
14
15    def get_queryset(self):
16        queryset = Book.objects.select_related('author').all()
17
18        # Фильтрация по категории
19        category = self.request.query_params.get('category')
20        if category:
21            queryset = queryset.filter(category__name=category)
22
23        return queryset

LimitOffsetPagination - пагинация по лимиту и смещению:

 1from rest_framework.pagination import LimitOffsetPagination
 2
 3class LargeResultsSetPagination(LimitOffsetPagination):
 4    default_limit = 25
 5    limit_query_param = 'limit'
 6    offset_query_param = 'offset'
 7    max_limit = 200
 8
 9class AuthorViewSet(viewsets.ModelViewSet):
10    queryset = Author.objects.all()
11    serializer_class = AuthorSerializer
12    pagination_class = LargeResultsSetPagination
13
14    def get_queryset(self):
15        queryset = Author.objects.prefetch_related('books').all()
16
17        # Сортировка
18        ordering = self.request.query_params.get('ordering', 'name')
19        if ordering in ['name', '-name', 'books_count', '-books_count']:
20            if ordering == 'books_count':
21                queryset = queryset.annotate(
22                    books_count=Count('books')
23                ).order_by('books_count')
24            elif ordering == '-books_count':
25                queryset = queryset.annotate(
26                    books_count=Count('books')
27                ).order_by('-books_count')
28            else:
29                queryset = queryset.order_by(ordering)
30
31        return queryset

CursorPagination - пагинация по курсору для больших данных:

 1from rest_framework.pagination import CursorPagination
 2
 3class CursorResultsSetPagination(CursorPagination):
 4    page_size = 50
 5    ordering = '-created_at'  # Обязательно указывать ordering
 6    cursor_query_param = 'cursor'
 7
 8class ReviewViewSet(viewsets.ModelViewSet):
 9    queryset = Review.objects.all()
10    serializer_class = ReviewSerializer
11    pagination_class = CursorResultsSetPagination
12
13    def get_queryset(self):
14        return Review.objects.select_related('book', 'user').order_by('-created_at')

Кастомная пагинация

Создавай собственную пагинацию для специфических нужд:

 1from rest_framework.pagination import BasePagination
 2from rest_framework.response import Response
 3from rest_framework import status
 4
 5class CustomPagination(BasePagination):
 6    page_size = 20
 7    page_size_query_param = 'page_size'
 8    max_page_size = 100
 9
10    def paginate_queryset(self, queryset, request, view=None):
11        self.page_size = self.get_page_size(request)
12        self.page = int(request.query_params.get('page', 1))
13
14        if self.page < 1:
15            self.page = 1
16
17        self.count = queryset.count()
18        self.total_pages = (self.count + self.page_size - 1) // self.page_size
19
20        if self.page > self.total_pages:
21            self.page = self.total_pages
22
23        start = (self.page - 1) * self.page_size
24        end = start + self.page_size
25
26        return list(queryset[start:end])
27
28    def get_paginated_response(self, data):
29        return Response({
30            'results': data,
31            'pagination': {
32                'page': self.page,
33                'page_size': self.page_size,
34                'total_pages': self.total_pages,
35                'total_count': self.count,
36                'has_next': self.page < self.total_pages,
37                'has_previous': self.page > 1,
38                'next_page': self.page + 1 if self.page < self.total_pages else None,
39                'previous_page': self.page - 1 if self.page > 1 else None,
40            }
41        })
42
43    def get_page_size(self, request):
44        if self.page_size_query_param:
45            try:
46                page_size = int(request.query_params[self.page_size_query_param])
47                if page_size > 0 and page_size <= self.max_page_size:
48                    return page_size
49            except (KeyError, ValueError):
50                pass
51        return self.page_size
52
53class BookViewSet(viewsets.ModelViewSet):
54    queryset = Book.objects.all()
55    serializer_class = BookSerializer
56    pagination_class = CustomPagination

Пагинация с метаданными

Добавляй дополнительную информацию в ответ:

 1class EnhancedPagination(PageNumberPagination):
 2    page_size = 20
 3    page_size_query_param = 'page_size'
 4    max_page_size = 100
 5
 6    def get_paginated_response(self, data):
 7        response = super().get_paginated_response(data)
 8
 9        # Добавляем метаданные
10        response.data['meta'] = {
11            'current_page': self.page.number,
12            'total_pages': self.page.paginator.num_pages,
13            'has_next': self.page.has_next(),
14            'has_previous': self.page.has_previous(),
15            'next_page_number': self.page.next_page_number() if self.page.has_next() else None,
16            'previous_page_number': self.page.previous_page_number() if self.page.has_previous() else None,
17            'start_index': self.page.start_index(),
18            'end_index': self.page.end_index(),
19        }
20
21        return response
22
23class BookViewSet(viewsets.ModelViewSet):
24    queryset = Book.objects.all()
25    serializer_class = BookSerializer
26    pagination_class = EnhancedPagination

Пагинация с фильтрацией

Комбинируй пагинацию с фильтрами:

 1from django_filters import rest_framework as filters
 2from rest_framework import viewsets
 3
 4class BookFilter(filters.FilterSet):
 5    title = filters.CharFilter(lookup_expr='icontains')
 6    author = filters.CharFilter(field_name='author__name', lookup_expr='icontains')
 7    category = filters.CharFilter(field_name='category__name', lookup_expr='icontains')
 8    price_min = filters.NumberFilter(field_name='price', lookup_expr='gte')
 9    price_max = filters.NumberFilter(field_name='price', lookup_expr='lte')
10
11    class Meta:
12        model = Book
13        fields = ['title', 'author', 'category', 'price_min', 'price_max']
14
15class BookViewSet(viewsets.ModelViewSet):
16    queryset = Book.objects.all()
17    serializer_class = BookSerializer
18    pagination_class = StandardResultsSetPagination
19    filterset_class = BookFilter
20
21    def get_queryset(self):
22        queryset = Book.objects.select_related('author', 'category').all()
23
24        # Применяем фильтры
25        queryset = self.filterset_class(
26            self.request.GET,
27            queryset=queryset
28        ).qs
29
30        # Сортировка
31        ordering = self.request.query_params.get('ordering', 'title')
32        if ordering in ['title', '-title', 'price', '-price', 'created_at', '-created_at']:
33            queryset = queryset.order_by(ordering)
34
35        return queryset

Пагинация для вложенных данных

Пагинация для связанных объектов:

 1from rest_framework.decorators import action
 2from rest_framework.response import Response
 3
 4class AuthorViewSet(viewsets.ModelViewSet):
 5    queryset = Author.objects.all()
 6    serializer_class = AuthorSerializer
 7    pagination_class = StandardResultsSetPagination
 8
 9    @action(detail=True, methods=['get'])
10    def books(self, request, pk=None):
11        author = self.get_object()
12        books = author.books.all()
13
14        # Применяем пагинацию к связанным книгам
15        page = self.paginate_queryset(books)
16        if page is not None:
17            serializer = BookSerializer(page, many=True)
18            return self.get_paginated_response(serializer.data)
19
20        serializer = BookSerializer(books, many=True)
21        return Response(serializer.data)
22
23    @action(detail=True, methods=['get'])
24    def reviews(self, request, pk=None):
25        author = self.get_object()
26        reviews = Review.objects.filter(book__author=author)
27
28        page = self.paginate_queryset(reviews)
29        if page is not None:
30            serializer = ReviewSerializer(page, many=True)
31            return self.get_paginated_response(serializer.data)
32
33        serializer = ReviewSerializer(reviews, many=True)
34        return Response(serializer.data)

Пагинация с кэшированием

Оптимизируй производительность с кэшированием:

 1from django.core.cache import cache
 2from rest_framework.pagination import PageNumberPagination
 3
 4class CachedPagination(PageNumberPagination):
 5    page_size = 20
 6    page_size_query_param = 'page_size'
 7    max_page_size = 100
 8
 9    def paginate_queryset(self, queryset, request, view=None):
10        # Создаем ключ кэша на основе параметров запроса
11        cache_key = self._generate_cache_key(request)
12
13        # Пытаемся получить из кэша
14        cached_result = cache.get(cache_key)
15        if cached_result is not None:
16            return cached_result
17
18        # Если нет в кэше, выполняем пагинацию
19        result = super().paginate_queryset(queryset, request, view)
20
21        # Кэшируем результат на 5 минут
22        cache.set(cache_key, result, 300)
23
24        return result
25
26    def _generate_cache_key(self, request):
27        """Генерирует уникальный ключ кэша"""
28        params = request.query_params.copy()
29        # Убираем параметры, которые не влияют на результат
30        params.pop('page', None)
31        params.pop('page_size', None)
32
33        # Сортируем параметры для консистентности
34        sorted_params = sorted(params.items())
35        param_string = '&'.join([f'{k}={v}' for k, v in sorted_params])
36
37        return f'pagination_{hash(param_string)}'
38
39class BookViewSet(viewsets.ModelViewSet):
40    queryset = Book.objects.all()
41    serializer_class = BookSerializer
42    pagination_class = CachedPagination

Пагинация для больших данных

Оптимизация для работы с миллионами записей:

 1from rest_framework.pagination import CursorPagination
 2from django.db.models import Q
 3
 4class OptimizedCursorPagination(CursorPagination):
 5    page_size = 100
 6    ordering = '-id'  # Используем ID для стабильной сортировки
 7    cursor_query_param = 'cursor'
 8
 9    def paginate_queryset(self, queryset, request, view=None):
10        # Оптимизируем queryset для больших данных
11        queryset = queryset.only('id', 'title', 'author__name', 'price')
12        queryset = queryset.select_related('author')
13
14        return super().paginate_queryset(queryset, request, view)
15
16class LargeBookViewSet(viewsets.ReadOnlyModelViewSet):
17    serializer_class = BookListSerializer  # Упрощенный сериализатор
18    pagination_class = OptimizedCursorPagination
19
20    def get_queryset(self):
21        # Базовый queryset с оптимизацией
22        queryset = Book.objects.only(
23            'id', 'title', 'author__name', 'price', 'created_at'
24        ).select_related('author')
25
26        # Фильтрация по индексированным полям
27        category = self.request.query_params.get('category')
28        if category:
29            queryset = queryset.filter(category__name=category)
30
31        # Используем составные индексы
32        queryset = queryset.order_by('-created_at', '-id')
33
34        return queryset

Тестирование пагинации

Создавай тесты для проверки пагинации:

 1from django.test import TestCase
 2from django.urls import reverse
 3from rest_framework.test import APIClient
 4from rest_framework import status
 5from .models import Book, Author
 6
 7class PaginationTestCase(TestCase):
 8    def setUp(self):
 9        self.client = APIClient()
10        self.author = Author.objects.create(name='Test Author')
11
12        # Создаем 50 книг для тестирования пагинации
13        for i in range(50):
14            Book.objects.create(
15                title=f'Book {i}',
16                author=self.author,
17                price=10.99 + i
18            )
19
20    def test_page_number_pagination(self):
21        url = reverse('book-list')
22        response = self.client.get(url)
23
24        self.assertEqual(response.status_code, status.HTTP_200_OK)
25        self.assertIn('count', response.data)
26        self.assertIn('next', response.data)
27        self.assertIn('previous', response.data)
28        self.assertIn('results', response.data)
29
30        # Проверяем размер страницы по умолчанию
31        self.assertEqual(len(response.data['results']), 20)
32        self.assertEqual(response.data['count'], 50)
33
34    def test_custom_page_size(self):
35        url = reverse('book-list')
36        response = self.client.get(f'{url}?page_size=10')
37
38        self.assertEqual(len(response.data['results']), 10)
39        self.assertEqual(response.data['count'], 50)
40
41    def test_page_navigation(self):
42        url = reverse('book-list')
43
44        # Первая страница
45        response = self.client.get(url)
46        self.assertIsNone(response.data['previous'])
47        self.assertIsNotNone(response.data['next'])
48
49        # Вторая страница
50        response = self.client.get(f'{url}?page=2')
51        self.assertIsNotNone(response.data['previous'])
52        self.assertIsNotNone(response.data['next'])
53
54        # Последняя страница
55        response = self.client.get(f'{url}?page=3')
56        self.assertIsNotNone(response.data['previous'])
57        self.assertIsNone(response.data['next'])
58
59class CursorPaginationTestCase(TestCase):
60    def setUp(self):
61        self.client = APIClient()
62        # ... создание тестовых данных
63
64    def test_cursor_pagination(self):
65        url = reverse('large-book-list')
66        response = self.client.get(url)
67
68        self.assertEqual(response.status_code, status.HTTP_200_OK)
69        self.assertIn('next', response.data)
70        self.assertIn('previous', response.data)
71        self.assertIn('results', response.data)
72
73        # Проверяем, что next содержит cursor
74        if response.data['next']:
75            self.assertIn('cursor=', response.data['next'])

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

  • Всегда используй пагинацию для больших списков
  • Выбирай подходящий тип пагинации для твоих данных
  • Устанавливай разумные лимиты для page_size
  • Используй cursor пагинацию для больших данных
  • Оптимизируй queryset для пагинированных запросов
  • Кэшируй результаты пагинации при необходимости
  • Тестируй пагинацию с большими объемами данных
  • Документируй параметры пагинации в API
  • Используй метаданные для улучшения UX
  • Мониторь производительность пагинированных запросов

FAQ

Q: Как изменить размер страницы динамически?
A: Используй параметр page_size в запросе: /api/books/?page_size=50

Q: Какой тип пагинации выбрать?
A: PageNumberPagination для простых случаев, CursorPagination для больших данных, LimitOffsetPagination для гибкости.

Q: Можно ли отключить пагинацию для конкретного view?
A: Да, установи pagination_class = None в ViewSet или используй @action без пагинации.

Q: Как добавить сортировку к пагинации?
A: Используй ordering параметр в queryset и убедись, что поля проиндексированы.

Q: Можно ли кэшировать пагинированные результаты?
A: Да, но учитывай параметры запроса при генерации ключа кэша.

Q: Как обработать пустые страницы?
A: DRF автоматически обрабатывает пустые страницы, возвращая пустой список results.