Даний матеріал присвячений Spring dependency injection – впровадження залежностей Spring. Тема досить важлива як для тих, хто починає вивчати Spring Framework, так і для досвідчених розробників.
Щоб розібратись як працює Dependency Injection, слід спочатку зрозуміти що таке взагалі залежність в коді.
Уявімо, що у нас є такий код:
package com.example.demo.service; public class EmailService { public void sendEmail(UserDetails userDetails, String body, String subject) { //some logic to get email from user details and send it System.out.println("Sending email to: " + userDetails.getEmail() + " with subject: " + subject + " with body: " + body); } }
package com.example.demo.service; public class SomeClassExample { public void sendRegistrationConfirmationNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details EmailService emailService = new EmailService(); emailService.sendEmail(userDetails, "Thanks for registration", "Registration success"); } public void sendPromoNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details //some logic for promo materials preparation... EmailService emailService = new EmailService(); emailService.sendEmail(userDetails, "Hi! Please check our latest updates on site", "Promo campaign"); } }
package com.example.demo.service; import lombok.Data; @Data public class UserDetails { private String email; private String phone; }
Приклад хоч і умовний, проте дуже схожий на те, що ми маємо на реальних проєктах. У нас є клас SomeClassExample, який має 2 методи для відправки сповіщень користувачам. В цих методах є ще певна логіка, щоб перевірити користувача в базі даних, сформувати тіло сповіщення і тд. Я навмисно не став писати багато коду, щоб спросити приклад. Після всіх операцій з користувачем, методи викликають sendEmail класу EmailService.
Недолік даного підходу полягає в тому, що клас SomeClassExample залежить від EmailService. Таку ситуацію ще називають сильна звʼязаність. Чим це погано? Уявіть, що вам потрібно створити інший клас для відправки емейлів. Можливо, це буде клас схожий на EmailService, однак тепер ви будете відправляти емейли іншим чином. Чи можливо ви захочете змінити канал сповіщень з емейлів на СМС.
При сильній звʼязаності коду, вам потрібно буде знайти і відредагувати кожен рядок, де використовується EmailService.
Як виправити таку ситуацію? Можна спробувати використати інтерфейс, який буде спільним для всіх сповіщень.
package com.example.demo.service; public interface NotificationService { void sendNotification(UserDetails userDetails, String body, String subject); }
Після створення інтерфейсу, ми можемо імплементувати його нашими сервісами по відправці сповіщень. Наразі в нас він тільки 1:
package com.example.demo.service; public class EmailService implements NotificationService { @Override public void sendNotification(UserDetails userDetails, String body, String subject) { sendEmail(userDetails, body, subject); } public void sendEmail(UserDetails userDetails, String body, String subject) { //some logic to get email from user details and send it System.out.println("Sending email to: " + userDetails.getEmail() + " with subject: " + subject + " with body: " + body); } }
Тепер в SomeClassExample ми можемо викликати універсальний метод по відправці сповіщень:
package com.example.demo.service; public class SomeClassExample { private final NotificationService notificationService; public SomeClassExample() { this.notificationService = new EmailService(); } public void sendRegistrationConfirmationNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details notificationService.sendNotification(userDetails, "Thanks for registration", "Registration success"); } public void sendPromoNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details //some logic for promo materials preparation... notificationService.sendNotification(userDetails, "Hi! Please check our latest updates on site", "Promo campaign"); } }
Такий підхід значно покращив наш код, але не прибрав сильну звʼязаність між класами. Адже ми все ще маємо рядок this.notificationService = new EmailService(); в конструкторі.
При спробі використати іншу імлементацію сповіщень, нам неодмінно буде потрібно змінювати цей рядок. А якщо в нас є багато класів, які використовують сповіщення, то їх всі треба буде знайти і змінити.
Ми можемо піти далі і переписати наш SomeClassExample наступним чином:
package com.example.demo.service; public class SomeClassExample { private final NotificationService notificationService; public SomeClassExample(NotificationService notificationService) { this.notificationService = notificationService; } public void sendRegistrationConfirmationNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details notificationService.sendNotification(userDetails, "Thanks for registration", "Registration success"); } public void sendPromoNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details //some logic for promo materials preparation... notificationService.sendNotification(userDetails, "Hi! Please check our latest updates on site", "Promo campaign"); } }
Однак нам потім всерівно треба буде передати new EmailService() при створені обʼєкту SomeClassExample.
package com.example.demo.service; public class Main { public static void main(String[] args) { SomeClassExample someClassExample = new SomeClassExample(new EmailService()); } }
Якби тільки був певний механізм, що дозволив би ініціалізувати змінну private final NotificationService notificationService; без виклику справжнього класу реалізації…
Інверсія контролю (Inversion of Control)
Inversion of Control або просто IoC – це концепція, яка полягає в тому, що управління потоком виконання програми переходить з програми до контейнера (або фреймворку), який управляє створенням та взаємозв’язками об’єктів.
Тобто щоб позбутися залежності new EmailService() в нашому SomeClassExample класі, нам потрібно написати якийсь код, який би слідкував як ініціалізовувати певні змінні. Наприклад щоб наш код використовував EmailService реалізацію всюди, де є використання NotificationService.
Ми могли б піти далі і написати такий код. Для цього нам потрібно було б створити окремий клас з певною логікою, щоб ініціалізувати обʼєкти. Нам також потрібно було б створити конфігураційний файл або певну аннотацію для того, щоб всерівно показати програмі, яку реалізацію під який обʼєкт слід використати.
Spring ApplicationContext
Ми не будемо наново писати те, що вже давно написано і протестовано. Існує багато IoC фреймворків для впровадження залежностей. Один з них – Spring Framework, який пропонує ще дуже багато корисних речей окрім DI.
Spring, за своєю суттю, є контейнером для впровадження залежностей, який керує створеними класами та їхніми залежностями за вас. Все, що від вас потрібно це створити класи і сконфігурувати їх. Все інше зробить Spring.
В Spring є такий інтерфейс як ApplicationContext – центральний інтерфейс для надання конфігурації програми.
Саме ApplicationContext керує всіма класами та їх залежностями. Ми не будемо вдаватись в деталі як це все працює на низькому рівні. Натомість, ми зосередимо наші зусилля на практичному використанні Spring Dependency Injection для досягнення нашої цілі, а саме зменшення залежності між класами.
Спочатку ми створюємо конфігураційний клас, де вказуємо Spring яка імплементація буде в того чи іншого інтерфейсу.
package com.example.demo.service; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class BeansConfig { @Bean public NotificationService notificationService() { return new EmailService(); } }
Більше про анотації @Configuration, @Bean я розпишу нижче.
Далі ми можемо передати наші конфігурації в ApplicationContext, який потім може видати нам повністю сконфігуровані залежності тоді, коли вони нам потрібні.
package com.example.demo.service; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; public class Main { public static void main(String[] args) { ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeansConfig.class); NotificationService notificationService = applicationContext.getBean(NotificationService.class); SomeClassExample someClassExample = new SomeClassExample(notificationService); someClassExample.sendRegistrationConfirmationNotificationToUser(new UserDetails()); } }
Як бачите, тепер у нас в Main і SomeClassExample класах немає залежності від EmailService. Наразі ми можемо налаштовувати всі залежності і реалізації в одному місці в BeansConfig. І при потребі змінити реалізацію NotificationService, нам потрібно буде відредагувати код тільки в одному класі BeansConfig, замість того, щоб шукати використання NotificationService по всій програмі.
Існує багато способів побудови контексту Spring. Наприклад, раніше його конфігурували за допомогою XML-файлів, зараз використовують анотації.
Клас BeansConfig містить специфічні для Spring анотації. Щоб вказати спрінг-у, що ми будемо використовувати класи з анотаціями як конфігурацію бінів, а не XML файл, ми створили AnnotationConfigApplicationContext реалізацію ApplicationContext.
Наразі ми явно налаштували свої bean (бін) компоненти у конфігурації ApplicationContext за допомогою анотації @Bean.
Bean це обʼєкт яким керує Spring IoC контейнер. Тобто якщо ми хочемо сказати спринг-у, щоб він замість нас керував обʼєктом, то в конфігураційному класі вказуємо анотацію @Bean і власне наш Java обʼєкт. Щоб вказати Spring, що клас буде конфігураційним, ми повинні “навішати” на нього @Configuration анотацію. При запуску програми, Spring просканує всі класи. Ті з них, які матимуть анотацію @Configuration, він розпізнає як конфігураційні класи і обробить їх відповідним чином.
Bean scope
Ми вияснили, що всі класи, які знаходяться в контексті Spring називаються бінами. Bean має так званий scope. Так і не зміг знайти нормальний переклад даному слову. Можете залишати свої пропозиції в коментарі.
Bean scope – це вказівка Spring-у як створювати змінну. Чи повинен Spring створити тільки 1 змінну (Singleton), чи повинен він створити нову змінну для кожного класу, де використовується бін. Існують такі bean scope:
- синглтон розміщує одне визначення компонента в один екземпляр об’єкта на контейнер Spring IoC.
- прототип розповсюджує окреме визначення компонента на будь-яку кількість екземплярів об’єкта.
- запит охоплює окреме визначення компонента життєвим циклом одного запиту HTTP; тобто кожен HTTP-запит матиме власний екземпляр bean-компонента.
- сесія охоплює окреме визначення компонента життєвим циклом HTTP-сеансу.
- глобальна сесія охоплює окреме визначення компонента життєвим циклом глобальної сесії HTTP. Зазвичай дійсний лише при використанні в контексті портлету.
За замовчуванням всі біни мають синглтон scope.
На нашому прикладі це означає, що якщо ми будемо використовувати NotificationService в багатьох класах нашого додатку, виклик new EmailService() ініціалізації NotificationService буде тільки 1 раз. І ми будемо використовувати тільки 1 інстанс нашого EmailService.
В нашому прикладі я створював ApplicationContext власноруч. Пропоную ще раз глянути на цей код.
package com.example.demo.service; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; public class Main { public static void main(String[] args) { ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeansConfig.class); NotificationService notificationService = applicationContext.getBean(NotificationService.class); SomeClassExample someClassExample = new SomeClassExample(notificationService); someClassExample.sendRegistrationConfirmationNotificationToUser(new UserDetails()); } }
Однак я зробив це тільки, щоб показати вам роботу ApplicationContext. Без розуміння контексту Spring, всі манупіляції з анотаціями і Dependency Injection будуть виглядати як магія.
Spring Dependency Injection (DI)
Нам не потрібно власноруч створювати контекст і задавати йому конфігураційні файли. Spring вміє це робити за нас. Від нас вимагається тільки створити біни, як ми це робили в BeansConfig. Однак нам часто і цього не потрібно робити, якщо в нас просте створення екземпляру класу. Як наприклад, new EmailService(), то Spring може налаштувати бін за нас. Щоб вказати фреймворку, що ми хочемо помістити клас в контекст як бін, існує анотація @Component. Саме вона вказує Spring, що клас потрібно помістити в контекст. Інші анотації, такі як @Controller, @Service, @Repository також використовують Component анотацію. Саме тому спрінг може знайти і помістити ваші контроллери або репозиторії в контекст і потім використовувати їх за потреби.
Тепер ми можемо видалити клас BeansConfig і додати анотацію @Service або @Component до нашого EmailService:
package com.example.demo.service; import org.springframework.stereotype.Service; @Service //or @Component public class EmailService implements NotificationService { @Override public void sendNotification(UserDetails userDetails, String body, String subject) { sendEmail(userDetails, body, subject); } public void sendEmail(UserDetails userDetails, String body, String subject) { //some logic to get email from user details and send it System.out.println("Sending email to: " + userDetails.getEmail() + " with subject: " + subject + " with body: " + body); } }
Дані анотації вважаються рівнозначними. Їх радять використовувати в залежності від того, яку роль відіграє ваш клас в програмі (зачасту це теж все дуже умовно). Якщо думаєте, що у вас сервіс клас, додавайте анотацію @Service. Інакше можна додавати @Component. Але функціональної різниці не буде. Різниця тільки в назві.
Інколи виникають ситуації, коли потрібно створювати незвичний бін. Наприклад, передати певні конфігурації чи обробити перед цим якусь логіку. В таких випадках можна завжди оголосити бін в Config класі, як ми це робили в BeanConfig.
Коли наш бін знаходиться в контексті, ми можемо впровадити його залежність в будь-який потрібний нам клас. Для цього треба використати анотацію @Autowired щоб Spring розумів коли і якого типу використовувати бін.
package com.example.demo.service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class SomeClassExample { @Autowired private NotificationService notificationService; public void sendRegistrationConfirmationNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details notificationService.sendNotification(userDetails, "Thanks for registration", "Registration success"); } public void sendPromoNotificationToUser(UserDetails userDetails) { //some logic to check user in db //some logic to prepare notification details //some logic for promo materials preparation... notificationService.sendNotification(userDetails, "Hi! Please check our latest updates on site", "Promo campaign"); } }
В коді вище ми позначили змінну NotificationService анотацією @Autowired. Таким чином, Spring під час старту програми запустить ApplicationContext (все це відбувається без вашої участі), додасть EmailService в контекст так як він містить анотацію @Service. Spring також “бачить”, що EmailService успадковує NotificationService. Під час додавання SomeClassExample в контекст (адже ми теж його позначили як @Component), спрінг додає залежність NotificationService, оскільки вона з анотацією @Autowired. І таким чином підставляє EmailService реалізацію для NotificationService.
Якщо ми матимемо декілька імплементацій інтерфейсу NotificationService з анотацією @Service чи @Component, то наш додаток при старті видасть помилку. Оскільки Spring не зрозуміє, яку саме реалізацію піставляти. В таких випадках подрібно явно вказати фреймворку, яку саме реалізацію взяти. Можна це зробити за допомогою анотації @Primary. Або створити бін власноруч, як ми це робили в BeanConfig, не забувши перед цим прибрати анотацію @Service чи @Component з класу реалізації, щоб спрінг не намагався додати клас в ApplicationConttext самотужки.
Способи Dependency Injection
В прикладі вище, ми впровадили залежність NotificationService в клас SomeClassExample, використовуючи просто поле з анотацією @Autowired. Однак, це не єдиний спосіб впровадження залежностей. Я би навіть сказав, що йому не радять давати перевагу.
Існує 3 способи dependency injection:
- через конструктор
- сеттер
- поле (той, що в прикладі)
Впровадження залежності через сеттер має такий вигляд:
package com.example.demo.service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class SomeClassExample { private NotificationService notificationService; @Autowired public void setNotificationService(NotificationService notificationService) { this.notificationService = notificationService; } ...other code }
В нових версіях Spring, анотацію @Autowired можна опускати, якщо впроваджувати залежність через конструктор.
package com.example.demo.service; import org.springframework.stereotype.Component; @Component public class SomeClassExample { private final NotificationService notificationService; public SomeClassExample(NotificationService notificationService) { this.notificationService = notificationService; } ... other code }
Для тих, хто вже знайомий з інструментом Lombok, використовувати впровадження залежностей через конструктор стало ще простіше.
package com.example.demo.service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class SomeClassExample { private final NotificationService notificationService; ... other code }
Я надаю перевагу саме конструктору, оскільки потім набагато легше підставляти моки (підміни) компонентів в тестах. Однак, всі варіанти будуть працювати однаково.
Висновки
Все, що стосується Spring dependency injection – це впровадження залежностей Spring. Використання Inversion of Control інструментів значно спростить вам роботу в написанні програм. Зменшення звязаності коду це також один з принципів SOLID. Код не тільки буде виглядати краще, а ще й потенційно матиме менше помилок. Його буде значно легше модифікувати в майбутньому.
Хоч використання Dependency Injection і виглядає як магія, проте, як ви могли переконатися вище: ніякої магії в програмуванні не існує. Однак мушу визнати, що використання декількох анотацій для впровадження величезного функціоналу в код, дійсно вражає 🙂
Не забувайте підписуватись на Telegram і YouTube. Там завжди цікавий контент.
Залишити відповідь