8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 10,
|
||
"slug": "editorial-2027-09-field-mentor-series",
|
||
"title": "API-diff в code review: как найти несовместимость и сохранить откат",
|
||
"excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.",
|
||
"contentHtml": "<p>Ошибка в API-изменении часто выглядит безобидно: сервер собирается, локальный тест получает 200, diff занимает несколько строк. Затем старый клиент отправляет прежний запрос, получает новый обязательный ответ или неизвестное значение enum и ломается на успешном пути. Цена ошибки растёт быстро: приходится восстанавливать потребителей, задерживать rollout и решать, как вернуть сервер, если он уже записал данные в новом формате.</p>\n<p>Тезис простой: review API-diff должен проверять не только код сервера. Он должен связать форму контракта, потребителей, данные и порядок выката. Сначала классифицируйте изменение. Затем найдите тех, кто читает и пишет старую форму. После этого выберите расширение, версию или expand/contract. Такой порядок превращает спор о «безопасном» diff в набор проверяемых условий.</p>\n<h2>Механизм: контракт живёт по обе стороны границы</h2>\n<p>API состоит как минимум из запроса, ответа и иногда события. У каждого есть форма, значения и смысл. Добавление необязательного поля в ответ обычно расширяет контракт: старый клиент может его проигнорировать. Удаление поля сужает контракт. Новое обязательное поле в запросе ломает старого отправителя. Сужение enum ломает ветвление, которое раньше обрабатывало удалённое значение.</p>\n<p>Тип изменения недостаточен. Поле может остаться строкой, но поменять часовой пояс, единицу измерения или правило пустого значения. Формальная схема пропустит такой ответ, а клиент изменит поведение. Поэтому отдельно фиксируйте структурную совместимость и семантическую совместимость. Первая отвечает на вопрос «можно ли распарсить данные». Вторая — «можно ли продолжить прежнюю операцию с тем же смыслом».</p>\n<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>В запрос добавили required-поле</td><td>Найти builders, SDK и fixtures старой версии</td><td>Оставить поле optional, задать совместимое значение или выпустить версию</td></tr><tr><td>Клиент падает на успешном ответе</td><td>Удалили свойство или изменили тип</td><td>Поиск чтения свойства и contract-тест старого клиента</td><td>Сначала deprecated-окно и новое поле, затем удаление</td></tr><tr><td>Новая ветка обработки не срабатывает</td><td>Сузили enum или добавили неизвестное значение</td><td>Прогнать значения через старые switch и парсеры</td><td>Сохранить старые значения либо сменить версию</td></tr><tr><td>Откат приложения не восстанавливает работу</td><td>Новый сервер записал только новый формат</td><td>Проверить чтение старой версией после частичного rollout</td><td>Сначала expand, затем switch, потом contract</td></tr><tr><td>Тесты зелёные, внешний потребитель сломан</td><td>Потребитель не попал в репозиторий</td><td>Проверить registry, документацию, логи и владельцев интеграций</td><td>Остановить удаление и оставить совместимый путь</td></tr></tbody></table>\n<h2>Пример: классификатор как первая страховка</h2>\n<p>Ниже учебная функция получает уже выделенные признаки diff. Она не читает OpenAPI, не ищет клиентов и не разрешает pull request автоматически. Её граница полезна именно поэтому: генератор или reviewer передаёт факты, а функция одинаково маркирует очевидный breaking-риск. Реальная проверка должна дополнить её consumer-тестами и проверкой данных.</p>\n<pre><code>function 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' }</code></pre>\n<p>Функция намеренно не считает любое добавление опасным. Необязательное поле в ответе обычно можно выпустить сразу, если клиенты действительно игнорируют неизвестные свойства. Но это условие нужно проверить. Клиент с жёстким декодером, схемой с <code>additionalProperties: false</code> или сравнением полного JSON может сломаться даже на расширении. Статус <code>compatible</code> здесь означает «нет очевидного структурного breaking-признака», а не «изменение доказанно безопасно».</p>\n<h2>Обратимость: сначала данные, потом бинарник</h2>\n<p>Rollback приложения не возвращает базу, очередь и уже отправленные события. Если новый код записал только <code>displayNameV2</code>, старый код может не знать, как его читать. Если событие получило новое обязательное поле, повторная доставка старому consumer не станет безопасной от одного переключения образа. Поэтому опасные изменения проводите в несколько фаз.</p>\n<p>На фазе expand добавьте новую колонку, поле или форму так, чтобы старый код продолжал работать. На фазе совместимости научите новый код читать старую и новую формы и, при необходимости, писать обе. На фазе switch переведите потребителей и проверьте сигнал использования старого пути. Только после этого выполняйте contract: удаляйте поле, старый writer или колонку. Для каждой фазы нужна обратная операция и условие перехода.</p>\n<figure><img src='/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg' alt='Маршрут API review: классификация diff, проверка потребителей и окно обратимой миграции перед удалением старой формы.' loading='lazy' /><figcaption>Схема связывает локальный diff с потребителями и данными. Если старый формат ещё нужен, путь к удалению должен остановиться.</figcaption></figure>\n<h2>Порядок действий для одного изменения</h2>\n<ol><li>Назовите endpoint, метод, статус, media type и версию клиента. Не смешивайте request, response и внутреннюю модель в одном описании.</li><li>Снимите старую и новую формы. Выпишите required-поля, типы, nullable, enum, значения по умолчанию и изменение смысла.</li><li>Запустите классификацию. Для каждого breaking-признака укажите конкретное поле и потребителя, которого он затрагивает.</li><li>Найдите потребителей по сгенерированным типам, сериализаторам, SDK, fixture, документации, очередям и логам. Отдельно отметьте внешние интеграции, которых нет в монорепозитории.</li><li>Составьте матрицу чтения и записи: старая версия против старой формы, старая против новой, новая против старой и новая против новой. Добавьте частичный rollout и повторную доставку события.</li><li>Напишите отрицательный contract-тест для старого клиента и положительный тест для нового. Ошибка должна называть поле, значение и ожидаемую форму.</li><li>Выберите способ перехода: совместимое расширение, новая версия или expand/contract. Для rollout запишите точку остановки и способ возврата.</li><li>Удаляйте старую форму только после измеримого сигнала: старый consumer не обращается к полю, истёк согласованный срок хранения, а восстановление проверено.</li></ol>\n<h2>Где автоматическая проверка не помогает</h2>\n<p>Классификатор не знает, что внешний клиент использует поле чаще внутреннего. Он не видит подписанный payload, кэш, задержанную очередь и SDK, который обновляется отдельно. Он также не проверяет авторизацию, конкурентное изменение ресурса и идемпотентность повторного запроса. Эти риски нужно описать в своих тестах и процедуре rollout.</p>\n<p>Схема не доказывает корректность операции. Ответ может быть валидным по JSON, но устареть между чтением и записью. Для такого случая нужны версия ресурса, условный запрос вроде <code>If-Match</code>, правило конфликта и отдельный статус. Не прячьте доменный инвариант в описании поля: форма данных и допустимость перехода состояния — разные проверки.</p>\n<p>Пример синтетический. Он показывает границу классификатора и порядок миграции, но не заявляет замеры, production-результаты или совместимость с конкретным сервисом. Перед выпуском замените вымышленные поля реальным diff и приложите доказательства по вашим потребителям.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение готово к выпуску, если reviewer может открыть одну страницу и увидеть старую и новую формы, список потребителей, результат классификации, матрицу частичного rollout, contract-тесты и процедуру возврата. Для breaking-изменения дополнительно указан владелец старого пути, сигнал его использования и условие удаления. Если хотя бы один потребитель неизвестен, не переходите к contract-фазе: оставьте совместимое расширение или выпустите отдельную версию.</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> — официальная спецификация описания HTTP-интерфейсов. Граница: описание не доказывает фактическое поведение сервера.</li><li><a href='https://json-schema.org/draft/2020-12/json-schema-core.html' target='_blank' rel='noopener noreferrer'>JSON Schema Core 2020-12</a> — официальный документ о языке схем JSON. Граница: схема не знает бизнес-состояние и права доступа.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>RFC 9110 — HTTP Semantics</a> — стандарт IETF о методах, статусах и семантике HTTP. Граница: стандарт не описывает внутреннее хранилище конкретного сервиса.</li></ul>"
|
||
}
|