2024 iThome 鐵人賽2024 iThome 鐵人賽

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

上一篇提到,API 回應常常是對 Django Model 物件內容的篩選與加工——然後 JSON 序列化。

其中「加工」部分,用更專業的說法,大概是「資料格式化」——依照一定的規則,對輸出資料進行某種轉換或重新組織,以符合特定的輸出格式

資料格式化的種類很多,例如:

  1. 時間格式轉換:將資料庫中的時間戳(timestamp),轉換為更易讀的格式。
  2. 數值轉換:將數字轉換為貨幣格式,或將小數點位數進行四捨五入。
  3. 字串處理:截斷過長的文字、加上統一的前綴等。

不論原因為何,絕大部分時候都是為了資料的「可讀性」,或符合特定業務規則。

可想而知,像資料格式化這樣的需求,不僅實務上重要,在 API 開發中也十分常見,值得我們用一整篇文章,細細探討。

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


場景與需求

再次回到「取得單一文章資訊」API,這是目前的回傳格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// http://127.0.0.1:8000/posts/2/
{
"id": 2,
"title": "Alice's Django Ninja Post 1",
"content": "Alice's Django Ninja Post 1 content",
"author": {
"id": 1,
"username": "Alice",
"email": "alice@example.com"
},
"created_at": "2024-09-12T02:28:16.801Z",
"updated_at": "2024-09-12T02:28:16.801Z"
}

我們決定簡化回應的時間字串,改採「"2024-09-12T02:28:16Z"」格式。

和舊版相比,只是少了「.801」這個小數部分而已,且依舊符合 ISO 8601 標準。

總之,回應中created_atupdated_at兩個欄位的內容,需要進行格式上的轉換。即上述提到的「資料格式化」。


Django REST framework 做法

首先,我們還是不免俗地先介紹 Django REST framework(以下簡稱 DRF)的做法,方便你對比兩者的差異——你會發現其實大同小異

在 DRF 中,我們可以透過SerializerMethodField實現時間格式的轉換。以下是透過 DRF 實現的範例:

1
2
3
4
5
6
7
8
9
10
class PostSerializer(serializers.ModelSerializer):
...
created_at = serializers.SerializerMethodField()
updated_at = serializers.SerializerMethodField()

def get_created_at(self, obj):
return obj.created_at.strftime('%Y-%m-%dT%H:%M:%SZ')

def get_updated_at(self, obj):
return obj.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ')

其中的重點有三:

  1. 要格式化的欄位,值必須是SerializerMethodField
  2. 在序列化器類別中,定義相同欄位名稱的實例方法(有第一位置參數self),且命名時要加上get_前綴,比如get_created_at
  3. obj參數指的是當前被序列化的物件。本例中,我們預期引數是一個Post模型實例。這個方法將在序列化過程中會被自動調用,將原始的 datetime 物件轉換為指定的字串格式。

附帶一提,在 DRF 序列化器的各種實例方法中,obj這個參數名稱可以稱得上是一個命名慣例


Django Ninja 的欄位資料格式化

看完 DRF,我們來看看 Django Ninja 怎麼做。

透過 Django Ninja 的 Resolver 方法,我們也能輕鬆處理這類需求。

Django Ninja 的 Resolver 方法

在 Django Ninja 中,我們用 Resolver 方法來實現同樣的功能:

1
2
3
4
5
6
7
8
9
10
11
class PostResponse(Schema):
...
created_at: datetime
updated_at: datetime

@staticmethod
def resolve_created_at(obj: Post) -> str:
return obj.created_at.strftime('%Y-%m-%dT%H:%M:%SZ')

def resolve_updated_at(self, obj: Post) -> str:
return obj.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ')

方法的命名,相較於 DRF 用的是get_前綴,Django Ninja 則是採resolve_前綴。

此外,你沒有看錯,這裡使用了兩種寫法

  • resolve_created_at 是一個「靜態方法(static method)」,需要使用@staticmethod裝飾器,且沒有self參數。
  • resolve_updated_at 是一個典型的實例方法,有self參數。

因為文件的範例中,確實存在這兩種寫法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TaskSchema(Schema):
...
owner: Optional[str] = None
lower_title: str

@staticmethod
def resolve_owner(obj):
if not obj.owner:
return
return f"{obj.owner.first_name} {obj.owner.last_name}"

def resolve_lower_title(self, obj):
return self.title.lower()

實例方法版本未實裝

但是!現階段,你只要知道「靜態方法」版本即可。

因為採第二種寫法,你將會得到下列錯誤訊息

Error extracting attribute: NotImplementedError: Non static resolves are not supported yet [type=get_attribute_error, input_value=<DjangoGetter: <Post: Ali…’s Django Ninja Post 1>>, input_type=DjangoGetter]

什麼?還沒有實作!

我只好乖乖都改成靜態方法。

回傳結果

最後看一下效果如何:

1
2
3
4
5
6
7
8
9
10
11
12
13
// http://127.0.0.1:8000/posts/2/
{
"id": 2,
"title": "Alice's Django Ninja Post 1",
"content": "Alice's Django Ninja Post 1 content",
"author": {
"id": 1,
"username": "Alice",
"email": "alice@example.com"
},
"created_at": "2024-09-12T02:28:16Z",
"updated_at": "2024-09-12T02:28:16Z"
}

很好,時間字串格式已經被成功轉換——是16Z而不是16.801Z


使用 Alias 攤平欄位資訊

另一個常見的格式化需求,是我們之前提過的「攤平」(flatten)複雜資料結構。

這是一種對資料的「重組」,而結構重組同樣屬於本文所探討的資料格式化範疇。

還記得在第 14 篇,我們透過@property產生「取得文章列表」回應中author_name欄位內容嗎?——這是對User模型的攤平,直接獲取其username欄位資訊。

這裡我們換一個更優雅的做法——alias

使用 Alias

Django Ninja(幾乎是從 Pydantic 照搬來的)提供了Fieldalias參數來實現這一功能。

有關Field,在〈卷 18:Pydantic Field 設定範例與預設值〉將會有更多著墨。

我們先來看看如何使用:

1
2
3
4
5
class PostListResponse(Schema):
id: int
title: str
created_at: datetime
author_name: str = Field(alias='author.username')

注意,原先Post模型的@property方法要拿掉,或至少不能author_name撞名,否則會出錯唷!

我選擇了移除@property方法,直接改用這個新做法。

重點解析

透過alias=author.username取得Post關聯模型——Userusername屬性值。實現了巢狀資料的攤平

這種設計,顯然是向 DRF 的優秀借鑑,相當於 DRF 中的 source=author.username寫法。

雖然有點抽象,卻非常優雅。

alias的用途不限於資料攤平(這反而是比較進階的用法),其它細節,如欄位名稱替換等,可直接參考 Pydantic 文件

回傳結果

這個做法,和之前使用@property的效果是完全一樣的:

1
2
3
4
5
6
7
8
9
// http://127.0.0.1:8000/posts/
[
{
"id": 1,
"title": "Alice's Django Ninja Post 1",
"created_at": "2024-09-12T02:28:16.801Z",
"author_name": "Alice" // 攤平後的作者名字
}
]

可以看到author_name欄位已經成功攤平,直接顯示了作者的名字。


結語

Django Ninja 的 Resolver 方法允許我們對 API 回應中的欄位資料進行動態處理,滿足各種格式轉換與自定義需求。

在處理像created_atupdated_at這樣的時間欄位時,Resolver 方法不僅簡單易用,還能保證程式碼的結構清晰。

Fieldalias參數則更優雅地實現了另一種常見的資料格式化——「攤平」。不僅簡化了 API 回應,且無需修改背後的 Django 模型。

透過這些方式,我們能更靈活地控制 API 的輸出,以符合客戶端需求。

下一章預告

完成了對「Django Ninja 處理 HTTP 回應」共 4 篇的學習,第三章也正式告一段落。接下來,我們要將目光轉向 API 開發中的另一個重要主題——文件

隨著專案規模的增長,清晰的 API 文件對於任何需要使用 API 的人員都至關重要——包括後端開發者自己

一份好的 API 文件能夠大幅降低溝通成本,提高開發效率、減少錯誤。它不僅是一種技術文書,更是團隊協作的重要樞紐

第四章,我們會探討如何有效地透過 Django Ninja 程式碼,產生高品質的 API 文件,從而提升整體的開發體驗。