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

a4e3fcwl22r0y3nlx8z-6pkhtny.jpeg

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

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

Оглавление


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

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

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


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

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

План доклада

rljdzqye-_lbzjguklndonlyc-4.jpeg

Областей, где появились особенности 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, в котором перечислены все (транзитивные для нас) зависимости от всевозможных сторонних библиотек, дополнительных фреймворков, инструментов и прочего.

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

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


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

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

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

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

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

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

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

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

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

На чем сломается компиляция (пример 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.

03a2dbs5_suz7osfukciaumo0-y.jpeg

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

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

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

dependencies {
    ext {
        springBootVersion = '1.5.14.RELEASE'
    }
    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 download(@PathVariable String fileName) {
  //формируем только тело ответа, без Content-Type
}

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

wand77z2iocxhhqbcr-bn6lvzxm.jpeg

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

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

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

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

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

cyre_fno26kbqiepmtpr0tewmrw.jpeg

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

xtmj50wpv1ossdx_6nqeakchwxc.jpeg

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

ContentNegotiationManagerFactoryBean

public ContentNegotiationManagerFactoryBean build() {
 List strategies = new ArrayList<>();

 if (this.strategies != null) {
  strategies.addAll(this.strategies);
 }
 else {
  if (this.favorPathExtension) {
   PathExtensionContentNegotiationStrategy strategy;
    // …

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

qyg3mscyw2mnlyqewltjefecwri.jpeg

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

efj8ilxfvvc1_dtss-7-becc_m0.jpeg

Если покопаться ещё чуть глубже, то можно нарыть вот такой фрагмент. В самом 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:

qarae8b3dxgmf-mwhx1-bf9tev0.jpeg

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

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

nbprsqxl5nlwzs5qtnv3vohv26y.jpeg
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 секунды.

rfaymhimyivf7htbkhf7fw3nia0.jpeg

Сделать это можно, очевидно, самыми разными способами, под них по-любому уже что-нибудь есть в 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: поиск нужной аннотации


nraxsvpmgs9u7z2yjavxhceqrli.jpeg

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

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

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, и ожидаем вывода нужной строчки. Проходит три секунды — строчка выводится, проходит шесть — строчка выводится. Оптимисты победили, всё работает.

edemirx8zopb5wbf193tlbbh6fi.png

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

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

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

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

e4rqcy4gms2t-cog16ru4cr8qcu.png

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

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


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

zz7_ardj3w6--f-wefbyenp7_p0.jpeg

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

mfyapbujxbyzbumuxy2cghrs2qk.jpeg

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

vdp_fjs2gqgaicooirzzwu-tjg8.jpeg

Всё тот же 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 секунды доклада потрачено без дела…”);
  }
}

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

6fpgldlhinmkrdq6git1-dovdrg.png

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

Как быть?


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

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

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

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

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

0asaqj87-tg2xetyig_ytjreb34.jpeg

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

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

Наш замес будет выглядеть на 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);
  }
}

Запускаем.

ujoshrw-kj_cn72s23h5e7fjgka.jpeg

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

hvlau913-05gppcoqmjfsbuzip0.jpeg

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

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


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


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

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

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


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

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

j8laaj9tuxuewycjnpqzuxcaxpw.png

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

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


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

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

a4tbawwbqhd72cn7zw-ain4zm4c.jpeg
https://jira.spring.io/browse/SPR-17381

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

kebnnswvcy8kwtnqezvikitcdvw.jpeg

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

Если понаблюдать, то при замене пула Hikari на какой-нибудь другой пул проблема исчезает, потому что Spring Cloud просто его не оборачивает. Ещё одно наблюдение: Hikari стал пулом по умолчанию именно в Spring Boot 2. Чувствуете? Что-то тут уже попахивает какими-то новшествами. Но казалось бы, где 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 refreshables = new HashSet<>(
     Arrays.asList("com.zaxxer.hikari.HikariDataSource"));

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

Какие выводы из этого можно сделать? Автообновление бинов в Spring Cloud достаётся не бесплатно, все бины сразу из коробки выходят в CGLIB-обёртках. Это нужно учитывать, например, для того, чтобы знать, что не все прокси-обёртки одинаково хорошо работают друг с другом. Этот пример как раз нам это доказывает (jira.spring.io/browse/SPR-17381). Оборачивать в прокси могут не только BeanPostProcessor, если вы вдруг так думали. Выдавать обёртки можно через подмену BeanDefinition и переопределение фабричного бина ещё до того, как применились все 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, обновлять бины на

    © Habrahabr.ru