Фреймворк 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.

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