Как стать автором
Обновить
ЮMoney
Всё о разработке сервисов онлайн-платежей

Как улучшить межсерверное взаимодействие и сэкономить время разработчика

Время на прочтение11 мин
Количество просмотров8K

Привет! Я Алексей, Java-разработчик. Хочу поделиться опытом внедрения подхода Contract-First в backend. 

Что такое контракт

Представим продакшн и три группы участников: мобильное приложение, бэкенд и фронтенд.

Кружки на картинке — множество инстансов одного приложения или множество мобильных устройств. Пунктиром показаны HTTP-вызовы. Предположим, два приложения на iOS и Android (зелёные кружки) обращаются к backend (фиолетовые кружки). Это и есть контракты — правила, по которым общаются два участника. 

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

Наш backend — это 60 разработчиков и 100 микросервисов. Мы поддерживаем внутренние и внешние интеграции и катим 50+ релизов в день, большинство — с изменениями контрактов. Согласование контрактов у нас приобретает повышенный уровень сложности. 

Code-First: как мы раньше управляли контрактами

До 2021 года мы использовали Code-First-подход. Описание контракта выглядело так:

@ApiModel(description = "Информация о пользователе")
public class UserInfo {
@ApiModelProperty(value = "Уникальный идентификатор пользователя", required = true)
@NotNull
@JsonProperty("id")
private final String id;
@ApiModelProperty(value = "Имя")
@JsonProperty("firstName")
private final String firstName;
...

Чтобы реализовать HTTP endpoint, мы описывали в Java-коде модели запросов и ответов. Затем помечали их аннотациями Springfox. Классы моделей, запросов и ответов паковали в JAR, а JAR отгружали в Maven Repository. Для каждого микросервиса заводили библиотеку с набором всех контрактов. Для контракта микросервиса profile была библиотека profile-api, для notifer, notifier-api и так далее.

Если одному микросервису нужно было был сходить в другой, он подключал необходимую API-библиотеку к себе в зависимости и использовали её для взаимодействия. Для тех, кто не умеет в Java, у нас был Swagger UI. 

Code-First Swagger UI
Code-First Swagger UI

Это OpenAPI-спецификация, отрендеренная в HTML и сгенерированная по метаданным классов. Так умеет делать Springfox и Springdoc из коробки. Подход достаточно распространен, я часто встречал его в других компаниях. 

Проблемы разработки с Code-First

Если масштабировать Code-First-подход на 100+ сервисов и 60 разработчиков, могут возникнуть проблемы. Давайте их разберём.

Code-First Jar Hell

Представим, что микросервис с именем profile — «коммуникабельный парень». Вот часть его дерева зависимостей на другие API-библиотеки:

+--- yoomoney:profile:2.0.0
| +--- yoomoney:notiifer-api:4.0.0
| | \--- yoomoney:command-api-engine:2.10.1 -> 2.13.1
| +--- yoomoney:cards-api:3.2.0
| | \--- yoomoney:command-api-engine:2.13.1
| +--- yoomoney:content-api:1.0.1
| | \--- yoomoney:command-api-engine:2.10.0 -> 2.13.1
| +--- yoomoney:debt-api:2.0.0
| | \--- yoomoney:command-api-engine:2.13.1
| +--- yoomoney:identifier-api:4.2.0
| | \--- yoomoney:command-api-engine:2.13.1
....

Допустим, мы выпустили новую мажорную версию библиотеки yoomoney:command-api-engine:3.0.0, подключили её в yoomoney:identifier-api:5.0.0 и хотим использовать новое identifier-api в сервисе profile.

Наше новое дерево зависимостей микросервиса profile теперь выглядит так:

+--- yoomoney:profile:2.0.0
| +--- yoomoney:notiifer-api:4.0.0
| | \--- yoomoney:command-api-engine:2.10.1 -> 3.0.0 // конфликт мажорных версий
| +--- yoomoney:cards-api:3.2.0
| | \--- yoomoney:command-api-engine:2.13.1 -> 3.0.0 // конфликт мажорных версий
| +--- yoomoney:content-api:1.0.1
| | \--- yoomoney:command-api-engine:2.10.0 -> 3.0.0 // конфликт мажорных версий
| +--- yoomoney:debt-api:2.0.0
| | \--- yoomoney:command-api-engine:2.13.1 -> 3.0.0 // конфликт мажорных версий
| +--- yoomoney:identifier-api:5.0.0
| | \--- yoomoney:command-api-engine:3.0.0
....

В одну JVM нельзя одновременно загружать несколько разных версий одного класса c совпадающими именами. Если API-библиотеки транзитивно тянут разные мажорные версии зависимостей, будут проблемы в runtime.

Хорошо, если ваш сервис упадёт ещё до запуска. Когда вы захотите подключить новую мажорную версию в API-либу, придётся пройтись по остальным API-библиотекам и сделать то же самое. Зарелизить 100 либ — не самое приятное занятие на вечер пятницы.

Изменения API-библиотек в Code-First

На ревью основное внимание уделяется Java-коду, а не контрактам. В идеале нужно проверить согласованность имён, правильность композиции и т.д. На ревью накидывают комментарии в стиле: забыл сделать builder для сущности, не поставил аннотацию. А то, что endpoint уже есть, но называется по-другому, могут пропустить.

Представьте API-библиотеку, в которой 30 endpoint'ов. Разработчик вносит правку в один endpoint или делает новый. На ревью вы не видите общей картины, 29 endpoint'ов ускользнут из поля зрения, из-за этого качество страдает.

Следующая проблема — внесение изменений мобильными разработчиками и фронтендерами. Помню тёмные времена, когда мне говорили: «У нас есть команда с информацией о пользователе, надо немного расширить её ответ, а мы нарисуем красивый UI». Мне приносили JSON-файлик или даже табличку на корпоративной Wiki с описанием endpoint'а?‍♂️ Согласования контракта с потребителями фактически не было. За пять лет я буквально пару раз видел, чтобы фронт заходил в API-библиотеки и говорил, каким ему хотелось бы видеть контракт. 

Актуальность Code-First

Помните Swagger UI, который генерируется автоматом для фронтов и мобильных разработчиков? Спецификация, которая получается путём генерации из классов, и реальный JSON, который отдаётся из endpoint, не соответствуют друг другу. Они постоянно разъезжаются, потому что правило преобразования Java-объекта в JSON описывается обычно в Jackson Config, а правило формирования OpenAPI-спецификации — в Docket Config.

Swagger UI, который генерируется автоматом по метаданным классов, показывает очень примерное описание того, что у нас реально отдаётся из API. Поэтому фронты и мобильные разработчики не могут начать разработку, не вызвав endpoint на живую.

С чего мы начали внедрять Contract-First

Из-за проблем с Code-First мы решили писать OpenAPI-спецификации руками. Такой подход называют Contract-First или Design-First

Мы начали с описания гайдов, как делать API, чтобы одну задачу сотрудники не решали дважды и чтобы API было написано в едином стиле. HTTP-стандарт достаточно широкий, одну задачу можно сделать несколькими способами. Гайды покрывают всё — от нейминга до того, как делать пагинацию, кеширование, отдавать ошибки, чтобы сервисы работали одинаково. Пример такого гайда.

Процессы и правила в Contract-First

Далее мы закрепили «новые правила игры» — рассказали разработчикам, где хранить OpenAPI-спецификации, как их релизить и ревьюить. Это не про технику, а про процессы — как жить в новой парадигме. 

Мы договорились, что для каждого микросервиса заводим Git-репозиторий со спецификацией. Перед началом разработки пишем спеку. Обозначили, что такое мажорное изменение. Например, была команда, отдающая профиль пользователя с полем Gender Enum с двумя значениями. Мы внезапно решили добавить третье значение. Вопрос: сломается клиент или нет? Есть много таких corner case, которые надо проговорить до начала разработки. Так вы сможете определиться, какие изменения контракта ломают обратную совместимость, а какие допустимы без предварительного релиза клиентов.

Contract-First: первые результаты

Мы завели по одному Git-репозиторию со спецификацией на микросервис. После введения Code-First репозиторий выглядит так: 

.
├── README.md (Описание сервиса)
├── index.html (swagger-ui)
└── specification
    └── card-specification.yaml (OpenApi 3)

В микросервисной архитектуре неизбежно возникают общие сущности. Например, у нас в коде есть сущность «число с валютой» MonetaryAmount. В спеке для микросервисов платежей, переводов, истории и так далее будет MonetaryAmount.

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

YAML-файл со спецификацией 30-40 endpoint’ов одного микросервиса занимает 7000 строк. Это большой файл, с которым тяжело работать. Если вы нечаянно поставите пробел в середине, документ станет невалидным. Если вы внесёте много изменений в одном pull-request, Bitbucket, Gitlab или Github, в UI вряд ли покажет diff такого файла.

Раньше была проблема между двумя конфигурациями, которые разъезжались (Jackson и Docket). Сейчас есть спека и её реализация, но никто не гарантирует их согласованность. Мы начали разрабатывать API-библиотеки, просто переписывая в код то, что написали в спецификации, допуская банальные ошибки из-за невнимательности. Кажется, стало только хуже. 

Зачем в Contract-First прикручивать CI/CD

Первое, что мы делаем, — прикручиваем CI к API-спецификациям. Будем относиться к спекам как к любым другим артефактам. Мы вводим Gitflow, собираем спеки на CI, прикручиваем линтер, начинаем версионировать спецификации по semver. Далее заводим два репозитория в нашем Nexus для собранных спецификаций: один snapshot-ный для сборок фича веток, другой — релизный для сборок с мастера. 

Важный момент: мы сделали свой линтер. Раньше проверяли спеки на соответствие гайдам глазами, на ревью. Теперь валидность спеки гарантируется линтером на CI.

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

Вопрос с файлами размером в 7000 строк решился просто. Определяем для каждого endpoint'а тег, endpoint'ы с одинаковыми тегами выносим в отдельный файл. Размер спецификаций существенно уменьшился.

Пример корневого файла со всеми командами сервиса:

cat ./card-specification.yaml
...
paths:
  /cards/issue:
    $ref: 'issue.yaml#/paths/~1cards~1issue'
  /cards/info:
    $ref: 'info.yaml#/paths/~1cards~1info’
  /cards/close:
    $ref: 'manage.yaml#/paths/~1cards~1close'
...

Сборка файлов в Contract-First

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

В OpenAPI Tools есть swagger-cli bundle — утилита, позволяющая собрать несколько спецификаций в одну. Минус в том, что она искажает имена схем. Мы хотели, чтобы собранные спецификации не отличались от тех, которые мы писали руками до разделения. Для этого мы запилили маленький инструмент OpenApi bundler.

Итак, мы научились описывать контракты. Фронтендеры и мобильные разработчики приходят к нам с предложениями по изменениям, и ревью стало эффективнее. Теперь они приносят не JSON-файлы, а pull request. Аналитики могут создавать драфт спецификации. У нас есть линтер, который следит, чтобы API было в одном стиле. 

Как кодогенерация помогает в Contract-First

У нас не было согласованности между спецификациями и реализацией. Решением оказалась кодогенерация. Из спецификаций мы генерим код и не даём править его руками.

В OpenAPI Tools есть популярный кодогенератор с оценкой 11к звёзд. У него 150 конфигураций, чтобы генерить по спеке артефакты на разных языках и фреймворках, в том числе несколько вариантов Java-кода.

За время подхода Code-First у нас сформировались требования к транспортным классам запросов и ответов. Например, мы хотели иметь Builder на все объекты, участвующие в транспорте, не падать при десериализации неизвестных Enum, не логировать чувствительные данные (номера карт и телефонов) — их надо маскировать. Мы хотели управлять тем кодом, который генерируется. Если завтра разработчики OpenAPI generator решат перейти с Jackson на Gson, мы не хотим в этом участвовать. Мы решили написать 151-ый вариант конфигурации для генератора. Оказалось, это не суперсложно.

Разберём, как работает кодогенерация. Есть «батя-генератор», который умеет генерировать код согласно указанной конфигурации. Первое, что происходит в генерации — десериализация OpenAPI-спецификации в Java-объекты с помощью библиотеки Swagger Parser. Из файлов JSON или YAML получаются Java-объекты, с которыми можно работать. Затем включается указанная нами конфигурация и преобразует OpenAPI-объекты во внутренние модели генератора. 

На этом этапе, написав свою конфигурацию, мы можем сами сказать, каким образом схема OpenAPI преобразуется во внутренние сущности генератора. Например, из двух схем мы можем сделать одну модель или из одной схемы — несколько моделей, поменять им имена и т.д. Третий этап – рендеринг внутренних моделей генератора в Java-файлы, используя шаблоны mustache.

Что такое собственный генератор кода

Генератор — это не только правила трансформации OpenAPI-спецификаций плюс mustache-шаблоны. Это ещё и правила, по которым нужно писать спецификации так, чтобы код сгенерился.

Вы знаете, что в get-запросах в Query-параметрах можно передавать массивы? OpenAPI поддерживает четыре разных формата, чтобы это сделать. В OpenAPI есть конструкции allOf, oneOf, anyOf. Используя их, можно завязать модель узлом так, что десериализовать JSON этой модель в Java-объект будет просто нельзя. Это не ложится на объектную модель.

Правда ли нужен генератор, который это всё умеет? На самом деле уже есть суперобщий генератор для всех кейсов из коробки OpenApi. К коду, который он генерирует, возникают вопросы. Ребята, которые пилят его, решают сложную задачу: им нужно генерить код по абсолютно любой OpenAPI-спецификации. Такую задачу сложно решать с точки зрения кодогенерации, поэтому надо срезать углы и выкидывать то, что не нужно. 

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

Зачем писать gradle-плагин для генерации

Следующая задача — доставить сгенерированный код до backend-приложений. Так как проблема Jar Hell рождалась из лишнего артефакта в виде API-библиотеки, было принято решение генерировать код по спецификациям в репозиторий микросервера перед компиляцией его кода. Мы решили написать gradle-плагин для этого. У нас большой опыт работы с ними: весь CI/CD построен на них, сделать ещё один было несложно. 

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

Наш генератор — парень простой. Мы скормили ему здоровенную спеку, он сгенерил код со всеми моделями, которые есть в спеке. Если в каждой спеке есть MonetoryAmount, он сгенерит N MonetoryAmount. Это лечится опцией import-mappings. Генератор можно научить не генерировать модель, а использовать те, что уже есть.

Мы у себя в спецификациях добавили расширения x-java-import-mapping, перед генерацией кода они собираются со спецификации и передаются генератору.

Вот пример библиотеки типов. Описана схема номера телефона с указанием вместо генерации кода использовать класс PhoneNumber из библиотеки domain:phone:

cat ./type-library.yaml

components:
  schemas:
    PhoneNumber:
      description: Номер телефона в формате ITU-T E.164
      type: string
      pattern: '[0-9]{4,15}'
      example: '79000000000'
      x-sensitive: true
      x-java-import-mapping: ru.yoomoney.model.domain.phone.PhoneNumber
      x-java-dependency: ru.yoomoney.domain:phone

Результаты внедрения Contract-First

  • Появилось достоверное описание, как работает продакшн.

  • Благодаря связке из линтера и генератора есть уверенность, что соблюдается правило гайдов во всём API и Java-коде.

  • Больше не нужно писать Java API-объекты руками для новых контрактов.

Вау-эффект подхода Contract-First: у вас на самом деле не один геренатор, а целых четыре
Вау-эффект подхода Contract-First: у вас на самом деле не один геренатор, а целых четыре

У нас не один генератор, а целых четыре. Мы умеем из OpenAPI-спецификаций генерить не только код в Java, но ещё и в TypeScript, Swift и Kotlin.

Резюме по Contract-First-подходу

Если вы решите повторить наш подход, рекомендую:

  • Начните с создания гайдов и конвенций. Опишите, как будет выглядеть ваше API.

  • Внедряйте Contract-First постепенно.

  • Отнеситесь к спецификациям внимательно — как к любому другому артефакту. Всё будет построено вокруг них: CI/CD, линтер, сборка, отгрузка в долгосрочное хранилище.

  • Напишите свой генератор. Это несложный инструмент, который окупится многократно. 

Если каждый из 60 backend-разработчиков за двухнедельный спринт хотя бы день потратит на написание API-библиотек для Code-First, это 12 человеко-недель за спринт. Если одного разработчика запереть в комнате и сказать: «Парень, вот генератор из OpenAPI Tools, спеки и пример кода. Собери по примеру генератор Java, чтобы он генерил то, что нужно». За две-четыре недели он это сделает, если не забывать его кормить.

Собственный генератор даёт гибкость в управлении кодом, что отличает его от soap, где то, что вы получаете, прибито гвоздями. Если вы захотите перейти с Jackson на GSON — без проблем: допишите генератор. Захотите перейти с RestTemplate на WebClient в части микросервисов, добавьте одну настройку в генератор. Пусть одним сервисом генерируются клиенты с синхронными API, а другим — с асинхронным. 

В завершение оставлю ссылку на наш гитхаб-аккаунт. Заходите посмотреть крутые шутки, которые мы юзаем в продакшн. Если есть вопросы, не стесняйтесь задавать их в комментариях.

Теги:
Хабы:
Всего голосов 13: ↑12 и ↓1+13
Комментарии5

Публикации

Информация

Сайт
jobs.yoomoney.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
yooteam

Истории