8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 66,
|
||
"slug": "editorial-2026-03-practice-data-contracts",
|
||
"title": "Изменение схемы без устных договорённостей: как проверить контракт данных",
|
||
"excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.",
|
||
"contentHtml": "<p>Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки складывается из простоя, ручного восстановления данных и времени на поиск настоящего контракта.</p>\n<p>Проблема начинается не с синтаксиса схемы. Она начинается с неявного обещания: «новое поле необязательное, значит всё совместимо». Это утверждение неполно. Нужно назвать исходную форму, новую форму, направление чтения, producer и конкретного consumer. Только после этого можно решить, additive change это или несовместимое изменение.</p>\n<h2>Тезис: версия не заменяет проверку</h2>\n<p>Номер <code>v1.1</code> связывает две точки во времени, но не отвечает на главный вопрос. Может ли reader, рассчитанный на baseline, принять candidate? Ответ зависит от обязательных полей, типов, дополнительных ключей и правил самого reader. Один consumer игнорирует незнакомые поля. Другой отвергает их. Одинаковый JSON для них имеет разный результат.</p>\n<p>Контракт данных — это не только схема. Это схема вместе с владельцем записи, ожидаемым reader, направлением совместимости и правилом изменения. Для практической проверки достаточно начать с одной пары: producer создаёт candidate, named consumer читает его как продолжение baseline. Остальные потребители требуют отдельных проверок.</p>\n<h2>Механизм: сравнить пару, а не два файла</h2>\n<p>Сначала зафиксируйте <code>baseline</code> — форму, которую уже читает потребитель. Затем опишите <code>candidate</code> — форму после изменения. В manifest перечислите добавленные, удалённые и изменённые по типу поля. Направление <code>backward</code> в этом материале означает: старый reader получает новую запись. Это не означает, что новый reader обязательно прочитает старую запись.</p>\n<p>Проверка должна идти в том же порядке. Сначала она убеждается, что обязательная поверхность baseline не исчезла. Затем проверяет, что каждое новое поле названо в manifest. После этого она спрашивает capability конкретного consumer: принимает ли он дополнительные ключи. Если входные данные не называют направление или consumer, проверка останавливается. Пустое сравнение нельзя считать зелёным результатом.</p>\n<figure><img src=\"/assets/editorial/2026/data-contracts-2026-contract-evolution.svg\" alt=\"Схема проверки изменения контракта данных: baseline и candidate проходят через manifest и compatibility gate к tolerant или strict consumer\"><figcaption>Учебная иллюстрация показывает, почему additive change зависит не только от candidate, но и от правил reader. Красная ветка означает остановку до передачи изменения дальше.</figcaption></figure>\n<h2>Минимальный пример</h2>\n<p>Ниже — учебная функция. Она не подключается к registry, не читает реальные схемы и не доказывает совместимость сервиса. Её задача — сделать порядок решений видимым.</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 candidate = {\n ...baseline,\n priority: { type: 'number', required: false },\n};\n\nconst change = {\n direction: 'backward',\n producer: 'work-item-api',\n consumer: 'billing-worker-v1',\n added: ['priority'],\n removed: [],\n changed: [],\n};\n\nfunction review({ baseline, candidate, change, acceptsAdditional }) {\n const requiredLost = Object.entries(baseline)\n .filter(([name, field]) => field.required && !candidate[name])\n .map(([name]) => name);\n\n if (!change.direction || !change.consumer) {\n return { status: 'stop-implicit-comparison' };\n }\n if (requiredLost.length > 0) {\n return { status: 'stop-backward-incompatible-schema', requiredLost };\n }\n\n const actualAdded = Object.keys(candidate)\n .filter((name) => !baseline[name]);\n const undocumented = actualAdded\n .filter((name) => !change.added.includes(name));\n\n if (undocumented.length > 0) {\n return { status: 'stop-undocumented-schema-field', undocumented };\n }\n if (actualAdded.length > 0 && !acceptsAdditional) {\n return { status: 'stop-incompatible-consumer' };\n }\n return { status: 'synthetic-compatibility-review-hand-off' };\n}\n\nconsole.log(review({\n baseline,\n candidate,\n change,\n acceptsAdditional: true,\n}));\n// { status: 'synthetic-compatibility-review-hand-off' }</code></pre>\n<p>В примере <code>priority</code> не удаляет <code>id</code> и <code>state</code>, поэтому структурная проверка проходит. Поле также записано в manifest. Tolerant consumer принимает дополнительные ключи, и функция возвращает ограниченный положительный статус. Этот статус означает только одно: фиксированная учебная пара прошла перечисленные правила. Он не означает deploy, миграцию базы или успешную обработку реального сообщения.</p>\n<p>Теперь измените candidate: замените <code>state</code> на <code>phase</code>. Функция вернёт <code>stop-backward-incompatible-schema</code>. Имена похожи, но старый consumer всё ещё ищет обязательное поле <code>state</code>. Не пытайтесь исправить этот результат добавлением номера версии. Здесь нужен отдельный план миграции или сохранение старого поля на период перехода.</p>\n<p>Третий случай — strict consumer. Оставьте additive candidate, но передайте <code>acceptsAdditional: false</code>. Результат станет <code>stop-incompatible-consumer</code>. Поле может быть корректным для одного reader и запрещённым для другого. Поэтому слово «optional» должно описывать не только schema declaration, но и поведение потребителя.</p>\n<h2>Симптомы и действия</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Старый reader падает на новом ключе</td><td>Consumer запрещает дополнительные поля</td><td>Проверить его parser policy и тест на candidate</td><td>Оставить поле вне старой формы, изменить reader или ввести отдельный контракт</td></tr><tr><td>После rename пропало значение</td><td>Удалено обязательное поле baseline</td><td>Сравнить required-поля baseline и candidate</td><td>Вернуть поле на переходный период или спроектировать миграцию</td></tr><tr><td>В review нет единого вердикта</td><td>Не задано направление или named consumer</td><td>Проверить manifest: direction, producer, consumer</td><td>Остановить изменение и сначала определить пару</td></tr><tr><td>Новый ключ появился без обсуждения</td><td>Diff шире заявленного manifest</td><td>Сравнить фактические ключи candidate со списком added</td><td>Назвать поле и его смысл либо удалить его из candidate</td></tr><tr><td>Тип остался строкой, но смысл изменился</td><td>Семантический breaking change не виден в structural diff</td><td>Сверить единицы, timezone, enum и документацию consumer</td><td>Дать новое имя или подготовить явную миграцию значения</td></tr></tbody></table>\n<h2>Почему schema validation недостаточно</h2>\n<p>JSON Schema описывает структуру экземпляра: свойства, типы и дополнительные свойства. Это полезная граница, но schema validation не знает, какой сервис владеет полем, кто читает объект и можно ли менять смысл значения без новой версии. Две схемы могут быть валидными по отдельности и всё равно не образовывать безопасную пару.</p>\n<p>Та же граница видна в JSON Type Definition. В RFC 8927 required properties и optionalProperties разделены явно. Режим дополнительных свойств тоже задаётся отдельно. Это хороший словарь для разговора о форме объекта. Но RFC не выбирает migration policy вашей команды и не сообщает, выдержит ли конкретный consumer изменение.</p>\n<p>В форматах с writer и reader schemas направление становится ещё заметнее. Apache Avro описывает schema resolution между схемой записи и схемой чтения. Для JSON-сервисов конкретные правила будут другими, но принцип переносим: нельзя обсуждать compatibility без указания стороны, которая пишет, и стороны, которая читает.</p>\n<h2>Порядок действий</h2>\n<ol><li><strong>Зафиксируйте baseline.</strong> Укажите идентификатор и версию формы. Выпишите обязательные поля и их типы.</li><li><strong>Опишите candidate.</strong> Покажите полную новую форму, а не только короткий diff. Не меняйте baseline задним числом.</li><li><strong>Составьте manifest.</strong> Перечислите <code>added</code>, <code>removed</code> и <code>changed</code>. Любое поле вне списка считается неоформленным.</li><li><strong>Назовите участников.</strong> Запишите producer, конкретного consumer, family контракта и направление: backward или другое явно определённое отношение.</li><li><strong>Проверьте обязательную поверхность.</strong> Убедитесь, что candidate сохраняет required-поля baseline и их типы.</li><li><strong>Проверьте новые поля.</strong> Сверьте фактический diff с manifest. Затем проверьте parser policy named consumer.</li><li><strong>Разберите отрицательный путь.</strong> Запустите тест на удаление обязательного поля, скрытый новый ключ и strict reader. Для каждой ветки сохраните отдельную причину остановки.</li><li><strong>Передайте результат с границей.</strong> Положительный synthetic verdict передаёт change на независимый review. Он не разрешает deploy без интеграционных проверок и наблюдаемого rollout.</li></ol>\n<h2>Ограничения</h2>\n<p>Учебный gate не видит неизвестных внешних клиентов. Он не проверяет кеши, очереди, сохранённые payload, SDK, базы и семантику бизнес-значений автоматически. Он также не определяет срок поддержки старой формы. Для этих вопросов нужны реальные инвентари потребителей, contract tests и план удаления.</p>\n<p>Добавление необязательного поля часто безопаснее удаления обязательного, но это не универсальное правило. Строгий parser, подпись payload или downstream-система с закрытым набором ключей превращают additive change в остановку. Не называйте поле безопасным только потому, что оно не помечено как required.</p>\n<p>Отдельный риск — изменение смысла без изменения типа. Строка <code>amount</code> может перейти с рублей на копейки. Timestamp может сменить timezone. Enum может получить другой смысл при том же наборе строк. Structural diff этого не докажет. Нужны доменное описание, тесты значений и проверка consumer.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение готово к следующему review, если другая инженерная команда без устного пояснения может открыть одну карточку и ответить на пять вопросов: какая форма была baseline, какая стала candidate, что изменилось по manifest, кто читает результат и в каком направлении выполнялась проверка. Для additive change дополнительно нужен положительный тест tolerant consumer и отрицательный тест strict consumer. Для breaking change нужен отдельный migration или versioning decision.</p>\n<p>Если хотя бы один ответ неизвестен, итогом должен быть stop, а не зелёный комментарий. Такая остановка дешевле аварийного отката: она превращает неясное обещание в конкретный вопрос, который можно проверить.</p>\n<h2>Проверяемые источники</h2>\n<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> — официальное описание structural keywords, включая <code>properties</code> и <code>additionalProperties</code>.</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> и режима дополнительных свойств.</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 schema, reader schema и schema resolution.</li></ul>"
|
||
}
|