{ "index": 10, "slug": "editorial-2027-09-field-mentor-series", "title": "API-diff в code review: как найти несовместимость и сохранить откат", "excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.", "contentHtml": "
В review приходит небольшой API-diff: в ответ добавили поле, в запросе появился новый параметр, а локальный тест всё ещё получает 200. Через несколько минут старый клиент отправляет прежнюю форму и получает 400, либо успешно читает ответ, но падает на новом значении enum. Цена такой ошибки — не только красный график: приходится останавливать rollout, искать неизвестного потребителя и решать, совместим ли уже записанный новый формат со старой версией приложения.
\nГлавный вопрос ревью звучит так: может ли старый потребитель выполнить ту же операцию, пока новая версия уже частично работает? Ответ нельзя получить из diff сервера в одиночку. Нужно связать форму запроса и ответа, смысл полей, список читателей и писателей, состояние данных и план возврата. Ниже — рабочий порядок: классификация, проверка потребителей, выбор перехода и явная точка остановки.
\nAPI-контракт — это не только TypeScript-интерфейс или OpenAPI-файл. У него есть метод, путь, media type, статусы, форма запроса, форма ответа и допустимые значения. У поля есть ещё смысл: часовой пояс, единица измерения, правило пустого значения и связь с состоянием ресурса. Изменение может пройти структурный валидатор и всё равно изменить операцию для клиента.
\nOpenAPI 3.1.1 описывает поверхность HTTP-интерфейса и позволяет инструментам строить документацию, клиентов и тесты. Это полезный источник формы, но не доказательство фактического поведения конкретного сервиса. Сверяйте описание с обработчиком, реальным ответом и потребителем. В статье примеры используют OAS 3.1.1 и JSON Schema Validation 2020-12; если проект работает на другой версии или диалекте, сначала проверьте поддерживаемые ключевые слова и генератор.
\n| Изменение | Риск для старого потребителя | Проверка | Решение и stop condition |
|---|---|---|---|
| В запрос добавили required-поле | Старый отправитель не проходит валидацию | Запустить старый SDK или fixture без поля | Сделать поле optional или выпустить новую версию; остановиться, если старый путь получает 4xx |
| Из ответа удалили поле или изменили его тип | Чтение становится ошибочным или теряет значение | Найти чтения поля и прогнать старый декодер | Сначала новое поле рядом со старым; удаление только после сигнала отсутствия чтений |
| В ответ добавили новое значение enum | Закрытый switch или декодер не знает значение | Передать новое значение старому consumer | Добавить unknown-ветку или версию; остановиться на необработанной ветке |
| В запросе сузили принимаемый enum | Ранее допустимый отправитель получает отказ | Сравнить старый набор входов с новым и проверить логи 4xx | Сохранить старое значение или сменить версию; удаление — после миграции отправителей |
| Новый writer сохраняет только новый формат | Rollback бинарника оставляет старый reader без данных | Записать новой версией, затем прочитать старой | Expand/contract с двойным чтением или записью; остановиться до contract-фазы |
| Потребитель вне репозитория | Зелёный CI не видит интеграцию | Проверить registry, владельца, документацию и журналы вызовов | Назначить owner или оставить совместимый путь; неизвестный consumer блокирует удаление |
Required отвечает на вопрос о наличии поля. В JSON Schema объект проходит это ограничение, только если каждое имя из массива required есть в экземпляре. Поэтому добавление обязательного поля в запрос меняет минимальную форму, которую должен уметь отправить старый клиент.
Enum ограничивает множество значений. Расширение enum в ответе опасно для клиента с закрытым набором веток. Сужение enum в запросе опасно для отправителя, который ещё шлёт удалённое значение. Удаление значения из ответа не равно автоматически breaking-изменению: оно может быть безопасным для парсера, но изменить бизнес-переход. Этот смысл проверяется отдельно, а не угадывается по схеме.
\nНеизвестные свойства зависят от декодера. Один клиент их игнорирует, другой валидирует ответ схемой с additionalProperties: false, третий сравнивает JSON целиком. Поэтому «добавили поле — безопасно» — только гипотеза. В карточке изменения укажите реальное поведение потребителя, иначе статус compatible означает лишь отсутствие очевидного признака, а не доказанную безопасность.
Наконец, тип и форма не покрывают инвариант. Поле revision может оставаться строкой, но сервер может начать требовать его актуальность. Для конкурентной записи полезен условный запрос: RFC 9110 описывает If-Match как проверку текущего entity tag до выполнения метода и допускает ответ 412 Precondition Failed, если условие не выполнено. Это отдельный контракт состояния, а не следствие сравнения JSON.
Ниже самодостаточный Node.js-скрипт без пакетов. Он не парсит OpenAPI и не пытается автоматически одобрить pull request. Скрипт получает две уже нормализованные формы, находит добавленное required-поле, удалённое свойство и опасное изменение набора входных enum. Сохраните его как api-diff.mjs и запустите командой node api-diff.mjs.
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) => !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]) => {\n const newValues = newContract.requestEnums?.[name] || [];\n return difference(oldValues, newValues).length > 0;\n })\n .map(([name]) => name);\n\n const breaking = addedRequired.length > 0\n || removedResponse.length > 0\n || narrowedRequestEnums.length > 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));\nДля приведённых данных результат содержит ownerId в addedRequired и role в narrowedRequestEnums. removedResponse пуст. Скрипт намеренно не помечает добавление необязательного response-поля как breaking: это оставляет место для проверки tolerant и strict consumers. Он также не проверяет изменение смысла, статусы, авторизацию, события, кеши и записи. Эти границы важнее красивого зелёного результата, поэтому автоматический классификатор — первая страховка, а не решение reviewer.
После классификации составьте карту поверхности. Для каждого endpoint запишите клиентов браузера и мобильного приложения, SDK, batch-задачи, webhooks, очереди и внешние интеграции. Внутри репозитория ищите сгенерированные типы, сериализаторы, имена полей, fixtures и contract-тесты. Вне репозитория запросите owner и дату последнего вызова; отсутствие записи в монорепозитории не доказывает отсутствие потребителя.
\nРазделяйте направление зависимости. Старый writer → новый reader обычно проверяется легче, чем новый writer → старый reader. Второй путь критичен для rollback: новая версия могла уже записать данные, которые старая не умеет прочитать. Для событий добавьте задержанную доставку и повторное проигрывание. Для кеша проверьте старую сериализацию после истечения TTL, а не только свежий запрос.
\nold client --request v1--> new server --response v2--> old client\n | |\n +-- old data <-- new writer ---+\n\nПеред switch проверяем четыре перехода:\n1. old client -> old server\n2. old client -> new server\n3. new client -> old server, если rollback реален\n4. new writer -> old reader, если данные переживают rollback\nЕсли команда не может воспроизвести третий или четвёртый переход, это не повод молча исключить его из тестов. Это сигнал, что rollback не определён. Тогда сначала ограничьте rollout, сохраните старый writer или подготовьте чтение обеих форм. Название «backward compatible» без конкретного направления мало помогает reviewer.
\nСовместимое расширение — самый дешёвый путь, когда добавляется optional-поле, сохраняются прежние значения и все декодеры это допускают. Цена — временно поддерживать две формы и не путать отсутствие значения с пустым значением. Этот вариант хорош для одного независимого поля, но не спасает изменение смысла.
\nНовая версия endpoint или media type изолирует breaking-контракт. Клиенты мигрируют по отдельному графику, а старый путь живёт до объявленного срока. Цена — две документации, два набора тестов, маршрутизация и наблюдение за обоими путями. Версия оправдана, когда старую и новую семантику нельзя честно совместить.
\nExpand/contract подходит для данных, которые переживают релиз. На expand добавьте новую колонку или поле, не ломая старый reader. На compatibility научите новый код читать старую и новую форму и, если нужно, писать обе. На switch переведите потребителей и включите новый writer. На contract удалите старую форму только после сигнала использования и проверенного восстановления. Это дороже в коде и миграциях, зато rollback остаётся возможным после записи.
\nПорядок можно записать коротким flow: diff → направление чтения/записи → список consumers → contract-тест → частичный rollout → сигнал → switch → contract. На каждой стрелке должен быть владелец. Если сигнал не определён, переход заканчивается на предыдущем узле.
Schema diff не видит авторизацию и права, конкурентную запись, идемпотентность, подписанные payload, кэш, задержанную очередь и клиент, обновляющийся вне вашего графика доставки. JSON Schema проверяет форму экземпляра, но не знает, имеет ли пользователь право изменить ресурс и не устарела ли его версия. Эти условия должны появиться в тесте boundary или в runbook, если они входят в ваш контракт.
\nHTTP-статус тоже нельзя выводить из имени поля. Ответ 200 может содержать бизнес-отказ, а 412 требует реальной проверки precondition на сервере. Если используете If-Match, проверьте сильное сравнение entity tag, порядок проверки до изменения состояния и поведение повторной доставки. Если такой контракт не поддержан сервером, не добавляйте заголовок в пример только ради видимости надёжности.
Учебные имена ownerId, role и status не описывают конкретный production-сервис. В тексте нет измерений rollout и заявленного результата: его нужно получить у своей системы. Версии OAS и JSON Schema здесь указаны для воспроизводимости примера; генератор, валидатор и политика неизвестных полей всё равно требуют проверки в проекте.
Я закрываю API-diff, когда в одном месте видны baseline и candidate, классификация с конкретными полями, список readers/writers, четыре перехода, contract-тесты, выбранный способ миграции, сигнал частичного rollout и операция возврата. Для изменения данных добавляю результат чтения старой версией после записи новой. Для внешнего consumer указываю owner или оставляю старый путь.
\nНачните со следующего небольшого изменения и заполните только одну карточку: endpoint, направление, поле, consumer, проверка и stop condition. Если после этого нельзя ответить, что произойдёт с данными при rollback, остановите удаление и сначала сделайте чтение старой и новой формы совместимым. Такой review занимает место в процессе, но возвращает команде управляемый выбор вместо срочного восстановления неизвестного клиента.
\nenum и required. Граница: схема не моделирует права, конкурентное состояние и бизнес-переход.412 Precondition Failed. Граница: RFC не выбирает миграцию базы, версионирование endpoint или стратегию rollout.