نسخهبندی قراردادهای JSON: سازگاری، منسوخسازی و مهاجرت داده
جمعبندی: افزودن فیلد خودکار امن نیست. مرزهای سازگاری، فیلد نسخه، پنجره منسوخسازی، مهاجرت همزماننویسی، آزمون قرارداد و معیارهای canary را پوشش میدهد.

جمعبندی: تکامل نسخه را بهصورت expand، backfill، سپس contract اجرا کنید و نسخه را داخل سند نگه دارید؛ پیش از ارسال هر چیز تأیید کنید مصرفکنندهها فیلدها و مقادیر enum ناشناخته را تحمل میکنند، و منسوخسازی را به ترتیب freeze، announce، observe و remove پیش ببرید.
بخش سخت نسخهبندی تغییر کد نیست؛ این است که نمیدانید چه کسی هنوز فیلد قدیمی را میخواند. سازگاری هرگز قول یکطرفه نیست — همترازی مداوم بین ناشر و مصرفکنندههای ناشناخته است.
۱. مرزهای سازگاری: افزودن خودکار امن نیست
کتابهای درسی فیلد اختیاری جدید را «سازگار رو به عقب» مینامند، اما این فقط وقتی برقرار است که مصرفکنندهها فیلدهای ناشناخته را تحمل کنند. افزودن فیلد همچنان دستکم در سه وضعیت کلاینت را میشکند:
- مصرفکننده با 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 زندگی کند.
۲. تغییرات شکنندهای که نمیتوانید منتشر کنید
تغییرات زیر را خط قرمز بدانید که نسخه جدید یا گذار همزماننویسی میخواهند. بهروزرسانی مستندات نجاتتان نمیدهد:
| تغییر | چرا سازگاری را میشکند | جایگزین قابل قبول |
|---|---|---|
| حذف فیلد | مصرفکننده فوراً undefined میخواند |
منسوخ کنید، مشاهده کنید، سپس پس از پنجره حذف کنید |
| تغییر نوع فیلد | "1" به 1 تبدیل میشود؛ کلاینتهای سختنوع کلاً شکست میخورند و سهلنوعها غالباً در اعتبارسنجی رد میشوند |
نام فیلد جدید اضافه و در گذار همزمان بنویسید |
| افزودن فیلد الزامی | فراخوانهای قدیمی نمیفرستند، پس اعتبارسنجی همیشه شکست میخورد | پیشفرض بدهید، یا فقط در نسخه جدید الزامی کنید |
| تغییر مجموعه مقادیر enum | دستورهای switch مصرفکننده شاخه جایگزین ندارند |
اول به کلاینتها بپذیرانید مقادیر ناشناخته را قبول کنند، سپس enum را گسترش دهید |
| تغییر معنای فیلد | نوع معتبر میماند در حالی که رفتار تغییر میکند — سختترین مورد برای عیبیابی | آن را فیلد جدید بدانید و معناشناسی قدیمی را قفل کنید |
| تغییر عمق تودرتویی | هر مسیر دسترسی باید تغییر کند | شکل قدیمی را نگه دارید و فیلدهای مسطح یا پارامتر expand اضافه کنید |
تغییر معنا خطرناکترین ردیف است: اعتبارسنجی schema سبز میماند، پایش خطا گزارش نمیکند و فقط معیارهای کسبوکار منحرف میشوند. هر تغییر در معنا باید بهعنوان فیلد جدید منتشر شود.
۳. نسخه کجا زندگی میکند: سند، URL، نوع رسانه
سه جایگاه رایج اهداف متفاوتی دارند:
| مکان | مثال | مناسب برای | محدودیت |
|---|---|---|---|
| فیلد سند | "schemaVersion": 2 |
پایگاهداده، صفهای پیام، پشتیبانهای آفلاین | لایه انتقال نمیتواند بر اساسش مسیریابی کند |
| مسیر URL | /v2/orders/8842 |
APIهای عمومی | آدرس منبع را به نسخه میبندد و مهاجرت را گران میکند |
| نوع رسانه | application/vnd.acme.order.v2+json |
سرویسهای داخلی که به مذاکره محتوا نیاز دارند | فراخوانها باید Accept را درست بگذارند |
تقسیم کار عملی: روی سیم از مذاکره نوع رسانه استفاده کنید، و همیشه فیلد نسخه را در سند ذخیره کنید. پیامهای قدیمی در صف با تغییر URL ارتقا نمییابند، و فایلهای پشتیبان هم نه. فقط schemaVersion درون سند به بازپخش، backfill و حسابرسی پایه میدهد.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
اگر URL را نسخهبندی میکنید، دستکم مسیر قدیمی را برای کل پنجره منسوخسازی به شکل قدیمی برگردانید نه اینکه بیصدا به پیادهسازی جدید اشارهاش دهید. برای تصمیمهای گستردهتر قرارداد API، نکات کلیدی طراحی قرارداد داده API در JSON را ببینید.
۴. باز کردن پنجره منسوخسازی
منسوخسازی یک اعلام نیست؛ پنجرهای با شروع، دوره مشاهده و پایان است. ریتم عملی:
- Freeze: نوشتن فیلد قدیمی را متوقف کنید، اما خواندنش را نگه دارید.
- Announce: تاریخ حذف را همزمان در مستندات، changelog و سرآیند پاسخ علامت بزنید.
- Observe: خواندن فیلد قدیمی را به ازای هر فراخوان بشمارید تا دو دوره متوالی صفر بماند.
- Remove: فقط پس از بسته شدن پنجره، صفر بودن خواندنها و پاس شدن آزمونهای رگرسیون، فیلد و کد سازگاریاش را حذف کنید.
سرآیند پاسخ Sunset (RFC 8594) تاریخ حذف ماشینخوان به کلاینت میدهد. Deprecation همچنان پیشنویس IETF است: قابل استفاده، اما فرض نکنید هر کلاینتی میفهمدش. با سرآیند 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"
طول پنجره قرارداد تیمی است. کف رایج یک چرخه انتشار یا ۹۰ روز، هر کدام بیشتر باشد. APIهای عمومی بیشتر میگیرند، سرویسهای داخلی کمتر، اما هرگز کمتر از زمانی که هر فراخوان برای ارتقا لازم دارد.
۵. یافتن مصرفکنندهها پیش از منسوخسازی
پیش از منسوخسازی باید بتوانید پاسخ دهید «چه کسی هنوز این را میخواند؟» وگرنه پنجره خودفریبی است. شواهد را بر اساس قابلیت اطمینان رتبه دهید:
- لاگهای دروازه: دسترسی فیلد قدیمی را بر اساس
appId،User-Agentو نسخه کلاینت تجمیع کنید. - traceهای توزیعشده: فیلد قدیمی را در لایه سریالایز برچسب بزنید تا trace نام سرویس فراخوان را مستقیم نشان دهد.
- رجیستری مصرفکننده صف: فهرست گروههای مصرفکننده از جستوجوی کد قابل اعتمادتر است.
- جستوجوی مخزن:
rgفقط کد خودتان را پوشش میدهد؛ فراخوانهای بیرونی همچنان لاگ میخواهند.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
یک نتیجه جستوجو اثبات میکند «کسی در این مخزن میخواند»، نه «هیچکس دیگری نمیخواند». لاگهای دروازه را روز به روز صادر کنید و تأیید کنید خواندنهای فیلد قدیمی صفر ماندهاند، پیش از حذف هر چیزی.
۶. مهاجرت داده: expand، backfill، contract
Parallel Change مارتین فاولر سه گام را توصیف میکند: expand (فیلد قدیمی و جدید همزیستی میکنند و هر دو نسخه خوانده میشوند)، backfill (رکوردهای تاریخی به شکل جدید منتقل میشوند) و contract (فیلد قدیمی و شاخههای سازگاری حذف میشوند). ترتیب را برنگردانید، وگرنه مسیر بازگشتی نخواهید داشت.
// Migration skeleton: cursor by primary key, conditional writes for 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بگذارید، تا اجرای دوباره هرگز رکورد مهاجرتشده را بازنویسی نکند. - دستهبندی و checkpoint: با cursor
_idجلو بروید، checkpoint ذخیره کنید و پس از شکست از آن ادامه دهید. - تمرین: اسکریپت را روی snapshot تولیدی اجرا کنید و تعداد رکورد و checksum قبل و بعد را مقایسه کنید.
- Rollback: فیلد قدیمی را تا پاس شدن اعتبارسنجی فیلد جدید نگه دارید؛ مسیرهای خواندن را با flag جابهجا کنید و هرگز فقط با ویرایش
schemaVersionبرنگردید. - اعتبارسنجی: قواعد کسبوکار را پس از مهاجرت اجرا کنید (مبلغ غیرمنفی، گذارهای حالت قانونی)، نه فقط بررسی تجزیه JSON.
داده پیکربندی همان فرایند را دنبال میکند؛ برای سازماندهی آن فایلها مدیریت JSON برای پیکربندی، fixture و داده محلی را ببینید.
۷. آزمون قرارداد: سازگاری را در CI بگذارید
سازگاری نمیتواند به حافظه بازبین کد تکیه کند؛ آزمون میخواهد. دو لایه کافی است:
- آزمون شکل: پاسخها را با JSON Schema اعتبارسنجی کنید، اما روی schemaهایی که مصرفکنندهها استفاده میکنند
additionalProperties: falseنگذارید. - آزمون زیرمجموعه مصرفکننده: فیلدهایی را که مصرفکننده قدیمی واقعاً به آنها تکیه دارد در sub-schema استخراج کنید و اعلان کنید پاسخهای جدید همچنان آن را برآورده میکنند.
// Contract test: new responses must still satisfy the old consumer 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؛ برای تعارض ادغام در آن فایلها تعارضهای Git در فایل JSON: union JSON شکسته مینویسد، بر اساس کلید ادغام کنید را ببینید.
۸. انتشار canary: بر اساس بُعد مقیاس بدهید، با معیار متوقف کنید
مشکلات سازگاری بهندرت در آزمون واحد ظاهر میشوند؛ در canary ظاهر میشوند. مواجهه را از باریک به گسترده باز کنید:
| بُعد | رویه | چه چیزی را اعتبارسنجی میکند |
|---|---|---|
| فهرست سفید مستأجر | مسیر خواندن جدید را اول برای مشتریان داخلی یا دوست فعال کنید | تفاوتهای شکل داده دنیای واقعی |
| نسخه کلاینت | فقط برای کلاینتی که از قبل به شکل جدید ارتقا یافته فعال کنید | کلاینت قدیمی و جدید کنار هم |
| درصد ترافیک | ۵٪ → ۲۵٪ → ۱۰۰٪، با مشاهده یک چرخه کامل کسبوکار در هر گام | فراخوانهای دمبلند و رفتار hit کش |
دستکم سه معیار را ببینید: نرخ خطای API، نرخ شکست اعتبارسنجی قرارداد، و نسبت خواندن بین فیلد قدیمی و جدید. شرایط توقف خودکار تعریف کنید، مثلاً 5xx بالای ۱٪ یا جهش ناگهانی خواندن فیلد قدیمی، و flag را برگردانید. همزماننویسی را در canary روشن نگه دارید تا rollback فقط به جابهجایی مسیر خواندن نیاز داشته باشد، نه تعمیر داده.
۹. چکلیست سازگاری پیش از انتشار
- [ ] فیلدهای جدید اختیاری، از نظر معنایی مستقل و بدون استفاده دوباره از نامهای قدیمیاند.
- [ ] مصرفکنندهها فیلدهای ناشناخته و مقادیر enum ناشناخته را با جایگزین مدیریت میکنند.
- [ ] فیلد نسخه با سند ذخیره میشود و روی سیم قابل مذاکره است.
- [ ] پنجره منسوخسازی اعلام، سرآیند
Sunsetو مشاهده دارد. - [ ] خواندنهای فیلد قدیمی از طریق لاگ یا trace به فراخوان مشخص قابل ردیابیاند.
- [ ] اسکریپت مهاجرت idempotent، دستهای، قابل ادامه و دارای مسیر rollback است.
- [ ] آزمونهای قرارداد زیرمجموعه مصرفکننده قدیمی را پوشش میدهند و در CI اجرا میشوند.
- [ ] canary ابعاد مواجهه و معیارهای توقف صریح دارد.
پرسشهای متداول
آیا افزودن فیلد اختیاری همیشه سازگار رو به عقب است؟
نه. فقط وقتی سازگار است که مصرفکنندهها فیلدهای ناشناخته را نادیده بگیرند و از اعتبارسنجی سختگیر schema پرهیز کنند. مصرفکنندهای که از additionalProperties: false استفاده میکند یا با موقعیت میخواند همچنان شکست میخورد. تحملشان را پیش از انتشار با آزمون قرارداد تأیید کنید.
آیا schemaVersion باید در URL باشد یا بدنه پاسخ؟
سیم میتواند نسخهها را از طریق نوع رسانه یا مسیر URL مذاکره کند، اما رکوردهای ذخیرهشده باید schemaVersion را داخل سند حمل کنند. صفها، پشتیبانهای آفلاین و حسابرسی هرگز از URL عبور نمیکنند، پس فقط نسخه درون سند میتواند بازپخش و backfill را پشتیبانی کند.
آیا فیلد منسوخ میتواند فوراً حذف شود؟
نه. نوشتنش را متوقف کنید، تاریخ حذف را اعلام کنید و خواندنها را به ازای هر فراخوان بشمارید. فقط پس از بسته شدن پنجره و صفر ماندن خواندنها حذف کنید. پیش از حذف، رگرسیون کامل اجرا کنید تا تأیید شود هیچ کار cron یا گزارش پنهانی هنوز میخواندش.
اگر اسکریپت مهاجرت نیمهکاره شکست بخورد چه؟
آن را بهصورت job idempotent طراحی کنید: بهروزرسانی شرطی، cursor کلید اصلی و checkpoint. پس از شکست از checkpoint ادامه دهید و اجراهای مکرر نباید دو بار بنویسند. برای rollback، فیلد قدیمی و snapshot را نگه دارید؛ فقط schemaVersion را برنگردانید.
مهاجرت داده را چگونه ترتیب دهم؟
Parallel Change را دنبال کنید: اول expand تا فیلد قدیمی و جدید همزیستی کنند و هر دو نسخه خوانده شوند، رکوردهای تاریخی را به شکل جدید backfill کنید، سپس با حذف فیلد قدیمی و شاخههای سازگاری contract کنید. ترتیب را برنگردانید، وگرنه مسیر rollback را از دست میدهید، و اسکریپت مهاجرت را idempotent، دستهای و قابل ادامه بسازید.