Автор материала рассказывает об устройстве системы управления версий, которая реализована в компании Stripe.
Когда дело доходит до API, изменения — вещь непопулярная. В то время как многие разработчики ПО привыкли к работе в режиме частых и быстрых итераций, разработчики API теряют эту гибкость как только у них появляется хотя бы первый пользователь их интерфейса. Многие из нас знакомы с историей эволюции операционной системы Unix.
В 1994 году была издана книга «Настольное пособие Unix-ненавистника» (Unix-Haters Handbook), в которой затрагивался целый список самых разных острых тем, от имен оптимизированных под телетайпы команд с совершенно непонятной историей происхождения до необратимого удаления данных, непонятных интуитивно программ с избытком опций. Более 20 лет спустя, подавляющее большинство этих жалоб по-прежнему актуальны несмотря на все многообразие доступные сегодня систем-наследников и ответвлений. Unix стал настолько широко популярен, что изменение его поведения может привести к далеко идущим последствиям. Хорошо это или плохо, но между ним и его пользователями уже сложились определенные договоренности, определяющие поведение Unix-интерфейсов.
Похожим образом и API представляет собой коммуникационный договор, изменить который без значительной доли сотрудничества и усилий с обеих сторон не представляется возможным. Многие бизнесы полагаются на Stripe как поставщика инфраструктуры и поэтому мы думаем над этим видом взаимодействия с самого начала деятельности нашей компании. В настоящее время нам удалось сохранить поддержку каждой версии нашего API с момента появления компании в 2011 году. В этой статье, мы бы хотели поделиться с вами, как нам в Stripe удается организовать работу с версиями API.
Написанный для интеграции с API код изначально сопряжен с некоторыми ожиданиями. Если конечная точка возвращает булевое поле verified для указания статуса банковского счета, пользователь может написать примерно такой код:
Если после этого мы заменим булевое поле verified полем статуса, которое может включать в себя поле verified (как мы сделали это в 2014 году), код перестанет работать, поскольку зависит от поля, которое больше не существует. Подобный вид изменений приводит к обратной несовместимости и потому мы избегаем таких изменений. Поля, которые присутствовали ранее, должны присутствовать и впредь и всегда сохранять тот же тип и название. Впрочем, не все изменения приводят к обратной несовместимости. К примеру, безопасно добавлять новую конечную точку API или абсолютно новое поле к существующей точке.
При достаточной координации усилий, мы могли бы держать пользователей в курсе предстоящих изменений и просить заранее обновлять свои интеграции, но даже если это и было бы возможно, такой подход нельзя назвать ориентированным на клиента. Подобно подключению к сети электроэнергии или водоснабжению, наш API должен работать как можно дольше без какой-либо необходимости вносить изменения.
Stripe предоставляет экономическую инфраструктуру для Интернета. Так же как и производители электроэнергии не должны менять напряжение каждые два года, так и мы верим, что наши пользователи должны быть уверены, что API будет сохранять свою стабильность как долго, как это возможно.
Существует распространенный подход, позволяющий развивать интернет-API — поддержка разных версий. При выполнении запросов пользователи указывают версию API, а его поставщики могут вносить изменения в следующей его итерации, в то же время сохраняя совместимость в текущей. По мере выхода новых версии, пользователи могут переходить на более новые в удобное для них время.
Суть наиболее распространенной на сегодняшний день схемы контроля версий заключается в использовании названий вроде v1, v2 и v3, передаваемых в виде префикса к URL (например, /v1/widgets) или через HTTP-заголовок вроде Accept. Такой подход может работать, но главный его недостаток заключается в том, что когда размер обновления между версиями велик, а само оно включает в себя серьезные изменения, что по сложности перехода он равносилен необходимости проводить повторную интеграцию с нуля.
Не столь очевидны и положительные стороны такого перехода, поскольку всегда присутствует класс пользователей, которые не могут или не желают делать апгрейд, в результате чего они оказываются в ловушке старых версий API. Перед поставщиками в таком случае встает нелегкий выбор между отказом от старых версий и, как следствие, потерей подобных клиентов, и продолжением поддержки старых версий навсегда, что влечет за собой существенные затраты. Несмотря на то что второй вариант может, на первый взгляд, показаться правильным с точки зрения клиент-ориентированности решением, поддержка устаревших версии косвенно сказывается на качестве работы проект в целом, поскольку по факту снижает темпы работы над нововведениями. Вместо разработки новых фич, рабочее время инженеров частично съедается поддержкой старого кода.
Мы в Stripe применяем контроль версии, названными по имени даты выпуска (например, 2017-05-24). Несмотря на их обратную несовместимость, каждый такой апдейт содержит небольшой набор изменений, превращающих обновление и актуализацию своей интеграции в плавный и относительно простой процесс.
При первом обращении к API от пользователя, за его аккаунтом автоматически закрепляется самая свежая из доступных версии, после чего система автоматически подразумевает, что каждый последующий вызов API с их стороны будет обращаться к этой версии. Такой подход исключает ситуацию, когда пользователи случайно получают нарушающее их интеграцию изменение, а также делает первоначальную интеграцию менее болезненной за счет снижения объема работ по ее настройке. Пользователи могут принудительно назначать версию каждого отдельного запроса путем ручной настройки заголовка Stripe-Version, или обновив закрепленную в своем аккаунте версию из панели управления Stripe.
Некоторые читатели уже могли заметить, что Stripe API также определяет главные версии с помощью префикса пути (например, /v1/charges). И хотя мы оставляем за собой право рано или поздно воспользоваться этой схемой, маловероятно, что подобное произойдет в обозримом будущем. Как уже было отмечено выше, крупные изменения, как правило, превращают апгрейды в тяжелое и неприятное занятие, и нам сложно представить себе столь важный редизайн API, который мог бы оправдать подобное причинение неудобств пользователям. Наш текущий подход показывает свою эффективность на протяжении почти сотни обратно-несовместимых обновлений, выпущенных за последние шесть лет.
Версионность — это всегда компромисс между улучшением инструментария разработчиков и дополнительным бременем поддержки старых версий. Мы всячески стремимся добиться первого, в то же время минимизируя стоимость работ по второму пункту. Для достижения этих целей, мы ввели систему контроля версий. Давайте разберем небольшой пример того, как она работает. Каждый возможный ответ от Stripe API написан в виде класса, получившего название API ресурс. Такие ресурсы определяют свои возможные поля с помощью предметно-ориентированного языка:
API-ресурсы написаны таким образом, что описываемая ими структура есть то, что мы ожидаем получить от текущей версии API. Когда нам необходимо внести обратно-несовместимое изменение, мы инкапсулируем его в модуле изменения версии, который определяет документацию об изменении, само преобразование и набор типов API-ресурсов, подпадающих под изменение:
В остальных случаях, изменения назначаются в соответствии с данными из мастер-списка:
Изменения версии написаны так, что в случае необходимости они автоматически применяются в обратном порядке начиная с текущей версии API. Каждое изменение версии предполагает, несмотря на наличие более новых изменений впереди, что данные, которые они получают, будут выглядеть также, как они были изначально написаны.
Генерируя ответ, API прежде всего форматирует данные путем описания API ресурса текущей версии. После этого идет определение целевой версии API исходя из:
После этого API делает обратный обход версии и применяет каждый модуль изменения версии на своем пути до тех пор, пока не дойдет до нужной версии.
Прежде чем API вернет ответ, все запросы обрабатываются модулями изменения версий.
Модули изменения версии позволяют абстрагироваться от старых версии API при работе с основным кодом. В результате большую часть времени разработчики могут избегать думать о старых версиях во время разработки новых продуктов.
Большинство наших обратно-несовместимых изменений API изменяют его ответ, но это происходит навсегда. Иногда требуется более сложное изменение, которое вытекает из определяющего его модуля. Мы присваиваем таким модулям примечание has_side_effects, (есть побочные эффекты) и описываемое ими преобразование превращается в холостой код:
Факт их деактивации также будут проверяться в других частях кода:
Подобное снижение инкапсуляции усложняет поддержку изменений с побочными эффектами, поэтому мы стараемся избегать такого подхода.
Одно из преимуществ автономных модулей изменения версии заключается в том, что они могут объявлять документацию, описывающую то, на какие поля и ресурсы они оказывают воздействие. Мы можем воспользоваться этим для быстрого предоставления нашим пользователям более полезной информации. К примеру, лог изменений нашего API генерируется программно и обновляется как только мы деплоим новые версии сервисов.
Мы также адаптируем справочную документацию API в соответствии с потребностями отдельных пользователей. Она проверяет, что за пользователь авторизован в системе, и оставляет к полям примечания, основываясь на текущей версии API его аккаунта. На изображении ниже, например, мы предупреждаем разработчика, о том, что в более новых по сравнению с его закрепленной версией вариантах API были внесены обратно-несовместимые изменения. Поле request ивента ранее было строкой, но теперь это подобъект, также содержащий ключ идемпотентности (созданные в рамках показанного выше кода изменения версии):
Наша документация определяет пользовательскую версию API и показывает ему соответствующие предупреждения.
Предоставление расширенной обратной совместимости не дается даром. Каждая новая версия добавляет больше кода, который необходимо понимать и поддерживать. Мы стараемся писать как можно чище, но со временем десятки проверок изменений версии не поддающиеся четкой инкапсуляции, могут привести к тому, что проект обрастет ненужными вещами и в как следствие станет более медленным, хрупким и потеряется его читабельность. Дабы избежать накопления такого рода дорогого технического долга мы предприняли несколько мер.
Несмотря на наличие у нас продуманной системы контроля версии, мы делаем все возможное, чтобы избежать ее использования и прежде всего стараемся с самого начала правильно выстраивать архитектуру нашего API. Любые запланированные изменения проходят через простой процесс рассмотрения, в рамках которого они описываются в кратком информационном документе и отправляются рассылкой. Это позволяет взглянуть на предложенные изменения шире, с точки зрения разных подразделений компании. Увеличивается и вероятность обнаружения ошибок и несоответствий, прежде чем они попадут в релиз.
Мы стараемся всегда помнить о балансе между необходимостью поддерживать предыдущие версии и развитием новых фич. Поддержка совместимости важна, но даже несмотря на это, мы ожидаем, что в конечном счете начнем отказываться от старых версии. Помощь пользователям в переходе на новые версии дает им доступ к новым возможностям и упрощает фундамент, используемый нами для создания новых фич.
Сочетание выкатывания версии и внутреннего фреймворка, поддерживающего их, позволило нам серьезно расширить свою базу пользователей и внести в API огромное количество изменений, которые, однако, практически никак не сказались на качестве существующих интеграций. В основе этого подхода лежат несколько принципов, выбранных нами в результате многолетней практики. Мы считаем, что обновлениям API важно соответствовать следующим критериям:
Несмотря на то что нам очень интересно наблюдать за спорами и развитием событий в таких темах как REST vs. GraphQL vs. gRPC, а также — в более широком смысле — обсуждениями того, как API будут выглядеть в будущем, мы полагаем продолжить поддержку схем контроля версий в течение довольно длительного времени.
Когда дело доходит до API, изменения — вещь непопулярная. В то время как многие разработчики ПО привыкли к работе в режиме частых и быстрых итераций, разработчики API теряют эту гибкость как только у них появляется хотя бы первый пользователь их интерфейса. Многие из нас знакомы с историей эволюции операционной системы Unix.
В 1994 году была издана книга «Настольное пособие Unix-ненавистника» (Unix-Haters Handbook), в которой затрагивался целый список самых разных острых тем, от имен оптимизированных под телетайпы команд с совершенно непонятной историей происхождения до необратимого удаления данных, непонятных интуитивно программ с избытком опций. Более 20 лет спустя, подавляющее большинство этих жалоб по-прежнему актуальны несмотря на все многообразие доступные сегодня систем-наследников и ответвлений. Unix стал настолько широко популярен, что изменение его поведения может привести к далеко идущим последствиям. Хорошо это или плохо, но между ним и его пользователями уже сложились определенные договоренности, определяющие поведение Unix-интерфейсов.
Похожим образом и API представляет собой коммуникационный договор, изменить который без значительной доли сотрудничества и усилий с обеих сторон не представляется возможным. Многие бизнесы полагаются на Stripe как поставщика инфраструктуры и поэтому мы думаем над этим видом взаимодействия с самого начала деятельности нашей компании. В настоящее время нам удалось сохранить поддержку каждой версии нашего API с момента появления компании в 2011 году. В этой статье, мы бы хотели поделиться с вами, как нам в Stripe удается организовать работу с версиями API.
Написанный для интеграции с API код изначально сопряжен с некоторыми ожиданиями. Если конечная точка возвращает булевое поле verified для указания статуса банковского счета, пользователь может написать примерно такой код:
if bank_account[:verified]
...
else
...
End
Если после этого мы заменим булевое поле verified полем статуса, которое может включать в себя поле verified (как мы сделали это в 2014 году), код перестанет работать, поскольку зависит от поля, которое больше не существует. Подобный вид изменений приводит к обратной несовместимости и потому мы избегаем таких изменений. Поля, которые присутствовали ранее, должны присутствовать и впредь и всегда сохранять тот же тип и название. Впрочем, не все изменения приводят к обратной несовместимости. К примеру, безопасно добавлять новую конечную точку API или абсолютно новое поле к существующей точке.
При достаточной координации усилий, мы могли бы держать пользователей в курсе предстоящих изменений и просить заранее обновлять свои интеграции, но даже если это и было бы возможно, такой подход нельзя назвать ориентированным на клиента. Подобно подключению к сети электроэнергии или водоснабжению, наш API должен работать как можно дольше без какой-либо необходимости вносить изменения.
Stripe предоставляет экономическую инфраструктуру для Интернета. Так же как и производители электроэнергии не должны менять напряжение каждые два года, так и мы верим, что наши пользователи должны быть уверены, что API будет сохранять свою стабильность как долго, как это возможно.
Схемы управления версиями API
Существует распространенный подход, позволяющий развивать интернет-API — поддержка разных версий. При выполнении запросов пользователи указывают версию API, а его поставщики могут вносить изменения в следующей его итерации, в то же время сохраняя совместимость в текущей. По мере выхода новых версии, пользователи могут переходить на более новые в удобное для них время.
Суть наиболее распространенной на сегодняшний день схемы контроля версий заключается в использовании названий вроде v1, v2 и v3, передаваемых в виде префикса к URL (например, /v1/widgets) или через HTTP-заголовок вроде Accept. Такой подход может работать, но главный его недостаток заключается в том, что когда размер обновления между версиями велик, а само оно включает в себя серьезные изменения, что по сложности перехода он равносилен необходимости проводить повторную интеграцию с нуля.
Не столь очевидны и положительные стороны такого перехода, поскольку всегда присутствует класс пользователей, которые не могут или не желают делать апгрейд, в результате чего они оказываются в ловушке старых версий API. Перед поставщиками в таком случае встает нелегкий выбор между отказом от старых версий и, как следствие, потерей подобных клиентов, и продолжением поддержки старых версий навсегда, что влечет за собой существенные затраты. Несмотря на то что второй вариант может, на первый взгляд, показаться правильным с точки зрения клиент-ориентированности решением, поддержка устаревших версии косвенно сказывается на качестве работы проект в целом, поскольку по факту снижает темпы работы над нововведениями. Вместо разработки новых фич, рабочее время инженеров частично съедается поддержкой старого кода.
Мы в Stripe применяем контроль версии, названными по имени даты выпуска (например, 2017-05-24). Несмотря на их обратную несовместимость, каждый такой апдейт содержит небольшой набор изменений, превращающих обновление и актуализацию своей интеграции в плавный и относительно простой процесс.
При первом обращении к API от пользователя, за его аккаунтом автоматически закрепляется самая свежая из доступных версии, после чего система автоматически подразумевает, что каждый последующий вызов API с их стороны будет обращаться к этой версии. Такой подход исключает ситуацию, когда пользователи случайно получают нарушающее их интеграцию изменение, а также делает первоначальную интеграцию менее болезненной за счет снижения объема работ по ее настройке. Пользователи могут принудительно назначать версию каждого отдельного запроса путем ручной настройки заголовка Stripe-Version, или обновив закрепленную в своем аккаунте версию из панели управления Stripe.
Некоторые читатели уже могли заметить, что Stripe API также определяет главные версии с помощью префикса пути (например, /v1/charges). И хотя мы оставляем за собой право рано или поздно воспользоваться этой схемой, маловероятно, что подобное произойдет в обозримом будущем. Как уже было отмечено выше, крупные изменения, как правило, превращают апгрейды в тяжелое и неприятное занятие, и нам сложно представить себе столь важный редизайн API, который мог бы оправдать подобное причинение неудобств пользователям. Наш текущий подход показывает свою эффективность на протяжении почти сотни обратно-несовместимых обновлений, выпущенных за последние шесть лет.
Под капотом системы управления версиями
Версионность — это всегда компромисс между улучшением инструментария разработчиков и дополнительным бременем поддержки старых версий. Мы всячески стремимся добиться первого, в то же время минимизируя стоимость работ по второму пункту. Для достижения этих целей, мы ввели систему контроля версий. Давайте разберем небольшой пример того, как она работает. Каждый возможный ответ от Stripe API написан в виде класса, получившего название API ресурс. Такие ресурсы определяют свои возможные поля с помощью предметно-ориентированного языка:
class ChargeAPIResource
required :id, String
required :amount, Integer
End
API-ресурсы написаны таким образом, что описываемая ими структура есть то, что мы ожидаем получить от текущей версии API. Когда нам необходимо внести обратно-несовместимое изменение, мы инкапсулируем его в модуле изменения версии, который определяет документацию об изменении, само преобразование и набор типов API-ресурсов, подпадающих под изменение:
class CollapseEventRequest < AbstractVersionChange
description \
“Cобытийные объекты (и веб-хуки) теперь генерируют “ \
“подобъект request, содержащий id запроса и “ \
“ключ идемпотентности вместо одной строки “ \
“с id запроса.”
response EventAPIResource do
change :request, type_old: String, type_new: Hash
run do |data|
data.merge(:request => data[:request][:id])
end
end
end
В остальных случаях, изменения назначаются в соответствии с данными из мастер-списка:
class VersionChanges
VERSIONS = {
'2017-05-25' => [
Change::AccountTypes,
Change::CollapseEventRequest,
Change::EventAccountToUserID
],
'2017-04-06' => [Change::LegacyTransfers],
'2017-02-14' => [
Change::AutoexpandChargeDispute,
Change::AutoexpandChargeRule
],
'2017-01-27' => [Change::SourcedTransfersOnBts],
...
}
end
Изменения версии написаны так, что в случае необходимости они автоматически применяются в обратном порядке начиная с текущей версии API. Каждое изменение версии предполагает, несмотря на наличие более новых изменений впереди, что данные, которые они получают, будут выглядеть также, как они были изначально написаны.
Генерируя ответ, API прежде всего форматирует данные путем описания API ресурса текущей версии. После этого идет определение целевой версии API исходя из:
- Заголовка Stripe-Version, если таковой был передан.
- Версии авторизованного OAuth-приложения, если запрос делает от имени пользователя.
- Закрепленной за пользователем версии, назначаемой при передаче в Stripe самого первого запроса.
После этого API делает обратный обход версии и применяет каждый модуль изменения версии на своем пути до тех пор, пока не дойдет до нужной версии.
Прежде чем API вернет ответ, все запросы обрабатываются модулями изменения версий.
Модули изменения версии позволяют абстрагироваться от старых версии API при работе с основным кодом. В результате большую часть времени разработчики могут избегать думать о старых версиях во время разработки новых продуктов.
Изменения с побочными эффектами
Большинство наших обратно-несовместимых изменений API изменяют его ответ, но это происходит навсегда. Иногда требуется более сложное изменение, которое вытекает из определяющего его модуля. Мы присваиваем таким модулям примечание has_side_effects, (есть побочные эффекты) и описываемое ими преобразование превращается в холостой код:
class LegacyTransfers < AbstractVersionChange
description "..."
has_side_effects
End
Факт их деактивации также будут проверяться в других частях кода:
VersionChanges.active?(LegacyTransfers)
Подобное снижение инкапсуляции усложняет поддержку изменений с побочными эффектами, поэтому мы стараемся избегать такого подхода.
Декларативные изменения
Одно из преимуществ автономных модулей изменения версии заключается в том, что они могут объявлять документацию, описывающую то, на какие поля и ресурсы они оказывают воздействие. Мы можем воспользоваться этим для быстрого предоставления нашим пользователям более полезной информации. К примеру, лог изменений нашего API генерируется программно и обновляется как только мы деплоим новые версии сервисов.
Мы также адаптируем справочную документацию API в соответствии с потребностями отдельных пользователей. Она проверяет, что за пользователь авторизован в системе, и оставляет к полям примечания, основываясь на текущей версии API его аккаунта. На изображении ниже, например, мы предупреждаем разработчика, о том, что в более новых по сравнению с его закрепленной версией вариантах API были внесены обратно-несовместимые изменения. Поле request ивента ранее было строкой, но теперь это подобъект, также содержащий ключ идемпотентности (созданные в рамках показанного выше кода изменения версии):
Наша документация определяет пользовательскую версию API и показывает ему соответствующие предупреждения.
Минимизируя изменения
Предоставление расширенной обратной совместимости не дается даром. Каждая новая версия добавляет больше кода, который необходимо понимать и поддерживать. Мы стараемся писать как можно чище, но со временем десятки проверок изменений версии не поддающиеся четкой инкапсуляции, могут привести к тому, что проект обрастет ненужными вещами и в как следствие станет более медленным, хрупким и потеряется его читабельность. Дабы избежать накопления такого рода дорогого технического долга мы предприняли несколько мер.
Несмотря на наличие у нас продуманной системы контроля версии, мы делаем все возможное, чтобы избежать ее использования и прежде всего стараемся с самого начала правильно выстраивать архитектуру нашего API. Любые запланированные изменения проходят через простой процесс рассмотрения, в рамках которого они описываются в кратком информационном документе и отправляются рассылкой. Это позволяет взглянуть на предложенные изменения шире, с точки зрения разных подразделений компании. Увеличивается и вероятность обнаружения ошибок и несоответствий, прежде чем они попадут в релиз.
Мы стараемся всегда помнить о балансе между необходимостью поддерживать предыдущие версии и развитием новых фич. Поддержка совместимости важна, но даже несмотря на это, мы ожидаем, что в конечном счете начнем отказываться от старых версии. Помощь пользователям в переходе на новые версии дает им доступ к новым возможностям и упрощает фундамент, используемый нами для создания новых фич.
Принципы, лежащие в основе изменений
Сочетание выкатывания версии и внутреннего фреймворка, поддерживающего их, позволило нам серьезно расширить свою базу пользователей и внести в API огромное количество изменений, которые, однако, практически никак не сказались на качестве существующих интеграций. В основе этого подхода лежат несколько принципов, выбранных нами в результате многолетней практики. Мы считаем, что обновлениям API важно соответствовать следующим критериям:
- Легковесность. Апгрейды следует делать настолько малозатратными (как для пользователей, так и для нас), насколько это возможно.
- Первоклассный подход. Превратите контроль версии в первоклассную концепцию для вашего API. Сделайте так, чтобы ей можно было пользоваться для точного документирования и совершенствования инструментов, а также для автоматической генерации ченджлогов.
- Фиксированная стоимость. Убедитесь, что старые версии добавляют только минимум работ поддержке, путем их прочной инкапсуляции в модули изменения версии. Иными словами, чем меньше вам нужно думать над старыми схемами поведения во время написания нового кода, тем лучше.
Несмотря на то что нам очень интересно наблюдать за спорами и развитием событий в таких темах как REST vs. GraphQL vs. gRPC, а также — в более широком смысле — обсуждениями того, как API будут выглядеть в будущем, мы полагаем продолжить поддержку схем контроля версий в течение довольно длительного времени.