Django Channels: работа с WebSocket и не только
Эпоха перемен
За последнее время благодаря активному развитию технологий, веб значительно преобразился. Буквально один десяток лет назад, всё что у нас было это несложные динамические веб-страницы с перезагрузкой при каждом запросе к серверу. Позже пришел Ajax, принёсший немало головной боли для программистов и пользователей (в основном из-за слабого канала, разных браузеров и кривых рук самих программистов). Идут годы, а тем временем запросы пользователей растут, инструменты находятся в постоянном режиме совершенствования с целью удовлетворения растущих потребностей конечных пользователей. Сейчас диву даёшься - веб-сайты превращаются в полноценные интерактивные приложения, способные практически полностью заменять своих настольных собратьев (Microsoft Word против Google Docs, например), появляются 3D-игры, мощности браузерных движков растут как на дрожжах. Современный браузер способен определять геолокацию, работать со звуком, камерой, 3D изображением и многое-многое другое. Не за горами время, когда единственным полезным приложением внутри операционной системы будет браузер.
Фреймворк Django появился в далёком 2003 году, то есть 13 лет назад. За эти годы многое изменилось. Сейчас Django является самымпопулярным веб-фреймворком в экосистеме Python. И это не просто так: поразительное качество документации, частота релизов ошеломляет, очень сильное сообщество разработчиков, активная поддержка со стороны коммерческих и некоммерческих организаций. Чтобы оставаться "на плаву" и быть актуальным инструментом, необходимо реагировать на вызовы рынка. И одним из таких вызовом для фреймворка является появление WebSocket и HTTP 2.
Задача WebSocket и HTTP 2 заключается в улучшении пользовательского взаимодействия (user experience) с веб-приложением путем сокращения задержек в передаче данных (уменьшение объема передаваемых данных, установка постоянного соединения с сервером). Django создавался по старой модели HTTP версии 1.1 "запрос-ответ": браузер посылает запрос серверу, тот в свою очередь его анализирует и отправляет ответ назад. Каждый запрос содержит в себе всю необходимую информацию: заголовки, куки, данные и так далее. С ростом числа пользователей и обилия передаваемой информации, сокращение объёма передачи данных приносит колоссальную экономию ресурсов и одновременно увеличивает производительность систем. WebSocket существует уже достаточно длительное время, за этот период разработчики придумали массу способов работы с этим протоколом даже в тех ситуациях, когда браузеры клиентов вовсе его не поддерживали (Flash, Ajax). Для создания так называемых real-time веб-приложений на Python, предпочтения в таких случаях отдавали далеко не Django, а фреймворкам вроде Tornado, Twisted, построенным на асинхронных подходах (событийно-ориентированных). Но настал момент, когда используя Django можно работать с WebSocket. Виновником этого события является ещё одна батарейка для Django под названием Channels, созданная небезызвестным Andrew Godwin (к слову, автором пакета South).
Что такое Channels?
Django Channels привносит в привычную модель работы Django новый концепт, а именно ориентированность на события. Вместо оригинальной модели по типу запрос-ответ, фреймворк реагирует на ряд событий, попадающих в тот или иной канал, который "просматривается/прослушивается" обработчиками событий (проще говоря, процессами, вызывающими те или иные функции, workers). Раньше для того, чтобы изменить любой HTTP запрос "на лету", необходимо было вмешиваться в цепочку Django Middleware, сейчас же HTTP запрос от браузера это событие, попадающее в канал http.request. Достаточно "повесить" на него прослушку, тем самым изменив его поведение должным образом (немного позже я опишу этот процесс для наглядности).
Изнутри Channels это классическая очередь задач (вроде Celery), использующая Redis в качестве прослойки для коммуникации между теми кто создаёт события (producers) и теми, кто их выполняет (workers). Redis в данном случае является необязательным условием, можно написать и своё решение. Кстати, стоит отметить, что релиз Django 1.10 уже будет иметь в своей кодовой базе пакет Channels, он скорее всего попадёт в django.channels (релиз назначен на август 2016 года). Сейчас же Channels поддерживает работу с версиями Django >= 1.8.
Ввиду того, что фреймворк работает в синхронном стиле, задача по его переписыванию на асинхронный лад является практически невозможной. В связи с этим, дабы упростить процесс написания кода (к слову, код пишется всё в том же привычном синхронном стиле), а также обработку long-polling соединений, появилась необходимость разделить привычный механизм запрос-ответ на 3 уровня.
- Уровень интерфейса. Это обработчики привычных нам протоколов взаимодействия между приложением и сервером, например WSGI, WebSocket.
- Уровень канала: проще говоря, брокер. В качестве данного уровня могут выступать Redis, SQL база данных, область памяти или любой собственный велосипед.
- Уровень обработчиков: процессы, следящие за поступлением сообщений в канал (очередь) и реагирующие на них тем или иным образом (обычно вызовом соответствующих функций-обработчиков).
Вот как Jacob Kaplan-Moss проиллюстрировал эту схему:
Модель работы сильно напоминает Celery. И это действительно так, Channels способен заменить Celery, но с некоторыми оговорками о которых речь пойдёт в конце статьи.
Теория и описание механизма с высоты птичьего полёта это хорошо, конечно, но пора бы приступить к практике.
Установка Channels
В качестве быстрой демонстрации возможностей работы с Channels, приведу пример перехватчика HTTP запросов. Но для начала нам необходимо поставить пакет.
Тут у нас всё просто.
$ pip install channels
Далее в новом Django проекте прописываем пакет в настройках:
INSTALLED_APPS = [
...
'channels',
]
...
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'asgiref.inmemory.ChannelLayer',
'ROUTING': 'myproj.routing.channel_routing',
},
}
Рядом с settings.py создаём routing.py со следующим содержимым:
from channels.routing import route
channel_routing = [
route('http.request', 'myapp.consumers.http_request_consumer')
]
Настройки routing.py чем то напоминают CELERY_ROUTES, где мы указываем очередь в которую должна падать задача (task). Здесь же мы указываем обработчика заданного канала (http.request обрабатывается функцией http_request_consumer).
myapp/consumers.py:
from django.http import HttpResponse
from channels.handler import AsgiHandler
def http_request_consumer(message):
response = HttpResponse('Hello world! You asked for %s' % message.content['path'])
for chunk in AsgiHandler.encode_response(response):
message.reply_channel.send(chunk)
Запускаем приложение:
$ python manage.py runserver
При переходе на любую ссылку, видим перехваченный запрос. Обработчики сообщений (consumers) принимают первым аргументом message (channels.message.Message). Не вдаваясь в подробности, это структура, хранящая в себе всю необходимую информацию об отправителе, канале и данных, пришедших к нам.
- content: содержимое сообщения (в нашем случае информация о запросе) типа dict.
- reply_channel: структура типа Channel. Содержит в себе "адресат отправителя" по которому нужно доставить ответ, либо None, если таковой отсутствует.
Любопытства ради, советую подключить отладчик и изучить содержимое message. Если внимательно приглядеться к коду, то можно заметить класс AsgiHandler, помимо него также существует AsgiRequest. Если мы хотим получить из message стандартный django request, то выполняем:
request = AsgiRequest(message)
AsgiHandler же нужен для обратной конвертации в message. Более подробную информацию можно получить, изучив спецификацию ASGI (Asynchronous Server Gateway Interface). Ссылка находится внизу.
Пишем real-time Django app
Простейший вариант, основанный на перехвате события http.request, мы изучили, пора бы теперь применить знания во благо. Самое простое, что приходит в голову при фразе "приложение реального времени" это чат (да-да, звучит банально). Готовый код приложения вы можете как всегда найти в моём репозитории на гитхаб.
В общем чате все участники видят сообщения друг друга. Ранее было показано как отправлять сообщение адресату, используя reply_channel, сейчас же мы поступим немного иначе.
chat/consumers.py
import json
from channels.channel import Group
def ws_connect(message):
Group('chat').add(message.reply_channel)
def ws_message(message):
Group('chat').send({'text': json.dumps({'message': message.content['text'],
'sender': message.reply_channel.name})})
def ws_disconnect(message):
Group('chat').discard(message.reply_channel)
blog_channels/routing.py
from channels.routing import route
channel_routing = {
'websocket.connect': 'chat.consumers.ws_connect',
'websocket.receive': 'chat.consumers.ws_message',
'websocket.disconnect': 'chat.consumers.ws_disconnect',
}
websocket.* - стандартные события Channels при использовании WebSocket соединения. Обратите внимание на Group, это простейшая структура данных, хранящая в себе адреса получателей/клиентов.
Вкратце алгоритм таков:
- Когда происходит соединение по веб-сокету, мы добавляем нашего клиента в группу chat.
- Когда один из участников группы посылает данные по веб-сокету (сообщение), мы транслируем сообщение по всей группе.
- Когда соединение по веб-сокету прерывается, то участник удаляется из группы.
Вот как выглядит JavaScript код для работы чата (полный код шаблона можно посмотреть в репозитории):
templates/chat.html
$(document).ready(function(){
var msgArea = $('#msgArea')
var elementMessage = $('#message')
var webSocket = new WebSocket('ws://' + window.location.host + '/chat/index');
webSocket.onmessage = function(message) {
var data = JSON.parse(message.data)
msgArea.append('<p><strong>'+ data.sender + '</strong>: ' + data.message + '</p>')
}
$('#btnSubmit').click(function(e) {
webSocket.send(elementMessage.val())
})
})
Вот как выглядит общение через 2 открытых браузера.
Я не стал заморачиваться с никами ;) и поэтому в качестве "имён" отправителей указываю их так называемый адресат (reply_channel).
Убийца Celery?!
Channels можно использовать в качестве системы очередей задач, более того, работать это скорее всего будет быстро, но есть одно большое НО... Channels не гарантирует выполнение задачи, так как отсутствует failover/retry механизм. Вот что говорит по этому поводу автор:
Channels’ design is such that anything is allowed to fail - a consumer can error and not send replies, the channel layer can restart and drop a few messages, a dogpile can happen and a few incoming clients get rejected.
Существует компромисс между скоростью и надёжностью. Channels выбрал скорость. Если есть необходимость в надёжности, то можно "докрутить" гайки поверх Channels или использовать проверенные инструменты вроде Celery (привет, RabbitMQ!).
Вкратце продемонстрирую как выполнять задачи на примере отправки письма (полный код в том же репозитории):
Дабы не заморачиваться с SMTP сервером, поменяем Django email backend на консольный (все "письма" будут сыпаться в stdout). Для этого необходимо задать настройки в settings.py:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
chat/consumers.py:
def send_email_consumer(message):
payload = message.content['payload']
send_mail(payload['subject'], payload['body'], 'root@localhost', [payload['email']],
fail_silently=False)
blog_chat/routing.py:
channel_routing = {
'websocket.connect': 'chat.consumers.ws_connect',
'websocket.receive': 'chat.consumers.ws_message',
'websocket.disconnect': 'chat.consumers.ws_disconnect',
'send_email': 'chat.consumers.send_email_consumer'
}
И наша вьюшка с формой из которой будут "улетать" письма счастья:
chat/views.py:
class MailMeView(View):
def get(self, request):
return render(request, 'mailme.html', {'form': MailMeForm()})
def post(self, request):
form = MailMeForm(request.POST)
if form.is_valid():
Channel('send_email').send({'payload': form.cleaned_data})
return HttpResponse('Good job!')
else:
return render(request, 'mailme.html', {'form': form})
chat/forms.py:
from django import forms
class MailMeForm(forms.Form):
subject = forms.CharField(max_length=100)
body = forms.CharField(widget=forms.Textarea)
email = forms.EmailField()
Проходим по адресу http://localhost:8000/chat/email, отправляем сообщение и видим в консоли:
Чтобы убедиться, что основной процесс обрабатывающий HTTP запрос не блокируется при отправке письма, достаточно добавить длительный sleep в функцию-consumer send_email_consumer.
Deploy, scale and conquer
В настройках приложения в качестве брокера я задал память процесса.
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'asgiref.inmemory.ChannelLayer',
'ROUTING': 'blog_channels.routing.channel_routing',
},
}
Этот вариант неплох для тестов на локальной машине, но совсем не годится, когда мы идём в продакшен, более того такой подход является немасштабируемым. Продакшен решением является использование Redis, но для этого предварительно необходимо поставить пакет asgi_redis.
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'asgi_redis.RedisChannelLayer',
'CONFIG': {
'hosts': [('localhost', 6379)],
},
'ROUTING': 'blog_channels.routing.channel_routing',
},
}
При установке channels, стандартная команда runserver немного модифицируется. Как я ранее уже упоминал, channels вносит изменения в привычную модель работы Django, вводя разделение между обработчиками запросов и обработчиками событий в очередях (более того, коммуникация теперь происходит через ASGI). В связи с этим деплой приложения немного отличается от привычного. Для обработки HTTP/WebSocket необходим ASGI совместимый веб-сервер. На данный момент с Channels поставляется веб-сервер Daphne, написанный всё тем же Andrew Godwin. Daphne основан на сетевом фреймворке Twisted и имеет не так много кода (слава небесам). Помимо веб-сервера, нам необходимо также запускать воркеров (их количество уже зависит от нагрузки на сайт и настраивается индивидуально), ответственных за обработку событий (http.request, websocket.connect и так далее).
Добавляем вот такой файл с именем asgi.py в проект:
blog_channels/asgi.py:
import os
import channels.asgi
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'blog_channels.settings')
channel_layer = channels.asgi.get_channel_layer()
В версии Django 1.10 он скорее всего будет создаваться автоматически при инициализации django-проекта.
Стартуем веб-сервер в консоли:
$ daphne blog_channels.asgi:channel_layer --port 8000
и запуск воркера:
$ python manage.py runworker
Заключение
Стоит признать, что в текущем виде Channels ещё не готов для развёртывания на продакшене, но к моменту выхода Django 1.10 все проблемы должны быть решены (включая возможное появление новых ASGI серверов). Нативная поддержка WebSocket и в скором времени HTTP2 это серьёзный шаг в развитии фреймворка. Более того, архитектура, выбранная автором пакета, также позволяет использовать Channels в качестве инструмента исполнения фоновых задач и не только. Я описал лишь небольшую часть возможностей Channels, остальное же оставлю вам для самостоятельного изучения в свободное время.
P.S. 25/08/2016: Написал небольшое описание нововведений в Django Channels.
UPDATE: В Telegram не так давно я открыл канал для разработчиков, где стараюсь делиться интересным и полезным материалом на тему программирования, методологий и разработки программного обеспечения. Подписывайтесь, буду рад всем!
Полезные ссылки
- Документация по Channels
- Спецификация ASGI
- Моя статья про работу с Celery
- Pull request в master проекта Django
- Daphne - Django Channels HTTP/WebSocket server
- Обсуждение ASGI в Python mailing list
- Репозиторий с кодом