Spring Boot Starter: практически, принципиально и подробно. Part 2

Привет, Хабр! На связи снова Сергей Соловых, Java-разработчик в команде МТС Digital.

Продолжаю рассказывать о Spring Boot Starter. В прошлой части мы создали принципиальное решение, которое позволит запустить стартер как подключаемую к другому Spring-Boot-приложению библиотеку.

В этой части мы разберемся с зависимостями, стандартными и кастомными аннотациями.

d59caf531066c07b9f43edd1da38d898.png

Зависимости

Созданный нами каркас Spring-Boot-стартера был неплох для понимания работы общих механизмов и знакомства с принципиальным устройством. Но развитие компонента требует пересмотреть его архитектуру. Мы реорганизуем фабрики: теперь они будут разделяться не по типам объектов (животные и места содержания), а по профилю каждого обитателя. Это позволит более тонко определить различные условия создания объектов. Давайте по порядку.

Каждую фабрику назовем по имени обитателя и перенесем туда создание питомца и вольеры для него:

@AutoConfiguration("napoleonFactory")
public class NapoleonFactory {

   @Bean
   public Napoleon napoleon() {
       return new Napoleon();
   }

   @Bean
   public Lawn lawn(Napoleon napoleon) {
       return new Lawn(napoleon);
   }
}

Оказалось, есть условие: Наполеон постоянно что-то жует, и чтобы он мог нормально существовать в космозоопарке, на его территории должен быть огород с разными овощами. Создадим класс Garden:

@RequiredArgsConstructor
public class Lawn {

   private final Napoleon napoleon;

   public static class Garden {
//code
   }
}

И добавим его в фабрику:

@AutoConfiguration("napoleonFactory")
public class NapoleonFactory {

   @Bean
   public Napoleon napoleon() {
       System.out.println("Napoleon created");
       return new Napoleon();
   }

   @Bean
   public Lawn.Garden garden() {
       System.out.println("Garden created");
       return new Lawn.Garden();
   }

   @Bean
   public Lawn lawn(Napoleon napoleon) {
       System.out.println("Lawn created");
       return new Lawn(napoleon);
   }
}

Напишем и запустим тест для проверки:

@SpringBootTest(classes = NapoleonFactory.class)
class NapoleonFactoryTest {

   @Autowired
   private ApplicationContext context;

   @ParameterizedTest
   @MethodSource("beanNames")
   void applicationContextContainsBean(String beanName) {
       Assertions.assertTrue(context.containsBean(beanName));
   }

   private static Stream beanNames() {
       return Stream.of(
               "napoleonFactory",
               "napoleon",
               "garden",
               "lawn"
       );
   }
}

Все бины созданы, но есть проблема — вывод в консоль показывает порядок их создания:

Napoleon created
Garden created
Lawn created

Как перед созданием Наполеона нам быть уверенными, что для него уже есть огород? Тут может помочь аннотация @DependsOn. Как гласит javadoc аннотации @DependsOn, она принимает параметром массив имен компонентов, от работы которых зависит наш bean. А еще гарантирует, что:

  • требуемые компоненты будут созданы до инициализации bean’а

  • при завершении работы приложения сначала будет завершена работа bean’а, а потом его зависимостей

Давайте укажем, что объект «napoleon» зависит от объекта «garden», а значит, должен быть создан после него:

@Bean
@DependsOn("garden")
public Napoleon napoleon() {
   System.out.println("Napoleon created");
   return new Napoleon();
}

Запустим тест и посмотрим на вывод в консоль:

Garden created
Napoleon created
Lawn created

Вот теперь все правильно. Условие зависимости гарантирует, что требуемые бины создадутся. Иначе, если в контексте не будет компонента »garden», приложение упадет с ошибкой NoSuchBeanDefinitionException.

Условия

Стандартные аннотации

Основное отличие @ConditionOn от @DependsOn в том, что невыполнение условий не приведет к ошибке старта приложения. Просто не будет создан бин определенного класса. Также условия более гибкие в настройке и работают с разными типами параметров. Давайте создадим несложный пример.

@ConditionalOnProperty

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

Давайте посмотрим, как это работает. Вернемся к нашему примеру. Выяснилось, что во время детских экскурсий в зоопарк некоторые дети пугаются тигрокрыса — уж больно свирепым он выглядит. Так что было решено не показывать его в дни экскурсий малышей. Давайте добавим настройку в параметры нашего зоопарка, которая позволит включать и выключать создание этого животного. В файл application.properties внесем параметр:

app.tigrokris.create=false

Создадим фабрику и применим там данную аннотацию:

@Bean
@ConditionalOnProperty(
       value = "app.tigrokris.create",
       havingValue = "true",
       matchIfMissing = false
)
public Tigrokris tigrokris() {
   return new Tigrokris();
}

Параметр value = «app.tigrokris.create» сообщает название требуемого параметра. havingValue = «true» — это ожидаемое значение для выполнения условия. matchIfMissing = false — это определение поведения в случае отсутствия этого параметра.

@ConditionalOnBean и @ConditionalOnMissingBean

Аннотация @ConditionalOnBean диктует условия, что бин будет создан, только если в контексте приложения есть компонент определенного типа или названия. Применяя @ConditionalOnMissingBean, мы получаем обратное условие: бин будет создан в случае отсутствия указанного объекта. Эти аннотации полезны при создании конфигураций для различных сред выполнения, а еще для дефолтной реализации интерфейсов, которые могут быть переопределены пользователями.

В предыдущем примере была описана ситуация, что в определенных условиях бин класса Tigrokris не будет создан. Но теперь приложение может упасть с ошибкой — этот объект необходим для создания вольера. Можно, конечно, и тут добавить @ConditionalOnProperty с тем же условием, но более правильным решением будет ориентироваться на наличие нужного бина в контексте. То есть в зависимости от наличия бина будет или не будет генериться клетка для тигрокрыса. Вот так выглядит вся фабрика целиком:

@AutoConfiguration("tigrokrisFactory")
public class TigrokrisFactory {

   @Bean
   @ConditionalOnProperty(
           value = "app.tigrokris.create",
           havingValue = "true",
           matchIfMissing = false
   )
   public Tigrokris tigrokris() {
       return new Tigrokris();
   }

   @Bean
   @ConditionalOnBean(name = "tigrokris")
   public ClosedEnclosure closedEnclosure(Tigrokris tigrokris) {
       return new ClosedEnclosure(tigrokris);
   }
}

Давайте напишем и запустим тест:

@SpringBootTest(classes = TigrokrisFactory.class)
class TigrokrisFactoryTest {

   @Autowired
   private ApplicationContext context;

   @ParameterizedTest
   @MethodSource("beanNames")
   void applicationContextContainsBean(String beanName, boolean expected) {
       Assertions.assertEquals(expected, context.containsBean(beanName));
   }

   private static Stream beanNames() {
       return Stream.of(
               Arguments.of("tigrokrisFactory", true),
               Arguments.of("tigrokris", false),
               Arguments.of("closedEnclosure", false)
       );
   }
}

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

@ConditionalOnResource

Проверяет наличие указанного ресурса. Этой аннотацией можно пользоваться для определения логгера, который будет использоваться в приложении — в зависимости от того, какой файл настроек размещен в classpath (например, logback.xml).

Применим это условие к Шуше. Шуша — личность творческая. Лунными ночами он любит писать стихи. И рядом всегда должен быть блокнот, куда он может записать то, что подсказала ему муза. Давайте создадим файл notebook.txt и разместим его в папке ресурсов. Фабрика по созданию объекта типа Shusha теперь выглядит так:

@AutoConfiguration("shushaFactory")
public class ShushaFactory {

   @Bean
   @ConditionalOnResource(resources = "classpath:notebook.txt")
   public Shusha shusha() {
       return new Shusha();
   }

   @Bean
   @ConditionalOnBean(name = "shusha")
   public Park park(Shusha shusha) {
       return new Park(shusha);
   }
}

Потом добавим тест:

@SpringBootTest(classes = ShushaFactory.class)
class ShushaFactoryTest {

   @Autowired
   private ApplicationContext context;

   @ParameterizedTest
   @MethodSource("beanNames")
   void applicationContextContainsBean(String beanName) {
       Assertions.assertTrue(context.containsBean(beanName));
   }

   private static Stream beanNames() {
       return Stream.of(
               "shushaFactory",
               "shusha",
               "park"
       );
   }
}

@ConditionalOnExpression

Эта аннотация позволяет указать условие как результат вычисления SpEL-выражения. Например, можно составить комбинацию нескольких параметров конфигурации, создавая объект-заглушку, который заместит необходимый, но недоступный в этом окружении веб-сервис с нужными нам данными:»${app.web.stub.enabled} and ${app.web.mock.data}».

Синий, как и все рептилии, очень любит греться на песочке в ясную теплую погоду. Давайте добавим два параметра в настройки приложения:

app.sun.is-shining=true
app.weather=clear

Обратите внимание, что у одного параметра значение булево, а у второго — строковое. Теперь добавим обращения к этим параметрам с помощью Spring Expression Language:

@AutoConfiguration("siniiFactory")
public class SiniiFactory {

@Bean
@ConditionalOnExpression("${app.sun.is-shining} and '${app.weather}'.equals('clear')")
public Sinii sinii() {
   return new Sinii();
}

   @Bean
   @ConditionalOnBean(name = "sinii")
   public Swamp swamp(Sinii sinii) {
       return new Swamp(sinii);
   }
}

Мы создали все условия, и Синий должен быть доволен. Дальше проверим создание этого объекта:

@SpringBootTest(classes = SiniiFactory.class)
class SiniiFactoryTest {

   @Autowired
   private ApplicationContext context;

   @ParameterizedTest
   @MethodSource("beanNames")
   void applicationContextContainsBean(String beanName) {
       Assertions.assertTrue(context.containsBean(beanName));
   }

   private static Stream beanNames() {
       return Stream.of(
               "siniiFactory",
               "sinii",
               "swamp"
       );
   }
}

@ConditionalOnJava

Любопытная аннотация, позволяет регулировать создаваемую реализацию согласно версии Java. Это условие полезно для соблюдения обратной совместимости.

Давайте укажем специальный класс, который будет создаваться при использовании java 1.7 и более ранних версий. Для этого используем аннотацию @ConditionalOnJava, указав в параметрах версию java и правило сравнения:

@ConditionalOnJava(value = JavaVersion.EIGHT, range = OLDER_THAN)
public class CosmoZooLegacy {

   @PostConstruct
   private void greeting() {
       System.out.println("Старая версия CosmoZoo");
   }
}

Не забудьте добавить его в spring.factories. А в основном классе CosmoZoo нам пригодится условие @ConditionalOnMissingBean:

@ConditionalOnMissingBean(CosmoZooLegacy.class)
public class CosmoZoo {
	//code
}

Внесем правку в тест:

@SpringBootTest
class CosmoZooTest {

   @Autowired
   private ApplicationContext context;

   @ParameterizedTest
   @MethodSource("beanNames")
   void applicationContextContainsBean(String beanName, boolean expected) {
       Assertions.assertEquals(expected, context.containsBean(beanName));
   }

   private static Stream beanNames() {
       return Stream.of(
               Arguments.of("science.zoology.CosmoZoo", true),
               Arguments.of("science.zoology.CosmoZooLegacy", false)
       );
   }

   @SpringBootApplication
   public static class TestApplication {
       //no-op
   }
}

Кроме аннотаций, которые мы рассмотрели, можно упомянуть еще несколько готовых к применению «из коробки». Давайте кратко по ним пробежимся:

  • @ConditionalOnClass и @ConditionalOnMissingClass — условия, проверяющие наличие указанного класса в classpath

  • @ConditionalOnSingleCandidate — это правило создания компонента требует, чтобы в контексте приложения был доступен только один бин определенного типа

  • @ConditionalOnWebApplication и @ConditionalOnNotWebApplication — помогают установить правила создания объекта в зависимости от того, веб-приложение — программа или нет

Существуют еще несколько аннотаций типа @Conditional, предложенных создателями Spring’а и готовых к использованию. Их можно найти в документации.

Собственные условия

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

public class TimeCondition implements Condition {

   @Override
   public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
       String start = context.getEnvironment().getProperty("app.cleaning.start");
       String end = context.getEnvironment().getProperty("app.cleaning.end");

       int currentHour = LocalDateTime.now().getHour();
       return Integer.parseInt(start) <= currentHour && currentHour <= Integer.parseInt(end);
   }
}

Добавим в application.properties:

app.cleaning.start=10
app.cleaning.end=18

И конечно же, создадим номинальный класс самого робота-уборщика:

public class Cleaner {

   public void doWork() {
       //code
   }
}

Добавим RobotFactory:

@AutoConfiguration("robotFactory")
public class RobotFactory {

   @Bean
   @Conditional(TimeCondition.class)
   public CleaningRobot cleaningRobot() {
       return new CleaningRobot();
   }
}

Реализация условий через аннотацию

Реализовать условие можно более компактно, написав собственную аннотацию. Давайте добавим робота-фокусника, который по выходным развлекает посетителей:

public class Magician {

   public void doWork() {
       //code
   }
}

Теперь добавим новый класс, реализующий интерфейс Condition:

public class DayOfWeekCondition implements Condition {

   @Override
   public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
       int dayOfWeekNumber = LocalDateTime.now().get(DAY_OF_WEEK);

       return dayOfWeekNumber > 5;
   }
}

Перенесем это условие в аннотацию:

@Retention(RUNTIME)
@Conditional(DateCondition.class)
public @interface ConditionalOnDayOfWeek {
}

Добавим к созданию бина:

@Bean
@ConditionalOnDayOfWeek
public Magician magician() {
   return new Magician();
}

Соединение нескольких условий

AND

Если нужно соблюсти два и более условий, достаточно указать их над нужным классом:

@Bean
@Conditional(TimeCondition.class)
@ConditionalOnDayOfWeek
public MyBean myBean() {
 return new MyBean();
}

В этом случае объект будет создан, когда выполняются оба условия: это выходные дни и время в указанном диапазоне. Но если условий много, удобнее собрать их в одно. Для этого нужно создать новый класс, наследовать его от AllNestedConditions и указать в нем все необходимые условия:

public class TimeAndDayOfWeekConditions extends AllNestedConditions {

   public TimeAndDayOfWeekConditions() {
       super(ConfigurationPhase.REGISTER_BEAN);
   }

   @Conditional(TimeCondition.class)
   static class OnTimeCondition {
   }

   @ConditionalOnDayOfWeek
   static class OnDayOfWeekCondition {
   }
}

Потом создать новую аннотацию:

@Retention(RetentionPolicy.RUNTIME)
@Conditional(TimeAndDayOfWeekConditions.class)
public @interface ConditionalOnTimeAndDayOfWeek {
}

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

@Bean
@ConditionalOnTimeAndDayOfWeek
public CandyShop candyShop() {
   return new CandyShop();
}

OR

Аналогично можно описать несколько условий, соединенных логическим «или». Для этого создаем класс, расширяющий абстрактный класс AnyNestedCondition:

public class TimeOrDayOfWeekConditions extends AnyNestedCondition {

   public TimeOrDayOfWeekConditions() {
       super(ConfigurationPhase.REGISTER_BEAN);
   }

   @Conditional(TimeCondition.class)
   static class OnTimeCondition {
   }

   @ConditionalOnDayOfWeek
   static class OnDayOfWeekCondition {
   }
}

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

Очевидно, что при необходимости условия можно усложнять — например, включая несколько условий «AND» в одно «OR». AllNestedConditions и AnyNestedCondition могут принять любые условия — как стандартные, так и разработанные самостоятельно. Варианты добавления условий на результат работы влияния не оказывают — доступно применение как кастомной аннотации, так и @Conditional с параметром — классом, реализующим интерфейс Condition.

ConfigurationPhase

Оба класса, объединяющие условия вложенных классов, принимают в своем конструкторе параметр ConfigurationPhase, enum. Он позволяет выбрать одно из двух значений:

  • PARSE_CONFIGURATION — используется, если условие применяется над классами, обозначенными аннотацией @Configuration

  • REGISTER_BEAN — параметр, применяющийся при описании обычного (не @Configuration) bean-компонента

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

© Habrahabr.ru