JSON Toolkit

JSON Schema: จากพื้นฐานสู่การใช้งานจริง

สรุปสำคัญ: คีย์เวิร์ดมีมากมาย แต่มีเพียงสิบกว่าตัวที่ใช้ทุกวัน ทำให้ type/properties/required/$ref ถูกต้อง จัดการข้อความ error ให้ดี ก็ครอบคลุมแปดสิบเปอร์เซ็นต์ของกรณีจริง

ระดับกลางอ่านประมาณ 14 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-30
ภาพปกสำหรับ JSON Schema: จากพื้นฐานสู่การใช้งานจริง

สรุปสั้น: ตรึง $schema ไว้ที่ 2020-12 ก่อน จากนั้นแยกโครงสร้างที่ใช้ร่วมกันด้วย $defs + $ref; ใช้ oneOf เฉพาะเมื่อต้องตรงกับสาขาเดียวเท่านั้น และที่เหลือให้เลือก discriminator หรือ anyOf

schema ไม่ใช่ภาคผนวกของเอกสาร; มันคือสินทรัพย์ที่รัน ทดสอบ และสร้างโค้ดได้

1. กำหนดเวอร์ชันให้ชัดเจนก่อน

เวอร์ชัน ค่า $schema สถานะ หมายเหตุ
draft-04 http://json-schema.org/draft-04/schema# ล้าสมัย โปรเจกต์เดิมและ tooling ของ OpenAPI 2.0 บางตัว
draft-07 http://json-schema.org/draft-07/schema# ใช้กันแพร่หลาย รากฐานโดยพฤตินัยของ OpenAPI 3.0
2019-09 https://json-schema.org/draft/2019-09/schema ใช้ได้ เพิ่ม $defs และคำศัพท์ annotation
2020-12 https://json-schema.org/draft/2020-12/schema แนะนำในปัจจุบัน เพิ่ม prefixItems และเปลี่ยนความหมายของ items

ใช้ 2020-12 สำหรับโปรเจกต์ใหม่ โปรดสังเกตว่าใน 2020-12 items จำกัดเฉพาะ "สมาชิกที่เหลือ" — การประกาศแบบ tuple ถูกย้ายไปที่ prefixItems นั่นคือกับดักที่พบบ่อยที่สุดเมื่อย้ายจาก draft-07

2. schema ขั้นต่ำที่ใช้งานได้

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/user.json",
  "type": "object",
  "required": ["id", "name"],
  "properties": {
    "id": { "type": "string", "minLength": 1 },
    "name": { "type": "string", "minLength": 1, "maxLength": 64 },
    "age": { "type": "integer", "minimum": 0, "maximum": 150 },
    "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
  },
  "additionalProperties": false
}

schema นี้ครอบคลุมความกังวลที่พบบ่อยที่สุดสี่ประการ: ข้อจำกัดชนิด ฟิลด์ที่จำเป็น ขีดจำกัดช่วง และข้อจำกัดสมาชิก array

3. แผนที่คีย์เวิร์ด

3.1 ทั่วไป

คีย์เวิร์ด จุดประสงค์ ระวัง
type ชนิด; อาจเป็นอาร์เรย์ "type": ["string", "null"] หมายถึง nullable
enum ค่าที่กำหนดได้ ค่าต้องไม่ซ้ำกัน
const ข้อจำกัดค่าเดียว เหมาะกับค่าตายตัวอย่างเลขเวอร์ชัน
$ref อ้างอิง schema อื่น อ้างอิง schema ทั้งตัวและไม่ merge กับคีย์เวิร์ดข้างเคียงบนออบเจกต์ที่อ้างอิง

3.2 ตัวเลข

multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum โปรดสังเกตว่าใน draft-04 exclusiveMaximum เป็น boolean (ใช้ร่วมกับ maximum); ตั้งแต่ draft-07 เป็นต้นมาจึงกลายเป็นคีย์เวิร์ดตัวเลขแบบเดี่ยว — ซึ่งทำให้หลาย "schema ที่คัดลอกจากเอกสารเก่า" ล้มเหลวภายใต้ strict mode

3.3 สตริง

minLength, maxLength, pattern (regular expression ตาม ECMA-262) และ format (annotation)

minLength นับ Unicode code point ไม่ใช่หน่วย UTF-16 และไม่ใช่ไบต์ การตรวจด้วย string.length ของ JavaScript จะไม่ตรงกัน

3.4 Array

คีย์เวิร์ด จุดประสงค์
prefixItems ข้อจำกัดแบบ tuple ของ 2020-12 จับคู่สมาชิกทีละตัวตามดัชนี
items ข้อจำกัดแบบเดียวกันสำหรับสมาชิกที่เหลือ (2020-12)
contains + minContains/maxContains ต้องมีสมาชิกที่ตรงอย่างน้อย/อย่างมากกี่ตัว
uniqueItems สมาชิกต้องต่างกันทั้งหมด (เปรียบเทียบเชิงลึก; หลีกเลี่ยงกับ array ใหญ่)
minItems / maxItems ข้อจำกัดความยาว

3.5 ออบเจกต์

properties, required, patternProperties, additionalProperties, propertyNames, minProperties, maxProperties, dependentRequired, dependentSchemas

ความเข้าใจผิดที่เกิดบ่อยสามข้อ:

  1. required ต้องการเพียงให้คีย์มีอยู่: {"name": ""} ถูกต้อง หากต้องการปฏิเสธสตริงว่างต้องเพิ่ม minLength: 1;
  2. additionalProperties: false ขยายปัญหาความเข้ากันได้: ฟิลด์เกินหนึ่งฟิลด์จากไคลเอนต์ทำให้ payload ทั้งก้อนล้มเหลว การพัฒนา API จึงบังคับให้ผู้เรียกทุกรายอัปเกรดพร้อมกัน;
  3. properties ไม่จำกัดคีย์ที่ไม่ได้ประกาศ — โดยค่าเริ่มต้นอนุญาต

3.6 การประกอบเชิงตรรกะ

{
  "oneOf": [
    { "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
    { "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
  ]
}
คีย์เวิร์ด ความหมาย ความล้มเหลวทั่วไป
allOf ต้องเป็นจริงทั้งหมด หลายสาขาวางข้อจำกัดขัดแย้งบนฟิลด์เดียวกัน
anyOf ต้องเป็นจริงอย่างน้อยหนึ่ง ข้อความ error ตามไปยังสาขาที่เจาะจงได้ยาก
oneOf ต้องเป็นจริง เพียงหนึ่งเดียว ข้อมูลตรงกับสองสาขาและล้มเหลว แม้ "ดูเหมือนถูกต้องชัด ๆ"
not ต้องไม่เป็นจริง ใช้น้อย และข้อความ error เป็นมิตรน้อยที่สุด

oneOf คือคีย์เวิร์ดที่สร้าง "ข้อมูลถูกต้องแต่ตรวจไม่ผ่าน" ได้บ่อยที่สุด ถ้าข้อจำกัดของสาขาทับซ้อนกัน (เช่นทั้งสองอนุญาตฟิลด์เกิน) เรกคอร์ดเดียวจะตรงกับหลายสาขาและ oneOf ล้มเหลวโดยสิ้นเชิง ในกรณีนั้นให้เปลี่ยนเป็น anyOf หรือใช้ฟิลด์ discriminator พร้อม const ภายในสาขา

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": { "properties": { "kind": { "const": "image" } } },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

สไตล์ discriminator อ่านง่ายกว่า oneOf และให้ error ที่ดีกว่า

4. การแยกและการใช้ซ้ำ

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
    "address": {
      "type": "object",
      "required": ["city"],
      "properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
    }
  },
  "type": "object",
  "required": ["total", "shipTo"],
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "shipTo": { "$ref": "#/$defs/address" }
  }
}
  • $defs คือรูปแบบที่เปิดตัวใน 2019-09; draft-07 ใช้ definitions;
  • การอ้างอิงข้ามไฟล์ใช้ $id แบบสัมพัทธ์ แต่ อย่าพึ่งการดึง schema ระยะไกลตอนรันไทม์ในโปรดักชัน — ให้ bundle เป็นไฟล์สถิตแทน;
  • ไม่สามารถเพิ่มข้อจำกัดข้างการอ้างอิงได้ (พฤติกรรมของ $ref กับคีย์เวิร์ดข้างเคียงแตกต่างตามเวอร์ชัน) เมื่อต้องประกอบ ให้ใช้ allOf

5. การตรวจสอบตอนรันไทม์ด้วย Ajv

import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({
  allErrors: true,
  strict: true,
  allowUnionTypes: true,
});
addFormats(ajv);

const validateOrder = ajv.compile(orderSchema);

export function assertOrder(input: unknown): void {
  if (validateOrder(input)) {
    return;
  }
  const detail = (validateOrder.errors ?? []).map((errorItem) => ({
    path: errorItem.instancePath || '/',
    keyword: errorItem.keyword,
    message: errorItem.message,
    params: errorItem.params,
  }));
  throw new Error(`Order data failed validation: ${JSON.stringify(detail)}`);
}

ประเด็นสำคัญ:

  • เรียก compile เพียงครั้งเดียว ที่ระดับบนสุดของโมดูล; การ compile ใหม่ทุกคำขอเปลี่ยนการตรวจ 10 ms เป็นหลายร้อยมิลลิวินาที;
  • strict: true เปิดเผยคีย์เวิร์ดที่สะกดผิดแต่เนิ่น ๆ (เช่น minlength แทน minLength) จึงคุ้มที่จะเปิด;
  • มีเพียง ajv-formats ที่ทำให้ format: "email" และ format: "date-time" มีผลจริง;
  • 2020-12 ต้องใช้ entry point ajv/dist/2020 — อย่าใช้ default export

6. ออกแบบข้อความ error อย่างไร

validator ให้คุณคือ "คีย์เวิร์ดใดล้มเหลว"; ผู้ใช้ต้องการคือ "ช่องกรอกใดผิด" คุ้มที่จะเพิ่มชั้นการแมป:

const FIELD_LABEL: Record<string, string> = {
  '/name': 'Name',
  '/age': 'Age',
  '/tags': 'Tags',
};

/**
 * แปลง error ของ Ajv เป็นข้อความพร้อมแสดง
 * @param errors รายการ error ของ Ajv
 * @returns อาร์เรย์ของข้อความที่ใช้แสดง
 */
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
  return errors.map((errorItem) => {
    const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
    if (errorItem.keyword === 'required') {
      const missing = (errorItem.params as { missingProperty: string }).missingProperty;
      return `${label} is missing the required field ${missing}`;
    }
    if (errorItem.keyword === 'minLength') {
      return `${label} is too short`;
    }
    return `${label} failed validation (${errorItem.keyword})`;
  });
}

ฝั่ง API ให้เก็บโครงสร้างที่เครื่องอ่านได้ เพื่อให้ frontend ตัดสินใจวิธีนำเสนอ:

{
  "type": "https://bbdrug.com/problems/validation",
  "title": "Request data validation failed",
  "status": 422,
  "errors": [
    { "path": "/items/0/price", "keyword": "pattern", "message": "Must be a monetary string with two decimal places" }
  ]
}

7. ประสิทธิภาพและกับดัก

  • ต้นทุนการตรวจเพิ่มตามความซับซ้อนของ schema และขนาดข้อมูล; สาขา oneOf หลายสาขาทำให้เกิดการระเบิดเชิงผสม ซึ่งเห็นชัดมากเมื่อมีหลายพันเรกคอร์ด;
  • uniqueItems ทำการเปรียบเทียบเชิงลึก O(n²) บน array; สำหรับ array ใหญ่ ให้ deduplicate ในชั้นธุรกิจแทน;
  • หลีกเลี่ยง pattern ที่ backtracking รุนแรงใน pattern (เช่น (a+)+$); นี่คือช่องทางคลาสสิกของ ReDoS;
  • เมื่อ additionalProperties: false ใช้ร่วมกับ allOf ฟิลด์ที่ประกาศใน subschema จะถูกตัดสินว่า "เกิน" โดยตัวแม่ คุณจึงต้องรวมเป็น schema เดียวหรือใช้ unevaluatedProperties

8. รายการตรวจสอบ

  • [ ] ตรึง 2020-12 และประกาศ $schema ที่ส่วนบนของไฟล์;
  • [ ] แยกโครงสร้างที่ใช้ร่วมกันด้วย $defs + $ref แทนการคัดลอกวาง;
  • [ ] เลือก discriminator แทน oneOf;
  • [ ] ใช้ผลลัพธ์ compile ของ validator ซ้ำและเปิด strict mode;
  • [ ] ระบุชัดเจนว่า format ถูกบังคับใช้หรือไม่;
  • [ ] การตอบกลับ error ให้พาธ คีย์เวิร์ด และข้อความสำหรับผู้ใช้;
  • [ ] ใส่ schema ใน CI: รันกรณีของ JSON Schema Test Suite เป็น regression test

คำถามที่พบบ่อย

ควรเลือก draft ของ JSON Schema อย่างไร

ใช้ 2020-12 สำหรับโปรเจกต์ใหม่และประกาศ $schema ที่ส่วนบนของไฟล์ ถ้า schema จะไปอยู่ใน OpenAPI 3.1 ต้องเป็น 2020-12 ด้วย เพราะ 3.1 ฝังเวอร์ชันนั้นโดยตรง; เก็บ draft-07 ไว้เฉพาะโปรเจกต์ที่ยังผูกกับ toolchain เก่า ทั้งสองมีพฤติกรรมต่างกันสำหรับ items และคีย์เวิร์ดข้าง $ref ดังนั้นอย่าผสมในชุด schema เดียว

ทำไม additionalProperties: false จึงขยายปัญหาความเข้ากันได้

มันถือทุกคีย์ที่ไม่ได้ประกาศเป็น error ฟิลด์เกินหนึ่งฟิลด์จากไคลเอนต์จึงทำให้ payload ทั้งก้อนล้มเหลว ผู้เรียกทุกรายต้องอัปเกรดพร้อมกันทุกครั้งที่ API พัฒนา มิฉะนั้นไคลเอนต์เก่าจะถูกปฏิเสธ schema แบบ config ควรปิดฟิลด์เกินอย่างชัดเจน ขณะที่ API สาธารณะต้องชั่งน้ำหนักต้นทุนด้านความเข้ากันได้

ควรใช้ oneOf หรือ anyOf

ใช้ oneOf เฉพาะเมื่อข้อมูลต้องตรงกับสาขาเดียวเท่านั้น ถ้าข้อจำกัดของสาขาเป็นจริงพร้อมกันได้ เรกคอร์ดเดียวจะตรงกับหลายสาขาและ oneOf ล้มเหลวโดยสิ้นเชิง; เปลี่ยนเป็น anyOf หรือเพิ่ม discriminator const ภายในแต่ละสาขา

แหล่งอ้างอิง

บทความที่เกี่ยวข้อง

การซิงก์สองทางระหว่าง Schema และ TypeScript Type

Type และ schema ต่างครอบคลุมคนละส่วน: หนึ่งตอน compile หนึ่งตอนรันไทม์ คำถามไม่ใช่ว่าจะเลือกอันไหน แต่จะทำให้ทั้งสองซิงก์กันโดยอัตโนมัติอย่างไร

ระดับกลางอ่านประมาณ 11 นาทีอัปเดต 2026-10-30

สาระสำคัญในการออกแบบ data contract ของ JSON API

มีหกสิ่งที่เปลี่ยนแปลงได้ยากมากเมื่อปล่อยไปแล้ว: response envelope, รูปแบบ error, รูปร่างของ pagination, การแทน ID และเวลา, ความหมายของ null เทียบกับฟิลด์ที่ไม่มีอยู่ และการวิวัฒน์เวอร์ชัน

ระดับกลางอ่านประมาณ 13 นาทีอัปเดต 2026-10-30

หากไม่ปิด additionalProperties การตรวจสอบ Schema ก็ไม่ได้ตรวจอะไร

การตรวจสอบผ่านไม่ได้แปลว่าข้อมูลสะอาด Pydantic โดยค่าเริ่มต้นเมินฟิลด์ที่ไม่รู้จัก, Zod ตัดทิ้ง, Ajv ปล่อยผ่าน — พารามิเตอร์ที่โมเดลมั่วและคีย์พิมพ์ผิดลอดผ่านอย่างเงียบงัน บทความนี้แสดงสามรูปแบบของการยอมรับอย่างเงียบและวิธีที่ครบถ้วน: ปิดออบเจ็กต์ ควบคุมการแปลงชนิด และรัดทุกระดับของการซ้อน

Intermediateอ่านประมาณ 8 นาทีอัปเดต 2026-11-02