Asosiy kontentga o‘tish

Maqolalar 8 daqiqa 1119 ko‘rish

DRF filtrlash va pagination: «hammasini» emas, «keraklisini» qaytaring

Ro'yxat endpoint'i bazadagi hamma yozuvni qaytarsa, u vaqt bombasi: bugun 50 ta film, bir yildan keyin 50 ming. django-filter bilan filtrlash, qidiruv, tartiblash va sahifalashni Netflix-uslub API misolida to'liq sozlaymiz.

api performance django python

Mundarija

GET /api/movies/ — va javobda bazadagi hamma film. Loyihaning birinchi haftasida bu hech kimga xalaqit bermaydi, chunki filmlar 50 ta. Bir yildan keyin ular 50 ming bo'ladi va o'sha endpoint har chaqiruvda megabaytlab JSON qaytara boshlaydi: mobil ilova qotadi, server terlaydi, hech kim esa bu javobning 20 tadan ortig'ini baribir o'qimaydi. Shuning uchun ro'yxat endpoint'ining ikkita majburiy jihozi bor: pagination (bo'lib berish) va filtrlash (so'ralganini berish). Avvalgi qismda hujjatlagan Movies API'ni endi shu jihozlar bilan to'ldiramiz — o'sha qismdagi Movie modeli (janr Genre jadvaliga bog'langan) va MovieSerializer bilan.

Pagination: birinchi kundan, global qilib#

DRF'da sahifalash bitta sozlama bilan butun loyihaga yoqiladi:

Python
# config/settings.py
REST_FRAMEWORK = {
    # ...
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

Javob shakli endi o'zgaradi — mijozlarga aynan shu shaklni va'da qilasiz:

Terminal
curl "http://127.0.0.1:8000/api/movies/?page=2"
JSON
{
  "count": 50000,
  "next": "http://127.0.0.1:8000/api/movies/?page=3",
  "previous": "http://127.0.0.1:8000/api/movies/?page=1",
  "results": [ ... 20 ta film ... ]
}

Mijoz next bo'yicha yuraveradi — sahifa raqamini o'zi hisoblashi shart emas. Sahifa hajmini mijozga ham berish kerak bo'lsa, PageNumberPaginationni meros olib page_size_query_param = "page_size" qo'shasiz, lekin max_page_size bilan chegaralab: aks holda kimdir ?page_size=1000000 deb eski muammoni qaytarib beradi.

Katta jadvalda page raqami sekinlashadi

?page=2500 degani bazaga "50 000 qator sanab o'tib, keyingisini ber" degani — OFFSET chuqurlashgan sari sekinlashadi. Ro'yxati millionlab yozuvli API'larda shuning uchun CursorPagination ishlatiladi: u "shu yozuvdan keyingilarini ber" deb ishlaydi va tezligi chuqurlikka bog'liq emas. Boshlang'ich loyihaga PageNumber yetadi, lekin bu chegarani bilib qo'ygan yaxshi.

Filtrlash: django-filter bilan#

Mijozga "faqat 2019-yilgi drama filmlar" kerak. Buni view ichida qo'lda if bilan yozish mumkin, lekin standart yechim tayyor:

Terminal
uv add django-filter
Python
# config/settings.py
INSTALLED_APPS = [
    # ...
    "django_filters",
]

REST_FRAMEWORK = {
    # ...
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        # DRF'ning OrderingFilter'i o'rniga — sababi pastda
        "apps.core.filters.StableOrderingFilter",
    ],
}

View'da esa qaysi maydonlar bo'yicha nima mumkinligini e'lon qilamiz:

Python
# apps/movies/views.py
class MovieViewSet(viewsets.ModelViewSet):
    queryset = Movie.objects.select_related("genre")
    serializer_class = MovieSerializer

    # aniq moslik filtrlari: ?genre__slug=drama&year=2019
    filterset_fields = ["genre__slug", "year"]

    # matnli qidiruv: ?search=matrix — sarlavha va tavsif bo'ylab
    search_fields = ["title", "description"]

    # tartiblash: ?ordering=-rating (minus — kamayish tartibi)
    ordering_fields = ["rating", "year"]
    # default tartib — doim aniq va NOYOB: yili bir xil filmlar orasini id hal qiladi
    ordering = ["-year", "id"]

Parametr nomi maydon yo'li bilan bir xil: genre__slug. ?genre=drama yozilsa, django-filter bunday parametrni bilmaydi va jimgina e'tiborsiz qoldiradi — sinovda bu so'rov 50 000 filmning hammasini qaytardi.

Uchala backend birga ishlaydi va zanjir bo'lib qo'shiladi:

Terminal
curl "http://127.0.0.1:8000/api/movies/?genre__slug=drama&year=2019&ordering=-rating"

ordering qatori mayda ko'rinsa ham yozilishi shart: tartibsiz PostgreSQL qatorlarni "qanday qulay bo'lsa shunday" qaytaradi va pagination sahifalari orasida yozuvlar takrorlanishi yoki tushib qolishi mumkin — mijoz ro'yxatni varaqlaganda bir film ikki marta chiqqani shundan.

Lekin tartib bo'lishining o'zi yetmaydi — u noyob bo'lishi kerak. Bir yilda minglab film chiqadi, va yili bir xil filmlar orasidagi tartibni baza kafolatlamaydi. Sinovda 50 000 filmli PostgreSQL 16 bazasida faqat ordering = ["-year"] bilan birinchi 60 sahifa varaqlanganda 263 ta film ikki marta chiqdi, ko'plari esa umuman ko'rinmadi. Oxiriga noyob "id" qo'shilgach — bitta ham takror yo'q.

Xuddi shu muammo mijoz so'ragan tartibda ham bor: ?ordering=-rating da reytingi bir xil filmlar juda ko'p, DRF'ning OrderingFilteri esa oxiriga noyob kalit qo'shmaydi — sinovda 60 sahifada 340 ta takror chiqdi. Shuning uchun uni kichik voris klass bilan almashtiramiz:

Python
# apps/core/filters.py
from rest_framework.filters import OrderingFilter


class StableOrderingFilter(OrderingFilter):
    """?ordering=-rating ga oxirgi kalit sifatida "id" qo'shadi.

    Reytingi bir xil filmlar ko'p — ular orasidagi tartibni baza kafolatlamaydi
    va sahifalar orasida yozuvlar takrorlanib yoki tushib qolishi mumkin.
    Noyob "id" tartibni har safar bir xil qiladi.
    """

    def get_ordering(self, request, queryset, view):
        ordering = super().get_ordering(request, queryset, view)
        if ordering and not {"id", "-id", "pk", "-pk"} & set(ordering):
            ordering = [*ordering, "id"]
        return ordering

Bu klass bilan ?ordering=-rating SQL'da ORDER BY rating DESC, id ASC bo'lib ketadi va sahifalar orasida takror qolmaydi (apps/core/ ichida bo'sh __init__.py bo'lishi kerak).

Oraliq filtrlar: FilterSet bilan#

filterset_fields aniq moslik uchun yetarli, lekin "reytingi 7 dan yuqori" kabi oraliq so'rovlarga maxsus klass kerak:

Python
# apps/movies/filters.py
import django_filters

from apps.movies.models import Movie


class MovieFilter(django_filters.FilterSet):
    # ?min_rating=7.5 — gte: katta yoki teng
    min_rating = django_filters.NumberFilter(field_name="rating", lookup_expr="gte")
    # ?year_after=2015&year_before=2020 — oraliq
    year_after = django_filters.NumberFilter(field_name="year", lookup_expr="gte")
    year_before = django_filters.NumberFilter(field_name="year", lookup_expr="lte")

    class Meta:
        model = Movie
        # aniq moslik filtrlari — filterset_fields'dagi ro'yxat shu yerga ko'chadi
        fields = ["genre__slug", "year"]
Python
# apps/movies/views.py — filterset_fields o'rniga:
    filterset_class = MovieFilter

filterset_class berilganda view'dagi filterset_fields ishlatilmaydi. Shuning uchun aniq moslik filtrlari Meta.fields ga ko'chirilishi kerak: "year" tushib qolsa, ?year=2019 jimgina e'tiborsiz qoladi — sinovda shunday bo'ldi.

Yoqimli tomoni: avvalgi qismdagi Swagger sahifasi bu parametrlarning hammasini o'zi ko'rsatadi — filtr qo'shdingiz, hujjat yangilandi.

Tezlikni unutmang#

Filtr va qidiruv qo'shilgach, bazaga tushadigan so'rovlar shakli o'zgaradi — ikki narsani tekshirib qo'ying. Filtrlanadigan ustunlarda indeks bo'lsin (genre, year, rating — qaysi biri bo'yicha ko'p so'ralsa), buni EXPLAIN bilan tekshirish usulini alohida yozganman. Va ro'yxat serializer'ida bog'liq jadvallar bo'lsa, select_related/prefetch_related turganiga ishonch hosil qiling. Bizning serializer har film uchun janr slug'ini chiqaradi: select_related("genre") bilan sahifa 2 ta SQL so'rov bilan tayyor bo'ladi (sanash va ro'yxat), usiz esa 22 ta — har film janri uchun alohida so'rov. Bu N+1 muammosining klassik ko'rinishi.

Yakun#

Ro'yxat endpoint'ining to'liq jihozi shu: global pagination (next/previous bilan), aniq moslik uchun filterset_fields, matn uchun SearchFilter, tartib uchun OrderingFilter va noyob default ordering — sahifalar sakramasligi uchun (oxirida doim id). Oraliqlar kerak bo'lganda FilterSet klassi bor, hammasi esa Swagger hujjatiga o'z-o'zidan tushib turadi.

Shu bilan o'quv turkumi ham yakuniga yetdi — endi Netflix-uslub API loyihasi ostida uning har qismini ochib beradigan maqolalar turadi.

8 daqiqa 1119 marta o‘qildi