查詢與校驗常見問題:JSONPath 與 Schema
結論:路徑查詢該用 JSONPath 還是 Pointer、Patch 與 Merge Patch 怎麼取捨、Schema 能攔住哪些錯誤、draft 版本怎麼選,一次講透。

結論:按條件找節點用 JSONPath,精確定位與增刪改用 JSON Pointer 或 JSON Patch,合併配置用 Merge Patch;新專案的 Schema 固定用 2020-12,並開啟 allErrors 一次拿到全部校驗錯誤。
查詢是「找到它」,校驗是「確認它對」。兩件事各有一套標準,別混著用。
一、路徑查詢
JSONPath 和 JSON Pointer 該怎麼選?
看你是「按條件找」還是「按位置取」。
| 維度 | JSONPath | JSON Pointer |
|---|---|---|
| 標準 | RFC 9535(2024) | RFC 6901(2013) |
| 表示式 | $.users[?(@.age > 18)].id |
/users/0/id |
| 通配與遞迴 | 支援 *、.. |
不支援 |
| 條件過濾 | 支援 | 不支援 |
| 返回結果 | 節點集(可能多個) | 單個位置 |
| 表達能力 | 強,但實現差異歷史包袱重 | 弱,但完全確定 |
| 典型用途 | 日誌提取、除錯、資料探查 | JSON Patch、配置定位、錯誤路徑 |
選擇規則很直接:
- 需要「取所有滿足條件的節點」→ JSONPath;
- 需要「精確定位某一個位置並做增刪改」→ JSON Pointer;
- 兩者配合:用 JSONPath 找到節點,用 Pointer 描述它的位置。
不要在介面協議裡用 JSONPath 做定址。JSONPath 的返回是集合,集合為空、單元素、多元素三種情況的 處理分支會讓協議變複雜;Pointer 的語義是「要麼指向一個存在的位置,要麼不存在」,更適合做契約。
$.. 和 $.* 有什麼區別?
$.. 是遞迴下降,會遍歷所有層級;$.* 只是當前層的所有直接子節點。
{
"a": { "id": 1, "child": { "id": 2 } },
"b": { "id": 3 }
}
$.*→{id:1, child:{...}}和{id:3}兩個物件;$..id→1、2、3三個值;$.*.id→1、3,不含巢狀的2。
遞迴下降很實用但代價高:$.. 會遍歷整棵樹,在大文件上是 O(n),而且容易把預期外的深層欄位一起撈出來。
能用具體層級就別用 ..。
過濾器裡能比較字串、能正則匹配嗎?
能比較字串,正則要看實現。
標準寫法(RFC 9535):
$.items[?(@.status == "active")]
$.items[?(@.price > 10 && @.stock > 0)]
$.items[?(@.name == $.target)] // 與另一個節點比較
字串比較用雙引號(單引號在 RFC 9535 裡也允許,但部分老實現只認雙引號)。
數字比較按數值語義,"10" > 9 這種字串與數字混比在標準裡是未定義行為,別依賴。
正則方面,RFC 9535 定義了 match() 與 search() 兩個函式,但各實現的函式支援程度參差不齊。
穩妥做法是:JSONPath 只負責粗篩,正則匹配放到取回結果後的程式碼裡做。
JSON Patch 和 Merge Patch 選哪個?
兩者都是描述「怎麼改」,但表達能力和語義完全不同。
| 維度 | JSON Patch (RFC 6902) | Merge Patch (RFC 7386) |
|---|---|---|
| 形式 | 運算元組 [{"op":"add",...}] |
一個區域性物件 |
| 支援操作 | add / remove / replace / move / copy / test | 只有合併(刪除靠 null) |
| 能運算元組元素 | 能,按下標 | 不能,陣列整體替換 |
| 原子性 | 有 test 可做樂觀鎖 |
無 |
| 文件大小 | 大,但精確 | 小,但表達受限 |
| 適合 | 精細增刪改、API 區域性更新 | 配置合併、表單部分提交 |
判斷口徑:
- 要改陣列裡第 3 個元素的某個欄位 → JSON Patch;
- 要把若干配置項合進現有配置 → Merge Patch;
- 需要「如果原值不是 X 就失敗」的併發控制 → JSON Patch 的
test。
注意 Merge Patch 的 null 語義有陷阱:null 表示「刪除該鍵」,而不是「把值設為 null」。
想設成 null 在 Merge Patch 裡做不到。
二、Schema 校驗
JSON Schema 能解決什麼問題?
它把「資料長什麼樣」從口頭約定變成可機器校驗的契約。具體能攔住的錯誤:
- 必填欄位缺失(
required); - 型別不符,例如該是數字卻傳了字串(
type); - 取值範圍越界(
minimum/maximum/minLength); - 列舉值不在允許集合(
enum); - 陣列元素型別不統一、數量越界(
items/minItems); - 巢狀結構缺欄位(
properties遞迴校驗); - 多餘欄位(
additionalProperties: false)。
它不能解決的:業務規則(「結束時間必須晚於開始時間」)、跨欄位條件(部分能用 if/then 表達,
但可讀性差)、資料庫唯一性、許可權判斷。
所以 Schema 是「資料形狀」的守門員,不是業務邏輯的替代品。
draft-07 和 2020-12 該用哪個?
新專案用 2020-12,存量系統看校驗器支援情況。
| 版本 | 狀態 | 關鍵變化 |
|---|---|---|
| draft-04 | 過時 | 大量老庫仍預設它 |
| draft-07 | 事實標準 | 支援最廣,生態最全 |
| 2019-09 | 過渡版 | 引入 $defs、unevaluatedProperties |
| 2020-12 | 當前推薦 | items 拆成 prefixItems + items,支援 $dynamicRef |
兩個容易踩的遷移點:
- 元組寫法變了:draft-07 裡
items是陣列表示元組,2020-12 改成prefixItems; $ref與兄弟關鍵字:draft-07 裡$ref存在時兄弟關鍵字被忽略,2020-12 允許共存。
如果校驗器只支援 draft-07,硬寫 2020-12 的語法會被靜默忽略——最危險的情況是「校驗透過」但其實壓根沒校驗。
Schema 怎麼生成 TypeScript 型別?
兩條路:型別從 Schema 推導,或者 Schema 從型別生成。
從 Schema 到型別(後端定義契約、前端消費):
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: {
id: { type: 'string' },
age: { type: 'integer' },
},
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
關鍵是 as const:不加的話 type: 'string' 會被推斷成 string 而不是字面量 'string',推導失效。
從型別到 Schema(前端已有型別、需要執行時校驗):
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const userSchema = z.object({ id: z.string(), age: z.number().int().optional() });
const jsonSchema = zodToJsonSchema(userSchema);
這條路的優勢是「一份定義同時得到型別與執行時校驗」,代價是引入了 DSL,
且生成的 Schema 可能帶實現細節(比如 $ref 結構不美觀)。
校驗失敗怎麼定位到具體欄位?
看校驗器返回的 instancePath(2020-12)或 dataPath(draft-07),它是指向錯誤位置的 JSON Pointer。
[
{
"instancePath": "/items/2/price",
"schemaPath": "#/properties/items/items/properties/price/type",
"keyword": "type",
"message": "must be number"
}
]
instancePath: "/items/2/price" 直接告訴你:items 陣列第 3 個元素的 price 欄位型別不對。
兩個實踐建議:
- 一次返回全部錯誤,不要遇到第一個就拋異常——前端一次改完比來回試效率高;
- 把 Pointer 轉成人話再給終端使用者看,
/items/2/price對開發有用,對使用者沒用。
allErrors: true 是多數校驗器的開關(Ajv 需要顯式開啟),預設往往是快速失敗模式。
additionalProperties: false 要不要開?
內部契約建議開,公開 API 謹慎開。
開啟後,任何未在 properties 裡宣告的欄位都會導致校驗失敗。好處是能抓住拼寫錯誤
(userNmae)和意外透傳;壞處是上游加欄位就會打破下游,破壞「向後相容」。
折中方案:
| 場景 | 建議 |
|---|---|
| 內部服務之間 | 開。欄位變更走版本管理 |
| 對外公開 API | 關。允許上游新增欄位 |
| 消費第三方資料 | 關。你控制不了對方 |
| 配置校驗 | 開。配置裡的錯拼應該立刻報錯 |
怎麼校驗陣列元素唯一?
兩種語義,別搞混:
{ "uniqueItems": true }
這是整個元素去重,要求陣列裡沒有完全相同的元素(深度相等)。
{
"type": "array",
"items": { "type": "object", "properties": { "id": { "type": "string" } } },
"uniqueItems": true
}
上面這段只能保證物件整體不重複,兩個 {"id":"a","name":"x"} 與 {"id":"a","name":"y"} 不算重複。
要按某個欄位去重,Schema 本身表達不了,得在業務層校驗:
const ids = items.map((item) => item.id);
if (new Set(ids).size !== ids.length) {
throw new Error('id 重複');
}
format 會真的校驗郵箱和日期嗎?
不一定。format 在規範裡預設是「註解」,不是「斷言」,校驗器可以只收集不校驗。
- Ajv 需要單獨裝
ajv-formats並配置,否則format: "email"形同虛設; - 某些校驗器把
format當作斷言預設開啟,行為不一致; - 即便開啟,
email的正則也很寬鬆,a@b就能透過。
結論:format 不要當作強校驗。郵箱、URL、日期的嚴格校驗放到業務程式碼裡做,
Schema 裡保留 format 作為文件說明是有價值的,但別指望它攔住髒資料。
三、速查
| 問題 | 結論 |
|---|---|
| 按條件找節點 | JSONPath |
| 精確定址、做 Patch | JSON Pointer |
| 改陣列元素 | JSON Patch,Merge Patch 做不到 |
| 合併配置 | Merge Patch |
| 新專案 Schema 版本 | 2020-12,注意 prefixItems |
| Schema 生成 TS 型別 | json-schema-to-ts + as const |
| 定位校驗錯誤 | 讀 instancePath,開 allErrors |
| 按欄位去重 | Schema 做不到,業務層用 Set |
format: email |
多數校驗器預設不校驗,別依賴 |