К микросервисам через reverse engineering и кодогенерацию

от автора

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

Часто основная сложность заключается в том, что нужно одновременно поддерживать уже существующий код монолита и параллельно внедрять новые принципы и подходы.  В статье я расскажу, как мы в 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. Если хотите посмотреть видео — вот ссылка.

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


ссылка на оригинал статьи https://habr.com/ru/articles/678208/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *