Как стать автором
Обновить

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

Время на прочтение 8 мин
Количество просмотров 23K
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.

Итоги


Итоги оказались для меня неожиданными.
Who Ser-OUT Ser-IN no Err Ser-IN +Err ErrMessage From BOX Easyness Django-ECO MultiLang  
Dj Form WerySlow WerySlow WerySlow Normal Yes Easy yes Yes 3
Dj Model Fast Normal Normal Normal Yes Easy Yes Yes 1
DRF Slow Normal Normal Best No Normal Yes Yes 2
PYDANTIC Fast Normal -- Bad No Difficult No No 4
ORM Fastest -- -- Yes Easy Yes Yes 3
Это очень субъективная таблица. Мне, например, важно уметь перевести сообщение об ошибке, а в 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. Огромное спасибо моему терпеливому коллеге, Павлу П., который является первым тестером всех моих сумасшедших идей, и, в том числе, этого проекта.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+9
Комментарии 20
Комментарии Комментарии 20

Публикации

Истории

Работа

Python разработчик
144 вакансии
Data Scientist
75 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн