lenec ru

← все посты

Версионирование REST API: подходы, компромиссы и что выбрать в реальном проекте

13K

Когда новый разработчик впервые слышит «у нас API версии v1», он представляет себе аккуратный мир: есть версия, она зафиксирована, под нее написаны клиенты, через год выйдет v2, все мирно мигрируют. На практике у вас одновременно живут три версии, каждая в чуть разном состоянии, в одной криво документировано поле, в другой нет endpoint, который просят клиенты, и кто-то предлагает сделать «v1.1, чтобы не плодить».

Версионирование REST API — это не про синтаксис URL, а про политику изменений. Само по себе наличие /v1/ в адресе не делает API устойчивым к изменениям. Расскажу про подходы, которые реально применяю, и про правила, которые на длинной дистанции экономят больше всего нервов.

Что вообще такое «новая версия»

Прежде чем выбирать схему, надо понять, в каких случаях вообще нужна новая версия. Изменения делятся на три категории.

  • Backwards-compatible (совместимые). Добавление нового поля в ответ, нового опционального параметра, нового endpoint. Существующие клиенты ничего не замечают.
  • Backwards-incompatible (несовместимые). Удаление поля, переименование, изменение типа, изменение поведения существующего endpoint, превращение опционального параметра в обязательный.
  • Деструктивные. Полное переосмысление модели: «теперь у нас не заказы, а транзакции, поля совсем другие, флоу другой».

Совместимые изменения — повседневная работа, они не требуют новой версии. Их можно выкатывать в текущую и предупреждать клиентов в release notes.

Несовместимые — повод для следующей версии или для аккуратного переходного периода. Сразу выкатывать их в текущую — ломать клиентов.

Деструктивные — повод задуматься, делать ли их вообще. Если очень нужно, это полноценная новая версия, иногда даже отдельный продукт.

Подходы к нумерации

В пути URL: /v1/, /v2/

Самый распространённый и самый простой для понимания. Видно с первого взгляда, в какую версию идёт запрос, можно явно роутить gateway-ом, можно держать две версии на разных сервисах.

GET /v1/orders/42
GET /v2/orders/42

Минусы: формально это нарушает идею «один ресурс — один URI», и с точки зрения чистого REST у /v1/orders/42 и /v2/orders/42 разные адреса для одного бизнес-объекта. На практике — самый практичный вариант, потому что его понимает каждый.

В заголовке (header)

GET /orders/42
Accept: application/vnd.shop.v2+json

Кошерно с точки зрения HTTP, но тяжело в эксплуатации. Сложнее тестировать через браузер, сложнее отлаживать в Kibana, сложнее объяснять интеграторам. Я применяю редко, в основном если API строго b2b и интеграторы пишут на Java/.NET, где работа с заголовками привычна.

В query-параметре

GET /orders/42?api-version=2024-04-01

Промежуточный вариант, бывает у Microsoft и Stripe. Ставит дату/версию параметром. У него две черты, которые могут быть и плюсом, и минусом одновременно: легко переопределить руками, легко забыть и попасть в дефолтную версию.

Дата вместо номера

Подход Stripe — версия как дата релиза: Stripe-Version: 2023-10-16. Каждое несовместимое изменение имеет дату, клиент явно фиксирует, какую он принимает. Удобно, когда несовместимых изменений много и не хочется плодить v3, v4, v5. Дороже в реализации (нужно поддерживать «диффы» между датами).

Сколько версий держать одновременно

Это один из главных вопросов, на который никогда нет универсального ответа. Я ориентируюсь на следующие правила.

  • Одновременно поддерживайте максимум 2–3 версии. Больше — резко растёт стоимость поддержки, тестирования, документации.
  • Объявляйте sunset заранее. Полгода-год — нормальный период между объявлением и реальным отключением старой версии. Для крупных интеграторов — больше.
  • Не выпускайте новую версию из-за одного маленького изменения. Лучше копите несовместимые изменения и выкатывайте новую версию пакетом. Иначе у вас будет v17 через два года.

Тонкие случаи

Изменение поведения без изменения схемы

Самый коварный класс. Например, раньше при отсутствии адреса доставки заказ создавался со статусом pending, а теперь возвращается ошибка 422. Схема не поменялась, контракт нарушился. Это backwards-incompatible изменение, требующее новой версии или хотя бы переходного периода с feature flag-ом.

Сужение значений в enum

Если в enum было пять значений и одно убрали — это несовместимо. Клиент, который умел обрабатывать пять, не сломается, но клиент, который ожидал получить шестое (обратное направление, POST), сломается. Будьте внимательны к enum-ам в обе стороны.

Изменение типов

Превращение string в int, превращение объекта в массив, превращение nullable-поля в обязательное — все это пугающе несовместимо. Делать только в новой версии, никогда в текущей.

Расширение через optional

Добавлять новые поля к ответам можно. Но клиенты должны быть к этому готовы и не падать на «неизвестных» полях. Это не ваша проблема, это их грамотность, но в документации стоит явно написать «мы можем добавлять поля в ответы; не падайте на неизвестных».

Документация и changelog

Версионирование без changelog — это аттракцион «угадай, что поменялось». На каждое изменение должна быть запись:

  • Что изменилось.
  • Совместимо или нет.
  • В какой версии появилось.
  • Что делать клиентам, которые сейчас используют старое поведение.

OpenAPI помогает в части схемы, но не в части семантики. Для семантики — обычный markdown changelog в репозитории и/или в публичной документации. Если в первый месяц после релиза изменения не подробно записаны, через год никто не вспомнит, что и зачем поменяли.

Технически: как уживаются две версии

Есть три подхода с разной стоимостью поддержки.

Один сервис, два набора endpoint-ов

Самый простой. В коде есть контроллеры OrderControllerV1 и OrderControllerV2, оба зовут общую доменную логику. Между ними — слой адаптеров, который превращает старый формат в новый и обратно.

@RestController
@RequestMapping("/v1/orders")
class OrderControllerV1(private val service: OrderService) {
    @GetMapping("/{id}")
    fun get(@PathVariable id: String): OrderV1Dto =
        OrderV1Dto.from(service.findById(id))
}

@RestController
@RequestMapping("/v2/orders")
class OrderControllerV2(private val service: OrderService) {
    @GetMapping("/{id}")
    fun get(@PathVariable id: String): OrderV2Dto =
        OrderV2Dto.from(service.findById(id))
}

Подходит, когда разница между версиями небольшая (косметика, переименования). Если различия серьёзные — этот путь забивает кодовую базу.

Один сервис, фасад-преобразователь

Сервис говорит на новой версии, отдельный фасад преобразует к старой для тех клиентов, что ещё на v1. Удобно, когда новая версия сильно отличается, и не хочется тащить старую логику дальше.

Два сервиса

Старая версия — отдельный legacy-сервис, новая — отдельный новый. Используется, когда между версиями принципиальная архитектурная разница, или когда старую поддерживают в режиме «не трогать, пока работает».

Deprecation и sunset

Объявление о выводе версии должно жить и в коде, и в документации.

  • В заголовках ответа полезно вернуть Deprecation: true и Sunset: Wed, 31 Dec 2026 23:59:59 GMT. Часть клиентов это даже логирует.
  • В документации — большое жёлтое предупреждение на странице старой версии.
  • В личных кабинетах интеграторов — индивидуальные уведомления, особенно если есть телеметрия по клиентам.
  • За пару месяцев до sunset — мониторинг кто ещё ходит. Часто оказывается, что один-два крупных клиента не успевают; договариваетесь индивидуально или сдвигаете дату.

Версионирование внутри организации

Внутренние API между сервисами одной компании заслуживают отдельных правил. Тут вы контролируете и продьюсера, и потребителя, и спешка с deprecation менее критична. Но и дисциплина чаще теряется: «у нас же все свои, договоримся».

Договоренность работает до момента, когда команда A меняет внутренний API без оповещения, а команда B узнаёт об этом из инцидента в проде. Поэтому даже внутри я рекомендую:

  • Выпускать совместимые изменения свободно.
  • Несовместимые — через переходный период с двумя версиями endpoint-а.
  • Объявлять deprecation в обычном Slack-канале, не в устной форме.

Это не громоздко, но избавляет от половины внутренних инцидентов на стыке команд.

Чек-лист при изменении API

  • Изменение совместимо? Если да — релиз в текущую версию, запись в changelog.
  • Если нет — есть ли способ сделать его совместимым (новое поле вместо переименования, новый endpoint вместо изменения существующего)?
  • Если нет и так — это либо новая версия, либо переходный период с явным флагом.
  • Документация обновлена до релиза.
  • Клиенты, которых это касается, оповещены и есть план миграции.

Что запомнить

Версионирование — это не про красивые URL. Это политика, по которой вы изменяете API так, чтобы клиенты могли мигрировать в комфортном темпе, а вы — двигаться, не ломая прод. Выбирайте простой механизм нумерации (обычно /vN/ в пути), держите 2–3 версии одновременно, не плодите версии из-за одного маленького изменения, и обязательно документируйте каждое изменение. Остальное — детали реализации, которые становятся видны только с опытом.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.