新發布得到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伺服器上。