2024 iThome 鐵人賽2024 iThome 鐵人賽

這是 Django Ninja 系列教學的第 25 篇。

上一篇我們介紹了 Django Ninja 的內建分頁器,並用它實作了簡單的分頁功能。

雖然內建的PageNumberPagination確實方便,但在很多時候,我們仍需要一些客製化功能。

為了實現這個目的,你需要自定義一個分頁類別

不過別擔心,這種自定義,並非從零開始。而是繼承 Django Ninja 所提供的基礎分頁類別,再進行自己的「加工」。

這篇文章就要來教你怎麼做。

本文所有的程式碼變動,可參考這個 PR


客製化需求

除了基本的分頁,我們還希望能夠:

  • 允許客戶端選擇每頁顯示的資料數量,可選範圍限定在 1 至 100 之間。
  • 在回應中新增兩個欄位,顯示當前的分頁資訊
    • 當前頁數(page)。
    • 每頁顯示數量(per_page)。

這無疑是很常見的需求。我們將透過自定義分頁類別,來實現這些功能。

話不多說,直接開始!

實作:自定義分頁類別

分頁器(分頁類別)通常是是供全專案使用,所以不適合放在 Django app 目錄中。但也不能像 exception handlers 一樣,放在專案的api.py,因為會引發循環引用

所以,我在專案目錄 NinjaForum 建立一個新的 Python 模組——pagination.py

在這個新模組中,直接撰寫一個名為CustomPagination的分頁類別,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from typing import Any

from django.db.models.query import QuerySet
from ninja import Field, Schema
from ninja.pagination import PaginationBase

class CustomPagination(PaginationBase):
class Input(Schema):
page: int = Field(1, ge=1)
per_page: int = Field(10, ge=1, le=100)

class Output(Schema):
items: list
page: int = Field(examples=[1])
per_page: int = Field(examples=[10])
total: int = Field(examples=[100])

def paginate_queryset(
self,
queryset: QuerySet,
pagination: Input,
**params: Any,
) -> dict[str, Any]:
start = (pagination.page - 1) * pagination.per_page
end = start + pagination.per_page
return {
'items': queryset[start:end],
'page': pagination.page,
'per_page': pagination.per_page,
'total': queryset.count(),
}

這個分頁類別允許我們透過查詢參數——pageper_page——來決定分頁的大小與頁數,而且回應中還多了兩個同名的新欄位,作為額外的分頁資訊。


自定義分頁類別解說

雖然程式碼看起來細節繁多,但仔細閱讀後,你會發現它其實不難理解。

限於篇幅,我們只挑一些重點來講。

重點一:整體結構來自繼承的類別——PaginationBase

第一個疑惑應該是:「啊我怎麼會知道分頁類別要這樣寫?」

沒錯,我們當然不知道,所以要看官方文件,還有原始碼

從官方文件我們可以得知,要繼承一個叫PaginationBase的類別。但文件中對該類別的描寫還是有點簡略,所以需要看原始碼來了解更多的具體資訊。

然後模仿並覆寫類別中的一些屬性、方法——差不多就是如此。

重點二:Input Schema

1
2
3
class Input(Schema):
page: int = Field(1, ge=1)
per_page: int = Field(10, ge=1, le=100)

你一定能看出來,這個 Schema 就是拿來定義和驗證與分頁有關的 URL 查詢參數

此外,Input類別會作為引數傳入paginate_queryset方法中,作為實現分頁邏輯的一部分。

Input中的每一個屬性,就代表一個查詢參數(限分頁相關)——而且一樣可以使用Field來設定細節!

這裡的Field是 Pydantic 的Field,我們在第 18 篇詳細介紹過。它允許我們為每個參數設定預設值文件範例基本的驗證規則

本例中,page的預設值是 1,且必須大於等於 1;per_page的預設值是 10,且必須在 1 到 100 之間。這樣可以確保我們的分頁參數始終在合理的範圍內

同樣的道理也適用於Output,它決定了 HTTP 回應「應該要有」的格式,相當於分頁回應的 Schema。

重點三:paginate_queryset 方法

這個方法是所有分頁類別的核心,它實現了具體的分頁邏輯

它的第一參數是self,可見它是一個「實例方法」。

最值得注意的是第二參數——queryset,它實際上就是 view 函式的 return 值,而且類型必須是 QuerySet

paginate_queryset會利用我們熟悉的「切片與索引」,對傳入的 QuerySet 進行「切割」。這是 Django 為 QuerySet 自行實作的功能,行為上類似 Python 的listtuple等容器。

當它回應給客戶端時,我們就得到了切片後的 QuerySet自定義的回應格式


測試自定義分頁

寫完上述的自定義類別,view 函式只要多一行@paginate(CustomPagination)即可,這裡就省略程式碼。

直接看結果吧!我使用了/?page=2&per_page=5(第 2 頁、每頁 5 筆)查詢參數:

十分理想!

那如果每頁數量設定為超過 100 會怎樣呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"detail": [
{
"type": "less_than_equal",
"loc": [
"query",
"per_page"
],
"msg": "Input should be less than or equal to 100",
"ctx": {
"le": 100
}
}
]
}

答案是 422 回應。


分頁功能總結

透過這兩篇文章,我們展示了如何在 DjangoNinja 中實作分頁,從簡單的內建方法,到複雜的自定義分頁類別。

根據專案需求,你可以選擇適合自己的分頁策略,讓每一個回應,都能以最適合的方式呈現給使用者。


為什麼「多重狀態碼回應」不實用?

還記得我們在第 13 篇、第 21 篇留下的伏筆嗎?

在〈卷 13:回應(一)Django Ninja 處理 HTTP 回應〉中我提到:

但我覺得這個「多重狀態碼回應」設定在實務上沒有很實用,為何?我們後續再談。

幫你複習一下,「多重狀態碼回應」指的是這個用法:

1
2
3
4
@api.post(
...,
response={200: Token, 401: Message, 402: Message} # 這裡
)

然後在 view 函式中,依照不同情況,給出不同的 return。

我在〈卷 21:錯誤處理(上)HttpError 與自定義 HTTP 回應〉又說了:

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

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

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

這不就來了嗎!

理由很簡單,關鍵就在於本文「重點三:paginate_queryset 方法」中的那句:

最值得注意的是第二參數——queryset,它實際上就是 view 函式的 return 值,而且類型必須是 QuerySet

因為paginate_queryset方法中,第二參數的類型必須是 QuerySet!

paginate_queryset內部,我們將這個參數視為 QuerySet 使用、操作。若傳入的不是 QuerySet,分頁邏輯就會出錯

「多重狀態碼回應」與 paginate_queryset 方法的衝突

然而,多重狀態碼的回應,return 型別未必是 QuerySet——很可能是tuple

我舉一個簡單的例子你就懂,我們把「取得文章列表」API 改成這樣:

1
2
3
4
5
6
7
8
9
10
@api.get(
path="/posts",
response={200: list[PostResponse], 404: ErrorMessage}
)
@paginate(CustomPagination)
def get_posts(...) -> QuerySet[Post] | tuple[int, dict]:
posts = Post.objects.all()
if not posts.exists():
return 404, {"message""沒有找到符合條件的文章"}
return posts

這個例子清楚地顯示了「多重狀態碼回應」與分頁器之間的衝突

  • 當查詢結果正常時,view 函式 return 一個 QuerySet(即posts),丟給分頁器進行分頁,一切運作良好。
  • 沒有找到文章時,view 函式則會試圖回傳一個tuple——因為 Django Ninja 的「非 200 回應」必須有狀態碼,所以是tuple,而不是 QuerySet。

這將導致paginate_queryset方法出錯,因為它預期接收一個 QuerySet,後續的內部操作也是以此為前提。


如果專案中所有的 API 都沒有分頁,使用「多重狀態碼回應」來處理「非 200」回應是完全可行的。

只要有一個 API 需要分頁,這個有分頁的 API,為了避免上述衝突,就要改用一樣是第 21 篇提到的方式——raise HttpError

考慮到專案整體的一致性,其餘的 API,也應該採用raise HttpError這個方式。

而分頁需求是如此的常見,所以「多重狀態碼回應」也就成為了雞肋