Django Channels – ответ современному вебу

В мире Django набирает популярность дополнение Django Channels. Эта библиотека должна принести в Django асинхронное сетевое программирование, которое мы так долго ждали. Артём Малышев на Moscow Python Conf 2017 объяснил, как она это делает, зачем она это делает и делает ли вообще.

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

  • Twisted;
  • Eventlet;
  • Gevent;
  • Tornado;
  • Asyncio.


Казалось бы, зачем писать еще одну библиотеку и надо ли вообще.


О спикере: Артём Малышев независимый Python разработчик. Занимается разработкой распределённых систем, выступает на конференциях по Python. Артёма можно найти по никнейму @PROOFIT404 на Github и в социальных сетях.
Django синхронный по определению. Если мы говорим об ORM, то синхронно обратиться к базе во время attribute access, когда мы пишем, например, post.author.username, ничего не стоит.

К тому же Django — это WSGI фреймворк

WSGI


WSGI — это синхронный интерфейс для работы с веб-серверами.

def app (environ, callback) :
    status, headers = '200 OK', []
    callback (status, headers)
    return ['Hello world!\n']


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

Сделано это было давным-давно, в далеком 2003 году, когда web был простой, пользователи читали в интернете всякие новости, заходили в гостевые книги. Достаточно было просто принять запрос и обработать его. Дать ответ и забыть о том, что этот пользователь вообще был.
-a3elynsjri1d0qbc73yl256qbo.jpeg

Но, на секундочку, сейчас не 2003 год, поэтому пользователи хотят от нас намного большего.
-jfpnzpcfn0w2hpbvmcejswxnaw.jpeg
Они хотят Rich web application, живой контент, хотят, чтобы приложение работало замечательно на десктопе, на лэптопе, на других топах, на часах. Самое главное, пользователи не хотят нажимать F5, потому что, например, на планшетах нет такой кнопки.

bqgiql6zzob87g74yngfmwvkehs.jpeg

Веб-браузеры, естественно, идут нам на встречу — они добавляют новые протоколы и новые возможности. Если бы мы с вами разрабатывали только фронтенд, то мы просто брали бы браузер как платформу и использовали бы его core фичи, поскольку он готов их нам предоставить.

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

Именно эту проблему и пытается решить Django Channels для Django. Эта библиотека призвана дать вам возможность обрабатывать коннекты, оставив Django Core, к которому мы привыкли, абсолютно неизменным.

Сделал это замечательный человек Andrew Godwin, обладатель ужасного английского акцента, который говорит очень быстро. Он должен быть вам известен по таким штукам, как давно забытый Django South и Django Migrations, которые пришли к нам с версии 1.7. С тех пор, как он починил миграции для Django, он занялся тем, что начал чинить веб-сокеты и HTTP2.

Каким образом он это сделал? Давным-давно по интернету ходила такая картинка: пустые квадратики, стрелочки, надпись «Хорошая архитектура» — вписываете в эти квадратики свои любимые технологии, получаете сайт, который хорошо масштабируется.

r1rzz99ck74ggy5uy8vnn7kebks.jpeg

Andrew Godwin вписал в эти квадратики сервер, который стоит фронтом и принимает любые запросы, будь они асинхронные, синхронные, e-mail, что угодно. Между ними стоит так называемый Channel Layer, который принятые сообщения хранит в формате, доступном для пула синхронных воркеров. Как только ассинхронный коннект что-то нам прислал, мы записываем это в Channel Layer, а далее синхронный воркер может его забирать оттуда и обрабатывать точно так же, как это делает любая Django View или что угодно другое, синхронно. Как только синхронный код отправил обратно в Channel Layer ответ, асинхронный сервер будет его отдавать, стримить, делать все, что ему нужно. Тем самым производится абстракция.

Это подразумевает несколько реализаций и в продакшене предлагается использовать Twisted, как асинхронный сервер, который реализует фронтенд для Django, и Redis, который будет тем самым каналом общения между синхронным Django и асинхронным Twisted.

Хорошая новость: для того, чтобы использовать Django Channels, вам вообще не нужно знать ни Twisted, ни Redis — это все детали реализации. Это будут знать ваш DevOps, или вы познакомитесь, когда будете чинить в три часа ночи упавший продакшен.


ASGI


Абстракция — это протокол под названием ASGI. Это стандартный интерфейс, который лежит между любым сетевым интерфейсом, сервером, будь то синхронный или асинхронный протокол и вашим приложением. Основным его понятием является канал.

Channel


Канал — это, упорядоченная по принципу firt-in-first-out, очередь сообщений, которые обладают временем жизни. Эти сообщения могут быть доставлены ноль или один раз, и могут быть получены только одним Consumer«ом.

Consumers


В Consumer вы как раз пишете ваш код.

def ws_message (message) :
    message.reply_channel.send ( {
        'text': message.content ['text'],
} )


Функция, которая принимает message, может отослать несколько ответов, а может не отсылать ответ вообще. Очень похоже на view, единственная разница в том, что здесь нет функции return, тем самым мы можем говорить о том, сколько ответов мы возвращаем из функции.

Добавляем эту функцию в routing, например, вешаем ее на получение сообщения по веб-сокету.

from channels.routing import route
from myapp.consumers import ws_message
channel_routing = [
    route ('websocket.receive' ws_message),
}


Прописываем это в Django settings, также, как прописывали бы база данных.

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'asgiref.inmemory',
        'ROUTING': 'myproject.routing',
    },
}


В проекте может быть несколько Channel Layers, точно также как может быть несколько баз данных. Эта штука очень похожа на db router, если кто-то этим пользовался.

Далее мы определяем наше ASGI приложение. В нем синхронизируется то, как запускается Twisted и то, как запускаются синхронные воркеры — им всем нужно это приложение.

import os
from channels.asgi import get_channel_layer
os.environ.setdefault(
    'DJANGO_SETTINGS_MODULE',
    'myproject.settings',
)
channel_layer = get_channel_layer()


После этого, деплоим код: запускаем gunicorn, стандартно отправляем HTTP-запрос, синхронно, с view, как привыкли. Запускаем асинхронный сервер, который будет стоять фронтом перед нашей синхронной Django, и воркеры, которые будут обрабатывать сообщения.

$ gunicorn myproject.wsgi
$ daphne myproject.asgi:channel_layer
$ django-admin runworker


Reply channel


Как мы видели, у message есть такое понятие как Reply channel. Зачем это нужно?

Сhannel однонаправленный, соответственно WebSocket receive, WebSocket connect, WebSocket disconnect — это общий channel на систему для входных сообщений. А Reply channel — это channel, который строго привязан к коннекту пользователя. Соответственно, message имеет входной и выходной канал. Эта пара позволяет вам идентифицировать от кого вам пришло это сообщение.
rp_kwmeuccjs2bunsar-hoei9au.jpeg

Groups


Группа — это набор каналов. Если мы посылаем сообщение в группу, то оно автоматически рассылается всем каналам этой группы. Это удобно, потому что никто не любит писать циклы for. Плюс реализация групп обычно сделана с помощью нативных функций Channel layer, поэтому работает быстрее, чем просто рассылка сообщений по одному.

from channels import Group
def ws_connect (message):
    Group ('chat').add (message.reply_channel)
def ws_disconnect (message):
    Group ('chat').discard(message.reply_channel)

def ws_message (message):
    Group ('chat'). Send ({
        'text': message.content ['text'],
        })


Группы точно также добавляются в routing.

from channels.routing import route
from myapp.consumers import *
channel_routing = [
    route ('websocket.connect' , ws_connect),
        route ('websocket.disconnect' , ws_disconnect),
        route ('websocket.receive' , ws_message),
]


И как только канал добавляется в группу, reply будет уходить всем пользователям, которые подключились к нашему сайту, а не только echo-ответ на нас самих.

Generic consumers


За что я люблю Django — это за декларативность. Точно также есть декларативные Consumers.

Base Consumer — базовый, умеет только маппить channel, который вы определили на какой-то свой метод и вызывать его.

from channels.generic import BaseConsumer
class MyComsumer (BaseConsumer) :
    method_mapping = {
        'channel.name.here':  'method_name',
    }
    def method_name (self, message, **kwargs) :
        pass


Есть большое количество предопределенных consumers с заведомо дополненным поведением, таких как WebSocket Consumer, который заранее определяет, что он будет обрабатывать WebSocket connect, WebSocket receive, WebSocket disconnect. Можно сразу указать, в какие группы добавлять reply channel, и как только вы будете использовать self.send он будет понимать, послать это в группу или одному пользователю.

from channels.generic import WebsocketConsumer
class MyConsumer (WebsocketConsumer) :
    def connection_groups (self) :
        return ['chat']
    def connect (self, message) :
        pass
    def receive (self, text=None, bytes=None) :
        self.send (text=text, bytes=bytes)


Также есть вариант WebSocket consumer с JSON, то есть в receive будет приходить не текст, не байты, а уже распаршенный JSON — это удобно.

В routing он добавляется точно также через route_class. В route_class берется myapp, который определяется из consumer, оттуда берутся все channel«ы и роутятся все channel указанные в myapp. Писать таким образом меньше.

Routing


Поговорим детально о routing и том, что он нам предоставляет.

Во-первых, это фильтры.

// app.js
S = new WebSocket ('ws://localhost:8000/chat/')
# routing.py
route('websocket.connect', ws_connect,
    path=r’^/chat/$’)


Это может быть path, который пришел нам из URI коннекта веб-сокета, или метод http-запроса. Это может быть любое поле сообщения из channel, например, для e-mail: текст, body, carbon copy, что угодно. Количество keyword аргументов у route — произвольное.

Routing позволяет делать вложенные route. Если несколько consumers определяются какими-то общими характеристиками, удобно сгруппировать их и добавить в route всех сразу.

from channels import route, include
blog_routes = [
    route ( 'websocket.connect', blog,
        path = r’^/stream/’) ,
]
routing = [
    include (blog_routes, path= r’^/blog’ ),
]


Multiplexing


Если мы открываем несколько веб-сокетов, у каждого разное URI, и мы можем повесить на них несколько handler«ов. Но скажем честно, открывать несколько коннектов только для того, чтобы на бэкенде сделать что-то красивое, не похоже на инженерный подход.

Поэтому есть возможность по одному веб-сокету вызывать несколько handler«ов. Мы определяем такую WebsocketDemultiplexer, который оперирует понятием stream в рамках одного веб-сокета. Через этот stream он будет перенаправлять ваше сообщение в другой канал.

from channels import WebsocketDemultiplexer
class Demultiplexer (WebsocketDemultiplexer) :
    mapping = {
        'intval': 'binding.intval',
        }


В routing мультиплексер добавляется точно также, как и в любой другой декларативный consumer route_class.

from channels import route_class, route
from .consumers import Demultiplexer, ws_message
channel_routing = [
    route_class (Demultiplexer, path=’^/binding/’) ,
    route ('binding.intval', ws_message ) ,
]


В message добавляется аргумент stream, чтобы мультиплексер мог понять, куда ему положить данный message. В аргументе payload присутствует все то, что уйдет в channel после того, как его обработает мультиплексер.

Очень важно отметить, что в Channel Layer, message попадет два раза: до мультиплексера и после мультиплексера. Таким образом, как только вы начинаете использовать мультиплексер, вы автоматически добавляете latency в свои запросы.

{
    "stream" : "intval",
    "payload" : {
        …
    }
}


Sessions


У каждого channel есть свои сессии. Это очень удобная штука, чтобы, например, хранить state между вызовами handler«ов. Сгруппировать их можно по reply channel, поскольку это идентификатор, который принадлежит пользователю. Сессия хранится в том же самом движке, в котором хранится обычная http сессия. По понятным причинам, signed cookie не поддерживается, их просто нет в веб-сокете.

from channels.sessions import channel_session
@channel_session
def  ws_connect(message) :
    room=message.content ['path']
    message.channel_session ['room'] = room
    Croup ('chat-%s' % room).add (
        message.reply_channel
    )


Во время коннекта вы можете получить http сессию и использовать ее в ваших consumer. Как часть negotiation process, установки соединения веб-сокета, передаются cookies пользователя. Соответственно поэтому вы можете получить сессию пользователя, получить объект пользователя, который вы до этого обычно использовали в Django, точно также, как будто работаете с view.

from channels.sessions import http_session_user
@http_session_user
def ws_connect(message) :
    message.http_session ['room'] = room
    if message.user.username :
        …


Message order


Channels позволяет решить очень важную проблему. Если мы устанавливаем соединение с веб-сокетом и сразу делаем send, то это приводит к тому, что два события — WebSocket connect и WebSocket receive — по времени очень близки. Очень вероятно, что consumer для этих веб-сокетов будут выполняться параллельно. Отлаживать это будет очень весело.

Django channels позволяет вводить lock двух видов:

  1. Легкий lock. С помощью механизма сессий, мы гарантируем, что пока не обработается consumer на получение сообщения, мы не будем обрабатывать никакие message по веб-сокетам. После того, как соединение установлено, порядок произвольный, возможно параллельное выполнение.
  2. Жесткий lock — в один момент времени выполняется только один consumer конкретного пользователя. Это overhead по синхронизации, поскольку используется медленный движок сессий. Тем не менее такая возможность есть.
from channels.generic import WebsocketConsumer
class MyConsumer(WebsocketConsumer) :
    http_user = True
    slight_ordering =  True
    strict_ordering =  False
    def connection_groups (self, **kwargs) :
        return ['chat']


Для того, чтобы это написать, есть такие же декораторы, которые мы видели ранее в http session, channel session. В декларативных consumer можно писать просто атрибуты, как только вы это их напишите, это автоматически применится ко всем методам данного consumer.

Data binding


В свое время прославился Meteor за Data binding.

Открываем два браузера, заходим на одну и ту же страницу, и в одном из них кликаем на скролл-бар. При этом во втором браузере, на этой странице скролл-бар меняет своё значение. Это клево.

class IntegerValueBinding (WebsocketBinding) :
    model = IntegerValue
    stream = intval'
    fields= ['name', 'value']

    def group_names (self, instance, action ) :
        return ['intval-updates']

    def has_permission (self, user, action, pk) :
        return True


Django теперь умеет точно так же.

Это реализуется с помощью hook«ов, которые предоставляет Django Signals. Если определен binding для модели, все соединения, которые находятся в группе для данного instance модели будут оповещены о каждом событии. Создали модель, изменили модель, удалили её — всё это будет в оповещение. Оповещение происходит по указанным полям: изменилось значение этого поля — формируется payload, отправляется по веб-сокету. Это удобно.

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

Redis Layer


Поговорим чуть подробнее о том, как устроен самый популярный Channel Layer для продакшена — Redis.

Устроен он неплохо:

  • работает с синхронными коннектами на уровне воркеров;
  • очень дружественен к Twisted, не тормозит, там, где это особо необходимо, то есть на вашем фронтовом сервере;
  • используется MSGPACK для сериализации сообщений внутри Redis, что позволяет уменьшить footprint на каждое сообщение;
  • можно распределить нагрузку на несколько экземпляров Redis, она будет автоматически шардиться с помощью алгоритма консистентного хэша. Тем самым, исчезает единая точка отказа.


Канал представляет собой просто список id из Redis. По id находится значение конкретного сообщения. Сделано это для того, чтобы можно было контролировать срок жизни каждого сообщения и канала отдельно. В принципе, это логично.

>> SET "b6dc0dfce" " \x81\xa4text\xachello"
>> RPUSH "websocket.send!sGOpfny" "b6dc0dfce"
>> EXPIRE "b6dc0dfce" "60"
>> EXPIRE "websocket.send!sGOpfny" "61"


Группы реализованы сортированными множествами. Рассылка на группы выполняется внутри Lua-скрипта — это очень быстро.

>> type group:chat
zset
>> ZRANGE group:chat 0 1 WITHSCORES
1)  "websocket.send!sGOpfny"
2)  "1476199781.8159261"


Problems


Посмотрим, какие проблемы у этого подхода.

Callback Hell


Первая проблема — это заново изобретенный callback hell. Очень важно понимать, что большая часть проблем с каналами, с которыми вы столкнетесь, будут в стиле: в consumer пришли аргументы, которых он не ждал. Откуда они пришли, кто их положил в Redis — все это сомнительная задача на расследование. Отладка распределенных систем вообще для сильных духом. AsyncIO решает эту проблему.

Celery


В интернете пишут, что Django Channels — это замена Celery.
x9bej8oul6vevk2r8e12q9-i_ia.jpeg
У меня для вас плохие новости — нет, это не так.

В channels:

  • нет retry, нельзя отложить выполнение handler;
  • нет canvas — есть просто callback. Celery же предоставляет группы, chain, мою любимую chord, которая после параллельного выполнения групп вызывает еще один callback с синхронизацией. Всего этого нет в channels;
  • нет задания времени прибытия сообщений, некоторые системы без этого просто невозможно проектировать.


Я вижу будущее, как официальную поддержку использования channels и celery вместе, с минимальными затратами, с минимальными усилиями. Но Django Channels — это не замена Celery.


Django для современного web


Django Channels — это Django для современного web. Это тот самый Django, который мы все привыкли использовать: синхронный, декларативный, с большим количеством батареек. Django Channels — это всего лишь плюс одна батарейка. Всегда надо понимать, где её использовать и стоит ли это делать. Если в проекте Django не нужен, то и Channels там не нужны. Они полезны только в тех проектах, в которых оправдан Django.

Moscow Python Conf++

Профессиональная конференция для Python-разработчиков выходит на новый уровень — 22 и 23 октября 2018 соберем 600 лучших Python-программистов России, представим самые интересные доклады и, конечно же, создадим среду для нетворкинга в лучших традициях сообщества Moscow Python при поддержке команды «Онтико».

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

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

© Habrahabr.ru