JSON Toolkit

การทำเวอร์ชัน JSON Contract: ความเข้ากันได้ การเลิกใช้ และการย้ายข้อมูล

สรุปสำคัญ: การเพิ่มฟิลด์ไม่ปลอดภัยโดยอัตโนมัติ ครอบคลุมขอบเขตความเข้ากันได้ ฟิลด์เวอร์ชัน ช่วงเวลาการเลิกใช้ การย้ายข้อมูลแบบเขียนคู่ contract test และตัวชี้วัด canary

ขั้นสูงอ่านประมาณ 10 นาทีเผยแพร่ 2026-10-31อัปเดต 2026-10-31
ภาพปกการทำเวอร์ชัน JSON contract ความเข้ากันได้ และการย้ายข้อมูล

สรุปสั้น: ดำเนินการวิวัฒน์เวอร์ชันเป็น expand, backfill แล้ว contract และเก็บเวอร์ชันไว้ในเอกสาร; ยืนยันว่าผู้บริโภคทนต่อฟิลด์และค่า enum ที่ไม่รู้จักก่อนปล่อยอะไร และดำเนินการเลิกใช้ผ่าน freeze, announce, observe และ remove ตามลำดับนั้น

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

1. ขอบเขตความเข้ากันได้: การเพิ่มไม่ปลอดภัยโดยอัตโนมัติ

ตำราพูดว่าฟิลด์ที่ไม่บังคับใหม่คือ "ความเข้ากันได้ย้อนหลัง" แต่นั่นเป็นจริงก็ต่อเมื่อผู้บริโภคทนต่อฟิลด์ที่ไม่รู้จัก การเพิ่มฟิลด์ยังทำลายไคลเอนต์ในอย่างน้อยสามสถานการณ์:

  • ผู้บริโภคตรวจสอบด้วย schema ที่ตั้ง additionalProperties: false;
  • ผู้บริโภคไล่ออบเจ็กต์ตามลำดับคงที่ หรืออ่านค่าตามตำแหน่งแทนที่จะตามคีย์;
  • ฟิลด์ใหม่ใช้ชื่อเก่าแต่เปลี่ยนความหมาย เช่นขยาย status จาก "สถานะคำสั่งซื้อ" เป็น "สถานะการจัดส่ง"

การเพิ่มที่ปลอดภัยต้องมีเงื่อนไขเบื้องต้นสามข้อ: ฟิลด์เป็นแบบไม่บังคับ ความหมายของมันอิสระ และผู้บริโภคไม่สนใจคีย์ที่ไม่รู้จัก ยืนยันทั้งสามก่อนจะคุยเรื่องหมายเลขเวอร์ชันด้วยซ้ำ

{
  "schemaVersion": 2,
  "orderId": "ord-8842",
  "status": "paid",
  "refundDeadline": "2026-11-30T00:00:00+08:00",
  "channel": "wechat"
}

ที่นี่ refundDeadline และ channel เป็นการเพิ่มใน v2 แบบไม่บังคับ ขณะที่ orderId และ status คงความหมายเดิม schemaVersion ถูกเก็บไปกับเอกสารแทนที่จะอยู่ใน HTTP header เท่านั้น

2. การเปลี่ยนแปลงที่ทำลายความเข้ากันได้ซึ่งปล่อยไม่ได้

ถือการเปลี่ยนแปลงต่อไปนี้เป็นเส้นแดงที่ต้องมีเวอร์ชันใหม่หรือช่วงเปลี่ยนผ่านแบบเขียนคู่ การอัปเดตเอกสารไม่ช่วยคุณ:

การเปลี่ยนแปลง ทำไมจึงทำลายความเข้ากันได้ ทางเลือกที่ยอมรับได้
ลบฟิลด์ ผู้บริโภคอ่าน undefined ทันที ทำ deprecated สังเกต แล้วลบหลังช่วงเวลา
เปลี่ยนชนิดฟิลด์ "1" กลายเป็น 1; ไคลเอนต์ที่พิมพ์เข้มงวดล้มเหลวทันที และไคลเอนต์ที่พิมพ์หลวมมักล้มเหลวการตรวจสอบ เพิ่มชื่อฟิลด์ใหม่และเขียนคู่ระหว่างช่วงเปลี่ยนผ่าน
เพิ่มฟิลด์ที่บังคับ ผู้เรียกเก่าไม่ส่งมา การตรวจสอบจึงล้มเหลวเสมอ ให้ค่าเริ่มต้น หรือทำให้บังคับเฉพาะในเวอร์ชันใหม่
เปลี่ยนชุดค่า enum switch ของผู้บริโภคขาดสาขา fallback สอนไคลเอนต์ให้รับค่าที่ไม่รู้จักก่อน แล้วจึงขยาย enum
เปลี่ยนความหมายฟิลด์ ชนิดยังถูกต้องขณะพฤติกรรมเปลี่ยน — กรณีที่ debug ยากที่สุด มองเป็นฟิลด์ใหม่และตรึงความหมายเก่า
เปลี่ยนความลึกการซ้อน ทุก path การเข้าถึงต้องเปลี่ยน คงรูปร่างเดิมและเพิ่มฟิลด์แบบแบนหรือพารามิเตอร์ expand

การเปลี่ยนความหมายคือแถวที่อันตรายที่สุด: การตรวจสอบ schema ยังเขียว การเฝ้าระวังไม่รายงาน error และมีเพียงตัวชี้วัดธุรกิจที่คลาดเคลื่อน การเปลี่ยนแปลงความหมายใด ๆ ควรถูกปล่อยเป็นฟิลด์ใหม่

3. เวอร์ชันอยู่ที่ไหน: เอกสาร, URL, media type

ตำแหน่งที่พบบ่อยสามแบบมีวัตถุประสงค์ต่างกัน:

ตำแหน่ง ตัวอย่าง เหมาะกับ ข้อจำกัด
ฟิลด์ในเอกสาร "schemaVersion": 2 ฐานข้อมูล, message queue, backup ออฟไลน์ ชั้นการขนส่งกำหนดเส้นทางตามมันไม่ได้
URL path /v2/orders/8842 API สาธารณะ ผูกที่อยู่ทรัพยากรกับเวอร์ชันและทำให้การย้ายข้อมูลแพง
Media type application/vnd.acme.order.v2+json บริการภายในที่ต้อง content negotiation ผู้เรียกต้องตั้ง Accept ให้ถูก

การแบ่งงานเชิงปฏิบัติ: ใช้ media-type negotiation บนสาย และเก็บฟิลด์เวอร์ชันในเอกสารเสมอ ข้อความเก่าในคิวจะไม่อัปเกรดเพราะคุณเปลี่ยน URL และไฟล์ backup ก็เช่นกัน มีเพียง schemaVersion ในเอกสารที่ให้พื้นฐานสำหรับ replay, backfill และ audit

GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json

หากคุณทำเวอร์ชันที่ URL อย่างน้อยให้ path เก่าคืนรูปร่างเดิมตลอดช่วงการเลิกใช้ แทนที่จะชี้ไปยัง implementation ใหม่อย่างเงียบ ๆ สำหรับการตัดสินใจเรื่อง API contract ที่กว้างขึ้น ดู สาระสำคัญในการออกแบบ data contract ของ JSON API

4. เปิดช่วงเวลาการเลิกใช้

การเลิกใช้ไม่ใช่การประกาศครั้งเดียว แต่เป็นช่วงที่มีจุดเริ่ม ช่วงสังเกต และจุดสิ้นสุด จังหวะที่ใช้งานได้:

  1. Freeze: หยุดเขียนฟิลด์เก่า แต่ยังอ่านมัน
  2. Announce: ระบุวันลบในเอกสาร changelog และ header ของ response พร้อมกัน
  3. Observe: นับการอ่านฟิลด์เก่าต่อผู้เรียกจนกว่าจะเป็นศูนย์ติดกันสองช่วง
  4. Remove: เมื่อช่วงปิด การอ่านเป็นศูนย์ และ regression test ผ่าน จึงลบฟิลด์และโค้ดความเข้ากันได้ของมัน

Header Sunset ใน response (RFC 8594) ให้วันลบที่เครื่องอ่านได้แก่ไคลเอนต์ Deprecation ยังเป็น IETF draft: ใช้ได้ แต่อย่าสมมติว่าทุกไคลเอนต์เข้าใจมัน จับคู่กับ header Link ที่ชี้ไปยังคู่มือการย้ายข้อมูล:

Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"

ความยาวของช่วงเวลาเป็นธรรมเนียมของทีม ค่าพื้นฐานทั่วไปคือหนึ่งรอบ release หรือ 90 วัน แล้วแต่ค่าใดยาวกว่า API สาธารณะควรได้มากกว่า บริการภายในได้น้อยกว่า แต่ไม่น้อยกว่าเวลาที่ผู้เรียกทุกรายต้องการเพื่ออัปเกรด

5. ค้นหาผู้บริโภคก่อนเลิกใช้

ก่อนเลิกใช้ คุณต้องตอบได้ว่า "ใครยังอ่านสิ่งนี้?" มิฉะนั้นช่วงเวลาคือการหลอกตัวเอง จัดอันดับหลักฐานตามความน่าเชื่อถือ:

  • Gateway log: รวมการเข้าถึงฟิลด์เก่าตาม appId, User-Agent และเวอร์ชันไคลเอนต์
  • Distributed trace: ติดแท็กฟิลด์เก่าในชั้นการทำให้เป็นอนุกรม เพื่อให้ trace แสดงชื่อบริการที่เรียกโดยตรง
  • ทะเบียนผู้บริโภคของคิว: รายการ consumer group น่าเชื่อถือกว่าการค้นหาโค้ด
  • ค้นหาใน repository: rg ครอบคลุมเพียงโค้ดของคุณเอง ผู้เรียกภายนอกยังต้องใช้ log
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

การค้นหาที่พบพิสูจน์ว่า "มีคนใน repository นี้อ่านมัน" ไม่ใช่ "ไม่มีใครอื่นอ่าน" ส่งออก gateway log รายวันและยืนยันว่าการอ่านฟิลด์เก่าเป็นศูนย์ก่อนลบอะไร

6. การย้ายข้อมูล: expand, backfill, contract

Parallel Change ของ Martin Fowler อธิบายสามขั้น: expand (ฟิลด์เก่าและใหม่อยู่ร่วมกันและอ่านได้ทั้งสองเวอร์ชัน), backfill (ย้ายเรกคอร์ดประวัติศาสตร์ไปยังรูปร่างใหม่) และ contract (ลบฟิลด์เก่าและสาขาความเข้ากันได้) อย่ากลับลำดับ มิฉะนั้นคุณจะไม่มีเส้นทางการย้อนกลับ

// โครงการย้ายข้อมูล: cursor ตามคีย์หลัก, การเขียนแบบมีเงื่อนไขเพื่อ idempotency
async function migrateBatch(collection, cursor, batchSize = 500) {
  const records = await collection
    .find({ _id: { $gt: cursor } })
    .limit(batchSize)
    .toArray();

  if (records.length === 0) {
    return { nextCursor: cursor, done: true };
  }

  const operations = records
    .filter((record) => record.schemaVersion < 2)
    .map((record) => ({
      updateOne: {
        filter: { _id: record._id, schemaVersion: record.schemaVersion },
        update: {
          $set: {
            schemaVersion: 2,
            status: record.legacyStatus ?? record.status,
            refundDeadline: record.refundDeadline ?? null,
          },
        },
      },
    }));

  if (operations.length > 0) {
    await collection.bulkWrite(operations);
  }

  return { nextCursor: records.at(-1)._id, done: false };
}

รายละเอียดที่พลาดง่าย:

  • Idempotency: ใส่หมายเลขเวอร์ชันเก่าใน filter เพื่อให้การรันซ้ำไม่เขียนทับเรกคอร์ดที่ย้ายแล้ว
  • Batching และ checkpoint: เดินหน้าด้วย cursor _id เก็บ checkpoint และเริ่มต่อจากมันหลังล้มเหลว
  • การซักซ้อม: รันสคริปต์กับ snapshot ของ production และเปรียบเทียบจำนวนเรกคอร์ดและ checksum ก่อนและหลัง
  • การย้อนกลับ: เก็บฟิลด์เก่าจนกว่าฟิลด์ใหม่จะผ่านการตรวจสอบ สลับ path การอ่านด้วย flag และอย่าย้อนกลับด้วยการแก้ schemaVersion อย่างเดียว
  • การตรวจสอบ: รันข้อจำกัดธุรกิจหลังย้ายข้อมูล (จำนวนเงินไม่ติดลบ การเปลี่ยนสถานะที่ถูกกฎ) ไม่ใช่แค่ตรวจ JSON parse

ข้อมูลคอนฟิกทำตามกระบวนการเดียวกัน ดู การจัดการ JSON สำหรับคอนฟิก fixture และข้อมูลภายในเครื่อง สำหรับวิธีจัดระเบียบไฟล์เหล่านั้น

7. Contract test: ใส่ความเข้ากันได้เข้า CI

ความเข้ากันได้พึ่งความจำของ code review ไม่ได้ ต้องมี test สองชั้นก็พอ:

  1. Shape test: ตรวจสอบ response ด้วย JSON Schema แต่ อย่า ตั้ง additionalProperties: false บน schema ที่ผู้บริโภคใช้
  2. Consumer-subset test: ดึงฟิลด์ที่ผู้บริโภคเก่าพึ่งพาจริง ๆ เป็น sub-schema และ assert ว่า response ใหม่ยังตอบสนองมัน
// Contract test: response ใหม่ต้องยังตอบสนอง subset ของผู้บริโภคเก่า
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';

const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);

test('a v2 response still satisfies the v1 consumer subset', () => {
  const response = buildOrderResponse({ schemaVersion: 2 });
  const { schemaVersion, ...legacyFields } = response;
  const isValid = validateLegacySubset(legacyFields);

  assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});

Sub-schema ประกาศเฉพาะฟิลด์และชนิดที่ผู้บริโภคเก่าอ่านและปล่อยฟิลด์ใหม่ทั้งหมดผ่าน สิ่งนี้จับฟิลด์ที่ถูกลบและชนิดที่เปลี่ยนได้โดยไม่เตือนผิดเมื่อมีการเพิ่ม ให้เก็บข้อมูลเคสในไดเรกทอรี fixture เรียงลำดับและ diff ได้; สำหรับ conflict การ merge ในไฟล์เหล่านั้น ดู Conflict Git ในไฟล์ JSON: union เขียน JSON ที่พัง ให้ merge ตามคีย์แทน

8. การเปิดตัวแบบ canary: ขยายตามมิติ หยุดตามตัวชี้วัด

ปัญหาความเข้ากันได้ไม่ค่อยปรากฏใน unit test; มันปรากฏใน canary ขยายการเปิดเผยจากแคบไปกว้าง:

มิติ แนวปฏิบัติ สิ่งที่ตรวจสอบ
Allowlist ผู้เช่า เปิด path การอ่านใหม่ให้ลูกค้าภายในหรือลูกค้าที่เป็นมิตรก่อน ความต่างของรูปร่างข้อมูลในโลกจริง
เวอร์ชันไคลเอนต์ เปิดเฉพาะไคลเอนต์ที่อัปเกรดเป็นรูปร่างใหม่แล้ว ไคลเอนต์เก่าและใหม่ทำงานเคียงกัน
เปอร์เซ็นต์ทราฟฟิก 5% → 25% → 100% สังเกตหนึ่งรอบธุรกิจเต็มต่อขั้น การเรียกหางยาวและพฤติกรรม cache hit

เฝ้าดูอย่างน้อยสามตัวชี้วัด: อัตรา error ของ API, อัตราล้มเหลวของการตรวจสอบ contract และอัตราส่วนการอ่านระหว่างฟิลด์เก่าและใหม่ นิยามเงื่อนไขหยุดอัตโนมัติ เช่น 5xx เกิน 1% หรือการอ่านฟิลด์เก่ากระโดดกลับ และย้อน flag เก็บการเขียนคู่ไว้ระหว่าง canary เพื่อให้การย้อนกลับต้องเพียงสลับ path การอ่าน ไม่ใช่ซ่อมข้อมูล

9. รายการตรวจสอบความเข้ากันได้ก่อนปล่อย

  • [ ] ฟิลด์ใหม่เป็นแบบไม่บังคับ มีความหมายอิสระ และไม่ใช้ชื่อเก่า
  • [ ] ผู้บริโภคจัดการฟิลด์ที่ไม่รู้จักและค่า enum ที่ไม่รู้จักด้วย fallback
  • [ ] ฟิลด์เวอร์ชันถูกเก็บไปกับเอกสารและเจรจาได้บนสาย
  • [ ] ช่วงการเลิกใช้มีการประกาศ header Sunset และการสังเกต
  • [ ] การอ่านฟิลด์เก่าสืบกลับไปยังผู้เรียกที่ระบุได้ผ่าน log หรือ trace
  • [ ] สคริปต์ย้ายข้อมูลเป็น idempotent แบ่งชุด ทำต่อได้ และมีเส้นทางย้อนกลับ
  • [ ] Contract test ครอบคลุม subset ของผู้บริโภคเดิมและรันใน CI
  • [ ] Canary มีมิติการเปิดเผยชัดเจนและตัวชี้วัดการหยุด

FAQ

การเพิ่มฟิลด์แบบไม่บังคับเข้ากันได้ย้อนหลังเสมอหรือไม่?

ไม่ มันเข้ากันได้ก็ต่อเมื่อผู้บริโภคไม่สนใจฟิลด์ที่ไม่รู้จักและหลีกเลี่ยงการตรวจสอบ schema เข้มงวด ผู้บริโภคที่ใช้ additionalProperties: false หรืออ่านตามตำแหน่งจะยังล้มเหลว ยืนยันความทนของพวกเขาด้วย contract test ก่อนปล่อย

schemaVersion ควรอยู่ใน URL หรือ response body?

บนสายสามารถเจรจาเวอร์ชันผ่าน media type หรือ URL path แต่เรกคอร์ดที่จัดเก็บต้องมี schemaVersion ภายในเอกสาร คิว backup ออฟไลน์ และ audit ไม่เคยผ่าน URL ดังนั้นมีเพียงเวอร์ชันในเอกสารที่รองรับ replay และ backfill

ฟิลด์ที่ทำ deprecated แล้วลบทันทีได้หรือไม่?

ไม่ หยุดเขียน ประกาศวันลบ และนับการอ่านต่อผู้เรียก ลบก็ต่อเมื่อช่วงปิดและการอ่านเป็นศูนย์ ก่อนลบ ให้รัน regression เต็มเพื่อยืนยันว่าไม่มี cron job หรือรายงานที่ซ่อนอยู่ยังอ่านมัน

ถ้าสคริปต์ย้ายข้อมูลล้มเหลวกลางทาง?

ออกแบบเป็นงาน idempotent: การอัปเดตแบบมีเงื่อนไข cursor ตามคีย์หลัก และ checkpoint เริ่มต่อจาก checkpoint หลังล้มเหลว และการรันซ้ำต้องไม่เขียนสองครั้ง สำหรับการย้อนกลับ ให้เก็บฟิลด์เก่าและ snapshot; อย่าเพียงย้อน schemaVersion

ควรเรียงลำดับการย้ายข้อมูลอย่างไร?

ทำตาม Parallel Change: expand ก่อนเพื่อให้ฟิลด์เก่าและใหม่อยู่ร่วมกันและอ่านได้ทั้งสองเวอร์ชัน, backfill เรกคอร์ดประวัติศาสตร์เป็นรูปร่างใหม่, แล้ว contract ด้วยการลบฟิลด์เก่าและสาขาความเข้ากันได้ อย่ากลับลำดับ มิฉะนั้นจะเสียเส้นทางย้อนกลับ และทำให้สคริปต์ย้ายข้อมูล idempotent แบ่งชุด และทำต่อได้