[Перевод] Апи версионирование по-взрослому

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

В разработке API вы рискуете сломать код ваших пользователей с каждым новым обновлением. Если API — ваш основной продукт, то обновления будут ещё более пугающими. Основные продукты Monite — это наше API и white-label SDK. Мы API-first компания, поэтому мы тщательно следим за тем, чтобы наше API было стабильным и удобным. Поэтому проблема нарушений обратной совместимости занимает одно из главных мест в нашем списке приоритетов.

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

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

  • Не нарушать обратную совместимость… никогда. Это возможно, но ваше API со временем превратится в свалку старых аргументов, депрекейтнутых эндпоинтов и неожиданной легаси логики

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

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

Учитывая простоту идеи, она кажется идеальной для любой компании. Это то, что мы ожидаем прочитать в типичном инженерном блоге: «Мы придумали велосипед с крыльями и всем рекомендуем». К сожалению, не всё так однозначно.

Побойтесь ценника

Версионирование API — это сложно, очень сложно. Его иллюзорная простота быстро исчезает, как только вы начинаете его внедрять. К сожалению, статьи в интернете не предупреждают вас об этом, да и на эту тему удивительно мало ресурсов. Абсолютное большинство из них спорят о том, куда пихнуть версию (хедер, путь, и т.д.), но лишь немногие пытаются ответить на вопрос: «А реализовывать-то как?». Вот наиболее распространенные варианты:

  • отдельно деплоить разные версии. Можно даже в отдельных бранчах/репозиториях их хранить. Роутинг между версиями через API Gateway

  • копирование отдельных эндпоинтов, изменившихся между версиями. Роутинг между версиями внутри самого приложения

  • копирование всего приложения для каждой версии, но деплоить одним «монолитом». Роутинг между версиями внутри самого приложения

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

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

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

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

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

Так как же сделали мы?

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

  1. »Поддержка большого числа версий должна быть простой» — чтобы версионирование не замедляло разработку новых фичей

  2. »Удаление старых версий должно быть простым» — чтобы мы могли легко очищать кодовую базу

  3. »Создание новых версий не должно быть слишком простым» — чтобы наши разработчики по-прежнему были мотивированы решать проблемы без создания новых версий

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

К сожалению, альтернатив существующим подходам было мало. В этот момент у меня появилась безумная идея:, а что если мы попробуем создать что-то сложное, что-то идеально подходящее для решения проблемы — что-то вроде API в Stripe?

В результате множества экспериментов я сделал Cadwyn: open-source фреймворк для API версионирования на Python, который не только реализует подход Stripe, но и значительно его расширяет. Мы будем говорить о его реализации с FastAPI и Pydantic, но основные принципы не зависят от языка и веб фреймворка.

Как работает Cadwyn

Изменения между версиями

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

С Cadwyn, когда мейнтейнеры API создают новую версию, они применяют любые изменения и ломают любую совместимость в последней версии. Затем они создают «Version Change» — класс, который инкапсулирует все различия между новой версией и предыдущей.

Например, предположим, что ранее наши клиенты могли создавать пользователя с адресом, но теперь мы хотим позволить им указывать несколько адресов вместо одного. Version Change будет выглядеть так:

class ChangeUserAddressToAList(VersionChange):
    description = (
        "`User.address` переименован в `User.addresses`, а его тип "
        "изменен на массив строк"
    )
    instructions_to_migrate_to_previous_version = (
        schema(User).field("addresses").didnt_exist,
        schema(User).field("address").existed_as(type=str),
    )

    @convert_request_to_next_version_for(UserCreateRequest)
    def change_address_to_multiple_items(request):
        request.body["addresses"] = [request.body.pop("address")]

    @convert_response_to_previous_version_for(UserResource)
    def change_addresses_to_single_item(response):
        response.body["address"] = response.body.pop("addresses")[0]

Cadwyn использует instructions_to_migrate_to_previous_version для генерации старых версий openapi (swagger), а две функции-конвертера — это трюк, который позволяет нам поддерживать огромное количество версий, если захочется. Процесс выглядит следующим образом:

  1. Cadwyn преобразует все пользовательские запросы из старых версий в последнюю версию, используя конвертер change_address_to_multiple_items, и передает их в нашу бизнес-логику

  2. Бизнес-логика, API response и модели базы данных всегда настроены на последнюю версию API (конечно, она все равно должна поддерживать старую функциональность, даже если она была удалена в новых версиях, но не должны поддерживать старые интерфейсы)

  3. После того как бизнес-логика возвращает ответ, Cadwyn преобразует его в старую версию API, на которой сейчас работает клиент, с помощью конвертера change_addresses_to_single_item.

После того как мейнтейнеры API создадут Version Change, они должны добавить его в Version Bundle, чтобы дать Cadwyn знать, в какой версии это изменение было сделано:

VersionBundle(
    Version(
        date(2023, 4, 27),
        ChangeUserAddressToAList
    ),
    Version(
        date(2023, 4, 12),
        CollapseUserAvatarInfoIntoAnID,
        MakeUserSurnameRequired,
    ),
    Version(date(2023, 3, 15)),
)

Вот и все: мы добавили ещё одну версию, а наша бизнес-логика по-прежнему работает только с одной версией — последней. Даже после того, как мы добавим десятки версий, наша бизнес-логика останется свободной от логики версионирования, постоянных переименований, if’ов и конвертеров данных. А наши клиенты даже не заметили — для них всё работает как раньше.

Цепочки версий

Version Changes зависят от публичного интерфейса API, f мы почти никогда не ломаем обратную совместимость в существующих версиях. Это означает, что после выпуска версии разница между ней и версиями до/после неё останется неизменной.

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

Диаграмма потока запросов и ответов через Cadwyn

Диаграмма потока запросов и ответов через Cadwyn

Побочные эффекты

API контракты на самом деле гораздо сложнее, чем просто схемы и поля. Они включают все эндпоинты, коды ответа, ошибки, сообщения об ошибках и даже поведение бизнес-логики. Cadwyn использует тот же DSL, который мы описали выше, для работы с эндпоинтами и кодами ответа, но ошибки и поведение бизнес-логики — это другая история: их невозможно описать с помощью DSL, их нужно встраивать в бизнес-логику.

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

class RequireCompanyAttachedForPayment(VersionChangeWithSideEffects):
    description = (
        "Теперь пользователь должен иметь company_id в своем аккаунте, "
        "если он хочет совершать новые платежи"
    )

Это также позволит мейнтейнерам API проверить, использует ли клиентский запрос версию API, включающую этот побочный эффект:

if RequireCompanyToBeAttachedForPayment.is_applied:
    validate_company_id_is_attached(user)

Но серебрянных пуль всё ещё нет

Cadwyn имеет множество преимуществ: он значительно снижает нагрузку на наших разработчиков и может быть интегрирован в нашу инфраструктуру для автоматической генерации ченжлога и улучшения нашей документации.

Однако, нагрузка от версионирования все еще существует, и даже сложный фреймворк не является серебряной пулей. Мы стараемся использовать версионирование API только тогда, когда это абсолютно необходимо. Мы также стараемся сделать наш API правильным с первого раза. Для этого у нас есть специальный «Совет API». Все значимые изменения в нашем API рассматриваются там нашими лучшими разработчиками, тестировщиками и техническими писателями до того, как начнется любая реализация.

Хочу выразить особую благодарность Brandur Leach за его статью о версионировании API в Stripe и за помощь, которую он оказал мне при реализации Cadwyn: без него Кэдвин не появился бы.

© Habrahabr.ru