Случается в нашей жизни, уважаемые коллеги, что хочешь сделать как проще, а получается как у новичка. И, что интересно, существует не мало мощных инструментов, которые предлагают простое решение в обмен на душу. Я имею ввиду, что цена абстракции бывает несоразмерна красоте её использования. Для меня примером такого неравноценного обмена стал Django Rest Framework 3.4.0, его механизм ViewSets и необходимость вывести подробную документацию по разрабатываемому API.

Начнём с простого: мой любимый формат работы с DRF — писать только APIView потомков. С одной стороны, это повторяющийся код, а с другой — вполне лаконичное решение с прогнозируемым и управляемым юзкейсом. Во-первых, с вероятностью 95%, мы не будем вешать на один эндпоинт несколько сериалазеров. Во-вторых, мы можем точнее настроить привязку URL. Но, со временем начинаешь задумываться: а всё ли я сделал правильно? Может, пора отойти от идеи проверенного годами REST консерватизма? Тем более, что DRF имеет достаточно неплохой слой абстракции: ViewSets.

Идея ViewSets проста: у нас есть обслуживаемая модель, и нам не надо сочинять свои эндпоинты или описывать их отдельными классами. Достаточно одного класса, который самостоятельно регистрирует views, проводит привязку urls и т.д. Т.е. это очень много шаблонов, запакованных в коробочку, повязанную голубой ленточкой. Задача стояла относительно стандартная:

1. Есть кастомный профиль пользователя.
2. У него есть дополнительные поля.
3. При регистрации мы используем REST и вручную определяем, какие поля обязательны, а какие нет (override полей модели на уровне DRF).
4. Логин генерируется автоматически.
5. У профиля есть связь с инвайтом, а инвайт связан с организацией, которая этот инвайт выписала.

После некоторого раздумья было решено сделать 2 или 3 сериалайзера. Абсолютно точно идёт отдельный сериалайзер на create. Отдельный — на view. Возможно, но не факт, что понадобится третий — на update (change). Классическая схема REST приложения выглядела бы так:

serializers.py

class UserCreateSerializer(serializers.ModelSerializer):
    pass


class UserViewSerializer(serializers.ModelSerializer):
    pass


class UserUpdateSerializer(serializers.ModelSerializer):
    pass


views.py

class UserCreateView(APIView):
    pass


class UserDetailsView(APIView):
    pass


class UserUpdateView(APIView):
    pass


После небольшого рефакторинга, мы можем получить один APIView:

views.py

class UserApiView(APIView):
    
    def get(self, request, *args, **kwargs):
        return self.__list_view(request) if 'pk' not in self.kwargs else self.__detail_view(request)

    def post(self, request, *args, **kwargs):
        return self.__create_view(request) if 'pk' not in self.kwargs else self.__update_view(request)


Как видите, особой надобности во ViewSet нету. Трэйс запроса происходит ровно одной строчкой, но нам доступны функции get, post, put и иже с ними. К тому же, если нам вдруг не понравится результат, мы всегда сможем вернуться к формату трёх отдельных классов эндпоинтов. У этого метода есть ещё один плюс: когда вы ставите приложение для автоматической документации (Swagger или DRF Docs), то получаете предсказуемый вывод: либо три эндпоинта, либо один эндпоинт с тремя описанными методами.

Однако, давайте перейдём к абстракции ViewSet:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def list(self, request, *args, **kwargs):
        serializer_class = self.serializer_classes['list']
        pass

    def create(self, request, *args, **kwargs):
        serializer_class = self.serializer_classes['create']


Во-первых, бросается в глаза обилие кода. Его гораздо больше, чем в варианте с одним эндпоинтом и тремя методами. Во-вторых, видим довольно приятную декларативность кода, которая, кстати, нам скоро пройдётся ножом по попе.

Итак, наша проблема заключается в том, что Swagger и DRF Docs не будут работать с этим вьюсетом правильно.

Я не копался в коде Swagger, но, думаю, не погрешу, если скажу, что он получает методы эндпоинта так:

1. Get urlpattern
2. Endpoint = urlpattern.callback
3. Methods = endpoint.available_methods

Обратите внимание на тот факт, что callback запрашивается без создания инстанса, либо обращения к методу as_view, который получает аргументом request. Давайте проверим нашу теорию:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def get_serializer_class(self):
        logger.warn(self.request)
        logger.warn(self.actions)
        return UserViewSerializer

    # Actions here...


Мы получим 500 ошибку с информацией о том, что объект UserViewSet не имеет атрибута request. Если мы уберём проблемную строчку, то получим вторую ошибку: этот объект не имеет атрибута actions. Так происходит потому, что ViewSetMixin выставляет actions при наличии request, хотя, логичнее было бы сделать список доступных actions в виде classproperty (ведь при наследовании миксина стандартные действия закрепляются по имени и условиям срабатывания).

Но сейчас нас не интересует что было бы, если бы у бабушки были мудики (словарь Даля, если не ошибаюсь). У нас есть интерфейс, который нельзя задокументировать. Вот же огорчение!

Задокументировать интерфейс на Swagger у меня не получилось. Костыль решения проблемы кроется в том самом методе get_serializer_class(), который вы видели в предыдущем сниппете. И Swagger, и DRF Docs используют его, чтобы получить текущий сериалайзер. Мы можем предположить, что наш код должен выглядеть так:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def get_serializer_class(self):
        return self.serializer_classes.get(self.action, UserViewSerializer)

    # Actions here...


Но мы помним, что на момент срабатывания get_serializer_class, self.action не существует как атрибута. Это вызывает 500 ошибку и не позволяет использовать данный кейс. Изучив оба решения (Swagger, DRF Docs), я остановился на последнем. И тут же получил ещё одну проблему:

сегодня 27 июля 2016 года, и код DRF Docs из ветки мастера отличается от кода DRF Docs, который ставится через pypi или путём скачивания репозитория GIT.

Не знаю, глюк ли это, но, видимо, git отдаёт код, отмеченный как релиз 0.0.11, а разработчики имели дерзость обновить мастер без релиза. Fail!

Проблема пока решается костылём — подменой api_endpoint.py в пакете. Вы прекрасно понимаете, что это не вариант. Тут у меня два пути развития кода: либо я дождусь, пока разработчики выкатят новый релиз, либо вернусь к варианту наследования от APIView. Сегодня уже нет времени и сил это делать. Разбираясь с кодом этого файла (который рабочий — в мастере), я наткнулся на два интересных фрагментв. Вот первый из них:

api_endpoint.py

    def __get_serializer_class__(self):
        if hasattr(self.callback.cls, 'serializer_class'):
            return self.callback.cls.serializer_class

        if hasattr(self.callback.cls, 'get_serializer_class'):
            return self.callback.cls.get_serializer_class(self.pattern.callback.cls())


Дело в том, что наша реализация ViewSet будет всегда содержать property serializer_class = None. Логично было бы поменять проверку местами, чтобы в приоритете исследовать динамическую смену сериалайзера.

Второй момент:

api_endpoint.py

    view_methods = [force_str(m).upper() for m in self.callback.cls.http_method_names if hasattr(self.callback.cls, m)]
    return viewset_methods + view_methods


Вот если вы воткнёте стоппер между этими двумя строками и попытаетесь получить self.callback.actions, то вы получите тот словарь, которого нам не хватает для работы. Конечно, тут можно было подключиться к разработке и добавить отдельную логику для документирования actions… но оно нам даром не надо. Сейчас я жду от разработчиков DRF Docs принятия issue с первой проблемой (serializer_class = None) и надеюсь на скорый релиз. Если его не случается, возвращаюсь к варианту с APIView. Что же касается метода получения сериалайзера, то выглядит он так:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
    serializer_classes = {
        'list': UserViewSerializer,
        'get': UserViewSerializer,
        'create': UserCreateSerializer,
        'update': UserUpdateSerializer,
        'set_password': UserEditSerializer,    # Нам оно надо?
        'activate': UserEditSerializer
    }
    
    def get_serializer_class(self):
        if not hasattr(self, 'action'):
            action = 'create' if 'POST' in self.allowed_methods else 'list'
        else:
            action = self.action
        return self.serializer_classes.get(action, UserViewSerializer)

    # Actions here...


Остаётся надеяться, что метод update не создаст проблем при добавлении. Ещё одна небольшая ремарка: мне пришлось всё-таки добавить метод post:

views.py

class UserViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):

    #...

    def post(self, *args, **kwargs):
        return super().post(*args, **kwargs)

    # Actions here...


Без него DRF Docs не смог получить allowed_methods, да и у Swagger были проблемы.

Вот так, уважаемые коллеги, при обращении к высокому уровню абстракции фрэймворка, я столкнулся с проблемой архитектурной. Сводится она к простому выводу: «Виноват сам». Хотя, вопрос, разумеется, спорный, ведь ViewSets — инструмент удобный и официальный. Однако, невооружённым взглядом видно, что вопрос регистрации actions в классе не проработан. Отсюда и нежелание разработчиков документаторов нормально обрабатывать actions. Исход ситуации прост: сегодня легче использовать отдельные API Views, чем шаблоны представлений для модели. По крайней мере, в большинстве известных REST движков или фреймворков, умеющих создавать REST, вы, скорее всего, не увидите подобных абстракций. И очень большой вопрос: нужны ли они вообще?
Поделиться с друзьями
-->

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


  1. kolyaflash
    27.07.2016 19:37
    +3

    > Во-первых, бросается в глаза обилие кода. Его гораздо больше, чем в варианте с одним эндпоинтом и тремя методами.

    Ага, а сколько кода (причем дублируемого) будет в вашем __list_view и __detail_view? А в примере с UserViewSet — это полный код. Осталось только указать queryset и другие атрибуты. Никаких "# Actions here..." там больше нет, всё в миксинах.

    > Как видите, особой надобности во ViewSet нету. Трэйс запроса происходит ровно одной строчкой, но нам доступны функции get, post, put и иже с ними.

    Что значит надобности? Зависит от решаемой задачи. ViewSet и дженерики хороши в двух случаях: когда всё просто и когда всё сложно.
    Когда всё просто — это наследовался от ModelViewSet, указал queryset, serializer_class и endpoint готов.
    Когда всё сложно — это ViewSet с различными (в том числе кастомными) миксинами, которые можно применять во всех ViewSet проекта и расширять/изменять их по мере необходимости через методы (типа perform_update у UpdateModelMixin). В итоге имеем правильную и красивую архитектуру приложения без своих костылей.

    На счёт Swagger — да, я все эти проблемы побороть не смог. Да что там, даже ApiRoot (тот, который для рендеринга карты урлов на главной) ломается. Но если исследовать проблему чуть глубже — становится понятно, что оно и не может работать. Нужно использовать другой путь, а это обычное дело в разработке.


    1. werevolff
      27.07.2016 21:34

      Собственно отсюда и выводы. Если подумать, то сам ViewSet — это лишний сахар. С точки зрения какого-нибудь JS, безусловно, проще создать кучу объектов, у которых весь функционал будет записываться декларативно. А если взглянуть на стиль вьюсетов внимательнее, то можно найти очень много общего с JQuery.


      1. kolyaflash
        27.07.2016 22:16
        +2

        А Django это лишний сахар над wsgi?
        JS то тут причем? Насколько я пониманию, если взглянуть на стиль вьюсетов внимательнее, то можно найти много общего с принципом SOLID. Тоесть, не нужно придумывать свою соответствующую структуру.


        1. werevolff
          28.07.2016 06:32

          При чём тут wsgi? Джанга в первых версиях связывалась с серваком по абсолютно иному протоколу.

          А что до вьюсетов, то они нарушают принцип SOLID где только можно. Начиная с первого: SRP.


          1. kolyaflash
            28.07.2016 10:10

            Учите матчасть.


            1. werevolff
              28.07.2016 10:44
              -1

              Это как у протестантов? Если нечего ответить оппоненту, скажи: «изучайте Писание».


    1. werevolff
      27.07.2016 22:09

      Да, а по поводу победы над этой темой я дал вполне явный намёк: в DRF Docs при формировании методов доступен словарь actions. Можно сделать форк и проработать создание эндпоинтов, включив туда actions вместо методов на ViewSets. Возможно, что овнер документатора заинтересуется таким подходом и согласится вмержить себе решение. Тем более, что у него там просто всё выводится на реакте.

      Ну а я этим точно не стану заниматься: сроки поджимают.

      Ещё хотел бы добавить, что методы класса post/get/put и т.д. создавали красивую экосистему, в которой надстройки в виде actions разумно доставались программисту проекта. ViewSets в эту экосистему слабо вписываются. Тут надо либо их выделять в полноценную концепцию со всеми методами получения и определения, либо удалять нафиг из системы. Полагаю, что так оно скоро и будет. Опенсорсные проекты часто избавляются от непопулярных узлов, либо перерабатывают их концепцию.


  1. mjr27
    28.07.2016 02:13

    DRF — боль моя. То, что для обычного CRUD обычно нужна пачка сериалайзеров — C, R две штуки для U (patch и post) + еще, например, комплект для суперюзера, по-моему автору в голову даже не приходило. 2/3 файла в результате состоит из унылой копипасты.


    Отдельная песня — DRF-Swagger (он уже научился догадываться, что на выходе может быть serializer_class[] ?). До удобоваримости пришлось очень сильно дорабатывать кувалдой и какой-то матерью. (input_serializer_class, many=True в yaml и прочее).


    Особенно порадовался, как автор в 3.4 (видимо обчитавшись xkcd) ВНЕЗАПНО стал пилить автодокументирование в 15м стандарте (wtf is coreapi?).


    А на тему статьи — ViewSets позволяют офигенно наглядно бутстрапить CRUD-образную обвязку любой сущности, выкидывая из CRUD ненужные буквы, и с этой задачей справляются на ура. Че еще от них надо-то?


    1. werevolff
      28.07.2016 06:41

      Полагаю, надо чтобы они CRUD реализовывали полноценно и явно. Судя по коду документаторов, вьюсет без request'а не знает какую операцию будет выполнять. Поэтому, документаторы логично описывают такой клас как единый и неделимый CRUD (без методов вообще). Вот ведь доупрощались. Хотели реализовать концепцию одной сущностью, а получили эндпоинт, которым не знаешь как пользоваться.


  1. einsfr
    28.07.2016 10:45

    По всей видимости, ощущение «неуклюжести» при использовании ViewSet'ов преследует многих, кто пытается ими пользоваться? ViewSet'ы, на мой взгляд, прекрасно подходят к случаю «один эндпоинт — одна модель, без отношений с другими». И совсем не подходят для сложных моделей с дополнительными action'ами. Выбор сериализатора на основании действия? Можно и так — делай соответствующий mixin и пользуйся им на здоровье. Выбор queryset'а? Ну, например, чтобы в list не тащить связанные модели, а вот в retrieve уже стянуть всё за один SQL-запрос — да тоже на здоровье.

    Получаются они примерно такими - если кому интересно
    # http://stackoverflow.com/questions/22616973/django-rest-framework-use-different-serializers-in-the-same-modelviewset
    class MultipleSerializerViewSetMixin:
    
        def get_serializer_class(self):
            try:
                return self.action_serializer_classes[self.action]
            except (AttributeError, KeyError):
                return super().get_serializer_class()
    
    
    class MultipleQuerysetViewSetMixin:
    
        def get_queryset(self):
            try:
                return self.action_querysets[self.action]
            except (AttributeError, KeyError):
                return super().get_queryset()
    


    1. werevolff
      28.07.2016 10:52

      Вот тут абсолютно согласен: ViewSet — сырой продукт для плоских моделей. Хотя бы потому, что он неуклюже пытается подменить HTTP методы модельным CRUD.

      Не согласен с тем, что методы генериков post/get/put — это низкоуровневое программирование. Скорее, наоборот. REST Framework не надо относить к пространству моделей. Это очевидный функционал формы. Отсюда следует, что отдельная форма — это отдельный класс сериалайзера. А представление, которое разруливает обращение к форме, должно работать с post/get/put а не с create/update/deflorate и т.д. Потому что это вьюха. Она обязана реализовывать функционал HTTP протокола, а не модели/формы для модели.


  1. Crandel
    28.07.2016 12:11

    Тоже были проблемы со сваггером в DRF, решились написанием докстринга в проблемной вьюхе вида


        '''
        Available methods:
        - `GET`: Getting `some` objects for all authenticated users.
        - `PATCH`: Updating `some` object.
    
        ---
        GET:
            serializer: DetailSerializer
            parameters:
                - name: update_categories
                  description: list with category id
                  required: false
                  type: array[int]
                  paramType: form
    
        PATCH:
            serializer: DetailSerializer
            parameters:
                - name: update_categories
                  description: list with category id
                  required: false
                  type: array[int]
                  paramType: form
        '''
    


    1. einsfr
      28.07.2016 12:55

      Только вот во 2-й ветке Django REST Swagger:

      Deprecated:

      YAML docstrings

      И работает он теперь через CoreAPI и схемы. DRF предоставляет возможность ручного определения схем. И всё бы ничего, но повторять те же самые поля ещё раз?..

      Возможность рисовать схему самому, но при этом таскать описания полей из имеющихся сериализаторов — это был бы не самый лучший, но выход. Можно попробовать покопаться в исходниках — rest_framework.schemas.SchemaGenerator и его get_path_fields, get_serializer_fields, get_pagination_fields и get_filter_fields, возможно, смогут помочь.


      1. Crandel
        28.07.2016 13:00

        Как по мне эта либа — один большой сплошной костыль, но альтернатив очень мало


  1. Akisame
    28.07.2016 13:10

    В последнем проекте плюнул на эти костыли для Swagger-a и перешел на RAML + raml2html
    В итоге, клиент остался доволен документацией, в документации нет привязки к конкретной технологии, зато все описание выглядит прилично.
    Как бонус, можно отдельно задокументировать большую часть ендпоинтов перед началом разработки (с свойствами, доступом и т.п) и дальше пилить серверную часть и клиента независимо друг от друга (сервер покрывался unit + integration тестами, клиент просто мокапил все запросы соответственно документации).

    Все время пытался этот способ обойти т.к. боялся проблем из-за расхождения документации и реальности, но как показала практика это не проблема вообще.