Комментарии 37
"Создание новых версий не должно быть слишком простым" — чтобы наши разработчики по-прежнему были мотивированы решать проблемы без создания новых версий
Я бы смотрел в сторону разделения решения и имплементации. Получить разрешение на изменение API должно быть сложно, а на создание неудобного просто невозможно. А реальзация может быть простой.
У нас в компании пошли по другому пути, через наследование (C#). В фреймворке вы создаёте класс с методами, и они превращаются в ендпоинты. Версия апи, это аннотация к классу, пусть к эндпоинту это аннотация метода.
Вот есть у нас версия v1 в классе V1, хотим сделать v2.
Переименовываем V1 в V1Base, создаём пустой V1 наслудующийся от V1Base. Просто рефакторинг, ничего не поменялось.
Создаём V2(V1Base). Получаем вторую версию идентично первой.
Меняем метод в v2 (через удаление и добавление нового):
Переносим метод из V1Base в V1. Удаление метода из V2
Cоздаём новый метод в V2. Добавление метода в V2
В результате хорошо видна структура каждой версии в коде. Удаление верисии - это просто удаление класса. Минимальное дублирование кода, за счёт наследования.
Правда ничего не мешает сдеалть не по канону и превраить код в месево. Ну список изменений не вытащишь автоматически.
Интересное решение, где подсмотрели или свое решение, есть ли где детальное описание на просторах интернета такого подхода?
В .net это популярный подход. В доке Кэдвина я его тоже упомянул
Мы начали с этого подхода, но быстро поняли, что он хорошо подходит только для 1-3 версий. Когда у тебя этих версий десятки, а бизнес логика непростая -- месиво начнётся в любом случае.
Monite (как и Stripe) продают прежде всего свою апишку, поэтому нам надо очень долго держать версии, чтобы нашим клиентам было комфортно. А поскольку мы быстро развиваемся в местами неисследованной области, мы нередко пересматриваем старые подходы, из-за чего новые версии бывают часто. И чтобы клиенты могли долго не обновляться -- иногда может потребоваться долго держать версии.
Поэтому нам и нужен был подход, который позволит одновременно держать живыми десятки или даже сотни версий, а там наследование уже перестаёт работать :)
Как ни странно: не особо. Как видите по коду, в этих "преобразованиях" нет вызовов к базе или к внешним сервисам — так и должно быть.
В них производятся простейшие преобразования легких объектов, поэтому даже при сотне версий разница будет довольно negligible.
Что страдает, так это startup time, поскольку Кэдвин на старте генерирует альтернативные версии для всех схем и ручек, которые их упоминают. Если версий 100, то ему нужно сделать это 100 раз. На сервере это не проблема, а вот на локалке неприятно. Но для этого нужно иметь большую кодовую базу и много версий. Я думаю, что и эту проблему мы подфиксим:)
По ощущениям 100 раз не выглядят что должно быть долго.
Долго именно для человеческого глаза. Я хочу, чтобы моя апка на фастапи запускалась мгновенно, а это начинает занимать секунду, а иногда и две при большом количестве версий и эндпоинтов
Для себя я нашел выход из похожего положения, разделив API на две части, которые обновляются по отдельности. Отдельно, интерфейс обмена (передачи данных) и отдельно формат передаваемых данных.
Интерфейс обмена нужно менять очень редко и только тогда, когда возникают какие-то новые потоки данных, тогда как формат передаваемых данных может меняться (дополняться) довольно часто, но это не затрагивает интерфейса обмена.
При такой реализации, поддерживать обратную совместимость становится значительно легче, так как обеспечить обратную совместимость формата данных без привязки к API значительно проще, чем поддерживать одновременно и то и другое.
Интересно, как такой подход выглядит в публичном REST API
В публичном API интерфейс описывается как обычно, но данные могут быть отмечены как raw, описание формата которых приводится в отдельной спецификации и без привязки в вызовам API.
Получается, что мы для части данных просто говорим клиенту "вот тут небезопасно. Можем и поломать" и усложняем понимание документации.
Это упрощает нам работу, но существенно усложняет интеграцию клиенту. Интересный трейдофф. В целом звучит как дешёвый и местами хорошо применимый подход.
но существенно усложняет интеграцию клиенту
Наоборот упрощаете.
Ведь API меняется редко (только расширяется), а часто меняется только сериализация данных. И если она будет сделана с сохранением обратной совместимости, то старые версии вообще не потребуется как либо изменять, т.е. клиентам вообще ничего не нужно делать.
Все почти так. Это концепция нами тоже рассматривалась. Но при большом и часто меняющимся API такое сделать тяжело, а результатом будут поля вроде: "address", "addresses", "addresses_links", и так далее. И в итоге апишка очень быстро превратится в кашу). Ну и две доки — часто слишком сложно. При интеграции у партнёров иногда есть только те разрабы, которым и одну доку тяжело освоить. Любое усложнение уменьшает шансы, что с нами заинтегрируются.
Но для внутреннего API — прекрасный подход.
Превращать в кашу или нет, это ваше дело. Ведь можно при накоплении определенного количества изменений в данных переключиться на новый endpoint в API с новым типом сериализации. Это и старых клиентов не поломает и у новых каши в данных уже не будет.
Все так. Это как раз и есть описание подхода, где мы эндпоинты копируем при необходимости и пишем сериализаторы.
На пятнадцатой версии такой подход уже прям тяжело становится поддерживать.
Не, мне кажется это немного не то.
Вы привязываетесь к версии API и каждая новая версия имеет свою точку входа, из-за чего вы вынуждены копировать предыдущие endpoint`ы, даже если они сами не изменились, но изменилась единая точка входа с увеличением номера версии API.
А я говор про расширение API, где новый endpoint не изменяет точку входа для новой версии API, а только расширяет её за счет новых сериализованных данных. Но так как сериализованные данные так же обеспечивают обратную совместимость, то старые клиенты могут работать с новым интерфейсом, даже если в сериализованных данных появились новые поля из новой версии API.
Нет-нет, я ровно про это: копирование одного эндпоинта, а не всех.
С десятками копий становится прям сложно поддерживать, как я и говорю. Мы тоже расширяем API, и делаем новые версии только при необходимости ломать совместимость. К сожалению, такая необходимость возникает чаще чем кажется, когда продукт быстро меняется. Таким образом количество скопированных эндпоинтов за пару лет может перевалить за десятки, а то и сотни — и с этим уже совсем тяжело будет жить.
А вы мониторите использование старых эндпоинтов?
Есть возможно понять, какой клиент им пользуется и когда он делал это последний раз?
есть же десятилетиями проверенное решение, клиенты должны реализовать процедуру получения АПИ через которые они хотят работать. Другими словами нужно реализовать 2 типа АПИ: АПИ для доступа к рабочим АПИ, и собственно рабочие АПИ через которые выполняется работа приложения/сервиса/службы/...
АПИ для доступа к рабочим АПИ это реализация шаблона проектирования "фабрика классов", потому что нет другого способа вызова АПИ, как через создание класса реализующего это АПИ.
Да это не решает проблемму работоспособности продукта если АПИ больше не поддерживается, но вы всегда будете знать причину по которой ваше приложение/сервис/служба/... не стало работать, потому что фабрика классов сообщит вам что она теперь не может создать класс нужным вам АПИ и не допустит череды непонятных исключений с которыми непонятно что делать. А перефразируя известную сентенцию: знание причины проблемы, это 95 % ее решения, то есть вы либо будете переориентировать ваше приложение на новый интерфейс, либо напишете претензию поставщику библиотеки (например) с реализациями интерфейсов.
Проблема в том, что у нас публичное API для клиентов, которые платят деньги :)
Представьте, что вы B2B, и вашей апишкой пользуется банк, который приносит вам 60% прибыли. Причем пользуется, продавая как часть своего продукта конечным пользователям. Вы готовы просто сломать ему существенную часть функциональности? Они же никогда больше не захотят с вами работать.
Вы можете сказать, что лучше предупреждать клиентов, но этот же банк может просто сказать: "нет, не переедем на новую версию. Мы можем запланировать переезд на третий квартал следующего года", и тут ничего не поделаешь — от них зависит ваш бизнес :)
Проблема в том, что у нас публичное API для клиентов, которые платят деньги :)
Ну я думаю трудно найти какое-то "публичное API для клиентов" более публичное чем DirectX, например.
Вы ведь знаете что такое DirectX (?) и практически все знают кто умеет пользоваться компьютером. Поэтому я думаю вы согласитесь что судить о том насколько ваше "публичное API для клиентов", действительно публичное, мне, и вообще аудитории Хабра, достаточно трудно.
Тут речь о REST API :)
HTTP API, а не библиотеках
так это тоже АПИ для работы с библиотекой, только с удаленной, принципиального отличия тут нет. Интерфейсы разных Direct интерфейсов спроектированы в том числе чтобы работать удаленно через любой протокол и тип соединения, но разбираться с этим с нуля по документации конечно достаточно сложно, придется продираться через море не всегда очевидной теории, лучше бы конечно начать с анализа и применения какого-то готового работающего решения, но тут я не знаю что посоветовать, к сожалению.
Но подход с "фабрикой классов" или если уточнить с "фабрикой ендпоинтов" также замечательно будет работать для REST API и для HTTP API, это точно, попробуйте!
Просто перед тем как начать пользоваться запрос определенного типа (рутинный запрос) надо в самом начале при инициализации иметь возможность послать запрос на разрешение использовать этот рутинный запрос, также можно согласовывать состав структур данных для обмена с клиентом или с сервером при запуске и тд итп. Это на самом деле очень небольшой оверхед и только при запуске клиента, но он кардинально решает проблему непонятных сбоев при неотслеженных изменениях софта реализаций.
Разница между удаленной библиотекой и локальной в том, что мейнтейнерам ничего не стоит оставить старую библиотеку лежать, и нет данных в старом формате на серверах мейнтейнера, которые надо держать в этом старом формате — т.е. оверхед для мейнтейнера минимальный. В случае публичного web API любой клиент на старой версии — это старые данные, старые эндпоинты, старая бизнес логика, и все они должны где-то жить. А если версий сотни, то места занимать начнет дай боже.
Просто перед тем как начать пользоваться запрос определенного типа (рутинный запрос) надо в самом начале при инициализации иметь возможность послать запрос на разрешение использовать этот рутинный запрос
Клиент платит, чтобы этот ответ всегда был "Разрешено". Далеко не все компании могут ставить клиента раком и заставлять его обновляться.
Клиент платит, чтобы этот ответ всегда был "Разрешено".
у меня маленько другая версия: клиент платит за то чтобы все работало и он не должен знать что там под капотом стало разрешено, а что запрещено. Для этого клиенту передается библиотека клиента через которую он работает. Эта библиотека в случае какого-то форс-мажора, когда работать все таки не возможно должна сообщить клиенту и далее через клиента поставщику услуг причину этого форс мажора. А поставщику услуг такая клиентская библиотека разрешает проводить внутренние обновления, после того как клиент получил и ввел в работу у себя эту соответственно обновленную библиотеку.
Если всего боятся то лучше действительно ничего не делать, запретить себе любое развитие, но это путь к деградации, как известно.
Проблема в том, что многие наши клиенты интегрируются с нами на низком уровне, и слишком "умный" SDK им просто не подойдёт.
У страйпа та же беда: у них 65 лярдов маркеткапа, офигенная апишка, огромные деньги вливаются в заботу об апишке и SDK, но их SDK крайне простой и даже не близко к тому, что вы описываете. А все потому, что вы описываете юзкейс "пользователю дали высокоуровневый SDK, и он с помощью него решает узкий набор бизнес-задач".
Монит предоставляет широкую инфраструктуру для финансов, а наши клиенты должны иметь гранулярный и гибкий доступ к этой инфраструктуре, поэтому любое слишком сильное ограничение или "специализация" просто оттолкнут часть наших клиентов. Они все пользуются нами по-разному, поэтому подход, который вы описали выше, реализовать будет проблематично)
наши клиенты интегрируются с нами на низком уровне
вы наверно имеете ввиду они сами сокеты открывают, но тогда, наверно, да сложно с ними. Но если им все таки предложить библиотеку, возможно, они на нее когда-то перейдут, особенно если окажется что через нее намного проще работать, что тоже не тривиальная задача конечно.
у меня маленько другая версия: клиент платит за то чтобы все работало и он не должен знать что там под капотом стало разрешено, а что запрещено
В этом нет противоречия.
Эта библиотека в случае какого-то форс-мажора, когда работать все таки не возможно должна сообщить клиенту и далее через клиента поставщику услуг причину этого форс мажора.
То что вы хотите обновить библиотеку не форс мажор.
А поставщику услуг такая клиентская библиотека разрешает проводить внутренние обновления, после того как клиент получил и ввел в работу у себя эту соответственно обновленную библиотеку.
А у клиента куча денег и штат интеграторов которые по 10 часов в день пишут бизнес код. Этот код приносит денег. Просто обновление денег не приносит. Чтобы обновить библиотеку нужно протсетировать код который её использует и тестов на интерграцию нет. Рисков много, выхлоп абстрактный. Поэтому будут откладывать до последнего.
Я описал крайний случай. Но если у вас клиентов много, то рано или поздно такие появятся.
То, что вы предлагаете будет работать если клиент вас не может продавить. Так AWS продаёт своё API хочешь покупай, хочешь не покупай.
Спасибо за стать. У меня пару вопросов.
Допустим у нас 10 версий. В первой addresses1, в последней addresses10 (Каждую версию мы переименовывали поле)
Я правильно понял что так как мы логику пишем под последнюю версию логики, каждый раз нам надо будет во всех предыдущих дописать/поменять VersionChange? А тесты старого API остаются?
А что, VersionChangeWithSideEffects threadlocal? Не вижу что ему что передается.
Я правильно понял что так как мы логику пишем под последнюю версию логики, каждый раз нам надо будет во всех предыдущих дописать/поменять VersionChange?
Нет, и это, наверное, главная фича подобного подхода. На десятой версии вам потребуется только написать, как мигрировать с десятой на девятую и наоборот. Т.е. как превратить addresses10 в addresses9. А вот VersionChange девятой версии уже давно знает, как с девятой перейти на восьмую :)
Таким образом у нас с каждой новой версией в 99% случаев не потребуется менять старые миграции.
А тесты старого API остаются?
Тесты -- отдельная головная боль. Моя рекомендация:
Держите в тестах папку head, в которой будут храниться тесты последней версии
Перед тем как сделать новую версию, внесите её измнеения в свою логику и схемы последней версии (ну т.е. сделайте то, что хотите сделать), а затем запустите тесты. Те, что упадут, описывают сломанные контракты
Создайте в тестах новую папку "v2024_05_04" (или любую другую дату -- по предыдущей версии), и скопируйте только сломанные тесты туда и дергать они должны старую версию апи
Затем напишите VersionChange, который пофиксит тесты в этой устаревшей версии путём своих миграций. В новой версии эти же тесты все еще должны быть сломаны
Затем, когда у вас все готово, измените тесты в новой версии, чтобы они соответствовали новому контракту и проходили
Результат: мы максимально безопасно выпустили новую версию и задублировали только те тесты, на которые повлияла наша миграция. Таким образом наше покрытие тестами не уменьшилось, хотя добавилась целая новая версия. Огромный плюс в том, что тестов стало не сильно больше.
В какой-то момент мы пробовали дублировать все тесты "для безопасности" -- а потом удивлялись, почему это они гонятся не минуту, а десять минут, и менять их стало невероятно сложно.
Апи версионирование по-взрослому