Веб-сокеты в Django Channels. Сделаем простой чат

от автора

Фреймворк Django представляет разработчику исчерпывающий набор функций для работы с базами данных, инъекцией зависимостей, обработке шаблонов и многим другим через механизм дополнений. Часто Django используется как решение для разработки динамического содержания сайтов, но также с его помощью можно создавать REST-интерфейсы (например, для взаимодействия с мобильным или frontend-приложением) через расширение DRF (Django Rest Framework), однако REST-методы часто не подходят для ситуаций динамического обновления информации на стороне клиента. В этом случае рационально использовать веб-сокеты для поддержки двухстороннего обмена данными с клиентом и асинхронные расширения Django Channels для создания таких каналов передачи информации. В этой статье мы последовательно разберем механизм работы Django Channels и сделаем простую реализацию чата с использованием веб-сокетов.

Для реализации чата мы будем использовать актуальные версии DRF (3.14.0) и Django Channels (4.0.0). Создадим новый проект Django и добавим необходимые зависимости. Важно, что для работы каналов требуется дополнительное внешнее хранилище и, например, можно использовать для этих целей пакет channels-redis (в дальнейшем мы зададим конфигурацию хранилища для каналов).

Использование веб-сокетов предполагает поддержку асинхронных вызовов со стороны процесса, обрабатывающего веб-запросы, поэтому для публикации будет использоваться протокол asgi. В нашем случае мы будем использовать сервер daphne, его тоже необходимо добавить в зависимости приложения. Для запуска Django приложения будем использовать следующую команду:

daphne -b 0.0.0.0 -p 8080 sample.asgi:application

В модуле sample создадим файл asgi.py и свяжем приложение с router. Router будет использоваться для одновременной обработки запросов через web socket и обычных REST-запросов.

from django.core.asgi import get_asgi_application from messages import routing  asgi = get_asgi_application()  application = ProtocolTypeRouter({     "http": asgi,     "websocket": CookieMiddleware(SessionMiddleware(URLRouter(routing.websocket_urlpatterns))), })

В модуле messages маршрутизация привязывается к Consumer-классам, которые должны быть преобразованы к необходимому интерфейсу через вызов метода .as_asgi()

websocket_urlpatterns = [     re_path(r'^ws/$', WebSocketConsumer.as_asgi()), ]

Здесь мы регистрируем префикс /ws для служебных целей (например, открытие нового чата, завершения чата и другие запросы, которые предполагают асинхронный двухсторонний обмен данными) и обмена сообщениями (также можно было бы создать дополнительные web-socket соединения для каждого группового чата, при этом в адресе нужно дополнительно передавать uuid).

Класс поддержки чата должен наследоваться от AsyncWebsocketConsumer (из from channels.generic.websocket import AsyncWebsocketConsumer), при этом необходимо переопределить несколько асинхронных методов:

  • connect — вызывается при открытии веб-сокета, может использовать метод self.send для отправки сообщения в открытый сокет (например, идентификатор сессии или приветственное сообщение)

  • receive — будет вызван при получении сообщения через веб-сокет (сообщение будет в объекте event).

  • disconnect — вызывается при завершении соединения через веб-сокет

Важно, что для каждого нового websocket-подключения создается новый объект Consumer и он будет существовать в течении всего времени, пока соединение установлено. Это позволяет сохранять внутри объекта дополнительное состояние (например, авторизацию, идентификатор сессии и др.).

Информация об исходном запросе доступна также через свойство self.scope, например из него можно получить параметры запроса:

id = self.scope['url_route']['kwargs']['id']

Также можно извлечь информацию об аутентификации (например, через токен авторизации или basic auth), в этом случае имя пользователя будет доступно в self.scope['user'].

Если для обработки запросов потребуется подключение к базе данных нужно будет обернуть ORM-запросы в адаптер database_sync_to_async из asgiref.sync. Завершение соединения через веб-сокет можно инициировать со стороны сервера через вызов метода disconnect.

Однако, как можно увидеть из описания, такой механизм поддерживает только диалоги между клиентом и сервером, но не между несколькими клиентами, что предполагается в реализации чата. А поскольку каждое сокет-соединение представляет отдельный объект, то взаимодействие между ними требует отдельный механизм, который может быть реализован посредством базы данных, очередей сообщений или встроенной поддержки каналов. Для работы с каналами прежде всего необходимо источник данных для хранения их состояния:

CHANNELS_REDIS_HOST = env.str('CHANNELS_REDIS_HOST', 'localhost') CHANNELS_REDIS_PORT = env.int('CHANNELS_REDIS_PORT', 6379)  CHANNEL_LAYERS = {     'default': {         'BACKEND': 'channels_redis.core.RedisChannelLayer',         'CONFIG': {             "hosts": [f"redis://{CHANNELS_REDIS_HOST}:{CHANNELS_REDIS_PORT}/3"],         },     }, } 

Каналы имеют собственные имена и любой consumer может получить доступ к каналам через self.channel_layer. У каждого соединения есть уникальное имя self.channel_name, которое должно использоваться при подключении или отключении от группового канала:

self.channel_layer.group_add('name', self.channel_name) self.channel_layer.group_discard('name', self.channel_name)

Для отправки сообщения всем подписчикам канала используется метод self.channel_layer.group_send('name', message), где message — сериализуемый python-объект. Также можно отправить сообщение адресно, если известно имя канала (из self.channel_name), в этом случае используется метод self.channel_layer.send('name', message).

Во всех случаях отправленное в канал сообщение будет вызывать метод on_chat(self, event), где в event будет доступен отправленный объект.

Также каналы могут использоваться не только в consumer (например, для отправки уведомлений при получении внешнего события), в этом случае доступ к channel_layer может быть получен через вызов соответствующей функции:

from channels.layers import get_channel_layer channel_layer = get_channel_layer() channel_layer.group_send('notifications', {})

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

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

Все взаимодействие происходит через JSON протокол, поэтому мы будем использовать реализацию AsyncJsonWebsocketConsumer, в которой необходимо определить метод receive_json (получает декодированный объект в content) и использовать send_json для отправки произвольного объекта.

Служебный протокол будет поддерживать следующие команды (различаются по полю command):

  • start — начать чат, в команде указываются members — список идентификаторов пользователей, которым будет отправлено приглашение в групповой чат (отправляется от клиента на сервер)

  • invite — приглашение в чат, содержит поле id с идентификатором канала чата (с сервера на клиент). Клиент должен открыть web-socket соединение с адресом chat/<id> для подключения к групповому чату

  • terminate — прервать чат, должен содержать поле id с идентификатором канала чата.

  • welcome — приветствие нового клиента, для идентификации будет использоваться scope["user"]

  • notify — отправка сообщений (может быть как от клиента, так и от сервера), содержит id с указанием идентификатора группы

Обмен сообщениями происходит через передачу сообщений notify, сами сообщения передаются в виде json-объектов с типом kind="message" для обычного сообщения или kind="notification" для системных уведомлений. Текст сообщения всегда отправляется в поле text.

При получении сообщения от клиента (сообщение с type="message" в направлении к серверу), сервер рассылает это сообщение всем подписчикам группового чата (для них это входящее сообщение с type="message") кроме отправителя.

clients = {} chats = []  import uuid  class ChatWebSocket(AsyncJsonWebsocketConsumer):    async def connect(self):     global clients     clients[scope["user"]] = self.channel_name     self.send_json({"type": "welcome"})    async def disconnect(self):     pass    async def receive_json(self, content):     global chats, clients     if content["type"]=="invite":       chat = str(uuid.uuid4())       chats.append(chat)       for member in content["members"]:         self.channel_layer.send(clients[member], {           "type": "invite",           "id": chat         })     if content["type"]=="disconnect":       self.channel_layer.group_send(content["id"], {           "type": "disconnect",           "id": content["id"]         })       chats.remove(content["id"])     if content["type"]=="notify":       # входящее сообщение от клиента       self.channel_layer.group_send(content["id"], {         "type": "notify",         "kind": content.kind,         "message": content.message,         "sender": self.channel_name       })    async def chat_message(self, event):     # пересылаем клиенту внутреннее сообщение о приглашении в группу     if event["type"]=="invite"       # добавимся также в группу       await self.group_add(event["id"], self.channel_name)       self.send_json(event)     if event["type"]=="disconnect":       # отключаемся от группы       await self.group_discard(event["id"], self.channel_name)     if event["type"]=="notify":       if event["sender"]!=self.channel_name:         self.send_json(event)

Здесь мы сохраняем связь имени пользователя и соответствующего управляющего канала (для отправки invite), а также добавляем-удаляем себя из группы, соответствующей указанному чату (по идентификатору канала). Обмен сообщениями выглядит значительно проще, фактически сообщение просто ретранслируется в канал и отправляется всем участникам группы, как входящее сообщение (кроме отправителя).

Для отправки внешних событий (системных уведомлений) можно получить get_channel_layer и отправить во все каналы (список идентификаторов каналов может быть получен итератором по глобальной переменной chats). Аналогично можно добавить пересылку файлов, в этом случае предпочтительно открывать отдельное соединение и передавать по нему двоичные данные через await self.send_body(body=data).

Разумеется, при необходимости можно сочетать обработку web socket и обычные DRF-методы, в этом случае для REST используются синхронные методы. Для совместного использования кода для REST и WebSocket’ов можно использовать адаптеры async_to_sync (для вызова асинхронных методов, например отправки уведомления в канал) и sync_to_async (для обращения к синхронным методам в контексте веб-сокетов).

Для настройки внешнего веб-сервера nginx (например, для отправки статических ресурсов совместно с поддержкой web sockets можно использовать следующую конфигурацию:

upstream channels-backend {  server localhost:8080; }  server {   listen 80;   server_name example.com   location /static/ {     root /var/www/nginx;   }    location /ws/ {     proxy_pass http://channels-backend;     proxy_http_version 1.1;     proxy_set_header Upgrade $http_upgrade;     proxy_set_header Connection “upgrade”;     proxy_redirect off;     proxy_set_header Host $host;     proxy_set_header X-Real-IP $remote_addr;     proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;     proxy_set_header X-Forwarded-Host $server_name;   } }

Таким образом, использование asgi и механизмов асинхронной обработки сообщений в python может быть полезно для создания приложений для взаимодействия в реальном времени через web sockets.

В заключение приглашаю всех на бесплатный урок курса «Специализация Python Developer» по теме: «Модули и импорты». Всем, кто зарегистрируется на урок и будет присутствовать онлайн, подарим абсолютно бесплатно подготовительный курс по Python.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/698276/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *