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

Часто основная сложность заключается в том, что нужно одновременно поддерживать уже существующий код монолита и параллельно внедрять новые принципы и подходы.  В статье я расскажу, как мы в Wrike, используя reverse engineering и немного кодогенерации, реализовали первые шаги по выделению отдельных микросервисов и запустили первый «почти настоящий» BFF-сервис в рамках нашего монолита.

Привет! Меня зовут Слава Тютюньков, я Backend Tech Lead в Wrike. В этой статье я хочу поговорить о том, как мы в backend-команде готовились к работе с монолитом, чем в этой задаче нам помог reverse engineering, как мы использовали кодогенерацию, с какими сложностями столкнулись в процессе и что получили в итоге.

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

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

При этом у монолита много различных потребителей API: основной web-клиент, мобильные приложения, публичные API и интеграции. 

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

Мы хотим, чтобы архитектура в итоге выглядела примерно так: 

Но сделать это не так просто по разным причинам:

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

  • Есть технические аспекты: код и модули тесно связаны.

  • Проблемы обратной совместимости по API. Например, у мобильного приложения Wrike отдельный релизный цикл. Нам сложно изменить что-то в монолите и не затронуть при этом мобильное приложение.

Поэтому в первую очередь мы решили изолировать API для мобильного приложения, вынеся его в отдельный сервис — BFF. Таким образом мы сможем отделить монолит от внешнего потребителя API и создать «фасад» для монолита.

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

Постепенно выделяя микросервисы, мы хотим прийти к такой архитектуре:

BFF отвечает за интерфейс общения с web-приложением, мобильными приложениями и публичным API, а внутри на бекенде — микросервисы
BFF отвечает за интерфейс общения с web-приложением, мобильными приложениями и публичным API, а внутри на бекенде — микросервисы

Готовимся к работе

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

О чем мы решили договориться заранее:

Протокол взаимодействия микросервисов. Мы выбрали REST-Like и JSON в качестве транспорта. Мы рассматривали и другие варианты вроде gRPC или RSocket, но нас устроил REST: мы пошли по «консервативному пути» — большинство разработчиков в команде умеют с ним работать, поэтому на первых этапах внедрения ребятам будет проще и удобнее. 

Библиотеки. В качестве клиента мы договорились использовать Retrofit2, Jackson — в качестве библиотеки для маппинга JSON-ов.

Описание схемы. Еще одна проблема монолита — у эндпоинтов нет описания, есть только код. При работе в микросервисном мире подобное недопустимо: без описания API невозможно построить нормальное взаимодействие между микросервисами. Опираясь на предыдущий выбор (REST+JSON), логичным способом описания стал OpenAPI.

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

В качестве «основы» для микросервисов мы выбрали довольно стандартное решение — Spring Boot и Spring MVC.

Дальше нужно было выбрать первую «жертву». 

Параметры, по которым мы выбирали:

  • Отсутствие собственного домена.

  • Узкоспециализированный API.

  • Отдельный релизный цикл.

  • Особые требования обратной совместимости.

В итоге мы решили создать BFF для Android-приложения:

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

  • Мобильное приложение разделяет общие эндпоинты с web-версией, но у него своя специфика построения интерфейса и загрузки данных. Наличие интерфейса, специализированного и оптимизированного под мобильное приложение — важный фактор, и BFF как раз может его решить.

  • Монолит деплоится ежедневно, мобильное приложение не может себе такого позволить. Более того, миграция пользователей на новые версии происходит довольно медленно.

  • Нам необходимо поддерживать обратную совместимость эндпоинтов продолжительное время. Сейчас с Android-командой действует соглашение о сохранении обратной совместимости как минимум полгода. 

Шаг первый: создаем сервис BFF

Для начала рассмотрим часть взаимодействия от мобильного приложения к монолиту через BFF.

Мы создали пустой сервис, настроили его и запустили:

Все заработало — сервис есть, трафика пока что нет, но первый этап мы прошли.

Шаг второй: разбираемся, где брать данные

Мы строим BFF в качестве «фасада» к монолиту. В этом случае логично брать данные из монолита. Мы это делаем, используя его текущий API.

Выбираем способ получения данных. Получить данные можно несколькими способами. Первый — использовать кастомный клиент. Если клиент позволяет получать данные через REST или внутренние коммуникации с монолитом, можно использовать его. В нашей ситуации такой клиент был, но не подходил для наших задач: у нас есть публичный API, но для требований Android-приложения этого было недостаточно. В частности есть различия в модели и представлении данных: Android-приложение ориентируется на внутреннюю специфику, недоступную через публичный API.

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

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

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

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

Тогда мы решили написать подобную библиотеку самостоятельно. 

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

@HandlerMetaInfo(
       tags = "navigation",
       path = "api/navigation_settings",
       method = HttpMethod.PUT,
       securitySchemas = {}
)
public class PutNavigationSettings implements SchemaHandler<Input, Output> {

   protected Input parseRequest(final HttpServletRequest request) {
       return new Input(
               Integer.parseInt(request.getParameter("mode")),
               Stream.of(request.getParameterValues("items"))
                       .map(NavigationItem::fromString)
                       .filter(Objects::nonNull)
                       .collect(Collectors.toList())
       );
   }

   protected Output processRequest(final Input input) {
       /// save to db
       return new Output(input.getItems());
   }

   static class Input {
       private final int mode;
       private final List<NavigationItem> items;

       Input(final int mode, final List<NavigationItem> items) {
           this.mode = mode;
           this.items = items;
       }

       public int getMode() {
           return mode;
       }

       public List<NavigationItem> getItems() {
           return items;
       }
   }

   static class Output {
       private final List<NavigationItem> items;

       Output(final List<NavigationItem> items) {
           this.items = items;
       }

       public List<NavigationItem> getItems() {
           return items;
       }
   }
}

Input (в нашем случае — отдельный класс, которые описывают модель), Output (то, что мы отдаем клиенту) и некоторая мета-информация в аннотация и/или конфигах. 

Эта структура легко ложится на схему OpenAPI:

Схема выглядит стандартной и несложной. Мы написали библиотеку, которая генерирует схему по структуре эндпоинтов и собрали полный список — получилось порядка 150. Затем запустили обход всех эндпоинтов и получили схему. После опубликовали схему в artifactory, чтобы переиспользовать в BFF. Также это нужно, чтобы фронтенд мог взять схему и на своей стороне получить декларативный клиент.

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

Шаг третий: из схемы в код

Дальше в дело вступила кодогенерация. С помощью схемы мы получили декларативный клиент для BFF. Чтобы получать данные из монолита, мы запроцессили схему через OpenAPI Generator и добавили особенности, которые были нужны в нашем случае.

Получили по схеме все нужные модели:

@JsonPropertyOrder({
       JSON_PROPERTY_MODE,
       JSON_PROPERTY_ITEMS
})
public class PutNavigationSettingsDto {
   static final String JSON_PROPERTY_MODE = "mode";
   static final String JSON_PROPERTY_ITEMS = "items";

   private final int mode;
   private final List<NavigationItem> items;

   @JsonCreator
   private PutNavigationSettingsDto(@JsonProperty(JSON_PROPERTY_MODE) final int mode,
                                    @JsonProperty(JSON_PROPERTY_ITEMS) final List<NavigationItem> items) {
       this.mode = mode;
       this.items = items;
   }

   @JsonGetter(JSON_PROPERTY_MODE)
   public int getMode() {
       return mode;
   }

   @JsonGetter(JSON_PROPERTY_ITEMS)
   public List<NavigationItem> getItems() {
       return items;
   }

   public static Builder builder(final int mode) {
       return new Builder(mode);
   }

   public static final class Builder {
       private final int mode;

       private List<NavigationItem> items;

       public Builder(final int mode) {
           this.mode = mode;
       }

       public Builder withItems(final List<NavigationItem> items) {
           this.items = items;
           return this;
       }

       public PutNavigationSettingsDto build() {
           return new PutNavigationSettingsDto(mode, items);
       }
   }
}
@JsonPropertyOrder({
       JSON_PROPERTY_ITEMS
})
public class PutNavigationSettingsResponseDto {
   static final String JSON_PROPERTY_ITEMS = "items";

   private final List<NavigationItem> items;

   @JsonCreator
   private PutNavigationSettingsResponseDto(@JsonProperty(JSON_PROPERTY_ITEMS) final List<NavigationItem> items) {
       this.items = items;
   }

   @JsonGetter(JSON_PROPERTY_ITEMS)
   public List<NavigationItem> getItems() {
       return items;
   }

   public static Builder builder() {
       return new Builder();
   }

   public static final class Builder {
       private List<NavigationItem> items;

       public Builder() {
       }

       public Builder withItems(final List<NavigationItem> items) {
           this.items = items;
           return this;
       }

       public PutNavigationSettingsResponseDto build() {
           return new PutNavigationSettingsResponseDto(items);
       }
   }
}

Получили декларативный Retrofit2-клиент:

public interface NavigationSettingsApi {
   @Headers({
           "Content-Type:application/json"
   })
   @PUT("navigation_settings")
   Call<PutNavigationSettingsResponseDto> updateNavigationSettings(@Header("Authorization") String authToken,
                                                                   @Header("account") Account accountId,
                                                                   @Body PutNavigationSettingsDto input);
}

Это позволило «объявить» клиент в сервисе, использовать его для работы и не думать о том, работаем мы с монолитом или микросервисом.

Шаг четвертый: проксируем

Чтобы минимизировать трудозатраты команды мобильных разработчиков, на первом этапе мы решили максимально сохранить текущий протокол и поменять только эндпоинт — адрес, куда «ходит» мобильное приложение. BFF в нашем случае получился проксирующим с небольшим добавлением специфики монолита.

Мы описали схему BFF, переиспользуя те компоненты, которые получили на предыдущем шаге:

/navigation_settings:
 put:
   tags:
     - navigation
   requestBody:
     required: true
     content:
       "application/json":
         schema:
           $ref: '#/components/schemas/NavigationSettingsPutRequest'
   responses:
     200:
       description: OK
       content:
         application/json:
           schema:
             $ref: "#/components/schemas/NavigationSettingsPutResponse"

Дальше мы сгенерировали интерфейсы для Spring MVC. Чтобы минимизировать количество ошибок в коммуникации между микросервисами, мы следуем правилу — разработчики самостоятельно не описывают интерфейсы эндпоинтов в микросервисах. Подход Schema-First постулирует, что описывается всегда только схема, а интерфейсы генерируются автоматически. Это уменьшает риск ошибки разработчика в имплементации и гарантирует консистентное взаимодействие между микросервисами.

@Validated
public interface NavigationControllerApi {

   @RequestMapping(value = "/navigation_settings",
           produces = {"application/json"},
           consumes = {"application/json"},
           method = RequestMethod.PUT)
   @PreAuthorize("#principal.accountId != null")
   default ResponseEntity<WrikeResponseDto> navigationSettingsPut(@AuthenticationPrincipal final AuthInfo principal,
                                                                  @Valid @RequestBody final NavigationSettingsPutRequestDto navigationSettingsPutRequestDto) {
       return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
   }
}

Аналогичным образом генерируем модели.

Проксируем запросы, используя Retrofit2-клиент:

@Override
public ResponseEntity<WrikeResponseDto> navigationSettingsPut(final AuthInfo principal, @Valid NavigationSettingsPutRequestDto input) {
   final Response<PutNavigationSettingsResponseDto> response;
   try {
       final WrikeToken wrikeToken = principal.getWrikeToken();
       final String authToken = wrikeToken.getBearerToken();
       response = navigationSettingsApi.updateNavigationSettings(
                       authToken,
                       wrikeToken.getRequestAccountId().get(),
                       PutNavigationSettingsDto.builder(0)
                               .withItems(input.getItems())
                               .build()
               )
               .execute();
   } catch (final IOException e) {
       log.error("", e);
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }

   if (response == null) {
       log.warn("[PUT] Response is null");
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }

   if (!response.isSuccessful()) {
       return ResponseEntity.status(response.code()).build();
   }

   final PutNavigationSettingsResponseDto data = response.body();
   return ResponseEntity.ok(
           WrikeResponseDto.builder(true)
                   .withData(data)
                   .build()
   );
}

Логика обработчика описывается следующим образом:

  • Берем входные данные из запроса.

  • Добавляем необходимые заголовки.

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

  • Полученные данные оборачиваем и отдаем в ответ клиенту.

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

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

Анализируем, что получилось

Кажется, что все хорошо, можем продолжать пилить микросервисы. Но давайте чуть внимательнее посмотрим на проксирующий код эндпоинов в BFF — тот самый код, который позволяет получать данные.

По сути это обращение к монолиту через использование общей архитектуры. При работе с микросервисом у нас будет все то же самое. 

Давайте взглянем на реализацию проксирования запросов еще раз:

Pеализация navigationSettingsPut
@Override
public ResponseEntity<WrikeResponseDto> navigationSettingsPut(final AuthInfo principal, @Valid NavigationSettingsPutRequestDto input) {
   final Response<PutNavigationSettingsResponseDto> response;
   try {
       final WrikeToken wrikeToken = principal.getWrikeToken();
       final String authToken = wrikeToken.getBearerToken();
       response = navigationSettingsApi.updateNavigationSettings(
                       authToken,
                       wrikeToken.getRequestAccountId().get(),
                       PutNavigationSettingsDto.builder(0)
                               .withItems(input.getItems())
                               .build()
               )
               .execute();
   } catch (final IOException e) {
       log.error("", e);
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }

   if (response == null) {
       log.warn("[PUT] Response is null");
       return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
   }

   if (!response.isSuccessful()) {
       return ResponseEntity.status(response.code()).build();
   }

   final PutNavigationSettingsResponseDto data = response.body();
   return ResponseEntity.ok(
           WrikeResponseDto.builder(true)
                   .withData(data)
                   .build()
   );
}

Если посмотреть на код внимательнее, то можно увидеть, что в нем очень много специфики эндпоинтов монолита. Нам нужно передавать токен авторизации: когда запрос идет между микросервисами или от BFF в основную систему, нужно явно передавать авторизационные заголовки. Также необходимо передавать дополнительные данные (в нашем примере — ID аккаунта пользователя, чтобы корректно отработал роутинг между различными сегментами системы). 

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

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

Что мы хотели изменить:

  1. Избавиться от бойлерплейта. 

  2. Не передавать и не заполнять общие параметры.

  3. Абстрагироваться от протокола и маппинга данных. Мы хотели описать микросервис как обычный бин, не завязывать интерфейсы на конкретные имплементации REST (Retrofit 2) и убрать маппинг (Jackson) из описания моделей.

  4. Оставить возможность работать на «низком» уровне (стриминг, более тонкая обработка статусов и т.д.).

Для решения этих проблем мы снова обратились к кодогенерации. 

«Тюним» кодогенерацию

Мы разбили генерацию клиента на два слоя-этапа.

Первый слой — интерфейс микросервиса. На этом уровне генерируется только API сервиса. Никаких аннотаций, упоминаний про Retrofit или что-либо еще. 

Сервисы:

public interface NavigationSettingsService {
   PutNavigationSettingsOutputDto updateNavigationSettings(PutNavigationSettingsInputDto input);
}

Также поступили с моделью данных: генерируем DTO из схемы с билдерами. Таким способом мы полностью изолируем систему от имплементации.

Модели:

public class PutNavigationSettingsInputDto {
   private final int mode;
   private final List<NavigationItem> items;

   protected PutNavigationSettingsInputDto(
           final int mode,
           final List<NavigationItem> items
   ) {
       this.mode = mode;
       this.items = items;
   }

   public static Builder builder(final int mode) {
       return new Builder(mode);
   }

   public static final class Builder {
       private final int mode;
       private List<NavigationItem> items;

       public Builder(final int mode) {
           this.mode = mode;
       }

       public Builder withItems(final List<NavigationItem> items) {
           this.items = items;
           return this;
       }

       public PutNavigationSettingsInputDto build() {
           return new PutNavigationSettingsInputDto(mode, items);
       }
   }
}

Модели не поменялась — мы только убрали аннотации.

Второй слой — имплементация «траспорта». На этом этапе генерируется декларативный Retrofit2-клиент и Jackson mixin-ы для моделей (они нужны, чтобы связать реальную модель с тем, как данные передаются по сети). В качестве дефолтной имплементации интерфейса микросервиса мы добавили вызовы Retrofit2-клиента и перенесли весь бойлерплейт обработки на этот уровень.

Retrofit2-клиент выглядит похожим на предыдущий вариант: мы только добавили дополнительную обертку для ответов методов и вынесли таким способом часть логики из сервиса.

public interface NavigationSettingsServiceGateway {
   @Headers({
           "Content-Type:application/json"
   })
   @PUT("navigation_settings")
   RetrofitCall<PutNavigationSettingsOutputDto> updateNavigationSettingsMobile(@Header("Authorization") String authToken,
                                                                               @Header("account") IdOfAccount accountId,
                                                                               @Body PutNavigationSettingsInputDto input);
}

Retrofit2 — дополнительная «обертка», которая реализует часть логики (в частности, обработки статуса ответа). Эта обертка может быть вынесена в отдельную библиотеку (что мы и планируем сделать в будущем). Сейчас обертка генерируется по шаблону и располагается рядом с остальными классами.

RetrofitCall
public class RetrofitCall<T> implements Call<T>, WrikeCall<T> {
   private static final Logger log = LoggerFactory.getLogger(RetrofitCall.class);

   protected final int retryCount;
   protected Call<T> delegate;

   public RetrofitCall(final Call<T> delegate, final int retryCount) {
       this.delegate = delegate;
       this.retryCount = retryCount;
   }

   private static <T> RemoteCallException processErrorMessage(final Response<T> response) {
       if (response == null) {
           return new RemoteCallException(0, "Unknown gateway error", null);
       }
       return RemoteCallException.of(response.message(), response.code(), getErrorBody(response));
   }

   private static <T> String getErrorBody(final Response<T> response) {
       try {
           return response.errorBody() != null
                   ? response.errorBody().string()
                   : "Null error body";
       } catch (final Exception ex) {
           return "Exception occurred while get error body. " + ex.getMessage();
       }
   }

   @Override
   public T getBody(final boolean withRetries) {
       final Response<T> response = withRetries && retryCount > 1
               ? getResponseWithRetries()
               : getResponse();
       if (response == null || !response.isSuccessful()) {
           throw processErrorMessage(response);
       }
       return response.body();
   }


   @Override
   public Response<T> execute() {
       return getResponse();
   }

   @Override
   public void enqueue(final Callback<T> callback) {
       delegate.enqueue(callback);
   }

   @Override
   public boolean isExecuted() {
       return delegate.isExecuted();
   }

   @Override
   public void cancel() {
       delegate.cancel();
   }

   @Override
   public boolean isCanceled() {
       return delegate.isCanceled();
   }

   @Override
   public RetrofitCall<T> clone() {
       try {
           @SuppressWarnings("unchecked") final RetrofitCall<T> clone = (RetrofitCall<T>) super.clone();
           clone.delegate = delegate.clone();
           return clone;
       } catch (final CloneNotSupportedException ex) {
           throw new RuntimeException(ex);
       }
   }

   @Override
   public Request request() {
       return delegate.request();
   }

   @Override
   public Timeout timeout() {
       return delegate.timeout();
   }

   private Response<T> getResponseWithRetries() {
       Response<T> currentResponse = null;
       RetrofitCall<T> currentCall = null;
       for (int retryNumber = 0; retryNumber < retryCount && (currentResponse == null || !currentResponse.isSuccessful()); ++retryNumber) {
           currentCall = currentCall != null
                   ? currentCall.clone()
                   : this;
           try {
               currentResponse = currentCall.getResponse();
           } catch (final RemoteCallException ex) {
               log.warn("Response getting failed on retry number {}", retryNumber, ex);
               currentResponse = null;
           }
       }
       return currentResponse;
   }

   private Response<T> getResponse() {
       try {
           return delegate.execute();
       } catch (final IOException ex) {
           throw new RemoteCallException(ex);
       }
   }
}

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

public class NavigationSettingsServiceImpl implements NavigationSettingsService {
   protected static final Logger log = LoggerFactory.getLogger(NavigationSettingsServiceImpl.class);

   protected final AuthDataProvider authDataProvider;
   protected final NavigationSettingsServiceGateway gateway;

   public NavigationSettingsServiceImpl(final AuthDataProvider authDataProvider, final NavigationSettingsServiceGateway gateway) {
       this.authDataProvider = authDataProvider;
       this.gateway = gateway;
   }

   @Override
   public PutNavigationSettingsOutputDto updateNavigationSettings(PutNavigationSettingsInputDto input) {
       final AuthDataProvider.AuthData authData = authDataProvider.getAuthData();
       if (log.isDebugEnabled()) {
           log.debug("request 'updateNavigationSettings': userId={}, accountId={}", authData.getUserId(), authData.getAccountId());
       }
      
       return gateway.updateNavigationSettingsMobile(authData.getAuthToken(), authData.getAccountId(), input).getBody();
   }
}

Mixin — специальный способ Jackson добавить описание моделей, не меняя их код.

@JsonPropertyOrder({
       JSON_PROPERTY_MODE,
       JSON_PROPERTY_ITEMS,
})
public abstract class PutNavigationSettingsInputDtoMixin {
   static final String JSON_PROPERTY_MODE = "mode";
   static final String JSON_PROPERTY_ITEMS = "items";

   @JsonCreator
   private PutNavigationSettingsInputDtoMixin(@JsonProperty(JSON_PROPERTY_MODE) final int mode,
                                              @JsonProperty(JSON_PROPERTY_ITEMS) final List<NavigationItem> items) {
   }

   @JsonGetter(JSON_PROPERTY_MODE)
   public abstract int getMode();

   @JsonGetter(JSON_PROPERTY_ITEMS)
   public abstract List<NavigationItem> getItems();
}

Чтобы вся схема заработала с минимальными усилиями, мы подготовили дефолтную конфигурацию Jackson, Retrofit2 и т.д. Разработчику останется только подключить конфигурацию к проекту. 

MixinRegistration + конфигурация: 

public class MixinRegistrationModule extends SimpleModule {

   @Override
   public void setupModule(final SetupContext context) {
       super.setupModule(context);
      
       /// ...
       context.setMixInAnnotations(PutNavigationSettingsInputDto.class, PutNavigationSettingsInputDtoMixin.class);
       context.setMixInAnnotations(PutNavigationSettingsOutputDto.class, PutNavigationSettingsOutputDtoMixin.class);
       /// ...
   }
}
@Configuration
public class JacksonModelMixinsConfiguration {
   @Bean(name = "jacksonObjectMapperMixinCustomizer")
   public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperBuilderCustomizer() {
       return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder
               .modulesToInstall(new MixinRegistrationModule());
   }
}

Регистрация бина сервиса с использованием дефолтной реализации с Retrofit2-клиентом:

public class ApiGatewayConfiguration {
   private final Retrofit retrofit;

   public ApiGatewayConfiguration(final Retrofit.Builder retrofitBuilder) {
       this.retrofit = retrofitBuilder
               .addCallAdapterFactory(retrofitCallAdapterFactory())
               .build();
   }

   protected CallAdapter.Factory retrofitCallAdapterFactory() {
       return new RetrofitCallAdapterFactory();
   }

   protected <T> T createRegionApiGateway(final Class<T> gatewayClass) {
       return retrofit.create(gatewayClass);
   }

   @Bean
   public NavigationSettingsService beanNavigationSettingsService(final AuthDataProvider authDataProvider) {
       return new NavigationSettingsServiceImpl(authDataProvider, createRegionApiGateway(NavigationSettingsServiceGateway.class));
   }

   /// other services

}

На текущий момент мы используем Retrofit2, но такой подход позволяет заменить его на другой инструмент — кастомный http-фреймворк, Spring OpenFeign или что-то другое. 

Возможности Spring Framework 6

В Spring Framework 6 ребята делают кастомные аннотации для описания декларативного клиента. Если при этом они сделают так, чтобы эти аннотации были наравне и на клиентской стороне (декларативный клиент), и на серверной (интерфейс для REST методов), то можно будет сократить количество кодогенерации и вместо схемы использовать одну разделяемую библиотеку с интерфейсом :)

Что получилось в итоге

@Override
public ResponseEntity<WrikeResponseDto> navigationSettingsPut(final AuthInfo principal, @Valid NavigationSettingsPutRequestDto input) {
   final PutNavigationSettingsOutputDto response = navigationSettingsService.updateNavigationSettingsMobile(
           PutNavigationSettingsInputDto.builder(0)
                   .withItems(input.getItems())
                   .build()
   );

   return ResponseEntity.ok(
           WrikeResponseDto.builder(true)
                   .withData(response)
                   .build()
   );
}
  1. Убрали бойлерплейт обработки http/rest.

  2. Вынесли отдельно модель и интерфейс сервиса.

  3. Код обработчика в BFF выглядит чистым и приятно читаемым.

  4. Используем подход для других микросервисов. 

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

Сейчас мы учим систему работать через конкретный интерфейс. Когда будем готовы вынести базу данных и код сервиса отдельно, нам нужно будет заменить транспорт на http, и все должно заработать.

BFF на проде, второй сервис на подходе, и мы почти завершили миграцию. 

Выводы

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

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

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

Такой подход позволяет нам пошагово двигаться от монолита к микросервисам.

Эта статья — пересказ моего доклада с конференции CodeFest 2022. Если хотите посмотреть видео — вот ссылка.

Буду рад ответить на вопросы и комментарии!

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