上一篇提到,API 回應常常是對 Django Model 物件內容的篩選與加工——然後 JSON 序列化。
其中「加工」部分,用更專業的說法,大概是「資料格式化」——依照一定的規則,對輸出資料進行某種轉換或重新組織,以符合特定的輸出格式。
資料格式化的種類很多,例如:
不論原因為何,絕大部分時候都是為了資料的「可讀性」,或符合特定業務規則。
可想而知,像資料格式化這樣的需求,不僅實務上重要,在 API 開發中也十分常見,值得我們用一整篇文章,細細探討。
本文所有的程式碼變動,可參考這個 PR。
再次回到「取得單一文章資訊」API,這是目前的回傳格式:
// 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_at
和updated_at
兩個欄位的內容,需要進行格式上的轉換。即上述提到的「資料格式化」。
首先,我們還是不免俗地先介紹 Django REST Framework(以下簡稱 DRF)的做法,方便你對比兩者的差異——你會發現其實大同小異。
在 DRF 中,我們可以透過SerializerMethodField
實現時間格式的轉換。以下是透過 DRF 實現的範例:
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')
其中的重點有三:
SerializerMethodField
。self
),且命名時要加上get_
前綴,比如get_created_at
。obj
參數指的是當前被序列化的物件。本例中,我們預期引數是一個Post
模型實例。這個方法將在序列化過程中會被自動調用,將原始的 datetime 物件轉換為指定的字串格式。附帶一提,在 DRF 序列化器的各種實例方法中,obj
這個參數名稱可以稱得上是一個命名慣例。
看完 DRF,我們來看看 Django Ninja 怎麼做。
透過 Django Ninja 的 Resolver 方法,我們也能輕鬆處理這類需求。
在 Django Ninja 中,我們用 Resolver 方法來實現同樣的功能:
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
參數。因為文件的範例中,確實存在這兩種寫法:
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]
什麼?還沒有實作!
我只好乖乖都改成靜態方法。
最後看一下效果如何:
// 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
。
另一個常見的格式化需求,是我們之前提過的「攤平」(flatten)複雜資料結構。
這是一種對資料的「重組」,而結構重組同樣屬於本文所探討的資料格式化範疇。
還記得在第 14 篇,我們透過@property
產生「取得文章列表」回應中author_name
欄位內容嗎?——這是對User
模型的攤平,直接獲取其username
欄位資訊。
這裡我們換一個更優雅的做法——alias。
Django Ninja(幾乎是從 Pydantic 照搬來的)提供了Field
與alias
參數來實現這一功能。
有關Field
,在〈卷 18:Pydantic Field 設定範例與預設值〉將會有更多著墨。
我們先來看看如何使用:
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
的關聯模型——User
的username
屬性值。實現了巢狀資料的攤平。
這種設計,顯然是向 DRF 的優秀借鑑,相當於 DRF 中的 source=author.username
寫法。
雖然有點抽象,卻非常優雅。
alias
的用途不限於資料攤平(這反而是比較進階的用法),其它細節,如欄位名稱替換等,可直接參考 Pydantic 文件。
這個做法,和之前使用@property
的效果是完全一樣的:
// 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_at
和updated_at
這樣的時間欄位時,Resolver 方法不僅簡單易用,還能保證程式碼的結構清晰。
Field
與alias
參數則更優雅地實現了另一種常見的資料格式化——「攤平」。不僅簡化了 API 回應,且無需修改背後的 Django 模型。
透過這些方式,我們能更靈活地控制 API 的輸出,以符合客戶端需求。
完成了對「Django Ninja 處理 HTTP 回應」共 4 篇的學習,第三章也正式告一段落。接下來,我們要將目光轉向 API 開發中的另一個重要主題——文件!
隨著專案規模的增長,清晰的 API 文件對於任何需要使用 API 的人員都至關重要——包括後端開發者自己!
一份好的 API 文件能夠大幅降低溝通成本,提高開發效率、減少錯誤。它不僅是一種技術文書,更是團隊協作的重要樞紐。
第四章,我們會探討如何有效地透過 Django Ninja 程式碼,產生高品質的 API 文件,從而提升整體的開發體驗。
本文同步發表於我的部落格——Code and Me
typo: 相較於 DRF 用的是_get
前綴
疑問: DRF 有傳入了 self,那所以是可以在該 func 中用 self 讀取其他屬性的值嗎?
例如: 我總是想將 get_updated_at 的結果改成 get_created_at 的1秒鐘後(首次建立時),可以使用 self 在 get_updated_at 讀取到 created_at的值嗎?
obj看起來像是解析了 func 名稱並截掉前面 get_
或 resolve
就是該屬性。
在創立物件之後讀取該屬性值然後重新賦值? 之後讀取都會讀取到重寫後的值?
typo: 相較於 DRF 用的是
_get
前綴
感謝告知!
DRF 只用實例方法,所以有 self 也有 obj,但我實際開發時幾乎都只用 obj 而已。Django Ninja 因為目前只有靜態方法,就省得思考這個問題
obj 代表當前正在被序列化的 model 實例(回應時),而 self 代表的是「序列化器實例」本身
根據我之前和 AI 的討論,這個 self 應該主要用來呼叫序列化器實例的其它自定義方法(或屬性),而不是 model 實例的屬性
所以我認為是讀不到