DRF Serializers 進階:巢狀序列化與自訂方法 | Django 教學

2026/06/12 2026/05/22
DRF Serializers 進階:巢狀序列化與自訂方法 | Django 教學

在前一篇文章中,我們認識了 DRF Serializers 的基本用法,包括 ModelSerializer 的自動欄位產生與資料驗證。但在真實專案中,API 的資料結構往往不是扁平的——文章要帶出作者資訊、訂單要包含商品明細、評論要顯示用戶頭像。這些需求都指向同一個核心技術:巢狀序列化(Nested Serialization)。本篇將深入探討巢狀序列化器、各種 關聯欄位(Relational Fields) 的表示方式、SerializerMethodField 自訂計算欄位,以及自訂 create() / update() 方法處理巢狀寫入,最後聚焦在 效能優化 策略,幫助你打造高效且結構清晰的 API。

範例 Model 定義

為了讓後續所有範例一致,我們先定義本篇使用的 Model:

# models.py
from django.db import models
from django.contrib.auth.models import User

class Category(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)

    def __str__(self):
        return self.name

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
    tags = models.ManyToManyField(Tag, blank=True)
    is_published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

關聯欄位的五種表示方式

DRF 提供了多種方式來呈現 Model 之間的關聯關係,每種都有不同的使用場景。讓我們從最基礎的開始逐一介紹。

PrimaryKeyRelatedField:預設的關聯表示

PrimaryKeyRelatedField 是 DRF 預設處理 ForeignKey 和 ManyToManyField 的方式,它以主鍵(Primary Key)的 ID 來表示關聯物件:

# serializers.py
from rest_framework import serializers
from .models import Post, Category

class PostSerializer(serializers.ModelSerializer):
    # 明確宣告,等同 DRF 的預設行為
    category = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(),
        allow_null=True,
        required=False,
    )

    class Meta:
        model = Post
        fields = ['id', 'title', 'author', 'category', 'created_at']

回應結果如下:

{
    "id": 1,
    "title": "Django 入門教學",
    "author": 42,
    "category": 3,
    "created_at": "2026-06-12T10:00:00Z"
}

優點是資料量小、可讀寫;缺點是前端拿到 ID 後還需要額外請求才能得知作者名稱或分類名稱。

StringRelatedField:用 __str__ 表示

StringRelatedField 會呼叫關聯物件的 __str__() 方法來產生可讀的文字表示,它是 唯讀(read-only) 的:

class PostSerializer(serializers.ModelSerializer):
    # 呼叫 Category.__str__(),顯示分類名稱
    category = serializers.StringRelatedField()

    class Meta:
        model = Post
        fields = ['id', 'title', 'category']
{
    "id": 1,
    "title": "Django 入門教學",
    "category": "Web 開發"
}

適合只需要顯示名稱、不需要進一步查詢關聯物件的場景。

SlugRelatedField:用 slug 欄位取代 PK

SlugRelatedField 讓你用指定的欄位值(而非 PK)來表示與查找關聯物件。這個欄位必須是唯一的(unique),否則查找時可能出現多筆結果的錯誤:

class PostSerializer(serializers.ModelSerializer):
    # 讀取時顯示 category 的 slug 欄位,寫入時也用 slug 查詢
    category = serializers.SlugRelatedField(
        slug_field='slug',
        queryset=Category.objects.all(),
        allow_null=True,
        required=False,
    )
    # ManyToMany 也可使用,加上 many=True
    tags = serializers.SlugRelatedField(
        slug_field='name',
        queryset=Tag.objects.all(),
        many=True,
    )

    class Meta:
        model = Post
        fields = ['id', 'title', 'category', 'tags']
{
    "id": 1,
    "title": "Django 入門教學",
    "category": "web-development",
    "tags": ["python", "django", "tutorial"]
}

寫入時,前端可以直接傳送 "category": "web-development",DRF 會自動用 slug 欄位查找對應的 Category 物件。這比傳 ID 更直覺,也更適合用於 URL 友善的 API 設計。

HyperlinkedRelatedField:超連結表示

HyperlinkedRelatedField 以完整的 URL 來表示關聯物件,符合 HATEOAS(Hypermedia as the Engine of Application State,超媒體作為應用程式狀態引擎)的 REST 設計原則:

class PostSerializer(serializers.ModelSerializer):
    author = serializers.HyperlinkedRelatedField(
        view_name='user-detail',  # 對應 URL name
        read_only=True,
    )
    category = serializers.HyperlinkedRelatedField(
        view_name='category-detail',
        read_only=True,
    )

    class Meta:
        model = Post
        fields = ['id', 'title', 'author', 'category']
{
    "id": 1,
    "title": "Django 入門教學",
    "author": "http://api.example.com/users/42/",
    "category": "http://api.example.com/categories/3/"
}

使用時需要注意:Serializer 必須接收 request context 才能產生完整的絕對 URL。通常在 ViewSet 中 DRF 會自動傳入 context,但如果你手動實例化 Serializer,需要自行傳遞。


巢狀 Serializer(Nested Serializers)

當 API 需要一次回傳關聯物件的完整資訊時,前面四種方式都不夠用——你需要的是 巢狀序列化器(Nested Serializer)

手動巢狀:在 PostSerializer 中嵌入 AuthorSerializer

from django.contrib.auth.models import User

class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email']

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ['id', 'name', 'slug']

class PostDetailSerializer(serializers.ModelSerializer):
    # 巢狀序列化器:將 author 以完整物件呈現
    author = AuthorSerializer(read_only=True)
    category = CategorySerializer(read_only=True)

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'category', 'created_at']

回應結果:

{
    "id": 1,
    "title": "Django 入門教學",
    "content": "這是一篇介紹 Django 的文章...",
    "author": {
        "id": 42,
        "username": "alice",
        "email": "alice@example.com"
    },
    "category": {
        "id": 3,
        "name": "Web 開發",
        "slug": "web-development"
    },
    "created_at": "2026-06-12T10:00:00Z"
}

注意這裡的 read_only=True——預設的巢狀序列化器是唯讀的。如果你需要寫入巢狀資料,需要自訂 create()update() 方法(稍後介紹)。

depth 參數自動巢狀

DRF 提供了一個快捷方式:在 Meta 中設定 depth 參數,自動將指定深度內的所有關聯欄位展開為巢狀物件:

class PostAutoNestedSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['id', 'title', 'author', 'category', 'tags']
        depth = 1  # 自動展開一層關聯

depth = 1 會將 authorcategorytags 全部自動巢狀展開,不需要額外定義子 Serializer。

depth 的優缺點:

  • 優點:程式碼極簡,快速原型開發時非常方便
  • 缺點:無法控制巢狀物件包含哪些欄位(會顯示 Model 所有欄位,包括可能不該暴露的欄位);只能唯讀;效能較難控制
  • 建議:正式 API 中優先使用手動巢狀序列化器,depth 適合用於除錯或內部工具

SerializerMethodField:自訂計算欄位

SerializerMethodField 讓你在序列化時動態計算欄位值。它是 唯讀(read-only) 的,透過定義 get_<field_name>() 方法來返回值:

class PostSerializer(serializers.ModelSerializer):
    # 自訂計算欄位
    author_name = serializers.SerializerMethodField()
    summary = serializers.SerializerMethodField()
    tag_count = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'author_name', 'summary',
            'tag_count', 'is_published', 'created_at',
        ]

    def get_author_name(self, obj) -> str:
        """組合作者的顯示名稱"""
        return obj.author.get_full_name() or obj.author.username

    def get_summary(self, obj) -> str:
        """擷取文章前 100 個字元作為摘要"""
        return obj.content[:100] + '...' if len(obj.content) > 100 else obj.content

    def get_tag_count(self, obj) -> int:
        """計算標籤數量"""
        return obj.tags.count()
{
    "id": 1,
    "title": "Django 入門教學",
    "author_name": "Alice Wang",
    "summary": "這是一篇介紹 Django 的文章,涵蓋了從安裝到部署的完整流程...",
    "tag_count": 3,
    "is_published": true,
    "created_at": "2026-06-12T10:00:00Z"
}

注意: get_tag_count 中的 obj.tags.count() 如果沒有搭配 prefetch_related,每筆資料都會產生一次額外查詢。我們會在效能優化章節中說明如何解決這個問題。


讀寫分離:巢狀讀取 + PK 寫入

實務上最常見的需求是:讀取時回傳巢狀物件,寫入時接受 PK(或 slug)。這可以透過 source 參數搭配 read_only / write_only 來實現:

class PostReadWriteSerializer(serializers.ModelSerializer):
    # 讀取用:巢狀序列化器,唯讀
    category = CategorySerializer(read_only=True)
    author = AuthorSerializer(read_only=True)

    # 寫入用:接受 PK,write_only 不會出現在回應中
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(),
        source='category',      # 對應到 Model 的 category 欄位
        write_only=True,
        allow_null=True,
        required=False,
    )
    author_id = serializers.PrimaryKeyRelatedField(
        queryset=User.objects.all(),
        source='author',
        write_only=True,
    )

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'content',
            'author', 'author_id',
            'category', 'category_id',
            'created_at',
        ]

這樣,POST 請求只需傳送 {"title": "...", "author_id": 42, "category_id": 3},而 GET 回應會自動展開為巢狀物件。這是一個非常實用的設計模式(Design Pattern),幾乎所有正式專案都會採用。


自訂 create() 方法(處理巢狀寫入)

當你需要在建立主物件時同時建立或關聯巢狀物件(例如一次建立文章與其標籤),就必須覆寫 create() 方法:

class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ['id', 'name']

class PostWithTagsSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True)  # 可寫入的巢狀序列化器

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'tags']
        read_only_fields = ['author']

    def create(self, validated_data):
        # 第一步:從 validated_data 取出巢狀資料
        tags_data = validated_data.pop('tags', [])

        # 第二步:建立主物件
        post = Post.objects.create(**validated_data)

        # 第三步:處理巢狀關聯(ManyToMany)
        for tag_data in tags_data:
            tag, _ = Tag.objects.get_or_create(**tag_data)
            post.tags.add(tag)

        return post

這裡使用 get_or_create() 而非 create(),是為了避免重複建立同名的 Tag。


自訂 update() 方法

更新巢狀資料比建立更複雜,因為你需要決定策略:是 清除後重建逐一比對更新,還是 僅新增不刪除

class PostWithTagsSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True)

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'tags']

    def update(self, instance, validated_data):
        # 取出巢狀資料
        tags_data = validated_data.pop('tags', None)

        # 更新主物件的一般欄位
        for attr, value in validated_data.items():
            setattr(instance, attr, value)
        instance.save()

        # 更新 ManyToMany 關聯(策略:清除後重建)
        if tags_data is not None:
            instance.tags.clear()  # 清除現有關聯
            for tag_data in tags_data:
                tag, _ = Tag.objects.get_or_create(**tag_data)
                instance.tags.add(tag)

        return instance

多層巢狀寫入的處理策略

當巢狀層級超過一層(例如 Post -> Comment -> Reply),處理邏輯會更加複雜。以下是幾個建議策略:

  1. 扁平化 API 設計:將深層巢狀拆分為獨立的 endpoint。例如不在 Post API 中處理 Comment 的建立,而是提供獨立的 /posts/{id}/comments/ endpoint
  2. 使用第三方套件drf-writable-nested 提供了 WritableNestedModelSerializer,自動處理多層巢狀的 create/update 邏輯
  3. 使用 Transaction(交易):多層巢狀寫入時,務必使用 transaction.atomic() 確保資料一致性
from django.db import transaction

class PostWithTagsSerializer(serializers.ModelSerializer):
    tags = TagSerializer(many=True)

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'tags']

    @transaction.atomic
    def create(self, validated_data):
        tags_data = validated_data.pop('tags', [])
        post = Post.objects.create(**validated_data)
        for tag_data in tags_data:
            tag, _ = Tag.objects.get_or_create(**tag_data)
            post.tags.add(tag)
        return post

使用 @transaction.atomic 裝飾器(Decorator)可以確保:如果巢狀物件建立過程中任何一步失敗,所有變更都會被回滾(Rollback),避免資料庫出現不一致的狀態。


Serializer 效能優化

巢狀序列化器最容易引發的效能問題就是 N+1 查詢(N+1 Query Problem)。假設你查詢 100 篇文章,每篇文章的巢狀 author 都會額外觸發一次資料庫查詢,總共產生 1 + 100 = 101 次查詢。

解決 N+1 問題的關鍵在於在 ViewSet 層級預載入關聯資料:

# views.py
from rest_framework import viewsets
from .models import Post
from .serializers import PostDetailSerializer

class PostViewSet(viewsets.ModelViewSet):
    serializer_class = PostDetailSerializer

    def get_queryset(self):
        return Post.objects.select_related(
            'author',      # ForeignKey:使用 SQL JOIN 一次查詢
            'category',    # ForeignKey:同上
        ).prefetch_related(
            'tags',        # ManyToMany:額外一次查詢預載入
        )

select_related vs prefetch_related 的選擇原則:

方法適用關聯類型實作方式查詢次數
select_relatedForeignKey、OneToOneFieldSQL JOIN1 次
prefetch_relatedManyToManyField、反向 ForeignKey額外 SELECT + Python 組合2 次

套用上述最佳化後,無論查詢多少篇文章,資料庫查詢次數始終固定為 2-3 次。

避免 N+1 查詢:SerializerMethodField 搭配 annotate

前面提到的 get_tag_count 方法會對每筆資料觸發一次 COUNT 查詢。更好的做法是在 QuerySet 層級使用 annotate()(註解)預先計算:

from django.db.models import Count

# views.py
class PostViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Post.objects.select_related(
            'author', 'category',
        ).prefetch_related(
            'tags',
        ).annotate(
            tag_count=Count('tags'),  # 在 SQL 層級計算標籤數量
        )

# serializers.py
class PostSerializer(serializers.ModelSerializer):
    # 直接從 annotate 的結果取值,無需 SerializerMethodField
    tag_count = serializers.IntegerField(read_only=True)

    class Meta:
        model = Post
        fields = ['id', 'title', 'tag_count', 'created_at']

這樣 tag_count 在 SQL 查詢階段就已經計算完成,序列化時直接讀取屬性即可,完全不會產生額外查詢。

使用 source 參數減少計算

source 參數可以直接映射 Model 的屬性、方法或關聯欄位路徑,在許多場景下可以取代 SerializerMethodField,既簡潔又高效:

class PostSerializer(serializers.ModelSerializer):
    # 用 source 取代 SerializerMethodField
    author_username = serializers.CharField(source='author.username', read_only=True)
    author_email = serializers.EmailField(source='author.email', read_only=True)
    category_name = serializers.CharField(source='category.name', read_only=True)

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'author_username',
            'author_email', 'category_name', 'created_at',
        ]

source='author.username' 讓 DRF 自動沿著關聯路徑取值,等同於 Python 的 instance.author.username。搭配 select_related('author') 就不會產生額外查詢。相比 SerializerMethodField 需要額外定義方法,source 參數讓程式碼更簡潔。


總結

本篇文章從 DRF 的五種關聯欄位表示方式出發,逐步深入巢狀序列化器的讀取與寫入,最後聚焦在效能優化上。以下是核心要點的整理:

關聯欄位選擇指南:

  • PrimaryKeyRelatedField:預設行為,可讀寫,傳輸 ID
  • StringRelatedField:唯讀,顯示 __str__() 結果,適合簡易展示
  • SlugRelatedField:可讀寫,用指定欄位值取代 PK,API 更直覺
  • HyperlinkedRelatedField:唯讀/可讀寫,以 URL 表示,符合 HATEOAS
  • Nested Serializer:最完整的巢狀物件展示,預設唯讀

巢狀寫入核心原則:

  • 覆寫 create() / update() 手動處理巢狀資料
  • 使用 transaction.atomic() 確保資料一致性
  • 多層巢狀建議拆分為獨立 endpoint 或使用 drf-writable-nested

效能優化三步驟:

  1. select_related():預載入 ForeignKey / OneToOneField
  2. prefetch_related():預載入 ManyToManyField / 反向 ForeignKey
  3. annotate():在 SQL 層級預先計算聚合值,避免 SerializerMethodField 的逐筆查詢

掌握這些進階技巧後,你就能設計出結構清晰、效能優異的 DRF API。下一篇我們將探討 DRF 的認證(Authentication)與權限(Permissions)機制,進一步完善 API 的安全性。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。