自動生成的 JSON Schema 為什麼總在拒絕真實資料
結論:把示例 JSON 丟進生成器,得到的 schema 往往一上線就攔掉正常資料。本文用五條同形狀記錄做對照實測,說明「樣本越全反而越糟」的原因,並給出多條取樣、語義補全、錯誤定位的完整落地流程。

結論:先人工把必填與可選邊界收斂——一次喂 5~20 條多形態記錄、關掉「全部必填」——再生成 schema,並手工補 format、enum 與 additionalProperties;只貼一條全欄位樣本必然過嚴。
生成器只看得見你貼進去的那一條記錄。它把「這一條裡有 email」讀成了「每條都必須有 email」,於是你越貼越全,它要求越多。
一、生成器做的是結構對映,不是理解業務
把一段 JSON 交給生成器,它做的事只有一件:走一遍值的結構,把每個節點翻譯成 schema 關鍵字。整個對映表是固定的:
| 樣本里的值 | 推斷出的型別 | 說明 |
|---|---|---|
"hello" |
{"type": "string"} |
不區分郵箱、日期、UUID |
42 |
{"type": "number"} |
整數不會推斷成 integer |
3.14 |
{"type": "number"} |
同上 |
true |
{"type": "boolean"} |
— |
null |
{"type": "null"} |
只認 null,不認「可空」 |
[1, "a"] |
{"type": "array", "items": {"type": ["number", "string"]}} |
元素型別取並集 |
{"a": 1} |
{"type": "object", "properties": {...}, "required": [...]} |
required 是這裡的關鍵 |
問題全在最後一行。required 不是從型別推出來的,而是從「這個鍵在這份樣本里存在」推出來的。樣本是一條記錄時,這句話等價於「每個鍵都必須有」。
而真實介面返回的資料,幾乎總有一半欄位是可選的。這就是衝突的起點。
二、五條同形狀記錄,五種接受率
先看實測。下面五條記錄,任何人一眼都會說「這是同一種形狀」:
[
{
"id": 1,
"name": "Ada",
"email": "ada@example.com",
"tags": ["a"],
"score": 10
},
{ "id": 2, "name": "Bob", "tags": [], "score": 7 },
{
"id": 3,
"name": "Cy",
"email": "cy@example.com",
"tags": ["a", "b"],
"score": 8.5
},
{ "id": 4, "name": "Dee", "email": null, "tags": ["c"], "score": 9 },
{
"id": 5,
"name": "Eve",
"email": "eve@example.com",
"tags": ["d"],
"score": 6,
"admin": true
}
]
email 在 5 條裡出現 3 次,admin 只出現 1 次,第 2 條的 tags 是空陣列。
現在逐條單獨生成 schema,再拿生成結果去校驗全部 5 條(校驗器為 ajv 8):
| 用哪條生成 | 鍵數 | 生成的 required |
接受 5 條中的 |
|---|---|---|---|
| 第 1 條 | 5 | id, name, email, tags, score |
3 條 |
| 第 2 條 | 4 | id, name, tags, score |
5 條 |
| 第 3 條 | 5 | id, name, email, tags, score |
3 條 |
| 第 4 條 | 5 | id, name, email, tags, score |
1 條 |
| 第 5 條 | 6 | id, name, email, tags, score, admin |
1 條 |
五份 schema 沒有一份是對的,但唯一一份能接受全部資料的,來自第 2 條——那條缺了 email 的記錄。它贏的原因很樸素:沒出現的鍵不會被寫進 required。
第 4 條更典型:它的 email 值是 null,生成器就寫死成 {"type": "null"},於是另外 4 條帶字串郵箱的記錄全被拒。
三、為什麼「貼最全的那條」是最糟的選擇
直覺是「貼欄位最全的樣本,schema 才完整」。實測正好相反,而且有個第三方做過更大規模的對照。
absurdtools 用 3000 個隨機「資料家族」、每個家族 8 條記錄、可選欄位各約一半出現,測了不同鍵數的樣本生成出的 schema 能接受多少資料:
| 樣本鍵數 | 能接受該家族資料比例 |
|---|---|
| 2 | 100.0% |
| 3 | 56.8% |
| 4 | 34.8% |
| 5 | 23.4% |
| 6 | 17.7% |
| 7 | 14.9% |
結論不是「略有下降」,而是完全反轉:最簡樣本在 95% 的家族裡給出了最寬鬆的 schema,最全樣本只有 0.1% 做到。
原因在於 required 的語義是「所有」,而可選性只能透過「缺失」被觀察到。你貼一條全欄位記錄,等於告訴生成器「這些鍵一個都不能少」;你貼一條缺欄位記錄,反而給了它「這個鍵可以不出現」的證據。
一句話記住:樣本的價值不在完整,在於覆蓋了哪些缺失。
到底採多少條才夠
同一批實驗還測了收斂速度:按「一個鍵只有至今每條都有才保持必填」的規則逐條累加,required 收斂到真實必填集合的中位記錄數是 3 條,6 條能覆蓋約九成家族,12 條覆蓋全部。
所以日常取樣 5~10 條是價效比最高的區間,硬湊二十條以上收益已經很小。
但有個前提:這些記錄必須來自不同的真實情形。5 條由同一條模板複製出來的記錄,從第 2 條起就不再提供任何新資訊——它們的欄位集合完全一樣,required 一條都收不窄。
四、正解:一次喂多條,讓缺失自己說話
既然單條取樣必然過嚴,正確做法就是讓生成器同時看多條記錄,用「每條都有才算必填」的規則收斂 required。
本站的生成器支援直接貼陣列,並且有一個「全部必填」開關。關掉之後,它按出現次數判定:只有每條記錄都帶的鍵,才寫進 required。
還是那 5 條記錄,這一次把它們組成陣列一起推斷,再取 items 作為元素契約:
| 輸入的記錄數 | 推斷出的 required |
校驗全部 5 條 |
|---|---|---|
| 1 條 | id, name, email, tags, score |
3 條透過 |
| 2 條 | id, name, tags, score |
4 條透過 |
| 3 條 | id, name, tags, score |
4 條透過 |
| 4 條 | id, name, tags, score |
5 條透過 |
| 5 條 | id, name, tags, score |
5 條透過 |
第 4 條記錄進來時 required 就收斂了,之後不再變化。email 和 admin 被正確地放進 properties、不放進 required。而且因為第 4 條的 email 是 null,多條推斷把它合成了 {"type": ["string", "null"]},字串和 null 都能過——這是單條取樣做不到的。
這與 absurdtools 測到的「中位 3 條記錄後 required 收斂」是同一量級。
不過多條推斷也有它自己的盲點:如果這幾條記錄裡的 email 恰好全是 null(比如都取自沒填資料的賬號),推斷結果就會寫成 {"type": "null"},帶字串的郵箱照樣被拒。同理,tags 若全是空陣列,元素型別就無從推斷。
所以取樣還有一條硬要求:每個欄位的每種形態都至少出現一次。字串、數字、null、空陣列各來一條,別讓某一類形態整體缺席。
具體操作:
- 從日誌、Mock 或真實請求裡撈 5~20 條返回,務必包含異常行(空欄位、null、空陣列那條);
- 組造成 JSON 陣列,貼進生成器;
- 關掉「全部必填」,草案選 2020-12;
- 生成結果是
type: array加一個items,取items那一段作為單條記錄的契約; - 立刻用這份 schema 校驗原始樣本,必須 100% 透過——有一條過不了,說明取樣還不夠,回去補記錄。
第 5 步是最省事的自檢。生成器如果連餵給它的資料都容不下,那就是取樣方式錯了,不用往下走。
五、生成器補不上的四件事,只能手寫
即使餵了多條記錄,下面這些仍然推不出來,必須人工補:
| 缺失項 | 實測表現 | 後果 | 補法 |
|---|---|---|---|
| 字串格式 | "a@b.com" 只生成 {"type": "string"} |
校驗 "不是郵箱" 透過 |
加 "format": "email" 並開啟格式斷言 |
| 範圍與列舉 | 看不出 score 只能是 0–100,也看不出 status 只有三種值 |
負數、超大值、亂填的列舉全部透過 | 加 minimum / maximum / enum |
| 未知欄位策略 | 不生成 additionalProperties |
拼錯的鍵被靜默接受,比報錯更危險 | 配置檔案類加 "additionalProperties": false |
| 可空與空集合語義 | 單條取樣遇到 null 就鎖死成 {"type": "null"};空陣列生成 "items": {} |
正常值被拒,或陣列元素完全不受約束 | 改成 ["string", "null"],空陣列補真實元素 |
第三行值得單獨說:additionalProperties 的預設值是放行。這意味著把 timeout 寫成 timeuot 的配置檔案,校驗會透過,然後在執行時靜默失效。對配置類 schema,這是必須顯式關掉的一項。
format 也有個坑:從 draft 2019-09 起,format 屬於註解詞彙,預設只做註釋不強制校驗。加了 format: "email" 卻還能透過非法值,八成是校驗器沒開 validateFormats 或對應的格式外掛,不是 schema 寫錯了。
順帶一句草案選擇:schema 若要進 OpenAPI 3.1,必須用 draft 2020-12,因為 3.1 直接內嵌這一版;老工具鏈(Swagger 2.0 生態、部分 $ref 實現)仍以 draft-07 為主。兩者在 $ref 同級關鍵字和陣列相關關鍵字上的行為不同,同一套 schema 別混著用。
六、沒有文件的第三方介面,用響應樣本反推契約
接第三方回撥、對接不給自己寫文件的合作方、維護十年前的老系統,都會遇到「資料在手、規範沒有」。這時生成器不只是省事,它是拿到契約最快的路徑。
做法與前面一致,但多兩個必須:
一、跨時間、按狀態取樣。 第三方欄位經常隨業務狀態變化——促銷期多出 discount,退款單多出 refundAt,錯誤響應裡乾脆沒有 data。只抓一次,等於只看到一個狀態。
成功、失敗、空結果各來幾條一起推,error 和 data 往往互斥出現,多條推斷會把兩者都放進 properties 而不放進 required,正好符合實際。
二、把 schema 存進倉庫,接一個每日校驗。 拿當天真實響應去校驗,欄位悄悄消失或悄悄改型別時,你會比使用者先知道。
// 每天跑一次:契約漂移告警
const drifted = records.filter((record) => !validate(record));
if (drifted.length > 0) {
console.warn(
`${drifted.length} 條響應與契約不符,首條差異:`,
validate.errors,
);
}
這套做法的價值不在「省了手寫」,而在把口頭約定變成了可執行的告警。第三方改欄位從來不提前通知你,但一定會在你的日誌裡出現。
有個邊界要劃清:反推出來的 schema 描述的是「對方現在返回什麼」,不是「對方承諾返回什麼」。前者用來監控,後者才該寫進合同或介面文件。
七、補全之後,讓校驗錯誤指回具體欄位
schema 能用了,接下來是報錯可讀性。ajv 的錯誤物件有五個欄位,真正有用的是前四個:
| 欄位 | 含義 | 示例(id: 0 違反 minimum: 1) |
|---|---|---|
instancePath |
出錯值在資料裡的 JSON Pointer | "/id" |
schemaPath |
觸發的規則在 schema 裡的位置 | "#/properties/id/minimum" |
keyword |
失敗的約束名 | "minimum" |
params |
該約束的機器可讀細節 | { "comparison": ">=", "limit": 1 } |
message |
英文散文,跨版本會變,不要解析它 | "must be >= 1" |
有一個不對稱最容易踩:required 失敗時,instancePath 指向的是父物件(通常是空字串,即根),不是缺失的那個欄位。缺失的鍵名在 params.missingProperty 裡。同理,additionalProperties 失敗時,多出來的鍵名在 params.additionalProperty。
所以把錯誤對映成「欄位 → 訊息」時,必須先處理這兩個特例:
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
/** 把 ajv 錯誤陣列轉成 { 欄位路徑: [訊息] },供表單高亮 */
function groupByField(errors) {
const grouped = {};
for (const error of errors) {
// required 與 additionalProperties 的錯位欄位在 params 裡,不在 instancePath 上
const field =
error.keyword === "required"
? error.params.missingProperty
: error.keyword === "additionalProperties"
? error.params.additionalProperty
: error.instancePath.replace(/^\//u, "").split("/").join(".") ||
"(root)";
(grouped[field] ??= []).push(error.message);
}
return grouped;
}
if (!validate(data)) console.log(groupByField(validate.errors));
allErrors: true 不能省。預設配置下 ajv 碰到第一個錯誤就停,使用者改完一個再提交、再報下一個,來回三趟才填對一張表。如果還要用 ajv-errors 做自定義文案,少了這個開關,外掛會靜默只替換部分訊息。
八、一條能複用的落地流程
把上面幾步串成固定動作,以後每次接新介面照著走:
- 取樣:抓 5~20 條真實返回,專門挑有空欄位、null、空陣列、多欄位的那幾條;
- 推斷:陣列一起貼進生成器,關「全部必填」,取
items; - 補語義:
format、enum、minimum/maximum、minItems、additionalProperties,一項一項加; - 自檢:原樣本必須 100% 透過,否則回第 1 步補樣本;
- 反證:每個約束都準備一條「應該失敗」的資料,確認它真的失敗——只測透過路徑,等於沒測;
- 入庫:schema 進版本庫,失敗樣本留成測試用例,介面改欄位時先跑測試。
第 5 步的反向測試最容易漏。required 加了一個鍵卻沒驗證過缺失時會不會報錯,等於賭校驗器理解一致。
import { describe, expect, it } from "vitest";
describe("user schema", () => {
it("接受完整記錄", () => {
expect(validate({ id: 1, name: "Ada", tags: [], score: 10 })).toBe(true);
});
it("拒絕缺少 name 的記錄", () => {
expect(validate({ id: 2, tags: [], score: 7 })).toBe(false);
// 同時斷言失敗原因,避免「因為別的欄位錯了才失敗」
expect(
validate.errors?.some((e) => e.params?.missingProperty === "name"),
).toBe(true);
});
});
最後一句斷言很關鍵。只斷言 false,可能在 schema 改壞之後依然綠——它失敗的原因可能早就換了一個。
九、常見問題
生成器出的 schema 能直接上生產嗎
不能,它只是結構的快照。至少要補格式、範圍、列舉和未知欄位策略,再用原始樣本和反向樣本各跑一遍。生成器解決的是「手敲 properties 太煩」,不是「業務規則由它決定」。
寫 schema 的關鍵字型系、$ref 拆分和組合關鍵字,見JSON Schema 從入門到工程化。
為什麼我貼了欄位最全的一條,反而拒絕更多資料
因為 required 是按「樣本里出現過」收集的。全欄位樣本把每個可選鍵都變成了必填項,而真實資料裡這些鍵大多不在。改貼多條記錄,或者貼一條故意缺欄位的記錄,都比貼最全的更接近真相。
整數會被推斷成 integer 嗎
本站的生成器不會,整數和浮點都輸出 number,所以 {"price": 5} 生成的 schema 能接受 5.99。這省掉了「樣本恰好是整數、結果上線後拒絕帶小數的價格」這類事故。部分第三方工具按值推斷 integer,遇到金額欄位要特別小心。
數字精度與型別邊界的細節,見JSON 六種資料型別與數字精度陷阱。
加了 format 卻沒生效
draft 2019-09 之後 format 預設只做註解。要麼在校驗器裡顯式開啟格式斷言(ajv 需裝 ajv-formats),要麼直接用 pattern 硬約束。別假設加了就會攔。
required 報錯,但資料裡明明有這個欄位
先看 instancePath。required 報錯指向的是父物件,說明缺失的鍵不在這一層——常見原因是巢狀層級錯了(/profile/name 不等於根上的 name)、大小寫不同(userId 與 userID),或者上游序列化時把 undefined 整個丟掉了。
排查前先確認文件本身是合法 JSON,註釋、尾逗號這類問題會在解析階段就失敗,輪不到 schema。JSON 解析格式化能直接指出第幾行出錯。
如何確定取樣條數與 JSON Schema 草稿版本?
日常取樣 5~10 條價效比最高,required 的中位收斂點是 3 條,12 條可覆蓋全部家族;每條記錄都要來自不同情形,並讓每個欄位的每種形態至少出現一次。草案固定 2020-12,因為要進 OpenAPI 3.1 時只認這一版。
為什麼 additionalProperties 預設放行反而更危險?
預設值不生成該關鍵字,拼錯的鍵會被靜默接受,校驗透過後在執行時失效,比直接報錯更難排查。對配置類 schema 必須顯式寫 "additionalProperties": false,介面類則要先評估相容成本。
是否應該用反推出來的 schema 做硬性攔截?
不應該。反推的 schema 描述的是對方「現在」返回什麼,不是「承諾」返回什麼,只適合做監控與漂移告警。要寫進合同或介面文件的,必須是對方承諾的契約。