Мы все любим пробовать новые инструменты, но ненавидим их поддерживать и обновлять. Это касается всего: операционных систем, приложений, 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, мы почти никогда не ломаем обратную совместимость в существующих версиях. Это означает, что после выпуска версии разница между ней и версиями до/после неё останется неизменной.

Поскольку 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: без него Кэдвин не появился бы.

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


  1. Andrey_Solomatin
    05.09.2024 12:01
    +2

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

    Я бы смотрел в сторону разделения решения и имплементации. Получить разрешение на изменение API должно быть сложно, а на создание неудобного просто невозможно. А реальзация может быть простой.


    1. Ovsyanka83 Автор
      05.09.2024 12:01

      Реализация там тоже простая, но посложнее чем "скопировать всю папку". Но я согласен, что это не совсем полноценный критерий на фоне остальных


  1. Andrey_Solomatin
    05.09.2024 12:01
    +1

    У нас в компании пошли по другому пути, через наследование (C#). В фреймворке вы создаёте класс с методами, и они превращаются в ендпоинты. Версия апи, это аннотация к классу, пусть к эндпоинту это аннотация метода.

    Вот есть у нас версия v1 в классе V1, хотим сделать v2.

    • Переименовываем V1 в V1Base, создаём пустой V1 наслудующийся от V1Base. Просто рефакторинг, ничего не поменялось.

    • Создаём V2(V1Base). Получаем вторую версию идентично первой.

    • Меняем метод в v2 (через удаление и добавление нового):

      • Переносим метод из V1Base в V1. Удаление метода из V2

      • Cоздаём новый метод в V2. Добавление метода в V2

    В результате хорошо видна структура каждой версии в коде. Удаление верисии - это просто удаление класса. Минимальное дублирование кода, за счёт наследования.

    Правда ничего не мешает сдеалть не по канону и превраить код в месево. Ну список изменений не вытащишь автоматически.


    1. zodchiy
      05.09.2024 12:01

      Интересное решение, где подсмотрели или свое решение, есть ли где детальное описание на просторах интернета такого подхода?


      1. Ovsyanka83 Автор
        05.09.2024 12:01

        В .net это популярный подход. В доке Кэдвина я его тоже упомянул


    1. Ovsyanka83 Автор
      05.09.2024 12:01
      +1

      Мы начали с этого подхода, но быстро поняли, что он хорошо подходит только для 1-3 версий. Когда у тебя этих версий десятки, а бизнес логика непростая -- месиво начнётся в любом случае.

      Monite (как и Stripe) продают прежде всего свою апишку, поэтому нам надо очень долго держать версии, чтобы нашим клиентам было комфортно. А поскольку мы быстро развиваемся в местами неисследованной области, мы нередко пересматриваем старые подходы, из-за чего новые версии бывают часто. И чтобы клиенты могли долго не обновляться -- иногда может потребоваться долго держать версии.

      Поэтому нам и нужен был подход, который позволит одновременно держать живыми десятки или даже сотни версий, а там наследование уже перестаёт работать :)


  1. zoto_ff
    05.09.2024 12:01
    +1

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

    но интересно было бы узнать про накладные расходы из-за такого решения - насколько из-за него страдает производительность приложения (Python, всё-таки, вам не шутка)


    1. Ovsyanka83 Автор
      05.09.2024 12:01
      +1

      Как ни странно: не особо. Как видите по коду, в этих "преобразованиях" нет вызовов к базе или к внешним сервисам — так и должно быть.

      В них производятся простейшие преобразования легких объектов, поэтому даже при сотне версий разница будет довольно negligible.

      Что страдает, так это startup time, поскольку Кэдвин на старте генерирует альтернативные версии для всех схем и ручек, которые их упоминают. Если версий 100, то ему нужно сделать это 100 раз. На сервере это не проблема, а вот на локалке неприятно. Но для этого нужно иметь большую кодовую базу и много версий. Я думаю, что и эту проблему мы подфиксим:)


  1. rsashka
    05.09.2024 12:01

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

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

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


    1. Ovsyanka83 Автор
      05.09.2024 12:01

      Интересно, как такой подход выглядит в публичном REST API


      1. rsashka
        05.09.2024 12:01

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


        1. Ovsyanka83 Автор
          05.09.2024 12:01

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

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


          1. rsashka
            05.09.2024 12:01

            но существенно усложняет интеграцию клиенту

            Наоборот упрощаете.

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


            1. Ovsyanka83 Автор
              05.09.2024 12:01

              Все почти так. Это концепция нами тоже рассматривалась. Но при большом и часто меняющимся API такое сделать тяжело, а результатом будут поля вроде: "address", "addresses", "addresses_links", и так далее. И в итоге апишка очень быстро превратится в кашу). Ну и две доки — часто слишком сложно. При интеграции у партнёров иногда есть только те разрабы, которым и одну доку тяжело освоить. Любое усложнение уменьшает шансы, что с нами заинтегрируются.

              Но для внутреннего API — прекрасный подход.


              1. rsashka
                05.09.2024 12:01

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


                1. Ovsyanka83 Автор
                  05.09.2024 12:01

                  Все так. Это как раз и есть описание подхода, где мы эндпоинты копируем при необходимости и пишем сериализаторы.

                  На пятнадцатой версии такой подход уже прям тяжело становится поддерживать.


                  1. rsashka
                    05.09.2024 12:01

                    Не, мне кажется это немного не то.

                    Вы привязываетесь к версии API и каждая новая версия имеет свою точку входа, из-за чего вы вынуждены копировать предыдущие endpoint`ы, даже если они сами не изменились, но изменилась единая точка входа с увеличением номера версии API.

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


                    1. Ovsyanka83 Автор
                      05.09.2024 12:01
                      +1

                      Нет-нет, я ровно про это: копирование одного эндпоинта, а не всех.

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


  1. rukhi7
    05.09.2024 12:01

    есть же десятилетиями проверенное решение, клиенты должны реализовать процедуру получения АПИ через которые они хотят работать. Другими словами нужно реализовать 2 типа АПИ: АПИ для доступа к рабочим АПИ, и собственно рабочие АПИ через которые выполняется работа приложения/сервиса/службы/...

    АПИ для доступа к рабочим АПИ это реализация шаблона проектирования "фабрика классов", потому что нет другого способа вызова АПИ, как через создание класса реализующего это АПИ.

    Да это не решает проблемму работоспособности продукта если АПИ больше не поддерживается, но вы всегда будете знать причину по которой ваше приложение/сервис/служба/... не стало работать, потому что фабрика классов сообщит вам что она теперь не может создать класс нужным вам АПИ и не допустит череды непонятных исключений с которыми непонятно что делать. А перефразируя известную сентенцию: знание причины проблемы, это 95 % ее решения, то есть вы либо будете переориентировать ваше приложение на новый интерфейс, либо напишете претензию поставщику библиотеки (например) с реализациями интерфейсов.


    1. Ovsyanka83 Автор
      05.09.2024 12:01
      +1

      Проблема в том, что у нас публичное API для клиентов, которые платят деньги :)

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

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


      1. rukhi7
        05.09.2024 12:01

        Проблема в том, что у нас публичное API для клиентов, которые платят деньги :)

        Ну я думаю трудно найти какое-то "публичное API для клиентов" более публичное чем DirectX, например.

        Вы ведь знаете что такое DirectX (?) и практически все знают кто умеет пользоваться компьютером. Поэтому я думаю вы согласитесь что судить о том насколько ваше "публичное API для клиентов", действительно публичное, мне, и вообще аудитории Хабра, достаточно трудно.


        1. Ovsyanka83 Автор
          05.09.2024 12:01

          Тут речь о REST API :)

          HTTP API, а не библиотеках


          1. rukhi7
            05.09.2024 12:01

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

            Но подход с "фабрикой классов" или если уточнить с "фабрикой ендпоинтов" также замечательно будет работать для REST API и для HTTP API, это точно, попробуйте!

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


            1. Ovsyanka83 Автор
              05.09.2024 12:01
              +2

              Разница между удаленной библиотекой и локальной в том, что мейнтейнерам ничего не стоит оставить старую библиотеку лежать, и нет данных в старом формате на серверах мейнтейнера, которые надо держать в этом старом формате — т.е. оверхед для мейнтейнера минимальный. В случае публичного web API любой клиент на старой версии — это старые данные, старые эндпоинты, старая бизнес логика, и все они должны где-то жить. А если версий сотни, то места занимать начнет дай боже.