iT邦幫忙

2024 iThome 鐵人賽

DAY 21
2
Python

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

卷 21:錯誤處理(上)HttpError 與自定義 HTTP 回應

  • 分享至 

  • xImage
  •  

在軟體開發中,錯誤處理是一個不容忽視——但常常被忽視——的環節。

不誇張地說,錯誤處理是一個「做得好沒人誇,做不好系統就慘兮兮」的議題。

沒關係,我們還是盡可能把自己做好

Django Ninja 使用 Pydantic 進行資料驗證,失敗時,預設回應「422 Unprocessable Entity」。

然而,我們有時候需要回應「400 Bad Request」或別的狀態碼,以符合現實業務需求團隊開發習慣

總之,無論出於何種原因,我們想自訂錯誤訊息、格式,以及回應的狀態碼,而不使用 Django Ninja 預設的 422 回應——不得不說,這個制式回應的資訊有點多、結構有點複雜,因為它要兼容各種情況。

本文將介紹如何自定義錯誤處理與回應——使用 Django Ninja 內建的HttpError

所有的程式碼改動,可參考這個 PR


Django Ninja 的自動錯誤處理

上一篇我們提到,如果你在 Schema 的驗證方法中,拋出ValueError錯誤,Django Ninja 將會自動捕捉並回應

事實上,不止ValueError,Django Ninja 還會替你處理以下這幾種錯誤:

  • pydantic.ValidationError,來自 Pydantic 的驗證錯誤,這是為何當 Schema 欄位有問題時,我們會直接收到 422 回應。
  • 此外,Django Ninja 還內建了一個 ninja.errors.ValidationError,這些錯誤同樣會返回 422。
  • ninja.errors.HttpError:這是本文的重點,下面會介紹。

這些都是 Django Ninja 會自動捕捉的錯誤,但不是每一種都給出制式的 422 回應——第三種就不是。


新需求:驗證失敗時,改用 400 回應

同樣以「新增使用者」API 為例,我們要實現一個新需求:確認密碼不一致時,要回應「400 Bad Request」而不是 422。

怎麼做最簡單

答:使用 Django Ninja 的HttpError

以下是 Schema 的程式碼改動,只改了兩行!

...
from ninja.errors import HttpError  # 第一行

class CreateUserRequest(Schema):
    ...

    @model_validator(mode='after')
    def check_passwords_match(self) -> Self:
        if self.password != self.confirm_password:
            raise HttpError(400, '密碼和確認密碼必須相同')  # 第二行
        return self

沒錯,就這麼簡單!

只是把驗證方法拋出的錯誤,從ValueError替換為HttpError即可。

值得留意的是,HttpError實例的初始化,需要兩個參數,第一個是 HTTP 狀態碼,第二個才是錯誤訊息。

回應內容

看看同樣的驗證失敗,回應有何不同:

// 400 Bad Request
{
    "detail": "密碼和確認密碼必須相同"
}

變成我們熟悉的格式——只有錯誤訊息。

對比上一篇,我們拋出ValueError時的回應:

// 422 Unprocessable Entity
{
    "detail": [
        {
            "type": "value_error",
            "loc": [
                "body",
                "payload",
                "confirm_password"
            ],
            "msg": "Value error, 密碼和確認密碼必須相同",
            "ctx": {
                "error": "密碼和確認密碼必須相同"
            }
        }
    ]
}

差距很大吧?


在驗證方法中使用 HttpError 的不妥之處

直接在 Schema 的驗證方法中拋出HttpError,是一種便捷的方式,因為它能夠簡化回應處理

我們無需額外捕捉錯誤或手動指定回應格式。驗證失敗時,API 會直接回應我們定義的狀態碼和錯誤訊息,既簡單又方便。

然而,這麼做其實並不妥當,主要有幾個問題,比如降低可測試性、限制回應的靈活性等等。但其中最關鍵的,還是是我們上一篇提到的——「關注點分離」。

違反「關注點分離」

這個做法違反了「關注點分離」原則。

驗證邏輯的職責是檢查資料的正確性,而回應應該由 view 函式負責。

將回應邏輯混入驗證過程中,會讓驗證和回應這兩個本應獨立的部分耦合在一起,導致職責混亂,不利於程式碼維護。

因此,雖然在驗證方法內使用HttpError,看似能夠方便地實現需求,但從架構設計的角度考慮,將回應處理放在 view 函式中,才是一個更合理的選擇。

別擔心,下一篇我們會換個做法,但本文的主角還是HttpError


HttpError 的典型情境:在 view 函式中使用

相比於在 Schema 中使用HttpError,把它放在 view 函式裡執行,方為正道。

以下就是一個經典場景。

儘管資料驗證邏輯應盡可能放在 Schema 中,但也不是所有的驗證都適合丟給 Schema 做

比如,使用者的 email 欄位具有「唯一性」——不能重複。所以我們希望先確認使用者輸入的 email 是否和 db 中的資料重複,是的話,直接回應409 Conflict

這無疑也是一種驗證,但它涉及了「資料庫查詢」。

這種涉及資料庫查詢的驗證,更適合在 view 函式中進行,而不是在 Schema 裡。因為資料庫查詢屬於比較重的動態操作,與 Schema 的靜態資料檢查有著本質的不同

因此,我們更常在 view 函式中使用HttpError處理這類需求。

新增程式碼如下:

@router.post(...)
def create_user(..., payload: CreateUserRequest):
    """
    新增使用者
    """
    if User.objects.filter(email=payload.email).exists():
        raise HttpError(409, '使用者 email 已存在')
    ...

上面是「預先查詢」,和下面這個寫法,在結果上是類似的:

try:
    user.save()
except IntegrityError:  # Django ORM 唯一性錯誤
    raise HttpError(409, '使用者 email 已存在')

只不過一個是事前驗證並拋出錯誤,一個是事後捕捉錯誤(然後再拋出)。

驗證失敗的回應:

// 409 Conflict
{
    "detail": "使用者 email 已存在"
}

確實還不錯!


為什麼不直接 return 409 回應就好?

聰明的你可能會想到:

咦,那我何不直接 return 一個帶有錯誤訊息的 Python 字典就好了?為什麼非得在 view 函式中raise HttpError

這個想法,大概的程式碼如下:

@router.post('/users/', response={201: dict, 409: dict}, ...)
def (...) -> tuple[int, dict]:
    """
    新增使用者
    """
    if User.objects.filter(email=payload.email).exists():
        return 409, {"detail": "使用者 email 已存在"}
    ...

這樣不是更加直觀嗎?

這是一個好問題

重點解析

我們還是先來看一下,這段程式碼中有哪些重點:

  • response={201: dict, 409: dict}:第 13 篇提過的「多重狀態碼回應」,這不就派上用場了!
  • return取代raise
  • 如果想驗證錯誤訊息格式,可以定義一個 Schema。這個例子只是簡化版。

看起來確實不錯,也很符合直覺,其實我以前寫 Django REST framework,都是這樣寫的。

可是,這個寫法在 Django Ninja 中,使用「分頁裝飾器」時,就會踢到鐵板了。

目前時機未到,在後續的〈卷 25:分頁(下)自定義分頁類別〉中,我們再把這件事說清楚。

總之,現階段我們只要知道,類似情況還是raise HttpError會比較妥當。


小結

在這篇文章中,我們學習了如何使用 Django Ninja 內建的HttpError來自定義錯誤回應,以避免預設的 422。

並解釋了為何HttpError不適合用在 Schema 中(雖然我們暫時這麼做了😅),而是應該放到 view 函式裡。

下一篇,我們將改善 Schema 拋出的錯誤、探討全域錯誤處理機制,並且使用 Django Ninja 所提供的exception_handler裝飾器,進一步提升 API 的錯誤處理能力。

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


上一篇
卷 20:資料驗證(下)Pydantic 跨欄位驗證
下一篇
卷 22:錯誤處理(下)全域錯誤處理——使用 Exception Handlers
系列文
Django 忍法帖——Django Ninja 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言