Было замечательное теплое австрийское утро, и ничего не предвещало … ничего, пока мой коллега не порекомендовал мне посмотреть запись недавно прошедшей Pyconf.
Там кто-то рассказывал, как при помощи желтого скотча, такой-то матери и усилий любимых разработчиков они наконец-то допилили Django Rest Framework до состояния
Оставим получившегося ф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 если была ошибка
Если добравшийся до этого момента читатель спросит: это все? Я отвечу, что есть еще варианты сериализаторов. Каждый из вас может добавить свой вариант в код репозитория.
Следующие шаги:
- Создаем несколько объектов Django-модели Organisation
- Получаем сериализованный лист объектов из базы по 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
Безоговорочным победителем сериализации объектов из базы в строку 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 |
Так какие же у нас выводы?
- Существующее решение на базе 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. Огромное спасибо моему терпеливому коллеге, Павлу П., который является первым тестером всех моих сумасшедших идей, и, в том числе, этого проекта.