Хабрахабр

Spring Boot 2: чего не пишут в release notes

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

А теперь специально для Хабра — текстовая версия этого доклада. На конференции Joker 2018 я рассказал, с какими проблемами столкнулся сам при переходе к Spring Boot 2 и как они решаются. Для удобства в посте есть и видеозапись, и оглавление: можно не читать всё целиком, а перейти непосредственно к волнующей вас проблеме.

Оглавление

Хочу рассказать вам о некоторых особенностях (назовём их граблями), с которыми вы можете столкнуться при обновлении фреймворка Spring Boot на вторую версию и при последующей его эксплуатации. День добрый!

Последние несколько лет занимаюсь там бэкенд-разработкой, отвечая за техническое развитие интернет-банка предоплаченных карт. Меня зовут Владимир Плизгá (GitHub), я работаю в компании «ЦФТ», одном из крупнейших и старейших разработчиков ПО в России. Ну и коль скоро большинство тех знаний, которыми я решил с вами поделиться, накоплено на примере именно этого проекта, расскажу о нём чуть-чуть поподробнее. Как раз на этом проекте я стал инициатором и исполнителем перехода от монолитной архитектуры к микросервисной (который ещё длится).

Коротко о подопытном продукте

Это интернет-банк, который в одиночку обслуживает порядка двух с лишним десятков компаний-партнёров по всей России: предоставляет конечным клиентам возможность управлять их денежными средствами с помощью дистанционного банковского обслуживания (мобильные приложения, сайты). Один из партнеров — компания Билайн и её платёжная карта. Интернет-банк для нее получился неплохим, судя по рейтингу Markswebb Mobile Banking Rank, где наш продукт занял неплохие позиции для новичков.

Внутри у микросервисов Spring Cloud Netflix, Spring Integration и кое-что ещё. «Кишочки» всё ещё в переходном процессе, поэтому у нас есть один монолит, так называемое ядро, вокруг которого возведены 23 микросервиса. И вот как раз на этом месте остановимся поподробнее. А на Spring Boot 2 всё это дело летает примерно с июля месяца. Переводя этот проект на вторую версию, я столкнулся с некоторыми особенностями, о которых и хочу вам рассказать.

План доклада

Чтобы сделать это быстро, нам понадобится опытный сыщик или следователь — кто-то, кто всё это раскопает как будто бы за нас. Областей, где появились особенности Spring Boot 2, довольно много, постараемся пробежаться по всем. Вперёд! Поскольку Холмс с Ватсоном уже выступили с докладом на Joker, нам будет помогать другой специалист — лейтенант Коломбо.

Spring Boot / 2

Для начала пару слов о Spring Boot в целом и второй версии в частности. Во-первых, вышла эта версия, мягко говоря, не вчера: 1 марта 2018 она уже была в General Availability. Одна из главных целей, которую преследовали разработчики, — это полноценная поддержка Java 8 на уровне исходников. То есть скомпилировать на меньшей версии не удастся, хотя runtime совместим. В качестве основы взят Spring Framework пятой версии, который вышел чуть-чуть раньше Spring Boot 2. И это не единственная зависимость. Ещё у него есть такое понятие, как BOM (Bill Of Materials) — это огромный XML, в котором перечислены все (транзитивные для нас) зависимости от всевозможных сторонних библиотек, дополнительных фреймворков, инструментов и прочего.

На всё это хозяйство написано два отличных документа: Release Notes и Migration Guide. Соответственно, не все те спецэффекты, которые привносит второй Spring Boot, произрастают из него самого или из экосистемы Spring. Но, по понятным причинам, там возможно охватить далеко не всё: есть какие-то частности, отклонения и прочее, что либо нельзя, либо не стоит туда включать. Документы классные, Spring в этом смысле вообще молодцы. О таких особенностях и поговорим.

Compile time. Примеры изменений в API

Начнём с более-менее простых и очевидных граблей: это те, что возникают в compile time. То есть то, что не даст вам даже скомпилировать проект, если вы просто поменяете у Spring Boot в скрипте сборки цифру 1 на цифру 2.

Кроме того, веб-стек Spring 5 и Spring Boot 2 разделился, условно говоря, на два. Основной источник изменений, который стал основанием для таких правок в Spring Boot, — это, конечно, переход Spring на Java 8. Кроме того, потребовалось учесть ряд недочётов из прошлых версий. Теперь он сервлетный, традиционный для нас, и реактивный. Если посмотреть в Release Notes, то никаких подводных камней с ходу не видно и, честно говоря, когда я впервые читал Release Notes, мне показалось, там вообще всё нормально. Ещё сторонние библиотеки поднакинули (извне Spring). И выглядело для меня это примерно вот так:

Но, как вы наверняка догадываетесь, всё не так хорошо.

На чем сломается компиляция (пример 1):

  • Почему: класса WebMvcConfigurerAdapter больше нет;
  • Зачем: для поддержки фишек Java 8 (default-методы в интерфейсах);
  • Что делать: использовать интерфейс WebMvcConfigurer.

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

На чем сломается компиляция (пример 2):

  • Почему: метод PropertySourceLoader#load стал возвращать список источников вместо одного;
  • Зачем: для поддержки мульти-документных ресурсов, например, YAML;
  • Что делать: оборачивать ответ в singletonList() (при переопределении).

Некоторые методы изменили даже сигнатуры. Пример из совсем другой области. Соответственно, это позволило поддержать мульти-документные ресурсы. Если вам доводилось использовать метод load PropertySourceLoader, то он теперь возвращает коллекцию. Если теперь вам понадобилось с ним работать из Java, имейте в виду, что это нужно делать через коллекцию. Например, в YAML через три чёрточки можно указать кучу документов в одном файле.

На чем сломается компиляция (пример 3):

  • Почему: некоторые классы из пакета org.springframework.boot.autoconfigure.web разъехались по пакетам org.springframework.boot.autoconfigure.web.servlet и .reactive;
  • Зачем: чтобы поддержать реактивный стек наравне с традиционным;
  • Что делать: обновить импорты.

Например, то, что раньше лежало в одном пакете web, теперь разъехалась по двум пакетам с кучей классов. Ещё больше изменений было привнесено тем самым разделением стеков. Зачем сделано? Это .servlet и .reactive. Нужно было сделать это так, чтобы они могли поддерживать свой собственный жизненный цикл, развиваться в своих направлениях и не мешать друг другу. Потому что реактивный стек не должен был стать огромным костылём поверх сервлетного. Достаточно поменять импорты: большинство из этих классов остались совместимыми на уровне API. Что с этим делать? Большинство, но не все.

На чем сломается компиляция (пример 4):

  • Почему: поменялась сигнатура методов класса ErrorAttributes: вместо RequestAttributes стали использоваться WebRequest(servlet) и ServerRequest(reactive);
  • Зачем: чтобы поддержать реактивный стек наравне с традиционным;
  • Что делать: заменить имена классов в сигнатурах.

Причина всё та же самая. Например, в классе ErrorAttributes отныне вместо RequestAttributes в методах стали использоваться два других класса: это WebRequest и ServerRequest. Если вы именно переходите с первого на второй Spring Boot, то надо поменять RequestAttributes на WebRequest. А что с этим делать? Очевидно, не правда ли?.. Ну а если вы уже на втором, то использовать ServerRequest.

Как быть?

Таких примеров довольно много, мы не будем разбирать по полочкам их все. Что с этим делать? Прежде всего, стоит поглядывать в Spring Boot 2.0 Migration Guide для того, чтобы вовремя заметить касающееся вас изменение. Например, в нём упоминаются переименования совершенно неочевидных классов. Ещё, если уж всё-таки что-то разъехалось и поломалось, стоит учитывать, что понятие «web» разделилось на 2: «servlet» и «reactive». При ориентации во всяких классах и пакетах это может помогать. Кроме того, надо иметь в виду, что переименовались не только сами классы и пакеты, но и целые зависимости и артефакты. Как это, например, произошло со Spring Cloud.

Content-Type. Определение типа HTTP-ответа

Хватит об этих простых вещах из compile time, там всё понятно и просто. Поговорим о том, что может твориться во время исполнения и, соответственно, может выстрелить, даже если Spring Boot 2 у вас уже давно работает. Поговорим об определении content-type.

И одна из прелестей, за которые Spring так любят, — это то, что можно вообще не заморачиваться с определением отдаваемого типа у себя в коде. Ни для кого не секрет, что на Spring можно писать веб-приложения, причём как страничные, так и REST API, и они могут отдавать контент с самыми разными типами, будь то XML, JSON или что-то ещё. Эта магия работает, условно говоря, тремя разными способами: либо полагается на заголовок Accept, пришедший от клиента, либо на расширение запрошенного файла, либо на специальный параметр в URL, которым, естественно, тоже можно рулить. Можно надеяться на магию.

Здесь и далее я буду использовать нотацию от Gradle, но даже если вы поклонник Maven, вам не составит труда понять, что здесь написано: мы собираем малюсенькое приложение на первом Spring Boot и используем всего один starter web. Рассмотрим простенький примерчик (полный исходный код).

Пример (v1.x):

dependencies compile("org.springframework.boot:spring-boot-starter-web:$springBootVersion")
}

В качестве исполняемого кода у нас один-единственный класс, в котором сразу объявлен метод контроллера.

@GetMapping(value = "/download/{fileName: .+}", produces = {TEXT_HTML_VALUE, APPLICATION_JSON_VALUE, TEXT_PLAIN_VALUE})
public ResponseEntity<Resource> download(@PathVariable String fileName) { //формируем только тело ответа, без Content-Type
}

Он действительно формирует его контент в одном из трёх указанных типов (определяя это по имени файла), но никак не задает content-type — у нас же Spring, он сам всё сделает. Он принимает на вход некое имя файла, которое якобы сформирует и отдаст.

Действительно, если мы будем запрашивать один и тот же документ с разными расширениями, он будет отдаваться с правильным content-type в зависимости от того, что мы возвращаем: хочешь — json, хочешь — txt, хочешь — html. В общем-то, можно даже попробовать так сделать. Работает как в сказке.

Обновляем до v2.x

dependencies { ext { springBootVersion = '2.0.4.RELEASE' } compile("org.springframework.boot:spring-boot-starter-web:$springBootVersion")
}

Мы просто меняем цифру 1 на 2. Приходит время обновляться на второй Spring Boot.

Но там упоминается какой-то «suffix path matching».
Spring MVC Path Matching Default Behavior Change
Но мы же инженеры, мы ещё заглянем в Migration Guide, а вдруг там что-нибудь про это сказано. Но это не наш случай, хотя немножко похоже. Речь о том, как правильно маппить методы в Java с URL.

— внезапно не работает. Поэтому забиваем, проверяем и бах! Почему так? Почему-то везде начинает отдаваться просто text/html, а если покопать, то не именно text/html, а просто первый из типов, указанных вами в атрибуте produces на аннотации @GetMapping. Выглядит, мягко говоря, непонятно.

И здесь уже никакие Release Notes не помогут, придётся почитать исходники.

ContentNegotiationManagerFactoryBean

public ContentNegotiationManagerFactoryBean build() { List<ContentNegotiationStrategy> strategies = new ArrayList<>(); if (this.strategies != null) { strategies.addAll(this.strategies); } else { if (this.favorPathExtension) { PathExtensionContentNegotiationStrategy strategy; // …

Значение этого флажка «истина» соответствует применению некой стратегии с другим понятным коротким лаконичным именем, из которого понятно, что она как раз отвечает за определение content-type по расширению файла. Там можно будет найти классик с очень понятным лаконичным коротким именем, в котором упоминается некий флажок под названием «учитывай расширение в пути» (favorPathExtension). Как видите, если флажок будет равен «ложь», то стратегия не применится.

Да, наверное, многие замечали, что в Spring, видимо, есть какой-то гайдлайн, чтобы имя обязательно было ну хотя бы из двадцати символов.

В самом Spring-фреймворке, причём не в пятой версии, как можно бы было ожидать, а испокон веков этот флажок по умолчанию равен «истина». Если покопаться ещё чуть глубже, то можно нарыть вот такой фрагмент. То есть теперь мы можем рулить им из энвайронмента, и это только во второй версии. В то время как в Spring Boot и именно во второй версии он был перекрыт ещё другим, который теперь доступен для управления из настроек. Там он уже принял значение «ложь». Чуете? Что с этим делать? То есть хотели, вроде как, сделать как лучше, вынесли этот флажок в настройки (и это здорово), но значение по умолчанию переключили на другое (это уже не очень).
Разработчики фреймворка тоже люди, им тоже свойственно ошибаться. Понятно, надо переключить параметр у себя в проекте, и всё будет хорошо.

И там он действительно упоминается, но только в каком-то странном контексте: Единственное, что стоит сделать на всякий случай, для очистки совести, — это заглянуть в документацию на Spring Boot просто на предмет какого-нибудь упоминания этого флажка.

If you understand the caveats and would still like your application to use suffix pattern matching, the following configuration is required:
spring.mvc.contentnegotiation.favor-path-extension=true

Чувствуете расхождение? Написано, дескать, если вы понимаете все заковырки и всё ещё хотите использовать suffix path matching, то ставьте этот флажок. Выглядит как-то непонятно. Вроде как мы говорим-то об определении content-type в контексте этого флажка, а здесь речь о матчинге Java-методов и URL.

На GitHub есть вот такой pull request: Приходится закапываться дальше.

То есть, другими словами, флажок относится и к тому, и к другому, и они неразрывно связаны. В рамках данного пулл-реквеста были сделаны эти изменения — переключение значения по умолчанию — и там один из авторов фреймворка говорит, что у этой проблемы есть два аспекта: один — это как раз-таки path matching, а второй — это определение content-type.

Можно бы было, конечно, найти это сразу на GitHub, если б знать только, где искать.


Suffix match

Более того, оно показало себя проблематичным в ряде случаев. Более того, в документации на сам Spring Framework ещё говорится, что использование расширений файлов было необходимо раньше, однако теперь более не считается необходимостью.

Резюмируем

Изменение значения флажка по умолчанию — это вовсе не баг, а фича. Она неразрывно связана с определением path matching и призвана делать три вещи:

  • снизить риски по безопасности (какие именно, я уточню);
  • выровнять поведение WebFlux и WebMvc, они отличались в этом аспекте;
  • выровнять заявление в документации с кодом фреймворка.

Как быть?

Во-первых, по возможности нужно не полагаться на определение content-type по расширению. Тот пример, который я показал, — это контрпример, так делать не надо! Равно как и не надо полагаться на то, что запросы вида «GET что-нибудь.json», например, смапятся просто на «GET что-нибудь». Так было в Spring Framework 4 и в Spring Boot 1. Больше так не работает. Если нужно смапиться на файл с расширением, это нужно делать в явном виде. Вместо этого лучше полагаться на заголовок Accept либо на URL-параметр, именем которого вы можете рулить. Ну если это никак не сделать, допустим, у вас какие-то старые мобильные клиенты, которые перестали обновляться в прошлом веке, то придётся вернуть этот флажок, выставить его в «true», и всё будет работать как раньше.

Кроме того, для общего понимания можно почитать главу «Suffix match» в документации на Spring Framework, она самими разработчиками считается своеобразным сборником best practices в этой области, и ознакомиться с тем, что такое атака Reflected File Download, как раз реализуемая с помощью манипуляций с расширением файлов.

Scheduling. Выполнение задач по расписанию или периодически

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

Пример задачи. Выводить сообщение в лог каждые 3 секунды

О чём идёт речь, я думаю, понятно. У нас есть какие-то бизнес-потребности, делать что-либо с каким-то повтором, поэтому мы сразу перейдём к примеру. Допустим, у нас стоит мегасложная задача: выводить в лог какую-нибудь гадость каждые 3 секунды.

И найти это — способов уйма. Сделать это можно, очевидно, самыми разными способами, под них по-любому уже что-нибудь есть в Spring.

Вариант 1: поиск примера в своём проекте

/** *A very helpful service */
@Service
public class ReallyBusinessService { // … a bunch of methods … @Scheduled(fixedDelay = 3000L) public void runRepeatedlyWithFixedDelay() { assert Runtime.getRuntime().availableProcessors() >= 4; } // … another bunch of methods …
}

На публичном методе будет висеть одна аннотация, и из неё будет понятно, что как только её вешаешь, всё работает прям как в сказке. Мы можем посмотреть в нашем же проекте и наверняка найдём что-нибудь вот такое.

Вариант 2: поиск нужной аннотации

Можно саму аннотацию поискать прямо по названию, и наверняка тоже будет понятно из документации, что её вешаешь — и всё работает.

Вариант 3: Googling

Если себе веры нет, то можно загуглить, и по найденному тоже будет понятно, что с одной аннотации всё заведётся.

@Component
public class EventCreator { private static final Logger LOG = LoggerFactory.getLogger(EventCreator.class); private final EventRepository eventRepository; public EventCreator(final EventRepository eventRepository) { this.eventRepository = eventRepository; } @Scheduled(fixedRate = 1000) public void create() { final LocalDateTime start = LocalDateTime.now(); eventRepository.save( new Event(new EventKey("An event type", start, UUID.randomUUID()), Math.random() * 1000)); LOG.debug("Event created!"); }
}

Мы же инженеры всё-таки, давайте проверим, как это работает в реальности. Кто видит в этом подвох?

Show me the code!

Рассмотрим конкретную задачу (сама задача и код есть в моем репозитории)
Кто не хочет читать, можете посмотреть вот этот фрагмент видео с демонстрацией (до 22-й минуты):

Один — для веба, мы же вроде как веб-сервер разрабатываем, а второй — spring starter actuator, чтобы у нас были production-ready фичи, чтобы мы были хотя бы немножко похожи на что-то настоящее. В качестве зависимости будем использовать первый Spring Boot с двумя стартерами.

dependencies { ext { springBootVersion = '1.5.14.RELEASE'
// springBootVersion = '2.0.4.RELEASE' } compile("org.springframework.boot:spring-boot-starter-web:$springBootVersion") compile("org.springframework.boot:spring-boot-starter-actuator:$springBootVersion")
// +100500 зависимостей в случае настоящего приложения
}

А исполняемый код у нас будет ещё проще.

package tech.toparvion.sample.joker18.schedule; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.Scheduled; @SpringBootApplication
public class EnableSchedulingDemoApplication { private static final Logger log = LoggerFactory.getLogger(EnableSchedulingDemoApplication.class); public static void main(String[] args) { SpringApplication.run(EnableSchedulingDemoApplication.class, args); } @Scheduled(fixedRate = 3000L) public void doOnSchedule() { log.info(“Еще 3 секунды доклада потрачено без дела…”); }
}

Мы её где-то скопипастили и ожидаем, что она будет работать. Вообще практически ничего примечательного, кроме одного-единственного метода, на который мы навешали аннотацию.

Запускаем. Давайте проверим, мы же инженеры. Всё должно работать из коробки, мы убеждаемся, что у нас всё запущено именно на первом Spring Boot, и ожидаем вывода нужной строчки. Мы предполагаем, что каждые три секунды в лог будет выводиться такое сообщение. Оптимисты победили, всё работает. Проходит три секунды — строчка выводится, проходит шесть — строчка выводится.

Не будем заморачиваться, просто переключимся с одного на другой: Только приходит время обновляться на второй Spring Boot.

dependencies { ext {
// springBootVersion = '1.5.14.RELEASE' springBootVersion = '2.0.4.RELEASE' }

С точки зрения исполняемого кода, никакие из других граблей, о которых я упоминал раньше (несовместимость на уровне API или что-то ещё) здесь у нас нет, поскольку приложение максимально простое. По идее, Migration Guide нас ни о чём не предупреждал, и мы ожидаем, что всё будет работать без отклонений.

Первым делом убеждаемся, что мы работаем на втором Spring Boot, в остальном никаких, вроде бы, отклонений нет. Запускаем.

Нам часто пишут в документации, что на самом деле в Spring Boot всё работает из коробки, что мы вообще можем с минимальными заморочками просто запуститься как есть, и никакой конфигурации не потребуется. Однако проходит 3 секунды, 6, 9, а Германа всё нет — никакого вывода, ничего не работает.
Как это часто бывает, ожидание расходится с реальностью. В частности, если хорошенько покопаться, там можно найти вот такие строчки: Но как только дело доходит до реальности, часто выясняется, что надо бы всё-таки почитать документацию.

3. 7. Enable Scheduling Annotations
To enable support for @Scheduled and Async annotations, you can add @EnableScheduling and @EnableAsync to one of your @Configuration classes. 1.

Для того, чтобы заработала аннотация Scheduled, надо повесить ещё одну аннотацию на класс с ещё одной аннотацией. Ну, как обычно в Spring. Но почему оно раньше-то работало? Мы же вроде ничего такого не делали. Очевидно, эта аннотация где-то висела раньше в первом Spring Boot, а сейчас во втором её почему-то нет.

Находим, что есть какой-то класс, на котором она якобы висит. Начинаем рыться в исходниках первого Spring Boot. Смотрим ближе, он называется «MetricExportAutoConfiguration» и, судя по всему, отвечает за поставку этих метрик производительности вовне, в какие-нибудь централизованные агрегаторы, и на нём действительно есть эта аннотация.

Именно этот класс был поставщиком этого поведения, а потом почему-то не стал. Причём она работает так, что включает своё поведение на всё приложение сразу, её не надо вешать на отдельные классы. Почему?

Почему? Всё тот же GitHub наталкивает нас на такую археологическую раскопку: в рамках перехода на вторую версию Spring Boot этот класс был выкошен вместе с аннотацией. Вот только вместе с ним удалилось кое-что лишнее. Да потому что движок поставки метрик тоже изменился: они больше не стали использовать свой самописный, а перешли на Micrometer — действительно осмысленное решение. Может быть, это даже правильно.

Кто не хочет читать, смотрите коротенькое демо на 30 секунд:

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

package tech.toparvion.sample.joker18.schedule; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled; @SpringBootApplication
@EnableScheduling
public class EnableSchedulingDemoApplication { private static final Logger log = LoggerFactory.getLogger(EnableSchedulingDemoApplication.class); public static void main(String[] args) { SpringApplication.run(EnableSchedulingDemoApplication.class, args); } @Scheduled(fixedRate = 3000L) public void doOnSchedule() { log.info(“Еще 3 секунды доклада потрачено без дела…”); }
}

Давайте проверять. Как думаете, заработает? Запускаем.

Видно, что через 3 секунды, через 6 и через 9 сообщение, ожидаемое нами, в лог всё-таки выводится.

Как быть?

Что с этим делать в этом конкретном и в более общем случае? Как бы нравоучительно это ни звучало, во-первых, стоит читать не только копируемые фрагменты документации, но и чуть шире, как раз чтобы охватывать вот такие аспекты.

Во-вторых, помнить, что в Spring Boot хоть многие фичи есть из коробки (scheduling, async, caching, …), они не всегда включены, их нужно явно включать.

Но тогда возникает вопрос: а что будет, если случайно я и мои коллеги добавим несколько аннотаций, как они себя поведут? В-третьих, не мешает перестраховаться: добавлять аннотации Enable* (а их целое семейство) в свой код, не надеясь на фреймворк. А на самом деле: почти никогда. Сам фреймворк утверждает, что дублирование аннотаций никогда не приводит к ошибкам. Дело в том, что некоторые из этих аннотаций имеют атрибуты.

Следовательно, вы можете случайно задать эти аннотации в двух местах с разным значением атрибутов. Например, @EnableAsync и EnableCaching имеют атрибуты, которые, в частности, управляют тем, в каком режиме будут проксироваться бины для того, чтобы реализовать соответствующую функциональность. Частично на этот вопрос отвечает javadoc на один из классов, как раз причастных к этой функциональности. Что в этом случае будет? Он знает о том, что есть несколько возможных Enable*, но по большому счёту ему всё равно, какой именно он выберет. Он говорит, что этот регистратор работает путём поиска ближайшей аннотации. А вот об этом мы как раз и поговорим в следующем кейсе. К чему это может привести?

Spring Cloud & Co. Совместимость библиотек

Ещё в качестве мониторинга прикрутим JavaMelody. Возьмём за основу маленький микросервис на Spring Boot 2 в качестве базы, навернём на него Spring Cloud — нам понадобится только его фича Service Discovery (обнаружение сервисов по имени). Не важно, какая, лишь бы поддерживала JDBC, поэтому возьмём простейшую H2.
И ещё нам понадобится какая-нибудь традиционная база.

Удобно в dev-окружении, в test, а в бою она умеет экспортировать метрики для потребления каким-нибудь централизованным инструментом, типа Prometheus. Не в качестве рекламы, а просто для общего понимания скажу, что JavaMelody — это встроенный мониторинг, к которому можно обратиться прямо из приложения и посмотреть всякие графики, метрики и прочее.

Наш замес будет выглядеть на Gradle вот таким образом:

dependencies { ext { springBootVersion = '2.0.4.RELEASE' springCloudVersion = '2.0.1.RELEASE' } compile("org.springframework.boot:spring-boot-starter-web:$springBootVersion") runtime("org.springframework.boot:spring-boot-starter-jdbc:$springBootVersion") runtime group: "org.springframework.cloud", name: "spring-clooud-starter-netflix-eureka-client", version: springCloudVersion runtime("net.bull.javamelody:javamelody-spring-boot-starter:1.72.0") //…
}

(полный исходный код)

Исполняемого кода у нас вообще практически не будет. Мы берём две зависимости от Spring Boot — это web и jdbc, от Spring Cloud берём его клиента к eureka (это, если кто не знает, как раз фишка Service Discovery), и сам JavaMelody.

@SpringBootApplication
public class HikariJavamelodyDemoApplication { public static void main(String[] args) { SpringApplication.run(HikariJavamelodyDemoApplication.class, args); }
}

Запускаем.

Выглядеть это будет не очень приятно, а в самом конце лога ошибок будет сказано, что якобы не удалось скастить какой-то там com.sun.proxy к Hikari, HikariDataSource. Такое приложение развалится прямо при старте. На всякий случай поясню, что Hikari — это пул коннектов к базе данных, такой же как Tomcat, C3P0 или прочее.

Тут нам как раз понадобится помощь следователя. Почему так произошло?

Материалы дела

Spring Cloud оборачивает dataSource в прокси

Следователь накопал, что Spring Cloud здесь причастен тем, что он оборачивает dataSource (единственный в этом приложении), в прокси. Делает он это для того, чтобы поддержать фичу AutoRefresh или RefreshScope — это когда конфигурацию микросервиса можно подтягивать из другого централизованного микросервисного конфига и на лету её применять. Как раз за счёт прокси он это обновление и проворачивает. Для этого он использует только CGLIB.

Обёртывание производится раньше всех BeanPostProcessor’ов за счёт подмены BeanDefinition и задания так называемого фабричного бина, который выпускает целевой бин сразу обернутым в прокси. Как вы, наверное, знаете в Spring Boot и в принципе в Spring поддерживаются два механизма проксирования: на основе встроенного в JDK механизма (тогда проксируется не сам бин, а его интерфейс) и с помощью библиотеки CGLIB (тогда проектируется сам бин).

JavaMelody оборачивает dataSource в прокси

Второй участник — это JavaMelody. Он тоже оборачивает DataSource в прокси, но делает это для снятия метрик, чтобы перехватывать вызовы и записывать их в своё хранилище. JavaMelody использует только JDK-проксирование, потому что больше никак не умеет, просто не предусмотрели. Но работает он более традиционным способом — при помощи BeanPostProcessor.

Получилась вот такая матрешка: Если посмотреть на всё это через призму дебаггера, то будет видно, что непосредственно перед падением DataSource выглядел как обертка в JDK-прокси, внутри которой обёртка CGLIB-прокси.

Если только не учитывать тот факт, что не все обёртки друг с другом хорошо работают. Само по себе это неплохо.

Spring Boot вызывает dataSource.unwrap()

Масло в огонь подливает Spring Boot, он делает на этом DataSource#unwrap(), чтобы провалидировать этот бин перед выставлением по JMX. В этом случае JDK-прокси свободно пропускает через себя этот вызов (поскольку ей нечего с ним делать), а CGLIB-прокси, которую добавил Spring Cloud, снова запрашивает бин у Spring Context. Естественно, получает полноценную матрёшку, у которой на внешнем уровне JDK-обёртка, применяет к ней CGLIB API и на этом ломается.

Если показать то же самое в картинках, то выглядит это примерно так:


https://jira.spring.io/browse/SPR-17381

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

При чём тут на самом деле Hikari? Но это не вся картина.

Ещё одно наблюдение: Hikari стал пулом по умолчанию именно в Spring Boot 2. Если понаблюдать, то при замене пула Hikari на какой-нибудь другой пул проблема исчезает, потому что Spring Cloud просто его не оборачивает. Что-то тут уже попахивает какими-то новшествами. Чувствуете? Из названия предполагается, что он витает где-то в облаках, а где пул коннектов к базе данных? Но казалось бы, где Spring Cloud? По идее, они не должны друг о друге знать. Тоже не близко.

А на самом деле…

org.springframework.cloud.autoconfigure.RefreshAutoConfiguration .RefreshScopeBeanDefinitionEnhancer: /** * Class names for beans to post process into refresh scope. Useful when you * don’t control the bean definition (e.g. it came from auto-configuration). */ private Set<String> refreshables = new HashSet<>( Arrays.asList("com.zaxxer.hikari.HikariDataSource"));

То есть разработчики Spring Cloud сразу предусмотрели возможность работы именно с этим пулом. А на самом деле в Spring Cloud есть такой волшебный autoconfiguration, в котором есть ещё более волшебный Enhancer BeanDefinition’ов, в котором пусть не в явном виде, но прямо захардкожена зависимость от Hikari. И именно поэтому оборачивают его в прокси.

Автообновление бинов в Spring Cloud достаётся не бесплатно, все бины сразу из коробки выходят в CGLIB-обёртках. Какие выводы из этого можно сделать? Этот пример как раз нам это доказывает (jira.spring.io/browse/SPR-17381). Это нужно учитывать, например, для того, чтобы знать, что не все прокси-обёртки одинаково хорошо работают друг с другом. Выдавать обёртки можно через подмену BeanDefinition и переопределение фабричного бина ещё до того, как применились все BeanPostProcessor’ы. Оборачивать в прокси могут не только BeanPostProcessor, если вы вдруг так думали. Но не всё проходит, и в некоторых случаях этого флажка просто нет. И ещё Stack Overflow часто учит нас тому, что если вы сталкиваетесь с какой-то такой ересью, то просто порулите флажками, proxyTargetClass переключите с true на false или наоборот, и всё пройдёт. Мы увидели сразу два таких примера.

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

Вытеснять можно тремя способами:

  • Переключиться на другой пул коннектов (например, Tomcat JDBC Pool)
    spring.datasource.type=org.apache.tomcat.jdbc.pool.DataSource
    Не забыв добавить зависимость
    runtime 'org.apache.tomcat:tomcat-jdbc:8.5.29'
    Hikari берёт, вроде как, производительностью, но не факт, что вы уже упёрлись в неё, можно вернуться на старый пул Tomcat, который использовался в первом Spring Boot.
  • Можно потеснить JavaMelody, либо отключив JDBC-мониторинг, либо вытеснив её полностью.
    javamelody.excluded-datasources=scopedTarget.dataSource
  • Отключить автообновление на лету в Spring Cloud.
    spring.cloud.refresh.enabled=false
    Мы, если помните, втащили эту фичу ради того, чтобы работать с Service Discovery, обновлять бины нам не нужно было вовсе.

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

Бонус (схожий случай*)

*но без Spring Cloud (и можно без JavaMelody)

@Component
@ManagedResource
@EnableAsync
public class MyJmxResource { @ManagedOperation @Async public void launchLongLastingJob() { // какой-то долгоиграющий код }
}

Полный исходный код: github.com/toparvion/joker-2018-samples/tree/master/jmx-resource

То же самое приложение, только мы выкинем из него Spring Cloud. Возьмём схожий случай. А чтобы придать этому проекту больше полезности, мы предположим, что в нём есть класс, который выставляет по JMX один публичный метод. Можно и JavaMelody выкинуть, оставив лишь один Spring-бин, создаваемый этим мониторингом. Чтобы он был виден по JMX, мы вешаем на него аннотацию @ManagedOperation, а чтобы всё это заработало, добавляем ещё два рубильника (как мы любим в Spring — надо накидать больше аннотаций, и тогда всё будет OK). И этот метод якобы выполняет какую-то долгую работу, поэтому мы его пометили как Async, чтобы не заставлять JMX-консоль долго ждать.

И если посмотреть через дебаггер, то снова будет видна всё та же самая матрёшка — бин обернут в две прокси, CGLIB и JDK. Так вот если такое приложение запустить, то оно действительно успешно стартанет, не будет никаких ошибок в логах, но, увы, бин myJMXResource не будет доступен по JMX, его даже не будет видно.

И сразу становится понятно, что где-то здесь замешан BeanPostProcessor. Снова JDK и CGLIB-прокси.

И действительно, есть два причастных BeanPostProcessor’а:
AsyncAnnotationBeanPostProcessor

  • Должность: директор по работе с аннотацией Async
  • Прописка: org.springframework.scheduling
  • Место рождения: аннотация @EnableAsync (через Import)

2. DefaultAdvisorAutoProxyCreator

  • Должность: помощник по работе с AOP-прокси, отвечает за перехват вызовов для навешивания аспектов
  • Прописка: org.springframework.aop.framework.autoproxy
  • Место рождения: @Configuration-класс PointcutAdvisorConfig (библиотечный или самописный)

В моём случае, когда я это дело проходил впервые, это был JavaMelody, но на самом деле он мог быть добавлен и любым другим прикладным configuration-классом. DefaultAdvisorAutoProxyCreator происходит из @Configuration-класса. И кстати, если он называется PointcutAdvisorConfig, то с ним будет ещё одно интересное наблюдение.

Он назывался PointcutAdvisorConfig, стал просто AdvisorConfig, причём это не обычный бин, а configuration-класс, который поставляет бины, то есть, вроде как, ничего не должно сломаться, но поведение реально меняется с ошибочного на корректное или наоборот. Стоит его переименовать, как внезапно проблема уходит.

Казалось бы, переименование тут ни при чём, но важно помнить, что где переименование, там и порядок чего-либо.

Если посмотреть на их список в двух режимах, когда всё хорошо и когда всё плохо, то будет видно, что эти два BeanPostProcessor следуют в обратном по отношению друг к другу порядке.
И в данном случае это порядок BeanPostProcessor’ов. Он делает и этим всё ломает. Если зарыться в кишочки, первый из них клюёт на искусственно добавленный интерфейс Advised (вставленный предыдущим BeanPostProcessor’ом), видит этот интерфейс, думает, что он прикладной, а раз есть прикладной интерфейс, значит, лучше применить JDK-проксирование, оно как раз позволяет это сделать более естественным образом.

Если посмотреть всю цепочку влияния, то она выглядит примерно так: Но на самом деле это не вся картина.

Тот в свою очередь определился порядком применения BeanPostProcessor. На обнаружение бина для выставления по JMX повлиял как раз порядок прокси. Он неразрывно связан с порядком регистрации BeanPostProcessor’ов, который происходит от порядка имен бинов в контексте, который, в свою очередь, как ни странно, определяется порядком загрузки или сортировки ресурсов с файловой системы или из JAR, в зависимости от того, откуда вы запускаетесь.

Как быть?

Во-первых, по возможности стоит использовать более высокоуровневые абстракции, предоставляемые фреймворком Spring AOP, в частности, аспекты. Почему «по возможности»? Потому что зачастую вы можете даже не знать, что откуда-то у вас существуют бины вот этих Advice и Advisor, они могут быть привнесены сторонними библиотеками, как это было в случае с мониторингом.

Элементарно, если бы мы взяли и спрятали этот прикладной бин JMX-ресурс под интерфейсом, то проблемы могло бы и не быть. Во-вторых, не забывать о best practices. Если вдруг этого не сделано раньше, можно попробовать autowire’ить (заинжектить) этот бин в любой другой. Если всё-таки сломалось, можно посмотреть на состав прокси через отладчик, увидеть, из каких слоёв он состоит. Скорее всего, вы увидите по исключению, что там есть какое-то наслоение. Это позволит вскрыть проблему раньше. И там, где это применимо, можно попробовать порулить режимом проксирования, т.е. В качестве обхода можно попытаться порулить порядком бинов через аннотации Order, чтобы не заниматься случайным переименованием. всё тем же флажком proxyTargetClass, если он применим.

Во-первых, «Keep calm and YAGNI». Резюме: что делать в общих случаях, связанных с прокси. Пробуйте «в лоб», в крайнем случае всегда можно где-то откатиться, где-то попробовать обойти иначе, но не стоит думать, что надо решать эту проблему до того, как она появилась. Не стоит думать, что такая жесть ждёт вас за каждым углом и надо прямо сейчас обязательно от неё защищаться. И ещё не стоит включать всё подряд на всякий случай. Если вы привносите новые библиотеки, стоит поинтересоваться, как они работают именно в отношении прокси: создают ли они прокси-объекты, в каком порядке, можно ли повлиять на режимы проксирования — как видите, это не всегда так. И вот доклады того же Кирилла Толкачёва tolkkv, где он показывает, что простенькое приложение состоит из 436-ти бинов, это ярко доказывают. Да, Spring приучает нас к тому, что там ничего не надо делать, всё работает из коробки. И на этом примере было видно, что не все фичи одинаково полезны.

Relax Binding. Работа со свойствами (параметрами) приложения

О прокси поговорили, переключаемся на другую тему.

0.
https://docs.spring.io/spring-boot/docs/2. RELEASE/reference/htmlsingle/#boot-features-external-config-relaxed-binding 5.

Это возможность чтения свойств для приложения откуда-то извне без чёткого их совпадения с именем внутри приложения. Есть такой замечательный механизм, как Relax Binding в Spring Boot. Как бы ни были заданы эти свойства снаружи: через camel case, через дефисы, как принято в переменных окружениях, или как-то ещё — всё это корректно смапится просто на firstName. Например, если у вас есть в каком-то бине поле firstName и вы хотите, чтобы оно смапилось извне с префиксом acme.my-project.person, то Spring Boot может это обеспечить достаточно лояльным способом. Как раз это и называется Relax Binding.

Я, честно говоря, этого не почувствовал, но так написано в документации: Так вот во второй версии Spring Boot’а эти правила немного ужесточились, а ещё утверждается, что с ними стало проще работать из кода приложения — унифицирован способ задания имен свойств в коде приложения.

Но грабли-то остались. Чтиво очень увлекательное, советую ознакомиться, много интересного. Понятно, что всех кейсов там не учтёшь.

Например:

dependencies { ext { springBootVersion = '1.5.4.RELEASE' } compile("org.springframework.boot:spring-boot-starter:$springBootVersion")
}

(полный исходный код)

У нас есть малюсенькое приложение с одним-единственным стартером, даже не web, на первом Spring Boot, и у него в исполняемом коде теперь уже чуть-чуть побольше всего.

@SpringBootApplication
public class RelaxBindingApplication implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(RelaxBindingDemoApplication.class); @Autowired private SecurityProperties securityProperties; public static void main(String[] args) { SpringApplication.run(RelaxBindingDemoApplication.class, args); } @Override public void main run(ApplicationArguments args) { log.info("KEYSTORE TYPE IS: {}", securityProperties.getKeyStoreType()); }
}

Этот POJO представляет из себя два поля с двумя геттерами и сеттерами, значения для которых берутся из applications.properties или application.yaml, не важно. В частности, мы инжектим некий POJO-объект (носитель свойств) и выводим в лог одно из этих свойств, в данном случае KEYSTORE TYPE.

Если говорить конкретно о поле keystoreType, то оно выглядит как private String keystoreType, а значение задаётся ему в applications.properties: security.keystoreType=jks.

@Component
@ConfigurationProperties(prefix = "security")
public class SecurityProperties { private String keystorePath; private String keystoreType; public String getKeystorePath() { return keystorePath; } public void setKeystorePath(String keystorePath) { this.keystorePath = keystorePath; } public String getKeyStoreType() { return keystoreType; } public void setKeystoreType(String keystoreType) { this.keystoreType = keystoreType; }
}

Запустим это на первом Spring Boot и всё будет работать отлично.

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

И сообщение будет, на первый взгляд, несколько неадекватным, якобы не найден какой-то сеттер для свойства, которого у нас вообще нет в проекте, какой-то key-store-type. Не то что свойство не определится, даже не запустится. То есть стоило нам в первый раз попробовать, всё работало прям огонь, а второй раз делаем фактически то же самое, и уже проходит не очень.

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

Мы проверяем проект не из 2 миллионов строк, поэтому легко убедиться, что свойство отсутствует. Такого свойства у нас нет. Но мы же знаем, что, наверное, там не сразу рефлексия применяется, должен ещё быть сеттер, и он тоже должен быть корректным. Сопоставляем прямо вот побуквенно и с учётом регистра код в Java и в properties — всё одинаковое, нигде ничего не разъехалось. На всякий случай, раз уж включили паранойю, проверим геттеры. Проверяем его — сеттер тоже удовлетворяет конвенции для Java bean и полностью соответствует остальным буквам в регистре. Внезапненько… А вот с геттером есть одна особенность, у него «keystore» написано так, будто это два слова: «Key» и «Store».

Разбираемся. Казалось бы, при чём тут геттер, если не смогли засеттить?

Не только они, но они в первую очередь. Оказывается, что первоисточником списка бинов, при вот этом Relax Binding стали внезапно геттеры (в том числе getStoreType()). Но под такое свойство, как keyStoreType, никакого сеттера нет. Соответственно, под каждый геттер должен быть найден и свой сеттер. Собственно, об это как раз и ломается Relax Binding, пытаясь найти возможность связывания, и выводит об этом сообщение таким неочевидным образом, как мы видели выше.

Но она может воспроизводиться достаточно легко, если принять поправку на реальность. Может показаться, что это какой-то вырожденный кейс, там кто-то один раз накосячил, и из этого раздули проблему. В действительности класс конфигурации может выглядеть примерно вот так:

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

Как быть?

В первую очередь режим паранойи придётся оставить включенным: сверять регистры букв в именах свойств всё-таки надо. Во-вторых, если вы предполагаете, что у вас в dev-окружении свойства будут браться, например, из YAML и properties, а на боевом — из переменных окружения в операционной системе, то проверьте это заранее, может и не смапиться. В-третьих, было бы здорово ознакомиться c тем самым гайдом, который написали именно по второй версии Relax Binding, достаточно один раз прочитать. Ну и, наконец, надеяться, что в третьей версии Spring Boot всё будет хорошо.

Unit Testing. Выполнение тестов в Mockito 2

Поговорим немного о тестировании, только не о каком-то там высокоуровневом интеграционном и функциональном, а об обычном модульном тестировании при помощи Mockito.

Mockito оказался при делах потому, что если вы подтяните себе в зависимости Spring Boot Starter, ну или вообще тестовую оснастку Spring, то автоматически получите зависимость ещё и от Mockito.

$gradle -q dependencyInsight --configuration testCompile --dependency mockito org.mockito:mockito-core:2.15.0 variant "runtime"
/--- org.springframework.boot:spring-boot-starter-test:2.0.2.RELEASE /---testCompile

Вот тут есть один неочевидный момент. Но какую версию этой зависимости? 5. В первой версии Spring Boot использовал Mockito также первой версии, однако с версией 1. Однако по умолчанию это по-прежнему не изменилось. 2 Spring Boot стал допускать ручное включение Mockito 2, то есть стал с ним совместим. И только со второй версии он стал использовать Mockito 2.

0 и Mockito. Сам Mockito обновился достаточно давно, ещё в конце 2016-го года вышли Mockito 2. 1— две серьёзные версии с кучей изменений: поддержали Java 8 с её выведением типов, обошли пересечение с библиотекой Hamcrest и учли много набитых давным-давно шишек. 2. И эти версии, как предполагает изменение мажорной цифры, обратно не совместимы с первой.

Всё это приводит к тому, что вы не только сталкиваетесь с кучей проблем в основном коде, но и получаете провальные (хоть и компилируемые) тесты.

С одной стороны, string’у можно присвоить null, с другой стороны, null не проходит instanceof на string. Например, у вас был вот такой простенький тест, в котором вы замокали JButton из Swing, выставили ему имя null, а потом проверяете, действительно ли вы выставили какую-то любую строчку. И заставляет проверять в явном виде: если вы ожидаете там увидеть null, будьте добры так и пропишите. В общем, в Mockito 1 это всё проходило без проблем, но Mockito 2 уже научился вникать в это дело более основательно и говорит, что anyString на null больше не проходит, он в явном виде выдаст ошибку о том, что такой тест более не является корректным. По утверждению разработчиков, они почувствовали, что такое изменение сделает тестовую оснастку чуть более безопасной, чем она была в Mockito 1.

Возьмем похожий, на первый взгляд, пример.

public class MyService { public void setTarget(Object target) { //… }
} <hr/>
@Test
public void testAnyStringMatcher() { MyService myServiceMock = mock(MyService.class); myServiceMock.setTarget(new JButton()); verify(myServiceMock).setTarget(anyString());
}

И теперь мы его замокировали и в качестве этого любого объекта задали ему всё тот же JButton. Допустим, у нас есть прикладной класс с единственным методом, принимающим любой объект. Казалось бы, это полная ересь: мы передали кнопку, а проверяем на строчку — вообще не связанные вещи. А потом внезапно проверяем его на anyString. Когда вы проверяете какое-либо поведение приложения под разными углами в 10-ти аспектах с сотней тестов, написать такое на самом деле легко и просто. Но это здесь, в вырожденном одном классе такое кажется безумным. И Mockito 1 никак не скажет, что вы проверяете полную фигню:

Mockito 2 уже об этом предупредит, хотя, на первый взгляд, anyString ведёт себя точно так же:

Ещё один пример, только теперь касающийся исключений. Поговорим немножко о другой области. При этом сам метод изначально никаких проверяемых исключений не декларировал, а SocketTimeoutException проверяемый, если помните. Допустим, у нас есть всё тот же прикладной класс, теперь с ещё более простым методом, который вообще ничего не принимает и ничего не делает, и мы хотим сэмулировать в нём падение по SocketTimeoutException, то есть как будто бы там какой-то сетевой сбой внутри. Так вот такой тест без проблем проходил в Mockito 1.

А вот Mockito 2 уже научился в это дело вникать и говорить, что проверяемое исключение для данного случая не валидно:

То есть если вы создали new SocketTimeoutException прямо через new, через constructor, то Mockito 1 бы тоже заругался. Фишка в том, что Mockito 1 тоже умел так делать, но только в том случае, если исключение было создано именно как экземпляр класса.

Придётся оборачивать такие исключения, например, в RuntimeException, а потом уже вышвыривать их из класса, так как теперь Mockito стал более чётким в проверках. Что с этим делать?

Я привёл парочку примеров, а остальное обозначу лишь в общих чертах. Таких кейсов достаточно много. Кое-какие классы переехали в другие пакеты, чтобы не пересекаться с Hamcrest. В первую очередь это несовместимость в compile-time. И ещё отдельного внимания заслуживает полноценный тестовый фреймворк, который запилили в Spring Integration, мне довелось приложить к нему руку в части review.
(Почитать: https://docs.spring.io/spring-integration/docs/5. Касательно Spring Boot, в Mockito 1 больше не поддерживаются классы @MockBean и @SpyBean. 0. 0. RELEASE/reference/htmlsingle/#testing)

Как быть?

Как бы нравоучительно ни звучало, но те практики, которые рекомендовались для использования с Mockito 1, зачастую позволяют обходить проблем с Mockito 2 (dzone.com/refcardz/mockito).

5. Во-вторых, если вам ещё предстоит такой переход, вы можете сделать его заранее, начав с версии Spring Boot 1. 2 использовать Mockito 2.

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

Gradle Plugin. Сборка Spring Boot проектов

Последнее, о чем хотелось бы поговорить, — это Spring Boot-плагин для Gradle.

Что есть, то есть. В самом Migration Guide сказано, что Spring Boot плагин для Gradle был изрядненько переписан. Он больше по умолчанию не подключает dependency management plugin, чтобы было поменьше магии. Из основного: ему теперь нужен Gradle 4 версии (соответственно, хотя бы пустой settings.gradle в корне проекта). Вот как раз на bootJar мы чуть поподробнее и остановимся. И ещё задача bootRepackage, самая магическая, распалась на две: на bootWar и bootJar.

Задача bootJar:

  • Активируется автоматически, если применены плагины org.springframework.boot и java;
  • Отключает задачу jar;
  • Умеет находить mainClassName (главное имя класса для запуска) разными способами (и валит сборку, если все-таки не нашла).

Казалось бы, такие понятные очевидные выводы, но что из них следует — отнюдь не очевидно, пока не попробуешь, ибо это Gradle, да ещё и со Spring Boot.

Мы возьмём какое-нибудь простенькое приложение на Spring Boot 2, сборочку, естественно, на Gradle 4 и с использованием вот этого Spring Boot-плагина. О чём конкретно идёт речь? И чтобы добавить ему реалистичности, мы сделаем его не обычным, а составным: у нас будет и прикладной код, и библиотека, как это часто делается (общий код выделяется в библиотеку, все остальные от него зависят).

Цифра в имени app1 предполагает, что может быть app2, app3 и т. Если на картинках, то вот так. Этот app1 зависит от библиотеки lib. д.

«Show me the code!»

Корневой проект

subprojects { repositories { mavenCentral() } apply plugin: 'java' apply plugin: 'org.springframework.boot'
}

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

app1: скрипт сборки

dependencies { ext { springBootVersion = '2.0.4.RELEASE' } compile("org.springframework.boot:spring-boot-starter:$springBootVersion") compile project(':lib')
}

В прикладном коде у нас указана только зависимость от lib и то, что это Spring Boot-приложение.

app1: исполняемый код

@SpringBootApplication
public class GradlePluginDemoApplication implements ApplicationRunner { //… @Override public void run(ApplicationArguments args) { String appVersion = Util.getAppVersion(getClass()); log.info("Current application version: {}", appVersion); }
}

В исполняемом коде мы только обратимся к классу Util, лежащему у нас в либе.

lib: исполняемый код

public abstract class Util { public static String getAppVersion(Class<?> appClass) { return appClass.getPackage().getImplementationVersion(); }
}

А скрипт сборки у этого подпроекта вообще пустой. Этот класс Util своим методом getAppVersion просто обратится к пакету, возьмёт у него манифест, запросит ImplementationVersion и вернёт её.

Но стоит выполнить gradle build извне IDE вручную, как сборка сломается. Если запускать такое приложение из IDE, всё будет нормально, она разрешит все зависимости, и всё будет работать идеально. То есть, казалось бы, библиотека лежит совсем рядышком, но найти её компилятор не может, потому что якобы не видит такого пакета. И причиной станет внезапно невозможность разрешения зависимости от Util.

Результаты расследования

Причины:

  • bootJar глушит собою jar;
  • Gradle поставляет зависимости подпроектам на основе выхлопа от jar.

Следствия:

  • Компилятор не может разрешить зависимость от библиотеки;
  • Все атрибуты манифеста, выставленные на задаче jar (тот же ImplementationVersion), игнорируются.

Один из вариантов: сделать в самом корневом скрипте вот такое исключение. Как можно это исправить?

Однако есть и другой способ. То есть как бы оторвать применение Spring Boot-плагина от остальных проектов, сказав, что для lib его применять не нужно.

Вариант 2: применять SB Gradle Plugin только к Spring Boot-подпроектам

bootJar { enabled = false
}

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

Прочее

Мы поговорили, наверное, лишь о небольшой доле всех граблей, которые могут встретиться при обновлении Spring Boot. Некоторые из оставшихся я упомяну лишь вкратце.

Чтобы с этим как-то работать, со второй версией поставляется такая классная штука, как Spring Boot properties migrator, очень рекомендую задействовать её для того, чтобы понять, какие у вас параметры используются с устаревшими именами. В Spring Boot за скобками нашего рассмотрения остались многочисленные переименования параметров: они связаны и с переходом на web-стек, на разбивку его на сервлетный и реактивный, и с другими правками. Она прям в лог вам выпишет, что вот этот параметр устарел, его нужно заменить на такой-то, а вот этот отныне бесполезен и так далее.

В частности, теперь придётся совсем иначе рулить безопасностью (доступностью) его методов, отныне это больше похоже на Spring Security. Ещё много изменений произошло в Actuator. Больше нет никаких специальных флажков для этого.

Это важно потому, что его версия тоже транзитивно будет подтянута вами. В Spring Cloud произошло много переименований. Это переименование артефактов, касающихся Netflix и Feign.

Одно из самых крупных — это то, что некогда экспериментальный Java DSL въехал в ядро самого проекта и, соответственно, его больше не нужно подключать как отдельную зависимость. В Spring Integration, который тоже достиг пятой версии, как и Spring Framework, произошёл ряд изменений. Ещё те адаптеры, которые служат для связи с внешним миром, входные и выходные, теперь заполучили свои собственные статические методы, которые могут использоваться в конвейерах на методе handle без специального метода handleWithAdapter.

Заключение. Резюме и выводы

Резюмируя, я обозначу четыре области, от которых стоит ждать подвохов:

При обновлении это, в первую очередь, конечно, Web, поскольку разделение стеков не могло пройти незамеченным.

Второе (Properties Binding), связывание внешних параметров, тоже изрядно доставляет удовольствие за счёт того, что изменился механизм Relax Binding.

Третье — всё, что связано с механизмами проксирования: это всякое кэширование, AOP и прочее, тоже может стать источником изменений за счёт того, что в Spring Boot 2 по умолчанию изменился режим проксирования.

Но это всё какие-то общие слова, а как с этим быть? И, наконец, четвёртое, тесты — как раз потому, что произошло обновление с Mockito 1 на Mockito 2.

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

Там нет никакой лишней логики, они исключительно занимаются воспроизведением конкретных ситуаций, которые документированы, описаны, как что делать и откуда что брать. Во-вторых, если с чем-то всё-таки столкнулись, можете свериться с некоторыми образцами, которые я насобирал, пока готовился к этому докладу. Благодаря тому, что этот документ постоянно пополняется и развивается самими разработчиками, велика вероятность того, что как только вы в него загляните, вдруг выяснится, что ваш кейс разрешён и там об этом уже написано. Плюс к тому не мешало бы периодически заглядывать в сам Migration Guide.

Надеюсь, что все эти советы и знания помогут вам обновиться без проблем и в дальнейшем успешно использовать Spring Boot, поставить его себе на службу, чтобы он служил вам верой и правдой… вплоть до третьей версии. Есть и другие такие грабледайджесты, которые собирали опыт и обобщали всевозможные грабли при обновлении и дальнейшей эксплуатации Spring Boot.

Подробности о конференции — на сайте. Если вам понравился доклад, обратите внимание: 5-6 апреля в Москве пройдёт JPoint, и там я выступлю с новым докладом о Spring Boot: в этот раз о переводе Spring Boot-микросервисов с Java 8 на Java 11.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть