iT邦幫忙

2024 iThome 鐵人賽

DAY 14
1

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

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

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

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

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


一、問題背景

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

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

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 的回應:

// 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

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

本來是這樣:

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

現在變成這樣:

"author_name": "Alice",

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

Schema 解耦

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

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

兩者都使用了PostResponse

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

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

我打算:

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

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

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

class PostListResponse(Schema):
    id: int
    title: str
    created_at: datetime
    author_name: str

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

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

# 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

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

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

更好的做法

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

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

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

是不是簡潔很多?

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

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


小結

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

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

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

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

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


上一篇
卷 13:回應(一)Django Ninja 處理回應概論
下一篇
卷 15:回應(三)為何不用 ModelSchema?——相比 DRF,我更偏愛 Django Ninja 的理由
系列文
Django 忍法帖——Django Ninja 入門指南15
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
gbaian10
iT邦新手 5 級 ‧ 2024-09-27 10:18:48

我第一時間看也覺得用@property好麻煩XDD 先盲猜一下可以透過 pydantic 的 Field(exclude=False) 來讓特定欄位不被序列會輸出,但內部依然可以使用。

gbaian10 iT邦新手 5 級 ‧ 2024-09-27 10:18:58 檢舉

另外同意幫屬性加底線命名很有幫助!
因為有時候一些 func 可能要執行一些複雜操作,但這個 func 操作可能只有某個特定的另一個函式會呼叫,而且通常可以根據名字就知道他做了什,這時候我會將此 func 命名以底線開頭,通常代表這是私有的 func,只有特定的函式會呼叫,正常 call 服務的人應該不會 call 這種以底線開頭的 func。
當然 python 的自由大家都知道,即使命名為底線開頭依然可以 call 這個 func,只要你明確知道他在做什就可以。

當然要養成這種習慣也不容易,我後來是使用 ruff 的 D 系列規則來幫助自己養成這個習慣和順便寫 docstring。
程序員多數都是懶惰的 (至少我就是!) ,要我寫 docstring 我可能也不是很願意,表示直接看 code 不就懂了?

而上面的 D 系列規則允許私有方法可以不用寫 docstring,但 public 的則要寫(非底限開頭的),所以這時候我為了讓自己少寫 docstring,我就會盡量讓一些內部使用的 func 都加上底線,但最外層要給人家 call 的 public func 當然是不能加,所以就乖乖謝這幾個就好,在docstring 寫清楚這個 func 是做什的,輸入輸出是什。
當你認真將處理這些 private func 後,你可能會發現你大約有7成的 func 都會變私有的,很多時候這些 code 給別人看時候他也許只要看看名字就好,他應該更把注意力專住在那些 public func 上

我要留言

立即登入留言