Всем привет.
Некоторое время назад я писал про альтернативные возможности, как можно добавить в 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 и асинхронные сервисы (на моём текущем проекте, увы, используется другой стэк).
Что думаете? Оцените старания автора по пятибальной шкале!
hardtop
Интересно. Надо будет попробовать в тестовом проекте.