Не суйте свой Pydantic в мое Django

image


Было замечательное теплое австрийское утро, и ничего не предвещало … ничего, пока мой коллега не порекомендовал мне посмотреть запись недавно прошедшей Pyconf.

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

Оставим получившегося фRESTенштейна для другой статьи, и поразмышляем только о прозвучавшей в докладе возможности использования PYDANTIC в экосистеме Django — DRF.

Предпосылка


В компании «Плохой больной» используется экосистема DJANGO-DRF-API. Компанию Django-Rest-Framework не устраивал и был переписан. Тимлидера разработчиков это не останавило, он слышал что-то хорошее про PYDANTIC и твердо решил внедрить его в DJANGO проект. Пока безуспешно.

Введение


PYDANTIC — это модуль python, позволяющий объявить специальный класс PYTHON, в котором атрибуты класса имеют статическую типизацию. Эта типизация используется в момент создания объекта класса для проверки значений, присваиваемых этим атрибутам.

Допустим, в Django проекте есть модель

class Organization(models.Model):
    domain = models.CharField(_('Domain'), max_length=25, unique=True, validators=(DomainValidator(),))


напомню, что id у такой модели создастся автоматически.

Если на базе Django-модели Organization создать аналогичный PYDANTIC-класс, то объект этого класса можно использовать в роли обработчика входящих «сырых» данных для последующего безопасного использования. Кто-то даже утверждает, что PYDANTIC-классы кроме валидации данных якобы можно использовать для сериализации данных нет.

Пример объявления PYDANTIC-класса

from pydantic import BaseModel
class OrganizationSchema(BaseModel):
    id: int
    domain: str
    class Config:
        min_anystr_length = 1
        max_anystr_length = 25


Я добавил настройку валидации для строковых данных.

Увы нормальное создание на лету PYDANTIC-классов на базе Django-моделей невозможно. Это минус. Решение возможно, но есть нюанс.

Вариант 1. С нюансом
Можно взять недоделанный модуль djantic, он обещает создать PYDANTIC-класс на базе Django-модели. Выглядит это так:

from djantic.main import ModelSchema
class OrganizationSchema(ModelSchema):
    class Config:
        model = Organization
        include = ['id', 'domain']


Нюанс в том, что не работает никак.

Можно, конечно, ручками доделать все, что не доделал автор, типа навешивания валидаторов:

    @validator('domain‘)
    def domainvalidator(cls, v):
        DomainValidator(v)
        DomainUniqueValidator(v)
        return v


Можно даже сделать автоматическое прикрепление валидаторов к OrganizationSchema в цикле:

for field in (Organisation._meta.fields("domain"), Organisation._meta.fields("id")):
    setattr(OrganizationSchema, f’validate_{field.name}’, validator(field.name)(lambda cls,value: not all(validator(value) for validator in field.validators) and value)

 
После некоторых доделок djantic у меня все же начал валидировать.

Из-за сырости пакета, не рекомендую его использовать — вы потратите те же усилия для достижения результата, что и с обычным PYDANTIC.

Вариант 2. Нюансов не меньше
Берем PYDANTIC-класс, объявленный выше. Он не связан с Django моделью, не знает, как нормально валидировать данные и не умеет чистить результаты. Вместо этого в PYDANTIC-классе можно объявить валидатор поля, в котором предлагается все это делать. В Django за это отвечают методы to_python, validate, и clean полей модели.

Мне не нравится, когда смешиваются разнородные идеологии внутри одного проекта, потому субъективный минус.

Nested PYDANTIC-класс тоже возможен:

class OrganizationsList(BaseModel):
    __root__: List[OrganizationSchema]


Применение. Без нюансов


Не важно, какой вариант выбран, пробуем реализовать следующее утверждение:

Объекты PYDANTIC возможно использовать в DJANGO и в DRF вместо объектов Django-form и DRF-serialiser соответственно.

Увы, без мега напильника сделать это не получится, но, надеюсь, мы все же найдем ответ на вопрос: КАКОЙ В ЭТОМ СМЫСЛ?

Создаем DRF-API на базе ListAPIView, этот обработчик будет выдавать лист объектов модели Organization из базы, добавим в него метод POST для сериализации и валидации данных, отправляемых пользователем:

class OrganisationUpdateApiView(ListAPIView):
    http_method_names = ['get', 'post']  # , 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'
    serializer_class = OrganisationSerializer
    permission_classes = (AllowAny,)
    queryset = Organization.objects.only('id', 'domain')

    def post(self, *args, **kwargs):
        serializer = self.get_serializer(None, data=self.request.data)
        serializer.is_valid(raise_exception=True) 
        return Response(serializer.validated_data)


результат работы подобной API

b'[{"id": 1, "domain": "localhost"}]'  # тело ответа на GET запрос
[{'id': 1, 'domain': 'localhost'}]  # validated_data после POST запроса


Стандартный DRF сериализатор для API:

class OrganisationSerializer(ModelSerializer):
    class Meta:
        model = Organization
        fields = ['id', 'domain']
        extra_kwargs = {'id': {'read_only': False}, 'domain': {'validators': []}}


Я убрал валидаторы домена, для соответствия результатам работы следующего сериализатора на базе PYDANTIC-класса:

class PydantedOrganisationSerializers:
    def __init__(self, queryset=None, **kwargs):
        super(PydantedOrganisationSerializers, self).__init__()
        vars(self).update(queryset=queryset, kwargs=kwargs)

    @property
    def validated_data(self):
        pydanted = OrganizationSchema
        try:
            data = OrganizationsList(__root__=(pydanted(**kwargs) for kwargs in self.queryset.values('id', 'domain')))
        except Exception as error:
            data = error
        return data.json()

    def is_valid():
        return True


Кстати, если мы посмотрим на PYDANTIC, то он умеет напрямую разбирать JSON строку и собирать обратно собственными методами parse_raw ()/json (). Это плюс. Потому при встраивании PYDANTIC сериализатора в DRF-API стоит отключить классы JSONRenderer и JSONParser, запускаемые по умолчанию DRF-View, и использовать соответствующие методы PYDANTIC-Модели.

class PydantedOrganisationUpdateApiView(OrganisationUpdateApiView):
    renderer_classes = []
    parser_classes = []


На ресурсах типа SOf в обсуждениях про использование PYDANTIC в DRF я не встречал упоминаний использования встроенных методов parse_raw/json.

Я уже хотел запустить проект, но тут подумалось, что если уж я сравниваю результаты, то эксперимент будет не полным без результатов работы сериализатора, построенного на базе Django-Form:

#  создаю модельную форму
class MyModelForm(forms.ModelForm):
    class Meta:
        fields = ['id', 'domain']
        model = Organization
    id = forms.IntegerField(label=_('auto key'), min_value=0, required=True)
    domain = forms.CharField(label=_('Domain'), max_length=25, required=True)

    def validate_unique(self):
        return
#  А теперь создаю сериализатор
class OrmSerializer(object):
    def __init__(self, *args, **kwargs):
        self._data = kwargs.get('data') or {}

    def is_valid(self, *args, **kwargs):
        forms = (MyModelForm(data) for data in self._data)
        self.validated_data = [form.cleaned_data if form.is_valid() else form.errors for form in forms]
        return True


Я уже хотел запустить проект, но меня было уже не остановить: я вспомнил еще один «сериализатор» из Django! Это же объект класса Model с его встроенными методами Model.full_clean и Model.serialize_value:

class DjangoModelSerializer:
    exclude = set(field.name for field in Organization._meta.fields if field.name not in ('id', 'domain'))

    def __init__(self, *args, **kwargs):
        self._data = kwargs.get('data') or {}

    def is_valid(self, *args, **kwargs):

        def data_yielder(full_data):
            for _data in full_data:
                try:
                    Organization(**_data).full_clean(exclude=self.exclude, validate_unique=False)
                except ValidationError as errors:
                    _data = errors
                yield _data
        self.data = self.validated_data = [data for data in data_yielder(self._data)]
        return True  # конечно же тут надо возвращать False если была ошибка


Если добравшийся до этого момента читатель спросит: это все? Я отвечу, что есть еще варианты сериализаторов. Каждый из вас может добавить свой вариант в код репозитория.

Следующие шаги:

  1. Создаем несколько объектов Django-модели Organisation
  2. Получаем сериализованный лист объектов из базы по GET-запросу
  3. Отправляем его обратно POST-запросом
  4. Получаем набор сериализованных объектов
  5. Сравниваем время работы всех объявленых сериализаторов.
  6. Профит

В репозитории в папке TEST вы найдете файл, который выполняет все действия, команды запуска в readme. Можно настроить количество сериализуемых объектов, печать в консоль и добавление ошибок в объекты.

Момент истины


GET POST без ошибок
Создавалось 3,30,300,3000 и 30000  + 1 объектов. Графики в логарифмическом масштабе. Тот, кто ниже всех — выиграл.

GET


Безоговорочным победителем сериализации объектов из базы в строку JSON стал ORM.VALUES_LIST + DRF JSONRenderer. 0.18sec/30000 объектов. Не секрет, что в некоторых gresSQL можно сделать еще быстрее.

Меня удивил DRF-сериализатор без доп настроек (самая верхняя линия). Он работает на сериализацию объектов из базы в 100 раз медленнее — 18,5sec/30000 объектов.

Остальные сериализаторы стоят вместе, практически с одинаковыми результатами.

Сравнение результатов, не совсем адекватно: REST сначала создает объекты и потом их сериализует, чего «победитель» не делает. Именно это я имел ввиду, когда говорил «без доп настроек». Если вы хотите увидеть честный результат, OrganisationSerializer надо доработать.

POST


Самым медленным оказался сериализатор на базе DJANGO FORM, в 5 раз медленнее остальных.
DRF-сериализатор без доп настроек работает тоже медленно, в 2 раза медленнее остальных двух.

А вот победителем, как мне кажется, оказался сериализатор на базе models.MODEL У меня он был быстрее в 3х случаях из 5, чем PYDANTIC сериализатор. Предлагаю это проверить читателям самостоятельно.

Сравнение результатов сериализации не совсем адекватно: PYDANTIC выдает сериализованные объекты своего класса, для использования далее в DAJNGO их скорее всего придется преобразовывать в объекты Dajngo.

Работа с поврежденными объектами


Мне не удалось заставить работать PYDANTIC сериализатор, в случае всего одного поврежденного объекта из нескольких. И на GET и на POST результат был »[{'loc': ('__root__', 0, 'domain'), 'msg': 'field required', 'type': 'value_error.missing'}]» Если вы знаете, как это можно исправить, жду помощи в комментариях. PYDANTIC пока выбывает из участия в этом тесте.


GET POST с ошибками
Конечно, это так себе тест: я получаю из базы уже «поврежденный» объект. Допустим, что такое в реальности тоже возможно. :)

GET


Безоговорочным победителем сериализации объектов из базы в строку JSON стал ORM.VALUES_LIST + DRF JSONRenderer.

Чистый DRF-сериализатор без доп настроек на последнем месте.

POST


Родной DRF-сериализатор без доп настроек работает наравне с сериализатором на базе models.MODEL, хотя последний все же быстрее.

Каждый сериализатор сообщает об ошибке по-своему, мне нравится, когда в листе объектов видно, какой объект не прошел валидацию:

[{'domain': ['This field is required.']}, {'domain': 'GkByUSnIFVRrcA7WFAAonMjeu', 'id': 2},…]


Пример ошибки валидации самого медленного сериализатора на базе DJANGO Form.

Кстати, все результаты возможно убыстрить, если вместо билиотеки JSON использовать UJSON.

Итоги


Итоги оказались для меня неожиданными.
Это очень субъективная таблица. Мне, например, важно уметь перевести сообщение об ошибке, а в PYDANTIC это не реализовано, или, «ИЗ КОРОБКИ» сериализатор DRF может только простые вещи, иначе надо настраивать. И т.п. А кому-то это, может быть, не важно.

Так какие же у нас выводы?


  • Существующее решение на базе django models.Model оказалось максимально интересным как для разработки — это просто, так и для быстродействия — это быстро.
  • DRF-сериализаторы, похоже, переоценены. Но они хороши для быстрой разработки проекта.
  • Использование PYDANTIC в тестовом DJANGO-DRF проекте не показало сильных плюсов по скорости работы, и, могу предположить, что, из-за инородности идеологии, это может сильно усложнить разработку DJANGO-проекта.


В завершение добавлю, что любая технология, какая бы она ни была интересная, имеет еще контекст применения. И как бы не оргазмировал на камеру очередной фанат статической типизации, стоит сначала проверить применимость новинки и получаемые бенефиты непосредственно для вашего проекта, прежде чем биться со сложностями внедрения инородной технологии.

А теперь я предлагаю читателям в комментариях поразмышлять, зачем действительно может быть нужен PYDANTIC в DJANGO?

P.S. Еще один смешной момент той же презентации, когда один слушатель спросил у докладчика, почему тот, вместо PYDANTIC, не возьмет django-ninja?
Согласен, что нет разницы, какую малоприменимую технологию использовать. django-ninja построена в стилистике FASTAPI и тоже не умеет работать с DJANGO моделями напрямую, что, собственно, честно указано на сайте:

Models Django to Schemas django-ninja.
This is just a proposal, and it is not present in library code, but eventually this can be a part of Django Ninja.


P.P. S. Большой дисклеймер о том, что все персонажи из статьи являются вымышленными, и любое совпадение с реально живущими или жившими людьми не случайно.

P.P. P.S. Огромное спасибо моему терпеливому коллеге, Павлу П., который является первым тестером всех моих сумасшедших идей, и, в том числе, этого проекта.

© Habrahabr.ru