Привет! Меня зовут Олег Мифле. В Skyeng работаю над проектом Skypro. В IT я уже больше десяти лет, семь из которых пишу на PHP. За плечами десятки разных проектов: e‑commerce, финтех, CRM, а недавно добавился и EdTech. Были и классические фуллстек‑проекты, и проекты, где фронтенд и бэкенд «живут» отдельно и коммуницируют друг с другом по API. Боль от отсутствия версионирования я испытал на себе. Хочу поделиться, как избежать проблем, как всё структурировать и организовать.

Обсудим:

  • Что такое API.

  • Зачем версионировать API и нужно ли вообще.

  • Какие способы версионирования существуют и как его организовать — и с точки зрения подходов, и с точки зрения кода.

  • Разберёмся, когда избавляться от старой версии или как жить с легаси до конца существования проекта.

Это статья по моему докладу с первого сезона Podlodka PHP Crew. 27 февраля стартует второй сезон конференции. Тема — «Инструменты разработки», там расскажу про популярные инструменты анализа кода. И полезный чит: если ввести код SKYENG — будет небольшая скидка на билет :)

Что такое API

Когда мы обсуждали API с программистами из разных сфер, часто выяснялось, что мы говорим про разные вещи. Но в современном понимании API — это любой интерфейс, позволяющий взаимодействовать двум программным компонентам.

При этом API — это не только REST. Взаимодействие между модулями в приложении тоже происходит через API. Когда вы используете интерфейс, чтобы клиенты подключались к модулю, задействуется API этого модуля. Если вам приходилось писать что‑то для операционной системы — таск‑менеджер, менеджер файлов, консольную утилиту — то наверняка вы использовали API операционной системы, то есть интерфейс для доступа к ней. Но чаще всего при обсуждении API речь идёт о Web API — REST, SOAP и других http‑взаимодействиях.

Зачем и как версионировать API

Для начала поделюсь историей из собственного опыта.

Я работал на проекте, где фронтенд и бэкенд существовали отдельно и деплоились по‑разному. У нас была задача немного изменить форму, в которой создавались тикеты. Для этого нужно было изменить контракт. Особенность проекта в том, что бэкенд деплоился достаточно быстро, это занимало минуту‑полторы, а с фронтендом было сложнее, он деплоился долго — 10–15 минут.

В контракте в форме нужно было выбрать ученика, его услугу и тему обращения в поддержку. Это было просто. Но бизнес развивался, тем обращений становилось всё больше. Чтобы их категоризировать, решили сделать отдел, который обрабатывает эти обращения: сначала выбирали отдел, потом тему. Так из одного контракта мы хотели получить немного другой.

Задача достаточно тривиальная. Каждый, наверное, делал что-то подобное. Вот и мы сделали и успешно вынесли на ревью, отдали в тестирование, задеплоили.

Но мы забыли про обратную совместимость, из-за чего прод практически 15 минут лежал, создавать задачи было невозможно. В Slack disaster, у всех паника. Через 15 минут всё нормализовалось, но инцидент оставил неприятный осадок.

Этого всего можно было избежать, если заранее подумать об обратной совместимости и версионировании.

Откуда берётся необходимость версионирования, почему данные меняются?

  • Данные эволюционируют в процессе работы систем. От одного формата мы переходим к другому, это влияет на контракты, а контракты меняются.

  • Бизнес развивается, хочет иначе видеть взаимодействие, появляются новые запросы. Всё это отражается на контрактах API.

  • Система эволюционирует, мы пробуем новые технологии, что‑то меняем и улучшаем — это отражается на всех частях системы, в том числе на контрактах.

Конечно, код можно дополнить разными условиями. Но это сделает из проекта большой ком грязи, а наша задача — этого не допустить. Без версионирования стоимость поддержки будет постоянно расти. Слишком большое количество условий приведёт к тому, что код будет очень хрупким — изменения в одной части сломают что‑то в другой.

Обратная совместимость очень важна не только для разработчиков, но и для клиентов, которые эксплуатируют наши API. Ведь кроме риска сломать что‑то на стороне клиента существует риск навредить этим его репутации.

Какие способы версионирования существуют

Разберём несколько способов на реальных примерах кода:

«Свой космолёт»

Этот способ я называю «Построить свой космолёт», и он часто первым приходит в голову при мысли о версионировании. Это отличное приключение, писать свой проект обычно весело. Но это не всегда проходит гладко и заканчивается хорошо.

Большой соблазн — использовать много условий, if, потому что хочется делать всё быстро, бизнесу надо получить моментальный результат.

Допустим, мы сделали какой-нибудь параметр и проверяем его. Потом добавился новый параметр, который тоже нужно проверить. А завтра добавится третий — так код будет постоянно расти и очень скоро legacy будет уже не контролируемым.

Можно пойти путём наследования. У нас есть контроллер первой версии, мы тащим контроллер второй и наследуем его от первой. Дополнительно можно добавлять условия, но цепочка наследования становится слишком длинной и управлять ей сложно, если версий больше трёх.

Техдолг такого решения будет накапливаться как снежный ком — не вы будете управлять им, а он вами. Порог входа в такой проект повышается, потому что со своими «велосипедами» всегда нужно разбираться самостоятельно — писать документацию, если есть такая возможность. Но новым разработчикам придётся во всём этом разбираться. Стоимость поддержки повышается, потому что изменения в одной части системы могут легко сломать любую другую часть.

«Строить свой космолёт» увлекательно, но лучше с этим не заигрываться.

Feature-флаги

Фича‑флаги (Feature Flags) — способ версионирования, когда явных версий нет — v1, v2 и так далее. Есть фича‑флаги, которые клиент сообщает на сервер. А сервер сам решает, как с ними работать: как обработать запрос и как отдать response.

Плюсы такого способа — всё получается достаточно гибко, и встроить в проект можно относительно быстро. Это решает проблему обратной совместимости, но и повышает порог входа. Когда фич становится много, их нужно уметь сочетать друг с другом, а сочетаются они не всегда. А значит, снова нужно писать документацию, которая постоянно под рукой. Для новичков проект может обернуться дополнительными сложностями при входе. Онбординг, соответственно, дорожает.

VCS-версионирование

Следующий способ — версионирование через систему контроля версий. Любая git‑система из коробки поддерживает теги, и вот этими тегами можно пользоваться для того, чтобы апнуть версию. Это достаточно легко реализуется в любом проекте.

Преимущество подхода — это легко и быстро. Закоммитили, запушили, поставили тэг — всё, новая версия готова. Потом раскатили каждую версию на свои инстансы, и всё сработало как надо. А если на какую‑то версию нагрузка больше, чем на другую, можно её масштабировать как нужно, а остальные не трогать. Это может обернуться дополнительным преимуществом.

Но у такой системы есть ряд недостатков. Например, доставка фиксов усложняется, потому что исправления нужно раскатывать на все версии. Чем больше таких версий в системе, тем сложнее эти фиксы доставлять. Для начала можно воспользоваться cherry‑pick, а потом этого может быть уже недостаточно.

Соответственно, чем больше система, чем больше версий, тем сложнее всё это поддерживать и развивать. Каждую версию нужно мониторить, каждую версию как‑то уметь посмотреть, развернуть, задеплоить, поддержать, а значит, расходы на инфраструктуру растут.

Такая система достаточно удобна для микросервисов. Когда у вас не очень развита система API и в целом сервис небольшой, то это очень неплохой вариант.

Blueprints-версионирование

Термин Blueprints-версионирование был введён в сообщество около года назад, когда со своим докладом выступили коллеги из Superjob на PHP Russia. Подход довольно обнадеживающий и очень удобный.

Есть файл — какой-то конфиг, который вам больше нравится, где происходит описание: взаимодействие контрактов, что получать, что отдавать, как валидировать и так далее. Всё хорошо, но всё равно это разновидность своего велосипеда.

Преимущество в том, что реализовать это несложно. Достаточно научиться читать конфиг, а потом всё это подружить с контроллерами и работать. Поддерживать тоже легко: есть конфиг и остальной код. Выглядит вполне неплохо.

Но у любых «велосипедов» есть недостаток. Если open-source-решения развиваются силами сообщества, своё решение нужно развивать силами только своей команды. Если такое решение будет на нескольких проектах, доработки и фиксы нужно будет раскатывать на каждый проект.

Вот пример конфигурации, который коллеги приводили на конференции. Я не стал его менять, всё достаточно наглядно:

У нас есть описания эндпоинтов, в примере есть user, у него есть методы get и patch, class c action, query, filters, сортировки — описание какого-то контракта.

Через явное указание версии

Версии в явном виде можно указывать в заголовках, в get-параметрах и в url. Этот способ версионирования мне кажется достаточно удобным и гибким. Предлагаю рассмотреть его чуть более подробно.

1. Явное указание версии в заголовке. Указание кастомного заголовка.

Допустим, берём заголовок Api-Version 3, не забываем про заголовок Vary, чтобы клиентское приложение, например, браузер, могло сбросить кэш. Вот эти кастомные заголовки на кэш никах не повлияют.

Выглядит как рабочий инструмент. Но что делать, если этот кастомный заголовок не передали? Отвечать ошибкой, не отвечать, отвечать последней версией, первой версией — не ясно.

Есть более стандартизированный подход — Content Negotiation. Там можно указать стандартизированный заголовок Accept: application/vhd, далее ключ своего проекта и тип данных JSON, а затем указываем версию.

Выглядит удобно и стандартизировано. Но у Content Negotiation есть определённые проблемы.

Заголовков может быть несколько. Их количество никак не регламентировано. То есть в заголовках можно написать противоречивые данные. Что делать, если я в заголовках хочу указать и JSON, и xml? А что, если будут разные версии — в одном вторая, в другом первая? А если Content-Type противоречит заголовку Accept? На все эти вопросы нет ответа, потому что это никак не регламентировано. Ответы придётся искать самостоятельно.

У Accept есть q-параметр, который регулирует вес. Последовательность в заголовке Accept тоже имеет значение. То есть version перед q и после q — две разные версии, которые по-разному интерпретируются. В общем, с заголовками есть определённые сложности, но тем не менее это решение более стандартизированное, чем прошлые решения.

В случае с Content Negotiation есть ошибка 406 Not Acceptable. В других случаях нет никакого стандарта и отвечать можно по-разному: ошибка 400, например. При этом ответе кажется, что повторение запроса не приведет к изменениям — выглядит валидно. Ещё можно ответить 412 Precondition Failed или как-то иначе. Но в целом от проекта к проекту последовательности нет.

Важно понимать, что кастомный заголовок может быть отфильтрован. Если вы используете прокси, кастомный заголовок может потеряться: клиент его передал, а сервер не получил. Тогда нужно настраивать фильтр. Но это может привести к проблемам.

2. Через get-параметр. В этом случае можно потерять version, и что делать — не очень понятно. Можно отдать первую или последнюю версию API. Но при отдаче последней версии есть вероятность наткнуться на потерю обратной совместимости.

3. Явное указание версии в пути. Можно указать её в поддомене, а можно прямо в URL. Версия в поддомене подойдёт совместно с версионированием через систему контроля версий. Можно даже автоматизировать таким образом деплой.

Версия в пути выделяется определённым преимуществом по сравнению с остальными. Такой подход поддерживается во всех PHP-фреймворках в отличие от заголовка и прочего. Версионирование через путь можно реализовать в принципе везде, а вот заголовки — только если прикрутить что-то своё или поискать существующее решение, несколько готовых пакетов. Коробочного решения я не встречал.

Что отвечать в случае неуказания версии в пути, понятно: 404, такого эндпоинта не существует, укажите версию и всё будет хорошо.

Как организовывать код

Давайте поговорим про способы организации кода — в случае указания через версию, например. Это подойдет к разным вариантам, но я объясню на примере последнего.

Мне хочется поддержать мнение о том, что код и организация архитектуры в целом не зависит от фреймворка. Хотя я это делал на Symfony, это можно сделать и на других фреймворках.

Структура директории. В примере вверху src, но может быть что угодно, зависит от вашей системы. На скриншоте — контроллер веб-запросов, выделенный в отдельную директорию. Потому что под контроллерами, особенно в современных архитектурах, подразумевают не только веб, но и много чего другого. Поэтому важно отделить веб от всего остального. Далее иерархично уходят контроллеры внешнего и внутреннего API.

Важно понимать, что внутренние\внешние API — это не правило, а особенность построения архитектуры в skyeng. В других компаниях наверняка есть такая же система, но важно оговориться, что это и зачем.

Внешний API — через который взаимодействуют клиенты, веб-клиенты, мобильные приложения, используются разные методы аутентификации. Внутренний API — с помощью которого взаимодействуют наши микросервисы. Этот API работает в локальной сети, там максимально простая авторизация через http. Безопасность гарантируется тем, что всё доступно только изнутри. Соответственно, домены для внешнего и для внутреннего API — разные.

Я попытался показать несколько вариантов, как можно организовать, в случае если у вас есть CRUD экшены. Они, как правило, версионируются вместе, то есть GET, POST, PUT и так далее кочуют от версии к версии. Иногда у вас есть экшн на одно действие, например регистрацию, когда CRUD предоставляет полноценный create, read, update, delete. В этом случае будет POST register, т.е. POST запрос. А в случае CRUD хочется иметь одинаковый response для всего набора, а в случае одиночных экшенов — сделать всё более гибко и понятно.

Почему одиночные экшены могут существовать, откуда они берутся и почему их можно версионировать? Приведём в пример случай регистрации. Регистрация не всегда касается только внутренней системы: закинул пользователя в базу и поехали дальше. Иногда его нужно сразу куда-то поместить — например, зарегистрировать во внешней реферальной системе, CRM.

Первая версия может обеспечивать регистрацию синхронно, вторая — асинхронно. Естественно, клиенту — браузеру или пользователю API — это нужно понимать и знать. Но при этом, если в случае CRUD’ов от первой версии можно будет в какой-то момент отказаться, то в случае с регистрацией от старых версий может и не получится отказаться. Потому что нужно иметь разные способы регистрации.

Давайте посмотрим, что под капотом. Я предлагаю использовать подход, когда один контроллер — это один экшен. Таким образом контроллер получается чище, зона ответственности у него меньше, а при изменении одного экшена меняется только он, а не весь контроллер. Такой код надёжнее и стабильнее.

Есть экшен, у него конструктор. Есть метод, который принимает request и отдаёт response. Всю логику он делегирует внутрь сервиса ResponseFactory. Важно понимать, что контроллер не делает лишней бизнес-логики.

Что из себя представляет ResponseFactory? Это сервис, который может свою работу сконцентрировать глубже, в конце сгенерировать response и выдать его, а бизнес-логику делегировать другому сервису.

Такая система — достаточно гибкая, универсальная для любого фреймворка вне зависимости от того, что используется — Symfony, Laravel или что-нибудь ещё. Главное организовать работу сервисов независимо друг от друга и гибкие изменения.

Для организации документации я предлагаю подход, когда у вас в первую очередь выделяется домен: course, user, register и так далее, а затем версия. Таким образом документацию можно нормально сгруппировать и посмотреть. Swagger поддерживает deprecated и прочее, чтобы старые версии можно было отделить от новых, если они уже не поддерживаются.

Сколько живут старые версии

Чтобы определить, когда старую версию уже пора убирать, можно использовать мониторинг и alerting.

Для этого на мониторинге смотрим, как падает трафик на ваш запрос. Но сколько этого трафика должно быть — 0 или не 0 — решать непосредственно в продукте. Для каких-то систем 0 — это недостижимый идеал. Другим просто нужно чуть-чуть подождать, и 0 будет достигнут. Как ни банально, но — это индивидуально для каждого проекта.

Как можно добиться скорейшего отказа от старой версии? Это тоже зависит от системы и подхода. Но если у вас API-продукт, то можно уведомить клиентов про переход на новую версию и поставить дедлайн, на который можно опираться, чтобы отказаться от старой. У нас в Skyeng клиенты в основном внутренние, мы приносим коллегам на планирование задачу по переезду на новую версию API, чтобы коллеги это делали не в спешном режиме, а запланировали на квартал. А через квартал старую версию просто убирают.

Есть ещё веб-клиенты, мобильные клиенты — их тоже можно пушить, чтобы они поменяли свою версию. У нас такая система реализована для фронтенда и мобильных устройств. Есть специальный конфиг в JSON, который хранится на сервере и содержит версию текущего приложения.

Это стандартная система, когда есть мажорная, минорная и патч-версии. Чтобы изменить патч, делать ничего не нужно. Если мы меняем минор, можем показать пользователю уведомление, что пора обновить веб-приложение. В случае с мажорной версией — мы никого не спрашиваем, просто обновляем.

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

Выше немного фронтового кода. За скобками я оставил, как получается JSON и как проверяется версия. У нас это просто статичный JSON-файл, который лежит на сервере и запрашивается раз в 5 секунд.

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

Говоря про мониторинг и алертинг, хочется упомянуть ещё одну систему — так называемую API management system. Напрямую это не относится к версионированию. Это скорее коробочное решение — набор инструментов, сервисов, которые позволяют создавать, анализировать, масштабировать API в своей среде.

API management system подойдёт для тех, кто предоставляет API как продукт, хочет получить всё и сразу, а не настраивать по отдельности. Он несёт в себе целый набор данных: мониторинг, лимиты, кеширование, тестирование, версионирование.

Всё настраивается через веб-интерфейс. Архитектура сервиса представляет из себя ядро, которое отвечает за формирование политик, настроек, курсов, файрволов, health check’ов, авторизации и так далее. Кроме ядра — веб-интерфейс, через который всё происходит, IP-шлюзы и прокси, отвечающие за обработку запросов от клиентов согласно политикам, backend API, отвечающие за обработку согласно бизнес-логике.

Достоинство такой системы — это абстракция над вашей реализацией. Вся аутентификация перекладываться на API management system. Кроме этого — на неё перекладывается управление трафиком, мониторинг, версионирование и даже преобразование API — запросов и ответов. То есть API management system может модифицировать backend и вернуть его исправленным.

Недостаток: стоимость поддержки такой системы возрастает, потому что тут владение происходит не только каким-то одним пакетом, проектом, а целой системой. И скорость работы такой системы ниже, чем если собрать всё самостоятельно. Потому что нужно всё это дело прогнать через все настройки. Наверное, если у вас дикий highload, это можно почувствовать. На стандартных приложениях разница будет практически не заметна.

А можно не версионировать?

Можно вообще не версионировать API? Короткий ответ — нет. Но, если очень хочется, если клиент вашего API — это ваш же фронтенд, то иногда можно обойтись и без него.

Как это можно сделать? Например, у нас есть простой контракт. Есть ID сущности и name — допустим, это курс Python for beginners. И есть преподаватель Джон Смит, который этот курс составил и ведёт. Хотим сделать другой контракт, чтобы учителю добавилась аватарка. Сделать это можно в три этапа.

К исходному контракту добавляем какое-то промежуточное решение, а фронтенд заставляем работать с двумя полями — и с полем teacher, и с временным полем для имени и аватарки. Следующим шагом спиливаем промежуточное решение и настраиваем фронтенд так, чтобы он работал уже с новым контрактом.

Таким образом в три этапа можно добиться изменения своего контракта, но без поддержки версий. Это работает в случае, если у вас один клиент. Или несколько, но вы ими всеми управляете.

Комментарии (9)


  1. Actor
    00.00.0000 00:00
    +2

    Вопрос к разделу об организации кода.

    Т.е. даже если мы меняем один метод в одном контроллере мы должны прицепом скопировать все остальные контроллеры без изменений?

    Всегда это казалось каким то оверхедом... может для этой цели использовать схемы API Gateway и описывать только изменённые пути, а остальные направлять на предыдущую версию, которая в свою очередь на другую вплоть до 1 версии


    1. OlegMifle Автор
      00.00.0000 00:00
      +3

      Спасибо за вопрос! Если я правильно понимаю, вы имеет в виду crud целиком. Конечно, возможен и такой вариант. Очень зависит от архитектуры в вашем проекте. Мне ближе всё-таки перетащить всё целиком и таким образом не связывать новую версию со старой, даже если изменения небольшие.


  1. savostin
    00.00.0000 00:00

    Особенность проекта в том, что бэкенд деплоился достаточно быстро, это занимало минуту‑полторы, а с фронтендом было сложнее, он деплоился долго — 10–15 минут.

    А есть еще база данных, которая может мигрировать (даже если все без ошибок) часами...


  1. BXVoral
    00.00.0000 00:00

    К пункту Явное указание версии в пути. на случай если в пути не указана версия, считать это актуальной стабильной версией. А в файловой системе это определять симлинком. Конечно если это позволяет проект.


    1. OlegMifle Автор
      00.00.0000 00:00
      +1

      Можно, конечно. Но это может сломать интеграцию кому-то после выкатки очередной версии. Я придерживаюсь идеи, что явное лучше неявного.


  1. muxx
    00.00.0000 00:00
    +1

    Хорошая статья! Хотелось бы отметить, что на практике порой большей проблемой является попытка совместить бизнес-логику разных версий API.

    Классический пример. Есть crud-набор API-методов работы с пользователями. У пользователей есть поле phone. Делаем фичу, чтобы пользователи могли указывать несколько телефонов. В новой версии API или в текущей же вводим поле-коллекцию phones. И возникает вопрос, что делать, когда через старую версию API редактируют пользователя, передавая новый телефон в phone, когда в базе у редактируемого пользователя уже лежит несколько телефонов, установленных ранее через новую версию API.


  1. GrustniyNos
    00.00.0000 00:00
    +1

    На первом скрине код интересный. Второе условие когда-нибудь выполнялось?


    1. Khaperets
      00.00.0000 00:00

      Уверен, что нет, тонко подмечено)


    1. OlegMifle Автор
      00.00.0000 00:00
      +2

      Спасибо за замечание. Думаю всё-таки стоит учитывать, что это, скорее, псевдокод)