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

結論:先用 $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"] }
}
三個細節決定了它是否按預期工作:
if裡的properties不會因為鍵缺失而失敗,必須配required才能表達「kind 必須存在且等於 image」;if本身不產生校驗錯誤,失敗只意味著走else,所以不要指望它攔住非法資料;- 單獨寫
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 在所有分支評估後只判斷一次。