Здравствуйте, дорогие бэкенд-разработчики на Python, у меня опять статья про django. И так будет до тех пор, пока в нём не появится нормальная поддержка асинхронности (шучу).
Вопреки распространённому мнению, что django - это фреймворк "с батарейками", но не очень поддающийся кастомизации, это не так. Необходимость поддержки разных провайдеров, поддержка так называемого multi-db (одновременное использование нескольких баз), да и просто банальное использование здравого смысла (местами) - сделало этот фреймворк одним из наиболее расширяемых среди ORM.
В этой статье я расскажу, как устроен database backend - это та штука, которая отвечает за поддержку конкретной базы данных и конкретного драйвера для неё. Я сделаю это на довольно экзотическом примере: мы с вами добавим поддержку асинхронного драйвера - psycopg3. Да, для постгрес - иначе причём здесь были бы слоны? Да, асинхронного. Или вы думаете, что django так не умеет? Читайте и убедитесь сами.
В общем, интрига - это не мой конёк, так что хочу сразу рассеять весь туман над асинхронностью. Дело в том, что я использую похожий подход, что и sqlalchemy, именно - greenlet. Я уже писал о нём на хабре, также об этом можно прочитать здесь. В общем, это такая древняя магия, которую поставили на службу производству.
Так вот, в силу этого выбранного подхода, написание бэкенда для асинхронного драйвера не очень уж отличается от написания оного для синхронного случая. В качестве драйвера я выбрал psycopg3: у него более дружелюбный API, близкий к DB-API2. Одиозный же asyncpg не следует ему совершенно, хотя по производительности и выигрывает. Кстати, упомянутый подход с гринлетами позволяет сделать асинхронный драйвер тоже совместимым с DB-API2 - что я и сделал.
Есть, конечно, и принципиальные отличия асинхронного бэкенда от синхронного, точнее, одно отличие. В синхронном случае, каждый поток обрабатывает в данный момент времени всего один запрос. За ним закреплено определённое подключение к базе - коннекшн, и он им пользуется. В асинхронном же случае, одновременно обрабатывается большое число запросов - нельзя заранее сказать, какое. Далеко не факт, что мы сможем каждому такому обработчику предоставить по коннекшну - их просто не хватит. Вместо этого, нам нужно использовать пул коннекшнов, и выдавать их оттуда во временное пользование (на время одной транзакции).
Этого несложно добиться. Например, как в джанговских бэкендах обычно используется коннекшн? Вот так:
with connection.cursor() as cursor:
# use cursor
Для асинхронного бэкенда, можно делать то же самое, но по закрытии курсора коннекшн возвращать в пул. Обязательно нужно использовать контекстный менеджер, чтобы гарантированно вернуть коннекшн.
Кстати, с названиями в django есть явная путаница. Вот, например, как получить дефолтный коннекшн? Вы, возможно, думаете, что так:
from django.db import connection, connections
connection # <- это прокси-объект
connections['default'] # <- вот к этому
Так вот, ничего подобного, это database backend, а не коннекшн. Сам коннекшн находится в одноимённом атрибуте:
connection.connection
Так что, не перепутайте. Ещё встаёт вопрос об автокоммите. Дело в том, что, если мы всегда берём коннекшн на время и точно знаем, когда мы его отдадим, то зачем нам автокоммит? Мы гарантированно сможем сделать коммит сами. Вообще, мне кажется, автокоммит - одна из неудачных идей в django.
В общем, об этом, наверно, можно говорить ещё долго, а у нас есть репозиторий с кодом - вот он. В директории pgbackend лежит - Вы, наверно, догадываетесь, что. В директории proj - тестовый проект django, в kitchen - тестовый django-app.
Код рабочий - можете запустить и проверить, в README есть инструкции. Извиняйте,что в зависимостях для django указан гит-репозиторий - дело в том, что нужна ветка с поддержкой psycopg3, а её ещё не вмерджили в main (но скоро вмерджат!)
Так вот, если Вы не догадались, директория pgbackend - это наш бэкенд. Мало-мальски продвинутые джангонавты знают, что там нужно искать модуль base - он там есть. Открыв его, вы увидите, что он почти пуст: прописан ops_class, в котором указан модуль с "компиляторами", в конструкторе происходит создание коннекшна - и больше ничего.
Отлично, если в модуле base.py почти ничего нет - смотрим, есть ли где-нибудь что-нибудь ещё.
Я упомянул компиляторы. Если что, они "компилируют" query в SQL. Компиляторы - один из основных способов кастомизации бэкенда. Вообще, по секрету, весь бэкенд - это всего один метод в компиляторе - execute_sql. Так что, вообще говоря, заменить бэкенд достаточно легко - нужно заменить эту единственную функцию. На самом деле, компилятор не один, а на каждый тип запроса свой - для SELECT, INSERT, UPDATE и DELETE. Но это уже частности - в целом, бэкенд, как я уже говорил, легко поддаётся расширению. А за то, что, по сути, нужно переопределять всего одну функцию - плюсы в карму разработчикам django.
Раз я уже начал говорить про компиляторы - скажу об изменениях, которые там потребовались. В общем, есть в django такая фича, как QuerySet.iterator() - поддержка server-side cursors. Она позволяет отдавать результаты запроса (и выполнять его - тоже) по частям, чанками. Это может пригодиться, например,если результат запроса не помещается в память. Для пагинации использовать это вряд ли разумно, учитывая, что мы занимаем целый коннекшн на неопределённое время.
Так вот, я решил, что эта фича ни зачем не нужна, и решил её не поддерживать. По завершении execute_sql() я просто закрываю курсор и возвращаю коннекшн в пул. В общем, я уже сомневаюсь: скорее всего, я был неправ. Скорее всего - верну эту фичу на место.
В репозитории также можно найти код враппера вокруг курсора - я им пользуюсь, чтобы превратить асинхронный курсор в синхронный. После превращения, мой курсор вполне соответствует DB-API2.
Транзакции в django не очень предназначены для переопределения в бэкенде: это обычная функция atomic и класс Atomic. Всё равно, это не стало большим препятствием: посмотрите модуль patches - узнаете, почему.
Возможно, если поискать, то найдутся ещё интересные моменты. Ну, а в остальном - скучный кодинг, что тут рассказать?
Что касается самого бэкенда - он самый что ни на есть настоящий, предназначенный для продакшна. Не тестировался ещё - это правда, но это - следующий этап. Кстати, я могу использовать любые тесты из джанго-сюиты для тестирования своего бэкенда. Возможно, ещё своих добавлю. И - можно публиковать бета-версию. Разработчиков django только попросить не сломать его за это время... нет, это лишнее.