Boot yourself, Spring is coming (Часть 1)

Евгений Борисов (NAYA Technologies) и Кирилл Толкачев (ЦИАН) рассказывают о самых важных и интересных моментах Spring Boot на примере стартера для воображаемого Железного банка.

wjp5pfvfgcnp_mda9gcpuiln3zs.png

В основе статьи — доклад Евгения и Кирилла с нашей конференции Joker 2017. Под катом — видео и текстовая расшифровка доклада.

Конференцию Joker спонсируют много банков, поэтому представим, что приложение, на котором мы будем изучать работу Spring boot и создаваемый нами стартер, связано именно с банком.

6b96b3735385c3774928851319b3569c.png

Итак, предположим, поступил заказ на некое приложение от «Железного банка Браавоса». Обычный банк просто переводит деньги туда-сюда. Например, так (для этого у нас предусмотрен API):

http://localhost:8080/credit\?name\=Targarian\&amount\=100

А в «Железном банке» перед тем, как перевести деньги, необходимо, чтобы API банка просчитал, сможет ли человек их вернуть. Может быть, он не переживет зиму и возвращать будет некому. Поэтому там предусмотрен сервис, который проверяет надежность.

Например, если мы попытаемся перевести деньги Таргариену, операция будет одобрена:

cc1d3bc67ae34759c85b4cae57189037.png

А вот если Старку, то нет:

a65453a4dc6bf6a0490cbbdaadf78404.png

Ничего удивительного: Старки слишком часто умирают. А зачем переводить деньги, если человек не переживет зиму?

Посмотрим, как это выглядит внутри.

@RestController
@RequiredArgsConstructor
public class IronBankController {
  private final TransferMoneyService transferMoney;
  private final MoneyDao moneyDao;

  @GetMapping("/credit")
  public String credit(@RequestParam String name, @RequestParam long amount) {
    long resultedDeposit = transferMoney.transfer(name, amount);
    if (resultedDeposit == -1) {
      return "Rejected
" + name + " will`t survive this winter"; } return format( "Credit approved for %s
Current bank balance: %s", name, resultedDeposit ); } @GetMapping("/state") public long currentState() { return moneyDao.findAll().get(0).getTotalAmount(); } }


Это обычный стринговый контроллер.

Кто отвечает за логику выбора, кому выдавать кредит, а кому — нет? Простая строчка: если тебя зовут Старк, точно не выдаем. В остальных случаях — как повезет. Обычный банк.

@Service
public class NameBasedProphetService implements ProphetService {

  @Override
  public boolean willSurvive(String name) {
    return !name.contains("Stark") && ThreadLocalRandom.current().nextBoolean();
  }
}


Все остальное не так интересно. Это какие-то аннотации, которые делают за нас всю работу. Все очень быстро.

Где же тут все основные конфигурации? Контроллер — только один. В Dao — вообще пустой интерфейс.

public interface MoneyDao extends JpaRepository {
}


В сервисах — только сервисы переводов и предсказаний, кому можно выдавать. Директории Conf нет. По сути у нас есть только application.yml (список тех, кто возвращает долги). И main — самый обычный:

@SpringBootApplication
@EnableConfigurationProperties(ProphetProperties.class)
public class MoneyRavenApplication {

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


Так где же спрятана вся магия?

Дело в том, что разработчики не любят думать о зависимостях, настраивать конфигурации, особенно если это XML конфигурации, и думать о том, как запускается их приложение. Поэтому Spring Boot решает эти проблемы за нас. Нам же остается только задача написать приложение.

Зависимости


Первая проблема, которая у нас всегда была — это конфликт версий. Каждый раз, когда мы подключаем разные библиотеки, которые ссылаются на другие библиотеки, появляются конфликты зависимостей. Каждый раз, когда я читал в интернете, что мне надо добавить какой-нибудь entity-manager, возникал вопрос, а какую версию мне надо добавить, чтобы она ничего не сломала?

Spring Boot решает проблему конфликтов версий.

Как мы обычно получаем проект Spring Boot (если мы не пришли в какое-то место, где он уже есть)?

  • либо заходим на start.spring.io, ставим чек-боксы, которые нас Josh Long учил ставить, нажимаем на Download Project и открываем проект, где уже все есть;
  • либо используем IntelliJ, где благодаря появившейся опции галочки в Spring Initializer можно прямо оттуда проставить.


Если мы работаем с Maven, то в проекте будет pom.xml, где есть родитель Spring Boot-а, который называется spring-boot-dependencies. Там и будет огромный блок dependency-менеджмента.

Я сейчас не буду вдаваться в подробности Maven-а. Буквально два слова.

Блок dependency-менеджмента не прописывает зависимости. Это блок, при помощи которого можно указать версии на случай, если эти зависимости будут нужны. И когда вы указываете в блоке dependency-менеджмента какую-то зависимость, не указав версию, то Maven начинает искать, а нет ли в parent pom-е или где-то еще блока dependency-менеджмента, в котором эта версия прописана. Т.е. в своем проекте, добавляя новую зависимость, я уже не буду указывать версию в надежде, что она указана где-то в parent-е. А если она не указана в parent-е, то она точно не создаст ни с кем никакого конфликта. У нас в dependency-менеджменте указаны добрые пять сотен зависимостей, и они все согласованы между собой.

Но в чем проблема? Проблема в том, что в моей компании, например, есть свой parent pom. Если я хочу использовать Spring, как мне быть с моим parent pom?

3fcb832ce3faa2a19f2d9f1e1cf6957a.png

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

02e835ff7cdd677895b23aa561e51130.png

Это сделать можно. Достаточно прописать блоке dependency-менеджмента импорт BOM«а.


  
     
        io.spring.platform
        platform-bom
        Brussels-SR2
        pom
        import
     
  



Кто хочет узнать подробнее про bom — смотрите доклад «Maven против Gradle». Там все это подробно объяснялось.

Сегодня среди больших компаний достаточно модно стало писать такие огромные блоки dependency-менеджмента, где они указывают все версии своих продуктов и все версии продуктов, которые используют их продукты, и которые не конфликтуют друг с другом. И это называется bom. Эту штуку можно импортировать в ваш блок dependency-менеджмента без наследования.

А вот так это делается в Gradle (как обычно, то же самое, только проще):

dependencyManagement {
  imports {
    mavenBom 'org.springframework.cloud:spring-cloud-dependencies:Dalston.RELEASE'
  }
}


Теперь давайте поговорим про сами зависимости.

Что мы пропишем в приложении? Dependency-менеджмент — хорошо, но мы хотим, чтобы у приложения были определенные способности, например, чтобы оно отвечало по HTTP, чтобы была БД или поддержка JPA. Поэтому все, что нам нужно сейчас — это получить три зависимости.
Раньше это выглядело так. Я хочу работать с БД и начинается: transaction менеджер какой-то нужен, соответственно нужно модуль spring-tx. Мне нужен какой-нибудь hibernate, поэтому требуется EntityManager, hibernate-core или еще что-то. Я все настраиваю через Spring, значит нужен spring core. То есть для одной простой вещи надо было думать о десятке зависимостях.

Сегодня у нас есть стартеры. Идея стартера заключается в том, что мы ставим зависимость на него. Начнем с того, что он агрегирует те зависимости, которые нужны для того мира, из которого он пришел. Например, если это стартер security, то вы не думаете о том, какие нужны зависимости, они сразу прилетают в виде транзитивных зависимостей к стартеру. Или если вы работаете со Spring Data Jpa, то ставите зависимость на стартер, и он принесет все модули, которые нужны, чтобы работать со Spring Data Jpa.

Т.е. pom у нас выглядит следующим образом: содержит только те 3–5 зависимостей, которые нам нужны:

'org.springframework.boot:spring-boot-starter-web'
'org.springframework.boot:spring-boot-starter-data-jpa'
'com.h2database:h2'


С зависимостями разобрались, все стало проще. Думать нам теперь нужно меньше. При этом нет конфликта, а количество зависимостей уменьшилось.

Настройка контекста


Поговорим про следующую боль, которая у нас была всегда, — настройка контекста. Каждый раз, когда мы начинаем с нуля писать приложение, на то, чтобы настроить всю инфраструктуру, уходит куча времени. Мы прописывали либо в xml, либо в java config-ах очень много так называемых инфраструктурных бинов. Если мы работали с hibernate, нам нужен был бин EntityManagerFactory. Много инфраструктурных бинов — и transaction manager, и data source, и т.п. — нужно было настраивать руками. Естественно, все они попадали в контекст.

В ходе доклада «Spring-потрошитель» мы в main-е создавали контекст, и если это был xml-ный контекст, он изначально был пустой. Если же мы строили контекст через AnnotationConfigApplicationContext, туда попадали некоторые beanpostprocessor-ы, которые умели настраивать бины согласно аннотациям, но контекст тоже был практически пустой.
А вот сейчас в main-е есть SpringApplication.run и не видно никакого контекста:

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


Но на самом деле контекст у нас есть. SpringApplication.run возвращает нам какой-то контекст.

da79883913f411287e7a5fe0606bab8d.png
Это совершенно нетипичный кейс. Раньше было два варианта:

  • если это desktop-приложение, прямо в main-е руками требовалось писать new, выбирать ClassPathXmlApplicationContext и т.д.
  • если мы работали с Tomcat, то там присутствовал диспетчер сервлета, который по неким конвенциям искал XML и по умолчанию строил из него контекст.


Иными словами, контекст так или иначе был. И мы все равно на вход передавали какие-то классы конфигурации. По большому счету мы выбирали тип контекста. Теперь же у нас только SpringApplication.run,  он принимает конфигурации в качестве аргументов и конструирует контекст

Загадка: что мы можем туда передать?


Дано:

RipperApplication.class

public… main(String[] args) {
   SpringApplication.run(?,args);
}


Вопрос: что еще можно туда передать?

Варианты:

  1. RipperApplication.class
  2. String.class
  3. "context.xml"
  4. new ClassPathResource("context.xml")
  5. Package.getPackage("conference.spring.boot.ripper")


Ответ

Ответ:
Документация говорит, что передать туда можно все, что угодно. Как минимум, это скомпилируется и будет как-то работать.


5249c0cae466170986ee0678fc608979.png

Т.е. на самом деле все ответы верны. Любой из них можно заставить работать, даже String.class, и в каких-то условиях даже ничего не придется делать, чтобы это заработало. Но это отдельная история.

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

SpringApplication.run(Object[] sources, String[] args)
# APPLICATION SETTINGS (SpringApplication)
spring.main.sources= # class name, package name, xml location
spring.main.web-environment= # true/false
spring.main.banner-mode=console # log/off


По-настоящему важен здесь SpringApplication — далее по слайдам он у нас будет Карлсоном.

Наш Карлсон создает какой-то контекст на основе входных данных, которые мы ему передаем. Напоминаю, передаем мы ему, например, такие пять замечательных вариантов, которые все можно заставить работать с помощью SpringApplication.run:

  • RipperApplication.class
  • String.class
  • "context.xml"
  • new ClassPathResource("context.xml")
  • Package.getPackage("conference.spring.boot.ripper")


Что же делает для нас SpringApplication?

f10dca945d839f103d5f557fe456d0c9.png

Когда мы в main-е сами через new создавали контекст, у нас было очень много разных классов, которые имплементируют интерфейс ApplicationContext:

3628c2936a6b2fb26ffca700bde37db8.png

А какие варианты есть, когда контекст строит Карлсон?

Он делает только два вида контекста: либо web-контекст (WebApplicationContext), либо дженерик-контекст (AnnotationConfigApplicationContext).

2fdcf8b4b95f922676b67bca5e80ebf7.png

Выбор контекста основывается на наличии в classpath двух классов:

07baa1a7908e04c88d3f707b2f60354e.png

То есть количество конфигураций не стало меньше. Чтобы построить контекст, мы можем указать все варианты конфигураций. Для построения контекста я могу передать groovy-скрипт или xml; могу указать, какие пакеты просканировать или передать класс, помеченный какими-то аннотациями. То есть у меня есть все возможности.

Однако это Spring Boot. Мы еще ни одного бина не создали, ни одного класса не написали, у нас есть только main, а в нем — наш Карлсон — SpringApplication.run. На вход он получает класс, помеченный какой-то Spring Boot аннотацией.

Если в этот контекст заглянуть, что там будет?

В нашем приложении после подключения пары стартеров было 436 бинов.

d3f8c23bde2c09e137620b7b13bda291.png

Почти 500 бинов только для того, что начать писать.

beacce7686a37a19fa7af18af29906af.png

Далее мы поймем, откуда взялись эти бины.

Но в первую очередь мы хотим сделать так же.

Магия стартеров, кроме того, что они нам решили все проблемы с зависимостями, заключаются в том, что мы подключили всего 3–4 стартера, и у нас 436 бинов. Подключили бы 10 стартеров, бинов было бы сильно больше 1000, потому что каждый стартер, кроме зависимостей, уже приносит конфигурации, в которых прописаны какие-то необходимые бины. Т.е. вы сказали, что хотите стартер для веба, значит нужен диспатчер сервлет и InternalResourceViewResolver. Подключили стартер jpa — нужен EntityManagerFactory бин. Все эти бины эти уже где-то в конфигурациях стартеров прописаны, и они магическим образом приходят в приложение без каких-то действий с нашей стороны.

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

Железный закон 1.1. Всегда посылай ворона


93f100bbf10a54759538b2bcb87c6b89.png

Давайте посмотрим на требование от заказчика. У Железного банка есть много разных приложений, запущенных в разных филиалах. Заказчики хотят, чтобы каждый раз, когда поднимается приложение, посылался ворон — информация о том, что приложение поднялось.

Начнем писать код в приложении конкретного Железного банка (Iron bank). Будем писать стартер, чтобы все приложения Iron Bank, которые будут зависеть от этого стартера, смогли автоматически посылать ворона. Мы помним, что стартеры позволяют нам автоматически подтягивать зависимости. А главное, мы не пишем практически никакой конфигурации.

Мы делаем listener, который слушает, что контекст обновился (последний event), после чего отправляет ворона. Будем слушать ContextRefreshEvent.

public class IronListener implements ApplicationListener {
  @Override
  public void onApplicationEvent(ContextRefreshedEvent event) {
        
System.out.println("отправляем ворона...");
  }
}

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

@Configuration
public class IronConfiguration {
        @Bean
        public RavenListener ravenListener() {
                return new RavenListener();
        }
}

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

На все случаи жизни есть «enable что-то».

6c45677768f95b821d097093387cf611.png

Неужели, если я буду зависеть от 20 стартеров, мне придется ставить @EnableЧтоТоТам для каждого? А если у стартера несколько конфигураций? Главный конфигурационный класс будет весь увешан @Enable*, как новогодняя елка?

6288714abd5cb5b81b1abd6745507c1d.png

На самом деле я хочу получить некую инверсию контроля на уровне зависимостей. Хочу подключать стартер (чтобы все заработало), и ничего не знать о том, как называются его внутренности. Поэтому мы будем использовать spring.factories.

5683cf7e11a3019052515b81db30bf11.png

Итак, что такое spring.factories


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

db0f3136cdcb579a498223f3272e5d61.png

Таким образом мы получаем инверсию контроля, что нам и нужно было.

9154be6fd40c4a626a2ecd8d5198636c.png

Попробуем реализовать. Вместо того, чтобы обращаться к кишкам стартера, который я подключил (эту конфигурацию взять, и эту…), все будет ровно наоборот. У стартера будет файл, который называется spring.factories. В этом файле мы прописываем, какая конфигурация у этого стартера должна быть активизирована у всех, кто его подгрузил. Чуть позже я объясню, как именно это работает в Spring Boot — в какой-то момент он начинает сканировать все jar-ы и искать файл spring.factories.

org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.ironbank.IronConfiguration

@Configuration
public class IronConfiguration {
        @Bean
        public RavenListener ravenListener() {
               return new RavenListener();
        }
}

Теперь все, что нам остается сделать, это подключить стартер в проекте.

compile project(‘:iron-starter’)

В maven аналогично — нужно прописать  dependency.

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

Это не магия. Инверсия контроля не должна быть магией. Также, как не должно быть магией использование Spring. Мы знаем, что это фреймворк в первую очередь для inversion of control. Как есть инверсия контроля для вашего кода, так есть и инверсия контроля для модулей.

@SpringBootApplication всему голова


Вспомните момент, когда мы руками строили контекст в main. Мы писали new AnnotationConfigApplicationContext и передавали туда на вход какую-то конфигурацию, которая была классом java. Сейчас мы тоже пишем  SpringApplication.run и передаем туда класс, который является конфигурацией, только он помечен другой довольно мощной аннотацией @SpringBootApplication, которая несет за собой целый мир.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
 @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
 @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
  …
}


Во-первых, внутри там стоит @Configuration, то есть это конфигурация. Там можно написать @Bean и как обычно прописывать бины.

Во-вторых, над ним стоит @ComponentScan. По умолчанию он сканирует абсолютно все пакеты и подпакеты. Соответственно, если вы в том же пакете или в его подпакетах начинаете создавать сервисы — @Service, @RestController — они автоматически сканируются, поскольку процесс сканирования запускает ваша главная конфигурация.

На самом деле @SpringBootApplication не делает ничего нового. Он просто собрал все best practice, которые были в приложениях на Spring, благодаря чему это теперь некоторая композиция аннотаций, в том числе и @ComponentScan.

Кроме этого тут есть еще вещи, которых не было раньше — @EnableAutoConfiguration. Именно этот класс я прописывал в spring.factories.
@EnableAutoConfiguration, если разобраться, несет с собой @Import:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({EnableAutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
   String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
   Class[] exclude() default {};
   String[] excludeName() default {};
}


Главная задача @EnableAutoConfiguration — сделать импорт, от которого мы хотели избавиться в нашем приложении, потому что его реализация должна была заставлять нас писать название какого-то класса из стартера. А узнать мы его можем разве что из документации. Но все должно быть само.

Нужно обратить внимание на этот класс. Он заканчивается на ImportSelector. В обычном Spring мы пишем Import(Some Configuration.class) какой-то конфигурации и она загружается, как и все зависимые от нее. Это же ImportSelector, это не конфигурация. ImportSelector протаскивает все наши стартеры в контекст.  Он обрабатывает аннотацию @EnableAutoConfiguration из spring.factories, которая выбирает, какие конфигурации загрузить, и добавляет в контекст те бины, которые мы прописали в IronConfiguration.

60662f5cd333ea707070ccf297177bc7.png

Как он это делает?

В первую очередь использует незамысловатый утилитный класс, SpringFactoriesLoader, который смотрит на spring.factories и грузит все из то, что попросят. У него есть два метода, но они не сильно отличаются.

2d2219686ee5232678b48cbaa051abf1.png

Spring Factories Loader существовал еще в Spring 3.2, просто им никто не пользовался. Его, видимо, написали, как потенциальное развитие фреймворка. И вот он перерос в Spring Boot, где есть очень много механизмов пользующейся конвенцией spring.factories. Мы покажем далее, что еще, кроме конфигурации, можно прописывать в spring.factories — listener-ы, необычные процессоры и т.п.

static  List loadFactories(
Class factoryClass, 
ClassLoader cl
)
static List loadFactoryNames(
Class factoryClass, 
ClassLoader cl
)


Так работает инверсия контроля. Мы как бы соблюдаем open closed principle, в соответствии с которым не надо каждый раз где-то что-то менять. Каждый стартер несет очень много полезных вещей в проект (пока мы говорим только про конфигурации, которые он несет). И у каждого стартера может быть свой собственный файл, который называется spring.factories. При помощи него он рассказывает, что именно несет. А в Spring Boot есть много разных механизмов, которые умеют из всех стартеров приносить то, что рассказывают spring.factories.

Но есть нюанс во всей этой схеме. Если мы пойдем изучать, как это устроено в самом Spring, как пишут люди, которые придумали всю эту схему стартеров, то увидим, что у них есть одна зависимость org.springframework.boot:spring-boot-autoconfigure, в META-INF/spring.factories присутствует строчка с EnableAutoConfiguration, и в ней много конфигураций (последний раз, когда я смотрел, не связанных друг с другом автоконфигураций там было порядка 80).

spring-boot-autoconfigure.jar/spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
 org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
 org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
 org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\
 org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration,\
 org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration,\
 org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration.\
...


То есть вне зависимости от того, подключил я стартер или не подключил, когда я работаю со Spring Boot, всегда будет один из jar-ов (jar самого Spring Boot), в котором есть его личный spring.factories, где прописано 90 конфигураций. Каждая из этих конфигураций может содержать в себе множество других конфигураций, например, CacheAutoConfiguration, содержащий вот такую вещь — то, от чего мы хотели уйти:

for (int i = 0; i < types.length; i++) {
 Imports[i] = CacheConfigurations.getConfigurationClass(types[i]);
}
return imports;


Более того, потом там статически из класса вынимается какая-то мапа, и в этой мапе захардкожены загружаемые конфигурации (которых нет в этом spring.factories). Их уже будет не так просто найти.

private static final Map> MAPPINGS;
static {
  Map> mappings = new HashMap>();
  mappings.put(CacheType.GENERIC,    GenericCacheConfiguration.class);
  mappings.put(CacheType.EHCACHE,    EhCacheCacheConfiguration.class);
  mappings.put(CacheType.HAZELCAST,  HazelcastCacheConfiguration.class);
  mappings.put(CacheType.INFINISPAN, InfinispanCacheConfiguration.class);
  mappings.put(CacheType.JCACHE,     JCacheCacheConfiguration.class);
  mappings.put(CacheType.COUCHBASE,  CouchbaseCacheConfiguration.class);
  mappings.put(CacheType.REDIS,      RedisCacheConfiguration.class);
  mappings.put(CacheType.CAFFEINE,   CaffeineCacheConfiguration.class);
  addGuavaMapping(mappings);
  mappings.put(CacheType.SIMPLE,     SimpleCacheConfiguration.class);
  mappings.put(CacheType.NONE,       NoOpCacheConfiguration.class);
  MAPPINGS = Collections.unmodifiableMap(mappings);
}


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

c3b2fc453c4a8dc085495a14e5e55fe5.png

Они будут пытаться. Но:

620a1424730c6f0a49c87e212966e049.png

Подведем промежуточные итоги. Часть конфигураций — хорошие, добрые, правильные стартеры, которые соблюдают инверсию контроля и open closed principle — несут свои spring.factories, в которых прописаны их кишки. Мы будем делать именно так, мы в принципе по-другому не можем сделать.

Кроме этого есть еще часть конфигураций, прописанных в самом Spring Boot, которые грузятся всегда — их еще 90 штук. Также есть еще штук 30 конфигураций, которые просто захардкожены в Spring Boot.

Все это дело поднимается, а потом конфигурации начинают фильтроваться. В конце 2013 года был доклад о том, что нового в Spring 4, где рассказывалось, что появилась аннотация @Conditional, которая дает возможность писать в свои аннотации conditions, которые ссылаются на классы, возвращающие true или false. В зависимости от этого бины либо создаются, либо нет. Поскольку java-конфигурация в Spring тоже является бином, там тоже можно ставить разные conditional. Таким образом, конфигурации считаются, но если conditional вернет false, то они будут отброшены.

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

2ccf9083f1a80c0d6b7723d846912b2e.png

Рассмотрим это на примере.

Железный закон 1.2. Ворон только в продакшене


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

e1f17e71dcd1ec015e9044dbf021fbba.png

Соответственно listener, который запускает ворона, должен создаваться, только если это продакшн. Попробуем это сделать.

Идем в конфигурацию и пишем:

@Configuration
@ConditionalOnProduction
public class IronConfiguration {
        @Bean
        public RavenListener ravenListener() {
                  return new RavenListener();
        }
}


Как мы решаем, продакшн это или не продакшн? У меня была одна странная компания, которая говорила: «Если Windows на машине, значит не продакшн, а если не Windows, значит продакшн». У всех свои conditional.

Конкретно Железный банк сказал, что они хотят управлять этим в ручном режиме: когда поднимается сервис, должен выскакивать попап: «продакшн или нет». Такой condition не предусмотрен в Spring Boot.

@Retention(RUNTIME)
@Conditional(OnProductionCondition.class)
public @interface ConditionalOnProduction {
}


Делаем старый-добрый попап:

public class OnProductionCondition implements Condition {
  @Override
  public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
    return JOptionPane.showConfirmDialog(parentComponent: null, "это продакшен?") == 0;
  }
}


Попробуем.

431b6d8658b449e804b2cad93ecf6f08.png

Поднимаем сервис, жмем в окне yes, и ворон летит (listener создается).
Запускаем еще раз, отвечаем No, ворон не летит.

Итак, аннотация @Conditional(OnProductionCondition.class) ссылается на только что написанный класс, где есть метод, который должен вернуть true или false. Такие кондишены можно придумывать самостоятельно, что делает приложение очень динамичным, позволяет ему работать по-разному в разных условиях.

Паззлер


Итак, @ConditionalOnProduction мы написали. Можем сделать несколько конфигураций, поставить на них кондишены. Допустим, у нас есть  свой кондишн и он популярный — типа @ConditionalOnProduction. И есть, например, 15 бинов, которые нужны только в продакшене. Я их этой аннотацией пометил.

Вопрос: логика, которая узнает, продакшн это или нет, сколько раз должна отработать?
Какая разница, сколько отработает? Ну может эта логика дорогая, требует времени, а время — это деньги.

В качестве иллюстрации мы придумали такой пример:

@Configuration
@ConditionalOnСуроваяЗима
public class UndeadArmyConfiguration {
  ...
}
@Configuration
public class DragonIslandConfiguration {
  @Bean
  @ConditionalOnСуроваяЗима
  public DragonGlassFactory dragonGlassFactory() {
   return new DragonGlassFactory();
  }
  ...
}


Здесь у нас есть два бина: один обычный, один конфигурационный. Оба помечены кондишн-аннотацией — они нужны, только если пришла зима.

Аннотация стоит два раза. Каждое обращение к гидрометцентру в мире Игры престолов дорогое — надо каждый раз платить деньги, чтобы узнать, какая погода.

69d220f6e04188268807f41dcde6c5ed.png

Если бы это работало с кешированием, логика вызывалась бы только один раз (то есть OnProductionCondition.class вызвался бы один, один раз показалось окошко с выбором — продакшн или нет). Консистентная работа выглядит логично. С другой стороны, конфигурация создается в один момент времени, а другой бин может создаться через несколько секунд, когда что-то измениться. Вдруг зима наступит за эти 5 секунд?

Правильный ответ не очень четкий — 300 или 400. Тут на самом деле какая-то полная дичь. Мы очень долго ковырялись, чтобы сначала понять, что происходит. Как оно происходит — это отдельный вопрос.

Ситуация такая. Если кондишн стоит над классом сверху (класс @Component, @Configuration или @Service и вместе с ним стоит кондишн), то он отрабатывает три раза на каждый такой бин. При этом если это конфигурация прописана в стартере, то два раза.

@Configuration
@ConditionalOnСуроваяЗима
public class UndeadArmyConfiguration {
  ...
}


Если же бин прописан внутри конфигурации, то всегда один раз.

@Configuration
public class DragonIslandConfiguration {
  @Bean
  @ConditionalOnСуроваяЗима
  public DragonGlassFactory dragonGlassFactory() {
   return new DragonGlassFactory();
  }
  ...
}


Поэтому данная загадка не имеет точного ответа, поскольку нужно выяснить, где прописана конфигурация. Если она прописана в стартере, то ее кондишн почему-то отработает два раза, а кондишн на бин в любом случае отработает один раз, получаем 300. Но если конфигурация прописана не в стартере, только ее кондишн трижды запустится, плюс еще один раз на бин. Получаем 400.

Возникает вопрос:, а как это вообще работает и почему оно так? И ответ у меня только такой:

6c7efad4bc5e1279c7ba350355a0fa59.png

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

Железный закон 1.3. Ворон по адресу


Продолжаем развивать наш стартер. Надо как-то конкретизировать полет ворона.

9324fc726c3e1f36d5459fae6838f093.png

В каком файле мы прописываем вещи для стартера? Стартеры приносят конфигурацию, в которой есть бины. Как эти бины настроены? Откуда они берут data source, user и т.п. У них, естественно, есть дефолты на все случаи жизни, но как они позволяют это все переопределять? Есть два варианта: application.properties и application.yml. Туда можно вписать некую информацию, которая будет еще красиво автокомплититься в IDEA.

Чем наш стартер хуже? Тот, кто им пользуется, тоже должен иметь возможность сообщить, по каким адресам лететь ворону — нам нужно сделать список получателей. Это первое.

Второе — мы хотим, чтобы листенер не создавался и ворон не отсылался, если человек не прописал у себя адресатов. Нам нужен дополнительный кондишн на создание listener-а, который посылает ворона. Т.е. сам стартер нужен, потому что в нем может быть много разных вещей, помимо ворона. Но если не написано, куда ворон должен летать, тот просто не создается.

И третье — мы тоже хотим автокомплит, чтобы люди, которые к себе подтянули наш стартер, получили комплит на все свойства, которые считывает стартер.

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

@ConditionalOnBean
@ConditionalOnClass
@ConditionalOnCloudPlatform
@ConditionalOnExpression
@ConditionalOnJava
@ConditionalOnJndi
@ConditionalOnMissingBean
@ConditionalOnMissingClass
@ConditionalOnNotWebApplication
@ConditionalOnProperty
@ConditionalOnResource
@ConditionalOnSingleCandidate
@ConditionalOnWebApplication
...


И действительно, тут есть штуки, которые нам помогут. В первую очередь @ConditionalOnProperty. Это кондишн, который срабатывает, если есть определенное property или property с каким-то значением, указанным в application.yml. Аналогично у нас есть @ConfigurationalProperty, чтобы сделать автокомплит.

Автокомплит


Мы должны сделать так, чтобы все property начали автокомплититься. Хорошо бы, чтобы это автокомплитилось не только у людей, которые в своем application.yml будут их прописывать, но и в нашем стартере.

Назовем наше property «ворон». Он должен знать, куда лететь.

@ConfigurationProperties("ворон")
public class RavenProperties {
        
List куда;
}


IDEA нам сообщает, что здесь что-то не так:

377da5e032812dd581b7832e2e6ef56b.png

В документации написано, что у нас не добавлена зависимость (в Maven была бы не отсылка к документации, а кнопочка «добавить зависимость»). Просто добавим ее в нужный проект.

subproject {
     dependencies  {
          compileOnly 'org.springframework.boot:spring-boot-configuration-processor'
          compile 'org.springframework.boot: spring-boot-starter'
      }
}


Теперь по мнению IDEA, у нас все есть.

Объясню, что за зависимость мы добавили. Все знают, что такое annotation processor. В упрощенном виде это такая штука, которая на этапе компиляции может что-то делать. Например, у Lombok есть свой annotation processor, который на этапе компиляции генерит кучу много полезного кода — сеттеры, геттеры.

Откуда берется автокомплит на property, которые находятся в application properties? Есть JSON-файл, с которым IDEA умеет работать. В этом файле описаны все property, которые IDEA должна уметь автокомплитить. Если вы хотите, чтобы property, которые вы придумали для стартера, IDEA тоже могла автокомплитить, у вас есть два пути:

  • вы можете сами вручную залезть в этот JSON и там в определенном формате их добавить;
  • вы можете подтянуть annotation processor из Spring Boot, который умеет этот кусок JSON-а гене

    © Habrahabr.ru