JSON 工具箱

JSON Schema 複用:$ref、$defs 與 allOf/oneOf 組合

結論:$ref 解決複用,allOf/oneOf 解決組合,兩者疊在一起最容易出事。本文按解析順序講清 $id、$defs、條件分支、迴圈引用與打包,並給出可重現的除錯路徑。

進階約 11 分鐘發佈於 2026-10-31更新於 2026-10-31
JSON Schema 複用與組合:$ref、$defs 與 allOf/oneOf 示意圖

結論:先用 $defs + $ref 把公共結構抽成一份,再考慮 allOf/oneOf 組合;分支可能同時成立時用 anyOf 或加 const 判別欄位,只有確實要求「恰好其一」才用 oneOf。

複製貼上省下十分鐘,代價是三個月後兩份 schema 悄悄分叉,而且沒人記得哪一份才是對的。

一、複用分三層,先想清楚在哪一層

欄位一多,第一反應是把所有約束塞進一個檔案。這在上線前是最快的做法,但檔案超過兩百行之後,改一個欄位要在三處保持一致,漏掉一處就會得到「本機通過、線上 500」的效果。複用的本質是讓同一個事實只寫一次,其餘地方只引用。

JSON Schema 提供了三層工具,各自適用的範圍不同:

層次 載體 生效範圍 典型場景
子結構 $defs + $ref 單一 schema 檔案 地址、金額、分頁參數、錯誤結構
檔案 獨立 schema 檔案 + $id 多個檔案、同一服務 使用者、訂單、商品等獨立實體
契約庫 schema 倉庫 + 打包產物 多個服務、跨語言 OpenAPI 元件、對外發布的介面規範

絕大多數專案在第一層就夠了。只有當兩個服務真的需要共享同一份契約、或者 schema 要作為產物發布給其他語言使用時,拆檔案才有收益。過早拆檔案會讓 $id 與相對路徑變複雜,除錯成本往往超過省下的重複。

判斷標準很簡單:同一個結構如果只在兩處出現,複製一次尚可接受;如果會出現在第三處,就應該抽成 $defs;如果它需要被另一種語言或另一個倉庫讀取,才升級成獨立檔案。

二、$id 決定身分,$ref 決定跳轉

$id 不是註解,它宣告這份 schema 的基準位址(base URI)。$ref 的值是一個 URI 引用:先相對目前基準位址解析成絕對位址,再取 # 後面的片段定位到具體節點。基準位址一旦錯位,後面所有相對引用都會指錯地方,但報錯通常只說「找不到引用」,不會告訴你錯在哪個網域。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "address": {
      "$anchor": "address",
      "type": "object",
      "required": ["city"],
      "properties": {
        "city": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" }
      }
    }
  },
  "type": "object",
  "required": ["shipTo"],
  "properties": {
    "shipTo": { "$ref": "#/$defs/address" },
    "billTo": { "$ref": "#address" }
  }
}

shipTo 用 JSON Pointer,billTo 用錨點,兩者指向同一個節點。片段寫法與解析結果對照如下:

寫法 指向 適用版本 注意
# 目前 schema 根節點 全部 遞迴結構常用
#/$defs/address 本文件 $defs 下的節點 2019-09 起 最直觀,推薦
#address 具名錨點 2020-12 用 $anchor draft-07 寫作 $id: "#address"
common.json 相對基準位址的檔案 全部 必須先註冊或打包
https://example.com/x.json 絕對位址 全部 生產環境避免執行時拉取

有一個隱蔽的坑:如果 $defs 裡的子 schema 自己寫了 $id,它會成為新的基準位址,從此處開始的相對引用全部以它為準。把從別處複製來的 schema 貼進檔案時,殘留的 $id 會讓相對引用指向原網域,而校驗器只在真正解析時才發現找不到目標。

三、$defs 只是收納盒,不參與校驗

$defs 是一個普通容器,裡面的內容不會自動生效。只有被 $ref 指過去的那一段才參與校驗,其餘定義哪怕寫得再嚴格,也只是躺在檔案裡。把 required 寫在 $defs 頂層卻沒人引用,資料依然能通過——這不是校驗器有 bug,而是規範如此。

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "兩位小數的金額字串,避免浮點誤差"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

幾個約定值得記住:

  • $defs 的鍵名沒有語意,只用來說明用途,address、Address、addr 都合法,但團隊內應統一風格;
  • 2019-09 之前叫 definitions。兩者都能被 $ref 指向,但在嚴格模式下 definitions 可能被當作未知關鍵字告警;
  • $defs 裡可以放任何 schema,包括只含 allOf 的組合片段,也可以放帶 $id 的獨立子 schema;
  • 不要指望 $defs 做「模板繼承」。它只負責收納,語意仍由引用處的上下文決定。

四、allOf、anyOf、oneOf 的語意差異

三個關鍵字都與「多個分支」有關,但判定條件完全不同。資料命中兩個分支時,allOf 與 anyOf 都算通過,oneOf 直接失敗,這是最容易被誤判為「schema 寫錯了」的地方。

關鍵字 至少命中 至多命中 命中兩個分支 錯誤可讀性 典型用途
allOf 全部 不限 通過 好,逐條約束 組合多個約束片段
anyOf 1 不限 通過 差,分支錯誤成堆 「電子郵件或手機號碼任選其一」
oneOf 1 1 失敗 差 帶判別欄位的聯合型別
not 0 0 失敗 最差 排除少數取值,極少使用
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf 常被誤以為會「合併」物件定義,但它只是要求所有分支都成立。每個分支獨立評估,一個分支裡的 additionalProperties: false 只認識本分支宣告的鍵,會把另一個分支宣告的欄位判成多餘。2020-12 的解法是把未知欄位策略統一到頂層,用 unevaluatedProperties: false 在所有分支評估完之後只判斷一次。

anyOf 適合「多種合法形態」而不是「多種互斥形態」。如果兩個分支可以同時成立,用 oneOf 就會得到 must match exactly one schema in oneOf 這種對使用者毫無意義的報錯。

五、if/then/else 與 discriminator:把條件寫成可判定結構

條件約束比 oneOf 更容易給出好錯誤,因為判定路徑是顯式的:if 成立就走 then,否則走 else。

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": {
    "properties": { "kind": { "const": "image" } },
    "required": ["kind"]
  },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

三個細節決定了它是否按預期工作:

  1. if 裡的 properties 不會因為鍵缺失而失敗,必須配 required 才能表達「kind 必須存在且等於 image」;
  2. if 本身不產生校驗錯誤,失敗只意味著走 else,所以不要指望它攔住非法資料;
  3. 單獨寫 then 或 else 沒有任何作用,它們必須與 if 同級出現。

當聯合型別的分支變多,條件鏈會迅速退化成難以維護的巢狀。更穩妥的寫法是在每個分支裡鎖定一個判別欄位,讓分支天然互斥:

{
  "oneOf": [
    {
      "properties": { "kind": { "const": "card" } },
      "required": ["kind", "cardNumber"]
    },
    {
      "properties": { "kind": { "const": "account" } },
      "required": ["kind", "accountId"]
    }
  ]
}

discriminator 是 OpenAPI 3.x 的擴充,不是 JSON Schema 標準關鍵字。Ajv 在開啟 discriminator: true 選項後支援它,但對映表寫在 mapping 裡,跨工具鏈不一定通用。如果 schema 要同時給多個校驗器使用,優先選 const 判別欄位的寫法,它沒有任何擴充依賴。

六、迴圈引用:樹、鏈結串列與互相引用的檔案

遞迴結構不需要特殊語法,$ref 指回自身即可。校驗器不會在編譯階段無限展開引用,它按資料的實際深度遞迴,所以一份幾十行的 schema 就能描述任意層級的樹。

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/tree-node.json",
  "$defs": {
    "node": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/node" }
        }
      }
    }
  },
  "$ref": "#/$defs/node"
}

遞迴有三條邊界要提前想清楚:

  • 資料深度:schema 不限制層數,但校驗與序列化都按深度消耗堆疊。對外介面應加業務層限制(例如最大 8 層),否則惡意構造的深鏈會讓服務端堆疊溢位;
  • 互相引用:A 檔案引用 B、B 又引用 A 是合法結構,但兩份 schema 都必須先註冊,否則解析順序會決定成敗;
  • 可空分支:遞迴欄位如果允許 null,記得寫成 "type": ["object", "null"] 或用 oneOf 包裹,$ref 不會自動帶上可空語意。

七、跨檔案引用與打包:別在線上拉 schema

跨檔案引用在開發環境往往「能跑」,因為校驗器會按 $id 去網路取檔案;上線後網路策略一變,整條校驗鏈就斷了。生產環境的原則是所有被引用的 schema 都必須在行程內註冊,或者提前打包成一個檔案。

import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

// 先註冊被引用的檔案,鍵必須是它的 $id,否則相對引用解析不到
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// 再編譯入口 schema,compile 只做一次
export const validateOrder = ajv.compile(orderSchema);

如果 order.json 的 $id 是 https://bbdrug.com/schemas/order.json,裡面的 "$ref": "address.json" 會解析成 https://bbdrug.com/schemas/address.json,正好命中上面註冊的鍵。只要鍵與 $id 有一個字元不一致,就會得到 can't resolve reference。

三種落地方式的取捨:

方式 做法 優點 代價
執行時註冊 addSchema 逐個註冊 無建置步驟,檔案可獨立維護 註冊順序與鍵必須手工保證
建置期打包 用引用解析工具合併成單檔 執行時零依賴,發布簡單 產物變大,diff 不直觀
非同步載入 loadSchema + compileAsync 適合 schema 頻繁變化 引入網路與快取問題,不適合關鍵路徑

對前端專案,建置期打包更穩;對服務端,註冊法足夠,但要有測試覆蓋「所有引用都能解析」。無論哪種方式,都應在 CI 裡跑一遍 compile,讓引用錯誤在合併前暴露,而不是等到線上第一條請求。

八、除錯順序:先解析,再編譯,最後看分支

$ref 相關問題幾乎都能按三個階段定位,順序不能顛倒:

現象 常見原因 處理
can't resolve reference ... from id ... $id 拼錯、相對路徑基準不對、目標未註冊 印出 $id 與解析後的絕對位址,再核對註冊鍵
schema with key or id already exists 同一 $id 被註冊兩次,或重複 compile 註冊集中到模組初始化階段,只執行一次
strict mode: unknown keyword: "definitions" 混用了 draft-07 寫法 改成 $defs,不要關嚴格模式
資料同時命中兩個 oneOf 分支 分支約束重疊,缺少判別欄位 每個分支加 const,或改用 anyOf
$defs 裡的約束完全不生效 沒有被任何 $ref 指向 檢查引用路徑,$defs 不會自動校驗

除錯時優先看 schemaPath,它能告訴你失敗發生在哪個分支的哪條關鍵字,比 message 可靠得多。把 compile 結果快取到模組頂層,避免在請求裡重複編譯;schema 改動後用測試涵蓋「應當通過」與「應當失敗」兩側,只測通過路徑等於沒測。更完整的校驗器選型與錯誤資訊設計,見JSON Schema 從入門到工程化,把 schema 接進型別生成的流程見Schema 與 TypeScript 型別雙向同步。

九、常見問題

$ref 能和 type、description 寫在同一層嗎

2020-12 允許 $ref 與兄弟關鍵字共存,兄弟關鍵字會正常參與校驗;draft-07 裡兄弟關鍵字會被忽略,只有 $ref 生效。同一份 schema 跨版本使用時,這是行為差異最大的地方,遷移前務必確認目標校驗器支援的草案版本。

$defs 和 definitions 該用哪個

新專案用 $defs(2019-09 引入)。definitions 在 2020-12 裡不是關鍵字,$ref: "#/definitions/foo" 仍能透過 JSON Pointer 解析成功,但嚴格模式會提示未知關鍵字。混用兩套寫法,等於給未來的自己埋一個只在 CI 才炸的雷。

為什麼 oneOf 明明資料合法卻報錯

因為資料同時滿足了兩個分支。oneOf 要求恰好一個,而分支裡只約束了部分欄位時,另一分支的欄位缺省也會被視為滿足。給每個分支加一個 const 判別欄位,讓分支互斥,是最省事的修法。

迴圈引用會不會讓校驗器無限迴圈

不會。校驗器不會在編譯期展開引用,而是按資料的實際結構遞迴。真正需要擔心的是資料本身過深導致堆疊溢位,以及跨檔案互相引用時解析順序出錯。給遞迴欄位加業務層的深度上限,比在 schema 裡想辦法更有效。

生產環境能不能直接用遠端 $ref

不建議。遠端引用把校驗的可用性綁在網路上,對方網域變更、憑證過期、內網隔離都會讓校驗整體失敗。把被引用的 schema 打包進產物或啟動時註冊,才是可預期的做法。

如何判斷該用 $defs 還是拆成獨立檔案?

同一結構只在兩處出現時複製尚可接受;出現在第三處就該抽成 $defs;需要被另一種語言或另一個倉庫讀取時,才升級成帶獨立 $id 的檔案。

為什麼 $defs 裡的約束完全不生效?

$defs 只是收納盒,沒有被任何 $ref 指向的節點不參與校驗。把 required 寫在 $defs 頂層卻沒人引用,資料依然能透過,這不是校驗器的 bug。

是否應該在 allOf 的分支裡寫 additionalProperties: false?

不應該。每個分支獨立評估,一個分支會把另一個分支宣告的欄位判成多餘。2020-12 的做法是把未知欄位策略提到頂層,用 unevaluatedProperties: false 在所有分支評估後只判斷一次。