iT邦幫忙

2024 iThome 鐵人賽

DAY 22
1
Python

Django 忍法帖——Django Ninja 入門指南系列 第 22

卷 22:錯誤處理(下)全域錯誤處理——使用 Exception Handlers

  • 分享至 

  • xImage
  •  

上一篇文章,我們學習了如何操作HttpError,並建議你只在 view 函式中使用它。

但光是這樣,專案 API 的錯誤處理,還遠遠不夠完善,至少有 3 個常見問題待解:

  1. Schema 中的驗證方法 ,如果不要raise HttpError,那要怎麼做才好?
  2. 我們應該如何處理其他類型的錯誤,例如資料庫操作錯誤?
  3. 如何確保不同 API 錯誤的回應格式一致

這些問題都指向了一個更大的需求:我們需要一個全面的錯誤處理機制

這篇文章,就要來回答這些問題。所有的程式碼改動,可參考這個 PR


改用 Django ValidationError

還記得驗證方法中,最原始的版本是拋出ValueError嗎?

ValueError會被 Django Ninja 自動捕捉並給出 422 回應,這是好事,但不符合我們的自定義需求

所以後來我們改採了HttpError,它雖然也會被捕捉,但回應的格式與內容比較簡潔——而且除了錯誤訊息,還能自訂狀態碼

然而,如上一篇所述,這樣做雖然簡單,卻並不合適。

那究竟要拋出什麼錯誤?

避免使用 Pydantic 或 Django Ninja 提供的錯誤

上一篇還提到,無論 Pydantic 或 Django Ninja,都有自己內建的ValidationError

但它們更多是供框架內部使用,而且回傳的錯誤格式過於詳細,初始化方式也很龜毛。比如 Django Ninja 的驗證錯誤,需要這樣初始化:

raise ValidationError(
    [{'loc': ('confirm_password',),
      'msg': '密碼和確認密碼必須相同',
      'type': 'value_error'}])

這不是我們熟悉的「塞一個錯誤訊息字串」就好了。

因此,我並不推薦在驗證邏輯中直接使用這些錯誤類型。

請愛用 Django ValidationError

在 Schema 驗證邏輯中,我們更應該使用 Django 內建的ValidationError

它的設計已經完整考慮到了開發者的需求,初始化方式可簡單(使用單一字串)可複雜(使用listdict),適合絕大多數場景。

這裡我們用字串來初始化即可,程式碼修正如下:

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 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中,加入下列程式碼:

# 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 回應,包含自定義錯誤訊息,這樣可以保持回應的格式一致性

程式碼很簡單,但其中的重點不少,讓我們逐一解析。


Exception Handlers 重點解析

我們從「專案組織」這個議題講起。

一、Exception Handlers 函式放哪好?

如前所述,這個錯誤處理器的影響範圍是全域的,所以可以把它放在專案的任何地方

不過,還是建議將它放在最適合的位置。我認為主要有兩個選擇

  1. 如果你的錯誤處理函式不多,可以直接放在專案的api.py——我們的例子就是這麼做的。這符合專案api.py全域管理屬性。
  2. 如果錯誤處理比較多,建議獨立出一個 Python 模組來管理。

二、函式與參數命名

又到了我喜聞樂見的「命名」部分☺️

Exception handler 是一個(被裝飾的)函式,理論上應該要遵循「動詞開頭」的函式命名慣例

但我卻使用了django_validation_error_handler這樣偏「名詞」的命名。

因為它的本質更接近於一個處理裝置機制,而非傳統意義上的函式。

當然,這取決於你從什麼角度看!你也可以說它就是有「處理的行為」,所以還是得用動詞開頭來命名。我完全同意。

接著是exception參數,Django Ninja 文件都會命名為exc,我個人很不喜歡,因為我覺得exc一點也不直觀,屬於完全沒必要的縮寫

退一步來說,我寧可使用單字母e——類似 Pydantic 驗證方法中的v

三、函式邏輯解析

Exception handler 的函式邏輯,可長可短、可簡單可複雜,但不外乎做這兩件事

  1. 接收特定錯誤類型
  2. 返回特定 HTTP 回應

本例中,我們接收 Django 的ValidationError,並返回「400 Bad Request」回應,而且錯誤訊息的內容來自於拋出的錯誤——由我們自行定義。

這樣的錯誤處理靈活性已經算不錯。如果你的ValidationError採用listdict初始化,那這個處理函式就需要寫得更複雜一些。


牛刀小試:以處理 404 回應為例

我們再來實作另一個 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 回應

經過這兩篇的介紹,你有兩種方式來回應 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)

這個做法相比於第一種,有其優點和缺點:

  • 優點:不必變更 view 函式的內容(寫法更簡潔),而且可以捕捉所有 API 拋出的ObjectDoesNotExist錯誤。(它是Post.DoesNotExist的父類別)
  • 缺點:無法自定義「詳細」的錯誤訊息——因為我們不知道ObjectDoesNotExist是發生在查詢什麼模型物件。
    • 當然,如果你願意為不同錯誤定義各自的 exception handler,就能夠實現!——比如只捕捉Post.DoesNotExist,錯誤訊息就可以寫「文章不存在」。
    • 但就要定義很多個 exception handlers,有點麻煩啦!

選擇第一種還是第二種做法,需要你視情況而定

404 回應的效果

一、使用HttpError

// 404 Not Found
{
    "detail": "文章不存在"
}

二、使用 exception handler:

// 404 Not Found
{
    "detail": "查無資料"
}

第五章總結

第五章說真的,資訊量頗大,這 4 篇文章我寫了很久,而且還「重構」過!——本來只有 2 篇而已。

我們先討論了如何自定義單一欄位驗證,以及跨欄位驗證。然後再循序漸進地學習如何處理 API 拋出的錯誤——愈來愈優雅、愈來愈全面

如果你是從第 1 篇看到這裡,真的,完全可以為自己感到驕傲。

下一步

接下來,比較輕鬆了嗎?——並沒有。

我們要介紹 API 的常見進階功能

這些功能相比於處理請求、回應,可以說「不一定」要有,但對許多 API 專案來說仍相當重要。

下一章,我們將逐一探討這些進階功能,並學習如何在 Django Ninja 中實現它們。讓我們繼續深入 API 開發的世界吧!

本文同步發表於我的部落格——Code and Me


上一篇
卷 21:錯誤處理(上)HttpError 與自定義 HTTP 回應
下一篇
卷 23:檔案上傳——Django UploadedFile 介紹
系列文
Django 忍法帖——Django Ninja 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言