Google Guice: Впровадження залежностей у JavaFX-проєкті
Google Guice: Впровадження залежностей у JavaFX-проєкті
Вступ: Коли граф залежностей стає лабіринтом
Уявіть типову сцену розробки. Ви відкриваєте контролер JavaFX, що відповідає за список треків аудіоплатформи. Там, всередині конструктора або методу initialize(), ховається ось такий код:
public class TrackListController {
private final TrackService trackService;
public TrackListController() {
ConnectionManager connectionManager = new ConnectionManager(
"jdbc:h2:./data/audiobook;MODE=PostgreSQL",
"sa", ""
);
TrackRepository trackRepository = new JdbcTrackRepository(connectionManager);
TrackValidator validator = new TrackValidator();
AudioFileProcessor processor = new AudioFileProcessor("/tmp/uploads");
this.trackService = new TrackService(trackRepository, validator, processor);
}
}
З першого погляду — нічого катастрофічного. Але подивіться уважніше: контролер сам вирішує, як саме побудувати весь граф своїх залежностей. Він знає про ConnectionManager, про шлях до файлів завантаження, про TrackValidator. Він прив'язаний до конкретних реалізацій усіх своїх залежностей так міцно, наче виготовлений разом з ними на одному заводі.
Проблема не в тому, що цей код не працює. Він працює. Проблема в тому, що відбувається далі.
Перший симптом — неможливість тестування. Щоб написати unit-тест для TrackListController, вам доведеться або підіймати реальну базу даних H2 (це вже інтеграційний тест), або вдаватись до складних маніпуляцій з рефлексією. Підмінити TrackRepository на mock-об'єкт неможливо: він жорстко закодований усередині конструктора.
Другий симптом — жорсткість при зміні реалізацій. Вирішили замінити H2 на PostgreSQL? Або AudioFileProcessor на хмарне сховище? Вам потрібно знайти кожне місце, де ці класи інстанціюються через new, і оновити їх вручну. У великому проєкті таких місць можуть бути десятки.
Третій симптом — дублювання ініціалізації. ConnectionManager зі своїм URL та обліковими даними створюється у кожному DAO-класі, у кожному тесті, у кожному місці, де потрібен доступ до бази. Жодної гарантії узгодженості.
Це не погані розробники — це природний наслідок відсутності систематичного підходу до управління залежностями. І саме цю проблему вирішує патерн Dependency Injection (Впровадження залежностей) та бібліотека Google Guice, яку ми вивчатимемо у цій статті.
Ручне зв'язування
Підхід: new ServiceA(new RepoA(new ConnMgr(...)))
Кожен клас сам створює залежності. Швидко для маленьких проєктів, але стає некерованим при зростанні. Тестування — лише інтеграційне.
Фабрики та Сервіс-локатор
Підхід: ServiceFactory.create("trackService")
Централізоване створення об'єктів, але клієнт сам шукає залежності. Залежність від фабрики — прихована зв'язаність.
IoC-контейнер (Guice)
Підхід: @Inject TrackService trackService
Контейнер будує весь граф залежностей автоматично. Клас лише декларує потреби — хто і як їх задовольнить, вирішує зовні.
Фундаментальні концепції: IoC, DI та принцип інверсії залежностей
Інверсія управління (Inversion of Control)
Інверсія управління (IoC) — це архітектурний принцип, згідно з яким управління потоком виконання програми передається від самого коду до зовнішнього фреймворку або контейнера. Термін систематизований Мартіном Фаулером у 2004 році, хоча сам принцип існував значно раніше.
Щоб зрозуміти «інверсію», необхідно спочатку усвідомити те, що саме інвертується. У традиційній програмі ваш код контролює все: він викликає бібліотечні функції, вирішує, коли і що створювати, сам організовує порядок дій. У IoC-підході фреймворк контролює загальний потік виконання, а ваш код лише реагує на події та надає конкретні реалізації через інтерфейси або анотації.
Голлівудський принцип (Hollywood Principle) — влучна метафора для IoC: «Не телефонуйте нам, ми самі зателефонуємо вам». Ваш код не «шукає» залежності — контейнер сам «передзвонює» й надає їх.
Dependency Injection (Впровадження залежностей) — це конкретний механізм реалізації IoC. Замість того щоб клас самостійно створював свої залежності через new, він декларує їх через конструктор, сеттери або поля, а зовнішній код (контейнер) впроваджує їх ззовні.
Принцип інверсії залежностей (DIP)
Принцип інверсії залежностей (Dependency Inversion Principle, DIP) — п'ятий принцип SOLID — формулюється так:
- Модулі верхнього рівня не повинні залежати від модулів нижнього рівня. Обидва повинні залежати від абстракцій.
- Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій.
На практиці це означає: TrackService (бізнес-логіка, верхній рівень) не повинен залежати від JdbcTrackRepository (деталь реалізації, нижній рівень). Обидва мають залежати від інтерфейсу TrackRepository. Це та «абстракція», що дозволяє замінити реалізацію без зміни споживача.
Рис. 1 ілюструє трансформацію архітектури:
Три типи впровадження залежностей
Специфікація Jakarta Inject (колишня javax.inject) визначає три місця, де можуть впроваджуватись залежності. Guice підтримує всі три, але надає різні рекомендації щодо їх застосування.
public class TrackService {
private final TrackRepository repository;
private final TrackValidator validator;
@Inject
public TrackService(TrackRepository repository, TrackValidator validator) {
this.repository = repository;
this.validator = validator;
}
}
Guice бачить анотацію @Inject на конструкторі та автоматично передає всі параметри. Поля оголошені final — залежності незмінні після ініціалізації. Клас неможливо створити без усіх залежностей: це унеможливлює часткову ініціалізацію. Для тестування достатньо передати mock у конструктор напряму, без жодних фреймворків.
public class TrackService {
@Inject
private TrackRepository repository; // Guice встановлює через рефлексію
@Inject
private TrackValidator validator;
}
Синтаксично коротший, але має суттєві недоліки: поля не можуть бути final, залежності невидимі в публічному API класу, а для тестування без Guice потрібна рефлексія або @InjectMocks від Mockito. Guice-документація не рекомендує цей підхід для нових проєктів.
public class TrackService {
private TrackEventListener listener;
@Inject(optional = true)
public void setEventListener(TrackEventListener listener) {
this.listener = listener;
}
}
Атрибут optional = true дозволяє Guice пропустити впровадження, якщо прив'язки для TrackEventListener не знайдено у жодному модулі. Доречний для залежностей, що підключаються лише в окремих конфігураціях — наприклад, listener для метрик у production, відсутній у тестах.
@Inject конструктора).Архітектура Google Guice: ключові компоненти
Перш ніж писати перший код з Guice, необхідно зрозуміти концептуальну модель бібліотеки. Guice побудований навколо чотирьох фундаментальних абстракцій, що взаємодіють у суворо визначеному порядку.
Module (Модуль) — це декларативний опис зв'язувань (bindings). Модуль — це місце, де ви як розробник кажете Guice: «якщо хтось попросить TrackRepository, надай йому JdbcTrackRepository». Модуль реалізується через успадкування від AbstractModule та перевизначення методу configure().
Injector (Інжектор) — центральний реєстр усіх прив'язок і фабрика об'єктів. Він створюється один раз, на старті застосунку, через Guice.createInjector(module). Після цього Injector є єдиним джерелом правди щодо того, які об'єкти і як створювати. У JavaFX-застосунку він живе протягом усього часу роботи програми.
Binding (Прив'язка) — зв'язок між ключем (зазвичай інтерфейсом або класом) та способом його задоволення (реалізацією, провайдером або екземпляром). Bindings описуються у Module і реєструються в Injector.
@Inject — анотація з пакету jakarta.inject (або com.google.inject), що позначає точки впровадження: конструктори, поля або методи. Це єдиний маркер, якого потребує клас; він не прив'язує клас до Guice як до фреймворку.
Provider<T> — функціональний інтерфейс для ліниво або параметризовано створюваних об'єктів. Guice може впроваджувати Provider<TrackService> замість TrackService — це дає отримувачу контроль над часом та кількістю створення екземплярів.
Послідовність роботи Guice
Розуміння порядку, в якому Guice виконує свою роботу, є критично важливим для правильного проєктування застосунку. Цей порядок незмінний і не залежить від того, скільки модулів та прив'язок ви визначаєте.
Зверніть увагу на рекурсивне вирішення: щоб створити TrackListController, Guice спочатку створює TrackService; щоб створити TrackService — JdbcTrackRepository; щоб створити JdbcTrackRepository — ConnectionManager. Весь цей граф вирішується автоматично, на основі анотацій @Inject у конструкторах та прив'язок у AppModule. Розробнику не потрібно описувати порядок ініціалізації — Guice визначає його сам.
Налаштування проєкту: від нуля до першого Injector
Перейдемо від теорії до практики. У цьому розділі ми налаштуємо Google Guice у JavaFX-проєкті аудіоплатформи та побудуємо фундамент: підключимо залежності, опишемо перший модуль та отримаємо перший об'єкт через Injector.
Крок 1: Додати залежності до системи збірки
Guice доступний через Maven Central. Нам потрібні два артефакти: сам guice та jakarta.inject-api — специфікація анотацій @Inject, @Singleton.
<dependencies>
<!-- Google Guice — IoC-контейнер -->
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>7.0.0</version>
</dependency>
<!-- JavaFX FXML-підтримка -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>21.0.3</version>
</dependency>
</dependencies>
dependencies {
implementation 'com.google.inject:guice:7.0.0'
implementation 'org.openjfx:javafx-fxml:21.0.3'
}
javax.inject на jakarta.inject. Якщо ваш проєкт залежить від старих бібліотек зі javax.inject, використовуйте Guice 6.x, що підтримує обидва простори імен.Крок 2: Налаштувати module-info.java
Якщо проєкт використовує Java Platform Module System (JPMS), необхідно відкрити пакети для Guice-рефлексії:
module com.example.audiobook {
requires javafx.controls;
requires javafx.fxml;
requires com.google.guice;
requires jakarta.inject;
// opens — глибокий рефлексивний доступ у runtime (потрібен Guice)
opens com.example.audiobook.controller to javafx.fxml, com.google.guice;
opens com.example.audiobook.service to com.google.guice;
opens com.example.audiobook.repository to com.google.guice;
exports com.example.audiobook;
}
Ключове слово opens (на відміну від exports) дозволяє саме глибокий рефлексивний доступ у runtime — це необхідно Guice для впровадження залежностей. Якщо пакет не відкрито, Guice викине InaccessibleObjectException при спробі створити об'єкт.
Крок 3: Описати перший Module
package com.example.audiobook;
import com.example.audiobook.repository.*;
import com.google.inject.AbstractModule;
import com.google.inject.name.Names;
public class AppModule extends AbstractModule {
@Override
protected void configure() {
bind(TrackRepository.class).to(JdbcTrackRepository.class);
bind(AuthorRepository.class).to(JdbcAuthorRepository.class);
bindConstant().annotatedWith(Names.named("dbUrl"))
.to("jdbc:h2:./data/audiobook;MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
bindConstant().annotatedWith(Names.named("dbUser")).to("sa");
bindConstant().annotatedWith(Names.named("dbPassword")).to("");
}
}
Кожен виклик bind(...).to(...) — одна прив'язка. bindConstant().annotatedWith(Names.named(...)).to(...) дозволяє впроваджувати прості значення (рядки, числа) через анотацію @Named на параметрах конструктора.
Крок 4: Впровадити залежності у клас
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
@Singleton
public class ConnectionManager {
private final String url;
private final String user;
private final String password;
@Inject
public ConnectionManager(
@Named("dbUrl") String url,
@Named("dbUser") String user,
@Named("dbPassword") String password
) {
this.url = url;
this.user = user;
this.password = password;
}
public Connection getConnection() throws SQLException {
return DriverManager.getConnection(url, user, password);
}
}
@Singleton на класі декларує єдиний екземпляр на весь Injector. Анотації @Named(...) на параметрах вказують Guice знайти саме ту константу, що зв'язана у модулі через Names.named(...) з відповідним ім'ям.
Крок 5: Створити Injector і отримати перший об'єкт
import com.google.inject.Guice;
import com.google.inject.Injector;
public class Main {
public static void main(String[] args) {
// Єдина точка створення Injector у всьому застосунку
Injector injector = Guice.createInjector(new AppModule());
// Guice рекурсивно вирішує весь граф залежностей
TrackService trackService = injector.getInstance(TrackService.class);
trackService.listAll().forEach(System.out::println);
}
}
Структура проєкту після налаштування:
Injector у статичному полі та не передавайте його класам як залежність — це антипатерн Service Locator. Клас, що отримує Injector, сам витягує залежності й унеможливлює тестування. Правильна практика: Injector живе лише у Main.java та App.java.Типи прив'язок (Bindings) та анотації Guice
Метод configure() у AbstractModule — це не просто перелік рядків коду. Це повноцінна декларативна мова опису залежностей. Guice підтримує кілька типів прив'язок, кожен з яких вирішує конкретний сценарій.
Linked Binding — зв'язування інтерфейсу з реалізацією
Найпоширеніший тип. Дозволяє замінити реалізацію без зміни жодного класу-споживача.
// Коли хтось запросить TrackRepository — отримає JdbcTrackRepository
bind(TrackRepository.class).to(JdbcTrackRepository.class);
// З явним Scope: лише один JdbcTrackRepository на весь Injector
bind(TrackRepository.class).to(JdbcTrackRepository.class).in(Singleton.class);
Зверніть увагу: JdbcTrackRepository сам також може мати @Inject-конструктор і власні залежності — Guice рекурсивно вирішить їх автоматично.
Instance Binding — впровадження готового екземпляра
Коли об'єкт уже створений (або не може бути створений Guice) — передайте його напряму:
// Передаємо готовий об'єкт — Guice не створює його сам
ConnectionManager manager = ConnectionManager.forH2("./data/audiobook");
bind(ConnectionManager.class).toInstance(manager);
// Або через статичну фабрику
bind(DataSource.class).toInstance(createHikariDataSource());
toInstance() автоматично реєструє переданий об'єкт як Singleton — він завжди повертає той самий екземпляр.
Provider Binding — відкладене або параметризоване створення
Provider<T> — це фабрика, що викликається Guice щоразу, коли потрібен новий об'єкт. Використовується, коли логіка створення складніша за простий new.
// Клас-провайдер для DataSource (наприклад, HikariCP)
public class HikariDataSourceProvider implements Provider<DataSource> {
private final String url;
private final String user;
@Inject
public HikariDataSourceProvider(
@Named("dbUrl") String url,
@Named("dbUser") String user
) {
this.url = url;
this.user = user;
}
@Override
public DataSource get() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(user);
config.setMaximumPoolSize(10);
return new HikariDataSource(config);
}
}
// У модулі:
bind(DataSource.class).toProvider(HikariDataSourceProvider.class).in(Singleton.class);
HikariDataSourceProvider сам також отримує @Inject-залежності. Поєднання з in(Singleton.class) гарантує, що пул з'єднань створюється рівно один раз.
@Named та кастомні кваліфікатори
Коли один інтерфейс має кілька реалізацій — Guice потребує підказки, яку саме надавати. Для цього існують кваліфікатори (qualifiers).
Варіант 1 — @Named (найпростіший):
// У модулі — дві реалізації одного інтерфейсу
bind(TrackRepository.class)
.annotatedWith(Names.named("jdbc"))
.to(JdbcTrackRepository.class);
bind(TrackRepository.class)
.annotatedWith(Names.named("cache"))
.to(InMemoryCacheTrackRepository.class);
// У споживачі — явно вказуємо, яка потрібна
@Inject
public TrackService(
@Named("jdbc") TrackRepository primary,
@Named("cache") TrackRepository cache
) { ... }
Варіант 2 — кастомний кваліфікатор (@BindingAnnotation):
// Оголошення кваліфікатора — окрема анотація
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@BindingAnnotation
public @interface ReadOnly {}
// У модулі
bind(TrackRepository.class)
.annotatedWith(ReadOnly.class)
.to(ReadOnlyJdbcTrackRepository.class);
// У споживачі
@Inject
public AnalyticsService(@ReadOnly TrackRepository repo) { ... }
Кастомні кваліфікатори кращі за @Named у великих проєктах: вони рефакторяться разом з кодом (компілятор перевірить назву анотації, але не перевірить рядок у @Named).
jakarta.inject (стандарт) та com.google.inject (Guice-специфічний).Injector. Можна застосувати до класу або у виклику bind(...).in(Singleton.class). Потоко-безпечний: Guice гарантує атомарність створення.Names.named(...) у configure(). Доступний з com.google.inject.name та jakarta.inject.@Named.@Provides стає фабрикою. Зручніше для простих випадків.Метод @Provides як зручна альтернатива Provider-класу
Замість окремого класу, що реалізує Provider<T>, можна написати метод прямо у Module:
public class AppModule extends AbstractModule {
@Override
protected void configure() {
bind(TrackRepository.class).to(JdbcTrackRepository.class);
}
// Guice автоматично визнає цей метод як провайдер для DataSource
@Provides
@Singleton
DataSource provideDataSource(
@Named("dbUrl") String url,
@Named("dbUser") String user
) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(user);
config.setMaximumPoolSize(10);
return new HikariDataSource(config);
}
}
Параметри методу @Provides автоматично впроваджуються Guice — він вирішує їх так само, як і параметри @Inject-конструкторів. Поєднання @Provides + @Singleton гарантує, що HikariCP-пул буде створено рівно один раз.
@Provides vs клас-провайдер: якщо логіка провайдера проста та умістилась в один метод — використовуйте @Provides. Якщо провайдер складний, потребує стану або хочеться тестувати його окремо — винесіть у власний клас, що реалізує Provider<T>.Інтеграція з JavaFX: вирішення ключової суперечності
Переходимо до центрального практичного питання цієї статті: як поєднати Guice з JavaFX? На перший погляд завдання виглядає тривіально — просто додайте @Inject до контролерів. Але реальність складніша.
Проблема: FXMLLoader — прихований конкурент
JavaFX використовує FXMLLoader для завантаження .fxml-файлів та автоматичного створення контролерів, описаних у fx:controller. Цей механізм сам інстанціює контролери через рефлексію, викликаючи конструктор за замовчуванням (без параметрів). Guice при цьому не задіяний — він нічого не знає про ці об'єкти, і всі @Inject-поля залишаються null.
Якщо у контролері написати:
public class TrackListController {
@Inject
private TrackService trackService; // завжди null при стандартному FXMLLoader!
}
Метод initialize() викличеться, trackService буде null, і застосунок впаде з NullPointerException. Це класична пастка, у яку потрапляє кожен, хто вперше намагається поєднати JavaFX та DI.
Рішення: setControllerFactory()
FXMLLoader передбачає цю ситуацію. Метод setControllerFactory(Callback<Class<?>, Object> factory) дозволяє замінити стандартний механізм створення контролерів на довільну фабрику. І саме тут з'являється Guice:
FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/track-list.fxml"));
// Передаємо Injector як фабрику контролерів
// injector::getInstance — це method reference на Injector.getInstance(Class)
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load();
Тепер, коли FXMLLoader зустрічає fx:controller="...TrackListController", він не викликає new TrackListController(), а передає клас у injector.getInstance(TrackListController.class). Guice будує контролер зі всіма залежностями через @Inject-конструктор.
Повна реалізація: App + GuiceModule + контролер
Розглянемо повний, робочий приклад для нашої аудіоплатформи.
package com.example.audiobook;
import com.google.inject.Guice;
import com.google.inject.Injector;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class App extends Application {
// Injector доступний у межах App — і нікуди далі
private Injector injector;
@Override
public void init() {
// init() — правильне місце для Guice. Викликається до start(),
// у окремому потоці JavaFX Launcher Thread, до JavaFX Application Thread.
injector = Guice.createInjector(new AppModule());
}
@Override
public void start(Stage primaryStage) throws Exception {
FXMLLoader loader = new FXMLLoader(
getClass().getResource("/fxml/track-list.fxml")
);
// Підключаємо Guice як фабрику контролерів
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load();
primaryStage.setTitle("Аудіоплатформа");
primaryStage.setScene(new Scene(root, 900, 600));
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
package com.example.audiobook.controller;
import com.example.audiobook.service.TrackService;
import com.example.audiobook.service.NavigationService;
import com.google.inject.Inject;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.ListView;
import java.net.URL;
import java.util.ResourceBundle;
public class TrackListController implements Initializable {
// Залежності впроваджуються через конструктор — не через поля!
private final TrackService trackService;
private final NavigationService navigationService;
@FXML
private ListView<String> trackListView;
@Inject
public TrackListController(
TrackService trackService,
NavigationService navigationService
) {
this.trackService = trackService;
this.navigationService = navigationService;
}
@Override
public void initialize(URL location, ResourceBundle resources) {
// trackService вже ін'єктований — NullPointerException неможливий
trackService.listAll()
.stream()
.map(t -> t.getTitle() + " — " + t.getArtist())
.forEach(trackListView.getItems()::add);
}
@FXML
private void onAddTrack() {
navigationService.openAddTrackDialog();
}
}
package com.example.audiobook.service;
import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Singleton;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
@Singleton
public class NavigationService {
// NavigationService сам отримує Injector для створення нових сцен
// Це ЄДИНИЙ клас у проєкті, що може зберігати Injector
private final Injector injector;
@Inject
public NavigationService(Injector injector) {
this.injector = injector;
}
public void openAddTrackDialog() {
try {
FXMLLoader loader = new FXMLLoader(
getClass().getResource("/fxml/add-track.fxml")
);
loader.setControllerFactory(injector::getInstance);
Stage dialog = new Stage();
dialog.setScene(new Scene(loader.load(), 600, 400));
dialog.setTitle("Додати трек");
dialog.show();
} catch (Exception e) {
throw new RuntimeException("Неможливо відкрити діалог", e);
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<!-- fx:controller вказує клас контролера.
FXMLLoader передасть його у setControllerFactory → Guice -->
<BorderPane
xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.example.audiobook.controller.TrackListController">
<center>
<ListView fx:id="trackListView" />
</center>
<bottom>
<Button text="Додати трек" onAction="#onAddTrack" />
</bottom>
</BorderPane>
Розберемо ключові архітектурні рішення у прикладі вище.
init() замість start(): метод init() класу Application викликається до відображення будь-якого UI і є правильним місцем для ініціалізації важких ресурсів (бази даних, пулів, Guice-контейнера). Якщо ініціалізація Injector займає час (наприклад, перевіряє з'єднання з БД), вона не блокує JavaFX Application Thread.
NavigationService з Injector: це єдиний виправданий випадок, коли клас зберігає посилання на Injector. NavigationService виступає централізованим сервісом навігації — він відкриває нові вікна/діалоги, завантажуючи FXML і передаючи injector::getInstance як фабрику. Всі інші класи отримують залежності виключно через конструктор.
Constructor Injection у контролері: незважаючи на те, що FXMLLoader традиційно очікує конструктор без параметрів, після підключення setControllerFactory Guice передає повністю сформований об'єкт. Поля @FXML заповнює сам FXMLLoader після отримання контролера від Guice, тому initialize() отримує і залежності (@Inject), і UI-компоненти (@FXML).
Stage, Scene або Parent через Guice @Inject. Ці об'єкти мають бути створені у JavaFX Application Thread, але Guice може будувати граф залежностей у будь-якому потоці. Передавайте UI-об'єкти явно через параметри методів або через NavigationService.Скопи (Scopes) та час життя об'єктів
Одне з найважливіших рішень при проєктуванні DI-архітектури — скільки екземплярів певного класу повинно існувати у системі та як довго. Guice вирішує цю задачу через механізм скопів (Scopes).
Prototype — скоп за замовчуванням
Якщо жодного скопу не вказано, Guice поводиться як фабрика без пам'яті: кожен виклик getInstance() або кожне впровадження @Inject отримує новий екземпляр. Цей режим називається Prototype або No Scope.
// Guice створює новий TrackValidator при кожному впровадженні
public class TrackService {
@Inject
public TrackService(TrackValidator validator) { ... }
}
public class AudioProcessor {
@Inject
public AudioProcessor(TrackValidator validator) { ... }
}
// TrackService та AudioProcessor отримають РІЗНІ екземпляри TrackValidator
Prototype ідеально підходить для stateful об'єктів, де кожен споживач повинен мати власний, незалежний стан: form-objects, команди (Command pattern), тимчасові обчислювальні об'єкти.
@Singleton — один на весь Injector
@Singleton гарантує, що Guice створить клас рівно один раз протягом усього часу життя Injector. Усі, хто залежать від цього класу, отримають той самий екземпляр.
@Singleton
public class ConnectionManager {
// Конструктор викликається один раз — при першому запиті
@Inject
public ConnectionManager(@Named("dbUrl") String url, ...) { ... }
}
Альтернативний спосіб — вказати скоп у модулі, не чіпаючи клас:
// У AppModule.configure() — корисно, коли клас з бібліотеки
bind(ConnectionManager.class).in(Singleton.class);
// або через статичний імпорт:
bind(ConnectionManager.class).in(Scopes.SINGLETON);
@Singleton ідеальний для: репозиторіїв, сервісів, пулів з'єднань, кешів, NavigationService.
Eager vs Lazy Singleton
За замовчуванням @Singleton є lazy (ліниво створюваним): Guice створює екземпляр лише при першому зверненні. Якщо потрібно створити його при старті застосунку (наприклад, щоб перевірити з'єднання з БД негайно), використовуйте asEagerSingleton():
// У AppModule.configure()
bind(ConnectionManager.class).asEagerSingleton();
// Guice створить ConnectionManager відразу при Guice.createInjector()
// Якщо ініціалізація провалиться — застосунок не запуститься
asEagerSingleton() підходить для критичних ресурсів, відсутність яких є фатальною для застосунку: бази даних, черги повідомлень, ліцензійні перевірки.
Thread Safety у Singleton
@Singleton у Guice є потоко-безпечним з точки зору створення: Guice гарантує, що конструктор викличеться рівно один раз, навіть при одночасних запитах з кількох потоків. Але стан синглтона — ваша відповідальність. Якщо @Singleton-клас зберігає змінні поля, вони повинні бути синхронізовані або використовуватись лише з JavaFX Application Thread.
@Singleton. Кожне відкриття FXML-сцени створює новий контролер — і Guice повинен мати можливість це зробити. Якщо контролер є Singleton, другий виклик loader.load() поверне той самий об'єкт зі старим UI-станом, що спричинить непередбачувану поведінку.@Singleton для класів, що: (1) містять дорогу ініціалізацію (пул з'єднань, кеш); (2) зберігають спільний стан між різними частинами застосунку (NavigationService, EventBus); (3) є stateless — не мають змінних полів, лише методи. Сервіси і репозиторії зазвичай є Singleton.@Singleton TrackService залежить від TrackValidator (Prototype), то при створенні Singleton отримає один екземпляр TrackValidator і зберігатиме його назавжди — фактично перетворюючи його на Singleton. Це «Scope Widening» — небезпечний антипатерн. Якщо потрібно щоразу отримувати новий Prototype — впроваджуйте Provider<TrackValidator> замість TrackValidator напряму.Тестування з Google Guice
Одна з головних переваг DI — тестованість. Guice надає елегантний механізм підміни залежностей у тестах без зміни продуктивного коду.
Проблема без DI
Уявімо, що TrackService без Guice сам створює свій репозиторій:
public class TrackService {
// Жорстка залежність — підмінити неможливо без рефлексії
private final TrackRepository repo = new JdbcTrackRepository(
new ConnectionManager("jdbc:h2:./data/test", "sa", "")
);
}
Щоб протестувати TrackService.getTopRatedTracks(), ми змушені підняти H2-базу, наповнити її тестовими даними, і виконати реальний SQL-запит. Це інтеграційний тест, а не unit-тест — він повільніший, нестабільніший і залежить від середовища.
Modules.override() — хірургічна підміна
Guice надає метод Modules.override(base).with(override), що дозволяє замінити конкретні прив'язки базового модуля тестовими, не змінюючи жодного рядка продуктивного коду:
// Тестовий модуль — лише ті прив'язки, що потрібно замінити
public class TestAppModule extends AbstractModule {
@Override
protected void configure() {
// Замінюємо JDBC-репозиторій на in-memory заглушку
bind(TrackRepository.class).to(InMemoryTrackRepository.class);
// Замінюємо реальний URL на тестовий (H2 in-memory)
bindConstant()
.annotatedWith(Names.named("dbUrl"))
.to("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
}
}
// Тест сервісу — без реальної бази даних
class TrackServiceTest {
private TrackService trackService;
private InMemoryTrackRepository fakeRepository;
@BeforeEach
void setUp() {
// Базовий модуль + тестові override
Injector injector = Guice.createInjector(
Modules.override(new AppModule()).with(new TestAppModule())
);
trackService = injector.getInstance(TrackService.class);
fakeRepository = (InMemoryTrackRepository)
injector.getInstance(TrackRepository.class);
}
@Test
void shouldReturnTopRatedTracks() {
// Arrange: наповнюємо in-memory сховище тестовими даними
fakeRepository.save(new Track("Beethoven - Symphony No.9", 5.0));
fakeRepository.save(new Track("Mozart - Requiem", 4.8));
fakeRepository.save(new Track("Bach - Goldberg Variations", 3.2));
// Act
List<Track> top = trackService.getTopRatedTracks(2);
// Assert
assertThat(top).hasSize(2);
assertThat(top.get(0).getTitle()).contains("Beethoven");
}
}
Метод Modules.override(base).with(override) будує новий об'єднаний модуль. Усі прив'язки з override мають вищий пріоритет і перекривають відповідні прив'язки з base. Решта прив'язок з AppModule залишаються незмінними — наприклад, TrackService, TrackValidator та всі інші класи, що не потребують підміни у тесті.
Тестування з Mockito без TestModule
Якщо ви використовуєте Mockito, можна не писати TestModule взагалі — використайте toInstance() з mock-об'єктом:
@ExtendWith(MockitoExtension.class)
class TrackServiceTest {
@Mock
private TrackRepository mockRepository;
private TrackService trackService;
@BeforeEach
void setUp() {
// Передаємо Mockito-mock напряму через toInstance()
Injector injector = Guice.createInjector(binder ->
binder.bind(TrackRepository.class).toInstance(mockRepository)
);
trackService = injector.getInstance(TrackService.class);
}
@Test
void shouldDelegateToRepository() {
// Arrange
when(mockRepository.findAll()).thenReturn(List.of(
new Track("Test Track", 4.0)
));
// Act
List<Track> result = trackService.listAll();
// Assert
assertThat(result).hasSize(1);
verify(mockRepository, times(1)).findAll();
}
}
Зверніть увагу: binder -> binder.bind(...).toInstance(...) — це лямбда, що реалізує інтерфейс Module. Guice підтримує цей скорочений синтаксис, що робить тестові конфігурації надзвичайно компактними.
Modules.override() або toInstance() лише ті залежності, що є зовнішніми по відношенню до тестованого класу (БД, файлова система, HTTP-клієнти). Внутрішні залежності (валідатори, маппери, утиліти) залишайте реальними — вони є частиною контракту тестованого класу.Практичні завдання
Завдання розбиті на три рівні складності. Рівні 1 і 2 є самостійними, рівень 3 утворює міні-проєкт — повноцінну JavaFX-сцену з Guice від нуля до готового застосунку.
Рівень 1 — Базовий: Синтаксис та анотації
Завдання 1.1 — Перший Module. Напишіть клас AudioModule, що розширює AbstractModule. У методі configure() зв'яжіть:
AuthorRepository→JdbcAuthorRepository- Константу
@Named("maxPoolSize")→ значення10(типint) - Константу
@Named("uploadPath")→ рядок"/var/audiobook/uploads"
Перевірте, що Guice може створити Injector без помилок.
Завдання 1.2 — Впровадження через конструктор. Візьміть клас AuthorService з попередньої статті, що має поля authorRepository та validator. Перепишіть його так, щоб обидві залежності впроваджувались через @Inject-конструктор із final полями. Переконайтесь, що клас неможливо інстанціювати без Guice без явної передачі залежностей у конструктор.
Завдання 1.3 — Виправлення антипатерну. Знайдіть помилку у такому коді та поясніть, чому вона є проблемою:
@Singleton
public class PlaybackService {
@Inject
private Injector injector; // впроваджуємо сам контейнер
public TrackPlayer createPlayer() {
return injector.getInstance(TrackPlayer.class); // Service Locator!
}
}
Перепишіть клас правильно — через Provider<TrackPlayer>.
Рівень 2 — Логіка: Провайдери та тестування
Завдання 2.1 — Provider для пулу з'єднань. Реалізуйте клас HikariConnectionPoolProvider implements Provider<DataSource>. Він повинен отримувати через @Inject такі параметри: @Named("dbUrl") String url, @Named("dbUser") String user, @Named("dbPassword") String password, @Named("maxPoolSize") int maxPoolSize. У методі get() створюйте і налаштовуйте HikariDataSource. Зареєструйте провайдер у AudioModule через bind(DataSource.class).toProvider(HikariConnectionPoolProvider.class).in(Singleton.class).
Завдання 2.2 — Тест з Modules.override(). Напишіть клас AudioServiceTest з JUnit 5. У методі setUp() створіть Injector через Modules.override(new AudioModule()).with(testModule), де testModule підміняє AuthorRepository на InMemoryAuthorRepository (проста реалізація у пам'яті на основі HashMap). Напишіть тест shouldFindAuthorByName(), що перевіряє логіку пошуку без жодного підключення до бази даних.
Завдання 2.3 — Кастомний кваліфікатор. Створіть анотацію @Primary (@BindingAnnotation, @Retention(RUNTIME), @Target(FIELD, PARAMETER)). Зареєструйте у модулі дві реалізації TrackRepository:
@Primary TrackRepository→JdbcTrackRepository(основна)TrackRepositoryбез кваліфікатора →InMemoryCacheTrackRepository(кеш)
Впровадьте обидві у TrackService і реалізуйте метод findById(UUID id), що спочатку перевіряє кеш, і лише якщо запис не знайдено — звертається до @Primary-репозиторію.
Рівень 3 — Архітектура: JavaFX-застосунок з Guice
Ці завдання є послідовними кроками до реалізації повноцінного JavaFX-застосунку аудіоплатформи з Guice.
Завдання 3.1 — Базова інтеграція. Налаштуйте JavaFX-застосунок з Guice за схемою, описаною у цій статті:
- Створіть клас
App extends Applicationз методомinit(), де ініціалізуєтьсяInjector. - Реалізуйте головну сцену
main-view.fxmlз контролеромMainController, що має@Inject-конструктор зTrackServiceтаAuthorService. - Підключіть
setControllerFactory(injector::getInstance)і переконайтесь, що сцена відображається безNullPointerException. - Додайте лог-повідомлення в конструктор
MainControllerта всіх сервісів — переконайтесь у правильному порядку ініціалізації.
Завдання 3.2 — NavigationService. Реалізуйте NavigationService @Singleton, що підтримує такі операції:
showTrackList()— відкрити сцену зі списком треківshowAuthorList()— відкрити сцену зі списком авторівopenAddTrackDialog()— відкрити модальний діалог додавання трекуgoBack()— повернутись до попередньої сцени (зберігайте стекDeque<Stage>)
Впровадьте NavigationService у три різних контролери. Переконайтесь, що всі три отримують той самий екземпляр (@Singleton), і навігація між сценами працює коректно.
Завдання 3.3 — Кастомний Scope @PerWindow. Реалізуйте власний Guice Scope, що гарантує: кожне нове вікно JavaFX отримує свій екземпляр певного сервісу (наприклад, TrackEditState, що зберігає стан форми редагування), але в межах одного вікна — завжди той самий. Для цього:
- Оголосіть анотацію
@PerWindow(@ScopeAnnotation). - Реалізуйте клас
WindowScope implements Scopeз методамиenter()таexit(). - Зареєструйте scope у модулі:
bindScope(PerWindow.class, windowScope). - Протестуйте: відкрийте два вікна редагування і переконайтесь, що кожне має власний
TrackEditState.
Підсумок
У цій статті ми пройшли шлях від ручного зв'язування залежностей через new до повноцінного IoC-контейнера, інтегрованого у JavaFX-застосунок. Ключові ідеї, які варто зафіксувати:
Що вивчили
- DI/IoC/DIP — три концепції, що доповнюють одна одну
- Guice Module — декларативний опис залежностей
- Injector — центральна фабрика об'єктів
- setControllerFactory — ключ до JavaFX-інтеграції
- Scopes — управління lifecycle об'єктів
Ключові анотації
@Inject— точка впровадження (конструктор!)@Singleton— один екземпляр на Injector@Named— рядковий кваліфікатор@BindingAnnotation— кастомний кваліфікатор@Provides— метод-фабрика у Module
Типові помилки
- Зберігати
InjectorпозаApp/NavigationService @Singletonна JavaFX-контролерах- Field Injection замість Constructor Injection
- Впроваджувати
Stage/Sceneчерез Guice - Не відкривати пакети у
module-info.java
Що далі
- Connection Pool → —
@Provides DataSourceз HikariCP - Repository Pattern → — інтерфейси для
bind().to() - Integration Testing → —
Modules.override()на практиці
- Google Guice Wiki — повна документація
- Guice Best Practices — рекомендовані патерни
- Jakarta Inject API — стандарт анотацій
@Inject,@Singleton,@Named
Testcontainers: Тестування з реальною PostgreSQL у Docker-контейнерах
Від емуляції до реальності: архітектура Testcontainers, lifecycle Docker-контейнерів у тестах, тестування PostgreSQL-специфічних функцій (ENUM, JSON, full-text search), паралельне виконання тестів та інтеграція з CI/CD.
JavaFX: Основи побудови графічних інтерфейсів
Від консольних додатків до вікон з кнопками: архітектура JavaFX, Scene Graph, життєвий цикл Application, layout-контейнери та FXML як декларативний опис UI.