Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Работа с веб-сокетами в django за счет использования Django Channels

Антон Рогазинский
web-разработчик

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

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

Примером этого может послужить основной протокол обмена данными в сети интернет — протокол HTTP. Согласно нему, взаимодействие между клиентом и сервером построено по принципу «запрос-ответ». Клиент (в виде мобильного или веб-приложения) отправляет запрос на сервер для создания, изменения или удаления каких-либо данных, а сервер, при получении, выполняет этот запрос и отправляет свой ответ с отчетом о результатах. До недавнего времени данный подход отлично подходил при разработке веб-сайтов и веб-приложений. Однако современные веб-приложения предоставляют пользователю возможность получать уведомление о каких-либо важных событиях на сервере. Для реализации этой возможности разработчикам приходится использовать другие протоколы, такие как websocket или HTTP2. Это может вызвать дополнительные сложности при использовании фреймворка, основанного на взаимодействии по протоколу HTTP. Использование дополнительных инструментов (библиотек), тем или иным образом реализующих push-уведомления, проблематично. Это связано с влиянием фреймворка на архитектуру приложения, в котором он используется.

В рамках статьи будут рассмотрены возможные решения этой проблемы для популярного фреймворка Django, написанного на языке Python. Речь пойдет о том случае, когда полностью переносить приложение на новый фреймворк, поддерживающий Websocket-ы и HTTP2, невозможно или нецелесообразно. С учетом того, что работа приложения на Django построена по принципу “запрос-ответ”, возможность отправить что-либо клиенту появляется только при поступлении запроса от него. Это делает невозможным реализацию упомянутых push-уведомлений. Для решения проблемы обычно используется дополнительное приложение на основе асинхронного фреймворка, например, Tornado, Twisted или даже Node.js, поддерживающего websockets и предоставляющего возможность отправки push-уведомлений. А общение между главным приложением на Django и этим дополнительным приложением происходит за счет глобально доступного хранилища данных и оповещений об изменении данных в нём. Подобный функционал предоставляется, к примеру, СУБД Redis.

Пример. Реализация серверной части

В качестве примера рассмотрим реализацию серверной части для системы проведения онлайн викторин. Согласно требованиям, сервер должен предоставлять возможность всем участникам викторины (игрокам), использующим мобильное приложение, получать уведомления о таких событиях, как начало викторины, смена вопроса, завершение викторины. При этом, основным источником данных событий является веб-приложение для ведущего, изменяющее состояние викторины. При использовании стандартного подхода веб-приложение, используемое ведущим, должно отправлять HTTP запрос при каждом из упомянутых событий. Серверное приложение на Django при получении данного запроса выполняет необходимые изменения в общей БД проекта, после чего в заранее оговоренное место в СУБД Redis сохраняет сообщение с информацией о том, каким игрокам необходимо отправить уведомление и с какой информацией. Далее приложение на Tornado получает уведомление от Redis об этом и с использованием websocket-ов отправляет игрокам сообщение с нужными данными.

Данный подход имеет следующие проблемы:

  1. Сложность разработки. Затраты на изучение и использование нового фреймворка и СУБД для реализации push уведомлений могут не соотносится с выгодой от данного функционала.
  2. Сложность дальнейшей поддержки и доработки. Это связано с излишним разделением серверной части на отдельные взаимодействующие приложения.
  3. Сложность использования отладчика для того, чтобы, к примеру, отследить всю цепочку действий от момента поступления запроса до отправки уведомления

Решение. Дополнение Django Сhannels

К счастью, существует дополнение под названием Django Сhannels, решающее данные проблемы. Оно совместимо, как с Python v3.x, так и с Python 2.7. Условно говоря, оно меняет модель работы приложения с “запрос-ответ” на “событие-реакция” (не путать со стандартной реализацией событий в Django). Это значит, что запрос по протоколу HTTP, сообщение, полученное через WebSocket, а также, к примеру, входящее письмо на электронной почте или получение SMS — всё это будет будет представлено для приложения как “сообщение”, на которое можно(!) отреагировать. Каждое сообщение приходит на определенный канал (зависит от типа сообщения). При этом возможна, но не обязательна, отправка ответного сообщения, которое автоматически будет преобразовано к нужному формату (ответу по протоколу HTTP, сообщению через веб-сокеты и.т.д). Отправка ответа происходит по соответствующему каналу для ответа. Все это происходит абсолютно прозрачно и разработчику нужно беспокоиться только о получении нужных данных из сообщений и выполнений нужных действий в ответ. Конечно, при отправке ответного сообщения разработчику придется придерживаться формата для конкретного протокола (к примеру, задать код статуса для HTTP ответа). Тем не менее, данная абстракция значительно облегчает разработку проекта, использующего, например, Websocket-ы.

Разработка и поддержка приложения в данном случае облегчается за счет возможности использования роутинга, сессий для каналов, разбиения каналов по группам, оповещения клиентов об изменении данных на сервере. Все эти возможности реализованы в упомянутом дополнении Django Channels. Подробнее о данных возможностях можно прочитать в официальной документации. Также, за счет многослойной архитектуры, о которой будет сказано далее, возможна более детальная настройка сервера под конкретные нужды.

По умолчанию дополнение Django Channels использует многопоточный режим работы, что позволяет легко использовать, к примеру, режим отладки в IDE Pycharm и настроить точки останова в любой части кода.

Возможность абстрагирования от используемых протоколов связи достигается за счет использования в Сhannels многослойной архитектуры, в которой код приложения находится в последнем, самом нижнем уровне, называемом “слоем исполнителей”. В общем случае любой запрос по HTTP или сообщение через WebSocket проходит через следующие слои:

  1. Интерфейсный слой — отвечает за преобразование запроса из внешнего формата в формат сообщения и сохранение его с использованием канального слоя. Также, при получении сообщения на каналы для ответа, осуществляет преобразование этого сообщения к нужному внешнему формату и его отправление. Данный слой может быть представлен в виде отдельного веб-сервера
  2. Канальный слой — отвечает за реализацию добавления, хранения, получения и удаления сообщений с соответствующего канала. Слой доступен для использования как на слое приложения, так и на интерфейсном слое. При этом для хранения сообщений может использоваться как внутренняя память приложения, так и внешняя БД (например, Redis). Также возможно использование брокера сообщений RabbitMQ в качестве канального слоя.
  3. Слой исполнителей (workers) — отвечает за реализацию логики приложения и выполнения необходимых действий в ответ на сообщения на определенных каналах. Обычно представлен в виде группы исполнителей. Исполнитель — сущность (отдельный процесс или поток), отвечающая за получение сообщений на каналы и вызов соответствующих обработчиков. Любое новое сообщение будет обработано одним из свободных исполнителей. Это позволяет писать код в синхронном стиле, свободно используя продолжительные по времени операции в коде исполнителей. По умолчанию, при запуске проекта через runserver, создается несколько исполнителей в виде отдельных потоков (Threads), чье количество равно количеству ядер в процессоре сервера. Возникновение исключения в одном из исполнителей не приведет к аварийному завершению работы, а лишь к остановке работы текущего обработчика сообщения, оповещению об этом в логе, после чего поток-исполнитель продолжит свою работу.

Рис 1: Схема послойной архитектуры приложения, использующего django channels

Важная деталь — Добавление новых исполнителей в виде отдельных процессов в системе возможно с помощью следующей команды python3 manage.py runworker

Однако для этого требуется использование канального слоя, поддерживающего межпроцессный обмен данными, например asgi_redis, использующего БД redis для хранения сообщений.

Для примера, рассмотрим, что будет происходить при получении обычного HTTP запроса в рамках данной архитектуры. На интерфейсном уровне происходит преобразование данного запроса в формат сообщения с данными о том, каков тип запроса, на какой URL он пришел, на какой канал отправлять ответ и.т.д. На канальном слое, происходит хранение данного сообщения в очереди для канала “http.request” и предоставлена возможность для дальнейшего получения этого сообщения. На слое исполнителей происходит вызов соответствующего обработчика (consumer) с передачей полученного сообщения в качестве одного из параметров. Обработчик, в соответствии с бизнес логикой, совершит необходимые действия по проверке корректности данных и необходимые манипуляции с СУБД. После этого возможна отправка ответа (в виде сообщения) на ответный канал. В случае не отправки ответа, клиенту (отправившему http запрос) вернется timeout error. На канальном слое отправленное в ответ сообщение будет сохранено в канале “http.response”. После этого на интерфейсном слое произойдет преобразование данного сообщения в HTTP ответ (response), который будет отправлен клиенту. Рассмотрим возможную реализацию серверной части для системы проведения викторин, описанной ранее. Сперва, все игроки подключаются к серверу с помощью веб-сокетов. При обработке запроса на соединение через веб-сокет, сервер разделяет все каналы для ответа по группам по признаку принадлежности к какой-либо игре. В результате, образуется, к примеру, группа “game_test”, содержащая в себе все каналы для отправки сообщений игрокам данной викторины. Для запуска и завершения викторины, смены текущего вопроса ведущий с помощью веб-приложения отправляет HTTP запрос на изменение данных о состоянии викторины на сервер. При обработке запроса, сервер отправляет сообщение об изменении в группу каналов, соответствующую данной игре. Далее, django channels преобразует это в сообщение для веб-сокетов и отправляет его всем участникам данной викторины. Также возможна реализация с использованием только обмена сообщениями через веб-сокеты между клиентами (игроками и ведущим) и сервером.

Однако может возникнуть вопрос сложности внедрения данного дополнения в уже существующий проект, использующий, например, Django rest Framework. Тут стоит сказать, что в текущей реализации django channels при поступлении http запроса, в случае отсутствия назначенного обработчика на уровне исполнителей, запускается стандартная обработка запроса, реализованная в django. Это значит, что добавление django channels к проекту не приведет к нарушению работы уже существующего кода. Это верно и для возможности получения файлов через HTTP, используемой в django в основном только в режиме разработки и тестирования приложения. Также, данным дополнением предоставляются инструменты по преобразованию сообщения с канала “http.request” в полноценный объект класса HttpRequest, необходимый для работы стандартных обработчиков Django. Похожим способом возможно преобразование объектов класса HttpResponse в сообщения для каналов.

Тем не менее, значительному изменению подвергнется развертывание (deployment) кода на рабочем сервере. Одной из причин этого является опять же ориентированность WSGI, общепринятого протокола для обмена данными между веб-сервером (вроде apache или nginx) и серверным приложением, на модель «запрос-ответ». В случае применения websockets, возможно использование веб-сервера только в качестве обратного прокси сервера. В этом случае приложение, выполняющее бизнес логику запущено всегда, а любой запрос или сообщение по веб-сокету сначала передается на веб-сервер, а после уже передается дальше на само серверное приложение. Ответ приложения на запрос клиента также проходит через веб-сервер. На данный момент, такие популярные веб-сервера как apache и nginx поддерживают проксирование по протоколу websocket. Стоит заметить, что, в связи с асинхронной природой протокола websocket, рекомендуется использовать именно асинхронный веб-сервер, например nginx. Пример его конфигурации для поддержки Websockets представлен ниже:


server {
    listen 80;
    location /test {
# WebSocket support (nginx 1.4)
        proxy_pass http://127.0.0.1:8000/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
    }
}

При данной конфигурации все запросы на ‘/test’, в том числе на подключение через websocket будут перенаправлены на адрес ‘http://127.0.0.1:8000/’

Немаловажно то, что программа, использующая django channels предполагает использование нового протокола обмена данными с веб-сервером — ASGI. По нему, веб-сервер выступает в качестве реализации интерфейсного слоя. Это значит, что он выполняет преобразование http-запросов или сообщения по вебсокету в сообщение для соответствующего канала. Однако, полная спецификация данного протокола всё ещё находится в разработке. И остается лишь надеяться на развитие поддержки этого протокола в будущем.

Также, может возникнуть вопрос про механизм аутентификации подключений по веб-сокету. На текущий момент, django channels предоставляет возможность создавать сессию для хранения данных для каждого канала, а также механизм аутентификации для веб-сокетов через session key, передаваемый в query string. К примеру, для подключения по адресу “ws://test.com/” клиенту c session_key равному “12345” необходимо изменить адрес подключения на “ws://test.com/?session_key=12345”. Однако, в случае использования другого механизма аутентификации, это может оказаться недостаточно. Возможная реализация аутентификации с использованием токенов из django rest framework представлена ниже:

https://pastebin.com/LgSJbugR


1.  from functools import wraps
2.  from channels.sessions import channel_session
3.  from urllib.parse import parse_qs
4.  import json
5.  from rest_framework.authtoken.models import Token
6.   
7.   
8.  def query_string_to_channel_session(consumer):
9.      @channel_session
10.     @wraps(consumer)
11.     def inner(message):
12.         query_string = message['query_string']  # assume UTF-8
13.         query_dict = parse_qs(query_string)
14.         for key, val in query_dict.items():
15.             message.channel_session[key] = val[0]
16.         return consumer(message)
17.     return inner
18.  
19.  
20. def channel_token_auth(consumer):
21.     @query_string_to_channel_session
22.     @wraps(consumer)
23.     def inner(message):
24.         received_token = message.channel_session.get('token', '')
25.         try:
26.             valid_token = Token.objects.select_related('user').get(key=received_token)
27.             message.channel_session['username'] = valid_token.user.username
28.             return consumer(message)
29.         except Token.DoesNotExist:  # TODO: check if user is inactive
30.             error = dict(
31.                 type='auth_error',
32.                 payload="Token is incorrect or not provided",
33.             )
34.             message.reply_channel.send({"text": json.dumps(error), "close": True})
35.     return inner

Здесь в начале создается декоратор, который дополняет сессию канала аргументами из query string. Далее, этот декоратор используется в другом, проверяющем корректность токена, переданного в query string. В случае успеха, в сессии канала сохраняется имя аутентифицированного пользователя (при желании имя можно заменить на id). Сохранение всего объекта пользователя в сессии невозможно, так как сохраняемые данные должны быть примитивами языка python (сериализуемые). В случае неудачной аутентификации клиенту отправляется сообщение об ошибке и соединение закрывается. Данное решение обеспечивает использование единого механизма аутентификации, независимо от используемого способа обмена данными между клиентом и сервером.

Также, возможно использование возможностей django channels в стандартных views и views set для django или django rest framework. Ниже представлен пример возможной реализации оповещения о изменении данных в модели.

https://pastebin.com/dhw6xP7f


1.  class GamesViewSet(mixins.ListModelMixin, mixins.CreateModelMixin,
2.                     mixins.RetrieveModelMixin, mixins.UpdateModelMixin,
3.                     GenericViewSet):
4.      serializer_class = GameSerializer
5.      permission_classes = (IsAuthenticated)
6.      queryset = Game.objects.all()
7.   
8.      def perform_update(self, serializer):
9.          serializer.save()
10.         self.notify_players(serializer.data)
11.  
12.     def notify_players(self, data):
13.         Group(data['name']).send({
14.             'text': json.dumps(data)
15.         })

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

Также возможно использовать прием подписки на определенные внутренние события в django, вроде изменения или удаления данных в модели. Именно такой подход используется в текущей реализации оповещения об изменении данных на сервере через веб-сокет в django channels (см. Data Binding в официальной документации). Для добавления django channels необходимо выполнить следующие шаги (из офф. документации):

  1. Установить данное дополнение командой: pip install django-channels
  2. В той же директории, где находится settings.py, добавить файл routing.py с содержимым, аналогичным следущему:
    
    from channels.routing import route
    channel_routing = [
        route("http.request", "myapp.consumers.http_consumer"),
    ]
    
    
    
    При этом myapp нужно изменить на актуальное название приложения, в директории которого находится код по обработке сообщений на канал (в данном случае ‘http.request’)
  3. Внести следующие изменения в settings.py:
    1. в INSTALLED_APP добавить строку “channels”
    2. добавить параметр channel_layer со следующим значением:
      
      CHANNEL_LAYERS = {
          "default": {
              "BACKEND": "asgiref.inmemory.ChannelLayer",
              "ROUTING": "myproject.routing.channel_routing",
          },
      }
      
      
      При это “myproject” нужно изменить на актуальное название проекта.
  4. Добавить код обработчика сообщения (в данном случае в файл consumers.py в директории myapp). Код обработчика представляет из себя функцию с следующими параметрами: message — сообщение на канал и kwargs — дополнительные аргументы, полученные из пути(напримере, url-адреса). При этом от обработчика не требуется возвращать какие-либо значения. Для отправки ответа необходимо использовать message.reply_channel. Ниже представлен пример обработчика сообщения на канал ‘http.request’:
    
    def http_request_consumer(message, *kwargs):
       response = HttpResponse('Hello world! You asked for %s' % message.content['path'])
       for chunk in AsgiHandler.encode_response(response):
           message.reply_channel.send(chunk)
    
    

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

https://pastebin.com/wcEMDQaj


1.  from channels.generic.websockets import JsonWebsocketConsumer
2.   
3.  class MyConsumer(JsonWebsocketConsumer):
4.   
5.      # Set to True if you want it, else leave it out
6.      strict_ordering = True
7.     
8.      def connection_groups(self, **kwargs):
9.          """
10.         Called to return the list of groups to automatically add/remove
11.         this connection to/from.
12.         """
13.         person_name = self.message.channel_session['username']
14.         player = Player.objects.get(user__username=person_name)
15.         game_name = player.game.name
16.         return [game_name]
17.  
18.     @channel_token_auth
19.     def connect(self, message, **kwargs):
20.         """
21.         Perform things on connection start
22.         """
23.         message.reply_channel.send({"accept": True})
24.        
25.  
26.     @channel_session
27.     def receive(self, content, **kwargs):
28.             """
29.             Called when a message is received with decoded JSON content
30.             """
31.             self.send({‘error': 'You can not send messages'})
32.  
33.     def disconnect(self, message, **kwargs):
34.         """
35.         Perform things on connection close
36.         """
37.         pass

Для его добавления достаточно добавить следующую строчку в channel_routing, созданный ранее:


route_class(consumers.MyConsumer, path=r"^/players")

где consumers.MyConsumer — класс данного обработчика

Таким образом, дополнение django-channels является хорошим вариантом решения проблемы добавления взаимодействия через протокол WebSocket в проект, построенный на django. Абстракция над используемыми для взаимодействия с клиентами протоколами дает возможность выйти из ограничений модели “запрос-ответ” в django. В частности, это дает возможность отправки уведомлений клиенту, что весьма популярно в сети интернет в настоящее время. Решение основных проблем, возможных при использовании этого дополнения представлено в данной статье. Помимо этого, проблемы, специфические для каждого проекта, вполне могут быть решены с помощью инструментов, предоставляемых данным дополнением.

Читать и комментировать

Краснодар

Коммунаров, 268,
3 эт, офисы 705, 707

+7 (861) 200 27 34

Хьюстон

3523 Brinton trails Ln Katy

+1 833 933 0204

Москва

+7 (495) 145-01-05