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