Рубрики
Без рубрики

3 эффективных примера асинхронных представлений Django без сна

Автор оригинала: Arun Ravindran.

В августе этого года появился Django 3.1 с поддержкой асинхронных представлений Django. Это была фантастическая новость, но большинство людей подняло очевидный вопрос – что я могу с этим сделать? Было несколько руководств по асинхронным представлениям Django, которые демонстрируют асинхронное выполнение при вызове asyncio.sleep . Но это привело лишь к уточнению популярного вопроса – что я могу с ним делать, кроме сна ?

Короткий ответ – это очень мощный метод написания эффективных представлений. Чтобы получить подробный обзор того, что такое асинхронные представления и как их можно использовать, продолжайте читать. Если вы новичок в поддержке асинхронного режима в Django и хотите узнать больше, прочтите мою предыдущую статью: Руководство по ASGI в Django 3.0 и его производительности.

Асинхронные представления Django

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

def index(request):
    return HttpResponse("Made a pretty page")

Он принимает объект запроса и возвращает объект ответа. В реальном проекте представление делает много вещей, например, выборку записей из базы данных, вызов службы или рендеринг шаблона. Но работают они синхронно или поочередно.

В архитектуре Django MTV (Model Template View) представления непропорционально мощнее других (я считаю его сопоставимым с контроллером в архитектуре MVC, хотя эти вещи спорны). После входа в представление вы можете выполнить практически любую логику, необходимую для создания ответа. Вот почему так важны асинхронные представления. Это позволяет вам делать больше вещей одновременно.

Написать асинхронное представление довольно просто. Например, асинхронная версия нашего минимального примера выше будет:

async def index_async(request):
    return HttpResponse("Made a pretty page asynchronously.")

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

Обратите внимание, что это конкретное представление ничего не вызывает асинхронно. Если Django работает в классическом режиме WSGI, то для запуска этой сопрограммы создается новый цикл событий (автоматически). Так что в этом случае он может быть немного медленнее, чем синхронная версия. Но это потому, что вы не используете его для одновременного выполнения задач.

Так зачем тогда писать асинхронные представления? Ограничения синхронных просмотров становятся очевидными только в определенном масштабе. Когда дело доходит до крупномасштабных веб-приложений, вероятно, ничто не сравнится с FaceBook.

Просмотры в Facebook

В августе Facebook выпустил инструмент статического анализа для обнаружения и предотвращения проблем безопасности в Python. Но что бросилось в глаза, так это то, как мнения были записаны в примерах, которыми они поделились. Все они были асинхронными!

# views/user.py
async def get_profile(request: HttpRequest) -> HttpResponse:
   profile  load_profile(request.GET['user_id'])
   ...
 
# controller/user.py
async def load_profile(user_id: str):
   user  load_user(user_id) # Loads a user safely; no SQL injection
   pictures  load_pictures(user.id)
   ...
 
# model/media.py
async def load_pictures(user_id: str):
   query  f"""
      SELECT *
      FROM pictures
      WHERE user_id = {user_id}
   """
   result  run_query(query)
   ...
 
# model/shared.py
async def run_query(query: str):
   connection  create_sql_connection()
   result  await connection.execute(query)
   ...

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

Если задуматься, в этом есть смысл. Синхронный код может быть заблокирован при ожидании операции ввода-вывода в течение нескольких микросекунд. Однако его эквивалентный асинхронный код не будет связан и может работать с другими задачами. Следовательно, он может обрабатывать больше запросов с меньшими задержками. Больше запросов дает Facebook (или любому другому крупному сайту) возможность обрабатывать больше пользователей в той же инфраструктуре.

Иллюстрация

Полагаю, проблемы масштабируемости в 1800-х годах

Даже если вы не приблизились к достижению масштаба Facebook, вы можете использовать asyncio Python в качестве более предсказуемого механизма потоковой передачи для одновременного выполнения многих задач. Планировщик потоков может прерываться между деструктивными обновлениями общих ресурсов, что затрудняет отладку условий гонки. По сравнению с потоками сопрограммы могут достичь более высокого уровня параллелизма с очень меньшими накладными расходами.

Вводящие в заблуждение примеры сна

Как я шутил ранее, большинство руководств по асинхронным представлениям Django показывают пример, связанный со сном. Даже в официальных примечаниях к выпуску Django был такой пример:

async def my_view(request):
    await asyncio.sleep(0.5)
    return HttpResponse('Hello, async world!')

Для гуру асинхронного программирования Python этот код может указывать на возможности, которые ранее были невозможны. Но для подавляющего большинства этот код во многих отношениях вводит в заблуждение.

Во-первых, сон, происходящий синхронно или асинхронно, не имеет значения для конечного пользователя. Бедняге, который только что открыл URL-адрес, связанный с этим представлением, придется подождать 0,5 секунды, прежде чем он вернет нахальный «Hello, async world!». Если вы полный новичок, вы, возможно, ожидали немедленного ответа и каким-то образом приветствие «привет» появилось асинхронно через полсекунды. Конечно, это звучит глупо, но что же тогда пытается сделать этот пример по сравнению с синхронным time.sleep () внутри представления?

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

Во-вторых, вроде бы ничего полезного не дает. Некоторые интерфейсы командной строки используют спящий режим, чтобы дать пользователям достаточно времени, чтобы прочитать сообщение перед его исчезновением. Но для веб-приложений все наоборот – более быстрый ответ веб-сервера является ключом к лучшему пользовательскому опыту. Итак, замедляя реакцию, что мы пытаемся продемонстрировать на таких примерах?

Иллюстрация

Лучше дать им поспать

Лучшее объяснение таких упрощенных примеров, которое я могу дать, – это удобство . Требуется немного больше настроек, чтобы показать примеры, которые действительно нуждаются в асинхронной поддержке. Это то, что мы пытаемся здесь исследовать.

Лучшие примеры

Перед написанием асинхронного представления необходимо запомнить практическое правило: проверять, является ли оно привязано к вводу-выводу или зависит от ЦП . Представление, которое большую часть времени проводит в активности, связанной с процессором, например, умножение матриц или манипулирование изображениями действительно не выиграют от их переписывания в асинхронные представления. Вы должны сосредоточиться на действиях, связанных с вводом-выводом.

Вызов микросервисов

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

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

В этом случае нам также нужны результаты другого микросервиса, который решает, какие рекламные баннеры отображать пользователю в виде вращающегося баннера или слайд-шоу. Эти баннеры не адаптированы для вошедшего в систему пользователя, но меняются в зависимости от товаров, которые сейчас продаются (активная рекламная кампания) или даты.

Давайте посмотрим, как может выглядеть синхронная версия такой страницы:

def sync_home(request):
    """Display homepage by calling two services synchronously"""
    context  {}
    try:
        response  httpx.get(PROMO_SERVICE_URL)
        if response.status_code  httpx.codes.OK:
            context["promo"]  response.json()
        response  httpx.get(RECCO_SERVICE_URL)
        if response.status_code  httpx.codes.OK:
            context["recco"]  response.json()
    except httpx.RequestError as exc:
        print(f"An error occurred while requesting {exc.request.url!r}.")
    return render(request, "index.html", context)

Здесь вместо популярной библиотеки Python requests мы используем httpx , поскольку она поддерживает синхронные и асинхронные веб-запросы. Интерфейс практически идентичен.

Проблема с этим представлением заключается в том, что время, затрачиваемое на вызов этих служб, увеличивается, поскольку они происходят последовательно. Процесс Python приостанавливается до тех пор, пока не ответит первая служба, что в худшем случае может занять много времени.

Попробуем запустить их одновременно, используя упрощенный (и неэффективный) вызов await:

async def async_home_inefficient(request):
    """Display homepage by calling two awaitables synchronously (does NOT run concurrently)"""
    context  {}
    try:
        async with httpx.AsyncClient() as client:
            response  await client.get(PROMO_SERVICE_URL)
            if response.status_code  httpx.codes.OK:
                context["promo"]  response.json()
            response  await client.get(RECCO_SERVICE_URL)
            if response.status_code  httpx.codes.OK:
                context["recco"]  response.json()
    except httpx.RequestError as exc:
        print(f"An error occurred while requesting {exc.request.url!r}.")
    return render(request, "index.html", context)

Обратите внимание, что представление изменилось с функции на сопрограмму (из-за ключевого слова async def ). Также обратите внимание, что есть два места, где мы ждем ответа от каждой из служб. Вам не нужно пытаться понять каждую строчку здесь, мы объясним это на более хорошем примере.

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

Давайте посмотрим, как правильно запускать вещи одновременно:

async def async_home(request):
    """Display homepage by calling two services asynchronously (proper concurrency)"""
    context  {}
    try:
        async with httpx.AsyncClient() as client:
            response_p, response_r  await asyncio.gather(
                client.get(PROMO_SERVICE_URL), client.get(RECCO_SERVICE_URL)
            )

            if response_p.status_code  httpx.codes.OK:
                context["promo"]  response_p.json()
            if response_r.status_code  httpx.codes.OK:
                context["recco"]  response_r.json()
    except httpx.RequestError as exc:
        print(f"An error occurred while requesting {exc.request.url!r}.")
    return render(request, "index.html", context)

Если две службы, которые мы вызываем, имеют одинаковое время отклика, то это представление должно завершиться за _половину _времени по сравнению с синхронной версией. Это потому, что вызовы происходят одновременно, как мы хотели бы.

Попробуем разобраться, что здесь происходит. Существует внешний блок try… except для перехвата ошибок запроса при выполнении любого из HTTP-вызовов. Затем есть внутренний блок async… with, который дает контекст, имеющий клиентский объект.

Самая важная строка – это строка с вызовом asyncio.gather, принимающим сопрограммы, созданные двумя вызовами client.get . Вызов сборки выполнит их одновременно и вернется только тогда, когда они оба будут выполнены. Результатом будет кортеж ответов, который мы распакуем в две переменные response_p и response_r . Если ошибок не было, эти ответы заполняются в контексте, отправленном для рендеринга шаблона.

Микросервисы обычно являются внутренними для организации, поэтому время отклика невелико и менее изменчиво. Тем не менее, никогда не стоит полагаться исключительно на синхронные вызовы для связи между микросервисами. По мере увеличения зависимости между сервисами создаются длинные цепочки запросов и ответов. Такие цепочки могут замедлять работу сервисов.

Нам нужно решить проблему парсинга веб-страниц, потому что они используются во многих примерах asyncio. Я имею в виду случаи, когда несколько внешних веб-сайтов или страниц на веб-сайте одновременно извлекаются и извлекаются для получения такой информации, как текущие биржевые цены (или цены на биткойны). Реализация будет очень похожа на то, что мы видели в примере Microservices.

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

В идеале парсинг должен выполняться в отдельном процессе, который должен запускаться периодически (с использованием сельдерея или rq ). Представление должно просто брать извлеченные значения и представлять их пользователям.

Обслуживание файлов

Django решает проблему обслуживания файлов, стараясь не делать этого сам. Это имеет смысл с точки зрения принципа «Не изобретать велосипед». В конце концов, есть несколько лучших решений для обслуживания статических файлов, таких как nginx .

Иллюстрация

‘Одновременное обслуживание не для всех’

Но часто нам нужно обслуживать файлы с динамическим содержимым. Файлы часто находятся в (более медленном) дисковом хранилище (теперь у нас есть гораздо более быстрые SSD). Хотя эту файловую операцию довольно легко выполнить с помощью Python, она может быть дорогостоящей с точки зрения производительности для больших файлов. Независимо от размера файла, это потенциально блокирующая операция ввода-вывода, которая потенциально может быть использована для одновременного выполнения другой задачи.

Представьте, что нам нужно предоставить сертификат PDF в представлении Django. Однако по какой-то причине дата и время загрузки сертификата должны храниться в метаданных файла PDF (возможно, для идентификации и проверки).

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

async def serve_certificate(request):
    timestamp  datetime.datetime.now().isoformat()

    response  HttpResponse(content_type"application/pdf")
    response["Content-Disposition"]  "attachment;
    async with aiofiles.open("homepage/pdfs/certificate-template.pdf", mode"rb") as f:
        contents  await f.read()
        response.write(contents.replace(b"%timestamp%", bytes(timestamp, "utf-8")))
    return response

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

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

Обработка загрузок

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

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

async def handle_uploaded_file(f):
    async with aiofiles.open(f"uploads/{f.name}", "wb+") as destination:
        for chunk in f.chunks():
            await destination.write(chunk)


async def async_uploader(request):
    if request.method  "POST":
        form  UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            await handle_uploaded_file(request.FILES["file"])
            return HttpResponseRedirect("/")
    else:
        form  UploadFileForm()
    return render(request, "upload.html", {"form": form})

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

Где использовать

Одна из основных целей проекта Django Async – полная обратная совместимость. Таким образом, вы можете продолжать использовать свои старые синхронные представления, не переписывая их в асинхронные. Асинхронные представления не являются панацеей от всех проблем с производительностью, поэтому в большинстве проектов по-прежнему будет использоваться синхронный код, поскольку их довольно просто понять.

Фактически, вы можете использовать как асинхронные, так и синхронизированные представления в одном проекте. Django позаботится о том, чтобы вызвать представление соответствующим образом. Однако, если вы используете асинхронные представления, рекомендуется развернуть приложение на серверах ASGI.

Это дает вам возможность постепенно пробовать асинхронные представления, особенно при работе с интенсивным вводом-выводом. Вам нужно быть осторожным, выбирая только асинхронные библиотеки или тщательно смешивая их с синхронизацией (используйте async_to_sync и sync_to_async ).

Надеюсь, эта статья дала вам несколько идей.

Спасибо Чиллару Ананду и Ритешу Агравалу за просмотр этого сообщения. Все иллюстрации любезно предоставлены Иллюстрации старых книг