Версионирование контрактов JSON: совместимость, устаревание и миграция данных
Главное: Добавление поля не автоматически безопасно. Рассматривает границы совместимости, поля версии, окна устаревания, миграцию с двойной записью, контрактные тесты и метрики канареечного развёртывания.

Главное: ведите эволюцию версий как расширение, обратное заполнение, затем сжатие, и сохраняйте версию внутри документа; подтвердите, что потребители терпимы к неизвестным полям и значениям перечислений, прежде чем что-либо выпускать, и проводите устаревание через заморозку, объявление, наблюдение и удаление в этом порядке.
Трудная часть версионирования — не изменение кода; это то, что вы не можете знать, кто всё ещё читает старое поле. Совместимость никогда не является односторонним обещанием — это постоянное выравнивание между публикатором и неизвестными потребителями.
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. Открытие окна устаревания
Устаревание — не одно объявление; это окно с началом, периодом наблюдения и концом. Рабочий ритм:
- Заморозка: прекратите запись старого поля, но продолжайте его читать.
- Объявление: отметьте дату удаления в документации, в changelog и в заголовке ответа одновременно.
- Наблюдение: считайте чтения старого поля по каждому вызывающему, пока они не продержатся на нуле два последовательных периода.
- Удаление: только после закрытия окна, когда чтения нулевые и регрессионные тесты проходят, удалите поле и его код совместимости.
Заголовок ответа 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. Миграция данных: расширение, обратное заполнение, сжатие
Martin Fowler в Parallel Change описывает три шага: расширение (старое и новое поля сосуществуют и обе версии можно читать), обратное заполнение (перевести исторические записи в новую форму) и сжатие (удалить старое поле и ветки совместимости). Не меняйте порядок, иначе у вас не будет пути отката.
// Каркас миграции: курсор по первичному ключу, условные записи для идемпотентности
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
Совместимость не может зависеть от памяти ревью кода; ей нужны тесты. Достаточно двух слоёв:
- Тесты формы: валидируйте ответы по 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('ответ v2 всё ещё удовлетворяет подмножеству потребителя v1', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
Подсхема объявляет только поля и типы, которые читает старый потребитель, и пропускает каждое новое поле. Это ловит удалённые поля и изменения типов без ложных тревог на добавления. Держите данные случаев в каталоге фикстур, упорядоченными и diff-пригодными; о конфликтах слияния в этих файлах см. Конфликты 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: сначала расширьте, чтобы старое и новое поля сосуществовали и обе версии можно было читать, заполните исторические записи в новую форму, затем сожмите, удалив старое поле и ветки совместимости. Не меняйте порядок, иначе теряете путь отката, и сделайте скрипт миграции идемпотентным, пакетированным и возобновляемым.