Асинхронный django, status update. Проект vinyl

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всем привет.

Некоторое время назад я писал про альтернативные возможности, как можно добавить в django асинхронность (есть официальный подход, изложенный в DEP-09). С тех пор у меня получилось оформить свои идеи в нечто относительно цельное, что вылилось в vinyl project. Описание проекта читайте на гитхабе, здесь же я хочу рассказать о его интересных особенностях.

Проект родился после нескольких предыдущих попыток, когда я узнал, что django, в действительности, очень неплохо расширяем. Например, он поддерживает использование нескольких баз данных одновременно (притом что модели одни и те же). Соответственно, например, ничего не мешает считать использование асинхронного драйвера как использование другой логической базы данных - чем я и воспользовался.

В итоге, получилось поместить всю асинхронную функциональность внутрь менеджера моделей. Не понадобилось делать форк django или его частей.

Вначале - небольшое демо того, что получилось. Для тестирования я использовал асинхронный драйвер psycopg3 (если что, вот ссылка на database backend).

Демо

from django.db import models 
from vinyl.manager import VinylManager
 
class Entry(models.Model):
	x = models.IntegerField()
     
	vinyl = VinylManager()

Entry - обычная модель django. То есть, можно пользоваться Entry.objects в полном соответствии с документацией django. Кроме этого, можно пользоваться менеджером vinyl и писать такой асинхронный код:

from django.db.models import Avg

await Entry.vinyl.filter(x__gt=a, x__lt=b).aggregate(Avg('x'))

API для работы с кверисетами такой же как в django. Попробуем создать объект:

obj = Entry.vinyl(x=1)
await obj.insert()

Как видим, API несколько другой. Для создания объекта используется менеджер vinyl, а для сохранения объекта используется .insert() вместо .save(). Сделаем апдейт:

await obj.update(x=2)

Снова другой API - CRUD-операции с объектами действительно отличаются от тех, которые в django. *vinyl* делает выбор в пользу более явного API. Об этом подробнее ниже.

Sync mode

Пожалуй, самая странная фича vinyl - наличие синхронного и асинхронного API одновременно. Регулируется это флагом, который можно установить динамически для конкретного потока:

from vinyl import set_async; set_async(False)

После исполнения этой строчки, при использовании Entry.vinyl будет использоваться синхронный ввод-вывод. То есть, мы сможем писать такой код:

obj = Entry.vinyl.get(x=1) obj.update(x=2)

История библиотек на питоне уже знает примеры такого подхода (sans-io, например). Чтобы его использовать, нужно писать универсальный код, годный для использования как в синхронном, так и в асинхронном контексте. Поэтому вы почти не увидите в коде async и await, а увидите что-то наподобие такого:

from vinyl.futures import later

def myfunc():
  result = maybe_async_func()
  
  @later
  def myfunc(result=result):
    print(f'The result was {result}')
    
  return myfunc()

myfunc() вернёт корутину или обычный питоновский объект, в зависимости от установленного флага, отвечающего за асинхронность.

Надо сказать, эта странная фича - универсальный код - одна из немногих, у которой нет реальной необходимости, и которая почти полностью является капризом автора. Да, хорошо иметь возможность писать как синхронный, так и асинхронный код - но ведь никуда не девается сам django, у которого и так есть синхронный API. Это так, однако, на мой взгляд, наличие синхронной версии улучшает тестируемость и документирование, а также затрудняет добавление каких-то сумасшедших и ненужных фич.

Упрощённый API на запись

В коротком демо вначале мы узнали, что CRUD-операции с объектами отличаются от API django. Это и есть API на запись (в базу данных). Этот API действительно сделан более явным - и более минималистичным.

Дело в том, что API django часто приводит к сложным цепочкам изменений в объектах, которые нужно учитывать в операциях на запись. В результате, для этого сложно писать нормальный универсальный (синхронно-асинхронный) код. Код действительно получается трудно читаемый - либо нужно прибегать к генераторам для несвойственных им целей.

Автор подумал, что неуклюжий код является свидетельством несовершенного API, и что лучшим решением будет упростить API на запись. В результате, новые CRUD-операции, как правило, приводят всего к одной операции на запись. Наследование моделей, конечно, немного добавляет проблем.

Наследование моделей

Я имею в виду то, что называется "concrete model inheritance". Наследование позволяет более компактно записать используемый OneToOneField, и при этом сократить цепочку атрибутов-связей в запросах. В общем, немного "избавиться от boilerplate". Но проблема в том, что, из-за того, что правила наследования достаточно свободные, при сохранении объекта (например, добавлении), нужно сохранять этих самых "родителей" в строго определённом порядке.

Не то, чтобы это очень сильно портило код, но я подумал, что небольшое ограничение правил наследования позволило бы сильно упростить вещи. Например, разрешить только наследование только от одной "concrete" модели, причём так, чтобы родитель был первичным ключом для ребёнка. Кстати, лично в своей практике я встречался именно с таким способом наследования. В таком случае, у всех родителей и детей одно и то же значение первичного ключа, и с этим гораздо проще работать. В частности, я думаю, нетрудно даже поддержать наследование в bulk-операциях (чего в django нет).

Однако, нужно понимать, что ограничение наследования ломает совместимость с django, поэтому к этому нужно относиться осторожно (в остальном, *vinyl* не ломает совместимость нигде, да и это изменение ещё обсуждается).

Ленивые атрибуты

Ленивые атрибуты - одна из типичных фич в django API. obj.related_obj сделает запрос в базу или вернёт данные из кэша - в зависимости от сделанных предыдущих запросов. В асинхронном же коде, как мы знаем, если код потенциально содержит ввод-вывод (обращается к базе), он должен быть помечен с помощью async и await.

Однако, это не представляет особых трудностей для возможного API. Вначале, я думал сделать так, чтобы obj.related_obj возвращал объект, если он есть в кэше, и корутину, если его там нет. Во втором случае, для запроса объекта нужно было бы написать await obj.related_obj.Конечно, для этого бы потребовалось переопределить поля в моделях, отвечающие за связи.

Однако, потом нашлось более элегантное решение - и более минималистичное: переопределять поля не потребовалось. vinyl, к тому времени, уже поддерживал prefetch_related. Соответственно, если нужно было обратиться к атрибуту, который присутствовал в prefetch_related (или select_related), это можно было сделать без всяких проблем. Я подумал, что можно разрешить только такой вариант использование атрибутов-связей (то есть, когда все данные уже присутствуют в кэше). А для случаев, когда нужны дополнительные запросы в базу сделать другой API.

Каким он может быть? Ну, например, await obj.q.related_obj (добавился атрибут .q). Как это реализовать? Методом "чайника": сначала делаем prefetch_related, потом возвращаем нужный атрибут.

Что мы получаем в итоге? Реализацию ленивых атрибутов, но только на чтение. API на запись, вроде obj.collection.add(related_obj), как всегда, отсутствует. Что ж - возможно, он и не нужен, вышеописанные CRUD-операции вполне удобны.

Заключение

Как вы заметили, у проекта есть название - это значит, что отношение к нему самое серьёзное! Лично для меня, это проект для портфолио, поэтому мне важно, чтобы он был годным.

vinyl - это первая попытка использовать django как библиотеку и как зависимость для другого фреймворка. Также, это шанс для самого django, который без асинхронной функциональности, без сомнения, вымрет.

Что касается достоинств - во-первых, мы получаем фреймворк, который годится для создания смешанных WSGI+ASGI приложений. Я имею в виду приложения как с синхронными, так и с асинхронными эндпоинтами, использующих одно и то же (наверно) окружение и, конечно, базу. Кстати, насколько такое распространено сейчас, и насколько удобно?

Также, несмотря на урезанные возможности, vinyl предоставляет достаточно широкие возможности - более широкие, чем большинство асинхронных ORM. А наличие собственной синхронной версии гарантирует его гармоничное развитие и независимость от django project.

Что касается дальнейшего развития фреймворка, автор планирует развивать и обкатывать его в боевых условиях - то есть, внутри компании, использующей django и асинхронные сервисы (на моём текущем проекте, увы, используется другой стэк).

Что думаете? Оцените старания автора по пятибальной шкале!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Оцените проект *vinyl*
0% 1 0
0% 2 0
0% 3 0
0% 4 0
0% 5 0
Никто еще не голосовал. Воздержавшихся нет.
Источник: https://habr.com/ru/post/660831/


Интересные статьи

Интересные статьи

Системы сильно менялись в 2021 году. Asana выпустила русскоязычную версию. Trello сделал рабочие области для группировки досок. Битрикс добавил редактор документов и интеграции с WhatsApp и Instagram....
Что вы представляете, когда вам говорят про IT-проекты с банками? Бюрократия, куча интеграций, разработчики из разных команд. Что вижу я? Отличный шанс организовать сложный и трудоёмкий процесс. ...
Считается, что запуск микросервисов изначально затратнее по времени, чем монолит, и наш опыт это подтверждает. Однако, если следовать проверенным процессам, эти затраты можно минимизировать. Делюсь лу...
Стандартное представление Xcode-проекта сложно назвать комфортным для командной работы. Даже в небольших проектах часто возникают merge-конфликты после изменения состава исходников в ра...
Цветовой контраст является важным аспектом доступности в дизайне продукта. Хорошая контрастность облегчает использование продуктов людьми с нарушениями зрения и помогает в несовершенных условиях,...