本文 AI 產出,尚未審核

FastAPI ‧ 資料驗證與轉換(Validation & Serialization)

主題:自訂 JSONEncoder


簡介

FastAPI 中,回傳的資料最終都會被序列化成 JSON,交給前端或其他服務使用。
FastAPI 內建的序列化機制主要仰賴 pydantic 的模型與 Python 標準的 json.dumps,但當我們的資料結構裡包含 datetime、Decimal、Enum、UUID、自訂類別或是 MongoDB ObjectId 等非 JSON 原生類型時,預設的編碼器會拋出 TypeError

因此,自訂 JSONEncoder 成了必備的技巧:

  1. 讓 API 能正確回傳複雜物件。
  2. 統一格式(例如 datetime 要固定為 ISO 8601、Decimal 要保留小數點)。
  3. 簡化前端處理,減少資料轉型的成本。

本篇將從概念說明、實作範例、常見陷阱與最佳實踐,一直到真實專案中的應用情境,帶你完整掌握 FastAPI 的自訂 JSONEncoder。


核心概念

1. 為什麼需要自訂 JSONEncoder?

  • 標準 JSON 只支援 str、int、float、bool、None、list、dict
  • Python 內建類型datetime.datetimedecimal.Decimaluuid.UUIDEnum 等,都不在支援範圍。
  • FastAPI 會先把 pydantic 模型轉成 dict,再交給 jsonable_encoderjson.dumps。若 json.dumps 無法處理,就會出錯。

2. FastAPI 的兩條路徑

路徑 說明 何時使用
fastapi.encoders.jsonable_encoder + json.dumps 手動呼叫,適合在 背景任務WebSocket測試 時使用。 想要自行控制序列化行為
自訂 JSONResponse(或全域 app.json_encoder 直接掛在 FastAPI 應用層,所有回傳的 Response 都會走自訂編碼器。 想要一次解決所有端點的序列化需求

小技巧:如果只需要在少數端點調整,可在 Response 建構子裡傳入 json_dumps=custom_json_dumps;若全局需求,直接設定 app.json_encoder

3. 建立自訂 JSONEncoder 的步驟

  1. 繼承自 json.JSONEncoder
  2. 覆寫 default(self, obj):檢查 obj 的類型,回傳可 JSON 序列化的值。
  3. 在 FastAPI 中註冊
    from fastapi import FastAPI
    app = FastAPI()
    app.json_encoder = MyJSONEncoder   # 全局設定
    

4. jsonable_encoder 與自訂編碼器的關係

jsonable_encoder 會先把 pydanticORMdatetime 等物件轉成「JSON 可接受」的 Python 基礎型別(例如 datetimestrEnumvalue)。
若你在 default 中再次處理相同類型,可能會重複轉換。
最佳實踐是:讓 jsonable_encoder 處理大部分常見類型,只在 default 裡處理「特殊」或「自訂」類別。


程式碼範例

以下範例皆以 Python 3.11FastAPI 0.110 為基礎,使用 pydantic 版型。

範例 1:最簡單的自訂 JSONEncoder(支援 datetime 與 UUID)

# file: encoder.py
import json
from datetime import datetime, date
from uuid import UUID

class MyJSONEncoder(json.JSONEncoder):
    """將 datetime、date、UUID 轉成字串"""
    def default(self, obj):
        if isinstance(obj, (datetime, date)):
            # ISO 8601 格式,保持時區資訊
            return obj.isoformat()
        if isinstance(obj, UUID):
            return str(obj)
        # 交給父類別處理其他類型
        return super().default(obj)
# main.py
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from encoder import MyJSONEncoder
from uuid import uuid4
from datetime import datetime

app = FastAPI()
app.json_encoder = MyJSONEncoder   # 全域註冊

@app.get("/info")
def get_info():
    return {
        "now": datetime.utcnow(),
        "id": uuid4()
    }

說明

  • app.json_encoder = MyJSONEncoder 後,所有回傳的 dict 會自動走 MyJSONEncoder
  • 前端收到的 now"2025-11-20T07:12:34.567890Z"id"c1b2a3e4-5d6f-7a8b-9c0d-1e2f3a4b5c6d"

範例 2:支援 Decimal 與 Enum,且保留小數點精度

# file: models.py
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel

class Currency(str, Enum):
    USD = "USD"
    TWD = "TWD"
    EUR = "EUR"

class Product(BaseModel):
    name: str
    price: Decimal          # 需要保留小數點
    currency: Currency
# file: encoder.py(續)
from decimal import Decimal
from enum import Enum

class MyJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Decimal):
            # 轉成字串,避免 float 精度遺失
            return str(obj)
        if isinstance(obj, Enum):
            # Enum 直接回傳 value
            return obj.value
        return super().default(obj)
# main.py(續)
from models import Product, Currency
from decimal import Decimal

@app.get("/product")
def get_product():
    p = Product(
        name="FastAPI 教學書",
        price=Decimal("199.99"),
        currency=Currency.TWD
    )
    return p

說明

  • Decimal 會被序列化成字串 "199.99",前端若要做數值運算可自行轉型。
  • Enum 只回傳 value,讓 JSON 更易讀。

範例 3:自訂類別(例如 MongoDB 的 ObjectId)

# file: encoder.py(續)
from bson import ObjectId   # pip install pymongo

class MyJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, ObjectId):
            # ObjectId 直接轉成 24 位十六進位字串
            return str(obj)
        return super().default(obj)
# main.py(續)
from bson import ObjectId

@app.get("/user/{uid}")
def get_user(uid: str):
    # 假設從 MongoDB 取回的文件
    user_doc = {
        "_id": ObjectId(uid),
        "name": "Alice",
        "created_at": datetime.utcnow()
    }
    return user_doc

說明

  • ObjectId 不是標準 JSON 類型,透過自訂編碼器直接轉成字串,前端只要把它當作唯一識別碼即可。

範例 4:在單一端點使用 JSONResponse 搭配自訂 json_dumps

from fastapi.responses import JSONResponse
import json

def custom_json_dumps(v, *, default):
    # 直接使用我們的編碼器,省去手動設定 app.json_encoder
    return json.dumps(v, cls=MyJSONEncoder, ensure_ascii=False)

@app.get("/custom")
def custom_endpoint():
    data = {
        "now": datetime.now(),
        "price": Decimal("1234.56")
    }
    return JSONResponse(content=data, json_dumps=custom_json_dumps)

說明

  • 只想在這個端點使用自訂序列化時,直接把 json_dumps 傳給 JSONResponse 即可,不會影響其他路由

範例 5:結合 jsonable_encoder 與自訂 Encoder(最佳實踐)

from fastapi.encoders import jsonable_encoder

@app.post("/order")
def create_order(order: dict):
    # 1. 先使用 jsonable_encoder 處理 pydantic、datetime 等常見類型
    safe_data = jsonable_encoder(order, custom_encoder={Decimal: str})
    # 2. 再交給自訂 JSONEncoder 處理特殊類型(例如 ObjectId)
    return JSONResponse(content=safe_data, json_dumps=lambda v, **_: json.dumps(v, cls=MyJSONEncoder))

說明

  • jsonable_encoder 內建支援 datetimeEnumUUID 等,並允許傳入 custom_encoder 針對 Decimal 做字串化。
  • 之後再交給 MyJSONEncoder 處理剩餘的特殊類型,形成 兩層防護,避免遺漏。

常見陷阱與最佳實踐

陷阱 說明 解法 / Best Practice
忘記 ensure_ascii=False 中文會被轉成 Unicode \uXXXX,不友好。 json.dumps 時加上 ensure_ascii=False,或在 JSONResponse 內部設定。
重複序列化 同時使用 jsonable_encoderdefault 處理 datetime,會得到兩次 isoformat,導致字串變成 "2025-11-20T07:12:34Z""\"2025-...\"" 只在一個地方處理,建議先讓 jsonable_encoder 處理常見類型,default 只處理「自訂」類別。
忘記回傳 super().default(obj) default 沒有呼叫父類別,非預期類型會拋出 TypeError,導致 API 500。 永遠在最後 return super().default(obj),讓 Python 處理未知類型。
使用 listdict 的子類別 自訂類別繼承自 list/dict 時,json.dumps 可能直接把它當成原生型別,跳過 default 若需要自訂行為,覆寫 __iter__ 或在 default 中檢查子類別的實例。
全域設定影響測試 app.json_encoder 會影響測試環境,導致測試資料不易比對。 在測試套件的 fixture 中臨時恢復預設 json_encoder,或在測試時使用 client.get(..., json_dumps=...)

最佳實踐總結

  1. 先使用 jsonable_encoder 處理大部分資料型別。
  2. 自訂 JSONEncoder 只針對「真的無法被 jsonable_encoder 處理」的類別。
  3. 全域註冊 (app.json_encoder) 方便統一管理;若只在少數端點需要,使用 JSONResponse(json_dumps=…)
  4. 保持 ensure_ascii=False,讓中文直接輸出。
  5. 寫單元測試,檢查特殊類別的序列化結果,避免因升級 FastAPI 而產生回傳格式變動。

實際應用場景

場景 為什麼需要自訂 JSONEncoder 解決方式
金融系統:回傳金額使用 Decimal,避免浮點誤差。 Decimal 會被 json.dumps 拒絕。 JSONEncoder.default 中將 Decimal 轉成字串,或在 jsonable_encodercustom_encoder={Decimal: str}
IoT 平台:感測器資料帶有 datetimeEnum、自訂 SensorStatus 類別。 前端需要 ISO8601 時間與 Enum 的文字說明。 MyJSONEncoder 處理 datetimeEnum,同時在模型中加入 json_encoders 讓 pydantic 直接支援。
電商後端:商品 ID 使用 MongoDB ObjectId,同時回傳 UUID 作為追蹤碼。 ObjectIdUUID 都不是 JSON 標準類型。 在全域 MyJSONEncoder 中同時處理 ObjectIdUUID,確保所有端點都能正確回傳。
多語系 API:回傳的文字資料含有繁體中文、日文、emoji。 若未設定 ensure_ascii=False,中文會被轉成 Unicode 編碼。 JSONResponsejson.dumps 設定 ensure_ascii=False,讓文字直接呈現。
大型微服務:不同服務使用不同的序列化規則(例如某服務要把 datetime 只保留日期)。 同一套 FastAPI 應用無法同時滿足所有需求。 為特定路由建立 自訂 Response(如 DateOnlyJSONResponse)或在路由內部使用 jsonable_encoder(..., custom_encoder={datetime: lambda d: d.date().isoformat()})

總結

  • FastAPI 的回傳資料最終會走 JSON 序列化,標準編碼器只能處理基本型別。
  • 透過 自訂 JSONEncoder,我們可以把 datetimeUUIDDecimalEnumObjectId 等特殊類別安全地轉成字串或其他 JSON 可接受的型別。
  • 最佳實踐:先利用 jsonable_encoder 處理常見類型,再在 default 中僅針對「自訂」或「第三方」類別做轉換;全域設定 app.json_encoder 讓整個應用保持一致,必要時可在單一端點使用 JSONResponse(json_dumps=…)
  • 注意 ensure_ascii=False、避免重複序列化、以及在測試環境中適當還原編碼器設定,都是提升開發效率與 API 穩定性的關鍵。

掌握了自訂 JSONEncoder 後,你的 FastAPI 專案將能更從容地面對各種複雜資料結構,提供乾淨、可預測的 API 介面,讓前端與其他服務的整合變得更順暢。祝你開發愉快 🎉