Routing – Django Channels:實現 WebSocket 異步通信,打造高效的長連接系統

A. 簡介

在使用 Django Channels 時,雖然消費者(consumers)本身就是有效的 ASGI 應用程式,但你不應該僅僅撰寫一個消費者並將其設置為提供給像 Daphne 這樣的協議伺服器的唯一應用程式。為了更靈活地管理應用程式,Channels 提供了路由(routing)類,可以讓你將消費者和其他有效的 ASGI 應用程式合併和堆疊,以便根據連接的性質進行分派。

Channels 的路由器僅在範圍(scope)層工作,而不是在個別事件的層次上運作,這意味著對於任何給定的連接,你只能指定一個消費者。路由的目的是決定要將一條連接交給哪個單一消費者,而非將一個連接的事件分散至多個消費者。

路由器本身也是有效的 ASGI 應用程式,而且可以嵌套。建議將 ProtocolTypeRouter 設為專案的根應用程式,這個應用程式是你會傳遞給協議伺服器的,然後在其中嵌套其他更具體的協議路由。Channels 預期你能夠定義一個單一的根應用程式,並透過 ASGI_APPLICATION 設定提供其路徑(類似於 Django 中的 ROOT_URLCONF 設定)。對於路由和根應用程式應放置在哪裡並沒有固定的規則,但建議遵循 Django 的慣例,將它們放在名為 asgi.py 的項目層級檔案中,並放置在 urls.py 的旁邊。

以下是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
from django.urls import path

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.consumers import AdminChatConsumer, PublicChatConsumer

application = ProtocolTypeRouter({
    # Django's ASGI application to handle traditional HTTP requests
    "http": django_asgi_app,

    # WebSocket chat handler
    "websocket": AllowedHostsOriginValidator(
        AuthMiddlewareStack(
            URLRouter([
                path("chat/admin/", AdminChatConsumer.as_asgi()),
                path("chat/", PublicChatConsumer.as_asgi()),
            ])
        )
    ),
})

在配置 Django Channels 的路由時,我們會使用 `as_asgi()` 類方法。這個方法會返回一個 ASGI 包裝應用程序,這樣每當有新的連線或 scope(範圍)時,就會自動實例化一個新的 consumer(消費者)實例。這種設計理念與 Django 的 `as_view()` 類似,後者是在每個請求中為 class-based views(基於類的視圖)創建相應的實例。

B. ProtocolTypeRouter

channels.routing.ProtocolTypeRouter

在建立 Django Channels 應用時,ASGI(Asynchronous Server Gateway Interface)應用程式堆疊的頂層應用,通常會是在路由檔案(routing file)中的主要入口。這個入口負責根據「範疇」(scope)中的 type 值,將請求分派給不同的其他 ASGI 應用。因此,協議會定義範疇內固定的 type 值,讓你可以利用這樣的資訊來辨別進來連線的類型。

具體來說,它需要一個參數:一個字典,鍵指的是 type 名稱,值則是提供這些連接類型服務的 ASGI 應用程式。這樣設計的目的是為了能夠柔性地處理不同類型的連接,例如 HTTP 請求、WebSocket 連接等等。

ProtocolTypeRouter({
    "http": some_app,
    "websocket": some_other_app,
})

此外,如果你希望在 HTTP 處理上區分長輪詢(long-poll)處理器與 Django 傳統的視圖,你可以在使用 URLRouter 路由時,把 Django 的 `get_asgi_application()` 指定為最後一個進入點,並使用一個「匹配所有」的模式(pattern)。這樣的設定會確保 Django 在其他特定 ASGI 處理應用都沒有匹配上請求時,作為最後的處理者來處理剩下的 HTTP 請求。

這種架構的好處是可擴展性:你可以在不改變整體結構的情況下,增加更多不同類型的 ASGI 應用處理不同的連接類型,或擴充新的功能,讓 Django Channels 的應用更加靈活和強大。

C. URLRouter

channels.routing.URLRouter

在 Django Channels 中,路由配置負責通過 HTTP 路徑來管理 HTTP 或 WebSocket 類型的連接。這個路由配置需要一個參數,即 Django URL 對象的列表,這些對象可以是 `path()` 或 `re_path()` 函數的結果。

URLRouter([
    re_path(r"^longpoll/$", LongPollConsumer.as_asgi()),
    re_path(r"^notifications/(?P<stream>\w+)/$", LongPollConsumer.as_asgi()),
    re_path(r"", get_asgi_application()),
])

任何捕獲到的群組將以字典的形式在 scope 中提供,這個字典的鍵為 url_route,包含兩個鍵:kwargs 和 args。kwargs 鍵對應一個字典,該字典包含所有命名的正則表達式群組,而 args 鍵對應一個列表,包含位置正則表達式群組。

stream = self.scope["url_route"]["kwargs"]["stream"]
注意,命名和未命名的群組不能混用:一旦匹配到一個命名群組,位置群組將被拋棄。
from django.urls import re_path
from myapp import consumers

# 使用命名群組的路由定義
websocket_urlpatterns_named = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer.as_asgi()),
]

# 使用位置群組的路由定義
websocket_urlpatterns_positional = [
    re_path(r'ws/chat/(\w+)/$', consumers.ChatConsumer.as_asgi()),
]

在這個範例中,我們有兩個路由定義:

1. **命名群組路由**:使用 `(?P<room_name>\w+)`,這是一個命名的正則表達式群組,它會將匹配到的字串存入 `scope[‘url_route’][‘kwargs’]` 中,作為一個 key-value 項,被命名為 `room_name`。

2. **位置群組路由**:使用 `(\w+)`,這是一個位置的正則表達式群組,匹配的字串會存入 `scope[‘url_route’][‘args’]` 中,作為一個列表項。

若你嘗試在同一個路由中混用命名和位置群組,例如:

re_path(r'ws/chat/(?P<room_name>\w+)/(\d+)/$', consumers.ChatConsumer.as_asgi())

在這個例子中,`(?P<room_name>\w+)` 是命名群組,而 `(\d+)` 是未命名的(位置)群組。根據 Django Channels 的行為,一旦匹配到命名群組,所有位置群組將被丟棄,也就是說 `(\d+)` 的匹配結果不會被儲存,因此這種混用方式會導致資料無法被正確捕獲。這就是為何建議不要混用兩者的原因。

注意,如果 URLRouter 的內層路由器被額外的中介軟體包裹,那麼 path() 路徑形式的路由嵌套將無法正常運作。

假設我們有一個 Django Channels 配置想要通過 URLRouter 嵌套來管理不同的路徑,但我們還在使用中介軟體來處理某些請求:

from django.urls import path
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.middleware import MiddlewareStack
from myapp import consumers

# 定義頂層的路由
application = ProtocolTypeRouter({
    # HTTP 協議不進行任何路由,直接傳遞
    "http": URLRouter([
        path("ws/", MiddlewareStack(  # 在這裡使用中介軟體包裹 URLRouter
            URLRouter([
                path("chat/", consumers.ChatConsumer.as_asgi()),
            ])
        )),
    ]),
})

在這個範例中,我們使用 `MiddlewareStack` 來包裹內層的 `URLRouter`。如果你使用的是 `path()`,而不是 `re_path()`,這種方式可能會引發問題。這是因為 `path()` 路由與中介軟體的某些交互可能會導致路徑未按預期匹配。

解決方法之一是避免這種嵌套的結構,或者在特定需要的情況下使用正則表達式(`re_path()`)來處理更複雜的匹配。

總結來說,當內層路由器包被額外的中介軟體包裹時,確保將可能存在的問題考慮在內,並根據需求選擇適當的路徑匹配方式。

D. ChannelNameRouter

channels.routing.ChannelNameRouter

根據 scope 中 channel 鍵的值來路由頻道類型範圍。此功能旨在用於 Worker 和背景任務上。它接收一個參數(字典資料格式),該字典將頻道名稱映射到為它們服務的 ASGI 應用程式。

ChannelNameRouter({
    "thumbnails-generate": some_app,
    "thumbnails-delete": some_other_app,
})

E. 總結

Django Channels 為 Django 應用程式提供了處理異步通信和長連接協議的能力。使用 Channels 時,應用程序的架構通常由不同的 ASGI 路由器組成,每個路由器負責將請求分派給適當的消費者(consumers)。在這個架構中,`ProtocolTypeRouter` 充當根路由器,區分不同的協議類型,例如 HTTP 和 WebSocket,並進行相應的處理。`URLRouter` 則利用 URL 路徑路由 HTTP 或 WebSocket 連接,其核心在於處理 URL 對象列表,以管理請求的路徑匹配。對於需要根據特定頻道名稱來處理的情況,`ChannelNameRouter` 提供了一個針對風格化命名頻道的映射解決方案,特別適合於後台的工作者任務。在這些工具的協作下,Django Channels 提供了靈活且可擴展的設計,簡化了日益增長的實時網絡功能需求。

F. 系列文章