{ "index": 10, "slug": "editorial-2027-09-field-mentor-series", "title": "API-diff в code review: как найти несовместимость и сохранить откат", "excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.", "contentHtml": "
Ошибка в API-изменении часто выглядит безобидно: сервер собирается, локальный тест получает 200, diff занимает несколько строк. Затем старый клиент отправляет прежний запрос, получает новый обязательный ответ или неизвестное значение enum и ломается на успешном пути. Цена ошибки растёт быстро: приходится восстанавливать потребителей, задерживать rollout и решать, как вернуть сервер, если он уже записал данные в новом формате.
\nТезис простой: review API-diff должен проверять не только код сервера. Он должен связать форму контракта, потребителей, данные и порядок выката. Сначала классифицируйте изменение. Затем найдите тех, кто читает и пишет старую форму. После этого выберите расширение, версию или expand/contract. Такой порядок превращает спор о «безопасном» diff в набор проверяемых условий.
\nAPI состоит как минимум из запроса, ответа и иногда события. У каждого есть форма, значения и смысл. Добавление необязательного поля в ответ обычно расширяет контракт: старый клиент может его проигнорировать. Удаление поля сужает контракт. Новое обязательное поле в запросе ломает старого отправителя. Сужение enum ломает ветвление, которое раньше обрабатывало удалённое значение.
\nТип изменения недостаточен. Поле может остаться строкой, но поменять часовой пояс, единицу измерения или правило пустого значения. Формальная схема пропустит такой ответ, а клиент изменит поведение. Поэтому отдельно фиксируйте структурную совместимость и семантическую совместимость. Первая отвечает на вопрос «можно ли распарсить данные». Вторая — «можно ли продолжить прежнюю операцию с тем же смыслом».
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый клиент не создаёт ресурс | В запрос добавили required-поле | Найти builders, SDK и fixtures старой версии | Оставить поле optional, задать совместимое значение или выпустить версию |
| Клиент падает на успешном ответе | Удалили свойство или изменили тип | Поиск чтения свойства и contract-тест старого клиента | Сначала deprecated-окно и новое поле, затем удаление |
| Новая ветка обработки не срабатывает | Сузили enum или добавили неизвестное значение | Прогнать значения через старые switch и парсеры | Сохранить старые значения либо сменить версию |
| Откат приложения не восстанавливает работу | Новый сервер записал только новый формат | Проверить чтение старой версией после частичного rollout | Сначала expand, затем switch, потом contract |
| Тесты зелёные, внешний потребитель сломан | Потребитель не попал в репозиторий | Проверить registry, документацию, логи и владельцев интеграций | Остановить удаление и оставить совместимый путь |
Ниже учебная функция получает уже выделенные признаки diff. Она не читает OpenAPI, не ищет клиентов и не разрешает pull request автоматически. Её граница полезна именно поэтому: генератор или reviewer передаёт факты, а функция одинаково маркирует очевидный breaking-риск. Реальная проверка должна дополнить её consumer-тестами и проверкой данных.
\nfunction classifyApiChange(change) {\n const removed = Array.isArray(change.removedProperties)\n ? change.removedProperties\n : [];\n const required = Array.isArray(change.addedRequiredProperties)\n ? change.addedRequiredProperties\n : [];\n const narrowedEnum = Boolean(change.narrowedEnum);\n const breaking = removed.length > 0\n || required.length > 0\n || narrowedEnum;\n\n return {\n status: breaking ? 'breaking' : 'compatible',\n action: breaking\n ? 'version-or-expand-compatibility-window'\n : 'run-consumer-contract-tests',\n };\n}\n\nconst result = classifyApiChange({\n removedProperties: ['displayName'],\n addedRequiredProperties: [],\n narrowedEnum: false,\n});\n\nconsole.log(result);\n// { status: 'breaking',\n// action: 'version-or-expand-compatibility-window' }\nФункция намеренно не считает любое добавление опасным. Необязательное поле в ответе обычно можно выпустить сразу, если клиенты действительно игнорируют неизвестные свойства. Но это условие нужно проверить. Клиент с жёстким декодером, схемой с additionalProperties: false или сравнением полного JSON может сломаться даже на расширении. Статус compatible здесь означает «нет очевидного структурного breaking-признака», а не «изменение доказанно безопасно».
Rollback приложения не возвращает базу, очередь и уже отправленные события. Если новый код записал только displayNameV2, старый код может не знать, как его читать. Если событие получило новое обязательное поле, повторная доставка старому consumer не станет безопасной от одного переключения образа. Поэтому опасные изменения проводите в несколько фаз.
На фазе expand добавьте новую колонку, поле или форму так, чтобы старый код продолжал работать. На фазе совместимости научите новый код читать старую и новую формы и, при необходимости, писать обе. На фазе switch переведите потребителей и проверьте сигнал использования старого пути. Только после этого выполняйте contract: удаляйте поле, старый writer или колонку. Для каждой фазы нужна обратная операция и условие перехода.
\nКлассификатор не знает, что внешний клиент использует поле чаще внутреннего. Он не видит подписанный payload, кэш, задержанную очередь и SDK, который обновляется отдельно. Он также не проверяет авторизацию, конкурентное изменение ресурса и идемпотентность повторного запроса. Эти риски нужно описать в своих тестах и процедуре rollout.
\nСхема не доказывает корректность операции. Ответ может быть валидным по JSON, но устареть между чтением и записью. Для такого случая нужны версия ресурса, условный запрос вроде If-Match, правило конфликта и отдельный статус. Не прячьте доменный инвариант в описании поля: форма данных и допустимость перехода состояния — разные проверки.
Пример синтетический. Он показывает границу классификатора и порядок миграции, но не заявляет замеры, production-результаты или совместимость с конкретным сервисом. Перед выпуском замените вымышленные поля реальным diff и приложите доказательства по вашим потребителям.
\nИзменение готово к выпуску, если reviewer может открыть одну страницу и увидеть старую и новую формы, список потребителей, результат классификации, матрицу частичного rollout, contract-тесты и процедуру возврата. Для breaking-изменения дополнительно указан владелец старого пути, сигнал его использования и условие удаления. Если хотя бы один потребитель неизвестен, не переходите к contract-фазе: оставьте совместимое расширение или выпустите отдельную версию.
\n