8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 64,
|
||
"slug": "editorial-2026-03-field-data-contracts",
|
||
"title": "Контракты данных: как не принять похожее поле за совместимое",
|
||
"excerpt": "Один consumer прочитал новый JSON, но это не доказывает совместимость всей схемы. Разбираем смысл полей, политику reader и fail-closed проверку producer → consumer.",
|
||
"contentHtml": "<p>После изменения JSON один сервис продолжает читать сообщения, и команда ставит контракту зелёный статус. Затем другой consumer начинает отбрасывать тот же объект: он запрещает неизвестные поля. Третий ждёт <code>status: paid</code> как признак завершённой оплаты, а producer использует <code>paid</code> для промежуточного состояния после авторизации. Форма объекта похожа, sample проходит, а решение всё равно ломает процесс.</p>\n<p>Цена такой ошибки — не только исключение в логах. Событие может попасть в очередь, сохраниться в архиве и быть обработано позже уже другой версией reader. Поэтому совместимость нельзя приписать JSON «вообще». Её проверяют для конкретной пары <code>producer → consumer</code>, версии, направления передачи и набора правил чтения.</p>\n<h2>Что именно является контрактом</h2>\n<p>Контракт данных — это не перечень ключей. Он отвечает как минимум на пять вопросов: какие поля обязательны, какие типы и единицы измерения допустимы, какие значения имеют деловой смысл, разрешены ли дополнительные поля и как долго сообщение остаётся читаемым. Если в карточке изменения написано только «добавили <code>status</code>», consumer не получил достаточного описания.</p>\n<p>В учебном кейсе producer отправляет заказ. В первой версии поле <code>status</code> означает жизненный цикл заказа: <code>new</code>, <code>paid</code>, <code>cancelled</code>. Другой сервис использует такое же имя для результата платежной операции: <code>authorized</code>, <code>captured</code>, <code>refunded</code>. Оба объекта валидны как JSON, но их значения нельзя смешивать. Совпадение имени не создаёт общей семантики.</p>\n<figure><img src=\"/assets/editorial/2026/data-contracts-2026-producer-consumer-matrix.svg\" alt=\"Матрица совместимости связывает candidate schema с tolerant и strict reader: добавленное поле проходит у tolerant reader, останавливает strict reader, а неизвестная пара требует уточнения связи.\" loading=\"lazy\" /><figcaption>Одна candidate schema даёт разные результаты для разных reader. Поэтому verdict относится к названной связи, а не ко всей системе без исключений.</figcaption></figure>\n<h2>Сначала разделяем форму и смысл</h2>\n<p>Удобно проверять контракт слоями. Первый слой — форма: объект, обязательные поля, типы, вложенность и ограничения размера. Второй — значения: enum, единицы измерения, часовой пояс и переходы между состояниями. Третий — политика consumer: что он делает с неизвестным полем, новым значением enum и отсутствующим необязательным полем. Четвёртый — жизненный цикл: какая версия producer ещё существует и может ли старое сообщение прийти после релиза.</p>\n<p>JSON Schema хорошо описывает первый слой. В спецификации есть <code>type</code>, <code>required</code>, <code>enum</code> и <code>additionalProperties</code>. Но сама структурная проверка не знает, означает ли <code>paid</code> захват денег, постановку операции в очередь или лишь успешную проверку реквизитов. Семантическое правило должно жить в контракте приложения и проверяться отдельным тестом.</p>\n<pre><code>{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"orderId\", \"status\", \"occurredAt\"],\n \"properties\": {\n \"orderId\": {\"type\": \"string\", \"minLength\": 1},\n \"status\": {\"enum\": [\"new\", \"paid\", \"cancelled\"]},\n \"occurredAt\": {\"type\": \"string\", \"format\": \"date-time\"}\n }\n}</code></pre>\n<p>В этом фрагменте <code>additionalProperties: false</code> — сознательная строгая политика для данного объекта. Она защищает от опечатки и скрытого расширения, но делает добавление поля изменением, которое требует координации. В расширяемом событии можно выбрать другую политику: разрешать дополнительные поля, документировать их и не использовать их как обязательные для старого reader.</p>\n<h2>Почему один успешный consumer ничего не доказывает</h2>\n<p>Совместимость — отношение, а не свойство producer. У одного события может быть HTTP-клиент, обработчик очереди, архиватор, аналитический загрузчик и старое мобильное приложение. Tolerant reader проигнорирует новое поле, strict reader вернёт ошибку, а аналитический consumer может принять JSON, но неверно посчитать показатель из-за другой трактовки <code>status</code>.</p>\n<p>Отдельно проверяйте направление. Старый producer → новый consumer и новый producer → старый consumer — разные случаи. Добавление необязательного поля часто безопасно для старого tolerant reader, но не для старого strict reader. Удаление обязательного поля опасно в обратную сторону: новый consumer может ожидать его у сообщений, которые ещё пишет старый producer.</p>\n<div class=\"table-scroll\"><table><caption>Матрица изменений контракта и минимальная проверка</caption><thead><tr><th scope=\"col\">Изменение</th><th scope=\"col\">Риск</th><th scope=\"col\">Что проверяем</th><th scope=\"col\">Решение</th></tr></thead><tbody><tr><td>Добавили необязательное поле</td><td>Strict reader отвергает extra property</td><td>Политику неизвестных полей у каждого consumer</td><td>Мигрировать reader, не добавлять поле или выпускать версию</td></tr><tr><td>Удалили обязательное поле</td><td>Reader не может собрать объект</td><td>Все producer и накопленные сообщения старой версии</td><td>Сначала период совместной поддержки, затем удаление</td></tr><tr><td>Переименовали <code>status</code> в <code>phase</code></td><td>Это удаление и добавление, а не косметика</td><td>Ссылки на старое имя и смысл каждого значения</td><td>Добавить новое поле с явной миграцией или новый контракт</td></tr><tr><td>Добавили значение enum</td><td>Код consumer может иметь закрытый switch</td><td>Поведение на неизвестном значении и запасную ветку</td><td>Расширить reader до отправки нового значения</td></tr><tr><td>Изменили секунды на миллисекунды</td><td>Число валидно, результат неверен в 1000 раз</td><td>Единицу измерения, диапазон и тест на границе</td><td>Назвать единицу в поле или выпустить отдельную форму</td></tr></tbody></table></div>\n<h2>Воспроизводимый fail-closed gate</h2>\n<p>Ниже — минимальный gate без сторонних пакетов. Он не пытается обнаружить всех consumers автоматически: список reader подаётся явно. Скрипт сравнивает обязательные поля и типы, проверяет новые значения <code>status</code> и учитывает политику неизвестных полей. Если связь не названа, он останавливается, а не угадывает.</p>\n<pre><code>// contract-gate.mjs\nconst baseline = {\n required: new Set(['orderId', 'status']),\n types: { orderId: 'string', status: 'string' },\n values: { status: new Set(['new', 'paid', 'cancelled']) },\n properties: new Set(['orderId', 'status'])\n};\n\nconst candidate = {\n required: new Set(['orderId', 'status']),\n types: { orderId: 'string', status: 'string', priority: 'integer' },\n values: { status: new Set(['new', 'paid', 'cancelled']) },\n properties: new Set(['orderId', 'status', 'priority'])\n};\n\nfunction check({ producer, consumer, direction, reader }) {\n if (!producer || !consumer || !direction) {\n return 'STOP relation is not named';\n }\n\n for (const field of baseline.required) {\n if (!candidate.required.has(field)) {\n return `STOP required field removed: ${field}`;\n }\n }\n\n for (const [field, type] of Object.entries(baseline.types)) {\n if (candidate.types[field] !== type) {\n return `STOP type changed: ${field}`;\n }\n }\n\n const added = [...candidate.properties]\n .filter((field) => !baseline.properties.has(field));\n if (added.length && !reader.allowUnknownProperties) {\n return `STOP unknown fields: ${added.join(', ')}`;\n }\n\n const oldStatuses = baseline.values.status;\n const newStatuses = candidate.values.status;\n const introduced = [...newStatuses].filter((value) => !oldStatuses.has(value));\n if (introduced.some((value) => !reader.statusValues.has(value))) {\n return `STOP unknown status value: ${introduced.join(', ')}`;\n }\n\n return 'PASS compatible for this named reader';\n}\n\nconst strictReader = {\n allowUnknownProperties: false,\n statusValues: new Set(['new', 'paid', 'cancelled'])\n};\nconst tolerantReader = {\n allowUnknownProperties: true,\n statusValues: new Set(['new', 'paid', 'cancelled'])\n};\n\nconsole.log(check({\n producer: 'orders-api',\n consumer: 'billing-worker',\n direction: 'producer-writes-consumer-reads',\n reader: strictReader\n}));\nconsole.log(check({\n producer: 'orders-api',\n consumer: 'analytics-loader',\n direction: 'producer-writes-consumer-reads',\n reader: tolerantReader\n}));\nconsole.log(check({ producer: 'orders-api', consumer: '', direction: '', reader: strictReader }));</code></pre>\n<p>Сохраните код в <code>contract-gate.mjs</code> и выполните:</p>\n<pre><code>node --check contract-gate.mjs\nnode contract-gate.mjs</code></pre>\n<p>Ожидаемый вывод:</p>\n<pre><code>STOP unknown fields: priority\nPASS compatible for this named reader\nSTOP relation is not named</code></pre>\n<p>Это не готовый schema registry и не доказательство успешной доставки. Gate демонстрирует порядок: сначала связь, затем breaking change, затем политика reader. В реальном проекте типы и значения следует брать из версионируемой схемы, а список reader — из inventory интеграций, конфигурации маршрутов и consumer contract tests.</p>\n<h2>Отдельно проверяем жизненный цикл status</h2>\n<p>Структура прошла — это только половина проверки. Для состояний задайте таблицу переходов: кто имеет право перевести заказ, какие события допустимы повторно и какое состояние считается финальным. Например, <code>paid → new</code> может быть запрещённым переходом, а повторное <code>paid</code> — допустимым при повторной доставке события. Эти правила не выводятся из JSON Schema.</p>\n<p>Не прячьте единицу времени в описании команды. Поля <code>occurredAt</code> и <code>receivedAt</code> отвечают на разные вопросы, а число без единицы измерения не даёт воспроизводимого контракта. Для времени зафиксируйте формат, часовой пояс и правило сравнения. Для денег зафиксируйте валюту, масштаб и способ округления. Число, строка и валидный ISO 8601 сами по себе не гарантируют деловой корректности.</p>\n<p>Для бинарных схем риск может выглядеть иначе. В Protocol Buffers номер поля участвует в wire format и не должен меняться или переиспользоваться; удалённые номера и имена рекомендуется резервировать, чтобы позднее не создать конфликт. В Avro reader и writer schema сопоставляются по правилам schema resolution. Это полезные модели, но их правила нельзя механически переносить на произвольный JSON API.</p>\n<h2>Порядок проверки изменения</h2>\n<ol><li><strong>Назовите отношение.</strong> Запишите contract family, producer, consumer, направление, транспорт и версию. Если consumer неизвестен, результат — остановка и поиск владельца.</li><li><strong>Сохраните baseline.</strong> Зафиксируйте обязательные поля, типы, enum, единицы измерения, ограничения и примеры до изменения.</li><li><strong>Опишите candidate.</strong> Разделите сохранённые, удалённые, переименованные и добавленные поля. Переименование рассматривайте как изменение формы и смысла, пока обратное не доказано.</li><li><strong>Проверьте reader policy.</strong> Узнайте, принимает ли consumer дополнительные поля и значения enum. Не выводите это из того, что один sample однажды прочитался.</li><li><strong>Проверьте отрицательные случаи.</strong> Удалите обязательное поле, добавьте неизвестное поле, передайте новое значение enum, смените единицу времени и отправьте сообщение в обратном направлении.</li><li><strong>Проверьте накопленные данные.</strong> Очередь, retry, архив и offline-клиенты могут доставить старую форму после релиза. Укажите срок совместного чтения и способ удаления старой версии.</li><li><strong>Сохраните адресный verdict.</strong> Запишите не только PASS или STOP, но и reader, входную версию, причину отказа и следующее действие. Общий итог можно вычислять только после результатов всех названных пар.</li></ol>\n<h2>Границы применимости</h2>\n<p>Описанный gate проверяет ограниченную структурную и перечислимую часть контракта. Он не обнаруживает скрытых consumers, не анализирует код всех клиентов, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку или корректность бизнес-переходов. Для этого нужны отдельные тесты, наблюдаемость и владелец интеграции.</p>\n<p>Даже строгая JSON Schema не делает API безопасным от неверного смысла. Спецификация описывает валидацию экземпляра; она не знает, что <code>status</code> относится к заказу, а не к платежу. Поле <code>format</code> также нельзя считать сетевой проверкой: в JSON Schema 2020-12 режим annotation и режим assertion различаются, а поддержка зависит от реализации и её конфигурации.</p>\n<p>Не объявляйте добавление поля безопасным без проверки политики reader. Не объявляйте удаление поля безопасным без проверки отложенных сообщений. Не объявляйте два объекта совместимыми только из-за одинаковых ключей. Если не хватает данных о producer, consumer или направлении, честный результат — STOP с конкретным запросом к владельцу.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение можно передавать дальше, когда для каждой известной пары сохранены baseline и candidate, описаны обязательные поля и значения, подтверждена политика reader, пройдены положительные и отрицательные тесты, а старые сообщения имеют срок совместной поддержки. В отчёте видны отдельные результаты для каждого consumer. Ни один общий зелёный статус не скрывает strict reader, неизвестное значение или неописанную связь.</p>\n<p>Практическая проверка готовности занимает несколько минут: возьмите новый объект, удалите из него обязательное поле, добавьте неизвестное поле и замените одно значение <code>status</code>. Скрипт должен остановиться на каждой нарушенной границе. Если он пропускает <code>status: refunded</code> для reader, который его не знает, проверка касается только формы. Если он пропускает пустое направление, результат нельзя связывать с конкретным контрактом.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation.html\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Draft 2020-12: Validation</a> — определяет <code>type</code>, <code>required</code>, <code>enum</code>, объектные ограничения и различие format-annotation/format-assertion.</li><li><a href=\"https://avro.apache.org/docs/1.12.0/specification/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro 1.12.0 Specification</a> — описывает writer schema, reader schema и schema resolution; правила Avro не являются универсальным verdict для JSON API.</li><li><a href=\"https://protobuf.dev/programming-guides/proto3/\" target=\"_blank\" rel=\"noopener noreferrer\">Protocol Buffers: Language Guide, proto3</a> — фиксирует ограничения на изменение и повторное использование номеров и имён полей; они относятся к protobuf-сообщениям и их форматам.</li></ul>"
|
||
}
|