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