JSON 工具箱

JSON Schema 報錯看不懂:Ajv 錯誤定位與錯誤資訊治理

結論:校驗器給出的是一串機器錯誤,使用者需要的是「哪個輸入框錯了」。本文拆解 Ajv 的 errors 陣列,講清 instancePath 與 schemaPath 的差別,並給出歸類、自訂文案、國際化與前端呈現的完整方案。

實戰約 10 分鐘發佈於 2026-10-31更新於 2026-10-31
Ajv 校驗錯誤定位與錯誤資訊治理示意圖

結論:先看 instancePath 定位出錯的資料位置,再看 schemaPath 定位觸發的規則;required 與 additionalProperties 要從 params 取回真正的鍵名,再決定展示文案。

校驗器只負責說「哪條規則沒過」,把這句話翻譯成「哪個輸入框錯了」,是使用者的工作。

一、errors 陣列到底給了什麼

Ajv 校驗失敗後不會拋異常,而是把 validate.errors 留給你。預設設定只保留第一條錯誤,開啟 allErrors 後才會給出完整清單。單條錯誤物件只有五個常用欄位,理解它們的語意是後續所有處理的前提:

欄位 含義 範例
instancePath 出錯值在資料中的 JSON Pointer /email
schemaPath 失敗關鍵字在 schema 中的位置 #/properties/email/format
keyword 失敗的約束名 format
params 該約束的機器可讀參數 { "format": "email" }
message 英文描述,隨版本變化 must match format "email"

一條真實的錯誤長這樣:

[
  {
    "instancePath": "/email",
    "schemaPath": "#/properties/email/format",
    "keyword": "format",
    "params": { "format": "email" },
    "message": "must match format \"email\""
  }
]

注意 message 是給人看的散文,不是穩定的介面。Ajv 升級後措辭可能變化,多語言場景下也沒法直接用,所以不要用正規表示式去解析它,更不要把原文直接回傳給客戶端。

二、instancePath 與 schemaPath 各回答什麼

instancePath 回答「資料裡哪一段錯了」,schemaPath 回答「哪條規則觸發了錯誤」。兩者不對稱,是兩個不同座標系:

  • instancePath 是 RFC 6901 的 JSON Pointer,空字串表示根節點;/items/0/price 表示陣列第 0 項的價格欄位;
  • schemaPath 以 # 開頭,指關鍵字在 schema 文件裡的位置,跨檔案 $ref 時它指向被引用 schema 內部,需要結合 $id 才能定位到具體檔案;
  • 鍵名裡含 / 或 ~ 時,Pointer 會分別轉義為 ~1 與 ~0,呈現前要還原,否則介面上會出現莫名其妙的符號。

舉個例子,instancePath: "/email" 與 schemaPath: "#/properties/email/format" 同時出現,含義是「根物件的 email 欄位沒有通過 format 校驗」。

最容易踩的坑是 required 與 additionalProperties:它們報錯的 instancePath 指向父物件,不是出問題的那個鍵。

關鍵字 instancePath 指向 真正的鍵在哪
required 父物件(根錯誤時為空字串) params.missingProperty
additionalProperties 父物件 params.additionalProperty
type 出錯值本身 —
enum 出錯值本身 params.allowedValues

把錯誤對映到表單欄位時,必須先處理這兩個特例,否則「缺少 email」會被顯示成「根物件錯誤」,使用者完全不知道該填哪裡。

三、allErrors 與 verbose:完整性與成本

預設的 allErrors: false 讓 Ajv 碰到第一個錯誤就停止。對命令列工具足夠,對表單是災難:使用者改完一個欄位提交,再看到下一個錯誤,來回三次才能填對一張表。

設定 行為 收益 代價
allErrors: false 只報第一條 最快、記憶體最小 使用者需要反覆提交
allErrors: true 收集全部錯誤 一次給出所有問題 複雜 schema 下錯誤數量爆炸
verbose: true 錯誤裡附帶 schema 與 data 便於服務端排查 體積成倍增長,絕不能直出
messages: false 去掉 message 欄位 減小產物與回應體積 失去除錯用的英文線索

建議:服務端校驗開 allErrors: true,內部日誌保留完整錯誤;對外回應只取白名單欄位。 verbose 只在排查階段臨時打開,它會把命中的 schema 片段和使用者資料一起塞進錯誤物件,既佔記憶體又可能把敏感資料帶進日誌。

另一個常見問題是錯誤數量。一個 anyOf 分支失敗會連帶產出多個子錯誤,再疊加一條彙總錯誤;oneOf 多分支時更明顯。直接把陣列原樣呈現,使用者會看到同一欄位重複五六條提示。歸類必須在呈現前完成。

四、把錯誤歸類到欄位

歸類要做三件事:把路徑還原成欄位名、把 required 與 additionalProperties 的錯位修正、把同一欄位的多條錯誤合併成一條最具體的提示。

/** 一條可呈現的校驗錯誤 */
interface FieldError {
  field: string;
  keyword: string;
  params: Record<string, unknown>;
}

/**
 * 把 Ajv 錯誤陣列歸類到欄位。
 * @param errors Ajv 產出的錯誤陣列
 * @returns 以欄位路徑為鍵、錯誤清單為值的對映
 */
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
  const grouped: Record<string, FieldError[]> = {};

  for (const errorItem of errors) {
    const keyword: string = errorItem.keyword;
    let field: string = errorItem.instancePath;

    if (keyword === 'required') {
      const parentPath: string = errorItem.instancePath;
      field = `${parentPath}/${errorItem.params.missingProperty}`;
    } else if (keyword === 'additionalProperties') {
      field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
    }

    const normalizedField = field === '' ? '(root)' : field;
    (grouped[normalizedField] ??= []).push({
      field: normalizedField,
      keyword,
      params: errorItem.params ?? {},
    });
  }

  return grouped;
}

合併策略按優先級從高到低:required(必填缺失)> type(型別錯誤)> enum/const > format/pattern > 長度與範圍。同一個欄位只呈現優先級最高的一條,其餘留在日誌裡。原因很簡單:型別錯了的時候,長度提示沒有意義,使用者先改型別即可。

oneOf 與 anyOf 的彙總錯誤(keyword 為 oneOf/anyOf)通常沒有可操作資訊,呈現時應跳過,只保留子分支裡命中具體欄位的那些。

五、自訂訊息:外掛與模板表兩條路

Ajv 本身不提供中文文案,常見做法有兩種。

方案一:ajv-errors 外掛。 它引入 errorMessage 關鍵字,可以在 schema 裡就近寫文案:

{
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "errorMessage": {
        "format": "請填寫有效的電子郵件地址",
        "type": "電子郵件必須是字串"
      }
    }
  }
}

優點是文案跟著欄位走,schema 即文件;缺點是文案被寫死成一種語言,國際化時要維護多份 schema,而且外掛只在 allErrors: true 時才能替換全部訊息,漏開這個選項會得到中英混雜的結果。

方案二:關鍵字到模板的對映表。 校驗器只負責給 keyword 與 params,文案由應用層生成:

關鍵字 模板 可用參數
required 缺少必填欄位:{field} missingProperty
type 型別應為 {type} type
minLength 至少輸入 {limit} 個字元 limit
maximum 不能大於 {limit} limit
format 格式不符合要求:{format} format
enum 只能是允許選項之一 allowedValues
pattern 內容不符合約定格式 pattern(不要直出)

方案二更適合需要多語言、需要統一文案風格的產品。它把「校驗」和「表達」徹底分開,schema 不承擔翻譯職責,前端也能複用同一套模板。

六、國際化:訊息不屬於校驗器

把文案放進語言包,是治理錯誤資訊的起點。語言包只存模板與鍵,不存具體欄位名;欄位名由表單設定或介面契約提供,兩相結合才生成最終提示。

/** 校驗錯誤文案模板,鍵為 Ajv 的 keyword */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
  required: (params) => `缺少必填欄位:${params.missingProperty}`,
  type: (params) => `型別應為 ${params.type}`,
  minLength: (params) => `至少輸入 ${params.limit} 個字元`,
  maximum: (params) => `不能大於 ${params.limit}`,
  format: () => '格式不符合要求',
};

/**
 * 產生一條面向使用者的校驗提示。
 * @param keyword 失敗的約束名
 * @param params 約束參數
 * @returns 本地化後的提示文字
 */
function buildMessage(keyword: string, params: Record<string, any>): string {
  const template = VALIDATION_TEMPLATES[keyword];
  return template ? template(params) : '內容不符合要求';
}

三個注意點:

  1. 不要拼接句子。中文可以說「電子郵件格式不正確」,英文需要「Email format is invalid」,語序不同,拼字串一定會出洋相;
  2. 複數與量詞交給 i18n 套件,模板裡不要寫死「個字元」,英文的 1 character 與 3 characters 就不同;
  3. 欄位名也要翻譯。params.missingProperty 是 email,介面上應顯示「電子郵件」,兩者的對映放在表單設定裡,而不是塞進語言包。

七、別把內部結構回傳給客戶端

開發階段把 schemaPath、params、message 一起回傳很省事,生產環境這是資訊洩露:allowedValues 會暴露內部列舉,pattern 會暴露校驗正規表示式,schemaPath 會暴露檔案結構與欄位命名,某些場景下等於把介面的弱點交給攻擊者。

對外回應只保留三個欄位:欄位路徑(或表單欄位名)、錯誤碼、使用者可讀文案。錯誤碼用穩定的短標識,別用 keyword 原名,給自己留一層緩衝。

{
  "type": "about:blank",
  "title": "請求資料校驗失敗",
  "status": 422,
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "請填寫有效的電子郵件地址" },
    { "field": "items[0].price", "code": "invalid_pattern", "message": "金額必須保留兩位小數" }
  ]
}

服務端日誌則相反:完整錯誤陣列、請求 id、schema 版本號都要留下,排查時才能回答「到底哪條規則攔的」。日誌裡避免整段列印使用者資料,只保留出錯路徑與關鍵字。

八、前端呈現:從欄位高亮到編輯器定位

錯誤最終要落到介面上,呈現方式取決於場景:

場景 呈現方式 關鍵動作
表單 欄位下方紅字 + 首個錯誤自動聚焦 路徑對映到表單 name,陣列索引保留
表格批次匯入 列號 + 欄名 + 錯誤摘要,支援匯出 把 /items/3/price 還原成第 4 列價格欄
JSON 編輯器 行內標記 + 問題面板 用解析器把 JSON Pointer 對映到行列
API 除錯工具 結構化錯誤清單,保留 schemaPath 面向開發者,可呈現更多細節

三個實作要點:

  • 一次只顯示每個欄位的第一條,其餘摺疊成「還有 2 個問題」,避免表單被紅字淹沒;
  • 陣列索引要保留在定位裡,但呈現時可去掉,例如「第 4 列的價格」比 /items/3/price 好讀;
  • 編輯器場景需要行列對映。純字串搜尋不可靠,欄位名重複時會定位到錯誤位置,應由解析器給出每個節點的偏移量,再換算成行列。

如果錯誤來自使用者手寫的 JSON,而不是表單,第一步應確認文件本身語法合法。語法錯誤發生在解析階段,根本輪不到 schema 校驗。JSON 解析格式化會直接指出行列,比在 schema 錯誤裡繞圈子快得多。關於錯誤資訊的產生端,即由樣本反推 schema 時的過嚴問題,見自動生成的 JSON Schema 為什麼總在拒絕真實資料;校驗器設定與關鍵字體系見JSON Schema 從入門到工程化。

九、常見問題

為什麼只收到一條錯誤

預設 allErrors 為 false,Ajv 遇到第一個失敗就回傳。表單場景應顯式開啟,否則使用者要反覆提交才能看到全部問題。

required 報錯的路徑為什麼是空的

required 的 instancePath 指向父物件,根物件就是空字串,缺失的鍵名在 params.missingProperty 裡。把兩者拼起來才是完整路徑,直接呈現 instancePath 會得到「根節點錯誤」這種無效提示。

message 能直接呈現給使用者嗎

不建議。它是英文散文,措辭隨版本變化,也沒有國際化能力。更穩妥的做法是按 keyword 與 params 自己生成文案,message 只用於日誌排查。

自訂錯誤訊息為什麼沒生效

先檢查是否開啟了 allErrors: true,ajv-errors 在這個開關關閉時只能替換部分訊息。其次確認 errorMessage 的層級與失敗關鍵字同級,寫錯一層就不會被識別。

生產環境回傳哪些欄位比較安全

只回傳欄位路徑、穩定錯誤碼與使用者文案。schemaPath、params、message 留在服務端日誌,既能排查問題,也不會暴露內部列舉、正規表示式與檔案結構。