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

สรุปสั้น: ตรึง $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
ความเข้าใจผิดที่เกิดบ่อยสามข้อ:
requiredต้องการเพียงให้คีย์มีอยู่:{"name": ""}ถูกต้อง หากต้องการปฏิเสธสตริงว่างต้องเพิ่มminLength: 1;additionalProperties: falseขยายปัญหาความเข้ากันได้: ฟิลด์เกินหนึ่งฟิลด์จากไคลเอนต์ทำให้ payload ทั้งก้อนล้มเหลว การพัฒนา API จึงบังคับให้ผู้เรียกทุกรายอัปเกรดพร้อมกัน;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 ภายในแต่ละสาขา