Всем привет, меня зовут Сергей Соловых, я Java-разработчик в команде МТС Digital. За последние 2 года я написал и выпустил в продакшен более 30 микросервисов. Выдержать столь высокий темп помогло применение общепроектных решений и паттернов разработки.
Общепроектные решения — хорошая практика. Так можно создать единую основу для всех микросервисов, снизить риск ошибок и сосредоточиться на конкретных задачах, связанных с бизнес-логикой наших проектов. К тому же, микросервисы на единой основе легче интегрировать друг с другом, а это важно при разработке сложных систем.
В этом цикле статей я поделюсь выжимкой из материалов, накопленных мною в процессе создания микросервисов. Это будет полезно тем, кто только начинает разбираться, что же такое Spring Boot Starter и с чем его едят.
Первую часть мы посвятим созданию принципиального решения, которое позволит запустить стартер как подключаемую к другому Spring Boot-приложению библиотеку.
Write once, run anywhere
Одно из преимуществ микросервисной архитектуры в модульности ее компонентов. Общее решение, вынесенное за скобки, можно переиспользовать как внутри разрабатываемого проекта, так и в следующих сервисах с аналогичным стеком. А механизмы Spring Boot помогут создать общий компонент, настроить и легко интегрировать его. Так что принцип из заголовка, сделавший Java столь знаменитым языком программирования, вполне уместен и здесь.
А что можно выделить в отдельное решение? Присмотритесь к своему проекту. Наверняка во многих частях задействованы кастомные аннотации и их обработчики, модули безопасности, различные интерцепторы, валидаторы, средства мониторинга и трассировки. Любое решение, используемое в двух и более микросервисах, — отличный кандидат на выделение в самостоятельный компонент.
Примечание
В этой статье я постарался изложить собранную информацию и опыт, полученный в ходе решения рабочих задач. Основная цель — познакомить читателя с доступными инструментами, а не создать реальную, готовую к применению библиотеку.
Что важно знать до начала
Согласно документации названия официальных библиотек строятся по единому шаблону: префикс из spring-boot-starter и суффикс из имени проекта. Например: spring-boot-starter-aop, spring-boot-starter-data-jpa, spring-boot-starter-web. Сторонние библиотеки напротив, должны иметь обратный порядок: сначала название проекта, а ключевые слова spring-boot-starter служат окончанием: my-project-spring-boot-starter. Это правило стоит учитывать для предотвращения конфликта в общем пространстве имен проекта.
Также обозначу несколько моментов, важных при реализации подобных решений:
Компонент должен легко интегрироваться. В идеальном случае достаточно добавления зависимости для активации желаемой функциональности.
К компоненту должно быть подробное ReadMe, даже если стартер работает по принципу «добавил и забыл». Предлагаю отталкиваться от следующей структуры документа:
○ Описание функций;
○ Способ и особенности интеграции;
○ Обзор доступных параметров проекта. Пример минимальной конфигурации;
○ Ссылки на внутреннюю документацию.Проектную документацию также стоит поддерживать в актуальном состоянии. Не всегда члены команды могут прочесть README, размещенный в репозитории.
Если компонент подразумевает активацию, то включаться и выключаться он должен через соответствующую настройку в конфигурационном файле. Лучше не использовать активацию через добавление аннотации или еще какую-либо правку Java-кода. Эта опция может пригодиться тестировщику или специалисту DevOps — они в состоянии отредактировать config-map проекта, а самостоятельно внести правку в код у них не получится
В некоторых случаях полезно предусмотреть возможность замены реальных механизмов стартера на заглушки (stub). Это может сильно упростить запуск микросервиса — как локально, так и на различных стендах. Например, если это стартер, отвечающий за авторизацию. Чтобы не воссоздавать на тестировочном стенде всё необходимое для реальной авторизации, можно просто переключиться на использование захардкоженных значений.
Стек
В примерах, описанных в статье, используется следующий стек технологий:
Java 21;
Gradle 8.6;
Spring Boot Starter 2.7.12;
Lombok 1.18.30.
Для более подробного ознакомления код доступен на GitHub.
Структура
Стартер можно разделить несколько основных составляющих:
Автоконфигурации;
Ограничения бинов: условия и зависимости;
Работа с параметрами;
Валидация данных;
Управление переменными среды и контекста;
Реализация бизнес-логики;
Тестирование;
Документация.
Пункты бизнес-логики и документации зависят от контекста поставленных задач и конкретной реализации вашего стартера. Так что в этом цикле статей я не буду их рассматривать. Давайте перейдём к практике.
Автоконфигурация
Разработчики Spring рекомендуют делить стартеры на два субмодуля — автоконфигурацию и бизнес-логику. Но при этом сразу оговариваются, что выбор решения за разработчиком. Для простоты наших примеров мы всё будем выполнять в одном общем модуле.
Создаем принципиальную реализацию
Автоконфигурация, пожалуй, это одна из самых крутых «киллер-фич» Spring Boot. С её помощью спринг пытается автоматически настроить приложение, исходя из подключенных зависимостей и переданных параметров. Однако в документации по автоконфигурации или созданию собственного стартера не описано, как реализовать ее самостоятельно. Попробуем закрыть этот пробел.
Давайте напишем небольшое приложение для Космического Зоопарка. Оно поможет нам наблюдать за жизнью инопланетных животных, общаться и ухаживать за ними, кормить и играть.
Создадим gradle-проект с названием 'cosmozoo-spring-boot-starter' и добавим в зависимости всего одну строку:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter:2.7.18'
}
Этого достаточно для создания стартера со всеми необходимыми функциями. Теперь сделаем класс сервиса:
public class CosmoZoo {
@PostConstruct
private void greeting() {
System.out.println("Добро пожаловать в CosmoZoo!");
}
}
И добавим ссылку на него в файл spring.factories, который нужно создать и разместить директории 'resources/META-INF/':
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
science.zoology.CosmoZoo
Всё, наш spring-boot-starter с минимальной функциональностью готов.
Публикуем и тестируем
Можно воспользоваться gradle-плагином 'maven-publish' и опубликовать проект в локальном maven-репозитории. Затем собрать новый Spring Boot-проект и в нем проверить работу стартера, добавив зависимость:
implementation 'science.zoology:'cosmozoo-spring-boot-starter:0.1.0-SNAPSHOT'
После запуска вашего стенда в консоли должно появится сообщение: «Добро пожаловать в CosmoZoo!». Я же напишу SpringBootTest, проверяющий контекст на наличие бина:
@SpringBootTest
class CosmoZooTest {
@Autowired
private ApplicationContext context;
@Test
void applicationContextContainsBean() {
boolean actual = context.containsBean("science.zoology.CosmoZoo");
Assertions.assertTrue(actual);
}
@SpringBootApplication
public static class TestApplication {
//no-op
}
}
Но зоопарка без животных не бывает. Так что и наш стартер не ограничится единственным классом. Давайте добавим классы, описывающие существ из нашего зоопарка. И пусть после создания они напишут вам: Шуша поинтересуется, как ваши дела, козел Наполеон, как всегда, голоден и требует капусты, тигрокрыс урчит, а Синий издаёт загадочное «буль» из своего болота. Создадим интерфейс Animal:
public interface Animal {
void voice();
}
И на примере Шуши рассмотрим написание класса:
public class Shusha implements Animal {
@Override
public void voice() {
System.out.println("Привет! Как дела?");
}
}
Добавим всех животных в класс зоопарка:
public class CosmoZoo {
@Autowired
private List<Animal> animals;
@PostConstruct
private void greeting() {
System.out.println("Добро пожаловать в CosmoZoo!");
animals.forEach(Animal::voice);
}
}
Теперь необходимо создать внести все классы животных в список AutoConfiguration:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
science.zoology.CosmoZoo,\
science.zoology.animal.Shusha,\
science.zoology.animal.Napoleon,\
science.zoology.animal.Sinii,\
science.zoology.animal.Tigrokris
Также добавим проверку новых бинов в нашем тесте:
@SpringBootTest
class CosmoZooTest {
@Autowired
private ApplicationContext context;
@ParameterizedTest
@MethodSource("beanNames")
void applicationContextContainsBean(String beanName) {
boolean actual = context.containsBean(beanName);
Assertions.assertTrue(actual);
}
private static Stream<String> beanNames() {
return Stream.of(
"science.zoology.CosmoZoo",
"science.zoology.animal.Napoleon",
"science.zoology.animal.Shusha",
"science.zoology.animal.Sinii",
"science.zoology.animal.Tigrokris"
);
}
@SpringBootApplication
public static class TestApplication {
//no-op
}
}
Если при выполнении тестов заглянуть в консоль, то там можно увидеть ожидаемый вывод:
Добро пожаловать в CosmoZoo!
Хочу капусты!
Привет! Как дела?
Буль
Мурр-рр-ррр
Перечисление компонентов в spring.factories — вполне допустимый способ создания бинов и добавления их в спринг-контекст. Если вы проявите любопытство и заглянете внутрь библиотеки spring-boot-starter, то найдёте файл spring.factories с более чем сотней строк — PropertySourceLoader, ConfigDataLoader, ApplicationContextFactory, ApplicationContextInitializer и прочими элементами, относящимися к жизненному циклу Spring Boot-приложения.
Если кто-то захочет получше узнать процесс обработки spring.factories, то можно начать с org.springframework.boot.SpringApplication#getSpringFactoriesInstances(java.lang.Class<T>, java.lang.Class<?>[], java.lang.Object...), откуда будет вызван метод loadFactoryNames класса SpringFactoriesLoader.
Дорабатываем и улучшаем
Описание создания бинов в spring.factories выглядит удобным, пока новый стартер включает в себя лишь несколько компонентов. Если количество классов гораздо больше или у них разная бизнес-ответственность, было бы хорошей практикой разделить их по группам.
Давайте добавим для наших животных комфортные условия содержания: создадим для Шуши уютный парк, для Наполеона — лужайку, тигрокрыса разместим в закрытом вольере, а Синий будет жить в чудесном болоте:
@RequiredArgsConstructor
public class Swamp {
private final Sinii sinii;
}
Остальные классы схожи с этим и отличаются только названием.
Следующим шагом перенесем создание объектов в фабрики, внутри которых будем использовать давно привычную аннотацию @Bean. Преимущества такого решения:
фабрик можно создать несколько, разделив ответственность согласно архитектуре стартера и принадлежности компонентов;
можно более гибко настраивать условия создания, становятся доступны управляющие факторы, в том числе через java-код;
в отличие от перегруженного файла spring.factories, условия и взаимодействие между бинами нагляднее и легко читаются.
Добавим следующий код в наш стартер. Вот так будет выглядеть фабрика по созданию существ:
public class AnimalFactory {
@Bean
public Napoleon napoleon() {
return new Napoleon();
}
@Bean
public Shusha shusha() {
return new Shusha();
}
@Bean
public Sinii sinii() {
return new Sinii();
}
@Bean
public Tigrokris tigrokris() {
return new Tigrokris();
}
}
И вот фабрика, создающая места для их размещения:
public class AviaryFactory {
@Bean
public Lawn lawn(Napoleon napoleon) {
return new Lawn(napoleon);
}
@Bean
public Park park(Shusha shusha) {
return new Park(shusha);
}
@Bean
public Swamp swamp(Sinii sinii) {
return new Swamp(sinii);
}
@Bean
public ClosedEnclosure closedEnclosure(Tigrokris tigrokris) {
return new ClosedEnclosure(tigrokris);
}
}
А уже сами классы фабрик добавим в spring.factories:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
science.zoology.CosmoZoo,\
science.zoology.factory.AviaryFactory, \
science.zoology.factory.AnimalFactory
Так как теперь мы создаем объекты через методы, отмеченные аннотацией @Bean, их имена изменились и они согласуются с названиями этих методов. Чуть изменим наш тест и проверим одержание контекста:
@Autowired
private ApplicationContext context;
@ParameterizedTest
@MethodSource("beanNames")
void applicationContextContainsBean(String beanName) {
boolean actual = context.containsBean(beanName);
Assertions.assertTrue(actual);
}
private static Stream<String> beanNames() {
return Stream.of(
"science.zoology.CosmoZoo",
"napoleon",
"shusha",
"sinii",
"tigrokris",
"science.zoology.factory.AnimalFactory",
"science.zoology.factory.AviaryFactory"
);
}
Запустим тестирование, результат — green line. В контекст также добавились бины фабрик — "science.zoology.factory.AnimalFactory" и "science.zoology.factory.AviaryFactory" — как если бы в обычном проекте использовали аннотацию @Configuration.
Будьте внимательны, особенно если вы строите Spring Boot Starter впервые. Так как модули для внутреннего обслуживания компонентов часто размещают в одном пакете с компонентами основного приложения, классы стартера могут попасть в classpath.
Там они будут просканированы спрингом, как и все прочие части приложения. В этом случае можно регулировать создание бинов обычными аннотациями, без автоконфигурации, но к Spring-Boot-Starter такая библиотека никакого отношения иметь не будет. Получится несколько классов, по неведомой причине вынесенных из проекта в подключаемый jar'ник.
Настраиваем
Классы, указанные в spring.factories, называются классами автоконфигурации. Их также можно настраивать через аннотации: как привычные нам, так и специальные из пакета org.springframework.boot.autoconfigure:
@AutoConfigureOrder позволяет зафиксировать порядок создания бинов из файлов автоконфигураций (по аналогии с аннотацией @Order);
@AutoConfigureBefore и @AutoConfigureAfter дают возможность более гибко указать очередность инициализации;
@AutoConfiguration включает в себя все предыдущие аннотации, а также позволяет задать имя бина. По умолчанию для класса автоконфигурации именем выступает его полное имя, например: "science.zoology.factory.AnimalFactory
Безусловно, Spring — фреймворк, который многое делает за разработчика и частично снимает с него ответственность. Spring обеспечит требуемый порядок появления объектов. Для создания вольеров нужны готовые объекты животных. Сначала будут созданы все существа, а затем места содержания. Но давайте обезопасим себя на случай, если что-то пойдет не так. Укажем этот порядок вручную, добавив соответствующие аннотации.
Для этого изменим фабрику создания животных, указав, что обитатели зоопарка должны появиться до объекта класса CosmoZoo, так как в нем проверяются созданные бины:
@AutoConfigureBefore(CosmoZoo.class)
public class AnimalFactory {
//code
}
А для фабрики AviaryFactory используем аннотацию @AutoConfiguration, указав не только порядок создания, но и новое имя для этого компонента:
@AutoConfiguration(
value = "aviaryFactory",
after = AnimalFactory.class
)
public class AviaryFactory {
//code
}
Готово! И не забудьте внести соответствующую правку в тест.
Вот и всё, что нужно знать разработчику для того, чтобы создать свой стартер. Данное решение будет работать и вполне применимо в продакшен. Но бывают ситуации, когда простых инструментов недостаточно. Что можно сделать в таком случае? Поговорим об этом в следующей части.
Комментарии (3)
DieSlogan
02.05.2024 14:59Вспомнил шутку, мол, пока в Springe зависимости сделаешь, сборщики настроишь начнёт казаться, что hello world пишешь, а биллинг средней руки
mello1984
В целом спасибо, но если честно - ожидалось, что поделитесь, тем, какие кастомные стартеры с каким функционалом оказались полезны при разработке в общей инфраструктуре. А оказалось - статья по созданию стартера. Что в целом тоже неплохо, но не то что ожидалось...
riccio Автор
Распространенные примеры использования стартеров — логирование, кэширование, сбор метрик, различные интерцепторы, а также компоненты, связанные с обеспечением безопасности. Конкретика зависит от проекта. Но, научившись писать стартер, вы можете создавать их под свои нужды, интегрировать их в стек технологий, который используется на вашем проекте.