Django的傳統(tǒng)概念圍繞著HTTP請求和響應(yīng)展開:服務(wù)器收到一個請求,Django調(diào)起為其服務(wù),生成響應(yīng)并發(fā)送,然后Django離開、等著下一個請求。
當(dāng)互聯(lián)網(wǎng)只包含簡單的瀏覽器行為時,這沒什么問題。但現(xiàn)代網(wǎng)絡(luò)包含了WebSocket和HTTP 2服務(wù)器推送等功能,允許網(wǎng)站在傳統(tǒng)請求/響應(yīng)循環(huán)之外進(jìn)行其他通信。
除此之外,還有許多非關(guān)鍵的任務(wù),需要應(yīng)用程序?qū)⑵浞职l(fā)出去,在響應(yīng)發(fā)送后繼續(xù)處理——比如保存緩存,或?yàn)樾律蟼鞯膱D像生成縮略圖。
Channels將Django的運(yùn)行方式改為“面向事件”——不僅僅響應(yīng)請求,而是響應(yīng)通道中發(fā)送的大量事件。這里仍然沒有持久狀態(tài)——每個事件處理程序,也可以叫事件消費(fèi)者,都是以獨(dú)立的方式調(diào)用的,就像調(diào)用視圖一樣。
讓我們先看看通道是什么。
通道是什么?
理所當(dāng)然,核心就是叫做通道的數(shù)據(jù)結(jié)構(gòu)。通道是什么?它是一個有序的、先進(jìn)先出的隊(duì)列,消息會過期,最多發(fā)一次,一次只發(fā)給一個監(jiān)聽?wèi)?yīng)用。
可以將其類比為任務(wù)隊(duì)列——生產(chǎn)者將消息發(fā)送到一個通道,再發(fā)給一個監(jiān)聽該通道的消費(fèi)者。
最多發(fā)一次是指,一條消息要么有一個消費(fèi)者收到,要么沒人收到(例如通道突然崩潰了)。與之對應(yīng)的是至少一次:正常情況下,一條消息有一個消費(fèi)者收到,但程序崩潰時,它極有事后可能再重新發(fā)送,因此還會有其他消費(fèi)者重復(fù)收到。后者不是我們想要的。
還有其他幾個限制——消息必須是可序列化的類型,并且必須保持在一定的大小限制之內(nèi)——但是在接觸更高級的用法之前,無需擔(dān)心這些實(shí)現(xiàn)細(xì)節(jié)。
通道有容量,即便沒有消費(fèi)者,生產(chǎn)者也可以先向通道中寫入大量消息,消費(fèi)者稍后出現(xiàn)再接收隊(duì)列中的消息。
如果你使用過Go通道:Go通道與Django通道相當(dāng)像,關(guān)鍵區(qū)別在于Django通道是網(wǎng)絡(luò)透明的,在不同進(jìn)程甚至不同計(jì)算機(jī)上運(yùn)行的生產(chǎn)者和消費(fèi)者,都可以通過網(wǎng)絡(luò)訪問到我們的通道。
在網(wǎng)絡(luò)中,我們通過名稱字符串來唯一地標(biāo)識通道——任何計(jì)算機(jī)可以將消息發(fā)送到任何命名通道,只要它們接入了同一個通道后端。例如兩臺不同的計(jì)算機(jī)都寫入叫做“http.request”的通道,他們寫入的就是同一個通道。
如何使用通道?
那么Django如何使用這些通道呢?在Django中,您可以編寫一個函數(shù)來使用通道:
def my_consumer(message):
pass
然后在通道路由中為其分配一個通道:
channel_routing = {
"some-channel": "myapp.consumers.my_consumer",
}
該通道每收到一條消息,Django都調(diào)用這個消費(fèi)者函數(shù),并傳入message參數(shù)。message帶有content、channel和其他一些屬性,其中content屬性一定是字典(dict)類型,channel屬性用來標(biāo)明發(fā)送消息的通道名稱。
Channels將Django從傳統(tǒng)的請求/響應(yīng)模式,改為工作進(jìn)程模式:監(jiān)聽所有分配了消費(fèi)者的通道,收到消息就運(yùn)行對應(yīng)的消費(fèi)者?,F(xiàn)在,Django不只是在一個連接到WSGI服務(wù)器的進(jìn)程中運(yùn)行,而是在三個獨(dú)立的層中運(yùn)行:
- 接口服務(wù)器,用于Django與外部世界之間的通信。這包括一個WSGI適配器以及一個單獨(dú)的WebSocket服務(wù)器——這在運(yùn)行接口服務(wù)器中進(jìn)行了介紹。
- 通道后端,其中包括可擴(kuò)展的Python代碼,以及用來傳輸消息的數(shù)據(jù)存儲機(jī)制(例如Redis或共享內(nèi)存段)。
- 工作進(jìn)程,監(jiān)聽所有相關(guān)的通道,并在消息就緒時運(yùn)行消費(fèi)者代碼。
這或許看起來很簡單,但我們就是這樣計(jì)劃的:與其嘗試完全異步的架構(gòu),不如將現(xiàn)有的Django視圖再稍微復(fù)雜抽象一點(diǎn)。
A view takes a request and returns a response; a consumer takes a channel message and can write out zero to many other channel messages.
現(xiàn)在,讓我們建立一個請求通道(取名http.request),以及面向單個客戶端的響應(yīng)通道(例如http.response.o4f2h2fd),請求通道中的消息帶有一個reply_channel屬性,對應(yīng)響應(yīng)通道的名稱。這樣,消息消費(fèi)者就很類似一個視圖:
# 監(jiān)聽http.request
def my_consumer(message):
# 將請求信息從message中解碼,生成Request對象
django_request = AsgiRequest(message)
# 運(yùn)行視圖
django_response = view(django_request)
# 將響應(yīng)編碼為message格式
for chunk in AsgiHandler.encode_response(django_response):
message.reply_channel.send(chunk)
這就是通道的機(jī)制。接口服務(wù)器將外部連接(HTTP、WebSocket等)轉(zhuǎn)換為通道中的消息,你編寫工作進(jìn)程來處理這些消息。正常情況下,普通的HTTP請求還是交給Django內(nèi)置的消費(fèi)者,后者將請求傳入視圖/模板系統(tǒng),但如果需要,也可以重寫它以添加功能。
關(guān)鍵的部分在于,你可以運(yùn)行代碼來響應(yīng)任何事件——包括你自己創(chuàng)建的事件,運(yùn)行的代碼還可以進(jìn)一步發(fā)消息。你可以在保存Model、其他Views和Forms的代碼中觸發(fā)事件。這樣可以方便的寫推送程序,例如使用WebSocket或HTTP長輪詢來實(shí)時通知客戶端(比如聊天信息,或者Admin實(shí)時查看其他用戶編輯更新的內(nèi)容)。
通道類型
通道主要有兩種用途。第一種顯而易見,就是把工作分配給消費(fèi)者——通道中新增一條消息,任何一個工作進(jìn)程都可以接收消息并運(yùn)行消費(fèi)者。
第二種通道用于響應(yīng)(HTTP、WebSocket)請求。值得注意的是,只有接口服務(wù)器才會監(jiān)聽這種通道的消息。每個響應(yīng)通道名字都不一樣,并且必須路由回到特定的接口服務(wù)器。
兩者區(qū)別不大——它們都符合通道的核心定義——但當(dāng)服務(wù)器集群擴(kuò)大規(guī)模時會有問題。對于第一類普通通道,我們可以在通道服務(wù)器集群和工作進(jìn)程中無差別的做負(fù)載均衡,任何工作進(jìn)程都可以通用的處理這些消息。但響應(yīng)消息只能發(fā)送到通道服務(wù)器群中、正在監(jiān)聽該響應(yīng)的通道服務(wù)器上。
因此,Channels將其視為兩種不同的通道類型,在響應(yīng)通道名稱中包含感嘆號!來標(biāo)識,例如http.response!f5g3fe21f。普通通道名稱不包含感嘆號。此外,通道名稱都只能包含字符a-z A-Z 0-9 - _,并且長度小于200個字符。
對于后端實(shí)現(xiàn)來說,不一定要處理這個區(qū)別——只有擴(kuò)大服務(wù)器集群規(guī)模時,才有必要分別處理這兩類通道。有關(guān)規(guī)?;母嘈畔ⅲ约熬帉懞蠖嘶蚪涌诜?wù)器時如何處理通道類型,請參見:規(guī)?;?/a>。
分組
通道只能向一個監(jiān)聽方發(fā)送消息,不能廣播。如果要向一批客戶端發(fā)送消息,你需要記錄所有客戶端對應(yīng)的響應(yīng)通道。
如果我有一個Liveblog,想在發(fā)布新帖子時推送更新,我可以注冊一個程序處理post_save信號,同時得記下要發(fā)送更新的通道(這個例子使用Redis):
redis_conn = redis.Redis("localhost", 6379)
@receiver(post_save, sender=BlogUpdate)
def send_update(sender, instance, **kwargs):
# 遍歷所有的reply channels,發(fā)送更新
for reply_channel in redis_conn.smembers("readers"):
Channel(reply_channel).send({
"text": json.dumps({
"id": instance.id,
"content": instance.content
})
})
# 連接到websocket.connect
def ws_connect(message):
# 添加到readers集合
redis_conn.sadd("readers", message.reply_channel.name)
這段代碼可以運(yùn)行,但還有點(diǎn)小問題——客戶端中斷連接的時候,我們要記得把他們從readers中刪除。為此我們要添加另一個消費(fèi)者程序,監(jiān)聽和處理websocket.disconnect消息。而且,接口服務(wù)器可能遇到強(qiáng)退、斷電等情況,來不及發(fā)送disconnect信號,你的代碼永遠(yuǎn)收不到disconnect通知,但此時響應(yīng)通道已經(jīng)完全失效了,為此我們還需要添加過期機(jī)制,發(fā)送到響應(yīng)通道的消息等待一段時間后會過期、被刪除,
由于通道的設(shè)計(jì)基礎(chǔ)是無狀態(tài)的,所以如果接口服務(wù)器中斷連接,通道服務(wù)器也無所謂“關(guān)閉”一條通道——通道的任務(wù)就是保存消息直到消費(fèi)者出現(xiàn)(對某些類型的接口服務(wù)器比如SMS網(wǎng)關(guān),理論上可以為來自任何接口服務(wù)器的任何客戶端提供服務(wù))。
我們不太介意斷開連接的客戶端是不是沒收到消息——是它自己斷開的——但持續(xù)維護(hù)這些斷開的客戶端會讓通道后端很亂,累積造成通道名稱重復(fù)、發(fā)錯消息,這讓我們很介意。
現(xiàn)在,我們回到前面的示例,需要添加過期集合、跟蹤過期時間等等,但使用框架的意義就是替你實(shí)現(xiàn)這些重復(fù)的工作。于是Channels將此抽象實(shí)現(xiàn)為一個核心概念,叫做組:
@receiver(post_save, sender=BlogUpdate)
def send_update(sender, instance, **kwargs):
Group("liveblog").send({
"text": json.dumps({
"id": instance.id,
"content": instance.content
})
})
# 連接到websocket.connect
def ws_connect(message):
# 添加到readers組
Group("liveblog").add(message.reply_channel)
# Accept the connection request
message.reply_channel.send({"accept": True})
# 連接到websocket.disconnect
def ws_disconnect(message):
# Remove from reader group on clean disconnect
Group("liveblog").discard(message.reply_channel)
組不僅有自己的send()方法(后端可以提供高效的實(shí)現(xiàn)),而且還自動管理組成員過期——當(dāng)通道消息沒人讀而開始過期時,我們找到所有包含該通道的組,將其從中刪除。當(dāng)然,如果能正常收到disconnect消息的話,你還是應(yīng)該在斷開連接時將渠道從組中刪除,過期機(jī)制是為了解決無法正常收到disconnect消息的情況。
組通常只用于響應(yīng)通道(包含感嘆號!的通道),但如果你愿意,也可以用于普通通道。
下一步
這是對通道和組的高級概述,幫助你形成一些初步概念。要記住,Django提供了一些通道,但您可以自由地創(chuàng)建和使用自己的通道,所有通道都是網(wǎng)絡(luò)透明的。
另外,通道不保證消息發(fā)送成功。如果你需要確保任務(wù)完成,請使用專門設(shè)計(jì)的、帶有重試和持久化功能的系統(tǒng)(例如Celery)。你也可以創(chuàng)建管理命令,如果有任務(wù)沒完成,就再次向通道提交消息(換言之,自己維護(hù)重試邏輯)。
我們將在其余文檔中更多地介紹什么任務(wù)適合用通道,現(xiàn)在讓我們進(jìn)到開始來寫些代碼。