Версионирование REST API в Spring Boot: практическое руководство

Оглавление

  1. Введение
  2. Зачем нужно версионирование API
  3. Способы версионирования REST API
  4. Реализация версионирования в Spring Boot (контроллеры, DTO, сервисы)
  5. Интеграция Spring Cloud Gateway для маршрутизации версий API
  6. Документирование API (OpenAPI/Swagger) для нескольких версий
  7. Поддержка версий на фронтенде (клиентская сторона)
  8. Деплоймент и версионирование в CI/CD
  9. Лучшие практики версионирования API
  10. Заключение

Введение

Версионирование API – ключевой механизм, позволяющий эволюционировать веб-сервисы без нарушения работы существующих клиентов. Многие крупные компании версионируют свои REST API, чтобы внести изменения в сервис, не ломая интеграции для внешних потребителей. С другой стороны, поддержка нескольких версий API приводит к усложнению кода и увеличению объема поддержки. Если нет прямой необходимости, лучше избегать множества версий, однако в реальных проектах часто приходится вводить новую версию при существенных изменениях в контракте API. В этой статье рассмотрим, зачем нужно версионирование, какие подходы существуют, и как их реализовать в Spring Boot. Мы также обсудим интеграцию со Spring Cloud Gateway для маршрутизации разных версий, документирование нескольких версий через OpenAPI/Swagger, поддержку версий на фронтенде, аспекты CI/CD и лучшие практики миграции.

Зачем нужно версионирование API

Эволюция и обратная совместимость. По мере развития приложения может потребоваться изменить формат запросов/ответов, добавить новые поля или отказаться от устаревших ресурсов. Любое ломающее изменение (breaking change) требует новой версии API, иначе существующие клиенты начнут получать неожиданные ошибки. К “ломаюшим” изменениям относятся, например, изменение формата JSON, переименование или удаление полей, изменение URL эндпоинтов, введение обязательных параметров, которые раньше были необязательными. Версионирование позволяет внести такие изменения, предоставив обновленный интерфейс (новую версию), в то время как старый интерфейс продолжает работать для тех клиентов, кто еще не обновился.

Независимость клиентов. Благодаря версионированию, разные клиенты могут работать с той версией API, которая им нужна. Например, мобильное приложение может пока использовать версию 1, в то время как веб-клиент уже перешел на версию 2. Это снижает зависимость графика выпуска клиентских приложений от изменений на сервере и наоборот. Новые возможности могут выпускаться в новой версии API, не затрагивая стабильную старую версию, которой продолжают пользоваться другие потребители.

Обратная и прямая-совместимость. Хорошее версионирование позволяет поддерживать обратную совместимость – то есть новые версии сервера могут продолжать обслуживать запросы старых клиентов (параллельно поддерживая старый контракт). Также стоит думать о прямой-совместимости – чтобы при возможности клиенты более старой версии могли безопасно игнорировать неизвестные им новые поля в ответах нового сервера. В идеале, изменения, не ломающие контракт (например, добавление нового поля в JSON), не должны требовать повышения мажорной версии API, если клиенты написаны гибко.

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

Способы версионирования REST API

Существует несколько распространенных подходов к версионированию REST API. Рассмотрим основные из них, их реализацию и плюсы/минусы:

Версионирование в пути URL (Path Versioning)

Этот подход включает версию прямо в путь (URI) запроса, обычно как префикс: например, /api/v1/… или просто /v2/…. Это самый очевидный и часто используемый способ: версия является частью URL ресурса.

Реализация: при таком подходе все маршруты (endpoints) дублируются под разными префиксами. Например:

@RestController
@RequestMapping("/api")
public class ExampleControllerV1 {
    @GetMapping("/v1/items")
    public List<ItemDtoV1> getItemsV1() { ... }

    @GetMapping("/v2/items")
    public List<ItemDtoV2> getItemsV2() { ... }
}

Здесь определены два URL: /api/v1/items и /api/v2/items – каждая возвращает данные в формате соответствующей версии (например, ItemDtoV1 vs ItemDtoV2). Можно также разделять контроллеры по версиям: например, ItemsControllerV1 с маппингом @RequestMapping(“/api/v1/items”) и отдельный ItemsControllerV2 с @RequestMapping(“/api/v2/items”). Использование отдельных классов делает код чище и логически разделяет реализацию разных версий.

Плюсы:

  • Простота и наглядность. Клиенту достаточно изменить URL, чтобы получить другую версию
  • Легко протестировать в браузере или через curl.
  • Явная сегрегация версий на уровне маршрутов, что упрощает обслуживание (можно, например, развернуть v1 и v2 как разные сервисы или модули).

Минусы:

  • Дублирование API и кодовая база. Даже небольшое изменение, требующее новую версию, приводит к копированию всех связанных ресурсов под новым префиксом. Фактически, возникает “дерево” URI для каждой версии. Со временем это увеличивает объем кода и сложность поддержки.
  • “Все или ничего”. Нельзя легко эволюционировать одну часть API – приходится версионировать весь API целиком. Если одна сущность меняется несовместимо, формально номер версии меняется у всей API (v2), даже если другие ресурсы остались без изменений. Это затрудняет быстрые инкрементальные обновления.
  • Миграция клиентов – тяжелый процесс. Переход от v1 к v2 требует от клиента перебора всех вызываемых endpoint-ов на новые URL, адаптации к новым контрактам сразу по всему API, что ведет к длительным периодам поддержки старых версий и медленному переходу клиентов.
  • Кэширование. С точки зрения HTTP-кэшей версия в URI означает, что кеш видит разные URL как разные ресурсы – т.е. будет храниться копия для /v1/items и отдельная для /v2/items. Это простое поведение, но ведет к дублированию в кеше и снижению hit-rate (разные клиенты запрашивают разные версии). При обновлении версии все кэши для старого URL не затрагиваются новым (что может быть плюс для изоляции версий).

Когда использовать: Версия в пути подходит, когда вы хотите максимально изолировать разные версии и обеспечить простую маршрутизацию (например, через API Gateway). Это наиболее понятный подход для внешних публичных API и широко используется в индустрии (многие публичные API имеют /v1/, /v2/ и т.д. в пути).

Версионирование через параметр запроса (Query Parameter)

В этом подходе версия передается как параметр URL, например: GET /api/items?version=2. Версия указывается в query-строке запроса.

Реализация: На уровне Spring Boot можно в контроллере указать специальный параметр для маппинга. Например, два метода для одного URL, но с разным требуемым параметром:

@RestController
@RequestMapping("/api")
public class ItemsController {
    @GetMapping(value = "/items", params = "version=1")
    public List<ItemDtoV1> getItemsV1() { ... }

    @GetMapping(value = "/items", params = "version=2")
    public List<ItemDtoV2> getItemsV2() { ... }
}

В данном примере, запрос /api/items?version=1 придет в метод getItemsV1(), а с ?version=2 – в getItemsV2(). Если параметр не указан, можно либо считать его по умолчанию 1, либо направлять на наиболее новую версию по умолчанию.

Плюсы:

  • Версия не “захламляет” структуру URL – без версии ресурс имеет “чистый” путь (/api/items), а версия указывается как опция. Это позволяет при отсутствии версии обрабатывать запрос как обращение к дефолтной (обычно последней) версии.
  • Легко реализуется и не требует отдельного домена или поддомена. Близко по сути к подходу с заголовками (версия отдельно от основного пути), но проще для отладки, так как видна прямо в URL.

Минусы:

  • Неявность. Клиент должен помнить о добавлении параметра; без документации не очевидно, что нужно дописывать ?version=….
  • Стандартность. В REST не принято, чтобы кардинально разные представления ресурса отличались только query-параметром. Семантически, ресурс /api/items с параметром и без – это как бы один и тот же URL, хотя реально возвращаются разные структуры. Это может путать и разработчиков, и систему кеширования.
  • Кэширование и прокси. Хотя HTTP-кэши различают URL с разными query, некоторые промежуточные прокси или инструменты мониторинга могут не учитывать параметр версии, если он нестандартен. Требуется явная настройка, чтобы прокси vary-кешировал по этому параметру. – Немного менее удобен для потребителей, чем версионирование в пути: например, в Swagger UI или Postman можно разделять по URL, а параметр – менее заметен.

Когда использовать: Параметр версии иногда используют для внутренних или приватных API, либо для экспериментального режима. В открытых API этот способ встречается реже. Он может быть удобен, если хочется иметь единый endpoint, но с разными ответами в зависимости от параметра (например, ?v=1 возвращает укороченную версию данных, ?v=2 – расширенную). Однако чаще такой подход считается менее предпочтительным, чем путь или заголовки.

Версионирование через HTTP-заголовки

Здесь версия передается не в URL, а в HTTP-заголовке запроса. Существует два основных варианта: пользовательский заголовок (Custom Header) или использование заголовка Accept в рамках механизма Content Negotiation. Оба подхода вынесены из URL – версия передается метаданными запроса.

Custom Header (например, X-API-Version). В этом случае клиент добавляет специальный заголовок, например: X-API-Version: 2. На стороне Spring Boot можно настроить контроллеры, которые мапятся на разные версии в зависимости от значения заголовка:

@RestController
@RequestMapping("/api")
public class ItemsController {
    @GetMapping(value = "/items", headers = "X-API-Version=1")
    public List<ItemDtoV1> getItemsV1() { ... }

    @GetMapping(value = "/items", headers = "X-API-Version=2")
    public List<ItemDtoV2> getItemsV2() { ... }
}

Теперь, если клиент сделает запрос GET /api/items с заголовком X-API-Version: 1, он получит результат от метода getItemsV1(), а с заголовком 2 – от getItemsV2(). В URL больше нет указания версии, что делает маршруты “чище”.

Accept Header (Content Negotiation). Здесь используется стандартный заголовок Accept с нестандартным MIME-типом. Идея в том, что разные версии ресурса отличаются медиа-типом возвращаемых данных. Например, клиент посылает:

Accept: application/some.company.app-v1+json

а сервер вернет контент с Content-Type: application/some.company.app-v1+json. Для версии 2 – аналогично v2 в типе. В Spring Boot можно разграничить методы по медиа-типам, используя атрибут produces или consumes в аннотациях контроллера:

@RestController
@RequestMapping("/api")
public class ItemsController {
    @GetMapping(value = "/items", produces = "application/some.myapp.v1+json")
    public List<ItemDtoV1> getItemsV1() { ... }

    @GetMapping(value = "/items", produces = "application/some.myapp.v2+json")
    public List<ItemDtoV2> getItemsV2() { ... }
}

В этом случае выбор метода происходит на основе заголовка Accept в запросе. Клиент явно запрашивает нужную версию через MIME-тип. Обратите внимание: чтобы Content Negotiation работал, у контроллера должны быть заданы корректные produces (или consumes для различия форматов запроса) и клиент должен отправлять Accept заголовок.

Плюсы заголовков (общие):

  • Чистый URL. Версия убрана из пути, URL отражает ресурс, а версия – это скорее параметр транспорта. С точки зрения архитектуры REST, это ближе к идеологии HATEOAS: URI не должен меняться, меняется только представление ресурса.
  • Гибкость версионирования. Можно версионировать выборочно. Например, для одного ресурса начать обрабатывать заголовок, а остальные оставить без изменений. Хотя это спорная практика, технически заголовки позволяют в разных ресурсах находиться на разных версиях, не требуя повышать версию всего API целиком.
  • HTTP-кеширование. Если версия передается в заголовке (будь то X-API-Version или Accept), то разные версии ресурса по-прежнему имеют одинаковый URL. Чтобы прокси-кеш корректно различал версии, серверу следует посылать заголовок Vary, указывающий зависимость от соответствующего заголовка (например, Vary: X-API-Version или Vary: Accept). С Accept это стандартная часть контент-негоциации, и многие кеши по умолчанию учитывают Accept. Таким образом, при правильной настройке, можно эффективно кэшировать и версионированные ответы без дублирования URL.
  • Сокрытие версии. В некоторых случаях продуктовые владельцы не хотят афишировать количество версий API в публичных адресах. Заголовки позволяют “спрятать” версию от конечного пользователя (но, конечно, разработчик клиента должен знать про заголовок).

Минусы заголовков:

  • Сложнее тестировать вручную. Без специальных инструментов (Swagger UI, Postman, curl) труднее получить нужную версию – например, браузер по прямому URL всегда получит дефолтную версию (т.к. не посылает пользовательский заголовок). Однако для API, нацеленных на программных клиентов, это обычно не проблема.
  • Неочевидность для новичков. Разработчики, незнакомые с API, могут не сразу понять, почему у них всегда ответ версии 1 – ведь URL одинаковый. Требуется хорошая документация: указать, какой заголовок и как использовать.
  • Content Negotiation усложняет контракт. Вариант с Accept заголовком подразумевает регистрацию кастомных MIME-типов (some.vendor.vX+json). Это формально соответствует REST принципам, но добавляет overhead: необходимо явно версионировать медиа типы и поддерживать их на сервере. Многие разработчики и инструменты привыкли к application/json, поэтому vendor-тип может вызвать вопросы (хотя Swagger и прочие поддерживают).
  • Поддержка на стороне клиентов. Если API доступен разным типам клиентов, некоторые из них (например, старые библиотечные REST клиенты) могут не поддерживать установку произвольных заголовков или Accept типов. В современном мире это редко, но учитывать стоит.

Когда использовать: Заголовки подходят для внутренних API и микросервисов, где взаимодействие происходит программно, и хочется сохранить единый путь ресурса. Content Negotiation (Accept) часто рекомендуется энтузиастами REST как наиболее “правильный” способ, ведь URI остается постоянным, а изменяется лишь представление ресурса. Однако на практике его сложнее внедрить и объяснить потребителям. Custom header (X-API-Version) – более простой вариант: он не требует придумывать новые MIME-типы и не конфликтует с механизмом Accept. Его минус – не является стандартом, это просто договоренность между клиентом и сервером. Тем не менее, custom-заголовки достаточно популярны, например Accept-Version или X-Version (некоторые API используют Accept: application/json;version=2 – комбинация content negotiation и параметра).

Примечание: По состоянию на Spring Boot 2.x/3.x, фреймворк не предоставляет встроенного механизма версионирования – разработчик реализует его самостоятельно приведенными способами. Однако в новой версии Spring MVC (Spring Framework 6+) появилась экспериментальная поддержка версионирования на уровне фреймворка, упрощающая этот процесс (в Spring Boot 4.0). В данном руководстве мы рассматриваем подходы, применимые в актуальных версиях Spring Boot 3 и ниже.

Реализация версионирования в Spring Boot (контроллеры, DTO, сервисы)

Структура контроллеров. Хорошая практика – разделять код разных версий. Самый понятный способ: выделить отдельные классы контроллеров для каждой версии. Например, пакет com.example.api.v1 содержит контроллеры версии 1, а com.example.api.v2 – версии 2. Внутри них можно прописать соответствующий префикс пути или условия. Для URI-версий можно использовать класс-уровень @RequestMapping(“/api/v1”) для контроллера версии 1, и аналогично “/api/v2” для версии 2. Тогда все методы внутри будут относиться к своему versioned-пути. Либо, как альтернативу, можно в одном контроллере объявлять несколько методов с разными версиями (как показано ранее) – но при большом количестве методов это менее удобно в поддержке. Разделение по классам повышает читаемость и тестируемость.

Например, реализация с разделением контроллеров:

@RestController
@RequestMapping("/api/v1/items")
public class ItemsControllerV1 {
    @GetMapping
    public List<ItemDtoV1> listItems() { ... }

    @PostMapping
    public ItemDtoV1 createItem(@RequestBody ItemDtoV1 newItem) { ... }
    // ... другие методы v1
}

@RestController
@RequestMapping("/api/v2/items")
public class ItemsControllerV2 {
    @GetMapping
    public List<ItemDtoV2> listItems() { ... }

    @PostMapping
    public ItemDtoV2 createItem(@RequestBody ItemDtoV2 newItem) { ... }
    // ... обновленные методы v2
}

При использовании query-параметра или заголовков структура может быть иной: путь у контроллеров остается общим (/api/items), но внутри одного класса можно иметь два метода, различаемых по параметру или заголовку (как в примерах выше для @GetMapping(params=”version=…”) или headers=”X-API-Version=…”). Можно и здесь разделить классы (например, ItemsControllerV1 и ItemsControllerV2), но тогда нужно позаботиться, чтобы они маппились на один и тот же путь, различаясь только условием. Spring MVC не позволит иметь два разных контроллера на один и тот же URL без уточнения условий, поэтому придется использовать @RequestMapping(headers=”…”) или params=”…” на уровне класса или методов. Некоторые предпочитают единый контроллер с несколькими методами – для небольших различий это удобно, но если версии сильно расходятся, лучше разнести.

DTO и модель данных. Версионирование почти всегда сопровождается изменениями в формате данных. Рекомендуется вводить отдельные классы DTO для каждой версии, чтобы четко выделять, какие поля в какой версии доступны. Например, у нас есть ItemDtoV1 и ItemDtoV2. В версии 2 могли добавиться новые поля или измениться существующие типы данных. Разделение DTO позволяет избежать ситуаций, когда одно поле пытается служить двум целям. В примере из практики: версия 1 возвращала поле birthDate (дата рождения), а версия 2 вместо него возвращает поле age (возраст), рассчитанный на сервере. В этом случае, можно создать PersonV1 DTO с birthDate, и PersonV2 DTO с age. Контроллер версии 2 может пользоваться сервисом версии 1, но преобразовывать модель к новому DTO.

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

  • Если изменения только в представлении данных (DTO), а сами операции те же, можно использовать единый сервис. Например, сервис возвращает сущность или базовый DTO, а контроллеры версии 1 и 2 самостоятельно преобразуют в нужный объект для ответа. В примере с birthDate vs age, сервис мог вернуть объект Person с датой рождения, а контроллер v2 обернет результат в DTO, где рассчитает age. Для таких преобразований часто пишут мапперы (например, MapStruct, либо вручную).
  • Если же изменения в бизнес-логике (например, новый параметр меняет поведение метода), можно либо:
    • а) добавить в сервис новый метод для версии 2 (например, savePersonV2()), оставив старый для v1,
    • б) либо усложнить существующий метод проверкой версии (плохая практика, ведет к if(version) логике).
    • в) или, при радикальных расходящихся логиках, вести два сервиса: PersonServiceV1 и PersonServiceV2. Это крайний случай, обычно лучше композиция/наследование, чтобы избежать копипаста бизнес-кода.

Глобальные обработчики (@ControllerAdvice). Spring позволяет создавать классы с аннотацией @ControllerAdvice для обработки исключений, приведения типов и т.п. Их можно ограничивать областью действия (например, @ControllerAdvice(basePackages=”…v1″) только для контроллеров версии 1). Это пригодится, если, скажем, в версии 2 вы изменили формат ошибки или обертку ответа. Вместо дублирования логики обработки исключений, можно завести два ControllerAdvice – один применяется к контроллерам v1 и формирует старый формат ошибки, второй – к v2 с новым форматом. Другой пример: можно через Advice автоматически проставлять заголовок в ответе с указанием версии API. Однако обычно эту задачу решают на уровне Gateway или вручную. Тем не менее, знать про @ControllerAdvice полезно – он позволяет разделить кросс-секционные аспекты (например, логирование, обработку ошибок, валидацию) для разных версий, если требования к ним отличаются.

Пример реализации: Представим, что в API версий 1.0 и 1.1 некая операция PUT /person сменилась на PUT /person/{id} в версии 1.2 (т.е. URI изменился), а также в 1.2 заменено поле birthDate на age. В Spring Boot можно реализовать оба варианта контроллера в одном приложении: методы версии 1.0/1.1 пометить @Deprecated (чтобы сигнализировать, что они устарели), но не удалять их, пока поддержка версии продолжается. Методы версии 1.2 будут работать параллельно. Таким образом, старая логика остается доступна для старых клиентов, но в документации и коде помечена, что будет удалена. Ниже фрагмент контроллера из реального проекта, демонстрирующий несколько версий в одном классе:

@RestController
@RequestMapping("/person")
public class PersonController {
    // Добавление нового объекта - одинаково для v1.0 и v1.1
    @PostMapping({"/v1.0", "/v1.1"})
    public PersonOld add(@RequestBody PersonOld person) {
        return repository.add(person);
    }

    // Добавление для v1.2 - принимает/возвращает новый DTO PersonCurrent
    @PostMapping("/v1.2")
    public PersonCurrent add(@RequestBody PersonCurrent person) {
        return mapper.map((PersonOld) repository.add(person));
    }

    // Обновление v1.0 - устаревший способ без {id}
    @PutMapping("/v1.0")
    @Deprecated
    public PersonOld update(@RequestBody PersonOld person) {
        return repository.update(person);
    }

    // Обновление v1.1 - новый путь с {id}, но старый DTO
    @PutMapping("/v1.1/{id}")
    public PersonOld update(@PathVariable Long id, @RequestBody PersonOld person) {
        return repository.update(person);
    }

    // Обновление v1.2 - новый путь + новый DTO (возраст вместо даты рождения)
    @PutMapping("/v1.2/{id}")
    public PersonCurrent update(@PathVariable Long id, @RequestBody PersonCurrent person) {
        return mapper.map((PersonOld) repository.update(person));
    }
    // ... другие методы (GET, DELETE) аналогично
}

В этом коде мы видим, что с ростом версий часть методов дублируется. Это допустимо, особенно если изменений немного. Пометка @Deprecated на методе версии 1.0 подсказывает потребителям (через Swagger документацию, см. ниже), что метод устарел и будет удален.

Подводя итог, стратегия реализации в Spring Boot сводится к комбинации: отдельные контроллеры или методы под каждую версию, версионные DTO для различий в данных и общие сервисы там, где бизнес-логика не изменилась. Такой дизайн позволяет минимизировать дублирование кода и изолировать различия между версиями.

Интеграция Spring Cloud Gateway для маршрутизации версий API

В микросервисной архитектуре часто используется API Gateway для единой точки входа. Spring Cloud Gateway (SCG) – это реактивный шлюз от Spring, который может маршрутизировать запросы на разные бэкенды или эндпоинты на основе различных предикатов (условий). Его можно настроить так, чтобы он учитывал версию API при проксировании запросов.

Маршрутизация по пути. Если версия включена в URL (например, /v1/ и /v2/), то настроить gateway довольно просто: разные маршруты по Path предикату. Например, в application.yml можно прописать:

spring:
  cloud:
    gateway:
      routes:
      - id: payment-service-v1
        uri: lb://payment-service-v1      # либо конкретный URL/host
        predicates:
          - Path=/api/v1/payments/**
      - id: payment-service-v2
        uri: lb://payment-service-v2
        predicates:
          - Path=/api/v2/payments/**

В этом сценарии предполагается, что у нас есть два развернутых сервиса (или один сервис, но с различающимися путями). Gateway по префиксу пути направит запрос либо на v1, либо на v2 сервис (здесь lb:// указывает на использование сервиса в Service Discovery (Eureka) с именем payment-service-v1 и payment-service-v2, либо можно маршрутизировать по разным контекстам одного сервиса). Такой подход очевиден, но требует либо два сервиса, либо единый сервис, способный обрабатывать оба пути.

Маршрутизация по заголовку. Интересный подход – скрыть версию от внешнего URL и использовать заголовок. Spring Cloud Gateway поддерживает предикат Header для маршрутов. Например, можно принять, что внешние запросы идут на /api/payments/** без версии в пути, но клиент должен слать X-API-Version: 2 для новой версии. Gateway-конфигурация может быть такой:

spring.cloud.gateway.routes.id=payment-service-v1
spring.cloud.gateway.routes.uri=lb://payment-service
spring.cloud.gateway.routes.predicates=Path=/api/payments/**
spring.cloud.gateway.routes.predicates=Header=X-API-Version, 1
spring.cloud.gateway.routes.filters=RewritePath=/api/payments/(?<segment>.*), /api/v1/payments/${segment}
spring.cloud.gateway.routes.filters=AddResponseHeader=X-API-Version, 1

spring.cloud.gateway.routes.id=payment-service-v2
spring.cloud.gateway.routes.uri=lb://payment-service
spring.cloud.gateway.routes.predicates=Path=/api/payments/**
spring.cloud.gateway.routes.predicates=Header=X-API-Version, 2
spring.cloud.gateway.routes.filters=RewritePath=/api/payments/(?<segment>.*), /api/v2/payments/${segment}
spring.cloud.gateway.routes.filters=AddResponseHeader=X-API-Version, 2

# Маршрут по умолчанию (если заголовок не указан -> слать на v1)
spring.cloud.gateway.routes.id=payment-service-default
spring.cloud.gateway.routes.uri=lb://payment-service
spring.cloud.gateway.routes.predicates=Path=/api/payments/**
spring.cloud.gateway.routes.filters=RewritePath=/api/payments/(?<segment>.*), /api/v1/payments/${segment}
spring.cloud.gateway.routes.filters=AddResponseHeader=X-API-Version, 1

Что тут происходит:

  • Для v1-маршрута: условие Path /api/payments/** и заголовок X-API-Version: 1. Фильтр RewritePath переписывает внешний путь (без версии) во внутренний – добавляет /v1/ сегмент перед payments. То есть, Gateway вызовет бэкенд по пути /api/v1/payments/…. А фильтр AddResponseHeader добавит к ответу заголовок X-API-Version: 1 (удобно, чтобы клиент знал, какая версия обслужила запрос).
  • Для v2-маршрута: аналогично, но для заголовка со значением 2 и переписываем на /api/v2/payments/….
  • Маршрут по умолчанию: без требования заголовка, все запросы на /api/payments/** без версии или с любым другим значением попадут сюда. Он по умолчанию направляет на v1 (добавляя /v1/). Это обеспечивает backward compatibility: старые клиенты, не знающие про версионный заголовок, автоматически идут на v1.

Таким образом, Gateway выполняет версионирование на уровне маршрутизации: внешний API кажется единым (/api/payments), а версия управляется заголовком. Внутри же может быть один сервис, обслуживающий оба пути (/v1/payments и /v2/payments). Либо RewritePath может перенаправлять на два разных сервиса, если указать разные uri. Например, uri=lb://payments-v1-service и для v2 другой. Predicates и Filters в SCG очень гибкие – можно проверять не только Header, но и, теоретически, содержимое тела (но это тяжело, лучше ограничиться заголовками или путями).

Преимущества Gateway-подхода:

  • Можно изолировать версии на уровне инфраструктуры. Например, развернуть v2 как отдельный сервис со своим CI/CD, а Gateway возьмет на себя мультиплексирование.
  • Gateway может добавлять унифицированные заголовки (как в примере), логировать или применять общие фильтры (авторизация, ограничение скорости) независимо для каждой версии.
  • Можно реализовать механизм канареечного релиза: например, если X-API-Version не указан, процент запросов отправлять на новую версию (для тестирования на небольшом трафике), а остальное – на старую. Хотя это более сложная логика, SCG теоретически позволяет пользовательские фильтры.

Пример практического сценария: приведенная конфигурация демонстрирует, как провайдер здравоохранения маршрутизирует API пациентов: старые системы получают ответы в формате v1, новые – с расширениями v2, просто задавая нужный заголовок. Такой подход облегчает постепенное обновление: компонентам, готовым к v2, достаточно начать слать заголовок 2, остальные же продолжат работать без изменений (попадая на маршрут по умолчанию, v1).

Заключение по Gateway: Если у вас микросервисная система, Spring Cloud Gateway или любой API Gateway – почти необходимый элемент для управления версиями на уровне маршрутизации. Он снимает часть логики с самих сервисов (например, может выполнять переписывание путей или агрегировать документацию разных версий). Важно помнить, что при версионировании через заголовки и Gateway документация и девелоперский опыт должны четко объяснять, как запрашивать нужную версию (т.к. URL одинаковый, а версия управляется неочевидно).

Документирование API (OpenAPI/Swagger) для нескольких версий

Документация – критичная часть версии API. Когда есть более одной версии, важно донести до потребителей, какие эндпоинты и форматы доступны в каждой. Современные Spring Boot проекты, как правило, используют OpenAPI (Swagger) для генерации интерактивной документации. Возникает вопрос: как описать сразу несколько версий? Есть два подхода:

  • Отдельная спецификация для каждой версии. Например, /v3/api-docs/v1 для версии 1 и /v3/api-docs/v2 для версии 2, и возможность переключаться между ними в Swagger UI.
  • Единая спецификация, включающая все версии. Например, в одном Swagger UI отражены сразу все варианты, возможно с пометками “v1” и “v2” на каждом эндпоинте. Этот вариант менее распространен, так как OpenAPI спецификация обычно предполагает один вариант каждого пути. Поэтому лучше генерировать раздельные документации.

Springfox Swagger2 (старый подход). Для Spring Boot 2 был популярен Springfox. Начиная с версии 2.8.0 Springfox поддерживал группировку документации. Можно было регистрировать несколько Docket (бин Swagger-конфигурации) – по одному на каждую версию. При создании Docket указывается уникальное имя группы (например, “person-api-1.0”) и настраивается отбор API, которые в него входят – обычно через предикат по пути или пакету контроллеров:

@Bean
public Docket apiV1(){
    return new Docket(DocumentationType.SWAGGER_2)
        .groupName("person-api-1.0")
        .select()
            .apis(RequestHandlerSelectors.basePackage("com.example.api"))
            .paths(regex("/person/v1.0.*"))
        .build()
        .apiInfo(new ApiInfoBuilder()
            .version("1.0")
            .title("Person API")
            .description("Documentation Person API v1.0")
            .build());
}
@Bean
public Docket apiV2(){
    return new Docket(DocumentationType.SWAGGER_2)
        .groupName("person-api-1.2")
        .select()
            .paths(regex("/person/v1.2.*"))
        .build()
        .apiInfo(new ApiInfoBuilder()
            .version("1.2")
            .title("Person API")
            .description("Documentation Person API v1.2")
            .build());
}

Здесь два бина генерируют две документации: для путей, начинающихся на /person/v1.0 и на /person/v1.2 соответственно. В Swagger UI появляется выпадающий список (dropdown) для выбора нужной группы (названия групп указываются как “person-api-1.0”, “person-api-1.2”). Выбрав группу, разработчик видит только эндпоинты этой версии, с соответствующими моделями DTO. На странице можно отметить и deprecated методы (Springfox перечеркивал их). Такой подход удобен: одна Swagger UI страница обслуживает несколько версий, и можно переключаться, просматривая разницы.

Springdoc OpenAPI 3 (актуальный подход). В новых приложениях (Spring Boot 3+) вместо Springfox обычно применяют springdoc-openapi. Он тоже поддерживает группировку. Концепция аналогична: регистрируются несколько бинов типа GroupedOpenApi, указывающих, какие пакеты или пути относятся к определенной группе (спецификации):

@Configuration
public class OpenApiConfig {
    @Bean
    public GroupedOpenApi apiV1() {
        return GroupedOpenApi.builder()
            .group("v1")
            .packagesToScan("com.example.api.v1")    // все контроллеры версии 1
            .build();
    }
    @Bean
    public GroupedOpenApi apiV2() {
        return GroupedOpenApi.builder()
            .group("v2")
            .packagesToScan("com.example.api.v2")
            .build();
    }
}

В этом примере группы определены по пакетам контроллеров версии 1 и 2. Springdoc генерирует несколько JSON по разным URL: например, /v3/api-docs/v1 и /v3/api-docs/v2. Swagger UI при этом по умолчанию позволяет переключать Definition – будет выпадающий список с вариантами v1 и v2. Выбрав версию, пользователь видит спецификацию именно для нее, со своими схемами моделей (что важно, т.к. ItemDtoV1 и ItemDtoV2 могут иметь одинаковые названия классов в разных пакетах, но OpenAPI должен их различать – разделение на несколько спецификаций решает эту проблему).

Обновление документации: При выпуске новой версии, нужно не забыть добавить новую группу/докет. Документация каждой версии должна содержать историю изменений: что нового, что удалено по сравнению с предыдущей. Хорошей практикой является наличие в Swagger/OpenAPI раздела описания, где перечислены изменения (можно в description Info-секции указать, что за изменения). Также, если какие-то старые методы устарели, их можно пометить @Deprecated – Springdoc отражает это, помечая методы как deprecated в UI.

Версионирование и API Gateway. Если у вас в Gateway агрегируются несколько микросервисов или версий, можно настроить централизованную документацию. Например, Gateway сам экспонирует Swagger UI, который тянет спецификации с разных бэкендов. В Springfox это делалось с помощью swagger-resources и SwaggerAggregation. В Springdoc можно просто вручную собрать YAML/JSON спецификации или сделать прокси на /v3/api-docs/v1 разных сервисов. Этот вопрос выходит за рамки статьи, но главное – документация должна быть доступна для каждой поддерживаемой версии API.

Вывод: Используйте возможности Swagger/OpenAPI для управления версиями: группируйте спецификации по версиям, давая пользователям ясный выбор. Так разработчики легко найдут нужные эндпоинты именно той версии, с которой они работают. Указывайте версии и статусы (актуально/устарело) прямо в спецификации. Генерировать документацию вручную для каждой версии нецелесообразно – лучше автоматизировать через фреймворк, как показано выше.

Поддержка версий на фронтенде (клиентская сторона)

Версионирование API влияет не только на серверную реализацию, но и на то, как клиенты (фронтенд-приложения, мобильные приложения, внешние потребители) взаимодействуют с API. Рассмотрим несколько моментов:

Выбор версии при запросе. Клиент должен явно указать, какую версию API он хочет использовать. В случае версионирования через URL это очевидно – использовать нужный префикс (/v2/). Обычно базовый URL для фронтенда включает версию: например, при инициализации API-клиента в приложении можно менять строку базового адреса с https://api.example.com/v1/ на …/v2/. Если версия передается через заголовок или параметр, то клиентский код должен добавить этот параметр ко всем запросам. Например, при использовании Axios или Fetch в JavaScript – настроить глобальный заголовок X-API-Version: 2 для всех запросов, или вызывать специальным методом API-клиента, принимающим параметр версии.

Совместимость и стратегия обновления. Главный принцип – клиент, написанный под версию X, должен работать, пока версия X поддерживается на сервере. То есть, если backend вводит версию 2, он не должен сразу ломать вызовы версии 1. Поддержка нескольких версий означает, что фронтенд может быть обновлен не мгновенно, а по своему графику. Например, мобильные приложения: пользователи могут не обновиться сразу, поэтому сервер может полгода-год поддерживать v1, обслуживая старые версии приложения, а новые выпуски приложения уже переходят на v2.

Выбор версии по умолчанию. Некоторые API делают версию по умолчанию (например, если не указан заголовок или параметр – считать, что v1). Это упрощает жизнь старым клиентам. Однако, рекомендуется всегда явно указывать версию на клиенте. Это дисциплинирует и исключает неоднозначность. Например, в приведенном ранее примере с Gateway, запросы без заголовка шли на версию 1 по умолчанию. Это хорошо для бэка (не ломает старых), но для нового клиента лучше явно поставить X-API-Version: 1, чтобы поведение не зависело от настроек по умолчанию.

Обработка изменений на фронте. Когда выходит новая версия API, фронтенд-команда должна:

  • Ознакомиться с документами по новой версии (чаще всего, сравнить Swagger для старой и новой версии).
  • Внести изменения в код запросов и обработки ответов: может, поменялись URL, структура JSON, названия полей.
  • Протестировать работу с новой версией на тестовом окружении.
  • Сохранить обратную совместимость на уровне UX (при необходимости). Например, если какая-то фича недоступна в старой версии API, но пользователь имеет старое приложение, можно либо запретить использование этой фичи, либо отобразить уведомление об обновлении. В общем, продукты должны планировать выпуски так, чтобы функциональность приложения синхронизировалась с возможностями API.

Версионирование и SDK. Если для вашего API существуют клиентские библиотеки (SDK) на разных языках, то обычно версия API отражается и в версии SDK. Например, пакет example-api-client версии 1.x соответствует API v1, а версия 2.x – API v2. Это не строгий закон, но упрощает понимание. Фронтенд-разработчикам стоит обновлять SDK вместе с переходом на новую версию API.

Пример: браузерное приложение. Допустим, у вас SPA (React/Vue/Angular). Вы можете хранить текущую версию API в конфигурации. Если вы планируете плавный переход, можно реализовать поддержку сразу двух версий: часть вызовов перейти на v2, а часть временно оставлять на v1, если на сервере v2 еще не реализовал все возможности. Однако, лучше избегать сложных смешанных режимов, кроме разве что тестирования. В продакшене чаще всего клиент полностью работает с одной версией API в данный момент времени.

Обратная совместимость и полифилы. Бывает, что новая версия API убирает какие-то данные. Старое приложение может их запрашивать. Одно из решений – на стороне клиента предусмотреть fallback: если поле отсутствует (в ответе v2), использовать какое-то значение по умолчанию или скрывать функциональность. Но более правильно – на стороне сервера не убирать резко данные без изменения версии. В конце концов, именно для того и делается новая версия, чтобы старая продолжала давать старые поля. Так что подобные “полифилы” на фронте – скорее исключение.

Рекомендация: В явном виде показывать пользователю версии API не нужно (это техническая деталь), но разработчики клиента должны четко знать, на какой версии они работают. Документация, как уже говорилось, должна быть доступна для каждой версии. Также хорошо, когда сервер предоставляет некий ендпоинт версионности, например, /api/status или headers, где можно узнать текущую версию API, дату устаревания и т.п. Например, некоторые API возвращают заголовок X-API-Version: 1 в каждом ответе, как мы делали на Gateway, или даже Deprecation: true если версия объявлена устаревшей. Это позволяет клиентскому коду обнаружить, что он на устаревшей версии, и, например, залогировать предупреждение. В mission-critical сценариях клиент может автоматически переключаться на новую версию, но в общем случае лучше, чтобы человек внес изменения сознательно.

Деплоймент и версионирование в CI/CD

Поддержка нескольких версий API затрагивает и процессы непрерывной интеграции/развертывания:

Стратегия управления кодом (VCS). Когда API v2 значительно отличается, имеет смысл вести разработку в отдельной ветке или репозитории. Одна из практик – ветка на каждую мажорную версию. Например, основная ветка main – актуальная версия (v2), а ветка v1-maintenance содержит код старой версии, куда попадают только багфиксы, но не новые фичи. При выпуске изменений, эти ветки выпускаются раздельно (например, тэги v1.3.5 для патч-версии старого API и v2.0.1 для нового). Таким образом, репозиторий управляет разными версиями параллельно. Однако, если у вас монолит, поддерживающий обе версии сразу, то код может жить вместе, и ветки нужны только если вы прям отделяете разработчиков или фичи. В микросервисах чаще версия = отдельный сервис, тогда и репозитории могут быть разные.

Версионирование артефактов. Если ваше приложение деплоится как Docker-образ или jar, используйте версионирование в именах. Например, myapi-v1:1.5.2 и myapi-v2:2.1.0 как тэги образов. В Kubernetes можно развернуть оба образа одновременно (разные Deployment), а Traffic Management (тот же Gateway/Istio) будет рулить трафиком.

CI/CD конвейер. Настройте pipeline, который при изменениях в коде прогоняет все тесты для всех поддерживаемых версий. Нельзя допустить, чтобы релиз v2 поломал совместимость v1, если они в одном коде. Поэтому в пайплайне должны запускаться юнит и интеграционные тесты для v1 и для v2 интерфейсов. Например, иметь две группы тестов с профилями: -Dapi.version=1 и -Dapi.version=2, либо явно вызывать эндпоинты v1 и проверять, что они по-прежнему работают. Автоматизация в CI позволит поймать проблему сразу, а не после выката.

Контрактное тестирование. Хорошей практикой является Consumer-Driven Contract Testing (напр., Pact) для API. Если у вас есть внешние потребители, можно зафиксировать их ожидания контрактами. Тогда при выпуске новой версии (или изменения старой) вы прогоняете тесты контрактов всех версий. Это особенно полезно, если эволюционируете API без явного повышения версии (плавные изменения). Для явных версий это тоже полезно: контракты гарантируют, что v1 все еще соответствует обещанному.

Развертывание (Deployment) нескольких версий. Есть несколько вариантов:

  • Единое приложение, поддерживающее все версии. Тогда деплоится всегда одна версия приложения (например, вы задеплоили новую версию кода, которая умеет и v1, и v2). Тут важно убедиться, что при деплое не нарушена работа старых API (опять же, тесты). Деплой можно делать стандартно, возможно с zero-downtime практиками (blue-green, rolling update).
  • Раздельные приложения. Например, v1 – отдельный сервис (старый код), v2 – новый сервис. Тогда при выпуске v2 вы деплоите дополнительный сервис, настроив Gateway как описано ранее. Старый сервис продолжает работать. Через некоторое время, когда v1 больше не нужен, вы просто удаляете деплой v1. Этот вариант потребляет больше ресурсов (нужно держать два набора инфраструктуры), но обеспечивает лучшую изоляцию. Многие крупные компании идут именно по пути запуска параллельных версий сервисов.
  • Флаг переключения (toggle). Можно ли без запуска второго сервиса плавно переключить поведение? Теоретически да, с feature flags. Например, вы разворачиваете код, который умеет и по-старому, и по-новому, но новый код выключен флагом. Затем для части пользователей (или по запросу с определенным маркером) включаете новую логику. Однако, feature toggles сложно применять для полностью разных контрактов – они хороши для постепенной эволюции, но в конце все равно обычно фиксируется новая версия API.

Тестирование производительности и безопасности. Не забывайте, что нужно проверять не только функциональность. Новая версия не должна быть медленнее в 10 раз или открывать дыры. В CI/CD можно добавить нагрузочное тестирование для основных методов, сравнивая v1 и v2 (как минимум, чтобы убедиться, что v2 в пределах нормы). Также, тесты безопасности (попробовать атаки, авторизацию) должны выполняться для всех версий.

Версионирование и GitOps. Если вы применяете GitOps, то репозиторий описания инфраструктуры может содержать параллельно манифесты для разных версий. Например, папка overlays/prod/v1 и overlays/prod/v2 с соответствующими Deployment. Это удобно, т.к. версионность сервиса явно отражена в конфигурации.

Семантическое версионирование API. Как и код, API стоит версионировать по семантическим правилам: мажорная версия – ломающее изменение, минорная – добавление функционала (backward-compatible), патч – мелкие изменения/фиксы. Например, API v2.1 vs v2.2 могут отличаться добавлением нового поля, но старые клиенты v2.1 это переживут (просто не будут использовать новое поле). В документации можно отмечать минорные релизы API, но обычно URL и заголовки содержат только мажор (v2). Минорные лучше отслеживать через заголовок API-Revision или в документации. Некоторые компании версионируют API по дате (Stripe, Twitter: 2023-11-01), но это за рамками обсуждения.

В общем, CI/CD пайплайн должен быть настроен так, чтобы обе версии проходили все проверки перед релизом, и должен обеспечивать возможность катить назад отдельно каждую версию, если вдруг обнаружится проблема.

Лучшие практики версионирования API

Наконец, обобщим ряд рекомендаций и best practices при работе с версиями:

  • Избегайте частых мажорных версий. Старайтесь по возможности вносить изменения так, чтобы не ломать существующий контракт (например, добавлять новые поля вместо изменения старых). Увеличение версии – это дорого для всех сторон. Цель – минимизировать количество мажорных версий. Идеально, если API может долго развиваться в рамках одной версии, повышая минорный номер для новых возможностей, но сохраняя обратную совместимость.
  • Продуманно планируйте API изначально. Многие ломающие изменения можно предотвратить дизайном: закладывайте расширяемость. Например, не фиксируйте списки значений жестко, предусмотрите, что их станет больше; документируйте, что клиент должен игнорировать незнакомые поля. Это позволит выпускать новые поля и функции без версионирования.
  • Официально объявляйте устаревание (deprecation). Когда решено убрать старую версию, заранее уведомите клиентов. Объявление о депрекейте обычно делается за несколько месяцев, а то и год. Способы уведомления:
  • Разместить предупреждение в документации (пометить версию как deprecated, указать дату отключения).
  • Отправить рассылку или сообщение партнерам (если у вас открыт API для интеграций).
  • Использовать технические средства: например, посылать в ответе заголовок Warning: 299 – “Deprecated API, use v2…” или новые стандартизованные заголовки Deprecation и Sunset. По RFC 8594, Sunset-заголовок может указывать дату, когда API будет отключен. Это помогает клиентам автоматически логировать предупреждения.
  • Ограничивайте поддерживаемые версии. Нет смысла поддерживать десять старых версий. Обычно достаточно две: текущая и предыдущая (n и n-1). Иногда, если клиенты очень медленно мигрируют, держат и 3 версии, но это нагрузка. Оптимально: когда выпускается v3, объявляется устаревание v1. То есть, дается время на переход с v1 на v2 или сразу на v3, после чего v1 выключается. Такая политика жизненного цикла должна быть частью стратегии API.
  • Четкая коммуникация с потребителями. Помимо документации Swagger, стоит готовить заметки по релизу (release notes) для API: что изменилось, что нового в версии. Желательно писать гайды по миграции: “в версии 2 формат адреса изменен, вот пример запроса v1 и эквивалент v2”. Чем легче вы сделаете жизнь интеграторов, тем быстрее они обновятся. Также не пренебрегайте каналами оповещения: email, сообщества разработчиков, блоги – расскажите о новой версии, ее преимуществах и необходимости обновления.
  • Мониторинг использования версий. Внедрите метрики или логи, чтобы видеть, какой процент запросов приходится на каждую версию. Это поможет принять решение, когда выключать старую версию – например, если видите, что 90% клиентов уже на v2, можно агрессивнее добиваться миграции оставшихся 10%. Если же половина трафика все еще на v1, рано его выключать. Инструменты API Management (Apigee, AWS API Gateway и т.д.) часто из коробки показывают распределение по версиям.
  • Единообразие версионирования. Выберите один способ (URL, заголовок или параметр) и придерживайтесь его в рамках организации. Комбинация разных схем может запутать. Например, если один сервис требует Accept-Version, а другой – в пути, интеграторам сложнее. Унификация здесь повышает DX (developer experience).
  • Версионирование и авторизация. Если API защищен OAuth2 или другими токенами, удостоверьтесь, что новые версии правильно работают с старыми токенами. Обычно проблем нет, но бывают случаи, когда scope токена связан с версией (например, разные разрешения на разные версии). Проверяйте совместимость.
  • Тестирование миграции. Имитация миграции клиента: напишите интеграционный тест, который дергает старый эндпоинт и новый, сравнивает результаты (для пересекающейся функциональности). Это покажет, отличаются ли ответы только тем, чем должны, и даст уверенность, что v2 действительно покрывает все, что было в v1 (если планируется замена).
  • Документация устаревших версий. Пока версия поддерживается, ее документация должна быть доступна. Даже если она deprecated, не убирайте ее описание – кому-то может понадобиться. Но можно явно пометить на Swagger UI (например, в описании Info написать “Deprecated”). После полного выключения версии имеет смысл убрать ее спецификацию из публичного доступа или пометить как архив.
  • Поэтапная миграция клиентов. Помогайте клиентам мигрировать: иногда полезно предоставить тестовый стенд или sandbox, где новая версия уже работает, чтобы они заранее попробовали, прежде чем переключаться на продакшен. Также можно предоставить совместимую библиотеку, которая скрывает различия (например, клиентский SDK, который внутри может работать с v1 или v2, абстрагируясь от изменений).
  • Пример из практики: как версии снимаются. Допустим, вы объявили, что через 6 месяцев выключаете v1. В этот период: 1) Все новые фичи идут только в v2. 2) В ответах v1 можно добавить заголовок Sunset с датой отключения. 3) Через 3 месяца – повторное уведомление клиентам, процент трафика v1 замеряем. 4) В последний месяц – возможно, начать отдавать warning в теле ответа или сокращать лимиты. 5) В день X – отключить v1 (например, Gateway начнет возвращать 410 Gone с сообщением). Этот процесс должен быть прописан и согласован с бизнесом.

Заключение

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

Loading