新發布得到Django 3.1中,提供了對步檢視的支援。在附帶的官方教程提供了一個有關Django非同步檢視示例演示在呼叫時的非同步執行asyncio.sleep。但是對此很多人會疑惑,這個sleep能幹什麼呢?本文我們就一起來學習一下 Django中的非同步檢視就能幹啥。
Django非同步檢視Django現在允許使用者編寫可以非同步執行的檢視view。Django中一個簡單且最小的同步檢視重新整理記憶體的示例:
def index(request):
return HttpResponse("This is a page.")
該例子接受一個請求物件並返回一個響應物件。在實際專案中,檢視承擔著很多工作,包括從資料庫中獲取記錄,呼叫服務或渲染模板。在目前的情況下,它們都是同步工作得,需要按照順序一個接一個地來執行。
在Django的MTV(Model Template View,模型-模板-檢視)體系結構中,檢視比其他部件更強大(大略感覺相當於MVC架構中的控制器)。在檢視中,幾乎可以執行建立響應所需的任何邏輯。這就是為什麼非同步檢視的重要性,可以讓我們同時做更多的事情。
編寫非同步檢視非常容易,只需在一般的函式前面增加個async。例如,上述最小示例的非同步版本為:
async def index_async(request): return HttpResponse("This is a asynchronously page!")
但是這樣定義的看上去和函式很像,但是她是協程而不是函式。我們不能直接呼叫,而要建立一個事件迴圈來執行。
請注意,此特定檢視不是非同步呼叫任何內容。如果Django以傳統的WSGI模式執行,則將建立(自動)新的事件迴圈來執行此協程。因此,在這種情況下,它可能會比同步版本慢一些。但這是因為沒有使用它來同時執行任務。
那麼,為什麼還要麻煩編寫非同步檢視呢?同步檢視的侷限性只有在訪問規模很大才顯現出其瓶頸。當涉及到大型Web應用程式時,比如FaceBook。
FaceBook的檢視Facebook釋出了靜態分析工具pysa來檢測和預防Python中的安全問題。在關注其程式碼時候,發現其示例都是非同步的寫法。
可以肯定,雖然這不是Django,但是肯定是類似的框架。
綜合考慮,Django將目前默認同步執行的檢視改為非同步還是非常有意義的。雖然等待I/O運算元微秒時,但是這會阻塞。如果換成非同步就不會任何阻塞,可以同時處理其他任務,從而以較低的延遲處理更多的請求。這尤其對Facebook這樣的大型網站效能改善而言。執行緒排程程式可能會在破壞性的共享資源更新之間中斷,從而導致難以除錯競爭條件。與執行緒相比,協程可以以更少的開銷實現更高級別的併發。
誤導性sleep例子Django非同步檢視教程中都只簡單提供了一個涉及sleep的示例。甚至正式的Django發行說明也包含以下示例:
async def my_view(request): await asyncio.sleep(0.5) return HttpResponse('Hello, async world!')
對於絕大多數人來說,這程式碼在可能會有誤導。同步或非同步發生的sleep對終端使用者沒有啥意義和效果。開連結到該檢視的URL,需要等待0.5秒,然後它才會返回一個 "Hello, async world!"。如果是一位新手,則可能會期望立即得到答覆。這與time.sleep()檢視中的同步物件相比,沒有啥意義。
非同步世界中的大多數事情一樣,在事件迴圈中。如果事件迴圈中還有其他任務等待執行,則該半秒視窗將為其他任務提供執行該任務的機會。協程假定每個人都能快速工作,並迅速將控制元件移交給事件迴圈。
一些命令列介面使用sleep來給使用者足夠的時間以使其消失之前閱讀訊息。但這對於Web應用程式是相反的-來自Web伺服器的更快響應是改善使用者體驗的關鍵。
更好的例項編寫非同步檢視之前要記住的經驗法則是檢查它是受I/O密集型還是受CPU密集型。大部分時間花費CPU密集型任務中的檢視(例如,矩陣乘法或影象處理)實際上不會從非同步檢視中受益,而專注於I/O繫結的活動。
呼叫微服務目前大多數大型Web應用程式正從單一架構轉型到有很多微服務組成的架構。渲染檢視可能需要許多內部或外部服務的結果。
比如這樣一個示例,在書籍電子商務網站顯示推薦書籍,為登入使用者量身定製了首頁。推薦引擎通常被實現為單獨的微服務,該微服務基於過去的購買歷史以及通過了解過去的推薦的成功程度來進行一些機器學習來做出推薦。
在這種情況下,還需要另一個微服務的結果,該服務決定將哪些促銷橫幅顯示為旋轉橫幅或幻燈片顯示給使用者。這些標語不是為登入使用者量身定製的,而是根據當前銷售的商品(有效的促銷活動)或日期而變化。這樣一個例項的同步版本:
def sync_home(request): context = {} try: response = httpx.get(PROMO_SERVICE_URL) if response.status_code == httpx.codes.OK: context["promo"] = response.json() response = httpx.get(RECCO_SERVICE_URL) if response.status_code == httpx.codes.OK: context["recco"] = response.json() except httpx.RequestError as exc: print(f"An error occurred while requesting {exc.request.url!r}.")return render(request, "index.html", context)
使用httpx庫來代替流行的Python請求庫,因為它支援發出同步和非同步Web請求。介面幾乎是相同的。
該檢視的問題在於,由於這些服務順序發生,因此呼叫這些服務所花費的時間加在一起。Python程序被掛起,直到第一個服務響應,在最壞的情況下這可能需要很長時間。
讓嘗試使用簡單(且無效)的await呼叫併發執行它們:
async def async_home_inefficient(request): context = {} try: async with httpx.AsyncClient() as client: response = await client.get(PROMO_SERVICE_URL) if response.status_code == httpx.codes.OK: context["promo"] = response.json() response = await client.get(RECCO_SERVICE_URL) if response.status_code == httpx.codes.OK: context["recco"] = response.json() except httpx.RequestError as exc: print(f"An error occurred while requesting {exc.request.url!r}.")return render(request, "index.html", context)
請注意,檢視已從函式更改為協程(由於async def關鍵字)。另請注意,例項中兩個地方等待每種服務的響應。不必嘗試在這裡理解每一行,因為將透過一個更好的示例進行解釋。
有趣的是,該檢視不能同時工作,並且所花費的時間與同步檢視相同。如果熟悉非同步程式設計,可能已經猜到只是等待協程並不會使其同時執行其他事情,只會將控制權交還給事件迴圈。檢視仍然被暫停。
讓我們看一下同時執行事務的正確方法:
async def async_home(request): context = {} try: async with httpx.AsyncClient() as client: response_p, response_r = await asyncio.gather( client.get(PROMO_SERVICE_URL), client.get(RECCO_SERVICE_URL) ) if response_p.status_code == httpx.codes.OK: context["promo"] = response_p.json() if response_r.status_code == httpx.codes.OK: context["recco"] = response_r.json() except httpx.RequestError as exc: print(f"An error occurred while requesting {exc.request.url!r}.")return render(request, "index.html", context)
如果我們正在呼叫的兩個服務具有相似的響應時間,那麼與同步版本相比,此檢視應在_half _time中完成。這是因為呼叫可以同時發生。
有一個外部try ... except塊可以在進行任何HTTP呼叫時捕獲請求錯誤。然後是一個內部async ... with塊,它提供了一個包含客戶端物件的上下文。
最重要的一行是asyncio.gather呼叫,其中包含兩個client.get呼叫建立的協程。collect呼叫將同時執行它們,並且僅在它們都完成時才返回。結果將是響應的元組,將其分解為兩個變數response_p和response_r。如果沒有錯誤,則將這些響應填充到傳送的用於模板渲染的上下文中。
微服務通常是組織內部的,因此響應時間短且變化少。但是,絕對不依賴同步呼叫在微服務之間進行通訊永遠不是一個好主意。隨著服務之間的依賴性增加,它會建立一長串的請求和響應呼叫。這樣的連鎖會減慢服務速度。
還有一很實際的例子就是Web抓取的問題,因為有許多非同步示例使用它們。這樣同時獲取和抓取多個外部網站或網站中的頁面以獲取實時股票市場(或比特幣)價格等資訊的情況。該實現將與我們微服務示例中看到的非常相似。
但這是非常危險的,因為檢視應能儘快將響應返回給使用者。因此,嘗試獲取具有這種隨著資訊時間變化的的站點可能會導致獲取過時的資訊。而微服務呼叫通常是內部的,因此可以透過適當的SLA來控制響應時間。
理想情況下,抓取應在安排為定期執行的單獨過程中進行(使用celery或)。該檢視應僅選擇已採集的值並將其顯示給使用者。
檔案服務通常有一個需求,需要透過動態內容來提供檔案服務。檔案通常位於基於磁碟的儲存(較慢的)中。儘管使用Python可以很容易地完成此檔案操作,但就大型檔案的效能而言,它可能會很昂貴。無論檔案大小如何,這都是一個潛在的阻塞I/O操作,可用於同時執行另一個任務。
假設我們需要在Django檢視中提供PDF證書。但是,出於某種原因(可能用於標識和驗證),需要將下載證書的日期和時間儲存在PDF檔案的元資料中。
該示例中我們使用aiofiles庫進行非同步檔案I/O。該API與熟悉的Python內建檔案API幾乎相同。下面非同步檢視的編寫方式:
async def serve_certificate(request): timestamp = datetime.datetime.now().isoformat() response = HttpResponse(content_type="application/pdf") response["Content-Disposition"] = "attachment; filename=certificate.pdf" async with aiofiles.open("homepage/pdfs/certificate-template.pdf", mode="rb") as f: contents = await f.read() response.write(contents.replace(b"%timestamp%", bytes(timestamp, "utf-8"))) return response
該例子說明了為什麼我們需要在Django中進行非同步模板渲染。但是在實現之前,只能使用aiofiles庫來提取本地檔案拍。直接使用本地檔案而不是Django的staticfiles有不利之處。
處理上傳另一方面,上傳檔案也可能是很長的阻塞操作。出於安全和組織方面的原因,Django將所有上傳的內容儲存在單獨的"媒體"目錄中。
如果有一種允許上傳檔案的表單,那麼我們需要預料到一些討厭的使用者會上傳一個不可能很大的檔案。值得慶幸的是,Django將檔案以一定大小的塊傳遞給檢視。結合aiofile非同步寫入檔案的功能,我們可以支援高度併發的上傳。
async def handle_uploaded_file(f): async with aiofiles.open(f"uploads/{f.name}", "wb+") as destination: for chunk in f.chunks(): await destination.write(chunk)async def async_uploader(request): if request.method == "POST": form = UploadFileForm(request.POST, request.FILES) if form.is_valid(): await handle_uploaded_file(request.FILES["file"]) return HttpResponseRedirect("/") else: form = UploadFileForm() return render(request, "upload.html", {"form": form})
同樣,這繞過了Django的預設檔案上傳機制,因此需要注意安全隱患。
總結Django Async專案具有完全向後相容性,這是其主要目標之一。因此,可以繼續使用舊的同步檢視,而無需將其重寫為非同步檢視。非同步檢視也並不是解決所有效能問題的靈丹妙藥,因此大多數專案仍將會繼續使用同步程式碼,因為它們非常容易推理。
實際上,可以在同一專案中同時使用非同步檢視和同步檢視。Django將負責以適當的方式呼叫檢視。但是,如果使用的是非同步檢視,建議將應用程式部署在ASGI伺服器上。