實時更新的多人聊天室 – 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 runserve
r指令,應該要能看到以下資訊:
可以看到我們啟動了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’]` 裡。
- type:str,請求類型,在專案的
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 信息,以及配置路由檔案以準確匹配這些功能模組。