Оверинженеринг при документировании ViewSets Django REST Framework

    Случается в нашей жизни, уважаемые коллеги, что хочешь сделать как проще, а получается как у новичка. И, что интересно, существует не мало мощных инструментов, которые предлагают простое решение в обмен на душу. Я имею ввиду, что цена абстракции бывает несоразмерна красоте её использования. Для меня примером такого неравноценного обмена стал 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, вы, скорее всего, не увидите подобных абстракций. И очень большой вопрос: нужны ли они вообще?

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 16

      +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 (тот, который для рендеринга карты урлов на главной) ломается. Но если исследовать проблему чуть глубже — становится понятно, что оно и не может работать. Нужно использовать другой путь, а это обычное дело в разработке.

        0
        Собственно отсюда и выводы. Если подумать, то сам ViewSet — это лишний сахар. С точки зрения какого-нибудь JS, безусловно, проще создать кучу объектов, у которых весь функционал будет записываться декларативно. А если взглянуть на стиль вьюсетов внимательнее, то можно найти очень много общего с JQuery.
          +2
          А Django это лишний сахар над wsgi?
          JS то тут причем? Насколько я пониманию, если взглянуть на стиль вьюсетов внимательнее, то можно найти много общего с принципом SOLID. Тоесть, не нужно придумывать свою соответствующую структуру.
            0
            При чём тут wsgi? Джанга в первых версиях связывалась с серваком по абсолютно иному протоколу.

            А что до вьюсетов, то они нарушают принцип SOLID где только можно. Начиная с первого: SRP.
              0
              Учите матчасть.
                –1
                Это как у протестантов? Если нечего ответить оппоненту, скажи: «изучайте Писание».
                  0
                  werevolff спасибо за статью, воткнулся сейчас в эту проблему с документацией, в итоге переделал с viewset на views, в итоге оказалось кода меньше.

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

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

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

          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 ненужные буквы, и с этой задачей справляются на ура. Че еще от них надо-то?

            0
            Полагаю, надо чтобы они CRUD реализовывали полноценно и явно. Судя по коду документаторов, вьюсет без request'а не знает какую операцию будет выполнять. Поэтому, документаторы логично описывают такой клас как единый и неделимый CRUD (без методов вообще). Вот ведь доупрощались. Хотели реализовать концепцию одной сущностью, а получили эндпоинт, которым не знаешь как пользоваться.
            0
            По всей видимости, ощущение «неуклюжести» при использовании 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()
            


            Теперь хочу фильтров! Разных! Тут становится понятно, что никакого «get_filters» нам не предоставлено — ну хорошо, переопределим filter_queryset, может даже полностью повторив его код — пускай… И где-то тут и возникает неловкость — как будто тебе дали рог изобилия, но забыли приложить к нему инструкцию и всё, что остаётся — лупить им по стенам и собирать вываливающиеся крошки.

            Теперь что касается использования сразу get, post и т.д. Это в большинстве случаев скорее приятно, чем продуктивно. Знание того, что именно заставляет программу работать — оно прекрасно само по себе. Но постоянно переписывать одни и те же строчки для каких-то простых моделек?… Получение данных из request, валидация сериализатора, сохранение — и так 500 раз… А если я хочу в одну транзакцию с сохранением модели включить ещё что-то? Например, запись в журнал об этом изменении? Получается действительно много… даже Много повторяющегося кода. Причём повторяющего зачастую то, что в generic'ах уже кем-то написано, протестировано и упаковано — бери да пользуйся.

            Так что по мне вариант — generic'и для сложных моделей, viewset'ы — для простых в две строчки. Есть конечно и свои нюансы — хочешь использовать меньше URL'ов и больше HTTP-методов — всё равно изволь выбирать сериализаторы… Но хотя бы не из простыни на 15 вариантов. Ну а get, post и остальные низкоуровневые методы — это для тех случаев, когда исполняемая логика не укладывается в обычный CRUD.
              0
              Вот тут абсолютно согласен: ViewSet — сырой продукт для плоских моделей. Хотя бы потому, что он неуклюже пытается подменить HTTP методы модельным CRUD.

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

              Тоже были проблемы со сваггером в 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
                  '''
              
                0
                Только вот во 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, возможно, смогут помочь.
                  0

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

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

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

                Only users with full accounts can post comments. Log in, please.