JSON Toolkit

Версіювання контрактів JSON: сумісність, застарівання й міграція даних

Головне: Додавання поля не є автоматично безпечним. Охоплює межі сумісності, поля версій, вікна застарівання, міграцію з подвійним записом, контрактні тести й канаркові метрики.

Просунутий~10 хв читанняОпубліковано 2026-10-31Оновлено 2026-10-31
Обкладинка статті про версіювання контрактів JSON, сумісність і міграцію даних

Коротко: ведіть еволюцію версій як expand, backfill, потім contract, і зберігайте версію всередині документа; підтвердьте, що споживачі терплять невідомі поля й значення переліків, перш ніж щось випускати, і проходьте застарівання через 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 Позначте застарілим, спостерігайте, потім видаліть після вікна
Змінити тип поля "1" стає 1; строго типізовані клієнти просто ламаються, а слабо типізовані часто провалюють перевірку Додайте нову назву поля й подвійний запис під час переходу
Додати обовʼязкове поле Старі викликачі його не надсилають, тож перевірка завжди збоїть Надайте значення за замовчуванням або зробіть його обовʼязковим лише в новій версії
Змінити набір значень переліку У switch споживача немає запасних гілок Спершу навчіть клієнтів приймати невідомі значення, потім розширте перелік
Змінити сенс поля Типи залишаються валідними, тоді як поведінка змінюється — найскладніший для налагодження випадок Трактуйте як нове поле й заморозьте стару семантику
Змінити глибину вкладеності Кожен шлях доступу має змінитися Залиште стару форму й додайте пласкі поля або параметр expand

Зміна сенсу — найнебезпечніший рядок: перевірка схеми залишається зеленою, моніторинг не повідомляє про помилки, і лише бізнес-метрики дрейфують. Будь-яку зміну сенсу слід випускати як нове поле.

3. Де живе версія: документ, URL, медіатип

Три поширені розміщення слугують різним цілям:

Місце Приклад Добре для Обмеження
Поле документа "schemaVersion": 2 Бази даних, черги повідомлень, офлайн-резервні копії Транспортний шар не може маршрутизувати за ним
Шлях URL /v2/orders/8842 Публічні API Повʼязує адреси ресурсів із версіями й робить міграцію дорогою
Медіатип application/vnd.acme.order.v2+json Внутрішні сервіси, що потребують узгодження вмісту Викликачі мають правильно встановити Accept

Практичний поділ праці: використовуйте узгодження медіатипу в мережі й завжди зберігайте поле версії в документі. Старі повідомлення в черзі не оновляться, бо ви змінили URL, як і файли резервних копій. Лише schemaVersion у документі дає відтворенню, заповненню й аудитам основу.

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

Якщо ви все-таки версіюєте URL, принаймні тримайте старий шлях, що повертає стару форму, протягом усього вікна застарівання, замість тихо спрямовувати його на нову реалізацію. Про ширші рішення контракту API дивіться Ключові моменти проєктування контрактів даних JSON API.

4. Відкриття вікна застарівання

Застарівання — не одне оголошення; це вікно з початком, періодом спостереження й кінцем. Робочий ритм:

  1. Заморозка: припиніть писати старе поле, але продовжуйте його читати.
  2. Оголошення: позначте дату видалення в документації, журналі змін і заголовку відповіді одночасно.
  3. Спостереження: рахуйте читання старого поля кожним викликачем, доки воно не залишиться нульовим два періоди підряд.
  4. Видалення: лише після закриття вікна, коли читання нульові й регресійні тести проходять, видаліть поле й його код сумісності.

Заголовок відповіді 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 днів, залежно від того, що довше. Публічні API мають отримати більше, внутрішні сервіси можуть отримати менше, але ніколи менше за час, потрібний кожному викликачу на оновлення.

5. Пошук споживачів перед застаріванням

Перед застаріванням ви маєте вміти відповісти «хто ще це читає?». Інакше вікно — самообман. Впорядкуйте докази за надійністю:

  • Логи шлюзу: агрегуйте доступ до старого поля за appId, User-Agent і версією клієнта.
  • Розподілені трасування: позначте старе поле в шарі серіалізації, щоб трасування напряму показувало назву сервісу-викликача.
  • Реєстр споживачів черги: список груп-споживачів надійніший за пошук у коді.
  • Пошук у репозиторії: rg охоплює лише ваш власний код; зовнішні викликачі все ще потребують логів.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

Збіг у пошуку доводить «хтось у цьому репозиторії це читає», а не «ніхто інший». Експортуйте логи шлюзу день за днем і підтвердьте, що читання старого поля залишаються нульовими, перш ніж щось видаляти.

6. Міграція даних: expand, backfill, contract

Parallel Change Мартіна Фаулера описує три кроки: expand (старі й нові поля співіснують, і обидві версії можна читати), backfill (перенесіть історичні записи в нову форму) і contract (видаліть старе поле й гілки сумісності). Не обертайте порядок, інакше у вас не буде шляху відкату.

// Скелет міграції: курсор за первинним ключем, умовні записи для ідемпотентності
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, щоб повторний запуск ніколи не переписував уже мігрований запис.
  • Пакетування й контрольні точки: просувайтеся курсором _id, зберігайте контрольну точку й відновлюйте з неї після збою.
  • Репетиція: проженіть скрипт проти виробничого знімка й порівняйте кількість записів і контрольні суми до і після.
  • Відкат: тримайте старе поле, доки нове поле не пройде перевірку; перемикайте шляхи читання прапорцем і ніколи не робіть відкат, просто редагуючи schemaVersion.
  • Перевірка: запускайте бізнес-обмеження після міграції (невідʼємні суми, законні переходи станів), а не лише перевірку розбору JSON.

Дані конфігурації дотримуються того самого процесу; дивіться Керування JSON для конфігурації, тестових даних і локальних даних про те, як організувати ці файли.

7. Контрактні тести: впровадьте сумісність у CI

Сумісність не може покладатися на памʼять під час рев'ю коду; їй потрібні тести. Достатньо двох шарів:

  1. Тести форми: перевіряйте відповіді через JSON Schema, але не встановлюйте additionalProperties: false у схемах, якими користуються споживачі.
  2. Тести підмножини споживача: видобудьте поля, на які старий споживач справді покладається, в підсхему й переконайтеся, що нові відповіді все ще її задовольняють.
// Контрактний тест: нові відповіді все ще мають задовольняти стару підмножину споживача
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));
});

Підсхема оголошує лише поля й типи, які читає старий споживач, і пропускає кожне нове поле. Це ловить видалені поля й зміни типів без хибних тривог на додавання. Тримайте дані випадків у каталозі тестових даних, впорядкованими й оглядопридатними; про конфлікти злиття в тих файлах дивіться Конфлікти Git у файлах JSON: union пише зламаний JSON, зливайте за ключами.

8. Канаркове розгортання: розширюйте за вимірами, зупиняйтеся за метриками

Проблеми сумісності рідко виявляються в модульних тестах; вони виявляються в канарці. Розширюйте вплив від вузького до широкого:

Вимір Практика Що перевіряє
Білий список орендарів Спершу ввімкніть новий шлях читання для внутрішніх або дружніх клієнтів Відмінності реальної форми даних
Версія клієнта Увімкніть лише для клієнтів, уже оновлених до нової форми Старі й нові клієнти працюють поряд
Відсоток трафіку 5% → 25% → 100%, спостерігаючи один повний бізнес-цикл на крок Довгохвості виклики й поведінка влучань у кеш

Стежте принаймні за трьома метриками: частка помилок API, частка збоїв перевірки контракту й співвідношення читання старих і нових полів. Визначте умови автоматичної зупинки, як-от 5xx понад 1% або раптове відновлення читань старого поля, і відкатайте прапорець. Тримайте подвійні записи ввімкненими під час канарки, щоб відкат вимагав лише перемикання шляху читання, а не лагодження даних.

9. Чек-лист сумісності перед релізом

  • [ ] Нові поля необовʼязкові, семантично незалежні й не повторюють старі назви.
  • [ ] Споживачі обробляють невідомі поля й невідомі значення переліків із запасним варіантом.
  • [ ] Поле версії зберігається разом із документом і узгоджується в мережі.
  • [ ] Вікно застарівання має оголошення, заголовок Sunset і спостереження.
  • [ ] Читання старого поля простежуються до конкретних викликачів через логи або трасування.
  • [ ] Скрипт міграції ідемпотентний, пакетований, відновлюваний і має шлях відкату.
  • [ ] Контрактні тести покривають стару підмножину споживача й запускаються в CI.
  • [ ] Канарка має явні виміри впливу й метрики зупинки.

Часті питання

Чи додавання необовʼязкового поля завжди зворотно сумісне?

Ні. Воно сумісне лише коли споживачі ігнорують невідомі поля й уникають строгої перевірки схемою. Споживач, що використовує additionalProperties: false або читає за позицією, усе одно зазнає збою. Перевірте їхню толерантність контрактним тестом перед релізом.

Чи має schemaVersion жити в URL чи в тілі відповіді?

Мережа може узгоджувати версії через медіатипи або шляхи URL, але збережені записи мають нести schemaVersion усередині документа. Черги, офлайн-резервні копії й аудити ніколи не проходять через URL, тож лише версія в документі може підтримувати відтворення й заповнення.

Чи можна застаріле поле видалити негайно?

Ні. Припиніть його писати, оголосіть дату видалення й рахуйте читання кожним викликачем. Видаляйте лише після закриття вікна й коли читання залишаються нульовими. Перед видаленням проженіть повну регресію, щоб підтвердити, що жодна прихована cron-задача чи звіт усе ще його не читає.

Що робити, якщо скрипт міграції зазнає збою посередині?

Спроєктуйте його як ідемпотентну задачу: умовні оновлення, курсор за первинним ключем і контрольні точки. Відновлюйте з контрольної точки після збою, і повторні запуски не повинні писати двічі. Для відкату тримайте старе поле й знімок; не просто повертайте schemaVersion.

Як упорядкувати міграцію даних?

Слідуйте Parallel Change: спершу expand, щоб старі й нові поля співіснували й обидві версії можна було читати, заповніть історичні записи в нову форму, потім contract, видаливши старе поле й гілки сумісності. Не обертайте порядок, інакше втратите шлях відкату, і зробіть скрипт міграції ідемпотентним, пакетованим і відновлюваним.