Привет! Меня зовут Саша Сусиков. Я проверяю клавиатуры на прочность около 10 лет из  них последние 2 года с помощью Go. Сейчас я участвую в разработке платформы СберМаркета, где создаю инструменты, которые упрощают жизнь разработчикам. 

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

Однажды 2 программиста…

...решили автоматизировать рассылку мемов в мессенджеры своим коллегам. Для это потребуется два сервиса:

  • первый будет ботом, хранящим информацию о подписках;

  • другой будет отвечать за поиск и выдачу случайного мема. 

Один из программистов взялся за написание бота, а другой — за сервис рандомизации.

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

Как это можно было пофиксить?

Использовать библиотеку

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

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

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

API First

Решением будет подход API First, когда мы сначала создаем описание API и публикуем его, а уже после этого делаем реализацию.

Для написания контрактов используются языки описания API — IDL (Interface Description Language). Основная идея такого языка — быть стек-агностиком, которому безразлично, на чем написаны клиент и сервис. Используя спецификацию, можно наладить взаимодействие между ними.  

Двумя самыми популярными спецификациями являются Open API (он же Swagger) и protobuf. Первый часто используется для описания RESTful API, второй — для gRPC API. Но сам контракт не позволит клиенту обратиться к сервису — нам нужен код.

С этим нам поможет кодогенерация. С помощью кодогенератора можно по описанию в контракте получить работающий код для взаимодействия клиента и сервиса. Например, для утилита protoc позволяет нам генерировать код по protobuf-спецификации. 

Two hours later...

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

Для решения проблемы хранения истории используется система контроля версий. Но как организовать хранение самих файлов? Рассмотрим несколько вариантов.

Общий репозиторий

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

http://gitlab.planetexpress.com/contracts
├── bender
├── calculon
└── memonator3000
    └── memonator3000.v1.proto

Недостатки этого подхода:

  • Атомарность контрактов. Мы храним контракты в одном репозитории, код — в другом, соответственно, всегда будет момент, когда контракты будут не соответствовать задеплоенному коду. Если же требуется откатить изменения, надо будет отдельно откатывать изменения контрактов.

  • Вопрос владельца контрактов. Так как мы храним все контракты в одном репозитории, необходимо устанавливать правила о том, где каждый владелец должен хранить свои контракты. А так как контракты вносятся вручную разработчиком, необходимо добавлять проверки того, что он добавляет контракты согласно правилу.

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

Репозиторий сервиса

Другим вариантом будет репозиторий самого сервиса. В нем мы будем хранить как контракты сервиса в отдельной папке, так и контракты зависимостей в другой папке.

https:://gitlab.planetexpress.com/memonator3000
├── api
│   └── memonator.proto
└── deps
    └── mummycorp
        └── mummycorp.proto

Другим вариантом будет репозиторий самого сервиса. В нем мы будем хранить как контракты сервиса в отдельной папке, так и контракты зависимостей в другой папке.

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

Но возникает проблема синхронизации: если владелец поменял контракты, нам 

необходимо сообщить пользователям о том, что им необходимо обновить контракты.

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

Several days later...

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

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

Вопрос обратной совместимости

Такие изменения называются обратно несовместимыми или breaking changes. Это любые изменения, которые могут привести к некорректной работе потребителей или клиентов API. Вот некоторые примеры таких изменений:

  • удаление ресурса

  • удаление поля из ответов

  • изменение имени ресурса

  • изменение кодов ошибок

  • добавление или изменение обязательных параметров

  • добавление или изменение правил валидации 

Чтобы найти такие изменения, существуют различные утилиты. Например, для protobuf есть buf — консольная программа для работы с protobuf-спецификацией. Ее главная цель — дать инструменты для построения надежных систем, которые реализуют gRPC API.

Buf позволяет:

  • форматировать protobuf

  • проверять валидность структуры

  • применять best practice

  • конфигурировать генерацию

  • выявлять обратно несовместимые изменения

Как работает buf?

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

  1. Для создания модуля используется команда buf mod init

  2. После этого появляется файл buf.yaml, которому мы указываем настройки линтинга и детектора изменений. Также в нем мы можем указать, какие файлы должны входить в модуль, а какие наоборот — надо исключить.

  3. Для проверки на обратно совместимые изменения следует использовать команду buf breaking. Она позволяет выполнять сравнение из различных источников: ветки, папки, zip-архивы. 

Вот так выглядит вариант проверки с веткой main в репозитории. После запуска команды buf выполнит следующее:

$> buf breaking –against ‘.git#branch=main
acme/weather/v1/weather.proto:1:1:Previously present message 
"GetWeatherRequest" was deleted from file.
acme/weather/v1/weather.proto:1:1:Previously present message 
"GetWeatherResponse" was deleted from file.
acme/weather/v1/weather.proto:10:21:Enum value 
"1" on enum "Condition" changed name from "CONDITION_SUNNY" to "CONDITION_FOGGY".
acme/weather/v1/weather.proto:15:3:Field 
"1" with name "latitude_min" on message "Location" changed option 
"json_name" from "latitude" to "latitudeMin".

Вернемся к нашему сервису

В его репозитории мы решили хранить контракт. Наш сервис позволяет получить рандомный мем и отправить запрос. Попробуем простейшее изменение: добавим новое поле типа string — поле author. 

// БЫЛО

$> cat api/memonator3000.v1.proto
message Meme {
  string id = 1;
  string name = 2;
  string image_url = 3;
  repeated string tags = 4;
}

// СТАЛО

$> cat api/memonator3000.v1.proto
message Meme {
  string id = 1;
  string name = 2;
  string image_url = 3;
  repeated string tags = 4;
  string author = 5;
}

Запустим buf breaking и посмотрим, что он нам выведет.

$> buf breaking --against '.git#branch=main'
$>

Вывод пустой. То есть такие изменения не являются обратно совместимыми.

Давайте теперь попробуем удалить поле.

// БЫЛО

$> cat api/memonator3000.v1.proto
message Meme {
  string id = 1;
  string name = 2;
  string image_url = 3;
  repeated string tags = 4;
}

// СТАЛО

$> cat api/memonator3000.v1.proto
message Meme {
  string id = 1;
  string name = 2;
  repeated string tags = 4;
}

Мы удаляем поле image_url, запускаем команду и видим, что buf нам сообщает, что было удалено поле image_url. Видим, что buf нам сообщает что мы сделали обратно несовместимые изменения.

$> buf breaking --against '.git#branch=main' 
api/memonator.proto:10:1:Previously present field 
"3" with name "image_url" on message "Meme" was deleted.
$>

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

Если по какой-то причине мы хотим убрать правило из проверки, то мы можем настроить исключение для buf breaking. В раздел except мы можем указывать правила, которые мы хотим убрать. 

// БЫЛО

$> cat api/memonator3000.v1.proto
message Meme {
  string id = 1;
  string name = 2;
  string image_url = 3;
  repeated string tags = 4;
}

 //СТАЛО

$> cat api/memonator3000.v1.proto
message Meme {
  string id = 1;
  string name = 2;
  repeated string tags = 4;
}

В данном случае мы убираем правило проверки поля на удаление. Запустим теперь команду и видим, что вывод пустой. То есть buf не нашел никаких обратно несовместимых изменений.

$> buf breaking --against '.git#branch=main'
$>

Однако добавлять правила в except — не лучшее решение, так как в будущем мы можем упустить те изменения, которые не хотели делать. Как быть в этой ситуации, как вносить breaking changes в контракты, рассмотрим позже.

Three weeks later...

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

Автоматизация

Если процесс разработки основан на внесении изменений через merge либо pull request (надеюсь, у вас так и построено), в него легко добавить этап валидации контрактов на несовместимые изменения. Давайте рассмотрим пример, как можно настроить в gitlab CI.

$> cat .gitlab-ci.yml
stages:
 - lint

breaking-changes:
 stage: lint
 image: 
   name: bufbuild/buf:0.41.0
   entrypoint: [""]
 rules:
   - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
 script:
   - buf breaking --against 
"${CI_REPOSITORY_URL}#branch=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}"

Здесь мы:

  1.  Добавляем джобу с наименованием breaking changes. 

  2. Используем готовый docker-образ, который содержит в себе buf вместо установки отдельным пакетом утилиты. 

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

  4. В самой команде мы указываем, что необходимо проверить изменения в репозитории с веткой, которая указана таргетом в merge request. 

Several months later...

Прошло много времени, бот запущен, и коллеги счастливо получают мемы. Но у ребят появились идеи о том, чтобы сделать новые клиенты. Для этого нам потребуется внести breaking changes.

Вспомним, как мы решали вопрос с помощью конфигурирования buf. Напомню: мы добавили правило исключения, но такие настройки могут позволить нам пропустить breaking changes в будущем. В данном случае необходимо версионирование. Мы создадим новые контракты и реализуем новые endpoints.

Версионирование

Вернемся к нашему сервису. Так как мы храним контракты внутри репозитория, и контракт хранится в одном файле, для того, чтобы создать новую версию, необходимо создать новый файл и внести в него наши breaking changes.

// БЫЛО

https:://gitlab.planetexpress.com/memonator3000
├── api
│   └── memonator.proto
└── buf.yaml

// СТАЛО

https:://gitlab.planetexpress.com/memonator3000
├── api
│   ├── memonator.proto
│   └── memonator.v2.proto
└── buf.yaml

После этого мы запускаем команду buf breaking и видим, что он не вывел сообщение, значит breaking changes не обнаружены.

$> buf breaking --against '.git#branch=main'
$>

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

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

Есть ещё такое решение: всегда у клиентов указывать user-agent. И если его нет, или он не разрешен — не принимать запрос.

Подведем итоги

  • Разговаривайте на одном языке. Используйте API First подход. Спецификации не зависят от языков, на которых написан сервис, поэтому вы можете организовать взаимодействие между сервисами. Для получения кода можно использовать кодогенерацию.

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

  • Контролируйте изменения. Используйте утилиты для проверки на обратную совместимость своих изменений.

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

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

А ещё 27 апреля мы в СберМаркете проведём Golang-митап. Поговорим про настройку Kafka, траблшутинг проблем с драйвером PostgreSQL и про то, нужны ли всё-таки в Go дженерики.

Tech-команда СберМаркета завела соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на  YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.

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