Еволюція Spring бінів
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою технологією, як Spring Framework та розкрити ті теми, які з нею пов’язані. Ми докладно розглядаємо цю технологію на деяких тренінгах, і незайвим буде ще раз докладніше розповісти про її використання.
Витоки написання цієї статті йдуть у далекий 2008 рік, який приніс нам не тільки дефолт та економічну кризу. Він став революційним для багатьох Java-розробників завдяки експансії трьох технологій. Це Spring, Hibernate та Maven. Щасливий випадок звів мене з цими абсолютно незнайомими для мене технологіями, у тому числі і Spring Framework, тоді ще версії 2.5.6, яка повністю перевернула мої уявлення про розробку Java-додатків. У ті роки такі терміни, як DI (Dependency Injection) та IoC (Inversion of Control) тільки входили в наше життя, і про них обов’язково напишу окрему статтю.
А в цій статті я хотів би розповісти про таку цікаву тему, як Spring біни, але звичний tutorial у вигляді Get Started був би не новим і банальним. А ось підготувати історичний екскурс і розповісти про те, що сталося в Spring з бінами за останні 20 років, як змінився спосіб роботи з ними, розвіяти популярні міфи та упередження — це виклик та цікаве нестандартне завдання. Адже це тісно пов’язано з тим, як змінювалися погляди розробників на програмування та пов’язані з цим парадигми. Принаймні такий огляд було б цікаво читати, і статей на подібну тематику я не зустрічав у відкритих джерелах. Всі приклади в статті будуть ґрунтуватися на Spring 6 та Spring Boot 3, які тільки недавно вийшли.
Сподіваюся, що стаття буде цікавою і новачкам, і профі. Перші зможуть поповнити свій багаж знань, а другі — систематизувати його та прибрати білі плями..
Spring біни
Думаю, кожен Java-розробник добре уявляє, що таке бін. Біни (beans) з’явилися ще до виникнення Spring. По-перше, у специфікації Java Beans, доданої в Java 1.2, яка описувала вимоги до Java бін:
- Публічний дефолтний конструктор.
- Геттери та сетери для доступу до полів.
- Клас повинен бути серіалізованим.
- У такому класі мають бути перевизначені методи equals/hashCode/toString.
Фактично, саме з цього моменту почалася переможна хода гетерів і сеттерів, які спочатку писали вручну, потім автоматично, поки не з’явився Lombok з його анотаціями.
Потім у 1999 році вийшла перша версія специфікації EJB з Java EE, де вже був закріплений такий термін як Enterprise Java Bean. Якщо Java біном міг бути будь-який клас, і часто їх використовували для клієнтської частини (в JSP сторінках), то EJB представляв собою серверний об’єкт з деякою бізнес-логікою, включаючи, наприклад, роботу з даними. Але і Java Bean, і EJB мали щось спільне — керованість, через що і існує синонім до терміну бін — managed object (керований об’єкт). Сам бін створювався не програмістом у коді, як завжди, а деяким контейнером (runtime). У ролі такого контейнера міг бути вебсервер або сервер додатків, який підтримував EJB.
Однак процедура опису та управління EJB була досить складною, як у версії 1.0, так і у версії 2.0, тому розробник Spring Framework Род Джонсон запропонував альтернативу — концепцію легковагих бінів, які були несумісні з EJB і які пізніше стали називати Spring beans. Описати та керувати таким біном було набагато простіше, для цього в принципі навіть не потрібен був і сервер, для цього було достатньо і консольної програми.
Spring Framework є досить складною технологією, але спрощено з нього можна виділити 6 основних пунктів:
- Розробники описують конфігурацію бінів (за допомогою XML/Groovy документів або Java коду).
- Для сканування/завантаження інформації про бін існує інтерфейс BeanDefinitionReader та його реалізації, які прив’язані до типу конфігурації, наприклад, XmlBeanDefinitionReader.
- Вся інформація про бін, його атрибути та властивості інкапсулюється через інтерфейс BeanDefinition та його реалізацію.
- Для реєстрації бінів у сховищі (реєстрі) призначений інтерфейс BeanDefinitionRegistry.
- Основним типом і відправною точкою для отримання бінів з реєстру є інтерфейс BeanFactory, який надає базовий API для роботи з бінами в режимі read-only, а реалізації BeanFactory якраз і є in-memory сховищем бінів.
- Всі перераховані вище інтерфейси є внутрішніми і рідко використовуються безпосередньо. Розробники для керування бінами використовують спеціальний інтерфейс ApplicationContext. Реалізації ApplicationContext пов’язують між собою всі згадані компоненти: завантаження бінів (наприклад, з XML) та їх збереження, зв’язування (на основі DI) бінів та їх властивостей, управління подіями, налаштуваннями оточення (environment), listeners та багато іншого. Тому початок роботи з Spring бінами полягає у створенні Spring контексту та завантаження конфігурації.
За минулі 20 років саме пункт 1 зазнав найбільших змін, і саме про нього ми й говоритимемо у цій статті. При цьому життєвий цикл Spring бін складається з п’яти логічних етапів, кожним з яких управляє Spring Framework:
- Створення біна.
- Зв’язування з іншими бінами.
- Ініціалізація внутрішнього стану.
- Використання бізнес-логіки біна у додатку, в тому числі за допомогою AOP (аспект-орієнтованого програмування).
- Логічне знищення.
Таким чином, використовуючи Spring та його функціональність, ми делегуємо Spring управління нашими бізнес-об’єктами. Чим складніший додаток та зв’язки всередині нього, тим більше ми виграємо від такого підходу. Ми заощаджуємо час розробки, фокусуючись на пункті 4.
Щоправда, може виникнути питання: якщо Spring біни це настільки зручна фіча, можливо, всі класи в наших проєктах оголосити, як біни? В принципі, це питання схоже на інше: якщо незмінність (immutability) об’єкта це перевага, чому Java не зроблять всі об’єкти незмінними? А все тому, що для деяких об’єктів це настільки ускладнить їх використання (наприклад, через performance), що зведе нанівець усі плюси цього підходу. Також з бінами. Якщо з 5 описаних пунктів для вашого об’єкта жоден не суттєвий, значить він не є кандидатом у Spring біни.
Конфігурація XML
Коли Spring почав розроблятися в 2001 році, найпопулярнішим текстовим форматом для зберігання конфігурації проєктів був XML. Він підтримував ієрархічну структуру документа з вкладеними елементами та атрибутами, а крім того, дозволяв валідувати
Уявімо, що у нас є найпростіший Java-проєкт, де для логування інформації (повідомлень) є інтерфейс Writer:
@FunctionalInterface
public interface Writer {
void write(String text);
}
Є його дефолтна реалізація, яка логує події у консолі:
public class MemoryWriter implements Writer {
public void init() {
// State initialization
}
public void destroy() {
// Destroy resources
}
public void write(String text) {
System.out.println("Saved in memory:" + text);
}
}
І є деякий клас Server, який надає бізнес-функціональність та використовує Writer для логування:
class Server {
private final Writer writer;
public Server(Writer writer) {
this.writer = writer;
}
public void start() {
writer.write("Server started");
}
public void stop() {
writer.write("Server stopped");
}
}
Тоді
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="writer" class="org.sample.it.MemoryWriter"
init-method="init" destroy-method="destroy" />
<bean id="server" class="org.sample.it.Server">
<constructor-arg ref="writer"></constructor-arg>
</bean>
</beans>
Однією з переваг такої конфігурації є те, що вона зрозуміла навіть тим, хто почав вивчати Spring. Тут немає того, що пізніше назвуть «магією Spring», головна магія тут — Java Reflection при створенні об’єктів та виклик методів. Такий фрагмент зрозумілий і людині, і комп’ютеру.
А для завантаження такої конфігурації потрібно використовувати XmlWebApplicationContext або GenericXmlApplicationContext (він з’явиться трохи пізніше, у Spring 3.0):
try (GenericXmlApplicationContext context = new GenericXmlApplicationContext(
"beans.xml")) {
Writer writer = context.getBean(Writer.class);
Якщо ви зміните конфігурацію бінів у XML, вам не потрібно перекомпілювати програму, тільки перезавантажити, що є великим плюсом. З іншого боку, така конфігурація містить надмірну інформацію, по-перше, через громіздкість самого XML, по-друге, через те, що потрібно вручну вказувати на зв’язки між бінами, і по-третє, тому що тут не дотримується політика конвенцій, що стала популярною після появи Spring Boot.
І справді, нехай ідентифікатор бина є унікальним, його можна автоматично згенерувати на основі імені класу. Крім того, якщо людина аналізує проєкт, то зазвичай вона починає аналіз з коду, і важко відразу зрозуміти, які класи відносяться до Spring конфігурації, а які ні.
У міру зростання проєкту та додавання технологій починаються нові складності. Коли кількість бінів досягне сотень штук, їх буде незручно зберігати в одному файлі, доведеться розбивати його на дрібніші, через що всю конфігурацію буде важче проглядати і аналізувати. Якщо у вас був повноцінний вебдодаток, що включає Spring Security, Spring MVC, Spring Web Flow та інші технології, сюди ще додавалася і їх
Більше того, ви можете перейменувати клас/ метод і забути зробити це в XML. Зараз це звучить не так страшно, тому що є Spring плагіни для IDE. Вони ж не дозволять написати некоректний XML, але 15 років тому цього нічого не було, що створювало безліч складнощів. Тому, коли в 2004 році вийшла Java 5 з підтримкою анотацій, на цю фічу в першу чергу звернули увагу не розробники додатків, а розробники бібліотек і фреймворків, таких як Spring або Hibernate.
Annotation-based конфігурація
Ідея Java-конфігурації давно назрівала і з’явилася в Spring 2.5, який вже підтримував Java
@Component
public class MemoryWriter implements Writer {
@PostConstruct
public void init() {
// State initialization
}
@PreDestroy
public void destroy() {
// Destroy resources
}
@Override
public void write(String text) {
System.out.println( "Saved in memory:" + text);
}
}
Тоді за допомогою анотацій можна вказати все те, що раніше вказувалося в XML:
- Ідентифікатор бина (якщо не підходить дефолтний).
- Ознака primary.
- Методи init/destroy.
- Qualifiers.
- Scope.
- Профілі.
Ну і, звичайно, можна вказати автоматичне зв’язування (auto-wiring) бінів, яке здійснюється за допомогою анотації @Autowired, а у випадку constructor injection навіть без неї.
Чи можна використовувати інші структури даних як біни, наприклад, перерахування? На жаль, немає. Це відоме обмеження, а ось Java records, можна, починаючи з версії JDK 16:
@Component
public record MemoryWriter() implements Writer {
@Override
public void write(String text) {
System.out.println("Saved in memory: " + text);
}
}
Все це виглядає дуже привабливо —
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext("org.sample")) {
Writer writer = context.getBean(Writer.class);
Для annotation-based конфігурації потрібно використовувати новий клас-контекст Annota-tionConfigApplicationContext, який включає вже не тільки екземпляр BeanDefinitionRegistry, але і об’єкт типу ClassPathBeanDefinitionScanner, який відповідає за пошук і реєстрацію бінів з classpath. Якщо у разі XML для пошуку бінів використовувався інтерфейс BeanDefinitionReader та його реалізації, то ClassPathBeanDefinitionScanner внутрішньо використовує інтерфейс MetadataReader та його реалізації.
Все це уповільнює створення контексту та старт додатку. Більше того, з’явилися несподівані обмеження, яких раніше не було:
- Ми не можемо оголосити два біни одного класу.
- Ми не можемо оголосити бін, якщо у нас немає доступу до його source code.
- Ми не можемо оголосити бін, якщо у нас бін знаходиться в runtime залежності, а не com-pile (наприклад, JDBC драйвер).
Що далі, то цікавіше. Виявилося, що дуже непросто виключити бін із Java-конфігурації динамічно, не змінюючи класу біну (наприклад, у вас немає доступу до коду класу). І головна відмінність від конфігурації XML — ми на етапі компіляції вказуємо, які класи є бінами і які атрибути вони будуть використовувати. Змінити це в run-time вже неможливо. Причому, якщо ви змінили якийсь бін та його атрибути, потрібно перекомпілювати додаток.
Ще один підводний камінь. Спочатку Spring позиціонував себе як легковажний фреймворк, який не вимагає, щоб ваші біни прив’язувалися до якихось спеціальних інтерфейсів. Що стосується XML так і було. Тепер, якщо ви, наприклад, захочете виділити ваші бізнес-об’єкти в окрему бібліотеку, то вона обов’язково потягне за собою разом з анотаціями та всі Spring залежності. Тобто, ваші бізнес-об’єкти вже не є POJO. А іноді буває необхідно забезпечити можливість використання бізнес-об’єктів і поза Spring.
Обмежень було так багато, що стало зрозуміло, що необхідний новий тип конфігурації, яка поєднує в собі переваги і XML, і анотацій — Java-based конфігурація.
Java-based конфігурація
Цей тип конфігурації з’явився в Spring 3.0 і завоював популярність на довгі роки. У цій конфігурації залишилися анотації, але ми їх розміщуємо не на самі бізнес-класи, а в спеціальному файлі з анотацією @Configuration, який називається Java-конфігурацією:
@Configuration
public class AppConfig {
@Bean
public Writer writer() {
return new MemoryWriter();
}
@Bean
public Server server() {
return new Server(writer());
}
}
І тепер для завантаження контексту потрібно лише вказати цей клас (або класи, якщо їх кілька):
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
Spring створить біни на основі всіх методів, які позначені анотацією @Bean. Такий підхід знімає майже всі обмеження, які існували в попередньому випадку. І головне: наші бізнес-об’єкти знову перетворилися на POJO, які не прив’язані до поточного контейнера (Spring Framework). Є і додаткові плюшки — ми можемо в @Bean-методі виконати зовнішню ініціалізацію нашого біну, чого раніше (з @Component) було неможливо.
Так як конфігурація бінів зберігається в одному (або кількох) Java-класах, то немає необхідності сканувати весь classpath у пошуках бінів. Ви можете передати в Spring контекст тільки базовий клас-конфігурацію для аналізу, а інші підключити (проімпортувати) за допомогою анотації @Import.
Якщо ж у вас legacy-проєкт, і вам потрібно підтримувати інші типи конфігурацій, їх теж легко можна підключити за допомогою анотацій @ComponentScan і @ImportResource. Якщо у вас використовується Spring Boot, то він додає корисні інструкції для завантаження бінів/конфігурацій у вигляді умовного завантаження за деякою умовою, наприклад:
Завантаження тільки у разі наявності класу в classpath:
@Configuration
@ConditionalOnClass(JsonIgnore.class)
public class AppConfig {
Завантаження тільки у випадку, якщо вже є певний бін:
@Configuration
@ConditionalOnBean(name = "writer")
public class AppConfig {
Або навпаки, завантаження лише у випадку, якщо немає бина певного типу:
@Configuration
@ConditionalOnMissingBean(value = Service.class)
public class AppConfig {
Такі анотації — це сильний інструмент в руках розробника і частина автоконфігурації, те, що називають «магією Spring». Але їх невиправдане застосування може призвести до «annotation hell», коли код буде перевантажений анотаціями настільки, що його буде важко читати і аналізувати, особливо коли поєднуються інструкції з різних технологій. Ще одна складність — якщо у вашому проєкті безліч Spring технологій, і всі вони працюють на основі auto-wring, auto-discovery та auto-configuration, то розібратися в тому, як все це разом працює, буває дуже непросто.
Усвідомлення цього факту призвело розробників Spring Framework до думки про те, що непогано б повернутися до витоків і надати програмістам можливість опису конфігурації без анотацій. Вони назвали це функціональною реєстрацією бінів.
Функціональна реєстрація
Такий тип конфігурації був доступний ще в Spring 4.x, але став активно просуватися в п’ятій версії, де Spring розробники оголосили про функціональний підхід як одну з основних фітч. У чому його суть?
Мало хто знає, що ще з ранніх версій Spring ви могли зареєструвати ваш бін без використання XML або анотації, причому вже після створення/завантаження контексту:
try (GenericXmlApplicationContext context = new GenericXmlApplicationContext(
"beans.xml")) {
context.getBeanFactory().registerSingleton("writer2", new MemoryWriter());
Writer writer = context.getBean("writer2", Writer.class);
Власне кажучи, саме так і працює BeanDefinitionRegistry, коли йому потрібно додати бін у сховище BeanFactory. Чому тоді й не користуватися лише цим способом? А все тому, що в метод registerSingleton потрібно передати вже проініціалізований бін. Ніякі init/destroy callbacks та auto-wiring процеси викликатися не будуть.
А для того, щоб повноцінно зареєструвати наш Java клас як Spring бін, потрібно скористатися іншим API:
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class)) {
context.registerBean(Server.class);
На жаль, методу registerBean немає в базовому інтерфейсі ApplicationContext, тому вам для функціональної реєстрації необхідний контекст типу GenericApplicationContext або його спадкоємців. На жаль, цей метод повертає void, тому ви не можете з нього отримати створений бін.
У чому користь такого підходу? По-перше, немає ніякої магії на зразок анотацій, ви за допомогою простого зрозумілого API реєструєте ваші біни. Менше використовується Reflection API, а це йде на користь тим, хто використовує GraalVM native image, підтримка яких з’явилася в Spring 5.3. По-друге, такий код набагато компактніший. Якщо знову повернутися до Java-based конфігурації, то ось як ми оголошували там бін Server:
@Bean
public Server server() {
return new Server(writer());
}
І як зараз:
context.registerBean(Server.class);
Чим більше параметрів у конструкторі, тим більша перевага у компактності. Більше того, якщо у вас змінюється сигнатура конструктора, то жодних змін в останньому випадку вносити не потрібно.
Ще одна перевага — тут не використовується магії, типу bean auto-detection, тобто Spring немає необхідності шукати ваші біни по всьому classpath. Правда, як і раніше, потрібно шукати системні біни типу GenericApplicationContext, але трохи нижче я розповім про те, як цього уникнути.
Що, якщо конструктор біна приймає параметри? Якщо ці параметри беруться з Environment, їх дуже легко отримати і передати. Уявимо, що ми маємо нову реалізацію Writer, яка логує повідомлення до бази даних:
public class DbWriter {
private final String server;
private final String dbName;
public DbWriter(String server, String dbName) {
this.server = server;
this.dbName = dbName;
}
Якщо ми спробуємо зареєструвати бін звичним способом:
context.registerBean(DbWriter.class);
то отримаємо помилку:
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type ’java.lang.String’ available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
Тому потрібно спочатку отримати параметри логування з налаштувань і передати до конструктора:
String server = context.getEnvironment().getProperty("app.server");
String dbName = context.getEnvironment().getProperty("app.dbName");
context.registerBean(DbWriter.class, server, dbName);
У такому способі реєстрації є одна каверза. Ті біни, які ми зараз зареєстрували, не беруть участь у auto-wiring, так ми їх додали вже після завантаження контексту. Потрібен механізм функціональної реєстрації, який відбувається ще до завантаження основного контексту. І такий механізм у Spring Boot називається ApplicationContextInitializer.
Якщо створити реалізацію цього інтерфейсу:
public class AppInitializer implements ApplicationContextInitializer<
GenericApplicationContext>{
@Override
public void initialize(GenericApplicationContext ctx) {
ctx.registerBean(Server.class);
}
}
То в методі initialize можна реєструвати Spring біни. А для того, щоб підключити такий клас до Spring Boot конфігурації, вам пропонують два підходи.:
- Для тих, хто віддає перевагу конфігураційним файлам, потрібно створити спеціальний файл spring.factories в папці src/main/resources/META-INF:
org.springframework.context.ApplicationContextInitializer=org.sample.it.AppInitializer
Таким чином, Spring потрібно просто прочитати один текстовий файл у кожному JAR-файлі, і це найшвидший спосіб завантаження бінів. Файл spring.factories був основним до Spring Boot 2.7, в якому було додано новий файл для авто-конфігурації — META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Правда, і файл spring.factories все ще підтримується, але ця підтримка може припинитися в нових версіях 3.x, тому краще перейменувати цей файл вже зараз, якщо ви використовуєте Spring Boot 2.7+
- Для тих, хто є шанувальником конфігурації Java, додати цей клас за допомогою SpringApplicationBuilder:
@SpringBootApplication
public class Application {
public static void main(String[] args) throws Exception {
new SpringApplicationBuilder(Application.class)
.initializers(new AppInitializer()).run(args);
І тоді після завантаження Spring контексту ви отримаєте повноцінний бін server. Але в цьому випадку вам потрібно перекомпілювати ваш клас у разі будь-яких змін.
Висновки
У цій статті ми обговорили основні способи оголошення/ реєстрації Spring бінів у Spring/Spring Boot додатках, починаючи від використання конфігурації XML, і закінчуючи функціональною реєстрацією. У кожного способу є свої плюси та мінуси, як і свої шанувальники та недоброзичливці. Головне — чітко розуміти вхідні вимоги та використовувати для їх реалізації відповідний інструмент.
Сподіваюся, що прочитана стаття виявиться корисною і ті знання, які ви отримали, ви використовуватимете у своїх проєктах та напрацюваннях.
129 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів