#44. WebSocket. Django channels

WebSocket. Django channels.

Протокол

Реалізація чату

Припустимо, ви хочете реалізувати на своєму сайті чат. Ви знаєте протокол HTTP, який передбачає систему запитів-відповідей.

Але що робити якщо, вам необхідно оновити інформацію у клієнта, хоча він її не запитував (клієнту пишуть повідомлення, але він не знає, коли саме воно буде написано)

Які існують варіанти вирішення цієї проблеми?

Polling (постійне опитування)

Ми можемо робити велику кількість запитів у надії, що вже хтось надіслав нам повідомлення

Чим поганий такий підхід?

Ми відправляємо величезну кількість запитів у “порожнечу”, витрачаючи ресурси і виконуючи непотрібні запити.

Long polling (довге опитування)

Ми можемо віддавати відповідь тільки коли повідомлення прийшло.

Як це реалізувати? Наприклад, у коді можна використовувати вічний цикл і опитування будь-якого сховища, наприклад redis.
Якщо дані з’явилися, віддавати відповідь.

Чим поганий такий підхід?

“Порожні” HTTP запити замінюються на “порожні” запити до сховища даних, що нічим особливо не краще, ми все ще витрачаємо велику кількість ресурсів. Більшість серверів і браузерів мають обмеження на час запиту, що теж є проблемою для такого підходу.

Socket (Сокет)

Сокет (англ. socket - заглиблення, гніздо, роз’єм) — назва програмного інтерфейсу для забезпечення обміну даними між процесами. Процеси при такому обміні можуть виконуватися як на одному компютері, так і на різних, пов’язаних між собою мережею.
Сокет - абстрактний об’єкт, що представляє кінцеву точку з’єднання.

Слід розрізняти клієнтські і серверні сокети. Клієнтські сокети грубо можна порівняти з кінцевими апаратами телефонної мережі, а серверні - з комутаторами. Клієнтський додаток (наприклад, браузер) використовує лише клієнтські сокети, а серверний (наприклад, вебсервер, якому браузер посилає запити) - як клієнтські, так і серверні сокети.

Інтерфейс сокетів вперше з’явився в BSD Unix. Програмний інтерфейс сокетів описаний в стандарті POSIX.1 І в тій чи іншій мірі підтримується усіма сучасними операційними системами.

У стандартних інтернет-протоколах TCP і UDP адреса
сокет - це комбінація IP-адреса і номер порту

WebSocket

WebSocket — це протокол, що призначений для обміну інформацією між браузером та вебсервером в режимі реального часу. Він забезпечує двонаправлений повнодуплексний канал зв’язку через один TCP-сокет. WebSocket спроектовано для втілення у веббраузерах та вебсерверах, але може також використовуватись будь-яким клієнт-серверним застосунком.

Як це працює?

  • Клієнт надсилає HTTP handshake request. на з’єднання із сокетом сервера.
  • Сервер надсилає HTTP handshake response.
  • Клієнт запитує WS з’єднання.
  • Сервер встановлює з’єднання WS.
  • Клієнт шле повідомлення серверу.
  • Сервер розсилає це повідомлення іншим клієнтам.
  • У будь-який момент обидві сторони можуть розірвати з’єднання, якщо це необхідно.

URL-схеми
Специфікація протоколу WebSocket визначає дві нові схеми URI,

  • ws: (для нешифрованого з’єднання)
  • wss: (для шифрованого з’єднання)
    Поза іменем схеми, решта складових URI визначена загальним синтаксисом URI.

Тобто, запити для сокетів проходять за протоколом WebSocket і виконуються на адреси, які починаються з ws://, а не http://

Сфера застосування

Де варто застосовувати веб сокети? Основні сфери застосування:

  • Чати
  • Додатки реального часу (наприклад, відображення курсу валют, вартості криптовалюти ітд.)
  • IoT застосунки (IoT - Internet of Things, інтернет речей, будь-які смарт предмети. Смарт-чайники, телевізори, датчики диму, кавомашини ітд.)
  • Онлайн ігри

Але якщо необхідно, то можна застосовувати де завгодно.

Django channels

Django + Channels basic structure

Звісно, для Python існує готовий пакет для підтримки цього протоколу з підтримкою Django

Документація

Встановлюється через pip

pip install channels==3.0.5

4.0 версія channels не працює із кодом, наданим нижче!

Tutorial

Давайте напишемо простий чат за допомогою Django

Створюємо віртуальне оточення, встановлюємо django і channels, створюємо проект

django-admin startproject chatsite

Отримаємо таку структуру:

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

Файли wsgi.py і asgi.py необхідні для запуску серверів, wsgi - синхронних, asgi - асинхронних. Веб сокет це асинхронна технологія.

ASGI - Asynchronous Server Gateway Interface
Клієнт-серверний протокол взаємодії веб-сервера і додатка, подальший розвиток технології WSGI. Порівняно з WSGI (Web Server Gateway Interface) надає стандарт як для асинхронних, так і для синхронних застосунків, з реалізацією зворотної сумісності WSGI і декількома серверами та платформами застосунків.

ASGI складається з двох різних компонентів:

  • Сервера протоколу (protocol server) - слухає сокети і перетворює їх на з’єднання та повідомлення про події всередині кожного з’єднання.
  • Додатка (application), який живе всередині сервера протоколу, його екземпляр створюється один раз для кожного з’єднання і обробляє повідомлення про події в міру їх виникнення.

Створимо додаток для чату

python3 manage.py startapp chat

Отримаємо приблизно таку структуру файлів:

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

Для простоти, пропоную видалити все окрім views.py і __init__.py

Отримана структура:

chat/
    __init__.py
    views.py

Додаємо наш додаток до INSTALLED_APPS у settings.py

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

Створимо папку templates, додамо її в settigns.py

Створимо файл index.html у папці templates:

<!-- templates/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>

Що буде на цій сторінці?

Поле для введення і кнопка увійти. Це буде можливість зайти в конкретний чат, за його назвою.

Що робить JS?

При заході на сторінку відразу виділяє поле для введення імені чату.

Якщо на інпуті натискається ентер на клавіатурі, то імітуємо натискання на кнопку входу.

Під час натискання на кнопку входу, беремо значення з інпуту і переходимо на сторінку /chat/<значення імпуту>/ - цієї сторінки поки що не існує.

Створимо view для цієї сторінки.

from django.views.generic import TemplateView


class Index(TemplateView):
    template_name = 'index.html'

Створимо файл з урлами всередині програми:

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

from chat.views import Index

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

А в основних урлах

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

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

Тепер якщо ми запустимо сервер, то побачимо в консолі щось таке:

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/ то буде ось так:

Спроба перейти на будь-яку сторінку ні до чого не приведе, сторінки кімнати поки що просто немає :)

Налаштування Channels

Для налаштування необхідно змінити файл asgi.py, якщо його немає, то створити його.

# chatsite/asgi.py
import os

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

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

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

Що ми зробили? Ми сказали нашому застосунку, що ми плануємо різні протоколи обробляти по-різному, на даний момент, ми вказали тільки протокол http, а значить що фактично, поки що, нічого не зміниться

Додаємо додаток у INSTALLED_APPS:

 INSTALLED_APPS = [
    'channels',
    'chat',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

І додаємо налаштування, щоб вказати, що основний сервер був asgi.py.

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

Тепер під час запуску програми ви маєте побачити трохи інший напис.

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.

Зверніть увагу, передостанній рядок, тепер сервер запущено з підтримкою веб сокетів.

Створюємо сторінку з конкретним чатом

Створимо html, 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>

Що відбувається на цій сторінці?

Текстове поле для відображення записів у чаті. Поле для введення нового повідомлення. Кнопка для надсилання.

{{ room_name|json_script: "room-name" }}

Фільтр json_script додасть на сторінку тег скрипт із даними зі змінної, якщо відкрити кімнату з назвою test то відрендерена сторінка матиме такий вигляд:

Потрібно для того, щоб зчитати змінну через JS.

Що відбувається в JS?

У першому рядку ми зчитуємо зі змінної ім’я кімнати.

І створюємо з’єднання з веб сокетом за адресою (ws://127.0.0.1:8000/ws/chat/<chat_name>/), ми створимо серверну частину далі. Зверніть увагу, використовується інший протокол не http. Під час створення такого об’єкта, запит на з’єднання надсилається автоматично.

Якщо за цим сокетом приходить повідомлення, то ми додаємо його до нашого місця для тексту

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-submit').onclick = function (e) {
    const messageInputDom = document.querySelector('#chat-message-input');
    const message = messageInputDom.value;
    chatSocket.send(JSON.stringify({
        'message': message
    }));
    messageInputDom.value = '';
};

І створити view.

from django.views.generic import TemplateView


class Room(TemplateView):
    template_name = 'room.html'

urls.py:

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

from chat.views import Index, Room

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

Запускаємо сервер, заходимо в будь-яку кімнату, пишемо будь-яке повідомлення і бачимо помилку.

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

Ми не створили бекенд для сокета. Давайте зробимо це.

Бекенд сокета

Створимо новий файл chat/consumers.py

    __init__.py
    consumers.py
    urls.py
    views.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
        }))

Що це таке? Це клас для роботи з веб сокетом.

Методи:

  • connect - Що робити під час запиту на з’єднання.
  • disconnect - Що робити в разі розриву з’єднання.
  • receive - Що робити під час надходження повідомлення.
  • send - Надіслати повідомлення всім, хто під’єднаний (включно з відправником, взагалі всім).

Створюємо новий файл для урлів веб сокета routing.py.

chat/
    __init__.py
    consumers.py
    routing.py
    urls.py
    views.py
# chat/routing.py
from django.urls import path
from .comsumer import ChatConsumer

websocket_urlpatterns = [
    path('ws/chat/<str:room_name>/', ChatConsumer.as_asgi(), name='room'),
]

Зверніть увагу, до класу було застосовано метод as_asgi, це аналогія as_view для звичайних класів.

Зазначимо цю змінну в нашому asgi.py:

# chatsite/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", "chatsite.settings")

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

Зверніть увагу, ми додали новий протокол для обробки.

Для того, щоб сокет працював, необхідні сесії, а для цього необхідно провести міграції.

python manage.py migrate

Перевіряємо, це вже буде працювати.

Наразі працювати буде тільки один чат!!!

Ми не додали можливість створювати різні сокети, для різних сторінок. Для цього необхідно розділити дані за шарами.

Channel layer

Канальний рівень - це своєрідна система зв’язку. Він дозволяє декільком екземплярам споживачів спілкуватися один з одним та з іншими частинами Django.

Канальний рівень надає наступні абстракції:

  • channel(канал) - це поштова скринька, до якої можна надсилати повідомлення. Кожен канал має назву. Будь-хто, хто має ім’я каналу, може надіслати повідомлення до цього каналу.
  • group(група) - це група пов’язаних каналів. Група має назву. Будь-хто, хто має ім’я групи, може додати/видалити канал до групи за іменем і надіслати повідомлення всім каналам у групі. Неможливо перерахувати, які канали входять до певної групи.
    Кожен екземпляр споживача має автоматично згенероване унікальне ім’я каналу, і тому з ним можна спілкуватися на канальному рівні.

Для того що б використовувати різні чати, які не перетинаються, ми будемо використовувати group, group це набір channel.

Для використання необхідно якесь зовнішнє сховище. Ми будемо використовувати Redis (RedisChannelLayer).

Для цього необхідно встановити ще один зовнішній модуль, для взаємодії між нашими шарами і редисом.

pip install channels_redis

Для користувачів Windows

На windows звичайний редис не працюватиме з останніми версіями django-channels.

Необхідно встановити це і запустити в консолі:

memurai

Це аналог Redis, який працюватиме

Docker

docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest

Оновлення налаштувань

Необхідно оновити налаштування і вказати, що ми будемо використовувати redis:

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

Є також інший варінт для тестування і локальної розробки - InMemoryChannelLayer.

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }
}

Для перевірки роботи редису необхідно відкрити shell:

python 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'}

Нагадую, спочатку вебсокети це асинхронна технологія. Для використання її синхронно, ми будемо використовувати вбудований метод async_to_sync.

У тесті ми надіслали повідомлення і отримали його.

Тепер можна оновити consumers.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
        }))

Методи:

connect - додали створення групи, виходячи з назви чату, і так само викликали метод accept

disconnect - видаляємо групу при розриві з’єднання

receive - При отриманні повідомлення ми виконуємо для всієї групи, метод chat_message могли назвати абсолютно як завгодно.

chat_message - надсилання повідомлення

Можемо перевіряти. Відкриваємо однакові назви чату в різних браузерах і пишемо по повідомленню з кожного

Запускаємо все асинхронно

Припустимо, ми хочемо надіслати другу великий файл, але ми хочемо писати повідомлення, поки файл завантажується. У випадку використання синхронного підходу, це неможливо, за асинхронного - це буде працювати.

Перепишемо consumers.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
        }))

Що ми змінили? Ми успадковувалися не від WebsocketConsumer, а від AsyncWebsocketConsumer, замінили всі функції з звичайних на асинхронні, і виклик функцій зі звичайного на асинхронні.

Усе, ваш чат повністю асинхронний.

Тестування

Для тестування веб сокетів використовуються специфічні тести. Розберіть самостійно тут.

Практика

  1. Повторіть туторіал із цього заняття - Channels Tutorial
  2. Давайте розбирати завдання на диплом!

Література

  1. Introduction to Django Channels