Files

8 lines
26 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 10,
"slug": "editorial-2027-09-field-mentor-series",
"title": "API-diff в code review: как найти несовместимость и сохранить откат",
"excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.",
"contentHtml": "<p>В review приходит небольшой API-diff: в ответ добавили поле, в запросе появился новый параметр, а локальный тест всё ещё получает 200. Через несколько минут старый клиент отправляет прежнюю форму и получает 400, либо успешно читает ответ, но падает на новом значении enum. Цена такой ошибки — не только красный график: приходится останавливать rollout, искать неизвестного потребителя и решать, совместим ли уже записанный новый формат со старой версией приложения.</p>\n<p>Главный вопрос ревью звучит так: <strong>может ли старый потребитель выполнить ту же операцию, пока новая версия уже частично работает?</strong> Ответ нельзя получить из diff сервера в одиночку. Нужно связать форму запроса и ответа, смысл полей, список читателей и писателей, состояние данных и план возврата. Ниже — рабочий порядок: классификация, проверка потребителей, выбор перехода и явная точка остановки.</p>\n<h2>Сначала отделяем форму от смысла</h2>\n<p>API-контракт — это не только TypeScript-интерфейс или OpenAPI-файл. У него есть метод, путь, media type, статусы, форма запроса, форма ответа и допустимые значения. У поля есть ещё смысл: часовой пояс, единица измерения, правило пустого значения и связь с состоянием ресурса. Изменение может пройти структурный валидатор и всё равно изменить операцию для клиента.</p>\n<p>OpenAPI 3.1.1 описывает поверхность HTTP-интерфейса и позволяет инструментам строить документацию, клиентов и тесты. Это полезный источник формы, но не доказательство фактического поведения конкретного сервиса. Сверяйте описание с обработчиком, реальным ответом и потребителем. В статье примеры используют OAS 3.1.1 и JSON Schema Validation 2020-12; если проект работает на другой версии или диалекте, сначала проверьте поддерживаемые ключевые слова и генератор.</p>\n<table><caption>Симптом API-diff: что проверять и где остановиться</caption><thead><tr><th scope='col'>Изменение</th><th scope='col'>Риск для старого потребителя</th><th scope='col'>Проверка</th><th scope='col'>Решение и stop condition</th></tr></thead><tbody><tr><td>В запрос добавили required-поле</td><td>Старый отправитель не проходит валидацию</td><td>Запустить старый SDK или fixture без поля</td><td>Сделать поле optional или выпустить новую версию; остановиться, если старый путь получает 4xx</td></tr><tr><td>Из ответа удалили поле или изменили его тип</td><td>Чтение становится ошибочным или теряет значение</td><td>Найти чтения поля и прогнать старый декодер</td><td>Сначала новое поле рядом со старым; удаление только после сигнала отсутствия чтений</td></tr><tr><td>В ответ добавили новое значение enum</td><td>Закрытый <code>switch</code> или декодер не знает значение</td><td>Передать новое значение старому consumer</td><td>Добавить <code>unknown</code>-ветку или версию; остановиться на необработанной ветке</td></tr><tr><td>В запросе сузили принимаемый enum</td><td>Ранее допустимый отправитель получает отказ</td><td>Сравнить старый набор входов с новым и проверить логи 4xx</td><td>Сохранить старое значение или сменить версию; удаление — после миграции отправителей</td></tr><tr><td>Новый writer сохраняет только новый формат</td><td>Rollback бинарника оставляет старый reader без данных</td><td>Записать новой версией, затем прочитать старой</td><td>Expand/contract с двойным чтением или записью; остановиться до contract-фазы</td></tr><tr><td>Потребитель вне репозитория</td><td>Зелёный CI не видит интеграцию</td><td>Проверить registry, владельца, документацию и журналы вызовов</td><td>Назначить owner или оставить совместимый путь; неизвестный consumer блокирует удаление</td></tr></tbody></table>\n<h2>Что именно считать несовместимостью</h2>\n<p><strong>Required</strong> отвечает на вопрос о наличии поля. В JSON Schema объект проходит это ограничение, только если каждое имя из массива <code>required</code> есть в экземпляре. Поэтому добавление обязательного поля в запрос меняет минимальную форму, которую должен уметь отправить старый клиент.</p>\n<p><strong>Enum</strong> ограничивает множество значений. Расширение enum в ответе опасно для клиента с закрытым набором веток. Сужение enum в запросе опасно для отправителя, который ещё шлёт удалённое значение. Удаление значения из ответа не равно автоматически breaking-изменению: оно может быть безопасным для парсера, но изменить бизнес-переход. Этот смысл проверяется отдельно, а не угадывается по схеме.</p>\n<p><strong>Неизвестные свойства</strong> зависят от декодера. Один клиент их игнорирует, другой валидирует ответ схемой с <code>additionalProperties: false</code>, третий сравнивает JSON целиком. Поэтому «добавили поле — безопасно» — только гипотеза. В карточке изменения укажите реальное поведение потребителя, иначе статус <code>compatible</code> означает лишь отсутствие очевидного признака, а не доказанную безопасность.</p>\n<p>Наконец, тип и форма не покрывают инвариант. Поле <code>revision</code> может оставаться строкой, но сервер может начать требовать его актуальность. Для конкурентной записи полезен условный запрос: RFC 9110 описывает <code>If-Match</code> как проверку текущего entity tag до выполнения метода и допускает ответ <code>412 Precondition Failed</code>, если условие не выполнено. Это отдельный контракт состояния, а не следствие сравнения JSON.</p>\n<h2>Учебный API-diff, который можно запустить</h2>\n<p>Ниже самодостаточный Node.js-скрипт без пакетов. Он не парсит OpenAPI и не пытается автоматически одобрить pull request. Скрипт получает две уже нормализованные формы, находит добавленное required-поле, удалённое свойство и опасное изменение набора входных enum. Сохраните его как <code>api-diff.mjs</code> и запустите командой <code>node api-diff.mjs</code>.</p>\n<pre><code>const before = {\n requestRequired: ['name'],\n requestEnums: { role: ['reader', 'editor'] },\n responseProperties: { id: 'string', name: 'string', status: 'string' },\n};\n\nconst after = {\n requestRequired: ['name', 'ownerId'],\n requestEnums: { role: ['reader'] },\n responseProperties: { id: 'string', name: 'string', status: 'string' },\n};\n\nfunction difference(left, right) {\n return left.filter((value) =&gt; !right.includes(value));\n}\n\nfunction classifyApiDiff(oldContract, newContract) {\n const oldRequired = oldContract.requestRequired || [];\n const newRequired = newContract.requestRequired || [];\n const addedRequired = difference(newRequired, oldRequired);\n const removedResponse = difference(\n Object.keys(oldContract.responseProperties || {}),\n Object.keys(newContract.responseProperties || {}),\n );\n const narrowedRequestEnums = Object.entries(oldContract.requestEnums || {})\n .filter(([name, oldValues]) =&gt; {\n const newValues = newContract.requestEnums?.[name] || [];\n return difference(oldValues, newValues).length &gt; 0;\n })\n .map(([name]) =&gt; name);\n\n const breaking = addedRequired.length &gt; 0\n || removedResponse.length &gt; 0\n || narrowedRequestEnums.length &gt; 0;\n\n return {\n status: breaking ? 'breaking-risk' : 'no-obvious-breaking-change',\n addedRequired,\n removedResponse,\n narrowedRequestEnums,\n next: breaking ? 'stop-and-open-compatibility-plan' : 'run-consumer-tests',\n };\n}\n\nconsole.log(JSON.stringify(classifyApiDiff(before, after), null, 2));</code></pre>\n<p>Для приведённых данных результат содержит <code>ownerId</code> в <code>addedRequired</code> и <code>role</code> в <code>narrowedRequestEnums</code>. <code>removedResponse</code> пуст. Скрипт намеренно не помечает добавление необязательного response-поля как breaking: это оставляет место для проверки tolerant и strict consumers. Он также не проверяет изменение смысла, статусы, авторизацию, события, кеши и записи. Эти границы важнее красивого зелёного результата, поэтому автоматический классификатор — первая страховка, а не решение reviewer.</p>\n<h2>Потребитель — это читатель, писатель и очередь</h2>\n<p>После классификации составьте карту поверхности. Для каждого endpoint запишите клиентов браузера и мобильного приложения, SDK, batch-задачи, webhooks, очереди и внешние интеграции. Внутри репозитория ищите сгенерированные типы, сериализаторы, имена полей, fixtures и contract-тесты. Вне репозитория запросите owner и дату последнего вызова; отсутствие записи в монорепозитории не доказывает отсутствие потребителя.</p>\n<p>Разделяйте направление зависимости. Старый writer → новый reader обычно проверяется легче, чем новый writer → старый reader. Второй путь критичен для rollback: новая версия могла уже записать данные, которые старая не умеет прочитать. Для событий добавьте задержанную доставку и повторное проигрывание. Для кеша проверьте старую сериализацию после истечения TTL, а не только свежий запрос.</p>\n<pre><code>old client --request v1--&gt; new server --response v2--&gt; old client\n | |\n +-- old data &lt;-- new writer ---+\n\nПеред switch проверяем четыре перехода:\n1. old client -&gt; old server\n2. old client -&gt; new server\n3. new client -&gt; old server, если rollback реален\n4. new writer -&gt; old reader, если данные переживают rollback</code></pre>\n<p>Если команда не может воспроизвести третий или четвёртый переход, это не повод молча исключить его из тестов. Это сигнал, что rollback не определён. Тогда сначала ограничьте rollout, сохраните старый writer или подготовьте чтение обеих форм. Название «backward compatible» без конкретного направления мало помогает reviewer.</p>\n<h2>Три способа выпустить изменение</h2>\n<p>Совместимое расширение — самый дешёвый путь, когда добавляется optional-поле, сохраняются прежние значения и все декодеры это допускают. Цена — временно поддерживать две формы и не путать отсутствие значения с пустым значением. Этот вариант хорош для одного независимого поля, но не спасает изменение смысла.</p>\n<p>Новая версия endpoint или media type изолирует breaking-контракт. Клиенты мигрируют по отдельному графику, а старый путь живёт до объявленного срока. Цена — две документации, два набора тестов, маршрутизация и наблюдение за обоими путями. Версия оправдана, когда старую и новую семантику нельзя честно совместить.</p>\n<p>Expand/contract подходит для данных, которые переживают релиз. На expand добавьте новую колонку или поле, не ломая старый reader. На compatibility научите новый код читать старую и новую форму и, если нужно, писать обе. На switch переведите потребителей и включите новый writer. На contract удалите старую форму только после сигнала использования и проверенного восстановления. Это дороже в коде и миграциях, зато rollback остаётся возможным после записи.</p>\n<figure><img src='/assets/editorial/2026/data-contracts-2026-compatibility-gate-loop.svg' alt='Цикл проверки совместимости: сравнение схемы, required-поверхности, manifest и потребителя, после чего изменение либо возвращается на доработку, либо проходит ограниченный gate.' loading='lazy' /><figcaption>Схема напоминает о границе автоматизации: сравнение формы должно закончиться проверкой реального потребителя и явным решением продолжать или остановиться.</figcaption></figure>\n<p>Порядок можно записать коротким flow: <code>diff → направление чтения/записи → список consumers → contract-тест → частичный rollout → сигнал → switch → contract</code>. На каждой стрелке должен быть владелец. Если сигнал не определён, переход заканчивается на предыдущем узле.</p>\n<h2>Runbook одного API-изменения</h2>\n<ol><li><strong>Зафиксировать baseline.</strong> Сохраните старую схему, пример запроса и ответа, статусы, media type и версии клиентов. Не смешивайте внешнюю форму с внутренней моделью.</li><li><strong>Разложить diff.</strong> Выпишите added/removed properties, required, nullable, типы, enum, default и изменение смысла. Для каждого пункта укажите request, response или event.</li><li><strong>Классифицировать риск.</strong> Запустите проверку вроде примера выше, но не называйте результат доказательством. Для каждого флага приложите конкретное поле и направление потока.</li><li><strong>Найти consumers.</strong> Проверьте код, SDK, генератор, fixtures, registry, владельцев внешних интеграций, очереди, webhooks и логи. Не заменяйте неизвестного owner предположением.</li><li><strong>Собрать четыре перехода.</strong> Прогоните старый и новый client против старого и нового server там, где такой маршрут возможен. Отдельно проверьте новый writer → старый reader и повторную доставку события.</li><li><strong>Написать тесты отказа.</strong> Старый клиент должен показать, что именно ломается: поле, значение, статус или декодирование. Новый клиент должен пройти положительный путь. Для семантических изменений добавьте бизнес-инвариант, а не только schema validation.</li><li><strong>Выбрать переход.</strong> Для простого расширения оставьте optional-путь; для несовместимой семантики версионируйте; для сохраняемых данных примените expand/contract. В карточке изменения запишите владельца rollback и момент остановки.</li><li><strong>Провести частичный rollout.</strong> Сначала включите небольшой контролируемый срез, сравните ошибки старых и новых consumers, проверьте чтение записей обеими версиями и только потом расширяйте охват. Число среза — параметр вашей платформы, здесь оно не задано.</li><li><strong>Закрыть старый путь.</strong> Нужны измеримый сигнал отсутствия старого consumer, согласованный срок хранения, подтверждённое восстановление и owner удаления. Если любой пункт неизвестен, contract-фаза не начинается.</li></ol>\n<h2>Ограничения и ложные зелёные проверки</h2>\n<p>Schema diff не видит авторизацию и права, конкурентную запись, идемпотентность, подписанные payload, кэш, задержанную очередь и клиент, обновляющийся вне вашего графика доставки. JSON Schema проверяет форму экземпляра, но не знает, имеет ли пользователь право изменить ресурс и не устарела ли его версия. Эти условия должны появиться в тесте boundary или в runbook, если они входят в ваш контракт.</p>\n<p>HTTP-статус тоже нельзя выводить из имени поля. Ответ 200 может содержать бизнес-отказ, а 412 требует реальной проверки precondition на сервере. Если используете <code>If-Match</code>, проверьте сильное сравнение entity tag, порядок проверки до изменения состояния и поведение повторной доставки. Если такой контракт не поддержан сервером, не добавляйте заголовок в пример только ради видимости надёжности.</p>\n<p>Учебные имена <code>ownerId</code>, <code>role</code> и <code>status</code> не описывают конкретный production-сервис. В тексте нет измерений rollout и заявленного результата: его нужно получить у своей системы. Версии OAS и JSON Schema здесь указаны для воспроизводимости примера; генератор, валидатор и политика неизвестных полей всё равно требуют проверки в проекте.</p>\n<h2>Когда review можно закрыть</h2>\n<p>Я закрываю API-diff, когда в одном месте видны baseline и candidate, классификация с конкретными полями, список readers/writers, четыре перехода, contract-тесты, выбранный способ миграции, сигнал частичного rollout и операция возврата. Для изменения данных добавляю результат чтения старой версией после записи новой. Для внешнего consumer указываю owner или оставляю старый путь.</p>\n<p>Начните со следующего небольшого изменения и заполните только одну карточку: endpoint, направление, поле, consumer, проверка и stop condition. Если после этого нельзя ответить, что произойдёт с данными при rollback, остановите удаление и сначала сделайте чтение старой и новой формы совместимым. Такой review занимает место в процессе, но возвращает команде управляемый выбор вместо срочного восстановления неизвестного клиента.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://spec.openapis.org/oas/v3.1.1.html' target='_blank' rel='noopener noreferrer'>OpenAPI Specification 3.1.1</a> — версия опубликована 24 октября 2024 года; разделы 2–4 описывают назначение OpenAPI, документ и версионирование. Граница: описание интерфейса не доказывает фактическое поведение сервиса.</li><li><a href='https://json-schema.org/draft/2020-12/json-schema-validation.html' target='_blank' rel='noopener noreferrer'>JSON Schema Validation 2020-12</a> — разделы 6.1.2 и 6.5.3 описывают проверку <code>enum</code> и <code>required</code>. Граница: схема не моделирует права, конкурентное состояние и бизнес-переход.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.1' target='_blank' rel='noopener noreferrer'>RFC 9110, section 13.1.1, If-Match</a> — нормативное описание условного запроса, strong comparison и возможного <code>412 Precondition Failed</code>. Граница: RFC не выбирает миграцию базы, версионирование endpoint или стратегию rollout.</li></ul>"
}