Привет! Меня зовут Женя, я Java-разработчик в Usetech, в последнее время много работаю с микросервисной архитектурой, и в этой статье хотела бы поделиться некоторыми моментами, на которые может быть полезно обратить внимание, когда вы пишете новый микросервис на Spring Boot.
Опытные разработчики могут счесть приведенные рекомендации очевидными, однако все они взяты из практики работы над реальными проектами.
1. Оставляем контроллеры тонкими
В традиционной слоистой архитектуре класс контроллера принимает запросы и направляет их сервису, а сервис занимается бизнес-логикой. Однако иногда в методах контроллера можно встретить какие-либо проверки входных параметров, а также преобразование Entity в DTO.
Например:
@GetMapping
public OperationDto getOperationById(@PathVariable("id") Long id) {
Optional<Operation> operation = operationService.getById(id);
if (operation.isEmpty()) {
return EMPTY_OPERATION_DTO;
}
OperationDto result = mapperFacade.map(operation.get(), OperationDto.class);
return result;
}
С одной стороны, маппинг занимает всего одну строку, да и проверка на отсутствие результата смотрится вполне логично. Однако в подобном случае нарушается принцип единой ответственности контроллера. Пока валидация или маппинг простые, пара лишних строк кода в методе контроллера совсем не бросаются в глаза, но в дальнейшем логика как валидации, так и маппинга может усложниться, и тогда станет очевидно, что контроллер не только принимает и перенаправляет запросы, но еще и занимается бизнес-логикой.
Чтобы не пришлось потом проводить объемный рефакторинг, лучше сразу, пока микросервис еще содержит минимальную функциональность, сделать все контроллеры "тонкими", лишь вызывающими методы сервиса, а валидацию и маппинг осуществлять в сервисном классе и возвращать из него сразу DTO.
Метод контроллера после рефакторинга:
@GetMapping
public OperationDto getOperationById(@PathVariable("id") Long id) {
return operationService.getById(id);
}
Метод сервиса после рефакторинга:
public OperationDto getById(Long id) {
Optional<Operation> operationOptional = ... //логика получения operation
return operationOptional
.map(operation -> mapperFacade.map(operation, OperationDto.class))
.orElse(EMPTY_OPERATION_DTO);
}
2. Используем разные DTO для разных случаев
Представьте микросервис, который отдает некие данные в виде DTO по REST API, а также пишет сообщения в виде DTO в Kafka. В начале жизни микросервиса состав данных, которые нужно отдать по REST и передать в Kafka, может быть одинаков, что может спровоцировать нас использовать один и тот же DTO в обоих случаях. Проблема усугубится, если мы используем один DTO для еще большего количества разных клиентов.
Если в дальнейшем изменятся требования по составу данных для различных случаев, и мы решим выделить отдельные ДТО, то появится риск в процессе выполнения этой задачи пропустить место, где нужно произвести замену на новое DTO, или наоборот заменить, где не нужно. Лучшим вариантом будет сразу предусмотреть отдельные DTO для разных случаев, даже если сейчас они совпадают по полям.
3. Вычищаем WARN-ы, пока их мало
После того, как первая задача, реализующая минимальную функциональность нового микросервиса, готова, важно не забыть обратить внимание на предупреждения, которые выводит Spring Boot в логах, и по возможности сразу исправить их, чтобы они не накопились как снежный ком.
Пример WARN, который выводится даже у "пустого" Spring Boot 2 приложения с Hibernate:
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
Это предупреждение сообщает, что по-умолчанию в Spring Boot 2 включен режим Open Session In View, при котором сессия Hibernate держится открытой все время обработки HTTP-запроса.
Хотя режим Open Session In View позволяет избежать LazyInitializationException, которое возникает если мы пытаемся получить данные, когда сессия Hibernate уже закрыта, тем не менее, его использование является анти-паттерном. Этот режим провоцирует проблемы с производительностью приложения, поскольку приложение держит долгое соединение с базой данных, а также значительно увеличивается количество запросов, так как каждая связанная с сущностью коллекция будет загружена отдельным запросом (проблема n+1). Подробнее об этом можно прочитать в статье.
Для того, чтобы отключить режим Open Session In View нужно сделать как раз то, что написано в предупреждении — добавить в application.yml настройку:
spring:
jpa:
open-in-view: false
4. Кэшируем контекст в тестах
Как известно, интеграционные тесты использованием @SpringBootTest
поднимают контекст приложения и создают бины, благодаря чему мы можем протестировать приложение не с заглушками, а с настоящими бинами. Однако если у нас есть несколько тестовых классов, использующих аннотацию @SpringBootTest
, то в некоторых случаях по логам можно заметить, что Spring поднимает контекст заново на каждый тестовый класс. На корректность тестов это не влияет, однако сильно влияет на общее время прохождения тестов и соответственно время сборки проекта. Если правильно настроить тестовые классы, то контекст будет подниматься всего один раз, кэшироваться и использоваться для всех тестовых классов.
Что заставляет контекст перезапускаться заново:
- наличие у некоторых тестовых классов аннотации
@Import
, подтягивающей дополнительную конфигурацию - разные профили
@ActiveProfiles
у разных тестовых классов - аннотация
@MockBean
из библиотеки Mockito — здесь реальный бин из приложения заменяется его моком, соответственно из-за изменения набора бинов создается новый контекст - аннотация
@TestPropertySource
— изменение свойств автоматически меняет ключ кэша, вследствие чего создается новый контекст - аннотация
@DirtiesContext
над классом — явное указание, что нужно перезапустить контекст после этого класса
Чтобы контекст кэшировался нужно устранить все перечисленные причины, т.е. явные различия в настройках тестовых классов. Мне подошел подход с созданием базового абстрактного тестового класса, который:
- помечен аннотацией
@SpringBootTest
- подтягивает сразу все нужные классы конфигурации и свойства для всех дочерных тестов
- использует общий тестовый профиль для всех тестов
@ActiveProfiles("test")
- содержит в качестве
protected
полей все нужные дочерним классам реальные бины (помечены@Autowired
) и мок-бины (@MockBean
)
Иногда в этом классе также может быть удобно держать метод очистки, запускаемый после каждого теста (@AfterEach
), однако это уже дело вкуса/особенностей тестов.
Тестовые классы, в свою очередь, наследуются от абстрактного базового класса, не содержат конфигурации и даже полей, а только setUp
метод и сами тестовые методы.
В случае реальной необходимости перезапустить контекст у дочернего тестового класса можно использовать @DirtiesContext
.
Буду рада, если какой-то из пунктов окажется для вас полезным в работе.
Автор: Евгения Янченко