Дисклеймер

Данный текст не имеет цели кого-то оскорбить или высмеять и несет исключительно развлекательный характер.

Всем привет! Я увидел небольшой общественный отклик по моей предыдущей статье и решил сценарно продолжить мою феерию по душераздирающему пути разработки на Django и, в частности, Django Rest Framework. Напомню, я - разработчик backend на Python, писал на самых популярных фреймворках, об одном из которых и пойдет сегодня речь.

Кстати, если вы разрабатываете backend на другом языке, д��маю, вам также будет интересно пронаблюдать, с какими артефактами встречается разработчик на Django или как можно poop-кодить без стыда и совести, потому что я не буду нагружать читателя спецификой языка или какой-либо сложной логикой.

Django Rest Framework (DRF) - чуть ли не единственный фреймворк для разработки REST на базисе Django. Мой нарратив о Django заключался в том, что это неповоротливый монолит, который абсолютно не следует best practices и не стремится к ним. Если вдруг вы не задумывались о том, как связаны DRF и Django, то вас может быть немного это удивит - никак. Их делали совершенно разные люди, но каким-то образом они сошлись в общей концепции: игнор хороших практик, перегруженные классы и магия, превращающая разработчика в гадалку.

Не буду распинаться о банальном отсутствии нативной асинхронщины или тех же type hints. Там они просто не предусмотрены (или криво реализованы).

Странно стартуем

Первое, что нас встречает - это установка пакета "djangorestframework" в виртуальную среду.

pip install djangorestframework

Довольно неудобный и незапоминающийся нейминг для установки пакета, потому что далее для Django мы его подключаем как "rest_framework". Импортируем все необходимое для разработки API тоже из rest_framework, что выглядит странно, как будто других REST фреймворков больше нет и не будет, и это режет глаз, можно же было как-то унифицировать и именовать django_rest или drf, но не мне разрешать нейминг, поэтому имеем то, что имеем.

# settings.py

INSTALLED_APPS = [
    ...,
    'rest_framework',
]

Заодно нам слёту предлагают работать за счет готовой аутентификации и авторизации Django, которая построена на сессиях. В общих settings.py прописывается словарик REST_FRAMEWORK, он накидает на все будущие эндпоинты мега логику, например авторизацию.

И не дай Бог вы ошибётесь в названии подключаемого модуля из коробки

# settings.py

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
        'rest_framework.authentication.TokenAuthentication',
    ],
}

Вроде удобно, что аутентификация и авторизация "из коробки", но она работает ровно до тех пор, пока вас устраивает устаревшая авторизация Django, либо фреймворк придется (снова) пропатчить для того же JWT (внимание!) пакетом "djangorestframework-simplejwt". (что-то на эльфийском) или выбирать из тысячи других, но все на свой страх и риск.

И кстати

В Python как раз много таких огрехов, связанных с неймингом пакетов и дальнейшим импортом, например, устанавливая пакет pip install python-dotenv придется делать import dotenv , для pip install Pillow получаем import PIL, но всему виной PyPI на котором, быть может, были заняты имена, однако для меня больше удивлений в нейминге конечно вызвало семейство (которое оказывается совсем не связано никакими узами между собой) Django Rest Framework, потому что дальше речь пойдет о Django filters.

Немного о фильтрах

Когда мы говорим в контексте разработки REST на DRF, проект строится не только на Django и DRF, но и на других библиотеках. Почему в DRF нет многих вещей до сих пор - остается загадкой, однако в нем есть то, что сильно перегружает разработку, но об этом позже.

100% вы будете использовать это при разработке на Django Rest Framework. Что такое Django filters?

Это (модно говорить мощные, но под мощью скрываются невообразимые айсберги) стероидные классы, которые используются в основном для отбора значений из БД по query-параметрам. Конечно не только для этого, можно и просто фильтровать данные из БД.

И кстати. Это опять сторонняя библиотека.

Снова про нейминг. Давайте посмотрим на установку:

pip install django-filter

И импорт, который переименовался из "django-filter" в "django_filters". С подсказками IDE конечно не будет проблем. Обещаю, что больше не буду говорить про нейминг пакетов и их неочевидных импортов.

import django_filters

Посмотрим на работу с query-параметрами с django-filters и без. Кстати view - это эндпоинт, а viewset - сет из эндпоинтов, но о них нам еще предстоит много что сказать далее, пока остановимся на filters (плохо писать логику во вьюхе, знаю, представьте, колоссальное количество проектов Django так написаны).

Query параметры

Допустим, нам нужно получить продукты на распродаже со скидкой 20%.

GET /api/products/on_sale?discount=20

# views.py

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all() 
    serializer_class = ProductSerializer 

    @action(detail=False, methods=['get'])
    def on_sale(self, request):
      
        # discount - query параметр, достаем из словаря
        discount = request.query_params.get('discount')

        # Фильтруем данные из бд на основе discount, то есть = discount, к примеру
        queryset = self.get_queryset().filter(discount=int(discount))
        
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

Не самый лаконичный способ получить query из URL, правда? Об этом я твердил в предыдущей статье, правда в чистой Django все выглядит куда скуднее. Поэтому на сцену выходят django-filters, которые не просто тащат из view query-параметры, но и сами фильтруют данные из БД под капотом, однако можно все накастомить, это не возбраняется.

# filters.py

class ProductFilter(filters.FilterSet):
    # Поле discount теперь берется из query в URL, и заодно становится фильтром 
    # для выборки из БД
    discount = filters.NumberFilter(field_name="discount")

    # Можно добавить еще поле, оно также берется как query из URL 
    # и данные возвращаются согласно кастомному фильтру filter_expired
    discount_expired = django_filters.BooleanFilter(
        field_name='expire',
        method='filter_expired',
    )
    
    class Meta:
        model = Product
        fields = ['discount', 'expire']

    # Фильтруем данные из БД по query параметру discount_expired
    def filter_expired(self, queryset, name, value):
        current_datetime = timezone.now()

        if value:
            return queryset.filter(
                expires_in__isnull=False,
                expires_in__lt=current_datetime,
            )

        else:
            return queryset.filter(
                Q(expires_in__isnull=True) | Q(expires_in__gte=current_datetime)
            )

    # Еще иногда переопределяют всю фильтрацию так:
    def filter_queryset(self, queryset):
        # Какая-то логика ...

        return queryset

Ну, немножко месиво, особенно эти ORM-ные Q-объекты, на которые я тоже косо смотрю, но в целом работа с Django ORM мне комфортна. Но вот как же небрежно смотрится кастомный фильтр, и да, такие функ��ии пишутся прямо в классе, никто их никуда не выносит.

Теперь подключение к эндпоинту.

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_class = ProductFilter
    
    @action(detail=False, methods=['get'])
    def on_sale(self, request):
        queryset = self.get_queryset()
        queryset = self.filter_queryset(queryset)
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

Вы видите, что запрос обрабатывает query? И я не вижу, а он обрабатывает, за счет filterset_class.

Path параметры

Допустим у нас есть несколько магазинов, и мы обращаемся к ним по shop_id.

GET /api/products/shops/{shop_id}/on_sale

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_class = ProductFilter

    @action(detail=False, methods=['get'], url_path='shops/(?P<shop_id>[^/.]+)/on_sale')
    def on_sale(self, request, shop_id=None):
        queryset = self.get_queryset().filter(shop_id=shop_id)
        queryset = self.filter_queryset(queryset)
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

О Боги, это что за shops/(?P<shop_id>[^/.]+)/on_sale? И такое бывает, но можем переписать на человекочитаемое: shops/<int:shop_id>/on_sale. Как видим, path параметр вытаскивается совершенно по-другому. Раз уж в коде фигурирует, как для многих может показаться, сырое похождение в базу, а не абстракция над БД, думаю стоит немного поговорить о Django ORM.

Django ORM - швейцарский нож и это плохо

ORM - важная тема при разработке API. Да, она не появилась благодаря разработчикам DRF, но для DRF нет ничего иного, кроме как использовать Django Model. Весь код сервиса на DRF будет пропитан походами в БД. Однако, нет такого понятия как изоляция над работой с БД, есть только Django Model (точнее Model.objects) - ящик пандоры. Данные из БД можно вытащить в любом месте кода и это не возбраняется, все библиотеки, которые так или иначе пляшут под дудочку DRF построены на этом аспекте:

# рандомный_файл.py

result = Model.objects.all() # до сих пор считается ОК решение

Причем можно вытащить из БД как QuerySet (особый тип данных из Django, по сути набор запросов, которые еще не выполнены, lazy-загрузка данных), так и массив словарей, или массив строк, или вообще аннотированные данные. Короче модель Django ORM огромная абстракция, которая красиво выведет результат и спокойно породит N+1. То есть изолировать работу с БД не выйдет, а это нарушение принципов, когда логика завязана на реализацию базы. Вы будете тащить данные и в Serializer, и во View, и в Filter и еще много куда. Удобно ли? В контексте маленького приложения может быть и да, но в другом случае лучше так не делать. А если вдруг вы решитесь сделать DDD, то мало того что фильтры с Swagger просто отвалятся, так еще вы будете чувствовать, что буквально делаете двойную работу. Я на себе это ощутил, и понял, как огромна пропасть между django-way и чистой архитектурой.

Вернемся к DRF

Когда я писал свою первую API на Django Rest Framework, глаза лезли на лоб. До сих пор я путаю и забываю некоторые тонкости реализации некоторого эндпоинта, например, через особую "вьюху" DRF. Виды эндпоинтов, которые реализуют одну и ту же GET-ручку:

# тут различные импорты

# 1) Чистый Django через функцию
def products(request): ...


# 2) Чистый Django View
class ProductsViewClass(View):
    def get(self, request): ...


# 3) Чистый Django ListView
class ProductsListView(ListView):
    model = Product
    
    def get(self, request, *args, **kwargs): ...


# 4) DRF @api_view функция
@api_view(['GET'])
def products_drf_func(request): ...


# 5) DRF APIView
class ProductsAPIView(APIView):
    def get(self, request): ...


# 6) DRF GenericView
class ProductsGenericView(GenericAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    
    def get(self, request): ...


# 7) DRF ListAPIView
class ProductsListAPIView(ListAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer


# 8) DRF ModelViewSet
class ProductsModelViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    http_method_names = ['get']


# 9) DRF GenericViewSet + mixins
class ProductsListRetrieveMixins(
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet
  
...

Куда так много? Конечно, я назвал не все. И конечно, ради запроса GET не стоит так распыляться, но оно же существует? А если у меня был GET, а потом резко придется делать полную CRUD, и все сойдет к тому, что надо выбирать для себя реализацию. Я сталкивался с такими проблемами, приходилось прям полностью убирать эндпоинты и переписывать унифицированно или максимально упрощать и писать чуть ли не на обычных функциях. Тогда к чему такие сложности?

Для новичков или незнакомых с фреймворком поясню: некоторые классы прямо-таки уже реализовали за вас стопку CRUD, которая и без вас работает, но ровно до тех пор, пока бизнес логика просто отсутствует. Как только мы вносим бизнес логику, начинается что-то вроде такого:

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

    def perform_create(self, serializer):
      # Действие перед сохранением
        if self.request.user.is_vip:
            serializer.save(bonus_points=100)
        else:
            serializer.save()

    def get_queryset(self):
        # Логика выборки
        qs = super().get_queryset()
        if self.request.query_params.get('special'):
            return qs.filter(is_special=True)
        return qs

    def get_serializer_class(self):
        # Логика выбора интерфейса
        if self.action == 'create':
            return ProductCreateSerializer
        return self.serializer_class

Жутко выглядит, пожалуй, и это еще "легкая" логика и мы не лезем в Serializer. В таком "прекрасном" viewset зарыто столько подводной логики, что становится очень сложно. perform_update, perform_create, get_serializer_class, get_queryset и другие функции скрытые от глаз, которые нам скорее всего придется переопределять, раз уж мы начали писать через viewsets.

Зачем мы прописываем загадочный queryset = Model.objects.all()

Опять магия. Опять мало понятностей, пока не погрузиться в это все с головой. Неочевидные моменты скрыты, хотя это относится (внимание!) к авторизации. Может, конечно, для еще каких-нибудь случаев, но обычно отсекаем какие-то данные на основе request.user. Ну и как вишенка на торте, не понимаешь что за тип self.request.user, откуда у него is_staff и вообще, и что изначально требует эта функция для возврата.

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    
    # Но можно переопределить:
    def get_queryset(self):
        # Динамический queryset в зависимости от условий
        user = self.request.user
        
        if user.is_staff:
            # Для админов
            return Product.objects.all()
        else:
            # Фильтруем для других пользователей, к примеру
            return Product.objects.filter(is_active=True)

Более менее понятнее, но не совсем ясен тайминг точки входа в get_queryset, а еще притянулся user, из которого уже можно дергать поля, ну или гадать, какие там поля у внезапно появившегося стандартного User, который скрыт под капотом (ох уж этот капот), пока вы делали API.

Опять Path параметр, но через Lookup

Предположим, что есть такая моделька.

# models.py

class Product(models.Model):
    title = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)

И хотим обратиться на:

GET api/products/{product_name}

# views.py

class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

    # Поле, по которому ищем в БД
    lookup_field = 'slug'
    
    # Поле из URL для поиска в БД
    lookup_url_kwarg = 'product_name'

И не дай Бог вы ошибётесь в названии lookup_fileld, lookup_url_kwarg

Нельзя менять название этих переменных, иначе ничего не сработает. Кстати, оно само кинет 404 или любые другие ошибки, по мере того как будет обрастать viewset различными примочками. Переопределить текст ошибки или код (если вам вдруг такое понадобится), также придется кастомить, даже не хочется писать про то, как это сделать. Важно осознавать, что такие магические штуки работают во viewsets и GenericViewSet, а для APIView придется все делать самому. Но если честно, мне было бы удобнее следить за порядком кода, нежели гадать, где стрельнет, а где нет такая магия. Да и вдобавок это некрасиво и неочевидно. Компоненты View как будто бы работают непредсказуемо, и потребуется достаточно экспертизы, чтобы осознать, какая функция за какой следует (где точки входа), чтобы не наделать делов. Мой личный опыт подсказывает, что это точно нужно знать, иначе ваш код не в ваших руках.

Объявление URLs - дополнительная боль

Я и мой стажер вспоминаем, как объявлялись View
Я и мой стажер вспоминаем, как объявлялись View

Эндпоинт выше, который ищет по lookup_url_kwarg, регистрируется в общей массе URL так:

path('products/<str:product_name>/', ProductViewSet.as_view({'get': 'retrieve'}))

Стоп, что... Где магия? Почему я должен указывать теперь какая функция относится к GET?

Только так оно заработает, потому что мы имеем дело с viewset с кастомным path-параметром. А вот вам еще стопочка вариантов, как вообще регистрируются эндпоинты в URLs (конечно же все зависит от того, каким способом вы описали view):

# тут какие-то импорты

# view-функция
urlpatterns = [
    path('products/', views.product_list),
    path('products/<int:id>/', views.product_detail),
]


# view - все еще функция, но параметры посредством regexp
urlpatterns = [
    re_path(r'^products/$', views.product_list),
    re_path(r'^products/(?P<id>\d+)/$', views.product_detail),


# DRF Simple / Default router для viewsets
router = SimpleRouter()
router.register('products', views.ProductViewSet)


# views собраны в другом app и включаются в общий urlpatterns
urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('api.urls')),
]


# Class-based Views
urlpatterns = [
    path('products/', views.ProductView.as_view()),
    path('products/<int:id>/', views.ProductView.as_view()),
]


# Еще можно самому определить path во View
class ProductViewSet(viewsets.ModelViewSet):
    @action(detail=True, methods=['get'], url_path='discount')
    def discount_info(self, request, pk=None):


# Ну и самый мёд, котрого я боялся, но оно иногда незаменимо
urlpatterns = [
    path('products/', views.ProductViewSet.as_view({'get': 'list'})),
    path('products/<int:pk>/', views.ProductViewSet.as_view({'get': 'retrieve'})),
    path('products/<int:pk>/update/', views.ProductViewSet.as_view({'put': 'update'})),
]

Наверняка не всё назвал, но обычно хватает такого сета. Но честно говоря, скомпоновать весь хаос DRF и Django воедино достаточно сложно даже опытному разработчику. Самый лучший здесь подход - писать простые функции-эндпоинты, где легко уследить за проблемами, а также самому грамотно и в едином для проекта стиле отдавать Responses с ошибками и пояснением. Но как показывает практика, разношерстность написания views преобладает над здравым смыслом в проектах. Может, конечно, я не был в проектах, где все благородно, но лично для меня DRF только подливает масло в огонь.

В заключении

Безусловно, есть что еще рассказать. Я также намеренно не затронул Serializers, потому что там много о чем можно бухтеть. Какие выводы сделаю? Если бы мой проект был на стадии зарождения, я бы использовал современные легковесные фреймворки. Уж если очень хочется Django, можно посмотреть в сторону Django Ninja, я был приятно удивлен подходом разработки API через него. А если вам достался рефакторинг Django проекта, а зачастую они legacy, то советую прибегнуть к какому-то одному сценарию разработки эндпоинтов, и большинство логики писать самому.

А так пишите свои мнения. Может быть в других фреймворках (даже для других ЯП) имеется что-то похлеще в арсенале, я же повествую исходя из собственного опыта.