Разрабатывая информационную систему с нуля, мы можем выбрать практически любой вариант технологии и архитектуры в целом, в том числе — принцип взаимодействия частей системы. Но что делать, если система уже есть и у неё довольно богатая история? Как большую энтерпрайз систему, которая развивалась в режиме монолита, разделить на микросервисы и организовать взаимодействие между ними?
Часто основная сложность заключается в том, что нужно одновременно поддерживать уже существующий код монолита и параллельно внедрять новые принципы и подходы. В статье я расскажу, как мы в Wrike, используя reverse engineering и немного кодогенерации, реализовали первые шаги по выделению отдельных микросервисов и запустили первый «почти настоящий» BFF-сервис в рамках нашего монолита.
Привет! Меня зовут Слава Тютюньков, я Backend Tech Lead в Wrike. В этой статье я хочу поговорить о том, как мы в backend-команде готовились к работе с монолитом, чем в этой задаче нам помог reverse engineering, как мы использовали кодогенерацию, с какими сложностями столкнулись в процессе и что получили в итоге.
Как система выглядит сейчас и к чему мы хотим прийти
Wrike — это SaaS-решение для совместной работы и управления проектами. Архитектура системы представляет собой распределенный монолит — одно большое веб-приложение и около сотни различных дополнительных сервисов рядом. Но несмотря на многообразие сервисов мы не можем назвать текущую архитектуру микросервисной: сервисы работают с общей базой данных, лежат в монорепозитории, большая часть логики сосредоточена в нескольких крупных модулях, разделяемых между всеми сервисами.
При этом у монолита много различных потребителей API: основной web-клиент, мобильные приложения, публичные API и интеграции.
Мы довольно продолжительное время работаем в рамках такой архитектуры, у нас неплохо выстроены процессы вокруг. Например, мы деплоимся ежедневно, обновляя при этом как часть системы, так и всю систему полностью. Но у подобной архитектуры (как и у любой другой) есть недостатки. Мы понимаем, что по мере развития и роста компании нам так или иначе станет «тесно» в рамках монолита. Поэтому мы постепенно движемся в сторону разделения монолита на микросервисы.
Мы хотим, чтобы архитектура в итоге выглядела примерно так:
Но сделать это не так просто по разным причинам:
Продукт не стоит на месте: мы постоянно развиваем его, изменяем функциональность, добавляем новые фичи и т.д.
Есть технические аспекты: код и модули тесно связаны.
Проблемы обратной совместимости по API. Например, у мобильного приложения Wrike отдельный релизный цикл. Нам сложно изменить что-то в монолите и не затронуть при этом мобильное приложение.
Поэтому в первую очередь мы решили изолировать API для мобильного приложения, вынеся его в отдельный сервис — BFF. Таким образом мы сможем отделить монолит от внешнего потребителя API и создать «фасад» для монолита.
BFF позволит нам сосредоточиться на изменениях в самом монолите, его устройстве, внутренних коммуникациях и вынесении отдельных фрагментов. При этом мы сможем не бояться что-то сломать и сделать 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. Мы должны убедиться, что запрос ушел, вернулся с правильным статусом и только после этого можем доставать данные и с ними работать.
Если для каждого вызова внутри микросервисов использовать подобный подход, то это не сильно облегчит нам жизнь. Поэтому мы решили посмотреть, что можно оптимизировать с точки зрения кода.
Что мы хотели изменить:
Избавиться от бойлерплейта.
Не передавать и не заполнять общие параметры.
Абстрагироваться от протокола и маппинга данных. Мы хотели описать микросервис как обычный бин, не завязывать интерфейсы на конкретные имплементации REST (Retrofit 2) и убрать маппинг (Jackson) из описания моделей.
Оставить возможность работать на «низком» уровне (стриминг, более тонкая обработка статусов и т.д.).
Для решения этих проблем мы снова обратились к кодогенерации.
«Тюним» кодогенерацию
Мы разбили генерацию клиента на два слоя-этапа.
Первый слой — интерфейс микросервиса. На этом уровне генерируется только 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()
);
}
Убрали бойлерплейт обработки http/rest.
Вынесли отдельно модель и интерфейс сервиса.
Код обработчика в BFF выглядит чистым и приятно читаемым.
Используем подход для других микросервисов.
Сейчас в команде мобильной разработки мы работаем над выделением одного из микросервисов из монолита. Для этого мы описываем схему сервиса, генерируем интерфейс по этой схеме, а в качестве имплементации подставляем локальные бины, которые у нас уже есть.
Сейчас мы учим систему работать через конкретный интерфейс. Когда будем готовы вынести базу данных и код сервиса отдельно, нам нужно будет заменить транспорт на http, и все должно заработать.
BFF на проде, второй сервис на подходе, и мы почти завершили миграцию.
Выводы
Выбор подхода — важный шаг при перестроении системы. Какие технологии будут использоваться, как все элементы системы связываются воедино — об этом стоит договориться заранее. Мы потратили на этот этап достаточно много времени и пересмотрели разные варианты решения проблемы, но оно того стоило.
Реверс инжениринг — хороший способ описать текущую систему. Изучение текущего кода, его обработка и получение схемы позволяет описать систему и решает проблему автоматизации.
Кодогенерация упрощает жизнь и избавляет от бойлерплейта. Кодогенерация помогает решить много задач и позволяет унифицировать подход, чтобы разработчики подходили к реализации одинаково.
Такой подход позволяет нам пошагово двигаться от монолита к микросервисам.
Эта статья — пересказ моего доклада с конференции CodeFest 2022. Если хотите посмотреть видео — вот ссылка.
Буду рад ответить на вопросы и комментарии!