первые шаги в Python
March 30, 2021

Первый чат на Channels — расширении для асинхронной работы с Django

Учебник, часть 1. Базовая настройка

В этом уроке мы создадим простой чат-сервер. Он будет иметь две страницы:

  • Индексное представление, которое позволяет вам ввести имя комнаты чата, чтобы присоединиться.
  • Представление для комнаты, которое позволяет видеть сообщения, опубликованные в определенной комнате чата.

Представление комнаты будет использовать WebSocket для связи с сервером Django и прослушивания любых сообщений, которые публикуются.

Мы предполагаем, что вы знакомы с основными понятиями для создания сайта Django. Если нет, мы рекомендуем сначала ознакомиться с учебником по Django, а затем вернуться к этому учебнику.

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

$ python3 -m django --version

Мы также предполагаем, что у вас уже есть установленый Channels. Вы можете узнать, что Channels установлены, выполнив следующую команду:

$ python3 -c 'import channels; print(channels.__version__)'

Это руководство написано для Channels 3.0, которые поддерживают Python 3.6+ и Django 2.2+. Если версия Channels не совпадает, вы можете обратиться к руководству для своей версии Channels, используя переключатель версий в нижнем левом углу этой страницы, или обновить каналы до последней версии.

В этом руководстве также используется Docker для установки и запуска Redis. Мы используем Redis в качестве резервного хранилища для слоя канала, который является необязательным компонентом библиотеки Channels, которую мы используем в руководстве. Установите Docker с официального веб-сайта - есть официальные среды выполнения для Mac OS и Windows, которые упрощают его использование, и пакеты для многих дистрибутивов Linux, где он может работать нативно.

Примечание

В то время как вы можете запускать стандартный Django runserver без необходимости в Docker’е, функциям каналов, которые мы будем использовать в последующих частях учебника, потребуется Redis для запуска, и мы рекомендуем Docker как самый простой способ сделать это.

Создание проекта

Если у вас еще нет проекта Django, вам нужно будет его создать.

Выполните в терминале в командной строке cd: смену каталога на тот, в котором вы хотите хранить код проекта, затем запустите следующую команду:

$ django-admin startproject mysite

Это создаст в вашем текущем каталоге каталог mysite со следующим содержимым:

mysite/
    manage.py
    mysite/
        __init__.py
        asgi.py
        settings.py
        urls.py
        wsgi.py

Создание приложения чата

Мы поместим код для сервера чата в собственное приложение.

Убедитесь, что вы находитесь в том же каталоге, что иmanage.pyи введите эту команду:

$ python3 manage.py startapp chat

Это создаст каталог chat, который выглядит следующим образом:

chat/
    __init__.py
    admin.py
    apps.py
    migrations/
        __init__.py
    models.py
    tests.py
    views.py

В данном руководстве мы будем работать только с chat/views.py и chat/__ init __.py. Поэтому удалите все остальные файлы из каталога chat.

После удаления ненужных файлов chatкаталог должен выглядеть так:

chat/
    __init__.py
    views.py

Нам нужно сообщить нашему проекту, что chatприложение установлено. Отредактируйте mysite/settings.pyфайл и добавьте 'chat'в параметр INSTALLED_APPS . Это будет выглядеть так:

# mysite/settings.py
INSTALLED_APPS = [
    'chat',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

Добавление представление индексной страницы

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

Создайте каталог templates в каталоге chat. В каталоге templates, который вы только что создали, создайте еще один каталог с именем chat, и в нем создайте файл с именем index.html для хранения шаблона для представления индексной страницы.

Ваш каталог чата теперь должен выглядеть так:

chat/
    __init__.py
    templates/
        chat/
            index.html
    views.py

Вставьте следующий код вchat/templates/chat/index.html:

<!-- chat/templates/chat/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Rooms</title>
</head>
<body>
    What chat room would you like to enter?<br>
    <input id="room-name-input" type="text" size="100"><br>
    <input id="room-name-submit" type="button" value="Enter">

    <script>
        document.querySelector('#room-name-input').focus();
        document.querySelector('#room-name-input').onkeyup = function(e) {
            if (e.keyCode === 13) {  // enter, return
                document.querySelector('#room-name-submit').click();
            }
        };

        document.querySelector('#room-name-submit').onclick = function(e) {
            var roomName = document.querySelector('#room-name-input').value;
            window.location.pathname = '/chat/' + roomName + '/';
        };
    </script>
</body>
</html>

Создайте функцию просмотра для вида комнаты. Вставьте следующий код chat/views.py:

# chat/views.py
from django.shortcuts import render

def index(request):
    return render(request, 'chat/index.html')

Чтобы вызвать представление, нам нужно сопоставить его с URL - и для этого нам нужен URLconf.

Чтобы создать URLconf в каталоге чата, создайте файл с именем urls.py. Каталог вашего приложения теперь должен выглядеть так:

chat/
    __init__.py
    templates/
        chat/
            index.html
    urls.py
    views.py

В chat/urls.pyфайл включаем следующий код:

# chat/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
]

Следующим шагом является указание корневого URLconf на модуль chat.urls . В mysite/urls.pyдобавьте импорт для django.conf.urls.include и вставьте include () в список шаблонов URL , чтобы у вас было:

# mysite/urls.py
from django.conf.urls import include
from django.urls import path
from django.contrib import admin

urlpatterns = [
    path('chat/', include('chat.urls')),
    path('admin/', admin.site.urls),
]

Давайте проверим, что просмотр индекса работает. Выполните следующую команду:

python manage.py runserver

Вы увидите следующий вывод в командной строке:

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
October 21, 2020 - 18:49:39
Django version 3.1.2, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Перейдите на страницу http://127.0.0.1:8000/chat/ в своем браузере — вы должны увидеть текст «What chat room would you like to enter??» наряду с вводом текста, чтобы ввести название комнаты.

Введите «lobby» в качестве названия комнаты и нажмите клавишу ввода. Вы будете перенаправлены в комнату по адресу http://127.0.0.1:8000/chat/lobby/, но мы еще не написали функцию комнаты, так что вы получите страницу с ошибкой «Page not found».

Перейдите в терминал, где вы выполнили команду runserver и нажмите Control-C, чтобы остановить сервер.

Интеграция библиотеки Channels

Пока что мы только создали обычное приложение Django; мы вообще не использовали библиотеку Channels. Теперь пришло время интегрировать ее.

Начнем с создания корневой конфигурации маршрутизации для каналов. Конфигурация маршрутизации каналов - это приложение ASGI, похожее на Django URLconf, в том, что оно сообщает каналам, какой код запускать, когда сервер каналов получает HTTP-запрос.

Начните с настройки mysite/asgi.pyфайла, включив в него следующий код:

# mysite/asgi.py
import os

from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    # Just HTTP for now. (We can add other protocols later.)
})

Теперь добавьте библиотеку каналов в список установленных приложений. Отредактируйте mysite/settings.pyфайл и добавьте 'channels'в INSTALLED_APPSнастройку. Это будет выглядеть так:

# mysite/settings.py
INSTALLED_APPS = [
    'channels',
    'chat',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

Вам также необходимо указать каналы на корневую конфигурацию маршрутизации. mysite/settings.pyСнова отредактируйте файл и добавьте в конец следующее:

# mysite/settings.py
# Channels
ASGI_APPLICATION = 'mysite.asgi.application'

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

Примечание

Сервер разработки Channels будет конфликтовать с любыми другими сторонними приложениями, для которых требуется перегруженная или заменяющая команда runserver. Примером такого конфликта является whitenoise.runserver_nostatic из whitenoise. Чтобы решить такие проблемы, попробуйте переместить channel в верхнюю часть INSTALLED_APPS или полностью удалить приложение-нарушитель.

Let’s ensure that the Channels development server is working correctly. Run the following command:

python manage.py runserver

Вы увидите следующий вывод в командной строке:

Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
October 21, 2020 - 19:08:48
Django version 3.1.2, using settings 'mysite.settings'
Starting ASGI/Channels version 3.0.0 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Примечание

Проигнорируйте предупреждение о непримененных миграциях базы данных. Мы не будем использовать базу данных в этом уроке.

Обратите внимание на строку, начинающуюся с . Это указывает на то, что сервер разработки каналов перешел на смену серверу разработки Django.Starting ASGI/Channels version 3.0.0 development server at http://127.0.0.1:8000/

Перейдите на http://127.0.0.1:8000/chat/ в своем браузере, и вы все равно должны увидеть индексную страницу, которую мы создали ранее.

Перейдите в терминал, где вы выполнили команду runserver и нажмите Control-C, чтобы остановить сервер.

Учебник, часть 2. Внедрение сервера чата

Этот урок продолжает урок 1. Мы заставим страницу комнаты работать так, чтобы вы могли общаться с собой и другими людьми в одной комнате.

Добавление отображения для комнаты

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

Создайте новый файл chat/templates/chat/room.html. Каталог вашего приложения теперь должен выглядеть так:

chat/
    __init__.py
    templates/
        chat/
            index.html
            room.html
    urls.py
    views.py

Создайте шаблон представления для представления комнаты в chat/templates/chat/room.html:

<!-- chat/templates/chat/room.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Chat Room</title>
</head>
<body>
    <textarea id="chat-log" cols="100" rows="20"></textarea><br>
    <input id="chat-message-input" type="text" size="100"><br>
    <input id="chat-message-submit" type="button" value="Send">
    {{ room_name|json_script:"room-name" }}
    <script>
        const roomName = JSON.parse(document.getElementById('room-name').textContent);

        const chatSocket = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/chat/'
            + roomName
            + '/'
        );

        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };

        chatSocket.onclose = function(e) {
            console.error('Chat socket closed unexpectedly');
        };

        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').onkeyup = function(e) {
            if (e.keyCode === 13) {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };

        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message
            }));
            messageInputDom.value = '';
        };
    </script>
</body>
</html>

Создайте функцию просмотра для комнаты в chat/views.py:

# chat/views.py
from django.shortcuts import render

def index(request):
    return render(request, 'chat/index.html', {})

def room(request, room_name):
    return render(request, 'chat/room.html', {
        'room_name': room_name
    })

Создайте маршрут для функции комнаты в chat/urls.py:

# chat/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('<str:room_name>/', views.room, name='room'),
]

Запустите сервер разработки Channels:

$ python3 manage.py runserver

Перейдите на http://127.0.0.1:8000/chat/ в своем браузере и увидите главную страницу.

Введите «lobby» в качестве названия комнаты и нажмите клавишу ввода. Вы будете перенаправлены на страницу комнаты по адресу http://127.0.0.1:8000/chat/lobby/, на которой теперь отображается пустой журнал чата.

Введите сообщение «hello» и нажмите клавишу ввода. Ничего не произошло. В частности, сообщение не появляется в журнале чата. Почему?

Представление комнаты пытается открыть WebSocket по URL-адресу ws://127.0.0.1:8000/ws/chat/lobby/, но мы еще не создали потребителя, который принимает соединения WebSocket. Если вы откроете консоль JavaScript вашего браузера, вы должны увидеть ошибку, которая выглядит примерно так:

WebSocket connection to 'ws://127.0.0.1:8000/ws/chat/lobby/' failed: Unexpected response code: 500

Пишем свой первый потребитель

Когда Django принимает запрос HTTP, он обращается к корневому URLconf для поиска функции представления, а затем вызывает функцию представления для обработки запроса. Точно так же, когда Channels принимает соединение WebSocket, он проверяет конфигурацию корневой маршрутизации для поиска потребителя, а затем вызывает различные функции потребителя для обработки событий из соединения.

Мы напишем базового потребителя, который принимает соединения WebSocket по пути /ws/chat/ROOM_NAME/, который принимает любое сообщение, полученное в WebSocket, и отправляет его обратно в тот же WebSocket.

Примечание

Хорошей практикой является использование префикса общего пути, такого как /ws/, чтобы отличать соединения WebSocket от обычных соединений HTTP, потому что это облегчит развертывание каналов в производственной среде в определенных конфигурациях.

В частности, для больших сайтов можно будет настроить HTTP-сервер промышленного уровня, такой как nginx, для маршрутизации запросов на основе пути либо к (1) серверу WSGI промышленного уровня, например Gunicorn+Django, для обычных HTTP-запросов, либо (2) к производственному серверу ASGI, например, Daphne+Channels для запросов WebSocket.

Обратите внимание, что для небольших сайтов вы можете использовать более простую стратегию развертывания, где Daphne обслуживает все запросы - HTTP и WebSocket - вместо того, чтобы иметь отдельный сервер WSGI. В этой конфигурации развертывания не требуется общий префикс пути, такой как /ws/.

Создайте новый файл chat/consumer.py. Каталог вашего приложения теперь должен выглядеть так:

chat/
    __init__.py
    consumers.py
    templates/
        chat/
            index.html
            room.html
    urls.py
    views.py

Поместите следующий код в chat/consumer.py:

# chat/consumers.py
import json
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.accept()

    def disconnect(self, close_code):
        pass

    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        self.send(text_data=json.dumps({
            'message': message
        }))

Это синхронный потребитель WebSocket, который принимает все соединения, получает сообщения от своего клиента и отправляет эти сообщения обратно тому же клиенту. На данный момент он не передает сообщения другим клиентам в той же комнате.

Примечание

Channels также поддерживают запись асинхронных потребителей для повышения производительности. Однако любой асинхронный потребитель должен быть осторожен, чтобы избежать непосредственного выполнения операций блокировки, таких как доступ к модели Django. Для получения дополнительной информации о написании асинхронных потребителей смотрите Consumers.

Нам нужно создать конфигурацию маршрутизации для приложения chat, у которого есть маршрут к потребителю. Создайте новый файл chat/routing.py. Каталог вашего приложения теперь должен выглядеть так:

chat/
    __init__.py
    consumers.py
    routing.py
    templates/
        chat/
            index.html
            room.html
    urls.py
    views.py

Поместите следующий код в chat/routing.py:

# chat/routing.py
from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/#39;, consumers.ChatConsumer.as_asgi()),
]

Мы вызываем метод класса as_asgi(), чтобы получить приложение ASGI, которое будет создавать экземпляр нашего потребителя для каждого пользовательского соединения. Это похоже на функцию as_view() в Django, которая играет ту же роль для экземпляров представления Django по запросу.

(Обратите внимание, что мы используем re_path() из-за ограничений в URLRouter.)

Следующим шагом является указание корневой конфигурации маршрутизации на модуль chat.routing. В mysite/asgi.py импортируйте AuthMiddlewareStack, URLRouter и chat.routing; и вставьте ключ 'websocket' в список ProtocolTypeRouter в следующем формате:

# mysite/asgi.py
import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
import chat.routing

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")

application = ProtocolTypeRouter({
  "http": get_asgi_application(),
  "websocket": AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

Эта корневая конфигурация указывает, что при установлении соединения с сервером разработки Channels ProtocolTypeRouter сначала проверяет тип соединения. Если это соединение WebSocket (ws:// или wss://), соединение будет передано AuthMiddlewareStack.

AuthMiddlewareStack будет заполнять область действия ссылкой на текущего пользователя, прошедшего проверку подлинности, аналогично тому, как AuthenticationMiddleware Django заполняет объект request функции представления у текущего пользователя, прошедшего проверку подлинности (области будут обсуждаться позже в этом руководстве). Затем соединение будет передано в URLRouter.

URLRouter проверит HTTP-путь соединения, чтобы направить его конкретному потребителю, на основе предоставленных шаблонов url.

Давайте проверим, что потребитель для пути /ws/chat/ROOM_NAME/ работает. Запустите миграции, чтобы применить изменения базы данных (инфраструктура сеанса Django нуждается в базе данных), а затем запустите сервер разработки каналов:

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK
$ python3 manage.py runserver

Перейдите на страницу комнаты по адресу http://127.0.0.1:8000/chat/lobby/, где теперь отображается пустой журнал чата.

Введите сообщение «hello» и нажмите клавишу ввода. Теперь вы должны увидеть «hello», отраженный в журнале чата.

Однако если вы откроете вторую вкладку браузера на той же странице комнаты по адресу http://127.0.0.1:8000/chat/lobby/ и введете сообщение, сообщение не появится на первой вкладке. Чтобы это работало, нам нужно иметь несколько экземпляров одного и того же ChatConsumer, чтобы иметь возможность общаться друг с другом. Каналы обеспечивают абстракцию канального уровня, которая обеспечивает такой вид связи между потребителями.

Перейдите в терминал, где вы выполнили команду runserver и нажмите Control-C, чтобы остановить сервер.

Включить канальный слой

Канальный уровень - это своего рода система связи. Это позволяет нескольким пользовательским экземплярам общаться друг с другом и с другими частями Django.

Канальный уровень предоставляет следующие абстракции:

  • Канал - это почтовый ящик, куда можно отправлять сообщения. У каждого канала есть имя. Любой, у кого есть название канала, может отправить сообщение на канал.
  • Группа - это группа связанных каналов. У группы есть имя. Любой, у кого есть имя группы, может добавить/удалить канал в группе по имени и отправить сообщение всем каналам в группе. Невозможно перечислить, какие каналы находятся в определенной группе.

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

В нашем приложении чата мы хотим, чтобы несколько экземпляров ChatConsumer общались друг с другом в одной комнате. Для этого каждый ChatConsumer добавит свой канал в группу, имя которой основано на названии комнаты. Это позволит ChatConsumers передавать сообщения всем остальным ChatConsumers в той же комнате.

Мы будем использовать слой канала, который использует Redis в качестве резервного хранилища. Чтобы запустить сервер Redis на порту 6379, выполните следующую команду:

$ docker run -p 6379:6379 -d redis:5

Нам нужно установить channels_redis, чтобы каналы знали, как взаимодействовать с Redis. Выполните следующую команду:

$ python3 -m pip install channels_redis

Прежде чем мы сможем использовать канальный уровень, мы должны его настроить. Отредактируйте файл mysite/settings.py и добавьте внизу параметр CHANNEL_LAYERS. Должно получиться так:

# mysite/settings.py
# Channels
ASGI_APPLICATION = 'mysite.asgi.application'
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

Примечание

Можно настроить несколько слоев канала. Однако большинство проектов будет использовать только один канальный слой — 'default'.

Давайте убедимся, что канальный уровень может взаимодействовать с Redis. Откройте оболочку Django и выполните следующие команды:

$ python3 manage.py shell
>>> import channels.layers
>>> channel_layer = channels.layers.get_channel_layer()
>>> from asgiref.sync import async_to_sync
>>> async_to_sync(channel_layer.send)('test_channel', {'type': 'hello'})
>>> async_to_sync(channel_layer.receive)('test_channel')
{'type': 'hello'}

Введите Control-D, чтобы выйти из оболочки Django.

Теперь, когда у нас есть слой каналов, давайте воспользуемся им в ChatConsumer. Поместите следующий код в chat/consumer.py, заменив старый код:

# chat/consumers.py
import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        # Leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

Когда пользователь отправляет сообщение, функция JavaScript передает сообщение через WebSocket в ChatConsumer. ChatConsumer получит это сообщение и отправит его группе, соответствующей названию комнаты. Каждый ChatConsumer в той же группе (и, следовательно, в той же комнате) получит сообщение от группы и перенаправит его через WebSocket обратно в JavaScript, где оно будет добавлено в журнал чата.

Несколько частей нового кода ChatConsumer заслуживают дальнейшего объяснения:

  • self.scope['url_route']['kwargs']['room_name']
    • Получает параметр 'room_name' из URL-маршрута в chat/routing.py, который открывает соединение WebSocket с потребителем.
    • У каждого потребителя есть область видимости, которая содержит информацию о его соединении, включая, в частности, любые позиционные или ключевые аргументы из URL-маршрута и текущего аутентифицированного пользователя, если таковой имеется.
  • self.room_group_name = 'chat_%s' % self.room_name
    • Создает имя группы каналов непосредственно из указанного пользователем номера комнаты, без кавычек или экранирования.
    • Имена групп могут содержать только буквы, цифры, дефисы и точки. Поэтому этот пример кода не будет работать с именами комнат, которые имеют другие символы.
  • async_to_sync(self.channel_layer.group_add)(...)
    • Присоединяется к группе.
    • Декоратор async_to_sync(…) требуется, потому что ChatConsumer является синхронным WebsocketConsumer, но он вызывает метод асинхронного канального уровня. (Все методы канального уровня являются асинхронными.)
    • Имена групп ограничены только буквенно-цифровыми символами ASCII, дефисами и точками. Поскольку этот код создает имя группы непосредственно из имени комнаты, произойдет сбой, если имя комнаты содержит какие-либо символы, которые недопустимы в имени группы.
  • self.accept()
    • Принимает соединение WebSocket.
    • Если вы не вызовете accept() в методе connect(), соединение будет отклонено и закрыто. Возможно, вы захотите отклонить соединение, например, потому что запрашивающий пользователь не авторизован для выполнения запрошенного действия.
    • Рекомендуется, чтобы accept() был вызван как последнее действие в connect(), если вы решите принять соединение.
  • async_to_sync(self.channel_layer.group_discard)(...)
    • Покидает группу.
  • async_to_sync(self.channel_layer.group_send)
    • Отправляет событие в группу.
    • У события есть специальный ключ 'type', соответствующий имени метода, который должен вызываться у потребителей, которые получают событие.

Давайте проверим, что новый потребитель для пути /ws/chat/ROOM_NAME/ работает. Чтобы запустить сервер разработки каналов, выполните следующую команду:

$ python3 manage.py runserver

Откройте вкладку браузера на странице комнаты по адресу http://127.0.0.1:8000/chat/lobby/. Откройте вторую вкладку браузера на той же странице комнаты.

Во второй вкладке браузера введите сообщение «hello» и нажмите клавишу ввода. Теперь вы должны увидеть «hello», отраженный в журнале чата как на второй вкладке браузера, так и на первой вкладке браузера.

Теперь у вас есть базовый полнофункциональный чат-сервер!

Учебник, часть 3. Переписываем сервер чата как асинхронный

Этот урок продолжает урок 2. Мы перепишем потребительский код как асинхронный, а не синхронный, чтобы улучшить его производительность.

Переписываем потребителя, чтобы он был асинхронным

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

ChatConsumer использует только асинхронные библиотеки (Channels и канальный уровень) и, в частности, не имеет доступа к синхронным моделям Django. Поэтому его можно переписать как асинхронный без осложнений.

Примечание

Даже если ChatConsumer имел доступ к моделям Django или другому синхронному коду, все равно можно переписать его как асинхронный. Такие утилиты, как asgiref.sync.sync_to_async и channel.db.database_sync_to_async, могут использоваться для вызова синхронного кода от асинхронного потребителя. Однако прирост производительности будет меньше, чем если бы он использовал только асинхронные библиотеки.

Давайте перепишем ChatConsumer в асинхронном стиле. Поместите следующий код в chat/consumer.py:

# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        await self.channel_layer.group_add(
            self.room_group_name,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(
            self.room_group_name,
            self.channel_name
        )

    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Send message to room group
        await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message
            }
        )

    # Receive message from room group
    async def chat_message(self, event):
        message = event['message']

        # Send message to WebSocket
        await self.send(text_data=json.dumps({
            'message': message
        }))

Этот новый код для ChatConsumer очень похож на оригинальный код, со следующими отличиями:

  • ChatConsumer теперь наследуется от AsyncWebsocketConsumer, а не от WebsocketConsumer.
  • Все методы являются async def, а не просто def.
  • await используется для вызова асинхронных функций, которые выполняют ввод/вывод.
  • async_to_sync больше не требуется при вызове методов на канальном уровне.

Давайте проверим, что потребитель для пути /ws/chat/ROOM_NAME/ по-прежнему работает. Чтобы запустить сервер разработки Channels, выполните следующую команду:

$ python3 manage.py runserver

Откройте вкладку браузера на странице комнаты по адресу http://127.0.0.1:8000/chat/lobby/. Откройте вторую вкладку браузера на той же странице комнаты.

Во второй вкладке браузера введите сообщение «hello» и нажмите клавишу ввода. Теперь вы должны увидеть «hello», отраженный в журнале чата как на второй вкладке браузера, так и на первой вкладке браузера.

Теперь ваш чат-сервер полностью асинхронный!

Урок 4. Автоматизированное тестирование

Этот урок продолжает Урок 3. Мы создали простой чат-сервер и теперь создадим для него несколько автоматических тестов.

Тестирование отображений

Чтобы сервер чата продолжал работать, мы напишем несколько тестов.

Мы напишем набор сквозных тестов с использованием Selenium для управления веб-браузером Chrome. Эти тесты гарантируют, что:

  • когда сообщение чата публикуется, его видят все в одной комнате
  • когда сообщение чата публикуется, оно не видится никому в другой комнате

Установите браузер Chrome, если у вас его еще нет.

Установите chromedriver.

Установите Selenium. Выполните следующую команду:

$ python3 -m pip install selenium

Создайте новый файл chat/tests.py. Каталог вашего приложения теперь должен выглядеть так:

chat/
    __init__.py
    consumers.py
    routing.py
    templates/
        chat/
            index.html
            room.html
    tests.py
    urls.py
    views.py

Поместите следующий код в chat/tests.py:

# chat/tests.py
from channels.testing import ChannelsLiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.wait import WebDriverWait

class ChatTests(ChannelsLiveServerTestCase):
    serve_static = True  # emulate StaticLiveServerTestCase

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        try:
            # NOTE: Requires "chromedriver" binary to be installed in $PATH
            cls.driver = webdriver.Chrome()
        except:
            super().tearDownClass()
            raise

    @classmethod
    def tearDownClass(cls):
        cls.driver.quit()
        super().tearDownClass()

    def test_when_chat_message_posted_then_seen_by_everyone_in_same_room(self):
        try:
            self._enter_chat_room('room_1')

            self._open_new_window()
            self._enter_chat_room('room_1')

            self._switch_to_window(0)
            self._post_message('hello')
            WebDriverWait(self.driver, 2).until(lambda _:
                'hello' in self._chat_log_value,
                'Message was not received by window 1 from window 1')
            self._switch_to_window(1)
            WebDriverWait(self.driver, 2).until(lambda _:
                'hello' in self._chat_log_value,
                'Message was not received by window 2 from window 1')
        finally:
            self._close_all_new_windows()

    def test_when_chat_message_posted_then_not_seen_by_anyone_in_different_room(self):
        try:
            self._enter_chat_room('room_1')

            self._open_new_window()
            self._enter_chat_room('room_2')

            self._switch_to_window(0)
            self._post_message('hello')
            WebDriverWait(self.driver, 2).until(lambda _:
                'hello' in self._chat_log_value,
                'Message was not received by window 1 from window 1')

            self._switch_to_window(1)
            self._post_message('world')
            WebDriverWait(self.driver, 2).until(lambda _:
                'world' in self._chat_log_value,
                'Message was not received by window 2 from window 2')
            self.assertTrue('hello' not in self._chat_log_value,
                'Message was improperly received by window 2 from window 1')
        finally:
            self._close_all_new_windows()

    # === Utility ===

    def _enter_chat_room(self, room_name):
        self.driver.get(self.live_server_url + '/chat/')
        ActionChains(self.driver).send_keys(room_name + '\n').perform()
        WebDriverWait(self.driver, 2).until(lambda _:
            room_name in self.driver.current_url)

    def _open_new_window(self):
        self.driver.execute_script('window.open("about:blank", "_blank");')
        self.driver.switch_to_window(self.driver.window_handles[-1])

    def _close_all_new_windows(self):
        while len(self.driver.window_handles) > 1:
            self.driver.switch_to_window(self.driver.window_handles[-1])
            self.driver.execute_script('window.close();')
        if len(self.driver.window_handles) == 1:
            self.driver.switch_to_window(self.driver.window_handles[0])

    def _switch_to_window(self, window_index):
        self.driver.switch_to_window(self.driver.window_handles[window_index])

    def _post_message(self, message):
        ActionChains(self.driver).send_keys(message + '\n').perform()

    @property
    def _chat_log_value(self):
        return self.driver.find_element_by_css_selector('#chat-log').get_property('value')

Наш набор тестов расширяет ChannelsLiveServerTestCase, а не обычные наборы Django для сквозных тестов (StaticLiveServerTestCase или LiveServerTestCase), так что URL-адреса внутри конфигурации маршрутизации каналов, такие как /ws/room/ROOM_NAME / будет работать внутри пакета.

Мы используем sqlite3, который для тестирования запускается как база данных в памяти, и поэтому тесты не будут работать правильно. Нам нужно сообщить нашему проекту, что база данных sqlite3 не должна находиться в памяти для запуска тестов. Отредактируйте файл mysite/settings.py и добавьте аргумент TEST в параметр DATABASES:

# mysite/settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
        'TEST': {
            'NAME': os.path.join(BASE_DIR, 'db_test.sqlite3')
        }
    }
}

Чтобы запустить тесты, выполните следующую команду:

$ python3 manage.py test chat.tests

Вы должны увидеть результат, который выглядит примерно так:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 5.014s

OK
Destroying test database for alias 'default'...

Теперь у вас есть проверенный чат-сервер!