Создание чата с использованием Django Channels

| Python

Стандартное Django приложение обрабатывает HTTP запросы, используя рабочий цикл запрос-ответ. Запрос создаётся браузером пользователя, далее он выполняется соответствующим Django view, которое возвращает ответ пользователю. Цикл запрос-ответ не подходит для приложений реального времени, которые требуют частых запросов к серверу. Новые стандарты, такие как websockets и HTTP2 позволяют устранить некоторые из этих недостатков. WebSockets – это новый протокол связи, который обеспечивает полнодуплексные каналы связи по одному TCP соединению и хорошо подходит для приложений реального времени. Открытие и поддержание соединения с сервером с помощью websocket очень дёшево с точки зрения потребления памяти и вычислительных ресурсов процессора.

Пакет Channels расширяет возможности Django, позволяя обрабатывать websocket соединения подобно обычным views. Channels представляет собой упорядоченную FIFO очередь продвигаемых сообщений с доставкой только одному листенеру. Проще говоря, сhannel является очередью задач, которая принимает сообщения от производителей и доставляет их потребителям.

Обработка websocket запроса в Django производится двумя процессами.

  • Интерфейс сервер – обрабатывает входящее соединение по HTTP и Websocket.
  • Рабочий процесс, запускает views для обработки websocket и http запросов.

Обмен данными между ними производится по протоколу ASGI, которые роутятся через Redis. Стоит отметить, поскольку интерфейсный сервер и рабочий процесс в Channels разъединены, можно добавлять и удалять рабочие процессы, не закрывая websocket соединения.

Следующая схема иллюстрирует, как Django обслуживает запросы традиционным способом и с использованием Channels:

Django обрабатывает только HTTP.

Браузер <——–> Web сервер <——–> Django View функция

Django с Channels Браузер <——–> Интерфейс сервер <——–> Слой Channel <——–> Django View функция + Websocket Consumer

Web сервер – работает с http соединениями (спасибо КэП). Интерфейс сервер – обрабатывает HTTP и websocket соединения. Слой Channel – HTTP транспорт websocket сообщения рабочим процессам, которые являются обычными Django представлениями, также известными как обработчики Channels.

Обратите внимание, что это упрощенная схема работы. В продашен развертываниях эта схема, вероятно, будет отличаться. В качестве другого примера можно запустить WSGI-сервер вместе с процессами обработки websocket для обслуживания обычных HTTP запросов Django.

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

Инструкции по установке Channels.

  1. pip install channels
  2. Добавляем channels в INSTALLED_APPS файла settings.py
  3. Устанавливаем redis

Функциональности чата будут минимальными:

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

Часто упускаемый из виду многими чатами момент неправильного подсчёта количества подключённых пользователей. Это происходит потому, что существует разновидность событий, которые заставляют клиентов отключаться от websocket соединений, не уведомляя об этом сервер. У сервера нет простого способа обновления количества пользователей, подключенных к чат-комнате без периодического процесса дисконнекта. Справиться с этой ситуацией помогает Django пакет django-channels-presence. Он позволяет закрывать устаревшие соединения с веб-сайтом и точно подсчитывает количество подключённых к чату пользователей. Как это работает? Чтобы отслеживать какие websockets всё ещё подключены, необходимо регулярно обновлять временную метку last_seen для всех существующих подключений и периодически удалять соединения, если от них не было отклика непродолжительное время.

Итак, перейдём к коду. У нас будет одна жестко закодированная комната all, для того чтобы не создавать отдельную Room модель. Модель пользовательского сообщения чата:

class ChatMessage(models.Model):
    """
    Модель для представления сообщения чата
    """

    #Поля
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    message = models.TextField(max_length=3000)
    message_html = models.TextField()
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    def __str__(self):
        """
        Строка для представления сообщения
        """

        return self.message

Поля модели:

  • user – ForeignKey на модель User;
  • message – необработанный текст, который пользователь вводит в поле чата;
  • message_html – это html-версия сообщения. html версия будет экранирована, в чат-комнате будут разрешены только теги ссылок. Все остальные теги, такие как <script> </ script>, не будут работать. Основная причина для создания html версии каждого сообщения чата – это экономия на обработке ресурсов, избегая рендеринга одного и того же html каждый раз после каждого запроса.
    class IndexView(generic.View):
    
            def get(self, request):
                #Показ последних 10 сообщений
                chat_queryset = ChatMessage.objects.order_by("-created")[:10]
                chat_message_count = len(chat_queryset)
                if chat_message_count > 0:
                    first_message_id = chat_queryset[len(chat_queryset)-1].id
                else:
                    first_message_id = -1
                previous_id = -1
                if first_message_id != -1:
                    try:
                        previous_id = ChatMessage.objects.filter(pk__lt=first_message_id).order_by("-pk")[:1][0].id
                    except IndexError:
                        previous_id = -1
                chat_messages = reversed(chat_queryset)
    
             return render(request, "chatdemo/chatroom.html", {
            'chat_messages': chat_messages,
            'first_message_id' : previous_id,
        })
    

IndexView извлекает последние 10 сообщений, опубликованных за последнее время в чате и устанавливает следующие переменные: first_message – идентификатор первого сообщения в прокрутке чата. Например, наша база данных содержит сообщения с id 4,3,2,1. Мы отправляем 4,3,2 для отображения в окне чата. Когда пользователь нажимает на кнопку «Load previous messages» из комнаты чата, загружаются все оставшиеся сообщения. В нашем случае это будет 1.

Чат рендерится с помощью шаблона chatroom.html.

Теперь, когда есть страница в чате отображающая десять последних сообщений, можем приступить к разработке обработчиков websocket подключений, которые позволяют пользователям отправлять чат-сообщение, а также загружать предыдущие сообщения чата. Структура файла, которая позволяет это сделать, проста: определяем маршруты в routing.py, которая является Channels версией urls.py, и определяем потребителей (представления) в users.py, которая является Channels версией views.py.

Определим 2 url в routing.py:

  • /ws/ – куда будут направляться сообщения чата;
  • /loadhistory/ – обрабатывает запросы для предыдущих сообщений чата;

Каждая url содержит обработчики 3х событий:

  • websocket.connect – вызывается при открытии нового подключения;
  • websocket.receive – обрабатывает фактическое сообщение, которое является либо чат-сообщением от пользователя, либо запросом истории чата;
  • websocket.disconnect – вызывается, когда клиент отключается от чата.

Код для routing.py, где определены указанные websocket url:

from channels.routing import route
from channels import include
from chatdemo.consumers import chat_connect, chat_disconnect, chat_receive, loadhistory_connect, loadhistory_disconnect, loadhistory_receive

chat_routing = [
    route("websocket.connect", chat_connect),
    route("websocket.receive", chat_receive),
    route("websocket.disconnect", chat_disconnect)
]

loadhistory_routing = [
    route("websocket.connect", loadhistory_connect),
    route("websocket.receive", loadhistory_receive),
    route("websocket.disconnect", loadhistory_disconnect)
]

channel_routing = [
    include(chat_routing, path=r"^/ws/$"),
    include(loadhistory_routing, path=r"^/loadhistory/$"),
]

Перед определением представлений, обрабатывающих запросы websocket в файле customers.py, рассмотрим, как сообщения чата обрабатываются в Django Channels.

Когда пользователь устанавливает веб соединения с url /ws/ происходят следующие процессы:

  • reply_channel соединения добавляются в группу all. Зачем это нужно? Все пользователи будут частью группы, которая позволяет нам отправлять чат сообщения одним-ко многим. Например, когда User A отправляет сообщение, все пользователи, подключенные к all группе, получат это сообщение.
  • Соединение также добавляется в комнаты all. Room здесь относится к модели в пакете channels_presence. Используется для отслеживания количества подключенных к чат комнате пользователей.
  • В завершение, пользователю направляется ответ {"accept": True} для подтверждения принятия подключения.

Код, который только что обсуждали:

@channel_session_user_from_http
def chat_connect(message):
    Group("all").add(message.reply_channel)
    Room.objects.add("all", message.reply_channel.name, message.user)
    message.reply_channel.send({"accept": True})

Перейдем к коду, который обрабатывает сообщения чата. Описание того, что происходит внутри функции:

  • Получить и декодировать сообщение json;
  • Подтвердить, что json содержит ключ message, который определяет сообщение;
  • Подтверждение пользовательской аутентификации, так как не имеет смысла принимать сообщения чата от неаутентифицированных пользователей;
  • Экранируем сообщение с помощью функции escape из django.utils.html;
  • Проверка, содержит ли сообщение какие-либо URL адреса и преобразует их в ссылки;
  • Возвращение json, содержащий имя пользователя и html сообщение. Реализация вышеуказанного описания:
    @touch_presence
    @channel_session_user
    def chat_receive(message):
            data = json.loads(message['text'])
            if not data['message']:
                    return
            if not message.user.is_authenticated:
                    return
            current_message = escape(data['message'])
            urlRegex = re.compile(
            u'(?isu)(\\b(?:https?://|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)[^\\s()<'
            u'>\\[\\]]+[^\\s`!()\\[\\]{};:\'".,<>?\xab\xbb\u201c\u201d\u2018\u2019])'
        )
    
    processed_urls = list()
    for obj in urlRegex.finditer(current_message):
        old_url = obj.group(0)
        if old_url in processed_urls:
            continue
        processed_urls.append(old_url)
        new_url = old_url
        if not old_url.startswith(('http://', 'https://')):
            new_url = 'http://' + new_url
        new_url = '<a href="' + new_url + '">' + new_url + "</a>"
        current_message = current_message.replace(old_url, new_url)
    m = ChatMessage(user=message.user, message=data['message'], message_html=current_message)
    m.save()
    
    my_dict = {'user' : m.user.username, 'message' : current_message}
    Group("all").send({'text': json.dumps(my_dict)})
    

Декоратор @touch_presence используется, чтобы зафиксировать периодический пульс, отправленный из браузера пользователя, который гарантирует, что пользователь не будет удалён из списка активных подключенных к чату пользователей.

В завершение, функция chat_disconnect просто удаляет пользователя из группы all и комнаты.

@channel_session_user
def chat_disconnect(message):
    Group("all").discard(message.reply_channel)
    Room.objects.remove("all", message.reply_channel.name)

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

@receiver(presence_changed)
def broadcast_presence(sender, room, **kwargs):
    # Широковещательная рассылка списка новых пользователей в комнате.
    Group(room.channel_name).send({
        'text': json.dumps({
            'type': 'presence',
            'payload': {
                'channel_name': room.channel_name,
                'members': [user.username for user in room.get_users()],
                'lurkers': int(room.get_anonymous_count()),
            }
        })
    })

Возвращаемый json содержит данные:

  • список зарегистрированных пользователей, подключенных к чату;
  • lurkers – количество анонимных пользователей.

Переходя к второй url /loadhistory/, функция loadhistory_connect (message) принимает соединение websocket, отправляет стандартный ответ, чтобы указать, что соединение принято: {"accept": True}. При завершении соединения дополнительных действий не требуется.

@channel_session_user_from_http
def loadhistory_connect(message):
    message.reply_channel.send({"accept": True})

@channel_session_user
def loadhistory_disconnect(message):
    pass

Функция loadhistory_receive выполняет следующие действия:

  • Декодирует json и извлекает идентификатор сообщения чата last_message_id;
  • Выполняет запрос к базе данных для извлечения 10 сообщений (если они существуют) с идентификатором, меньшим или равным last_message_id, и возвращает сообщения чата в формате json.

Код:

@channel_session_user
def loadhistory_receive(message):
    data = json.loads(message['text'])
    chat_queryset = ChatMessage.objects.filter(id__lte=data['last_message_id']).order_by("-created")[:10]
    chat_message_count = len(chat_queryset)
    if chat_message_count > 0:
        first_message_id = chat_queryset[len(chat_queryset)-1].id
    else:
        first_message_id = -1
    previous_id = -1
    if first_message_id != -1:
        try:
            previous_id = ChatMessage.objects.filter(pk__lt=first_message_id).order_by("-pk")[:1][0].id
        except IndexError:
            previous_id = -1

    chat_messages = reversed(chat_queryset)
    cleaned_chat_messages = list()
    for item in chat_messages:
        current_message = item.message_html
        cleaned_item = {'user' : item.user.username, 'message' : current_message }
        cleaned_chat_messages.append(cleaned_item)

    my_dict = { 'messages' : cleaned_chat_messages, 'previous_id' : previous_id }
    message.reply_channel.send({'text': json.dumps(my_dict)})

Теперь реализованы все websocket url, поэтому перейдём к клиентскому JavaScript, который обращается к этим url.

Прежде чем перейти к JavaScript обратим внимание на библиотеку Reconnecting websockets, она предназначена для автоматического восстановления закрытого соединения из-за того, что websockets не восстанавливают подключений если оно было закрыто.

2 клиентских JavaScript файла:

  • realtime.js – обрабатывает сообщения чата;
  • loadhistory.js – обрабатывает предыдущие сообщения чата.

Краткое описание внутренностей файла realtime.js:

  • Установление соединения ReconnectingWebSocket с /ws/ url. Подобно http и https, соединения websocket могут быть ws, либо wss, то есть незашифрованными или зашифрованными. В коде используется wss, если сайт загружается через https, в противном случае он использует незашифрованный ws режим.
  • Отправка периодического пульса в url /ws каждые 10 секунд, чтобы сервер знал, что соединение активно;
  • Преобразование текст пользовательского сообщения чата в json формат и отправить его на сервер.

Существует два типа ответов сервера, которые обрабатывает этот файл:

  1. Когда сервер отправляет полезную информацию присутствия, содержащую обновленный список активных пользователей вместе с подсчетом анонимов, JavaScript использует эти данные для обновления списка пользователей и подсчета анонимов.
  2. После отправки нового сообщения, сервер возвращает ответ содержащий имя пользователя, отправившего сообщение вместе с отображаемым html. JavaScript использует эти данные для обновления чата пользователя.

Реализация обсуждаемого JavaScript:

$(function() {
    // Когда используется HTTPS, задействуется также и WSS.
    $('#all_messages').scrollTop($('#all_messages')[0].scrollHeight);
    var to_focus = $("#message");
    var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws";
    var chatsock = new ReconnectingWebSocket(ws_scheme + '://' + window.location.host + "/ws/");

    chatsock.onmessage = function(message) {

        if($("#no_messages").length){
            $("#no_messages").remove();
        }

        var data = JSON.parse(message.data);
        if(data.type == "presence"){
            //обновляем счетчик гостей
            lurkers = data.payload.lurkers;
            lurkers_ele = document.getElementById("lurkers-count");
            lurkers_ele.innerText = lurkers;

            //обновляем список залогиненных пользователей
            user_list = data.payload.members;
            document.getElementById("loggedin-users-count").innerText = user_list.length;
            user_list_obj = document.getElementById("user-list");
            user_list_obj.innerText = "";

            //alert(user_list);
            for(var i = 0; i < user_list.length; i++ ){
                var user_ele = document.createElement('li');
                user_ele.setAttribute('class', 'list-group-item');
                user_ele.innerText = user_list[i];
                user_list_obj.append(user_ele);
            }

            return;
        }
        var chat = $("#chat")
        var ele = $('<li class="list-group-item"></li>')

        ele.append(
            '<strong>'+data.user+'</strong> : ')

        ele.append(
            data.message)

        chat.append(ele)
        $('#all_messages').scrollTop($('#all_messages')[0].scrollHeight);
    };

    $("#chatform").on("submit", function(event) {
        var message = {
            message: $('#message').val()
        }
        chatsock.send(JSON.stringify(message));
        $("#message").val('').focus();
        return false;
    });

    setInterval(function() {
    chatsock.send(JSON.stringify("heartbeat"));
    }, 10000);
});

Второй файл JavaScript обрабатывает загрузку предыдущих сообщений чата. Он работает следующим образом:

  • Когда пользователь нажимает кнопку «Load old messages», JavaScript отправляет переменную last_message_id в url /loadhistory.
  • После получения ответа от сервера, JavaScript обновляет чат с сообщениями в прошлом. Если сервер сообщает, что в скроллинге больше нет сообщений, JavaScript удаляет кнопку «Load old messages».

Код loadhistory.js:

$(function() {
    // Когда используется HTTPS, задействуется также и WSS.
    var ws_scheme = window.location.protocol == "https:" ? "wss" : "ws";

    var loadhistorysock = new ReconnectingWebSocket(ws_scheme + '://' + window.location.host + "/loadhistory/");

    loadhistorysock.onmessage = function(message) {

        var data = JSON.parse(message.data);

        new_messages = data.messages

        last_id = data.previous_id

        if(last_id == -1){
            $("#load_old_messages").remove();
            $("#last_message_id").text(last_id)
            if(new_messages.length == 0){
                return;
            }
        }
        else{
            $("#last_message_id").text(last_id)
        }

        var chat = $("#chat")

        for(var i=new_messages.length – 1; i>=0; i--){
            var ele = $('<li class="list-group-item"></li>')

            ele.append(
                '<strong>'+new_messages[i]['user']+'</strong> : '
                )

            ele.append(
                new_messages[i]['message'])

            chat.prepend(ele)
        }

    };

    $("#load_old_messages").on("click", function(event) {
        var message = {
            last_message_id: $('#last_message_id').text()
        }
        loadhistorysock.send(JSON.stringify(message));
        return false;
    });
});

Ранее уже упоминалось об необходимости отсечения устаревших соединений с веб сайтом. Для этого реализуем celery задачу, которая выполняется каждые 10 секунд и отсекает устаревшие соединения с веб сайтом.

def prune():
    from channels_presence.models import Room
    Room.objects.prune_presences()

Пакет django-channels-presence упрощает решение этой задачи. Если вы не знакомы с celery, обратитесь к официальной документации.