Spring Boot Starter: практически, принципиально и подробно. Part 2
Привет, Хабр! На связи снова Сергей Соловых, Java-разработчик в команде МТС Digital.
Продолжаю рассказывать о Spring Boot Starter. В прошлой части мы создали принципиальное решение, которое позволит запустить стартер как подключаемую к другому Spring-Boot-приложению библиотеку.
В этой части мы разберемся с зависимостями, стандартными и кастомными аннотациями.
Зависимости
Созданный нами каркас 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-компонента
Вот мы и освоили навыки точного конфигурирования контекста через различные условия создания бинов. Использование условных аннотаций позволяет создавать более гибкие и настраиваемые компоненты, которые могут адаптироваться к различным условиям выполнения. К тому же это позволяет избежать создания лишних бинов, которые не будут использоваться в конкретной конфигурации приложения. Если есть вопросы или хотите поделиться своим опытом, добро пожаловать в комментарии. Все почитаю и обязательно вернусь с обратной связью!