Как начать писать микросервис на Spring Boot, чтобы потом не болела голова

Привет! Меня зовут Женя, я 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, или наоборот заменить, где не нужно. Лучшим вариантом будет сразу предусмотреть отдельные 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.


Буду рада, если какой-то из пунктов окажется для вас полезным в работе.

Средняя зарплата в IT

120 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 6 532 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 23

  • НЛО прилетело и опубликовало эту надпись здесь
      0

      Ну это как раньше некоторые вместо "логика" всегда говорили "бизнес-логика", теперь вместо "сервис" говорят "микросервис".

      +2

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

        0
        Если говорить, что мэппинг вызывается в контроллере, но сам код или в сервисах-конверторах или в библиотеках типа MapStruct, то холивара не получится
          0

          Полнстью согласен. когда РЕСТ ендпоинт вырождается в


          @GetMapping
          public OperationDto getOperationById(@PathVariable("id") Long id) {
              return operationService.getById(id);
          }

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

            +2

            REST сервис вообще должен выставлять данные как данные, а не что там дизайнеры понапридумывали в очередной версии аппликейшна. Если важно дать потребителям возможность уточнять "вот это мне надо, а вот это — нет", API должен позволять указывать всякие fields=id,name&filter=age>20.


            А наделять API знанием о том, что бывают веб клиенты, а бывают — мобильные, это по-моему кривизна дизайна. Ну или можно не называть это словом REST просто.

          –1
          > Как начать писать микросервис на Spring Boot

          Лучше вообще не начинать
            +3
            1. Оставляем контроллеры тонкими


            @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);
            }

            1. Обычно контроллеру нужно больше контекста, чтобы дать хороший ответ. Нашлось там что запрашивали и есть доступ — 200, нашлось, но нет доступа — 403, не нашлось — 404.
            2. Многие REST API позволяют потребителю указать набор полей, которые он хочет получить: ?fields=id,name. Это знание можно использовать чтобы построить более оптимальный запрос для вытягивания данных из БД (не делать лишний join например).
            3. В случае ресурсов-коллекций конечно же потребитель хочет фильтровать: GET /orders?filter=date<2019-01-01, сортировать: GET /orders?sort=date, и паджинацию: GET /orders?skip=10&take=100. И конечно это всё вместе тоже GET /orders?filter=date<2019-01-01&sort=date&skip=10&take=100&fields=id,name.
            4. PUT (который "создать или обновить") тоже свои нюансы добавляет — там и тривиальная валидация ("name" не может быть пустым) — 400, и "глобальная" ("name" обязано быть уникальным) — 409, и разница между 201 created и 204 no content.
            5. Аж уж PATCH так вообще.

            Моё мнение такое, что если делаешь "REST API фасад к сервису", совершенно всё равно как это делать — получится нечто жёсткое и нерасширяемое (но этого вполне может хватать). Если делаешь "REST API", то лучше с самого начала код писать в терминах моделей HTTP-ресурсов, маппингов между полями данных в API и полями/таблицами в БД.

              +1

              Поддерживаю. REST это об общей архитектуре, а не об API. Если начинать делать HTTP API фасад к сервису, который архитектурно основан не на передаче представлений ресурсов, то лучше подумать почему этот HTTP API должен быть непременно REST и не лучше ли использовать JSON RPC или просто разумный набор HTTP ендпоинтов и принимаемых/отдаваемых данных, соответствующий юзкейсам, типа POST /contract/1/approve без тела, а не делать попыток представлять всё как ресурсы и передавать PATCH /contract/1 {status:"approved" } а потом парсить тело на конкретные значения чтобы определить approve метод сервиса вызывать или cancel, если сервис оперирует понятиями бизнес-логики, а не REST ресурсов.

                +1

                Кконтроллер не должен выбрасывать 403, так как не авторизованный запрос должен быть остановлен фильтрами аутентификации в Spring Security.

                  0

                  В общем случае — нет. Spring Security — это скорее про аутентификацию и роли — когда хочется сказать "если не админ, то точно нет". Логика типа "если вы не друзья с Бобби и у вас нет общих друзей, то фотку Бобби мы вам 403" намного органичнее пишется "прямо по месту".

                    0

                    Не совсем понятно что вы подразумеваете под общим случаем. Есть фрэимворк и он отвечает за аутентификацию на уровне фильтров которые будут делегировать ее authenticationManager, а он в свою очеред выберет нужного authentificationProvider. Далее этот фрэимворк представляет нам возможности авторизации при входе в метод, с обширными возможностями SpEL и иерархией ролей. И мне не совсем понятно в каком случае, кроме "Hello World" мы не должны им пользоваться и делегировать это все нашему контроллеру.

                      +1

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

                    0

                    А если там права на уровне записи надо проверить, типа что текущий пользователь является автором комментарий, который он пытается отредактировать?

                      0
                      Можно заюзать @PreAuthorize
                        +1

                        Можно-то можно, но не понятно зачем.


                        @PutMapping("/comments/{id}")
                        @PreAuthorize("canPutComment(commentId, userId)")
                        public ResponseEntity<?> putComment(@PathVariable("id") commentId) {
                          ...
                        }

                        Т.е. canPutComment() должен будет уметь загружать коммент, т.е. там будет вся эта логика — если коммент есть, то сравниваю автора с текущим пользователем, если коммента нет, то разрешаю, потому что создаётся новый. Аналоничная логика также будет и в putComment() — попытаться найти коммент, если он есть то редактируем, если нет, то создаём. А добились-то чего этим разделением?

                  0

                  Можно просто не использовать Spring, и жить спокойно контролируя свой код самому.

                    0
                    Автор предлагает выключить open-in-view, но маппить в контроллере?
                      0

                      А в чем проблема? Сделайте ваш метод в сервисе транзакционным и сущность выйдет из него уже detached.

                    0
                    проблемы нет. просто за пределами транзакции придется писать дополнительно код, который тащит все lazyload связные сущности до закрытия транзакции(ну или после — с открытием новой).
                      0

                      Изначально не надо тащить такие связи, если вы знаете что они вам понадобятся, вы делаете заррос с Entity Graph и получаете их в один запрос, а не несколько. Если же они вам не нужны вы мапите сущность в дто где их уже не будет.

                        0
                        не, так не работает, мы ж передаем набор «полей», которые хотим получить.
                        Да и что делать с нескольими сущностями (one2many) и lazyload в List — придется все равно что-то свое писать и делать несколько запросов, иначе MultipleBagFetchException.

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

                    Самое читаемое