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