גרסאות של חוזי JSON: תאימות, deprecation והעברת נתונים
שורה תחתונה: הוספת שדה אינה בטוחה אוטומטית. הסבר על גבולות תאימות, שדות גרסה, חלונות deprecation, העברה בכתיבה כפולה, בדיקות חוזה ומדדי canary.

שורה תחתונה: נהלו אבולוציית גרסאות כ-expand, backfill ואז contract, ושמרו את הגרסה בתוך המסמך; ודאו שהצרכנים סובלניים לשדות ולערכי enum לא מוכרים לפני שאתם משחררים משהו, והעבירו deprecation דרך freeze, announce, observe ו-remove בסדר הזה.
החלק הקשה בניהול גרסאות אינו שינוי הקוד; הוא שאי אפשר לדעת מי עדיין קורא את השדה הישן. תאימות היא לעולם לא הבטחה חד-כיוונית — היא התאמה מתמשכת בין המפרסם לצרכנים לא מוכרים.
1. גבולות תאימות: תוספת אינה בטוחה אוטומטית
ספרי לימוד קוראים לשדה אופציונלי חדש "תואם לאחור", אבל זה מתקיים רק כשהצרכנים סובלניים לשדות לא מוכרים. הוספת שדה עדיין שוברת לקוחות לפחות בשלושה מצבים:
- הצרכן מאמת עם סכימה שמגדירה
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.
2. שינויים שוברים שאסור לשחרר
התייחסו לשינויים הבאים כאל קווים אדומים שמחייבים גרסה חדשה או מעבר בכתיבה כפולה. עדכון התיעוד לא יציל אתכם:
| שינוי | למה הוא שובר תאימות | חלופה מקובלת |
|---|---|---|
| הסרת שדה | הצרכנים קוראים undefined מיד |
סמנו כ-deprecated, עקבו, ואז הסירו אחרי החלון |
| שינוי טיפוס שדה | "1" הופך ל-1; לקוחות עם טיפוסים חזקים נכשלים מיד ולקוחות עם טיפוסים רופפים לעתים קרובות נכשלים באימות |
הוסיפו שם שדה חדש וכתבו כפול בזמן המעבר |
| הוספת שדה נדרש | קוראים ישנים לא שולחים אותו, ולכן האימות תמיד נכשל | ספקו ברירת מחדל, או הפכו אותו לנדרש רק בגרסה החדשה |
| שינוי קבוצת ערכי enum | להצהרות switch של הצרכן אין ענף fallback |
למדו את הלקוחות לקבל ערכים לא מוכרים קודם, ואז הרחיבו את ה-enum |
| שינוי משמעות של שדה | הטיפוסים נשארים חוקיים בעוד ההתנהגות משתנה — המקרה הקשה ביותר לדיבוג | התייחסו אליו כשדה חדש והקפיאו את הסמנטיקה הישנה |
| שינוי עומק קינון | כל נתיב גישה צריך להשתנות | שמרו את הצורה הישנה והוסיפו שדות שטוחים או פרמטר expand |
שינוי משמעות הוא השורה המסוכנת ביותר: אימות הסכימה נשאר ירוק, הניטור לא מדווח על שגיאות, ורק מדדים עסקיים סוחפים. כל שינוי במשמעות צריך להשתחרר כשדה חדש.
3. היכן הגרסה חיה: מסמך, URL, media type
שלושת המיקומים הנפוצים משרתים מטרות שונות:
| מיקום | דוגמה | טוב ל | מגבלה |
|---|---|---|---|
| שדה במסמך | "schemaVersion": 2 |
מסדי נתונים, תורי הודעות, גיבויים לא מקוונים | שכבת התעבורה לא יכולה לנתב לפיו |
| נתיב URL | /v2/orders/8842 |
APIs ציבוריים | מצמיד כתובות משאב לגרסאות והופך העברה ליקרה |
| media type | application/vnd.acme.order.v2+json |
שירותים פנימיים שצריכים משא ומתן תוכן | הקוראים חייבים לקבוע Accept נכון |
חלוקת עבודה מעשית: השתמשו במשא ומתן media-type על הרשת, ותמיד שמרו שדה גרסה במסמך. הודעות ישנות בתור לא ישתדרגו בגלל ששיניתם URL, וכך גם קובצי גיבוי. רק schemaVersion בתוך המסמך נותן ל-replay, backfill וביקורות בסיס.
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
אם אתם בכל זאת מנהלים גרסאות ב-URL, לפחות השאירו את הנתיב הישן מחזיר את הצורה הישנה לאורך כל חלון ה-deprecation במקום להפנות אותו בשקט למימוש החדש. להחלטות רחבות יותר על חוזה API, ראו נקודות מפתח לתכנון חוזי נתונים ב-JSON API.
4. פתיחת חלון deprecation
deprecation אינו הודעה אחת; הוא חלון עם התחלה, תקופת התבוננות וסוף. קצב עבודה אפשרי:
- 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"
אורך החלון הוא מוסכמת צוות. רצפה נפוצה היא מחזור שחרור אחד או 90 יום, הארוך מביניהם. APIs ציבוריים צריכים לקבל יותר, שירותים פנימיים יכולים לקבל פחות, אבל לעולם לא פחות מהזמן שכל קורא צריך כדי לשדרג.
5. איתור צרכנים לפני deprecation
לפני deprecation חייבים להיות מסוגלים לענות "מי עדיין קורא את זה?" אחרת החלון הוא הונאה עצמית. דרגו את הראיות לפי מהימנות:
- לוגי gateway: צברו גישות לשדה הישן לפי
appId,User-Agentוגרסת לקוח. - טרייסים מבוזרים: תייגו את השדה הישן בשכבת הסריאליזציה כך שטרייס יציג ישירות את שם השירות הקורא.
- רישום צרכני תור: רשימת קבוצות הצרכנים מהימנה יותר מחיפוש קוד.
- חיפוש במאגר:
rgמכסה רק את הקוד שלכם; קוראים חיצוניים עדיין דורשים לוגים.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
פגיעה בחיפוש מוכיחה ש"מישהו במאגר הזה קורא את זה", לא ש"אף אחד אחר לא". ייצאו את לוגי ה-gateway יום אחר יום וודאו שקריאות השדה הישן נשארו אפס לפני שאתם מסירים משהו.
6. העברת נתונים: expand, backfill, contract
Parallel Change של Martin Fowler מתאר שלושה שלבים: expand (שדות ישנים וחדשים מתקיימים יחד ושתי הגרסאות ניתנות לקריאה), backfill (העברת רשומות היסטוריות לצורה החדשה), ו-contract (הסרת השדה הישן וענפי התאימות). אל תהפכו את הסדר, אחרת לא יהיה לכם נתיב rollback.
// שלד העברה: cursor לפי מפתח ראשי, כתיבות מותנות לאטומיות
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 };
}
פרטים שקל לפספס:
- אידמפוטנטיות: כללו את מספר הגרסה הישן ב-
filter, כך שהרצה חוזרת לעולם לא משכתבת רשומה שכבר הועברה. - אצווה ונקודות ביקורת: התקדמו לפי cursor של
_id, שמרו checkpoint והמשיכו ממנו אחרי כשל. - חזרה: הריצו את הסקריפט מול snapshot של פרודקשן והשוו מספרי רשומות ו-checksums לפני ואחרי.
- Rollback: שמרו את השדה הישן עד שהשדה החדש עובר אימות; החליפו נתיבי קריאה עם flag ואל תחזרו אחורה על ידי עריכת
schemaVersionבלבד. - אימות: הריצו אילוצים עסקיים אחרי ההעברה (סכומים לא שליליים, מעברי מצב חוקיים), לא רק בדיקת פיענוח JSON.
נתוני תצורה עוברים את אותו תהליך; ראו ניהול JSON עבור תצורה, fixtures ונתונים מקומיים לארגון הקבצים האלה.
7. בדיקות חוזה: הכניסו תאימות ל-CI
תאימות לא יכולה להיות תלויה בזיכרון של סקירת קוד; היא צריכה בדיקות. שתי שכבות מספיקות:
- בדיקות צורה: אמתו תשובות עם JSON Schema, אבל אל תגדירו
additionalProperties: falseעל סכימות שמשתמשים בהן צרכנים. - בדיקות תת-קבוצת צרכן: חלצו את השדות שהצרכן הישן באמת מסתמך עליהם לסכימת משנה ואמתו שהתשובות החדשות עדיין עומדות בה.
// בדיקת חוזה: תשובות חדשות חייבות עדיין לעמוד בתת-קבוצת הצרכן הישן
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));
});
סכימת המשנה מצהירה רק על השדות והטיפוסים שהצרכן הישן קורא ומעבירה כל שדה חדש. זה תופס שדות שהוסרו ושינויי טיפוס בלי אזעקות שווא על תוספות. שמרו את נתוני המקרים בספריית fixtures, מסודרים וניתנים ל-diff; להתנגשויות מיזוג בקבצים האלה, ראו התנגשויות Git בקבצי JSON: union כותב JSON שבור, מזגו לפי מפתח במקום.
8. פריסת canary: הגדילו לפי ממד, עצרו לפי מדדים
בעיות תאימות מגיחות לעתים רחוקות בבדיקות יחידה; הן מגיחות ב-canary. הרחיבו את החשיפה מצר לרחב:
| ממד | פרקטיקה | מה זה מאמת |
|---|---|---|
| רשימת לקוחות מותרת | הפעילו את נתיב הקריאה החדש קודם ללקוחות פנימיים או ידידותיים | הבדלים בצורת נתונים בעולם האמיתי |
| גרסת לקוח | הפעילו רק ללקוחות שכבר שודרגו לצורה החדשה | לקוחות ישנים וחדשים רצים זה לצד זה |
| אחוז תעבורה | 5% → 25% → 100%, עם התבוננות במחזור עסקי שלם בכל שלב | קריאות בזנב ארוך והתנהגות פגיעות מטמון |
עקבו אחרי לפחות שלושה מדדים: שיעור שגיאות API, שיעור כשלי אימות חוזה, ויחס הקריאה בין שדות ישנים לחדשים. הגדירו תנאי עצירה אוטומטיים, כמו 5xx מעל 1% או קפיצה פתאומית בקריאות השדה הישן, והחזירו את ה-flag. השאירו כתיבה כפולה דולקת בזמן ה-canary כדי ש-rollback ידרוש רק החלפת נתיב קריאה, לא תיקון נתונים.
9. צ'קליסט תאימות לפני שחרור
- [ ] שדות חדשים אופציונליים, עצמאיים סמנטית, ולא משתמשים בשמות ישנים.
- [ ] הצרכנים מטפלים בשדות לא מוכרים ובערכי enum לא מוכרים עם fallback.
- [ ] שדה הגרסה נשמר עם המסמך וניתן למשא ומתן על הרשת.
- [ ] לחלון ה-deprecation יש הודעה, כותרת
Sunsetוהתבוננות. - [ ] קריאות של השדה הישן ניתנות לאיתור לקוראים ספציפיים דרך לוגים או טרייסים.
- [ ] סקריפט ההעברה אידמפוטנטי, באצוות, ניתן לחידוש ויש לו נתיב rollback.
- [ ] בדיקות החוזה מכסות את תת-קבוצת הצרכן הישן ורצות ב-CI.
- [ ] ל-canary יש ממדי חשיפה מפורשים ומדדי עצירה.
FAQ
האם הוספת שדה אופציונלי תמיד תואמת לאחור?
לא. היא תואמת רק כשהצרכנים מתעלמים משדות לא מוכרים ונמנעים מאימות סכימה קפדני. צרכן שמשתמש ב-additionalProperties: false או קורא לפי מיקום עדיין ייכשל. אמתו את הסובלנות שלהם בבדיקת חוזה לפני השחרור.
האם schemaVersion צריך לחיות ב-URL או בגוף התשובה?
הרשת יכולה לנהל משא ומתן על גרסאות דרך media types או נתיבי URL, אבל רשומות מאוחסנות חייבות לשאת schemaVersion בתוך המסמך. תורים, גיבויים לא מקוונים וביקורות לא עוברים דרך URL, ולכן רק גרסה בתוך המסמך יכולה לתמוך ב-replay וב-backfill.
האם אפשר למחוק שדה deprecated מיד?
לא. הפסיקו לכתוב אותו, הודיעו על תאריך ההסרה, וספרו קריאות לכל קורא. מחקו רק אחרי שהחלון נסגר והקריאות נשארות אפס. לפני המחיקה, הריצו רגרסיה מלאה כדי לוודא שאין job מתוזמן או דוח נסתר שעדיין קורא אותו.
מה אם סקריפט העברה נכשל באמצע?
תכננו אותו כ-job אידמפוטנטי: עדכונים מותנים, cursor לפי מפתח ראשי ונקודות ביקורת. חדשו מה-checkpoint אחרי כשל, והרצות חוזרות חייבות לא לכתוב פעמיים. ל-rollback, שמרו את השדה הישן ו-snapshot; אל תסתפקו בהחזרת schemaVersion.
איך צריך לסדר העברת נתונים?
עקבו אחרי Parallel Change: expand קודם כדי ששדות ישנים וחדשים יתקיימו יחד ושתי הגרסאות ייקראו, backfill של רשומות היסטוריות לצורה החדשה, ואז contract על ידי הסרת השדה הישן וענפי התאימות. אל תהפכו את הסדר, אחרת תאבדו את נתיב ה-rollback, והפכו את סקריפט ההעברה לאידמפוטנטי, באצוות וניתן לחידוש.