Наконец-то появился Django в реальном времени: начните работу с каналами Django

К моменту создания Django, более десяти лет назад, веб был менее сложным местом. Большинство веб-страниц были статичными. Веб-приложения в стиле Model/View/Controller, основанные на базе данных, были новой модной фишкой. Ajax едва начинал использоваться, и то лишь в узком контексте.

Веб в 2016 году стал значительно мощнее. В последние несколько лет наблюдается рост так называемого "веба реального времени": приложений с гораздо более высоким уровнем взаимодействия между клиентами и серверами и одноранговой связью. Приложения, состоящие из множества сервисов (так называемые микросервисы), стали нормой. А новые веб-технологии позволяют веб-приложениям двигаться в направлениях, о которых десять лет назад можно было только мечтать. Одной из таких технологий являются WebSockets: новый протокол, обеспечивающий полнодуплексную связь - постоянное, открытое соединение между клиентом и сервером, причем каждый из них может отправлять данные в любое время.

В этом новом мире Django показывает свой возраст. В своей основе Django построен на простой концепции запросов и ответов: браузер делает запрос, Django вызывает представление, которое возвращает ответ, отправляемый обратно браузеру:

Django asgi websockets

Это не работает с WebSockets! Представление существует только в течение одного запроса, и не существует механизма, позволяющего удерживать открытое соединение или передавать данные клиенту без соответствующего запроса.

Так: Django Channels. В двух словах, Channels заменяет "внутренности" Django - цикл запрос/ответ - на сообщения, которые передаются по каналам. Каналы позволяют Django поддерживать WebSockets способом, очень похожим на традиционные HTTP представления. Каналы также позволяют выполнять фоновые задачи, которые работают на тех же серверах, что и остальная часть Django. HTTP-запросы продолжают вести себя так же, как и раньше, но при этом маршрутизируются по каналам. Таким образом, в разделе Channels Django теперь выглядит следующим образом:

Django wsgi

Как видите, Channels вводит в Django несколько новых понятий:

Каналы по сути являются очередями задач: сообщения забрасываются на канал производителями, а затем передаются одному из потребителей, слушающих этот канал. Если вы использовали каналы в Go, то эта концепция должна быть вам хорошо знакома. Основное отличие заключается в том, что каналы Django работают через сеть и позволяют производителям и потребителям прозрачно работать на многих dynos и/или машинах. Этот сетевой уровень называется channel layer. Channels рассчитан на использование Redis в качестве предпочтительного канального слоя, хотя имеется поддержка других типов (и первоклассный API для создания собственных канальных слоев). Существуют аккуратные и тонкие технические детали; обратитесь к документации для получения полной информации.

На данный момент Channels доступен как отдельное приложение, работающее с Django 1.9. Планируется внедрить Channels в Django в релизе 1.10, который должен выйти летом этого года.

Я считаю, что Channels станут невероятно важным дополнением к Django: они позволят Django плавно перейти в новую эру веб-разработки. Хотя эти API еще не являются частью Django, скоро они станут таковыми! Так что сейчас самое время начать изучение Channels: вы сможете узнать о будущем Django еще до того, как оно наступит.

Начало работы: как сделать приложение для чата в реальном времени на Django

В качестве примера я создал простое приложение для чата в реальном времени - что-то вроде очень, очень легкого Slack. Есть несколько комнат, и все, кто находится в одной комнате, могут общаться друг с другом в реальном времени (используя WebSockets).

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

Примечание: после использования кнопки необходимо увеличить масштаб worker типа процесса. Используйте Dashboard или выполните heroku ps:scale web=1:free worker=1:free.

Для получения подробной информации о работе приложения - в том числе о том, зачем нужны worker диноскопы! - читайте дальше. Я расскажу о шагах, которые необходимо предпринять для создания этого приложения, и по ходу дела буду выделять важные моменты и концепции.

Первые шаги - это все еще Django

Хотя под капотом есть отличия, это все тот же Django, который мы используем уже десять лет. Поэтому начальные шаги такие же, как и для любого приложения на Django. (Если вы впервые знакомитесь с Django, вам стоит ознакомиться с Getting Started With Python on Heroku и/или Django Girls Tutorial). После создания проекта мы можем определить модель для представления чата и сообщений в нем (chat/models.py):

class Room(models.Model):
    name = models.TextField()
    label = models.SlugField(unique=True)

class Message(models.Model):
    room = models.ForeignKey(Room, related_name='messages')
    handle = models.TextField()
    message = models.TextField()
    timestamp = models.DateTimeField(default=timezone.now, db_index=True)

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

А затем представление (chat/views.py, и связанные с ним urls.py и chat/room.html шаблон):

def chat_room(request, label):
    # If the room with the given label doesn't exist, automatically create it
    # upon first visit (a la etherpad).
    room, created = Room.objects.get_or_create(label=label)

    # We want to show the last 50 messages, ordered most-recent-last
    messages = reversed(room.messages.order_by('-timestamp')[:50])

    return render(request, "chat/room.html", {
        'room': room,
        'messages': messages,
    })

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

Куда мы едем

Чтобы понять, что необходимо сделать на бэкенде, обратимся к коду клиента. Он находится в файле chat.js - и его не так уж много! Сначала мы создаем websocket:

var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws";
var chat_socket = new ReconnectingWebSocket(ws_scheme + '://' + window.location.host + "/chat" + window.location.pathname);

Обратите внимание, что:

Далее мы организуем обратный вызов, чтобы при отправке формы мы отправляли данные через WebSocket (а не POST):

$('#chatform').on('submit', function(event) {
    var message = {
        handle: $('#handle').val(),
        message: $('#message').val(),
    }
    chat_socket.send(JSON.stringify(message));
    return false;
});

Мы можем передавать по WebSocket любые текстовые данные. Как и для большинства API, проще всего использовать JSON, поэтому мы будем упаковывать данные в JSON и отправлять их таким образом.

Наконец, нам необходимо создать обратный вызов, который будет срабатывать при получении новых данных по WebSocket:

chatsock.onmessage = function(message) {
    var data = JSON.parse(message.data);
    $('#chat').append('<tr>' 
        + '<td>' + data.timestamp + '</td>' 
        + '<td>' + data.handle + '</td>'
        + '<td>' + data.message + ' </td>'
    + '</tr>');
};

Простая вещь: просто добавляем строку в нашу таблицу транскриптов, извлекая данные из полученного сообщения. Если я запущу этот код сейчас, то он не сработает - нет ничего, прослушивающего соединения WebSocket, только HTTP. Поэтому перейдем к подключению WebSockets.

Установка и настройка каналов

Для "канализации" этого приложения нам потребуется сделать три вещи: установить Channels, настроить канальный слой, определить канальную маршрутизацию, и модифицировать наш проект для работы под Channels (а не под WSGI).

1. Установить каналы

Чтобы установить Channels, просто pip install channels, затем добавьте "channels” в настройку INSTALLED_APPS. Установка Channels позволяет Django работать в "канальном режиме", заменяя цикл запрос/ответ на архитектуру, основанную на каналах, описанную выше. (Для обеспечения обратной совместимости можно по-прежнему запускать Django в режиме WSGI, но WebSockets и все остальные возможности Channel в этом режиме работать не будут)

2. Выберите слой канала

Далее необходимо определить канальный слой. Это транспортный механизм, который Channels использует для передачи сообщений от производителей (отправителей сообщений) к потребителям. Это тип очереди сообщений с некоторыми специфическими свойствами ( смотрите документацию по Channels).

В качестве канального слоя мы будем использовать Redis: он является предпочтительным канальным слоем производственного качества и очевидным выбором при развертывании на Heroku. Существуют также канальные уровни с поддержкой in-memory и базы данных, но они больше подходят для локальной разработки или использования с небольшим трафиком. (За более подробной информацией опять же обратитесь к документации.)

Но сначала: поскольку канальный слой Redis реализован в другом пакете, нам потребуется pip install asgi_redis. (О том, что такое ASGI, я расскажу чуть ниже). Затем мы определяем наш канальный слой в настройках CHANNEL_LAYERS:

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "asgi_redis.RedisChannelLayer",
        "CONFIG": {
            "hosts": [os.environ.get('REDIS_URL', 'redis://localhost:6379')],
        },
        "ROUTING": "chat.routing.channel_routing",
    },
}

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

3. Маршрутизация каналов

В CHANNEL_LAYERS мы рассказали Channel, где искать нашу маршрутизацию каналов - chat.routing.channel_routing. Маршрутизация каналов очень похожа на маршрутизацию URL: URL-маршрутизация сопоставляет URL с функциями просмотра; маршрутизация каналов сопоставляет каналы с потребительскими функциями. По аналогии с urls.py, канальные маршруты по умолчанию находятся в routing.py. Сейчас мы просто создадим пустой каталог:

channel_routing = {}

(Несколько маршрутов каналов мы рассмотрим ниже, когда будем подключать WebSockets)

Вы заметите, что в нашем примере есть и urls.py, и routing.py: мы используем одно и то же приложение для обработки HTTP-запросов и WebSockets. Это ожидаемо и типично: Приложения Channels остаются приложениями Django, поэтому все функции, которые вы привыкли использовать в Django - представления, формы, модели и т.д. - продолжают работать так же, как и до появления Channels.

4. Работа с каналами

Наконец, нам необходимо заменить обработчик запросов Django, основанный на HTTP/WSGI, на обработчик, встроенный в каналы. Он основан на развивающемся стандарте ASGI (Asynchronous Server Gateway Interface), поэтому мы определим этот обработчик в файле asgi.py:

import os
import channels.asgi

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chat.settings")
channel_layer = channels.asgi.get_channel_layer()

(В будущем Django, вероятно, будет автоматически генерировать этот файл, как это делается сейчас для wsgi.py.)

На этом этапе, если мы все сделали правильно, мы должны иметь возможность запускать приложение под каналами. Сервер интерфейса Channels называется Daphne, и мы можем запустить наше приложение с его помощью следующим образом:

$ daphne chat.asgi:channel_layer --port 8888

** Если мы теперь посетим http://localhost:8888/, то увидим.... ничего не произойдет. Это может показаться непонятным, пока вы не вспомните, что Channels разделяет Django на две части: внешний интерфейсный сервер Daphne и внутренние потребители сообщений. Поэтому для обработки HTTP-запросов нам необходимо запустить рабочий сервер:

$ python manage.py runworker

Теперь, запросы должны проходить. Это иллюстрирует довольно интересную вещь: Channels продолжает прекрасно обрабатывать HTTP(S) запросы, но делает это совершенно по-другому. Это не слишком отличается от работы Celery с Django, где вы запускаете Celery worker вместе с WSGI сервером. Однако теперь все задачи - HTTP-запросы, WebSockets, фоновые задачи - выполняются на рабочем сервере.

(Кстати, для удобства локального тестирования мы по-прежнему можем запускать python manage.py runserver. В этом случае Channels просто запускает Daphne и рабочий в одном процессе.)

Потребители WebSocket

Итак, достаточно настроек, перейдем к приятным моментам.

Channels сопоставляет WebSocket-соединения с тремя каналами:

  • В каналы websocket.connect отправляется сообщение при первом подключении нового клиента (т.е. браузера) через WebSocket. Когда это происходит, мы фиксируем, что клиент теперь "в" данном чате.

  • Каждое сообщение, отправленное клиентом по сокету, передается по каналу websocket.receive. (Это просто сообщения, полученные от браузера; помните, что каналы являются однонаправленными. Как отправлять сообщения обратно клиенту, мы рассмотрим чуть позже.) При получении сообщения мы будем транслировать его всем остальным клиентам в "комнате".

  • Наконец, когда клиент отключается, сообщение отправляется на адрес websocket.disconnect. Когда это произойдет, мы удалим клиента из "комнаты".

Сначала нам необходимо подключить каждый из этих каналов в routing.py:

from . import consumers

channel_routing = {
    'websocket.connect': consumers.ws_connect,
    'websocket.receive': consumers.ws_receive,
    'websocket.disconnect': consumers.ws_disconnect,
}

Довольно простая вещь: достаточно подключить каждый канал к соответствующей функции. Теперь давайте рассмотрим эти функции. По традиции мы поместим эти функции в consumers.py (но, как и представления, они могут находиться где угодно)

Сначала рассмотрим ws_connect:

from channels import Group
from channels.sessions import channel_session
from .models import Room

@channel_session
def ws_connect(message):
    prefix, label = message['path'].strip('/').split('/')
    room = Room.objects.get(label=label)
    Group('chat-' + label).add(message.reply_channel)
    message.channel_session['room'] = room.label

(Для наглядности я убрал из этого кода всю обработку ошибок и протоколирование. Полную версию можно посмотреть в consumers.py на GitHub).

Это плотно, давайте перейдем от строки к строке:

7. Клиент подключается к WebSocket с URL вида /chat/{label}/, где label соответствует атрибуту Room label. Поскольку все WebSocket-сообщения (независимо от URL) отправляются одному и тому же набору потребителей каналов, нам необходимо выяснить, о какой комнате идет речь, разобрав путь сообщения.

Обратите внимание, что потребитель отвечает за разбор пути WebSocket, читая message['path'] . Это отличается от традиционной маршрутизации URL, где Django urls.py маршрутизирует на основе пути; если у вас есть несколько URL WebSocket, вам нужно будет самостоятельно маршрутизировать к различным функциям. (Это одно из мест, где проявляются "ранние" аспекты Channels; вполне вероятно, что будущие версии Channels будут включать маршрутизацию WebSocket URL.)

8. Теперь мы можем искать объект Room в базе данных.

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

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

10. Наконец, последующие сообщения (получение/отключение) уже не будут содержать URL (поскольку соединение уже активно). Таким образом, нам необходим способ "запоминания", к какой комнате относится WebSocket-соединение. Для этого мы можем использовать сессию канала. Канальные сессии очень похожи на фреймворк сессий Django: они сохраняют данные между канальными сообщениями по атрибуту message.channel_session. Добавление декоратора @channel_session к потребителю - это все, что нам нужно для работы сессий. (В документации более подробно описано, как работают канальные сессии).

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

@channel_session
def ws_receive(message):
    label = message.channel_session['room']
    room = Room.objects.get(label=label)
    data = json.loads(message['text'])
    m = room.messages.create(handle=data['handle'], message=data['message'])
    Group('chat-'+label).send({'text': json.dumps(m.as_dict())})

(Еще раз повторю, что для наглядности я убрал обработку ошибок и протоколирование.)

Первые несколько строк довольно просты: извлекаем комнату из channel_session, ищем ее в базе данных, разбираем сообщение в формате JSON и сохраняем его в базе данных в виде объекта Message. После этого нам остается только транслировать это новое сообщение всем в чате, для чего мы будем использовать ту же группу каналов, что и раньше. Group.send() позаботится о том, чтобы отправить это сообщение каждому reply_channel, добавленному в группу.

После этого ws_disconnect очень просто:

@channel_session
def ws_disconnect(message):
    label = message.channel_session['room']
    Group('chat-'+label).discard(message.reply_channel)

Здесь, после просмотра комнаты в сеансе канала, мы отключаем (discard) reply_channel от чат-группы. Вот и все!

Развертывание и масштабирование

Теперь, когда WebSockets подключен и работает, мы можем протестировать его, запустив daphne и runworker, как указано выше (или запустив manage.py runserver). Но разговаривать с самим собой очень скучно, поэтому давайте посмотрим, что нужно сделать, чтобы запустить это на Heroku!

По большей части приложение Channels работает так же, как и приложение Python на Heroku - указываем требования в requirements.txt, определяем среду исполнения Python в runtime.txt, развертываем с помощью стандартных git push heroku master и т.д. (Для ознакомления смотрите учебник Getting Started with Python on Heroku). Я лишь выделю отличия приложения Channels от стандартного приложения Django:

1. Типы профилей и процессов

<<<Поскольку приложениям Channels необходим как HTTP/WebSocket-сервер , так и внутренний потребитель каналов, Procfile необходимо определить оба этих типа. Вот наш Procfile:

web: daphne chat.asgi:channel_layer --port $PORT --bind 0.0.0.0 -v2
worker: python manage.py runworker -v2

При первоначальном развертывании нам необходимо убедиться, что запущены оба типа процессов (по умолчанию Heroku запускает только web dyno):

$ heroku ps:scale web=1:free worker=1:free

(Простое приложение будет работать в пределах бесплатного или хобби-уровня Heroku, хотя для реального использования вы, вероятно, захотите перейти на производственный уровень, чтобы обеспечить более высокую пропускную способность)

2. Аддоны: Postgres и Redis

Как и большинству приложений на Django, вам понадобится база данных, и Heroku Postgres идеально подходит для этого. Однако Channels также требует наличия экземпляра Redis для работы в качестве канального слоя. Поэтому при первом развертывании приложения мы должны создать как экземпляр Heroku Postgres, так и экземпляр Heroku Redis:

$ heroku addons:create heroku-postgresql
$ heroku addons:create heroku-redis

3. Масштабирование

Поскольку система Channels является достаточно новой, проблемы масштабируемости еще не до конца известны. Однако я могу сделать несколько предположений, основываясь на архитектуре и некоторых ранних тестах производительности, которые я проводил. Ключевым моментом является то, что Channels разделяет процессы на те, которые отвечают за обработку соединений (daphne), и те, которые отвечают за обработку сообщений канала (runworker). Это означает, что:

  • Пропускная способность каналов - HTTP-запросов, WebSocket-сообщений или сообщений пользовательского канала - в основном определяется количеством рабочих дино. Поэтому, если необходимо обработать больший объем запросов, то это можно сделать, увеличив число рабочих дино (например, heroku ps:scale worker=3).
  • Уровень параллелизма - количество текущих открытых соединений - в основном будет ограничен масштабом front-end web dyno. Так, если необходимо обрабатывать большее количество одновременных WebSocket-соединений, то следует увеличить масштаб web dyno (например, heroku ps:scale worker=2)

Судя по моим ранним тестам производительности, Daphne вполне способен обрабатывать многие сотни одновременных соединений на дино Standard-1X, поэтому я ожидаю, что масштабирование веб-дино будет требоваться нечасто. Количество рабочих дино в приложении Channels, как мне кажется, довольно близко к количеству необходимых веб-дино в аналогичном приложении Django старого образца.

Что дальше?

Поддержка WebSocket - это огромная новая возможность для Django, но она лишь в общих чертах раскрывает возможности Channels. Помните: Channels - это утилита общего назначения для выполнения фоновых задач. Таким образом, многие функции, для которых раньше требовались Celery или Python-RQ, могут быть реализованы с помощью Channels. Channels не может полностью заменить выделенные очереди задач: у него есть ряд существенных ограничений, в том числе доставка "только один раз", которые не позволяют использовать его во всех случаях. Для получения более подробной информации обратитесь к документации. Тем не менее, каналы могут значительно упростить выполнение обычных фоновых задач. Например, с помощью каналов можно легко выполнять миниатюризацию изображений, рассылать электронные письма, твиты или SMS, выполнять дорогостоящие вычисления и т.д.

Что касается самих каналов: планируется включить каналы в Django 1.10, выход которого запланирован на лето. Это означает, что сейчас отличное время для того, чтобы опробовать его и дать обратную связь: ваш вклад может помочь определить направление развития этой важной функции. Если вы хотите принять в этом участие, ознакомьтесь с руководством Contributing to Django, а затем присоединяйтесь к списку рассылки django-developers, чтобы поделиться своими отзывами.

Наконец: огромная благодарность Andrew Godwin за его работу над Channels. Это чрезвычайно интересное новое направление развития Django, и я рад видеть, как оно начинает обретать форму.

Вернуться на верх