8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 66,
|
||
"slug": "editorial-2026-03-practice-data-contracts",
|
||
"title": "Изменение схемы без устных договорённостей: как проверить контракт данных",
|
||
"excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.",
|
||
"contentHtml": "<p>Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки — не только ошибка парсинга: в очереди остаются сообщения, повторная доставка увеличивает нагрузку, а поиск владельца контракта съедает время.</p>\n<p>Чтобы не обсуждать совместимость на уровне догадок, нужно проверить одну конкретную пару: какая форма была исходной, какая стала новой, кто пишет данные и какой reader их читает. В этой статье разберём маршрут для JSON-подобного объекта. Он не заменяет интеграционные тесты и не объявляет изменение безопасным для неизвестных клиентов.</p>\n<h2>Главный вопрос: кто читает новую запись</h2>\n<p>Версия <code>v1.1</code> сама по себе ничего не гарантирует. Старый reader может игнорировать незнакомые поля, а может использовать строгую проверку и отклонять их. Один и тот же candidate поэтому совместим с одним consumer и несовместим с другим.</p>\n<p>Дальше под <strong>backward compatibility</strong> будем понимать одно проверяемое направление: старый reader получает запись, созданную новой схемой. Это определение относится только к выбранной паре. Оно не доказывает обратное направление, совместимость SDK, сохранённых сообщений или других потребителей.</p>\n<h2>Сначала зафиксируйте четыре артефакта</h2>\n<p><code>Baseline</code> — форма, которую уже принимает named consumer. <code>Candidate</code> — полная форма после изменения. <code>Manifest</code> — явный список добавленных, удалённых и изменённых полей. Наконец, поведение consumer — правила его парсера: допускает ли он дополнительные ключи и как обрабатывает неизвестные значения.</p>\n<p>Нельзя подменять baseline коротким описанием вроде «добавили priority». Если одновременно изменились тип <code>amount</code>, enum <code>state</code> или единицы измерения, короткая заметка скроет breaking change. Полный candidate и diff должны быть доступны тому, кто будет читать запись после выпуска.</p>\n<figure><img src=\"/assets/editorial/2026/data-contracts-2026-contract-evolution.svg\" alt=\"Схема проверки изменения контракта данных: baseline и candidate проходят через compatibility gate, который проверяет направление, обязательные поля, manifest и правила consumer\"><figcaption>Проверяется не только новая схема, но и её отношение к известному reader. Неописанное поле routingHint останавливает поток до передачи изменения в review.</figcaption></figure>\n<h2>Минимальный воспроизводимый gate</h2>\n<p>Ниже — самостоятельный пример на Node.js без сторонних пакетов. Сохраните код в файл <code>contract-gate.mjs</code> и запустите командой <code>node contract-gate.mjs</code>. Внутренняя модель намеренно мала: она проверяет имена, типы, обязательность, manifest и способность consumer принимать дополнительные ключи.</p>\n<pre><code>const baseline = {\n id: { type: 'string', required: true },\n state: { type: 'string', required: true },\n note: { type: 'string', required: false },\n};\n\nconst additive = {\n ...baseline,\n priority: { type: 'integer', required: false },\n};\n\nconst manifest = {\n direction: 'backward',\n producer: 'work-item-api',\n consumer: 'billing-worker-v1',\n added: ['priority'],\n removed: [],\n changed: [],\n};\n\nfunction sameList(left, right) {\n return [...left].sort().join('|') === [...right].sort().join('|');\n}\n\nfunction review({ baseline, candidate, manifest, consumer }) {\n if (manifest.direction !== 'backward' || !manifest.producer || !manifest.consumer) {\n return { status: 'stop-missing-contract-context' };\n }\n\n const baselineNames = Object.keys(baseline);\n const candidateNames = Object.keys(candidate);\n const added = candidateNames.filter((name) => !baseline[name]);\n const removed = baselineNames.filter((name) => !candidate[name]);\n const changed = baselineNames.filter((name) => {\n if (!candidate[name]) return false;\n return baseline[name].type !== candidate[name].type\n || baseline[name].required !== candidate[name].required;\n });\n const requiredLost = removed.filter((name) => baseline[name].required);\n\n if (requiredLost.length > 0) {\n return { status: 'stop-required-field-removed', fields: requiredLost };\n }\n if (changed.length > 0) {\n return { status: 'stop-field-definition-changed', fields: changed };\n }\n if (!sameList(added, manifest.added)\n || !sameList(removed, manifest.removed)\n || !sameList(changed, manifest.changed)) {\n return { status: 'stop-manifest-mismatch', actual: { added, removed, changed } };\n }\n if (added.length > 0 && !consumer.acceptsAdditional) {\n return { status: 'stop-consumer-rejects-additional-fields' };\n }\n return { status: 'compatible-for-named-reader', actual: { added, removed, changed } };\n}\n\nconsole.log(review({\n baseline,\n candidate: additive,\n manifest,\n consumer: { name: 'billing-worker-v1', acceptsAdditional: true },\n}));\nconsole.log(review({\n baseline,\n candidate: additive,\n manifest,\n consumer: { name: 'strict-billing-worker-v1', acceptsAdditional: false },\n}));\nconsole.log(review({\n baseline,\n candidate: { id: baseline.id, note: baseline.note, phase: { type: 'string', required: true } },\n manifest: { ...manifest, added: ['phase'], removed: ['state'] },\n consumer: { name: 'billing-worker-v1', acceptsAdditional: true },\n}));</code></pre>\n<p>У первого вызова результат <code>compatible-for-named-reader</code>: обязательные поля сохранились, <code>priority</code> попал в manifest, а consumer принимает дополнительные ключи. У второго тот же candidate отклоняется, потому что strict consumer запрещает новый ключ. Третий вызов останавливается на удалённом обязательном <code>state</code>. Это три разных решения для почти одинакового diff.</p>\n<p>Пример можно проверить на чистой машине с Node.js 18 или новее: <code>node --version</code> покажет установленную версию, а <code>node contract-gate.mjs</code> выведет три объекта в консоль. Скрипт не обращается к сети, registry или вашему сервису, поэтому его положительный результат относится только к указанным данным.</p>\n<h2>Как читать результат проверки</h2>\n<table><thead><tr><th>Результат</th><th>Что установлено</th><th>Следующий шаг</th><th>Граница вывода</th></tr></thead><tbody><tr><td><code>compatible-for-named-reader</code></td><td>Схемы и manifest совпали, reader допускает addition</td><td>Запустить contract/integration tests и проверить rollout</td><td>Неизвестные consumer и семантика значений не проверены</td></tr><tr><td><code>stop-required-field-removed</code></td><td>В candidate исчезло обязательное поле baseline</td><td>Сохранить поле на период миграции или версионировать контракт</td><td>Переименование не исправляется номером версии</td></tr><tr><td><code>stop-consumer-rejects-additional-fields</code></td><td>Reader не принимает добавленные ключи</td><td>Изменить reader, изолировать новый контракт или дождаться миграции</td><td>Свойство optional в схеме не меняет parser policy</td></tr><tr><td><code>stop-manifest-mismatch</code></td><td>Фактический diff шире заявленного</td><td>Исправить candidate или manifest и повторить проверку</td><td>Gate не выясняет смысл незаявленного поля</td></tr><tr><td><code>stop-field-definition-changed</code></td><td>Изменился тип или признак обязательности</td><td>Сделать отдельный migration plan и тесты значений</td><td>Даже тот же JSON-тип может скрывать смену единиц</td></tr></tbody></table>\n<h2>Почему одной проверки схемы мало</h2>\n<p>JSON Schema отвечает на вопрос о валидности экземпляра относительно набора ограничений. В спецификации 2020-12 есть структурные ключевые слова <code>type</code>, <code>required</code> и <code>additionalProperties</code>. Они помогают описать форму объекта и правила дополнительных ключей, но не знают, какой сервис владеет полем и какой reader будет обрабатывать запись.</p>\n<p>Это видно и по JSON Type Definition (JTD). RFC 8927 разделяет <code>properties</code> и <code>optionalProperties</code>, а режим дополнительных свойств задаётся отдельно. Такой словарь дисциплинирует схему, но не выбирает срок поддержки старой формы и не проверяет ваш parser.</p>\n<p>В Apache Avro терминология writer schema и reader schema встроена в механизм разрешения схем. Спецификация описывает, как reader сопоставляет поля, что происходит с отсутствующим полем и когда возникает ошибка. Для обычного JSON API правила Avro автоматически не применяются, но сам способ постановки вопроса полезен: всегда указывайте обе стороны чтения и записи.</p>\n<h2>Структурное изменение и изменение смысла</h2>\n<p>Добавление необязательного поля часто проще, чем удаление обязательного. Но это не универсальное правило. Строгий parser, подписанный payload, whitelist ключей или downstream-система с фиксированным форматом могут отклонить additive change.</p>\n<p>Опаснее всего изменение смысла без изменения типа. <code>amount</code> мог быть суммой в рублях, а стал суммой в копейках. Строковый timestamp мог перейти из локального времени в UTC. Enum <code>state=ready</code> мог означать «готов к отправке», а после изменения — «готов к оплате». Structural diff такие изменения не докажет. В manifest нужны единицы, timezone, допустимые значения и ссылка на владельца доменного смысла.</p>\n<p>Если контракт использует JSON Schema, структурную валидацию можно выполнять отдельно, например через выбранный валидатор в CI. Его настройки должны быть зафиксированы: разные библиотеки могут по-разному трактовать форматные аннотации. Проверка <code>format: date-time</code> не заменяет проверку бизнес-часового пояса и срока действия события.</p>\n<h2>Порядок действий перед изменением</h2>\n<ol><li><strong>Назовите пару.</strong> Запишите producer, конкретного consumer и направление проверки. Слова «все клиенты» недостаточно.</li><li><strong>Снимите baseline.</strong> Сохраните идентификатор схемы, обязательные поля, типы, enum, единицы и правила дополнительных ключей.</li><li><strong>Опишите candidate.</strong> Покажите полную новую форму. Не редактируйте baseline задним числом, иначе diff потеряет исходную точку.</li><li><strong>Составьте manifest.</strong> Перечислите <code>added</code>, <code>removed</code> и <code>changed</code>. Для изменения смысла добавьте текстовое правило и владельца.</li><li><strong>Проверьте структуру.</strong> Сверьте required-поля и типы. Отдельно проверьте зависимости полей, enum и дополнительные ключи.</li><li><strong>Запустите отрицательные тесты.</strong> Удалите обязательное поле, добавьте незаявленный ключ, включите strict consumer и измените тип. Каждый сценарий должен остановиться с понятной причиной.</li><li><strong>Проверьте реальный reader.</strong> Выполните contract test на версии consumer, которая будет читать запись после rollout. Учебный gate не заменяет такой тест.</li><li><strong>Согласуйте удаление.</strong> Для breaking change укажите период dual-read/dual-write, миграцию сохранённых сообщений и условие, при котором старое поле можно убрать.</li><li><strong>Наблюдайте выпуск.</strong> После deploy сравните ошибки валидации, долю отвергнутых сообщений и lag очереди с baseline. При росте ошибок остановите rollout.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Описанный gate проверяет только одну форму объекта и одного named consumer. Он не обнаружит неизвестных внешних клиентов, старые записи в очереди, кэшированные ответы, сгенерированные SDK, схемы в базе или трансформации промежуточного сервиса. Для этого нужен инвентарь потребителей и тесты на реальные границы системы.</p>\n<p>Положительный результат не является самостоятельным разрешением на deploy. Нужны проверка авторизации, размер payload, подпись, порядок событий, повторная доставка, таймауты и наблюдаемость. Эти свойства не следуют из JSON Schema и не выводятся из номера версии.</p>\n<p>Наконец, не называйте изменение backwards-compatible, если вы проверили только новый reader на старой записи. Это другое направление. Если продукт требует оба направления, проведите две отдельные проверки и запишите их результаты в manifest.</p>\n<h2>Критерий готовности</h2>\n<p>Перед review другая команда должна без устного пояснения ответить на пять вопросов: какая форма является baseline, что изменилось в candidate, совпадает ли фактический diff с manifest, кто читает новую запись и какое правило дополнительных ключей действует у reader. Если хотя бы один ответ неизвестен, результат проверки — остановка и уточнение контракта.</p>\n<p>Такой порядок не делает изменение автоматически безопасным. Он делает риск видимым: структурную ошибку можно поймать до выпуска, несовместимый parser — проверить на named consumer, а смену бизнес-смысла — вынести в отдельное решение. Именно эта граница превращает сообщение «мы добавили одно поле» в проверяемое инженерное изменение.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation\" target=\"_blank\" rel=\"noopener\">JSON Schema Draft 2020-12: Validation vocabulary</a> — официальная спецификация структурной валидации, включая <code>type</code>, <code>required</code>, <code>enum</code> и связанные ключевые слова. Статья использует её только для описания формы JSON, а не как универсальный compatibility policy.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc8927.html\" target=\"_blank\" rel=\"noopener\">RFC 8927: JSON Type Definition</a> — нормативное описание <code>properties</code>, <code>optionalProperties</code> и <code>additionalProperties</code> в JTD. Это отдельный формат, его правила нельзя молча переносить на JSON Schema.</li><li><a href=\"https://avro.apache.org/docs/1.12.0/specification/\" target=\"_blank\" rel=\"noopener\">Apache Avro 1.12.0 Specification</a> — официальное описание writer/reader schemas и schema resolution. Пример статьи не реализует Avro и использует только общий принцип явного направления чтения.</li></ul>"
|
||
}
|