實時更新的多人聊天室 – Django Channels:為 Django 提供異步通信與長連接協議支持

A. 安裝Django Channels

接下來是文件上搭建一個實時更新聊天室的入門教學,透過這篇教學能夠更好的了解如何使用Django Channels搭建出一個Web Socket伺服器。

首先,你需要先建立一個Django專案,同時建立一個Django APP。

接著,安裝相關Python套件:

pip install -U 'channels[Daphne]'

安裝 Django Channels 的同時,預設會安裝 Daphne ASGI 應用伺服器。Daphne 是 Channels 提供的一個常用 ASGI 應用伺服器選項,適合不要過多配置就能夠快速建立起應用。

安裝完成後在 Django 專案的 settings.py 中找到 INSTALLED_APPS,並在最上方加入 daphne

INSTALLED_APPS = (
    "daphne",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.sites",
    ...
)

daphne會複寫原有runserver指令,他必須在INSTALLED_APPS的最前面,確保 Daphne 的命令優先執行。

同時修改Django專案資料的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')
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()

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

並在Django專案設定檔settings.py中增加設定:

ASGI_APPLICATION = "myproject.asgi.application"

B. 使用WebSockets協議搭建即時多人聊天室

在傳統的 HTTP 通信中,客戶端向伺服器發送請求,然後伺服器返回響應。這意味著客戶端需要不斷地向伺服器發送請求來檢查是否有新資料(例如通過長輪詢),這可能會導致不必要的網絡負擔和計算資源消耗。

WebSockets 是一種全雙工通信協議,它允許伺服器和客戶端之間的持續連接。這意味著伺服器可以主動將消息推送到客戶端,而不需要客戶端發起新的請求,大大減少了延遲和網絡交通。

1. 基礎設定 – 建立聊天室導航頁及聊天室頁面

我們總共需要建立兩個view,其一是聊天室的導航頁,另一則是供多人實時聊天的聊天室頁面。

在Django APP的views.py中加入以下內容:

from django.shortcuts import render


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

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

並在模板檔案資料夾中增加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.key === 'Enter') {  // 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>

相同資料夾,新增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.key === 'Enter') {  // 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>

並修改專案的urls.py,在urlpatterns中加入端點:

from django.urls import path

from your_app_name import views


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

到目前為止若你執行python manage.py runserver指令,應該要能看到以下資訊:

可以看到我們啟動了ASGI/Daphne version 4.1.2 development server at http://127.0.0.1:8000/

你可以透過 http://127.0.0.1:8000/chat/ 訪問聊天室導航頁:

並輸入房間名稱,會進入對應聊天室頁面,並會透過WebSocket與後端建立連線:

此時你會發現終端機上出現WebSocket HANDSHAKING /ws/chat/room1/ [127.0.0.1:33588]ValueError: No application configured for scope type 'websocket',這是正常現象,因為接下來才要搭建後端WebSocket伺服器,

2. 建立即時多人聊天室WebSocket伺服器

在 Django 中,當接受到一個 HTTP 請求時,系統會查詢根 URL 配置來呼叫對應的view function來處理請求。類似地,當 Channels 接收到一個 WebSocket 連接時,它會查詢根路由配置來尋找對應的 Consumer(消費者),然後調用該消費者上的各種函數來處理來自該連接的事件。

建立一個WebSocket伺服器就是定義一個路徑,同時這個路徑為他編寫一個consumer來處理WebSocket請求。這個範例我們將編寫一個基本的消費者(consumer),該消費者能夠接受位於路徑 /ws/chat/ROOM_NAME/ 上的 WebSocket 連接,並將在 WebSocket 上收到的任何消息回顯到同一個 WebSocket。

使用類似 `/ws/` 的路徑前綴來區分 WebSocket 連接與普通 HTTP 連接是一個好的實踐,因為這樣做會讓在某些配置中,將 Channels 部署到生產環境變得更容易。

這種設置的好處在於,特別是對於大型網站來說,可以配置一個生產級的 HTTP 伺服器(例如 nginx),根據路徑將請求路由到兩個不同的伺服器:

1. 對於普通 HTTP 請求,將它們路由到一個生產級的 WSGI 伺服器,比如 Gunicorn 與 Django 的組合。

2. 對於 WebSocket 請求,則將它們路由到一個生產級的 ASGI 伺服器,比如 Daphne 與 Channels 的組合。

這樣,nginx 可以根據請求的路徑來分辨是普通 HTTP 請求還是 WebSocket 請求,從而將它們分別引導到合適的處理伺服器中。

而對於小型網站,你可以使用更簡單的部署策略,將所有的請求(包括 HTTP 和 WebSocket)都交給 Daphne 來處理,而不用單獨設置一個 WSGI 伺服器。在這種部署配置中,像 `/ws/` 這樣的路徑前綴就不再是必要的。

這樣做的好處在於簡化了路由管理和伺服器配置,尤其是在應用程序規模不大的情況下,可以提高維護和擴展的靈活性。你可以根據網站的需求和規模選擇適合的部署策略。

2-1. 設定Channel Layer

在 Django Channels 中,Channel layer 是構建異步通訊的核心元件,其具備兩個主要組成部分:頻道(Channel)和群組(Group)。這兩者相輔相成,為多樣化的通訊需求提供靈活支持。

  • 頻道(Channel):相當於一個郵箱,用於接受和處理特定訊息,作為 Channel layer 進行訊息傳遞的基本單位。每個頻道擁有一個唯一的名稱(自動生成),這個名稱用於識別特定的通訊對象。通常,每一個頻道對應到一個獨立的 WebSocket 連線,意味著訊息在頻道之間是相互隔離的,因此非常適合用於點對點的通訊。
  • 群組(Group):在 Django Channels 中是一個頻道(Channel)的集合,允許將訊息廣播到集合中的所有頻道,確保所有成員接收到相同的訊息。每個群組都有一個名稱,用於管理和操作其中的頻道。可以方便地將頻道新增到群組中或從群組中移除,並且可以一次性向群組內的所有頻道發送訊息,使其非常適合多點通訊場景。

Channel 和 Group 的結合使得 Django Channels 在面對不同通訊場景時展現出很高的靈活性。在簡單的點對點通訊場景下,頻道可以實現兩個連線之間的直接溝通;而在需要多點廣播的應用中,如聊天室和通知系統,群組允許系統高效地向所有成員廣播訊息。此外,無論是多人遊戲還是協作工具,這種設計確保了訊息在多個對象間的即時性和一致性。因此,頻道和群組的協同工作構成了一個強大且靈活的通訊體系,使得 Django Channels 能夠在各種應用場景中提供有效且高效的即時通訊支持。

若要在 Django Channels 中使用 Redis 作為通道層的後台存儲,我們需要啟動一個 Redis 伺服器。Redis 是一個廣泛使用的記憶體數據結構存儲工具,非常適合作為消息傳遞的中介。

首先安裝 channels_redis 這個 Python 套件:

pip install channels_redis

接著在Django專案中設定我們的CHANNEL_LAYERS,同時也要確保我們的127.0.0.1的6379 port正在被Redis服務監聽:

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    },
}

2-2. 撰寫Consumer

Django Channels 支持同步和異步的 WebSocket 消費者。異步消費者則提供更高效的性能,允許非阻塞地處理多個訊息。然而,在異步消費者中,需避免直接執行阻塞操作,如訪問 Django 模型。可以藉助異步 ORM 來避免此問題。

2-2-1. Synchronous Version

接著我們在APP底下與views.py同級的地方新增一個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 = f"chat_{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}))

這是一個同步的 WebSocket 消費者,它接受所有連線,從客戶端接收訊息,並將這些訊息回傳給相同房間(相同Group)的所有客戶端。

ChatConsumer中,有以下資訊需要稍作留意:

  • self.scope:dict,代表這次連接的相關資訊,主要有以下資訊,
    • type:str,請求類型,在專案的ASGI.py中,ProtocolTypeRouter我們設定的key,這個範例中我們使用了http及websocket兩種協議,對於ChatConsumer我們取得的type永遠是websocket。
    • path:str,端點路徑,如‘/ws/chat/roo/’
    • raw_path:bytes,端點路徑,如b'/ws/chat/roo/'
    • headers:List[Tuple[bytes, bytes]],請求的headers資訊,分別為key跟value,皆以bytes表現。
    • query_string:bytes,建立 WebSocket 連接URL上帶的參數。
    • client:list,有兩個元素ip及port,用戶端WebSocket的連接。
    • server:list,有兩個元素ip及port,伺服器WebSocket服務。
    • cookies:dict,這個連接的Cookie資訊。
    • session:Django Session物件。
    • user:Django的User物件,串接Django的身份驗證時,能夠透過該物件得知當前使用者。
    • url_route:dict,{'args': (), 'kwargs': {'room_name': 'roo'}},在路由過程中,如果您的路徑使用了命名的參數,那麼這些值會保留在 `scope[‘url_route’][‘kwargs’]` 裡。
  • self.room_group_name = f"chat_{self.room_name}":根據使用者連線的聊天室,設定這個Channel屬於哪個Group。Group名稱僅限 ASCII 字母數字字符、連字符(”-“)、底線(”_”)和句點(”.”)且長度在100 個字符之內。
  • async_to_sync(self.channel_layer.group_add)(...):加入Group,透過async_to_sync將async行為轉換為sync。
  • self.accept():在Django Channels中,必須在`connect()`方法中呼叫`accept()`以接受WebSocket連接,否則連接會被自動拒絕。這允許在驗證用戶授權後顯式接受連接,建議將`accept()`放在所有驗證後的最後一步。
  • async_to_sync(self.channel_layer.group_discard)(...):WebSocket連線中斷時離開Group。
  • async_to_sync(self.channel_layer.group_send):在 Django Channels 中,可以將事件發送到一個群組。事件中包含一個特別的 ‘type’ 鍵,這個鍵的值對應於應該在接收該事件的消費者 (consumers) 上調用的方法名。這個轉換是通過將句點 (.) 替換成底線 (_) 完成的。因此,在這個例子中,chat.message 會調用 chat_message 方法。

同步消費者以同步方式處理訊息,每次佔用一個線程,可能不適合高併發需求。

2-2-2. Asynchronous version

同步的 Consumers 很方便,因為它可以直接使用常規的同步 I/O 函數,比如訪問 Django 的模型而不需要編改寫程式碼。然而,異步的 Consumers 可以提供更高的性能,因為在處理請求時不需要創建額外的線程。

在我們的例子中,ChatConsumer 僅使用了支持異步操作的庫(Channels 和通道層),特別是它不涉及同步代碼。因此,我們可以在不引入任何複雜性的情況下,將其重寫為異步的版本。

在APP底下與views.py同級的地方新增或複寫Synchronous版本的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 = f"chat_{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}))

Asynchronous版本與Synchronous版本的Consumer,在邏輯上是相同的,只是部分行為實作上做了調整:

  • ChatConsumer繼承了AsyncWebsocketConsumer方法(原本是WebsocketConsumer)。
  • ChatConsumer的方法都加上async關鍵字。
  • 透過await呼叫非同步function處理I/O。
  • 不再使用async_to_sync

2-3. 設定routing.py

接著在與consumers.py相同資料夾下建立一個routing.py,跟urls.py是類似的東西,只是這邊專們設定WebSocket的路由。

from django.urls import re_path

from . import consumers

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

as_asgi() 的作用是將一個 consumer 類轉換為可用於 ASGI 協議的應用程序接口,這樣一來,當有新的 WebSocket 連接或其他協議連接建立時,ASGI 就能夠實例化一個 consumer 來處理這個連接的所有事件和消息。這與 Django 中的 as_view() 類似,後者在每個請求中扮演著對應的角色,即為 Django 視圖實例進行處理。

最後我們回到Django專案的asgi.py,並修改如下:

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()

from chat.routing import websocket_urlpatterns

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(
            AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
        ),
    }
)

透過ProtocolTypeRouter根據連接協議類型(如 HTTP、WebSocket 等)來決定如何處理該連接。並透過AllowedHostsOriginValidator檢查Host是否在被允許清單內,另外再由AuthMiddlewareStack自動給每個 WebSocket 連接上下文(scope)中填入當前已認證的使用者信息。最後再由URLRouter根據不同路徑分配給不同的Consumer。

以上設定都完成後,你可以嘗試開啟兩個視窗,進入相同聊天室,你會發現在其中一個視窗輸入訊息後,兩個視窗都能夠顯示相同訊息,此時代表你成功搭建了一個多人在線的即時聊天室了。

C. 總結

文章介紹了如何使用 Django Channels 在 Django 架構中建立即時更新的多人聊天室。文章首先講解了安裝 Channels 這個套件的基本步驟,包括 Django 專案初始化、安裝相關 Python 套件如 ‘channels[Daphne]’ 並設置相關參數。

接著介紹了 WebSocket 協議的用法以及如何藉由 Channel Layer 實現多名用戶同時互動的聊天功能。具體步驟包括定義 Django 的 views、撰寫同步及非消費者 (Consumers) 來處理 WebSocket 信息,以及配置路由檔案以準確匹配這些功能模組。

D. 系列文章