Здравствуйте, читатели хаба про 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`. По моему, не очень смотрится, нужно что-нибудь более удачное.

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

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


  1. SergeiMinaev
    20.11.2021 19:29

    Как раз недавно сталкивался с ограничениями джанговского ORM, который нельзя нормально юзать в асинхронных функциях - приходится делать обёртки из sync_to_async. Тогда подумалось - есть же ведь асинхронные ORM, так может их попробовать юзать вместо джанговского? Интересно, какие будут подводные камни и сработает ли такое вообще.


    1. random1st
      20.11.2021 19:39
      +9

      А зачем тогда собственно Django? Я вообще считаю, что Django как асинхронный фреймворк никакого смысла не имеет - излишнее усложнение и куча оберток. Есть великолепный FastAPI, который непрерывно наращивает количество батареек, есть SQLAlchemy.


      1. abetkin Автор
        20.11.2021 19:45
        +2

        Потому что основные концепции в django orm, например,тесамые__атрибуты__исвязи,никак не завязаны на синхронный или асинхронный способ выполнения кода


      1. abetkin Автор
        20.11.2021 20:04

        И, как мы знаем, SQLAlchemy - это не асинхронный фреймворк, его асинхронный экстеншн очень и очень своеобразен


      1. un1t
        20.11.2021 21:06
        -1

        У меня сложилось ощущение, что FastAPI это новый мэйнстрим. Django уже походу умирает.

        P.S. Я пока еще не успел поработать с FastAPI


      1. SergeiMinaev
        21.11.2021 06:32

        А зачем тогда собственно Django?

        В джанге много приятных мелочей, которые удобны и хорошо работают.


  1. dd84ai
    20.11.2021 20:46
    +3

    Хмык, а в следующем месяце, в декабре, официальная версия между прочим выходит с поддержкой асинхронной ORM. Django 4


    1. NerVik
      21.11.2021 08:26
      +1

      Глянул только что релиз, не нашёл там ничего про асинхронную орм


      1. dd84ai
        21.11.2021 09:09

        Как минимум подключения к кешируемым NoSQL базам данным уже в следующем релизе, а вот ORM похоже позже ток будет, перенесли увы эти планы.

        >>> Cache

        • The new async API for django.core.cache.backends.base.BaseCache begins the process of making cache backends async-compatible. The new async methods all have a prefixed names, e.g. aadd()aget()aset()aget_or_set(), or adelete_many().

          Going forward, the a prefix will be used for async variants of methods generally.


        1. dopusteam
          21.11.2021 11:16

          Названия, конечно, странные

          aadd, почему не add_async или async_add? Это в питоне так принято?


    1. SergeiMinaev
      21.11.2021 08:49
      +1

      А откуда инфа про асинк ORM? Я не смог найти про это в release notes.


      1. dd84ai
        21.11.2021 09:14

        цитата со страницы про Async Support:

        >>> We’re still working on async support for the ORM and other parts of Django. You can expect to see this in future releases. For now, you can use the sync_to_async() adapter to interact with the sync parts of Django. There is also a whole range of async-native Python libraries that you can integrate with.

        Похоже нам это примерно в 4.1+ ожидать ток


      1. abetkin Автор
        21.11.2021 20:52

        В release notes это сложно найти. Как я написал в начале статьи, об async orm даже речи не идёт, чтобы её делать. Вот это последние подвижки, которые были в этом направлении https://github.com/django/django/pull/14843. Что же касается этого проекта, то несомненно асинхронный джанго очень нужен тем, кто его уже использует, а для меня полезное упражнение и галочка в портфолио


  1. MikesoWeb
    21.11.2021 14:53
    +1

    Moscow Python уже обсудили ассинхронный Django.

    Он не нужен.


    1. telpos
      21.11.2021 21:52
      +1

      Админка удобная


  1. Megadeth77
    21.11.2021 16:37

    FastAPI плюс орм не орм sql монга что хочешь (для монги вот сам велосипед попиливаю https://github.com/AntonOvsyannikov/pymotyc). Пора ветерану на заслуженную пенсию. С другой стороны в мире кровавого ентерпрайза (внезапно это не только ява) — Django стандарт де факто.