2024 iThome 鐵人賽2024 iThome 鐵人賽

在 API 開發中,我們經常會遇到關聯模型之間的資料需要同時返回的情況。

特別是在處理「一對一」或「一對多」關聯時,多層結構往往是常態。

我們希望以巢狀結構Nested Objects)的方式返回資料,這樣可以讓 API 的使用者一次取得必要資訊,而不需要進行多次請求。

本文將繼續使用並擴充「單一文章資訊」API 這個範例,講述如何在 Django Ninja 中實現巢狀結構回應,讓我們的 API 回應更加豐富、有體系。

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


一、問題背景

在之前的 API 設計中,「取得單一文章資訊」的回應包括了文章資訊及作者的 id

1
2
3
4
5
6
7
class PostResponse(Schema):
id: int
title: str
content: str
author_id: int
created_at: datetime
updated_at: datetime

有經驗的開發者都知道,無論是id還是author_id,通常不是給服務的使用者看的——而是給前端人員靈活運用的。

比如在系統的畫面中,文章可能包括作者的個人資訊連結,點進去可以看到作者資訊。此時前端必須透過 id,再呼叫另一支 API「取得用戶資訊」來獲得額外的內容。

如果額外資訊很多,這樣的「解耦」設計是非常合理的。但如果我們希望一併呈現作者的「必要資訊」,那分次呼叫的設計就略嫌拖沓

所以我們需要巢狀結構

API 可以直接在回應中,嵌入作者的「必要資訊」,這樣用戶就不必再進行多次請求。這裡我們以一併顯示作者的「名字」和「email」為例。


二、API 改進:重新定義 Schema

只需要做一件事,就可以讓回應的內容、結構有所不同——重新定義PostResponse

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from ninja import Schema
from datetime import datetime

class _AuthorInfo(Schema):
id: int
username: str
email: str

class PostResponse(Schema):
id: int
title: str
content: str
author: _AuthorInfo # 巢狀結構,包含作者資訊
created_at: datetime
updated_at: datetime

_AuthorInfo包含了作者的idnameemail,並將這個結構嵌入PostResponse中的author欄位(從author_id易名而來,因為資訊內涵已有所不同)。

如此一來,我們便可以同時獲得文章和作者的必要資訊。

命名小建議

你可能留意到我在_AuthorInfo使用了「底線開頭」這個命名原則。在 Python 中,這是一種慣例,用來表示這個屬性、函式、類別主要是作為內部使用

所謂的「內部」可以有很多種解讀,而這裡我的用意是:它只是某個或多個 Schema 的一部分,不直接供 view 函式調用。

別小看這個命名細節。隨著你的 Schema 數量增加,在開發新 API 時,你總是需要先瀏覽現有的 Schema,以決定是重新定義還是延用既有的。

此時有這樣的命名區別就顯得很「貼心」了——你不必在大大小小的 Schema 中翻來覆去,看得眼睛要脫窗。

撰寫巢狀 Schema 的機會不少,所以我認為養成這樣的好習慣是值得的。

Nested Response

我們來看 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"
}

看看新的author欄位內容,巢狀結構,非常完美!

用戶可以直接到看文章作者的名字與 email,如果想看更多作者資訊,依舊能透過id欄位,再讓前端呼叫另一支 API。

這是一個理想的折衷方案


三、「攤平」巢狀資訊

前面的「折衷方案」確實挺理想。不過,有時我們的需求更簡單

比如在「取得文章列表」API 中,我們可能也需要顯示作者的資訊——但此時只要名字就足夠了。

不需要作者 id,更不用 email,只要名字即可。

那麼,為何稱之為「攤平巢狀資訊」呢?因為作者的名字並非Post模型的直接屬性,它實際上來自於關聯模型——User

我們必須要把有關作者的巢狀資訊進行化簡

本來是這樣:

1
2
3
4
5
"author": {
"id": 1,
"username": "Alice",
"email": "alice@example.com"
},

現在變成這樣:

1
"author_name": "Alice",

從兩層變回一層(但不是作者 id 而是名字了),所以稱為「攤平」(flatten)。

Schema 解耦

還記得「取得文章列表」API 的回應格式,其實是和「取得單一文章資訊」共用的:

1
2
3
4
5
6
@router.get(path='/posts/', response=list[PostResponse])
def get_posts(...) -> QuerySet[Post]:
"""
取得文章列表
"""
...

兩者都使用了PostResponse

本文上半部對「取得單一文章資訊」回應的修改,也會影響到「取得文章列表」——這通常不是我們想要的結果。

所以,我們要為「取得文章列表」API 建立一個屬於自己的回應 Schema,並依照前面提到的需求,簡化資訊!

我打算:

  1. 省略文章的內容(content)還有更新時間(updated_at)這兩個欄位,因為在列表中並不需要。
  2. 作者的部分只留下「名字」即可。

四、實作攤平巢狀資訊——使用@property

我們先看看新 Schema 如何定義:

1
2
3
4
5
class PostListResponse(Schema):
id: int
title: str
created_at: datetime
author_name: str

你可能覺得奇怪,哪來的author_name屬性?Post模型並沒有啊?

沒錯!因為那是我們自己定義的——使用@property

1
2
3
4
5
6
7
# post/models.py
class Post(models.Model):
...

@property
def author_name(self) -> str:
return self.author.username

如此一來,你的 Post 模型物件,就會有author_name這個屬性了。

但要注意,呼叫這個屬性通常意味著觸發第二次查詢(因為它是關聯模型上的屬性),所以 view 函式中要搭配 Django QuerySet 方法select_related

1
posts.filter(title__icontains=title).select_related('author')

這是 Django ORM 中常見的「N+1」議題,在此先不展開。

更好的做法

你可能覺得這個方式好像不怎麼優雅(至少我第一次看到時就是這麼想!)——尤其是和 Django REST framework 的做法相比。

Django REST framework 會在序列化器中這樣寫:

1
author_name = serializers.CharField(source="author.name")

是不是簡潔很多?

但這確實是 Django Ninja 作者早期推薦的方式

別擔心,第 16 篇我們會介紹更好、更現代化的做法。不過@property某些情況下,還是很有用的。

攤平後的回應

最後,我們來看看「取得文章列表」API 的新回應:(假設只有一篇文章)

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" // 攤平後的作者名字
}
]

漂亮!


小結

這篇文章中,我們展示了如何在 Django Ninja 中使用 Schema 實現巢狀結構回應。

接著介紹如何「攤平」這個巢狀結構,把原來的作者 id 替換成名字欄位。

這些方法大大增加了 API 回應的靈活性。

下一篇文章,我們將討論 Django Ninja 和 Django REST framework 在序列化與回應結構處理上的不同設計理念,並比較兩者的優劣。