Пагинация в Django REST Framework
Пагинация обязательна для API с большими объемами данных. Она улучшает производительность, снижает нагрузку на сервер и улучшает пользовательский опыт.
Настройка пагинации
Базовая настройка в settings.py:
Типы пагинации
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.