JSON 工具箱

查詢與校驗常見問題:JSONPath 與 Schema

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

進階約 12 分鐘發佈於 2026-10-30更新於 2026-10-30
查詢與校驗常見問題封面

結論:按條件找節點用 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

兩個容易踩的遷移點:

  1. 元組寫法變了:draft-07 裡 items 是陣列表示元組,2020-12 改成 prefixItems;
  2. $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 欄位型別不對。

兩個實踐建議:

  1. 一次返回全部錯誤,不要遇到第一個就拋異常——前端一次改完比來回試效率高;
  2. 把 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 多數校驗器預設不校驗,別依賴