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

สรุปสั้น: ล็อก response envelope, รูปแบบ error, การแทน ID และเวลา และความหมายของ null เทียบกับฟิลด์ที่ไม่มีอยู่ไว้ใน contract ก่อนจะเถียงเรื่อง pagination และรายละเอียดการตั้งชื่อ; ห่อระดับบนสุดด้วย envelop data เสมอ เพื่อให้เพิ่ม pagination และ tracing ID ภายหลังได้โดยไม่ทำให้ผู้เรียกเดิมพัง
ไม่มีคำตอบมาตรฐานในการออกแบบ API แต่มีการตัดสินใจบางอย่างที่ "เอาคืนยากเมื่อเลือกแล้ว" ซึ่งควรคิดให้รอบคอบก่อนเขียนโค้ดบรรทัดแรก
1. จะห่อ response หรือไม่
สามแบบ:
// แบบ A: response เปล่า
{ "id": 1, "name": "张三" }
// แบบ B: data envelope
{ "data": { "id": 1, "name": "张三" } }
// แบบ C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| แบบ | ข้อดี | ข้อเสีย |
|---|---|---|
| Response เปล่า | ง่ายที่สุด frontend ลดหนึ่งชั้น | แนบ metadata ไม่ได้; อาร์เรย์ระดับบนมีความเสี่ยง JSON hijacking และการวิวัฒน์ |
| Data envelope | เพิ่ม meta / errors / links ได้ง่าย |
ซ้อนเพิ่มหนึ่งระดับ frontend ต้องเขียน res.data.data |
| Data + metadata | Observability ดีที่สุด | ต้องตกลงกันเข้มงวดว่าฟิลด์ใดอยู่ใน meta |
คำแนะนำ: ใช้ envelop data อย่างสม่ำเสมอทั้งรายการและรายละเอียด และอย่าทำให้ระดับบนสุดเป็นอาร์เรย์ วิธีนั้นการเพิ่ม pagination หรือ tracing ID ภายหลังจะไม่ทำให้ contract พัง
2. รูปแบบ error: ลอก RFC 9457
การประดิษฐ์โครงสร้าง error ของตัวเองเป็นแหล่งต้นทุนการทำงานร่วมกันมหาศาล RFC 9457 (แทนที่ RFC 7807) กำหนด application/problem+json:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| ฟิลด์ | ความหมาย | จำเป็น |
|---|---|---|
type |
URI ของชนิดปัญหาที่มั่นคง | แนะนำ |
title |
สรุปสั้นที่มนุษย์อ่านได้ | แนะนำ |
status |
รหัสสถานะ HTTP | แนะนำ |
detail |
คำอธิบายเฉพาะของสิ่งที่เกิดขึ้นครั้งนี้ | ไม่บังคับ |
instance |
อินสแตนซ์ทรัพยากรที่มีปัญหา | ไม่บังคับ |
| ฟิลด์ส่วนขยาย | ฟิลด์ธุรกิจใด ๆ | ไม่บังคับ |
ฟิลด์ส่วนขยาย (เช่น balance / required ข้างบน) ได้รับอนุญาตตามสเปก และ frontend สามารถใช้ค่าเหล่านี้สร้างข้อความที่แม่นยำแทนการแยกวิเคราะห์ข้อความ detail
3. Pagination: cursor ชนะ offset
// Pagination แบบ offset
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Pagination แบบ cursor
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| มิติ | Pagination แบบ offset | Pagination แบบ cursor |
|---|---|---|
| ความยากในการใช้งาน | ต่ำ (LIMIT/OFFSET) |
ปานกลาง (ต้องมีคีย์เรียงลำดับที่มั่นคง) |
| ประสิทธิภาพบนหน้าลึก | แย่ (offset ยิ่งใหญ่ยิ่งช้า) | ดี |
| เมื่อข้อมูลเปลี่ยน | มีรายการซ้ำหรือหายไป | มั่นคง |
| กระโดดไปหน้าใดหน้าหนึ่ง | ได้ | ไม่ได้ |
| ต้องมีจำนวนรวม | ต้องนับเพิ่ม | ไม่ต้อง |
คำแนะนำ: ใช้ pagination แบบ offset สำหรับตารางผู้ดูแลระบบ (ต้องกระโดดหน้าและดูยอดรวม) และ pagination แบบ cursor สำหรับฟีดแบบไม่สิ้นสุดที่ผู้ใช้เห็น
4. ID และเวลา
4.1 ใช้สตริงสำหรับ ID เสมอ
เหตุผลอยู่ในบทความเรื่องชนิดข้อมูล: ฝั่ง JavaScript ค่าที่เกิน 2^53 สูญเสียความแม่นยำอย่างเงียบ ๆ
แม้ ID ปัจจุบันเป็นจำนวนเต็ม auto-increment ขนาดเล็ก การใช้สตริงก็เปิดที่ว่างสำหรับเปลี่ยนไปใช้ snowflake ID ภายหลัง
{ "id": "1000000000000000001" }
4.2 ใช้สตริง RFC 3339 สำหรับเวลา
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- อย่าใช้ timestamp แบบตัวเลขมิลลิวินาที: อ่านยากและหน่วย (วินาที/มิลลิวินาที) ถูกเข้าใจผิดบ่อย;
- ระบุเขตเวลาอย่างชัดเจน: ด้วย
Zหรือ offset; อย่าใช้ "เวลาท้องถิ่นที่ไม่มีเขตเวลา"; - ตั้งชื่อฟิลด์อย่างสม่ำเสมอเป็น
xxxAtเพื่อให้เห็นได้ชัดว่าเป็นจุดในเวลา
5. null และการไม่มีอยู่
คุณต้องเลือกหนึ่งวิธีและเขียนลงในเอกสาร:
| แนวทาง | การแสดง "ไม่มีค่า" | การแสดง "ไม่ต้องแก้ไข" |
|---|---|---|
| แนวทาง A | ค่าฟิลด์เป็น null |
ละฟิลด์นั้นใน request |
| แนวทาง B | ฟิลด์ไม่มีอยู่ | ละฟิลด์นั้นใน request |
ปัญหาาของแนวทาง A คือ "ละไว้" กับ "ส่งเป็น null" มีความหมายต่างกันใน PATCH ซึ่งตีความผิดได้ง่าย;
ปัญหาาของแนวทาง B คือ JSON.stringify ฝั่ง frontend จะตัดฟิลด์ที่ค่าเป็น undefined ทิ้ง ซึ่งบังเอิญตรงกับความหมาย
คำแนะนำเชิงปฏิบัติ:
- ใช้แนวทาง A สำหรับ response body (
nullหมายถึงไม่มีค่าอย่างชัดเจน และ frontend ไม่ต้องแยกundefined); - ใช้แนวทาง B สำหรับ request body (โดยเฉพาะ
PATCH) ร่วมกับ Merge Patch หรือ JSON Patch เพื่อทำให้ความหมายชัดเจน
6. การตั้งชื่อฟิลด์และโครงสร้าง
| การตัดสินใจ | ตัวเลือกทั่วไป | ข้อควรระวัง |
|---|---|---|
| สไตล์การตั้งชื่อ | camelCase (ระบบนิเวศ JS) / snake_case (Python, ฐานข้อมูล) |
ให้สม่ำเสมอทั้งโปรเจกต์ อย่าผสม |
| ค่า enum | ตัวพิมพ์เล็กคั่นด้วยขีดล่าง "pending_review" |
อย่าใช้ตัวเลข อ่านยาก |
| ฟิลด์บูลีน | isActive / enabled |
อย่าใช้ status: 0/1 |
| ฟิลด์อาร์เรย์ | คำนามพหูพจน์ items |
คืน [] สำหรับอาร์เรย์ว่าง ไม่ใช่ null |
| ความลึกการซ้อน | ไม่เกิน 3 ระดับ | ลึกกว่านั้นให้แยกหรือเสนอพารามิเตอร์ expand |
การคืน [] แทน null สำหรับอาร์เรย์ว่างสำคัญมาก: data.items.map(...) ฝั่ง frontend จะไม่พังเพราะ null
7. กลยุทธ์การวิวัฒน์
| การเปลี่ยนแปลง | ทำลายความเข้ากันได้หรือไม่ | วิธีรับมือ |
|---|---|---|
| เพิ่มฟิลด์ที่ไม่บังคับ | ไม่ | ส่งได้เลย |
| เพิ่มฟิลด์ที่บังคับ | ใช่ | เพิ่มค่าเริ่มต้น หรือปล่อยเป็นเวอร์ชัน |
| ลบฟิลด์ | ใช่ | ทำเครื่องหมาย deprecated ก่อน สังเกตผู้เรียก แล้วจึงลบ |
| เปลี่ยนชนิดฟิลด์ | ใช่ | เพิ่มชื่อฟิลด์ใหม่และเขียนคู่กันระหว่างช่วงเปลี่ยนผ่าน |
| เปลี่ยนชุดค่า enum | ขึ้นกับ | ไคลเอนต์ต้องมี fallback สำหรับค่าที่ไม่รู้จัก |
| เปลี่ยนความหมายฟิลด์ | ใช่ | มองเป็นฟิลด์ใหม่ |
กฎวินัยสามข้อ:
- ไคลเอนต์ต้องทนต่อฟิลด์ที่ไม่รู้จัก (อย่าตีกรอบตัวเองด้วย
additionalProperties: false); - ไคลเอนต์ต้องมีสาขา fallback สำหรับค่า enum ที่ไม่รู้จัก;
- เก็บฟิลด์ deprecated ไว้อย่างน้อยหนึ่ง major version และทำเครื่องหมายด้วย
Deprecationใน header ของ response หรือเอกสาร
8. ตัวอย่างที่สมบูรณ์
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Response นี้สะท้อนธรรมเนียมทั้งหมดข้างบน: envelop data, ID เป็นสตริง, จำนวนเงินเป็นสตริง, เวลาที่มีเขตเวลา,
null หมายถึงไม่มี, อาร์เรย์ว่าง, pagination แบบ cursor และ meta สำหรับข้อมูล observability
9. รายการตรวจสอบ
- [ ] ระดับบนสุดไม่ใช่อาร์เรย์ ใช้ envelop
dataอย่างสม่ำเสมอ; - [ ] Error response ตาม RFC 9457 และมี
typeที่มั่นคง; - [ ] รูปร่างของ pagination เข้ากับสถานการณ์ธุรกิจ;
- [ ] ID เป็นสตริง และเวลาใช้ RFC 3339;
- [ ] ความหมายของ
nullและการไม่มีอยู่ถูกเขียนในเอกสาร; - [ ] สไตล์การตั้งชื่อสม่ำเสมอทั้งโปรเจกต์ และ enum ใช้สตริงตัวพิมพ์เล็ก;
- [ ] ไคลเอนต์มี fallback สำหรับฟิลด์ที่ไม่รู้จักและค่า enum ที่ไม่รู้จัก;
- [ ] การเปลี่ยนแปลงที่ทำลายความเข้ากันได้ผ่านการทำเวอร์ชันหรือช่วงเปลี่ยนผ่านแบบเขียนคู่
FAQ
ควรออกแบบ JSON API ที่วิวัฒน์ได้อย่างไร?
ห่อรายการและรายละเอียดด้วย envelop data อย่างสม่ำเสมอและอย่าทำให้ระดับบนสุดเป็นอาร์เรย์ เพื่อให้เพิ่ม pagination และ tracing ID ได้โดยไม่ทำให้ contract พัง ใช้ ID เป็นสตริงและ timestamp RFC 3339 และเขียนความหมายของ null เทียบกับฟิลด์ที่ไม่มีอยู่ลงในเอกสาร ให้ฟิลด์ใหม่เป็นแบบไม่บังคับและมีความหมายอิสระ และให้ไคลเอนต์มี fallback สำหรับฟิลด์และค่า enum ที่ไม่รู้จัก
ทำไมไม่ควรประดิษฐ์รูปแบบ error ของตัวเอง?
การประดิษฐ์โครงสร้าง error ของตัวเองเป็นแหล่งต้นทุนการทำงานร่วมกันมหาศาล ลอก application/problem+json ของ RFC 9457 ระบุปัญหาด้วย type ที่มั่นคง และอนุญาตฟิลด์ส่วนขยายอย่าง balance และ required เพื่อให้ frontend สร้างข้อความที่แม่นยำได้โดยไม่ต้องแยกวิเคราะห์ข้อความ detail
ทุก endpoint ควรใช้ pagination แบบ cursor หรือไม่?
ไม่ ตารางผู้ดูแลระบบต้องกระโดดหน้าและยอดรวม ดังนั้น pagination แบบ offset เหมาะกว่า; ฟีดแบบไม่สิ้นสุดที่ผู้ใช้เห็นควรใช้ pagination แบบ cursor ซึ่งมั่นคงบนหน้าลึกและหลีกเลี่ยงรายการซ้ำหรือช่องว่างเมื่อข้อมูลเปลี่ยน เลือกตามว่าสถานการณ์ต้องการการกระโดดหน้าและยอดรวมหรือไม่