錯誤處理(下)全域錯誤處理——使用 Exception Handlers
文章目錄
2024 iThome 鐵人賽
這是 Django Ninja 系列教學的第 22 篇。
上一篇文章,我們學習了如何操作HttpError
,並建議你只在 view 函式中使用它。
但光是這樣,專案 API 的錯誤處理,還遠遠不夠完善,至少有 3 個常見問題待解:
- Schema 中的驗證方法 ,如果不要
raise HttpError
,那要怎麼做才好? - 我們應該如何處理其他類型的錯誤,例如資料庫操作錯誤?
- 如何確保不同 API 錯誤的回應格式一致?
這些問題都指向了一個更大的需求:我們需要一個全面的錯誤處理機制。
這篇文章,就要來回答這些問題。所有的程式碼改動,可參考這個 PR。
改用 Django ValidationError
還記得驗證方法中,最原始的版本是拋出ValueError
嗎?
ValueError
會被 Django Ninja 自動捕捉並給出 422 回應,這是好事,但不符合我們的自定義需求。
所以後來我們改採了HttpError
,它雖然也會被捕捉,但回應的格式與內容比較簡潔——而且除了錯誤訊息,還能自訂狀態碼。
然而,如上一篇所述,這樣做雖然簡單,卻並不合適。
那究竟要拋出什麼錯誤?
避免使用 Pydantic 或 Django Ninja 提供的錯誤
上一篇還提到,無論 Pydantic 或 Django Ninja,都有自己內建的ValidationError
。
但它們更多是供框架內部使用,而且回傳的錯誤格式過於詳細,初始化方式也很龜毛。比如 Django Ninja 的驗證錯誤,需要這樣初始化:
1 | raise ValidationError( |
這不是我們熟悉的「塞一個錯誤訊息字串」就好了。
因此,我並不推薦在驗證邏輯中直接使用這些錯誤類型。
請愛用 Django ValidationError
在 Schema 驗證邏輯中,我們更應該使用 Django 內建的ValidationError
。
它的設計已經完整考慮到了開發者的需求,初始化方式可簡單(使用單一字串)可複雜(使用list
或dict
),適合絕大多數場景。
這裡我們用字串來初始化即可,程式碼修正如下:
1 | from django.core.exceptions import ValidationError |
將原本的HttpError
改為了 Django 的ValidationError
。
並以「錯誤訊息字串」作為初始化方式,少了原本的第一參數「狀態碼」。
Django Ninja 不會自動處理這些錯誤
將拋出的錯誤類型改為 Django 的ValidationError
後,你可能會注意到一個問題:Django Ninja 並不會自動捕捉這些錯誤!
也就是說,當我們拋出ValidationError
時,Django Ninja 不會像處理HttpError
一樣,自動格式化並返回 422 錯誤回應——而是直接 500。
這部分我們在〈卷 20:資料驗證(下)Pydantic 跨欄位驗證〉的結尾處提過。
現在,則要介紹具體的解決之道——exception_handler
。
我們需要自行處理這些拋出的錯誤,這正是exception_handler
發揮作用的地方。
全域錯誤處理器——Exception Handlers
為了統一處理這些不同來源(不限於 Schema 驗證方法)的同類型錯誤,我們可以使用 Django Ninja 提供的@api.exception_handler
裝飾器。
這個裝飾器允許我們針對「特定類型的錯誤」定義專屬的回應邏輯,並套用到整個 API 範圍內。
定義exception_handler
我們可以為 Django 的ValidationError
定義一個全域錯誤處理器,確保當任何地方拋出這個錯誤時,handler 都會加以捕捉,讓 API 返回我們自定義的回應格式。
在專案api.py
中,加入下列程式碼:
1 | # NinjaForum/api.py |
我們定義了一個 exception handler 函式,當遇到 Django 的ValidationError
時,會回傳 HTTP 400 回應,包含自定義錯誤訊息,這樣可以保持回應的格式一致性。
程式碼很簡單,但其中的重點不少,讓我們逐一解析。
Exception Handlers 重點解析
我們從「專案組織」這個議題講起。
一、Exception Handlers 函式放哪好?
如前所述,這個錯誤處理器的影響範圍是全域的,所以可以把它放在專案的任何地方。
不過,還是建議將它放在最適合的位置。我認為主要有兩個選擇:
- 如果你的錯誤處理函式不多,可以直接放在專案的
api.py
中——我們的例子就是這麼做的。這符合專案api.py
的全域管理屬性。 - 如果錯誤處理比較多,建議獨立出一個 Python 模組來管理。
二、函式與參數命名
又到了我喜聞樂見的「命名」部分☺️
Exception handler 是一個(被裝飾的)函式,理論上應該要遵循「動詞開頭」的函式命名慣例。
但我卻使用了django_validation_error_handler
這樣偏「名詞」的命名。
因為它的本質更接近於一個處理裝置或機制,而非傳統意義上的函式。
當然,這取決於你從什麼角度看!你也可以說它就是有「處理的行為」,所以還是得用動詞開頭來命名。我完全同意。
接著是exception
參數,Django Ninja 文件都會命名為exc
,我個人很不喜歡,因為我覺得exc
一點也不直觀,屬於完全沒必要的縮寫。
退一步來說,我寧可使用單字母e
——類似 Pydantic 驗證方法中的v
。
三、函式邏輯解析
Exception handler 的函式邏輯,可長可短、可簡單可複雜,但不外乎做這兩件事:
- 接收特定錯誤類型。
- 返回特定 HTTP 回應。
本例中,我們接收 Django 的ValidationError
,並返回「400 Bad Request」回應,而且錯誤訊息的內容來自於拋出的錯誤——由我們自行定義。
這樣的錯誤處理靈活性已經算不錯。如果你的ValidationError
採用list
或dict
初始化,那這個處理函式就需要寫得更複雜一些。
牛刀小試:以處理 404 回應為例
我們再來實作另一個 exception handler,處理常見的 404。
以「取得單一文章資訊」API 為例:
1 |
|
目前,如果前端輸入的文章 id 不存在,伺服器將直接 500:
raise self.model.DoesNotExist(
post.models.Post.DoesNotExist: Post matching query does not exist.
而且還曝露內部訊息——這實在太瞎了!🤣
因為 Django ORM 中 QuerySet 的get
方法,在查詢不到結果或查到複數結果時,都會拋出錯誤。而我們並沒有捕捉或處理這些錯誤,所以伺服器直接 500 了。
兩種錯誤並不相同——錯訊訊息也要不同。這裡先處理第一種情況就好。
查無結果時,返回 404 回應
經過這兩篇的介紹,你有兩種方式來回應 404。
第一,直接使用HttpError
:
1 | try: |
這是我們前一篇的做法,也相當推薦。
第二,使用 exception handler:
1 | # NinjaForum/api.py |
這個做法相比於第一種,有其優點和缺點:
- 優點:不必變更 view 函式的內容(寫法更簡潔),而且可以捕捉所有 API 拋出的
ObjectDoesNotExist
錯誤。(它是Post.DoesNotExist
的父類別) - 缺點:無法自定義「詳細」的錯誤訊息——因為我們不知道
ObjectDoesNotExist
是發生在查詢什麼模型物件。- 當然,如果你願意為不同錯誤定義各自的 exception handler,就能夠實現!——比如只捕捉
Post.DoesNotExist
,錯誤訊息就可以寫「文章不存在」。 - 但就要定義很多個 exception handlers,有點麻煩啦!
- 當然,如果你願意為不同錯誤定義各自的 exception handler,就能夠實現!——比如只捕捉
選擇第一種還是第二種做法,需要你視情況而定。
404 回應的效果
一、使用HttpError
:
1 | // 404 Not Found |
二、使用 exception handler:
1 | // 404 Not Found |
第五章總結
第五章說真的,資訊量頗大,這 4 篇文章我寫了很久,而且還「重構」過!——本來只有 2 篇而已。
我們先討論了如何自定義單一欄位驗證,以及跨欄位驗證。然後再循序漸進地學習如何處理 API 拋出的錯誤——愈來愈優雅、愈來愈全面。
如果你是從第 1 篇看到這裡,真的,完全可以為自己感到驕傲。
下一步
接下來,比較輕鬆了嗎?——並沒有。
我們要介紹 API 的常見進階功能。
這些功能相比於處理請求、回應,可以說「不一定」要有,但對許多 API 專案來說仍相當重要。
下一章,我們將逐一探討這些進階功能,並學習如何在 Django Ninja 中實現它們。讓我們繼續深入 API 開發的世界吧!