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

Прежде всего, дзен 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 был простой, пользователи читали в интернете всякие новости, заходили в гостевые книги. Достаточно было просто принять запрос и обработать его. Дать ответ и забыть о том, что этот пользователь вообще был.


Но, на секундочку, сейчас не 2003 год, поэтому пользователи хотят от нас намного большего.

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



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

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


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

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

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



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


Канал — это, упорядоченная по принципу first-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 имеет входной и выходной канал. Эта пара позволяет вам идентифицировать от кого вам пришло это сообщение.


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.

У меня для вас плохие новости — нет, это не так.

В 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 сентября.

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

Комментарии (18)


  1. SirEdvin
    31.07.2018 15:00
    +1

    как устроен самый популярный Channel Layer для продакшена — Redis.

    Мне было всегда интересно, почему? Redis же просто ужасен как MQ, почему он так популярен то?)


    1. eyeofhell Автор
      31.07.2018 15:43

      Очень простой, очень быстрый. Простота развертывания и использования компенсирует ужасность :)


  1. Alexs177
    31.07.2018 17:16
    +1

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


  1. barker
    31.07.2018 20:04

    Очень странно писать во второй половине 2018 года про channels, а не про channels2, да ещё и как будто про новую библиотеку. На самом деле в channels2 всё архитектурно совсем не так (и намного лучше).


    1. barker
      31.07.2018 20:06

      А, увидел что видео с конференцией

      Опубликовано: 3 нояб. 2016 г.
      ну в общем-то вопросы отменяются, хотя всё равно странно.


    1. eyeofhell Автор
      01.08.2018 08:43

      Это толстый намек на то, что осенью на conf.python.ru мы будем обсуждать channels2. Заходите на огонек!


      1. gigimon
        01.08.2018 12:44

        Начал в своем проекте использовать channels2, читаю статью и не понимаю, толи я дурак, толи статья странная. Пошел смотреть код, не нашел ничего из выше сказанного…

        Думаю стоит вначале написать, для какой версии это все


        1. eyeofhell Автор
          01.08.2018 12:49

          Без проблем, написал в прекате.


  1. baldr
    01.08.2018 00:34
    +3

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

    Как красиво сказано. Но на самом деле в «детали реализации» вы полезете смотреть уже через 20 минут, когда вам захочется, например, узнать сколько юзеров в группе и через час-два гугления вы поймете что это не поддерживается API вообще и тикет закрыт с пометкой о том что так и надо.

    Когда будете гуглить, то учитывайте что 90% примеров в сети и в SO в частности относятся к channels 1.x, в то время как автор уже переписал ее в 2.0 и она, мягко говоря, поменялась. В доках стоит почитать его восторги по этому поводу и порадоваться за него.

    Вообще говоря, немного разочаровала эта библиотека, хотя, конечно, принесла много новых вещей. Ждали ее как silver bullet, но она довольно туповата.
    Во всех без исключения примерах разбирается «чат» и кроме чата на ней, действительно, что-то реализовать уже потребует некоторых размышлений. Синяя изолента должна быть под рукой.


  1. sha4
    01.08.2018 02:21
    -4

    "Пользователи хотят, хотят.." Главное в популизм не скатиться. Лично мне все равно что хочет кухарка, ничего кроме яблофона в руках не державшая, торчащая в инсте и вк. Когда группа талантливых программистов идет на поводу у кухарок, до добра это не доведет. Или может все же другой мотиватор у ребят, а не "пользователи хотят"? Лично мне на них все равно, приложение должно решать конкретную бизнес-задачу, а не быть няшкой.


  1. Nailgun
    01.08.2018 03:16
    +1

    pep8? не думаю


  1. user-vova
    01.08.2018 14:24
    +1

    С 14го года не интересовался джангой, она всё также вширь и вширь? Всё больше и больше батареек? А шаблонизатор всё также отстаёт в несколько раз от jinja2 по всем параметрам? ORM думаю тоже не приблизился к уровню SQLAlchemy?


    1. eyeofhell Автор
      01.08.2018 14:31
      +2

      Все верно, можно подключить какой нарвится шаблонизатор, ORM и пользоваться в свое удовольствие.


      1. user-vova
        02.08.2018 01:06

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


        1. immaculate
          02.08.2018 03:53

          Достоинство Django в том, что уже есть готовые решения для практически любой задачи. Не надо изобретать велосипед. А ORM совершенствуется с каждым релизом. Если в первые годы использования Django регулярно приходилось использовать .raw запросы или вообще голый SQL, то внезапно осознал, что не делаю этого уже последние года 4-5.


    1. urticazoku
      02.08.2018 08:36

      ORM думаю тоже не приблизился к уровню SQLAlchemy?

      Чем SQLAlchemy лучше ORM? Имхо, ORM намного удобнее.


      1. Fragik
        03.08.2018 06:53

        SQLAlchemy и есть ORM.


  1. ZaEzzz
    01.08.2018 20:22

    Закидают меня камнями… Но даже в 2018… Если нет дикой необходимости в асинхронщине и вебсокетах, то стоит на них смотреть только в образовательных целях.