Асинхронному django быть. Эксклюзивно для Хабра

dff4c34309a4c49bcbbcc55527f354e6.jpg

Здравствуйте, читатели хаба про django. Эта статья о фреймворке для перфекционистов с дедлайнами, и о том, можно ли добавить в него асинхронность. Некоторые в курсе, что со стороны Django Foundation также есть некоторые усилия в этом направлении. Например, есть DEP-09, который примерно очерчивает границы будущих изменений. Причём, некоторые преобразования, на взгляд авторов, слишком объёмные, и про них явно говорится, что они выходят за рамки DEP. Такой, например, является задача сделать django orm асинхронной. Учитывая, что, по любым меркам, django orm — это больше 50% всего django, а на мой взгляд — его главная часть, DEP-09 мне кажется какими-то непонятными полумерами.

У меня есть альтернативное предложение по добавлению асинхронности, в какой-то степени, более радикальное. Такое, для которого не нужен DEP. В общем, я хочу сделать новую версию, которая заменит собой django. Не то чтобы это была самоцель: главное, я хочу выпустить асинхронную версию django, но синхронная будет тоже поддерживаться, так что традиционный django вряд ли будет очень нужен. К слову, если даже выбирать между только синхронной и только асинхронной версией, то только асинхронная имеет явное преимущество, на мой взгляд. Кстати, я хочу портировать только django orm, а не весь django. То есть, именно ту часть, которая выходит за рамки DEP-09. Итак, встречайте.

Предлагаемый подход

Cреди программистов на питоне достаточно известен (здесь сомнения) принцип, который называется «No I/O». Он использовался для написания разных веб-клиентов (HTTP, HTTP/2), про них можно прочитать здесь: sans.io. «No I/O» — это значит, библиотеки не занимаются вводом-выводом, вообще. Предлагается считать, что способ передачи данных может быть каким угодно (голубиная почта!), и про него мы не можем сказать ничего. Тем не менее, формат передачи данных от этого не меняется, и сам сетевой протокол может быть реализован на 95%, как минимум.

Что интересно — такой подход позволяет, в частности, иметь единую реализацию для синхронного и асинхронного кода. Правда, неполную: нужна ещё высокоуровневая оболочка, которая, собственно и обеспечивает ввод/вывод. Таким образом, разделение библиотеки или фреймворка на разные части (No I/O и I/O) не только облегчает другим библиотекам переиспользование кода, но и позволяет самому этому фреймворку или библиотеке покрыть асинхронный вариант использования. Для любителей видео — вот ссылка.

Как это можно применить к django? Напомню, что я буду говорить только о портировании django orm. Вообще, django-orm — это единственная крупная часть, которая не зависит от остального django.

Так вот, как обойтись без ввода-вывода, если нужно обращаться к базе данных? Конечно, не обращаться к ней. Нужен отдельный тонкий слой, который будет этим заниматься (выполнять функции драйвера). Этот слой будет отделён от django (orm), сама же django будет считать, что если у нас есть SQL, по нему можно как-то получить строки с данными, а их она уже умеет обрабатывать. Можно сказать, что апи будет построен на колбэках — это универсальный интерфейс, который не зависит от того, синхронный или асинхронный у нас код.

Чтобы не быть голословным, я написал proof-of-concept, который делает django-кверисеты Awaitable. То есть, чтобы можно было писать `await MyModel.objects.all ()`. Если учесть всякие фишки вроде prefetch_related, это не на 100% тривиально. Вот он https://github.com/pwtail/django/pull/2/files, он работает! Но подробнее об этом ниже.

Совместимость

На самом деле, вначале я хотел сделать только асинхронную версию. Идея использовать подход «No I/O» появилась потом, когда я увидел, что это позволяет иметь единую версию для синхронного и асинхронного кода, и притом, что она будет совместимой с традиционным django. Так что же такое совместимость и какой она будет?

Как ни странно, под совместимостью, в контексте портирования на асинхронные рельсы, принято понимать больше, чем просто совместимость API (новой синхронной и старой, только синхронной, версии). Ещё требуется, чтобы синхронная и асинхронная версия обеспечивались одним репозиторием. Иначе, как бы — нет, недостаточно. Так вот, предлагаемый подход обеспечивает совместимость именно в этом смысле. И синхронная версия тоже совместима (со старой, только синхронной). Возможно, будут breaking changes. Я думаю, это зависит от сроков разработки проекта. Но это как бы нормально. Но есть ещё один вид совместимости, о котором я хочу сказать.

Это совместимость на уровне базы данных. И на уровне моделей. Другими словами, как питоновские объекты соответствуют сущностям базы данных (таблицам, колонкам, индексам). Здесь от асинхронности точно ничего не зависит. И это тот вид совместимости, который я, без веской причины, точно нарушать не планирую. Это значит, например, что джанго-админка из django/django должна нормально работать с моей версией, хоть и не будет частью моего репозитория.

Мотивация и цели

Для меня самого, этот проект — упражнение, своего рода, курсовая работа. Цель проекта чётко определена и достижима. Результат тоже легко оценить. Я не собираюсь расширять scope проекта, он посвящён только асинхронности.

Что касается цели, то это, главным образом — асинхронная версия django, с тем же самым API, что и традиционный django. То, что одновременно будет обеспечена и синхронная версия — это следствие используемого подхода, и приятный бонус.

proof-of-concept

Итак, мы добрались, собственно, до самой идеи и до примера её воплощения.

Как мы знаем, вводом-выводом занимается драйвер базы данных — мы его вынесем из orm. Напомню, что вообще в django интеграцией с конкретной базой данных занимается database backend, только малую часть которой составляет сам драйвер. Конечно, мы хотим избавиться только от драйвера, а сам database backend оставить. У драйвера простой интерфейс: по заданному SQL мы получаем строки с данными, возможно, чанками (и возможно, используем server-side cursors). Конечно, рассматривать всё это нужно на примере, давайте возьмём мой пул-реквест.

Это пул-реквест в django 3.2, который делает кверисеты awaitable. Основной интерфейс кверисета — это итератор, который возвращает объекты. Увы, теперь он станет менее самодостаточным: он может нам вернуть объекты, если мы передадим ему строки, которые мы получили из базы данных. Например, для этого у нас может быть метод .send_rows(rows), который будет итератором и возвращать объекты. Если мы получаем строки из базы чанками, метод нужно будет вызывать много раз. Вы можете заметить, что интерфейс кверисета в таком случае похож на интерфейс генератора: у него тоже есть метод send. Но нет, у нас не генератор, а самый обычный объект.

В основном, изменения касаются django.db.models.query.ModelIterable и ему подобных.

Можете также посмотреть на код драйверов, синхронного и асинхронного, я их положил в модуль driver.py

Вот так теперь выглядят интерфейсы __iter__ и __await__:

def __iter__(self):
    yield from driver.execute(self.queryset)

async def _await(self):
    return await async_driver.execute(self)

# __await__ - обёртка вокруг метода выше

Ну, и не могу не написать про prefetch_related. Там исполняются запросы один за другим, соответственно, и взаимодействовать с драйвером нужно на каждый такой запрос. Здесь я решил прибегнуть к настоящим генераторам, чтобы сделать изменения кода минимальными. Результат превзошёл ожидания: нужно было только в нескольких местах заменить return на yield from. Например, главный метод _fetch_all() выглядит так. Иногда генераторы очень упрощают жизнь.

Асинхронный API

Выше я написал, что django останется целиком таким же, только станет асинхронным — это так и есть, в общем и целом. Но некоторые могут сказать, что это невозможно в силу синтаксических особенностей. Например, при обращении к базе нужно обязательно писать await, потому что это асинхронная операция, а в django есть так называемые ленивые атрибуты (обычно — это связанные сущности, через foreign key, или же полученные заранее через prefetch_related). Да, это так, и без некоторого изменения API, действительно, не обойтись.

Но для этого есть простое решение: пусть все запросы в базу, или их отсутствие, будут явными. Например, по дефолту любое обращение к атрибутам не должно приводить к запросам к базу: они должны быть извлечены заранее (например, с помощью select_related или prefetch_related). Если мы обращаемся к связанной через foreign key cущности, и хотим сделать для этого запрос к базе, то мы не можем пользоваться тем же API

obj.related_obj  # Exception: not in the cache

Объект не был запрошен заранее, а чтобы запросить его снова, нужен асинхронный вызов, каким доступ к атрибуту не является. Но мы можем немного изменить API для таких случаев. Например, использовать букву R (R — for relation)

await obj.R("related_obj")
await obj.R("related_objects").all()

Или можно использовать другую букву. Или другой API, например, R.related_obj. В общем, другое что-нибудь). Ну, а если мы используем старый API (obj.related_obj), это значит, что объект уже находится в кэше вследствие prefetch_related или чего-то подобного.

Кстати, не могу не сказать: я считаю, что такая фича была бы разумной и в синхронном контекcте: обычно разработчик всегда понимает, должны ли данные браться из кэша, или нужно делать запрос в базу. И если разработчик считает, что данные будут браться из кэша, а на самом деле их там не окажется, то эксепшн в таком случае подошёл бы больше всего. Но — как сделано, так сделано, совместимость превыше всего.

Асинхронный API, часть 2: модели

Выше была описана не единственная проблема, которая может возникнуть, но основная, на мой взгляд. Вот, для сравнения, ещё одна.

Суть её в следующем: мы делаем асинхронную версию, чтобы можно было писать асинхронные сервисы. Скорее всего, им нужно будет обращаться к тем же самым данным (реляционным таблицам), что и синхронным сервисам. В таком случае, дублировать объявление моделей не хотелось бы.

Попробую описать одно из возможных решений. Если вы заметили, в django весь интерфейс навешан на модели: так или иначе, мы всё делаем либо через класс модели (MyModel.objects), либо через инстанс. Решение, которое напрашивается: сделаем асинхронный класс модели — для использования в асинхронном контексте, при этом синхронный и асинхронный классы будут соответствовать одной и той же таблице.

Что касается полей в этих классах моделях (тех, что отвечают за схему базы данных) — конечно, они должны быть одинаковыми, и определять их в 2х разных местах смысла нет. Но это такая проблема, которая точно найдёт решение: наверно, каждый разработчик предложит способ, как избежать копипасты при объявлении 2х классов, если у них должны быть одинаковые поля.

Но переиспользовать какие-либо методы между этими классами (синхронным и асинхронным), наверно, смысла нет. Наследовать друг друга они тоже не должны.

В одном классе метод save будет синхронным, в другом — асинхронным:

async def save(self, **keywords)

На этом я описание закончу. Вероятнее всего, у нас будет 2 базовых класса модели — синхронный и асинхронный. Синхронный класс нельзя будет использовать в асинхронном контексте, и наоборот (всё будет вылетать с эксепшном очень быстро).

Таймлайн

Первой появится асинхронная версия. Прямо в стадии альфа: будут баги, но API будет стабилен, как скала. Версия будет двигаться в сторону беты и релиза.

В какой-то момент выйдет синхронная версия. Здесь уже нужны некоторые требования относительно качества и совместимости. К тому же, её выход никто и ничто не торопит: есть традиционный django, пользуйтесь им. В более-менее отдалённой перспективе синхронная и асинхронная версии обеспечиваются (и развиваются) одновременно.

Что касается собственно таймлайна, выход первой версии можно оценить в несколько месяцев.

Ну и ещё некоторые детали: конечно, проект будет отдельным пакетом в PyPI c другим названием (не django). Я ещё не определился: может быть, «remake»? Он будет иметь очень похожую структуру, то есть одним из типичных импортов будет `remake.db.models`. По моему, не очень смотрится, нужно что-нибудь более удачное.

И так очень много букв получилось, жду вашей реакции, господа.

© Habrahabr.ru