8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 228,
|
||
"slug": "editorial-2021-09-practice-api-versioning",
|
||
"title": "Версионирование API: как изменить контракт и не сломать клиентов",
|
||
"excerpt": "Пошаговая схема для изменения request и response: найти реальный разрыв, проверить старого и нового потребителя, а опасное удаление поставить на паузу.",
|
||
"contentHtml": "<p>Рассмотрим учебный сценарий: после выпуска новой версии API старое мобильное приложение перестало показывать сумму заказа. HTTP-ответ оставался успешным, сервер не сообщал об ошибке, а в JSON вместо обязательного <code>totalMinor</code> появилось новое поле <code>amountMinor</code>. Пользователь видел пустой экран или не мог подтвердить заказ. Цена ошибки — не только сломанный экран: это срочный релиз, ручная сверка данных и риск повторить тот же разрыв в другом клиенте.</p>\n<p>Тезис простой: версионирование API — это управление договором между отправителем и получателем данных. Номер в URL помогает разделить маршруты, но не доказывает совместимость. Перед изменением нужно отдельно проверить request и response, обязательные поля, допустимые значения и смысл ответа. Если проверка не проходит, старый договор остаётся действующим, а удаление откладывается.</p>\n<h2>Что именно считается версией</h2>\n<p>В одной фразе «мы обновили API» часто смешивают четыре слоя. Первый — версия документа OpenAPI. Второй — публичный маршрут, например <code>/api/orders</code> или <code>/api/v2/orders</code>. Третий — форма сообщения: ключи, типы, обязательность и значения. Четвёртый — поведение клиента: какую ветку он выбирает после статуса <code>confirmed</code>, что делает при отсутствии поля и как обрабатывает неизвестный ключ.</p>\n<p>Эти слои связаны, но не заменяют друг друга. OpenAPI может описать поле, но не знает, что клиент использует <code>status</code> как разрешение показать кнопку. Сегмент <code>/v2</code> может направить запрос на другой обработчик, но не мигрирует сохранённые приложения. Поэтому сначала фиксируют фактическую границу договора: кто отправляет request, кто читает response, какие значения обязательны и какое отсутствие считается нормальным.</p>\n<table><caption>Слои изменения и проверка перед выпуском</caption><thead><tr><th>Слой</th><th>Пример</th><th>Риск</th><th>Проверка</th></tr></thead><tbody><tr><td>Маршрут</td><td><code>/api/orders</code> → <code>/api/v2/orders</code></td><td>Старый клиент продолжает ходить в прежний маршрут</td><td>Составить список потребителей каждого маршрута</td></tr><tr><td>Response</td><td>Добавить <code>deliveryWindow</code></td><td>Валидатор со строгой схемой может отвергнуть новый ключ</td><td>Прогнать реальный reader на additive response</td></tr><tr><td>Request</td><td>Добавить <code>deliveryPreference</code></td><td>Старый сервер не знает поле или молча его теряет</td><td>Проверить v1 request и каждое новое значение</td></tr><tr><td>Смысл</td><td><code>confirmed</code> → <code>accepted</code></td><td>Тип остался string, но ветка клиента изменилась</td><td>Проверить переходы состояния и пользовательское действие</td></tr></tbody></table>\n<h2>Модель на одном endpoint</h2>\n<p>Возьмём учебный endpoint <code>POST /api/orders/{orderId}/confirm</code>. Это ограниченная in-memory модель, а не описание production-сервиса. Версия v1 отправляет только строковый <code>confirmationCode</code>. В этом договоре новый сервер принимает такой request. Версия v2 может добавить <code>deliveryPreference</code>. Отсутствие поля означает «не менять настройку», <code>null</code> — «очистить настройку», а строки <code>weekday</code> и <code>weekend</code> задают значение.</p>\n<pre><code>function acceptRequest(request) {\n const known = new Set(['confirmationCode', 'deliveryPreference']);\n const unknown = Object.keys(request).filter((key) => !known.has(key));\n\n if (unknown.length) return { ok: false, reason: 'unknown-field' };\n if (typeof request.confirmationCode !== 'string' || !request.confirmationCode) {\n return { ok: false, reason: 'confirmationCode-required' };\n }\n if (!Object.hasOwn(request, 'deliveryPreference')) {\n return { ok: true, preference: 'unchanged' };\n }\n if (request.deliveryPreference === null) {\n return { ok: true, preference: 'clear' };\n }\n if (!['weekday', 'weekend'].includes(request.deliveryPreference)) {\n return { ok: false, reason: 'invalid-preference' };\n }\n return { ok: true, preference: 'set' };\n}\n\nconsole.assert(acceptRequest({ confirmationCode: 'c-17' }).ok);\nconsole.assert(acceptRequest({ confirmationCode: 'c-17', deliveryPreference: null }).preference === 'clear');\nconsole.assert(acceptRequest({ confirmationCode: 'c-17', deliveryPrefrence: 'weekday' }).reason === 'unknown-field');</code></pre>\n<p>Функция показывает направление проверки. Новый сервер принимает обязательный непустой код без нового поля, но не принимает опечатку <code>deliveryPrefrence</code>. Он различает отсутствие, <code>null</code> и строку. В реальном сервисе эти правила должны жить в его валидаторе и тестах. Здесь код служит учебной моделью: три <code>console.assert</code> дают минимальную проверку положительного, очистившего и ошибочного request.</p>\n<p>Response проверяют в обратную сторону. Старый клиент должен прочитать текущий ответ, пока команда обещает его поддержку. В базовом ответе обязательны <code>id</code>, <code>status</code> и <code>totalMinor</code>. Новое поле <code>deliveryWindow</code> можно добавить только после проверки конкретного v1 reader-а. Если reader строго валидирует набор ключей, additive change тоже ломает договор. Если он игнорирует неизвестные поля, это свойство нужно зафиксировать тестом, а не считать общим правилом.</p>\n<pre><code>const baseResponse = {\n id: 'order-17',\n status: 'confirmed',\n totalMinor: 129900,\n};\n\nconst additiveResponse = {\n ...baseResponse,\n deliveryWindow: { from: '2021-08-03T10:00:00Z', to: '2021-08-03T12:00:00Z' },\n};\n\nconst removalResponse = { id: 'order-17', status: 'confirmed' };\n\nfunction readV1(response) {\n if (typeof response.id !== 'string') throw new Error('id');\n if (response.status !== 'confirmed') throw new Error('status');\n if (!Number.isInteger(response.totalMinor)) throw new Error('totalMinor');\n return response.totalMinor;\n}\n\nreadV1(baseResponse); // учебный пример: проходит\nreadV1(additiveResponse); // проходит: reader не обращается к новому ключу\n// readV1(removalResponse); // выбрасывает ошибку: totalMinor обязателен</code></pre>\n<p>В этой модели последний вызов действительно отклонит ответ без <code>totalMinor</code>, а additive response пройдёт, потому что reader обращается только к нужным полям. Другой клиент может применять схему с запретом неизвестных полей и отклонить тот же additive response. Проверять нужно поведение своего reader-а на полном candidate response, а не делать вывод по тому, что JSON синтаксически разобрался.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Экран не показывает сумму</td><td>Удалено или переименовано обязательное поле</td><td>Сравнить base и candidate response на v1 reader-е</td><td>Вернуть legacy field и остановить retirement</td></tr><tr><td>HTTP 200, но неверная ветка клиента</td><td>Изменён смысл допустимого значения <code>status</code></td><td>Проверить переходы по значениям, а не только JSON type</td><td>Сохранить старый смысл или выпустить явный новый contract</td></tr><tr><td>Новое предпочтение не применилось</td><td>Старый сервер отбросил неизвестное request-поле</td><td>Проверить ответ валидатора и итоговое состояние</td><td>Дождаться поддержки writer-а или использовать отдельный маршрут</td></tr><tr><td>Сервис принимает опечатку</td><td>Unknown request fields разрешены молча</td><td>Отправить <code>deliveryPrefrence</code> и проверить отказ</td><td>Отклонять неизвестные поля с понятной причиной</td></tr><tr><td>Старый клиент падает после добавления поля</td><td>Валидатор строгий, хотя изменение считали additive</td><td>Прогнать реальный parser на полном candidate response</td><td>Сохранить форму ответа или расширить поддержку reader-а</td></tr></tbody></table>\n<h2>Rollout и отрицательный путь</h2>\n<p>Безопасный rollout не начинается с удаления старого ключа. Сначала фиксируют базовый response и v1 request. Затем добавляют новое поле в response и проверяют старого reader-а. После этого проверяют v2 request на новом сервере. Только потом обсуждают retirement. Удаление <code>totalMinor</code> проходит через тот же v1 compatibility test. Если тест отклоняет candidate response, legacy field остаётся, а выпуск останавливается до изменения состояния.</p>\n<figure><img src='/assets/editorial/2021/api-versioning-rollout-2021.svg' alt='Маршрут эволюции API: базовый контракт, additive response, v2 request и безопасная пауза перед удалением totalMinor'><figcaption>Рисунок 1. Сначала проверяют additive-изменение и новый request. Удаление обязательного поля останавливается на v1 gate.</figcaption></figure>\n<p>Это отрицательный путь, а не исключение. Отказ теста сообщает: команда пока не доказала совместимость. Нельзя превращать его в разрешение на выпуск с флагом «проверим позже». Сохраняют прежний response, записывают вход и результат проверки, затем уточняют владельца клиента или договор миграции. Если нужный клиент неизвестен, это причина расширить инвентаризацию, а не причина считать его отсутствующим.</p>\n<p>Для вывода из эксплуатации можно сообщить клиентам о будущем сроке. Заголовок <code>Sunset</code> из RFC 8594 помогает передать намерение для ресурса, но сам по себе не доказывает миграцию и не отключает endpoint. Нужны также список потребителей, срок поддержки, новая форма ответа и проверяемое условие удаления.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте request и response, которые реально использует старый клиент. Запишите обязательные поля, типы, значения и поведение при отсутствии.</li><li>Разделите проверку на два направления: новый сервер читает старый request; старый клиент читает новый response.</li><li>Классифицируйте изменение. Добавление ключа, удаление ключа, переименование и смена смысла требуют разных проверок.</li><li>Проверьте additive response конкретным v1 reader-ом. Не делайте вывод по одной схеме OpenAPI.</li><li>Проверьте новый request: отсутствие optional-поля, <code>null</code>, допустимые значения и опечатку неизвестного ключа.</li><li>Добавьте наблюдаемый guard: contract test, проверку на границе сервиса или другой автоматический сигнал, который блокирует опасное изменение.</li><li>Только после зелёных проверок объявите поддержку новой формы. Retirement запускайте последним и оставьте обратимый шаг.</li></ol>\n<h2>Ограничения модели</h2>\n<p>Учебный endpoint не проверяет настоящий HTTP-трафик, gateway, кеш, авторизацию, SDK, базу и несколько одновременных обновлений. Он не отвечает на вопрос о retry для операции, которая меняет состояние. Для такого endpoint отдельно фиксируют idempotency key, допустимый повтор и способ сверить результат. Модель также не говорит, нужно ли использовать URL-версию, media type или заголовок. Выбор зависит от числа потребителей, размера breaking change, маршрутизации и способа поддержки клиентов.</p>\n<p>OpenAPI описывает интерфейс HTTP API, но поле <code>openapi</code> — это версия спецификации OpenAPI, а <code>info.version</code> — версия самого OpenAPI-документа; это не идентификатор версии реализации. Ни одно поле не заменяет тест reader-а. HTTP задаёт семантику request, response и status code, но смысл <code>confirmed</code> или <code>deliveryWindow</code> принадлежит вашему договору.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение готово к выпуску, когда команда может воспроизвести его на сохранённых примерах и получить четыре проверяемых результата: v1 request принимается новым сервером; v1 reader принимает обещанный response; v2 request валидирует отсутствие, <code>null</code>, допустимые значения и неизвестный ключ; candidate removal или semantic change автоматически отклоняется. Для каждого результата есть вход, ожидаемый ответ и владелец исправления. Пока хотя бы один пункт не доказан, старый contract не удаляют.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://spec.openapis.org/oas/v3.1.0.html' target='_blank' rel='noopener'>OpenAPI Specification 3.1.0</a> (15 февраля 2021 года) — различает поле <code>openapi</code> и <code>info.version</code> и описывает интерфейс HTTP API.</li><li><a href='https://www.rfc-editor.org/rfc/rfc7231.html' target='_blank' rel='noopener'>RFC 7231: HTTP/1.1 Semantics and Content</a> (июнь 2014 года) — фиксирует семантику request, response и status codes.</li><li><a href='https://www.rfc-editor.org/rfc/rfc8594.html' target='_blank' rel='noopener'>RFC 8594: The Sunset HTTP Header Field</a> (май 2019 года) — задаёт сигнал о вероятной будущей недоступности ресурса и прямо называет его подсказкой, а не гарантией.</li></ul>"
|
||
}
|