Всем привет.

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

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

Комментарии (1)


  1. hardtop
    14.04.2022 13:51

    Интересно. Надо будет попробовать в тестовом проекте.