快轉到主要內容
  1. 教學文章/

Python msgspec 實戰:高速 Schema 驗證、JSON 與 MessagePack

·6 分鐘· loading · loading · ·
Python Msgspec Json Messagepack Schema Serialization Validation
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 92: 本文

featured

一. 前言:資料邊界不是只有「快」或「安全」
#

Python 專案裡,資料通常不是乖乖待在一個地方。 它會從 API 進來,從 queue 進來,從 Redis 快取讀出來,也可能從 JSONL 檔案一行一行掃過去。

這些地方都有同一個問題:你收到的是 bytes 或字串,但程式真正想要的是有結構、有型別、能安心使用的物件。

常見做法通常分成兩派。 一派用 json / orjson 很快地 parse 成 dict,然後自己檢查。 另一派用 Pydantic 建 model,讓 validation 比較清楚。

兩派都很好。 拍拍君不是來拆台的。 但如果你的需求剛好是「decode 很快,而且 decode 的同時做 schema validation」,那就可以看看 msgspec

msgspec 是一個高效能 serialization / validation 函式庫。 它的核心概念很簡單:

  • 用 Python type hints 定義資料 shape。
  • msgspec.Struct 做輕量資料物件。
  • decode JSON 或 MessagePack 時直接轉成 typed object。
  • 如果資料格式不對,在邊界就失敗。

如果你看過 Python orjson 實戰,可以把 orjson 想成很快的 JSON 引擎。 如果你看過 Python 資料驗證小幫手:Pydantic,可以把 Pydantic 想成強調 validation 功能與生態整合的 model 工具。

今天這篇不說誰取代誰。 我們只整理一個實務判斷:msgspec 很適合 API client、event payload、cache entry、JSONL ingestion 這種資料邊界。

不要讓髒資料在系統裡旅行太遠。 這就是今天的主題。

二. 安裝:先建立一個乾淨練習專案
#

uv 建一個小專案:

mkdir msgspec-demo
cd msgspec-demo
uv init
uv add msgspec

不用 uv 的話:

python -m venv .venv
source .venv/bin/activate
pip install msgspec

確認版本:

import msgspec

print(msgspec.__version__)

這篇會用三個主要模組:

  • msgspec.Struct:定義資料模型。
  • msgspec.json:JSON encode/decode。
  • msgspec.msgpack:MessagePack encode/decode。

先不急著開 framework。 把資料邊界練清楚,之後放進 FastAPI、CLI、worker、batch job 都比較穩。

三. 第一個 Struct:比 dict 更有邊界
#

先寫一個最小資料模型:

import msgspec


class User(msgspec.Struct):
    id: int
    name: str
    email: str
    active: bool = True

建立物件:

user = User(
    id=1,
    name="拍拍君",
    email="ppy@example.com",
)

print(user)
print(user.name)

它看起來有點像 dataclass。 但 msgspec.Struct 是為 serialization 和 validation 設計的。

它有幾個實務上很舒服的特性:

  • 欄位固定,不會不小心塞錯 key。
  • type hints 直接成為 decode 時的 schema。
  • 物件比 dict 更容易補全和重構。
  • encode/decode 的效能通常很好。

如果你用 dict,很多錯誤會拖到很後面才爆。 用 Struct 的做法是:資料進來時就檢查。 早點失敗,通常比晚點爆炸便宜很多。

四. JSON decode:從 bytes 直接變成 typed object
#

假設 API 回傳一段 JSON bytes:

import msgspec


class User(msgspec.Struct):
    id: int
    name: str
    email: str
    active: bool = True


raw = b'''
{
  "id": 1,
  "name": "拍拍君",
  "email": "ppy@example.com"
}
'''

user = msgspec.json.decode(raw, type=User)
print(user)
print(user.active)

重點是這行:

user = msgspec.json.decode(raw, type=User)

它不是先 decode 成 dict,再手動丟進 User(...)。 它是 decode 的時候就知道目標型別。 如果資料不符合 schema,會直接丟錯。

例如 id 變成字串:

bad_raw = b'''
{
  "id": "oops",
  "name": "拍拍君",
  "email": "ppy@example.com"
}
'''

try:
    msgspec.json.decode(bad_raw, type=User)
except msgspec.ValidationError as exc:
    print(exc)

你會拿到類似這樣的錯誤:

Expected `int`, got `str` - at `$.id`

這種錯誤訊息很適合放在 API client、資料匯入、事件處理器裡。 它直接告訴你哪個欄位錯了、預期型別是什麼、實際拿到什麼。

這比某個很後面的地方發生 KeyError 好處理太多。

五. JSON encode:輸出也可以保持 schema 感
#

decode 是資料進來。 encode 是資料出去。

msgspec 可以把 Struct 直接編成 JSON bytes:

import msgspec


class User(msgspec.Struct):
    id: int
    name: str
    email: str
    active: bool = True


user = User(id=1, name="拍拍君", email="ppy@example.com")

raw = msgspec.json.encode(user)
print(type(raw))
print(raw)

輸出是 bytes。 這對很多場景很自然:

  • HTTP response body。
  • Redis value。
  • queue message payload。
  • 檔案寫入。

如果你真的需要字串,再 decode:

text = msgspec.json.encode(user).decode("utf-8")

但拍拍君建議先問一句:下一站真的需要 str 嗎? 很多資料管線其實 bytes 就夠了。

六. MessagePack:同一套型別,換一種 wire format
#

JSON 很通用,但不是唯一選擇。 如果你在內部服務、快取、queue、檔案中傳資料,MessagePack 也很常見。

它比 JSON 更緊湊,保留 binary 形態也比較自然。 msgspec 支援同一套 Struct 直接 encode/decode MessagePack。

import msgspec


class Event(msgspec.Struct):
    event_id: str
    kind: str
    user_id: int
    payload: dict[str, str]


event = Event(
    event_id="evt_001",
    kind="user.created",
    user_id=1,
    payload={"source": "signup"},
)

packed = msgspec.msgpack.encode(event)
decoded = msgspec.msgpack.decode(packed, type=Event)

print(type(packed))
print(decoded.kind)

這對 cache payload 很方便。 你可以把資料用 Struct 表達清楚,再決定 wire format:

  • 對外 API:JSON。
  • 內部 queue:MessagePack。
  • 快取:JSON 或 MessagePack,看除錯和大小需求。

重點是 model 不需要重寫。

七. 實戰:寫一個嚴格 API client
#

假設我們要呼叫某個內部 API:

from datetime import datetime

import httpx
import msgspec


class JobStatus(msgspec.Struct, rename="camel"):
    job_id: str
    state: str
    progress: float
    updated_at: datetime

client 可以這樣寫:

def fetch_job_status(base_url: str, job_id: str) -> JobStatus:
    url = f"{base_url}/jobs/{job_id}"

    response = httpx.get(url, timeout=10.0)
    response.raise_for_status()

    try:
        return msgspec.json.decode(response.content, type=JobStatus)
    except msgspec.ValidationError as exc:
        raise RuntimeError(f"Invalid job status response: {exc}") from exc

這段的邊界很清楚:

  • HTTP 錯誤由 raise_for_status() 處理。
  • schema 錯誤由 msgspec.ValidationError 處理。
  • function 回傳值保證是 JobStatus

如果 API response 改版,少了 updatedAt,這段會在 client 邊界立刻爆。 這是好事。 早爆比晚爆好。

八. 測試:schema 錯誤也要測
#

資料邊界的測試不要只測 happy path。 至少補三種:

  • 正確 payload 能 decode。
  • 少欄位會失敗。
  • 型別錯會失敗。

pytest 可以這樣寫:

import pytest
import msgspec


class User(msgspec.Struct):
    id: int
    name: str
    email: str


def test_decode_user_ok():
    raw = b'{"id": 1, "name": "拍拍君", "email": "ppy@example.com"}'

    user = msgspec.json.decode(raw, type=User)

    assert user.id == 1
    assert user.email == "ppy@example.com"


def test_decode_user_missing_email():
    raw = b'{"id": 1, "name": "拍拍君"}'

    with pytest.raises(msgspec.ValidationError):
        msgspec.json.decode(raw, type=User)


def test_decode_user_wrong_id_type():
    raw = b'{"id": "one", "name": "拍拍君", "email": "ppy@example.com"}'

    with pytest.raises(msgspec.ValidationError):
        msgspec.json.decode(raw, type=User)

如果這段邊界很重要,可以再把 ValidationError 包成你自己的 domain exception。 這樣上層程式不需要知道你底下用的是 msgspec

九. 遷移策略:不要一口氣全換
#

如果你現在有很多 dict-based code,可以慢慢遷。

第一步,只替最痛的邊界加 schema。 例如 webhook envelope、queue event、cache entry、API response。

第二步,把最常用的 payload 拆成 Struct。 暫時不確定的深層資料,可以先保留成 dict[str, object],等 shape 穩定後再收斂。

第三步,把 decode 放進單一 function,讓呼叫端只接 typed object。 這樣 msgspec 的使用範圍會集中在邊界,不會散得到處都是。

這樣遷移的好處是風險小、diff 清楚、測試容易補。 不要把整個專案變成大型改造現場。

拍拍君很喜歡這種「先收斂邊界,再收斂內部」的節奏。

十. 什麼時候不要用 msgspec?
#

工具再好,也不是每題都要拿出來。 下面情況可以先不用:

  • 資料量很小,標準庫 json 已經夠用。
  • 團隊已經大量使用 Pydantic,且效能不是瓶頸。
  • 需要非常複雜的人類可讀 validation error format。
  • model 跟 ORM、表單、OpenAPI schema 生成綁很深。
  • 只是一次性腳本,schema 寫完比任務本身還多。

拍拍君的建議是先找一個資料量大、邊界清楚、錯誤代價高的地方試。 例如 API client、event consumer、cache entry。 你會很快感覺到它適不適合你的專案。

不要因為 benchmark 很漂亮就全專案重寫。 效能工具最怕變成架構衝動。

結語
#

msgspec 最迷人的地方,不只是快。 而是它把「快」和「資料契約」放在同一個動作裡。

你可以用 Struct 寫出清楚的 payload shape。 用 msgspec.json.decode(..., type=YourStruct) 在資料進來時驗證。 用 msgspec.json.encode()msgspec.msgpack.encode() 把資料送出去。

今天拍拍君最想你帶走三件事:

  • msgspec 很適合資料邊界,不一定要取代所有 model。
  • JSON 和 MessagePack 可以共用同一套 typed schema。
  • 遷移時先從 API client、event、cache 這些邊界開始。

資料不是只要 parse 成 dict 就結束了。 真正可靠的系統,會在資料剛進門時就問清楚: 你是誰? 你有沒有缺欄位? 你是不是我期待的形狀?

問完再放行,後面的程式會輕鬆很多。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 92: 本文

相關文章

Python orjson 實戰:高速 JSON 序列化與 API 資料處理
·7 分鐘· loading · loading
Python Orjson Json Serialization Api Performance
Python Plotly 實戰:互動式資料視覺化與 Dashboard 圖表
·7 分鐘· loading · loading
Python Plotly Data-Visualization Dashboard Data-Analysis
Python marimo 實戰:可重現的 Reactive Notebook 與資料小工具
·7 分鐘· loading · loading
Python Marimo Notebook Data-App Developer-Tools Reactive
Rich + Typer:打造漂亮又好用的 Python CLI 體驗
·10 分鐘· loading · loading
Python Rich Typer Cli Command-Line Developer-Tools
Streamlit 進階:session_state、cache 與多頁 Dashboard 完全攻略
·11 分鐘· loading · loading
Python Streamlit Dashboard Data-App Cache Session-State
Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools