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 проиллюстрировал эту схему:

django-wsgi

Модель работы сильно напоминает 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, это простейшая структура данных, хранящая в себе адреса получателей/клиентов.

Вкратце алгоритм таков:

  1. Когда происходит соединение по веб-сокету, мы добавляем нашего клиента в группу chat.
  2. Когда один из участников группы посылает данные по веб-сокету (сообщение), мы транслируем сообщение по всей группе.
  3. Когда соединение по веб-сокету прерывается, то участник удаляется из группы.

Вот как выглядит 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 открытых браузера.

Django Websocket

Я не стал заморачиваться с никами ;) и поэтому в качестве "имён" отправителей указываю их так называемый адресат (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, отправляем сообщение и видим в консоли:

Django Channels Async Task :)

Чтобы убедиться, что основной процесс обрабатывающий 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 не так давно я открыл канал для разработчиков, где стараюсь делиться интересным и полезным материалом на тему программирования, методологий и разработки программного обеспечения. Подписывайтесь, буду рад всем!

Полезные ссылки