µDjango — технология создания асинхронных микросервисов

µDjango — технология для асинхронных микросервисов

µDjango — технология для асинхронных микросервисов

Примерно 5 годами ранее появления FastAPI была обнародована идеология построения легковесных микросервисов на Django, которая стала актуальной только после внедрения асинхронности в этом фреймворке.

Хотя в последней (на момент написания статьи) версии Django 5.0.5 асинхронно решается только часть задач, но уже сейчас можно начать создавать асинхронные микросервисы воспользовавшись технологией µDjango.

Ключевой идеей этой технологии является переиспользование частей существующего большого Django-проекта при изолированном выполнении одной и только одной µ-задачи для повышения надежности работы сервиса.

Django-проект с одним файлом .py

Django-проект с одним файлом .py

µDjango — это проект с одним  .py файлом и возможностью использовать весь функционал Django и всех её «батареек». Такой проект можно запускать как отдельно, так и в монолитах.

Ваши компоновка проекта может отличаться и включать разные необходимые файлы типа .gitignore, Dockerfile, README.md… Но к test.py и settings.py это не относится: причины их отсутствия объяснены ниже, но возможность тестирования проекта в Django-Style, разумеется, остается.

Подобный подход не является общепринятым, и в этой статье я рассматриваю случаи, когда его применение имеет смысл.

История становления технологии

Впервые технология описана в книге Lightweight-Django 2014 г. После эти идеи неоднократно поднимались на обсуждение в Django community, особенно в 2019 и 2023 гг.

Все началось, конечно, с появления Django, но первый явный шаг сделали Julia Solórzano и Mark Lavin в книге Lightweight-Django в 2014 г.

Lightweight-Django, O’Reilly

Первая глава так и называется «Самый маленький Django проект в мире». Эта книга в полной мере описывает технологию в применении к Django v. 1.x, а некоторые примеры кода включают использование Django в связке с Tornado и интересны для изучения сами по себе, без привязки к теме статьи.

Я лично узнал про эту технологию в 2015, когда начал работать над проектом winepad.at. Это data-lake с информацией про алкогольные напитки. Armin Wolf и Florian Ennmoser создали прототип этого проекта в Lightweight стилистике. Сегодня идеи low-weight все еще живут в winepad, хотя проект уже давно перешагнул 500 000 строк кода.

Позже, в 2019 г. я встретил эту идею, предположительно, в репозитории Кирилла Кленова, который использовал Lightweight-Djangо в тестах производительности различных python-web-framework.

После книги Lightweight-Django громким шагом в популяризации этой технологии, стало выступление Carlton Gibson на DjangoCon US 2019 и последующее обсуждение его доклада в Django-коммьюнити. В результате был создан репозиторий Django-microframework. Автор Will Vincent собрал все озвученные идеи в репозиторий, в котором код на 99% совпадает с кодом из книги 2014 г.

Завершающий этап этой истории произошел на DjangoCon US 2023, когда Paolo Melchiore представил свой проект µDjango, он добавил asynс-handler к коду django-microframework и предложил новое название этой технологии.

В отличие от книги, все обсуждаемые Django-сообществом примеры являются интерпретацией «hello world». Я решил исправить этот недостаток, и в этой статье привёл примеры из реальных проектов.

По истории становления технологии видно, что несколько инженеров в разное время в разном месте воспроизвели одинаковый код. А это знак, что сформировался паттерн, отвечающий запросам сообщества.

Руководство по созданию µ-Django проекта.

1. Создадим стандартный проект Django

Обычно это выполняется с помощью стандартной команды:

django-admin startproject name_of_my_project

Dont repeate yourself!

Dont repeate yourself!

В реальности после этой команды мы получаем какую-то шляпу в форме двух одноименных папок одна в другой.

Dont repeate yourself!

В дочерней папке лежат настройки. Так почему эту папку сразу не назвать, например, settings или configs? С Django напрямую так просто не не получится… хотя:

django-admin startproject settings && ren settings name_of_my_project

Красота же!

Красота же!

А вот так уже лучше.

Папка с названием проекта, а внутри папка с настройками.

Красота!

И DRY не нарушается.

2. Создадим наше первое приложение в Django-проекте

CLI команда

python manage.py startapp uapp

зачем мне все эти файлы?

зачем мне все эти файлы?

Выполнив команду получим папку, в ней будут созданы несколько .py файлов. И тут, как говорил тут один очень талантливый автор, Django у меня к тебе несколько вопросов:

• действительно ли нужен файл models.py для моделей, или нет?

• почему папка миграций создается сразу, а не в момент создания схем миграций?

• почему apps.py, содержащий один класс настроек, написан во множественном числе. А admin.py с несколькими администраторами моделей — в единственном?

• почему apps.py, а не, например, configs.py? Тем более, что внутри лежит класс AppConfig.

Так много вопросов… так мало ответов. Не спрашивайте, просто пользуйтесь, as is.

Кстати: AppConfig забытый, но чрезвычайно полезный класс для хранения и управления настройками отдельно взятого приложения, который, похоже, использую только я и еще один известный разработчик.

3. Начинаем создавать µDjango проект

Копируем все файлы в корень проекта, все, что пустое, удаляем.

4. Смешиваем manage.py, asgi.py и wsgi.py

Если изучить эти три файла, то можно заметить, что они состоят из двух действий:

  1. Защита от отсутствия переменной окружения.

  2. Запуск кода проекта в синхронном или асинхронном режиме.

Копируем всё в один файл, название файла может быть любым, я предлагаю main.py, этот файл будет «центром» вашего проекта.

#main.py
import os
from django.core.asgi import get_asgi_application as asgi
from django.core.wsgi import get_wsgi_application
from django.core.management import execute_from_command_line

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')

wsgi = lambda *args, **kwargs: get_wsgi_application()(*args, **kwargs)

if __name__ == "__main__":
    execute_from_command_line()

Обратите внимание, что wsgi-handler я сделал «ленивым», а asgi-handler вообще не стал инициализировать. Необходимый из них будет проинициализирован на старте автоматически.

На мой взгляд, вообще все, что происходит в вашем коде Python, стоит делать lazy, правда, подход к разработке и тестированию кода заметно меняется.

Мы избавились от пары файлов, а их в папке еще много! Продолжим. 

5. Смешиваем models.py и urls.py

Если посмотреть, как Django работает с моделями, то оказывается, что название файла не важно! Важно, что этот файл импортируется на старте. Если в этом файле будет класс унаследованный от models.Model, он автоматически зарегистрируется в django.apps.apps.all_models. Что, собственно, нам и необходимо, если мы хотим использовать этот класс позже в runtime. А какой файл точно импортируется в Django на старте? Тот, что указан в settings как источник для urlpatterns, обычно это urls.py
Копируем содержимое models.py в urls.py. и проверяем, как это работает.

#urls.py
from django.urls import path
from django.contrib import admin
from django.db import models

@admin.site.register
class MyModel(models.Model):
    class Meta:
        app_label = 'uDjango'

    name = models.CharField(max_length=100)

urlpatterns = [path('admin/', admin.site.urls)]

Меньше еще на один файл, финал близок

5. Заключительный шаг. Копируем содержимое urls.py в main.py

#main.py
import os
from django.core.asgi import get_asgi_application as asgi
from django.core.wsgi import get_wsgi_application
from django.core.management import execute_from_command_line
from django.urls import path
from django.contrib import admin
from django.utils.functional import SimpleLazyObject

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')

wsgi = lambda *args, **kwargs: get_wsgi_application()(*args, **kwargs)

urlpatterns = SimpleLazyObject(lambda:[path('admin/', admin.site.urls)])

if __name__ == "__main__":
    execute_from_command_line()

При копировании я по возможности делаю объекты ленивыми. В settings.py надо исправить, где теперь хранятся urslpatterns, и откуда вызывать wsgi-handler.

6. Тестовый запуск в режиме µ-проекта.


Запуск происходит в стандартном режиме, с использованием стандартных CLI-команд:

python main.py makemigrations
python main.py migrate
python main.py createsuperuser
python main.py runserver

Форма входа в админ-панель Django

Форма входа в админ-панель Django


Как вы видите, при переходе по http://localhost:8000/admin/ все работает, только выглядит как-то коряво.

Все потому, что я забыл добавить статику. В парадигме µDjango — это вообще-то не важно, но для текущего примера пригодится.

Кстати, бывает полезно, отключить css и посмотреть, как будет выглядеть ваш сайт, когда css не подгрузился.

Я вспоминаю раннее детство, когда все нормально выглядело и без css. Жаль, это таинственное знание теперь утеряно.

Делаем красиво, добавив на 14 строке предыдущего примера следующий код:

# main.py
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

Теперь после старта мы должны увидеть стандартный форму входа в админ-панель Django.

only three .py

only three .py

Собственно, это все.

Да, я умею считать, и у нас пока 3 .py файла.

Но прежде, чем я расскажу, в чем тут дело — сначала про применение этого микро фреймворка на практике.

Применение

Задача: получать из базы данных и сериализовывать объекты модели и выдавать их пользователям через API. Вариант реализации main.py, для решения задачи:

# main.py
from django.apps import apps ...

wsgi = lambda *args, **kwargs: get_wsgi_application()(*args, **kwargs)

class UserDetailView(BaseDetailView):
    model = SimpleLazyObject(lambda:apps.get_model("auth.user"))

    def render_to_response(self, *args, **kwargs):
        data = Json().serialize([self.object], **kwargs)
        return HttpResponse(data, content_type="application/json")

urlpatterns = SimpleLazyObject(lambda:[path('users//', view.as_view()])

if __name__ == "__main__":
    execute_from_command_line()

В качестве сериализатора я использовал Django serialization framework из коробки. Можете проверить самостоятельно, как это работает в сравнении с DRF-serializers. Передавая поля в GET запросе, можете управлять схемой сериализаци данных.

Уверен, что внимательный читатель заголовков может спросить:, а где тут async? Ответ в следующем примере:

# async_main.py
from django.urls import path ...

@lambda view: globals().setdefault('urlpatterns', []).append(path('users//', view.as_view()))
class AsyncUserDetailView(UserDetailView):

    async def get(self, request, *args, **kwargs):
        self.object = await self.get_object()
        return self.render_to_response(fields=('email','username'))

    async def get_object(self):
        pk = self.kwargs.get(self.pk_url_kwarg)
        return await aget_object_or_404(self.model, pk=pk)

# urlpatterns=[path('users//', AsyncUserDetailView.as_view()]

Как вы видите, я использую GCBV в качестве представлений, и эта часть Django вообще не готова для работы c async/await синтаксисом. Если вам, как и мне, нравятся представления на классах — переписывать придется много.

Еще одна странность в примере выше — декоратор класса для регистрации url. Я не рекомендую в Django-проектах использовать этот синтаксис, хотя пользователям быстроапи, флакона, или легкой звезды — такое привычнее. Стандартное для Django объявление urlpatterns в комментарии на 15-й строке.

Пару слов про «много». Важный элемент µDjango технологии — это ваша интуиция. Если в процессе работы вам захочется вынести часть кода в соседний файл типа utils.py или endpoints.py или whatever.py, значит вы уже вышли за пределы парадигмы µ-проекта. Другой подходящий сигнал — код µ-проекта вылазит за пределы одного экрана. В подобных случаях, вероятно, стоит отказаться от µDjango.

Тестирование

Файл тестов выглядит аналогично main.py (manage.py). Для большей осведомленности я привожу тест для async представления, надеюсь, что написание синхронных тестов не вызовет у вас никаких затруднений.

# tests.py
import main ...

if __name__ == "__main__":
    execute_from_command_line()

class TestsAsyncUserDetailView(TestCase):

    async def test_get_object(self):
        user_manager = django_apps.get_model("auth.user").objects.create_superuser
        user = await sync_to_async(user_manager)(username="admin")
        view = get_resolver().resolve('/ausers/1/').func.view_class(kwargs={'pk':user.pk})
        obj = await view.get_object()
        self.assertTrue(obj.is_superuser, msg="User should be a superuser")

Тесты запускаются стандартно:

python tests.py test --keepdb –-settings=test_settings

В примере использованы представления на классах, асинхронные functional-based представления тестируются аналогично.

О settings.py и tests.py

Попробуйте начать работать в проектах, сконцентрированных на методологии »12-Factor App», и вам быстро станет понятно: настройки проекта — это не часть проекта.

На тестовом стенде файл один, на продакшене другой, а на локали с настройками можно творить что угодно. Это здорово, что Django разрешает запускать проект с разными файлами настроек и через переменную окружения, и через командную строку.

вариант команды старта проекта с установкой переменной окружения:
set DJANGO_SETTINGS_MODULE=my_settings&& uvicorn "main:asgi”

вариант команды старта проекта с дополнительными атрибутами:
python main.py runserver --settings=my_settings

Убираем settings.py из проекта, это еще защитит его от попадания в репозиторий. Но не удаляем, а кладем отдельно. Обычно этот файл используется для нескольких µпроектов и потому еще и улучшим немного следование принципу DRY.

Организацией проекта, когда тесты лежат в отдельной от проекта папке, мне кажется, тоже мало кого можно удивить. В итоге тесты уезжают в другое место.

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

Запуск µ-Проекта

Запуск локально

Вы можете запустить один µ-Django проект локально для разработки:

set DJANGO_SETTINGS_MODULE=test_settings&& 
set PYTHONPATH=path/to/settings && 
python path/to/main.py runserver

Или как standalone µ-сервис непосредственно с помощью uvicorn:

set DJANGO_SETTINGS_MODULE=test_settings&& 
set PYTHONPATH=path/to/settings && 
uvicorn main:asgi --host 0.0.0.0 --lifespan=off --factory

Запуск в контейнере

пример Dockerfile в репозитории

docker build . -t udjango
docker run -it --rm -p 8000:8000  \
--mount type=bind,source=/path/to/prod_settings.py,target=/app/settings.py,readonly \
--env-file=/path/to/.env 

Запуск в режиме modular monolith.

Run µ-services in a bunch

Run µ-services in a bunch

У меня есть корневая папка с запускающим файлом main.py.

Все что делает этот файл проходит по дочерним папкам и собирает urlpaths из всех main.py дочерних папок. Сколько найдет — все запустит.

# main.py from root
from pathlib import Path ...

wsgi = lambda *args, **kwargs: get_wsgi_application()(*args, **kwargs)

def service_finder(filename=__file__):
    base = Path(filename).parent
    for service in base.glob('**/main.py'):
        if not service.samefile(filename):
            urlpaths = '.'.join(service.relative_to(base).parts).removesuffix('.py')
            yield path('', include(urlpaths))

urlpatterns = SimpleLazyObject(service_finder)

if __name__ == "__main__":
    execute_from_command_line()

CLI-Команда для закуска приведена выше. Вы так же можете запустить несколько проектов в отдельных контейнерах с помощью docker compose. Все зависит от вас.

А где же requirements?

Они в Dockerfile. One source of truth, так сказать. Для установки локально, вне докера, я использую fabrique, который парсит Dockerfile построчно и запускает установку зависимостей, если они найдены.

µ-Django в реальной жизни

  • На практике в medical-datalake я видел, как 412 µ-сервисов запущены по-отдельности. Мне нравилось, что если что-то падает, то просто выяснить в каком узле это произошло и быстро исправить, протестировать и выкатить в продакшн. И это объяснимо — в работе с медицинскими обследованиями задержек быть не должно.

  • В другом Django-проекте я познакомился как на µ-сервисах реализован CQRS паттерн в подсистеме обработки gps-координат судов (vessel tracker).

  • Предложенный вариант µ-сервисов отлично сочетается с Modular Monolyth Arhitecture. Очень рекомендую почитать серию постов 2019 г. от Kamil Grzybek

    messy monolith

    messy monolith

    Но есть область, где мне железно пригодилась µ-Django  технология— это SaaS платформы с хаотичными монолитами внутри.
    «messy monolith» — это состояние многих успешных IT-стартапов сразу после гартнеровской волны хайпа.

    Для таких проектов я написал небольшой скрипт, для вытаскивания кусков кода в µ-сервисы, и запуска их отдельно.

Обратите внимание, что в перечисленных случаях, при создании µ-Django сервисов вопрос быстродействия не стоял в приоритете: если вам действительно требуется высокая производительность —  вероятно, стоит переписать конкретный сервис с использованием другого языка программирования.

Итого.

Чтобы применить технологию  µ-Django на вашем Django-проекте — этого руководства достаточно.

За рамками этого руководства остались:

  1. Миграции моделей. На картинке выше видно, что µ-сервисы являются сателлитами большого проекта и не управляют схемой базы данных, хотя это возможно.

  2. Легкая организация репозитория. Тут все завязано на автоматизацию и использование шаблонов для быстрого создания микросервисов.

  3. Выкладка и управление всем этим на продакшене. Увы у меня мало информации по этому вопросу.

  4. Авто-документация микросервисов. Вероятно, мой следующий доклад будет именно про это.

В остальном — желаю вам много сервисов хороших и разных!

Подвал

Я благодарю мою дочь Майю, за иллюстрации к этой статье.

Special thanks to my daughter, Maja

Special thanks to my daughter, Maja

Ссылка на видеозапись доклада про µ-Django с PyCon DE 2024
Коды из статьи в репозитории
Эта статья является частью серии докладов «Управление сложностью в больших проектах», представленных на различных PyCon в 2024 г.
Если вы хотите еще больше узнать про сложность в программных проектах — вам помогут статьи Мартина Фаулера.

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

© Habrahabr.ru