Інтеграція MVVM з Guice: Автоматична ін'єкція залежностей
Інтеграція MVVM з Guice: Автоматична ін'єкція залежностей
Вступ: Проблема ручного створення ViewModel
У попередніх статтях ми побудували повноцінну MVVM-архітектуру: ViewModel з Properties та Commands, Controller з Bindings, Repository для доступу до даних. Але є одна проблема, яку ми обходили стороною: хто створює всі ці об'єкти?
Розглянемо типовий граф залежностей нашого додатку:
AudiobookListController
↓ залежить від
AudiobookListViewModel
↓ залежить від
AudiobookService
↓ залежить від
AudiobookRepository (JdbcAudiobookRepository)
↓ залежить від
DataSource (HikariCP Connection Pool)
Якщо створювати ці об'єкти вручну у Application.start(), код виглядатиме так:
@Override
public void start(Stage primaryStage) throws Exception {
// Створення DataSource
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:audiobook");
config.setUsername("sa");
config.setPassword("");
DataSource dataSource = new HikariDataSource(config);
// Створення Repository
AudiobookRepository audiobookRepository = new JdbcAudiobookRepository(dataSource);
AuthorRepository authorRepository = new JdbcAuthorRepository(dataSource);
GenreRepository genreRepository = new JdbcGenreRepository(dataSource);
// Створення Service
AudiobookService audiobookService = new AudiobookService(
audiobookRepository,
authorRepository,
genreRepository
);
// Створення ViewModel
AudiobookListViewModel viewModel = new AudiobookListViewModel(audiobookService);
// Завантаження FXML
FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-list-view.fxml"));
Parent root = loader.load();
// Передача ViewModel у Controller
AudiobookListController controller = loader.getController();
controller.setViewModel(viewModel);
// Налаштування Scene
Scene scene = new Scene(root, 900, 600);
primaryStage.setScene(scene);
primaryStage.show();
}
Цей код працює, але має кілька критичних проблем:
Проблема 1: Жорстке кодування залежностей. Якщо ми захочемо замінити JdbcAudiobookRepository на InMemoryAudiobookRepository для тестування, доведеться змінювати код у start().
Проблема 2: Дублювання коду. Якщо у нас 10 екранів, кожен з власним Controller та ViewModel, доведеться повторювати цей код 10 разів.
Проблема 3: Порушення Single Responsibility Principle. Application.start() не повинен знати, як створювати Repository, як конфігурувати Connection Pool, як передавати залежності. Його відповідальність — запустити додаток.
Проблема 4: Складність тестування. Щоб протестувати AudiobookListViewModel, потрібно створити весь ланцюжок залежностей вручну. Це робить тести громіздкими та крихкими.
Рішення всіх цих проблем — Dependency Injection (DI) через Google Guice. Guice автоматично створює об'єкти, резолвить залежності, керує життєвим циклом (Singleton vs Prototype), дозволяє легко замінювати реалізації для тестування.
У статті 24 ми вже розглядали основи Guice. Тепер застосуємо його для повної інтеграції з MVVM-архітектурою JavaFX-додатку.
Архітектура інтеграції: Guice + JavaFX
Перш ніж писати код, розглянемо архітектуру інтеграції Guice з JavaFX. Ключові компоненти:
Компоненти інтеграції
1. Guice Injector — центральний контейнер, що створює об'єкти та впроваджує залежності. Створюється один раз при старті додатку:
Injector injector = Guice.createInjector(new AudiobookModule());
2. Guice Module — конфігурація bindings: які інтерфейси прив'язані до яких реалізацій, які об'єкти є Singleton, як створювати складні залежності (Provider).
3. ControllerFactory — міст між FXMLLoader та Guice. За замовчуванням FXMLLoader створює Controllers через рефлексію (Class.newInstance()), що не підтримує Constructor Injection. ControllerFactory делегує створення Controllers до Guice Injector.
4. @Inject анотації — маркери для Guice: які конструктори, поля або методи потребують ін'єкції залежностей.
Діаграма архітектури
Пояснення потоку:
AudiobookApp.start()створює Guice Injector зAudiobookModule.AudiobookModuleконфігурує bindings:AudiobookRepository → JdbcAudiobookRepository,DataSource → HikariDataSource.ApplicationвстановлюєControllerFactoryдляFXMLLoader:loader.setControllerFactory(injector::getInstance).FXMLLoaderпарсить FXML, знаходитьfx:controller="AudiobookListController".FXMLLoaderвикликаєControllerFactory.call(AudiobookListController.class).ControllerFactoryделегує доInjector.getInstance(AudiobookListController.class).- Guice аналізує конструктор
AudiobookListController(@Inject AudiobookListViewModel viewModel). - Guice рекурсивно резолвить залежності: створює
AudiobookListViewModel→AudiobookService→JdbcAudiobookRepository→HikariDataSource. - Guice повертає повністю ініціалізований
AudiobookListControllerз усіма залежностями. FXMLLoaderін'єктує@FXMLполя (UI-елементи) та викликаєinitialize().
Guice Module: Конфігурація залежностей
Guice Module — це клас, що конфігурує bindings: які інтерфейси прив'язані до яких реалізацій, які об'єкти є Singleton, як створювати складні залежності.
Створення AudiobookModule
Створимо AudiobookModule — центральну конфігурацію нашого додатку:
package dev.kostyl.audiobook.infrastructure;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.repository.AuthorRepository;
import dev.kostyl.audiobook.repository.GenreRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcAudiobookRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcAuthorRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcGenreRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;
import javax.sql.DataSource;
public class AudiobookModule extends AbstractModule {
@Override
protected void configure() {
// ===== Data Access Layer =====
// Repositories: інтерфейс → реалізація
bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class);
bind(AuthorRepository.class).to(JdbcAuthorRepository.class);
bind(GenreRepository.class).to(JdbcGenreRepository.class);
// ===== Service Layer =====
// Services як Singleton (stateless, можна перевикористовувати)
bind(AudiobookService.class).in(Singleton.class);
// ===== Presentation Layer =====
// ViewModels як Singleton (зберігають стан між переходами)
bind(AudiobookListViewModel.class).in(Singleton.class);
}
// ===== Provider Methods =====
@Provides
@Singleton
DataSource provideDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:audiobook;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setPassword("");
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(30000);
return new HikariDataSource(config);
}
}
Розбір конфігурації
Рядки 20-25: Bindings для Repositories. bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class) означає: "коли хтось запитує AudiobookRepository, створи екземпляр JdbcAudiobookRepository". Це дозволяє легко замінити реалізацію (наприклад, на InMemoryAudiobookRepository для тестів) без зміни коду, що використовує Repository.
Рядок 30: Service як Singleton. bind(AudiobookService.class).in(Singleton.class) — Guice створить один екземпляр AudiobookService при першому запиті та повертатиме його для всіх наступних запитів. Це оптимізація: Service не має стану (stateless), тому немає сенсу створювати новий екземпляр кожного разу.
Рядок 35: ViewModel як Singleton. AudiobookListViewModel — Singleton, бо він зберігає стан (список аудіокниг, фільтри, вибраний елемент), що має зберігатися між переходами між екранами. Якщо користувач переходить з "Audiobook List" до "Audiobook Details" і повертається назад, список має залишитися у тому самому стані (прокрутка, фільтри, вибір).
Рядки 41-53: Provider Method для DataSource. @Provides метод — це спосіб створити складний об'єкт, що потребує конфігурації. Guice викличе цей метод один раз (через @Singleton) та збереже результат. Provider Method зручніший за bind().toProvider(), коли логіка створення проста.
Альтернатива: Provider Class
Якщо логіка створення DataSource складна (читання конфігурації з файлу, міграції через Flyway), краще винести її у окремий Provider:
public class HikariDataSourceProvider implements Provider<DataSource> {
@Override
public DataSource get() {
// Читання конфігурації з application.properties
Properties props = loadProperties("application.properties");
HikariConfig config = new HikariConfig();
config.setJdbcUrl(props.getProperty("db.url"));
config.setUsername(props.getProperty("db.username"));
config.setPassword(props.getProperty("db.password"));
config.setMaximumPoolSize(Integer.parseInt(props.getProperty("db.pool.size")));
DataSource dataSource = new HikariDataSource(config);
// Виконання міграцій через Flyway
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load();
flyway.migrate();
return dataSource;
}
private Properties loadProperties(String filename) {
// Реалізація завантаження properties
// ...
}
}
Використання у Module:
@Override
protected void configure() {
bind(DataSource.class)
.toProvider(HikariDataSourceProvider.class)
.in(Singleton.class);
}
@Provides метод для простої логіки (2-10 рядків). Використовуйте Provider Class для складної логіки (читання конфігурації, міграції, валідація).ControllerFactory: Міст між FXMLLoader та Guice
FXMLLoader за замовчуванням створює Controllers через рефлексію: controllerClass.getDeclaredConstructor().newInstance(). Це працює лише для конструкторів без параметрів. Але наші Controllers мають залежності (ViewModel), тому потрібен конструктор з параметрами.
Проблема: FXMLLoader не знає про Guice
Розглянемо Controller з Constructor Injection:
public class AudiobookListController {
private final AudiobookListViewModel viewModel;
@Inject
public AudiobookListController(AudiobookListViewModel viewModel) {
this.viewModel = viewModel;
}
}
Якщо завантажити FXML без ControllerFactory:
FXMLLoader loader = new FXMLLoader(getClass().getResource("AudiobookListView.fxml"));
Parent root = loader.load(); // Викине виняток!
FXMLLoader спробує викликати new AudiobookListController(), але такого конструктора немає → InstantiationException.
Рішення: setControllerFactory()
FXMLLoader підтримує кастомну фабрику для створення Controllers:
public interface Callback<P, R> {
R call(P param);
}
FXMLLoader.setControllerFactory(Callback<Class<?>, Object>) приймає функцію, що отримує клас Controller та повертає його екземпляр. Ми можемо передати туди Guice Injector:
FXMLLoader loader = new FXMLLoader(getClass().getResource("AudiobookListView.fxml"));
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load(); // Працює!
injector::getInstance — це method reference, еквівалент лямбди clazz -> injector.getInstance(clazz).
Повний приклад: Завантаження FXML з Guice
public class FxmlLoaderHelper {
private final Injector injector;
@Inject
public FxmlLoaderHelper(Injector injector) {
this.injector = injector;
}
public Parent load(String fxmlPath) throws IOException {
FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath));
loader.setControllerFactory(injector::getInstance);
return loader.load();
}
public <T> T loadWithController(String fxmlPath) throws IOException {
FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath));
loader.setControllerFactory(injector::getInstance);
loader.load();
return loader.getController();
}
}
Використання:
@Inject
private FxmlLoaderHelper loaderHelper;
public void showAudiobookList() throws IOException {
Parent root = loaderHelper.load("/fxml/audiobook-list-view.fxml");
scene.setRoot(root);
}
FxmlLoaderHelper, ViewLoader, Navigator) та ін'єктуйте Injector туди. Це дозволить централізовано керувати завантаженням Views.Constructor Injection: Ін'єкція ViewModel у Controller
Тепер, коли Guice інтегрований з FXMLLoader, можемо використовувати Constructor Injection у Controllers.
Controller з Constructor Injection
package dev.kostyl.audiobook.controller;
import com.google.inject.Inject;
import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;
import dev.kostyl.audiobook.viewmodel.AudiobookViewModel;
import javafx.fxml.FXML;
import javafx.scene.control.*;
public class AudiobookListController {
// ===== FXML-ін'єкція UI-елементів =====
@FXML private TableView<AudiobookViewModel> audiobookTable;
@FXML private TableColumn<AudiobookViewModel, String> titleColumn;
@FXML private TableColumn<AudiobookViewModel, String> authorColumn;
@FXML private TableColumn<AudiobookViewModel, String> genreColumn;
@FXML private TableColumn<AudiobookViewModel, String> durationColumn;
@FXML private TextField searchField;
@FXML private ComboBox<String> genreComboBox;
@FXML private Button addButton;
@FXML private Button editButton;
@FXML private Button deleteButton;
@FXML private ProgressIndicator loadingIndicator;
@FXML private Label statusLabel;
@FXML private Label countLabel;
// ===== Guice-ін'єкція залежностей =====
private final AudiobookListViewModel viewModel;
@Inject
public AudiobookListController(AudiobookListViewModel viewModel) {
this.viewModel = viewModel;
}
// ===== Lifecycle =====
@FXML
public void initialize() {
setupTableColumns();
setupBindings();
setupEventHandlers();
// Завантаження даних
viewModel.initialize();
}
// ===== Налаштування =====
private void setupTableColumns() {
titleColumn.setCellValueFactory(cellData ->
cellData.getValue().titleProperty());
authorColumn.setCellValueFactory(cellData ->
cellData.getValue().authorNameProperty());
genreColumn.setCellValueFactory(cellData ->
cellData.getValue().genreNameProperty());
durationColumn.setCellValueFactory(cellData ->
cellData.getValue().formattedDurationProperty());
}
private void setupBindings() {
// Таблиця
audiobookTable.setItems(viewModel.getSortedAudiobooks());
audiobookTable.getSelectionModel().selectedItemProperty()
.bindBidirectional(viewModel.selectedAudiobookProperty());
// Фільтри
searchField.textProperty().bindBidirectional(viewModel.searchQueryProperty());
genreComboBox.valueProperty().bindBidirectional(viewModel.selectedGenreProperty());
// Індикатори
loadingIndicator.visibleProperty().bind(viewModel.isLoadingProperty());
statusLabel.textProperty().bind(viewModel.statusMessageProperty());
// Кнопки
editButton.disableProperty().bind(viewModel.selectedAudiobookProperty().isNull());
deleteButton.disableProperty().bind(viewModel.selectedAudiobookProperty().isNull());
}
private void setupEventHandlers() {
addButton.setOnAction(e -> onAddClicked());
editButton.setOnAction(e -> onEditClicked());
deleteButton.setOnAction(e -> onDeleteClicked());
}
// ===== Event Handlers =====
@FXML
private void onAddClicked() {
viewModel.addNew();
}
@FXML
private void onEditClicked() {
viewModel.editSelected();
}
@FXML
private void onDeleteClicked() {
viewModel.deleteSelected();
}
}
Розбір коду
Рядки 13-28: @FXML поля. Ці поля ін'єктуються FXMLLoader після парсингу FXML. Це відбувається після виклику конструктора, але до виклику initialize().
Рядки 32-37: Constructor Injection. Конструктор з @Inject — це сигнал для Guice: "створи AudiobookListViewModel та передай його сюди". Guice рекурсивно резолвить залежності: якщо AudiobookListViewModel залежить від AudiobookService, Guice створить AudiobookService (і його залежності), потім створить AudiobookListViewModel, потім створить AudiobookListController.
Рядок 32: final для залежностей. Поле viewModel оголошене як final — це гарантує, що воно буде ініціалізоване у конструкторі та не зміниться протягом життя Controller. Це immutability — важлива практика для безпеки та передбачуваності коду.
Рядки 41-48: initialize(). Цей метод викликається FXMLLoader після ін'єкції @FXML полів. На цей момент viewModel вже ініціалізований (через конструктор), і всі UI-елементи вже ін'єктовані (через @FXML). Тут ми налаштовуємо Bindings та викликаємо viewModel.initialize() для завантаження даних.
Рядки 52-84: Налаштування. Методи setupTableColumns(), setupBindings(), setupEventHandlers() розбивають логіку ініціалізації на логічні блоки. Це покращує читабельність: замість одного методу на 100 рядків — три методи по 20-30 рядків.
- Guice викликає конструктор →
viewModelініціалізований. FXMLLoaderін'єктує@FXMLполя → UI-елементи ініціалізовані.FXMLLoaderвикликаєinitialize()→ Bindings налаштовані, дані завантажені.
@FXML полів у конструкторі, отримаєте NullPointerException — вони ще не ін'єктовані.Порівняння: Setter Injection vs Constructor Injection
== Setter Injection (без Guice)
public class AudiobookListController {
private AudiobookListViewModel viewModel;
public void setViewModel(AudiobookListViewModel viewModel) {
this.viewModel = viewModel;
setupBindings();
}
@FXML
public void initialize() {
// viewModel може бути null!
if (viewModel != null) {
viewModel.initialize();
}
}
}
Недоліки:
viewModelможе бутиnull→ потрібні перевірки.- Хтось має викликати
setViewModel()вручну після завантаження FXML. - Неявна залежність: з сигнатури класу не зрозуміло, що потрібен ViewModel.
== Constructor Injection (з Guice)
public class AudiobookListController {
private final AudiobookListViewModel viewModel;
@Inject
public AudiobookListController(AudiobookListViewModel viewModel) {
this.viewModel = viewModel;
}
@FXML
public void initialize() {
// viewModel гарантовано не null
viewModel.initialize();
}
}
Переваги:
viewModelгарантовано ініціалізований (черезfinal).- Явна залежність: з конструктора зрозуміло, що потрібен ViewModel.
- Автоматична ін'єкція через Guice — не потрібен ручний код.
- Легко тестувати:
new AudiobookListController(mockViewModel).
Scopes: Singleton vs Prototype
Guice підтримує різні scopes (області видимості) для об'єктів. Два найважливіші для JavaFX-додатків:
Singleton Scope
Визначення: Guice створює один екземпляр об'єкта при першому запиті та повертає його для всіх наступних запитів.
Коли використовувати:
- Stateless об'єкти: Repository, Service, Validator — вони не зберігають стан між викликами.
- Дорогі ресурси: DataSource (Connection Pool), ExecutorService — створення нового екземпляра кожного разу — це витік ресурсів.
- Глобальний стан: ViewModel для головних екранів, що зберігає стан між переходами (список аудіокниг, фільтри, вибір).
Приклад:
@Override
protected void configure() {
bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class).in(Singleton.class);
bind(AudiobookService.class).in(Singleton.class);
bind(AudiobookListViewModel.class).in(Singleton.class);
}
Альтернативний синтаксис через анотацію:
@Singleton
public class AudiobookService {
private final AudiobookRepository repository;
@Inject
public AudiobookService(AudiobookRepository repository) {
this.repository = repository;
}
}
Prototype Scope (No Scope)
Визначення: Guice створює новий екземпляр об'єкта при кожному запиті.
Коли використовувати:
- Stateful об'єкти: ViewModel для діалогів, форм — кожне відкриття діалогу має мати свій стан.
- Короткоживучі об'єкти: Task, Command — створюються для одноразового виконання.
Приклад:
@Override
protected void configure() {
// Без .in(Singleton.class) — це Prototype scope
bind(AudiobookFormViewModel.class);
}
Кожного разу, коли хтось запитує AudiobookFormViewModel, Guice створить новий екземпляр:
AudiobookFormViewModel vm1 = injector.getInstance(AudiobookFormViewModel.class);
AudiobookFormViewModel vm2 = injector.getInstance(AudiobookFormViewModel.class);
System.out.println(vm1 == vm2); // false — різні екземпляри
Проблема: Singleton ViewModel для діалогів
Розглянемо проблемний сценарій:
// AudiobookFormViewModel — Singleton
bind(AudiobookFormViewModel.class).in(Singleton.class);
Користувач відкриває діалог "Add Audiobook" → вводить дані → закриває діалог. Потім знову відкриває діалог → бачить попередні дані! Це тому, що ViewModel — Singleton, і він зберігає стан між відкриттями.
Рішення: ViewModel для діалогів має бути Prototype:
bind(AudiobookFormViewModel.class); // Без Singleton
Тепер кожне відкриття діалогу створює новий ViewModel з чистим станом.
Application Entry Point: Ініціалізація Guice
Тепер з'єднаємо всі частини разом: створимо Application клас, що ініціалізує Guice Injector, завантажує головний екран та керує життєвим циклом додатку.
AudiobookApp: Точка входу
package dev.kostyl.audiobook;
import com.google.inject.Guice;
import com.google.inject.Injector;
import dev.kostyl.audiobook.infrastructure.AudiobookModule;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import javax.sql.DataSource;
import java.io.IOException;
public class AudiobookApp extends Application {
private Injector injector;
@Override
public void init() throws Exception {
// Створення Guice Injector з модулем конфігурації
injector = Guice.createInjector(new AudiobookModule());
// Ініціалізація бази даних (міграції через Flyway)
initializeDatabase();
}
@Override
public void start(Stage primaryStage) throws IOException {
// Завантаження головного екрану через FXMLLoader з ControllerFactory
FXMLLoader loader = new FXMLLoader(
getClass().getResource("/fxml/audiobook-list-view.fxml")
);
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load();
// Налаштування Scene
Scene scene = new Scene(root, 1000, 700);
scene.getStylesheets().add(
getClass().getResource("/css/styles.css").toExternalForm()
);
// Налаштування Stage
primaryStage.setTitle("Audiobook Platform");
primaryStage.setScene(scene);
primaryStage.setMinWidth(800);
primaryStage.setMinHeight(600);
primaryStage.show();
}
@Override
public void stop() throws Exception {
// Закриття ресурсів при завершенні додатку
closeResources();
}
private void initializeDatabase() {
DataSource dataSource = injector.getInstance(DataSource.class);
// Виконання міграцій через Flyway
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load();
flyway.migrate();
System.out.println("Database initialized successfully");
}
private void closeResources() {
try {
// Закриття Connection Pool
DataSource dataSource = injector.getInstance(DataSource.class);
if (dataSource instanceof HikariDataSource) {
((HikariDataSource) dataSource).close();
System.out.println("DataSource closed");
}
} catch (Exception e) {
System.err.println("Error closing resources: " + e.getMessage());
}
}
public static void main(String[] args) {
launch(args);
}
}
Розбір життєвого циклу Application
Рядки 20-25: init(). Метод init() викликається перед start(), у фоновому потоці. Це ідеальне місце для ініціалізації Guice Injector та виконання міграцій бази даних. Якщо init() викине виняток, додаток не запуститься.
Рядок 22: Створення Injector. Guice.createInjector(new AudiobookModule()) — це точка, де Guice читає конфігурацію з Module та будує граф залежностей. Це відбувається один раз при старті додатку.
Рядки 28-49: start(). Метод start() викликається у JavaFX Application Thread. Тут завантажуємо головний екран через FXMLLoader з ControllerFactory, налаштовуємо Scene та Stage, показуємо вікно.
Рядок 34: ControllerFactory. loader.setControllerFactory(injector::getInstance) — це міст між FXMLLoader та Guice. Тепер FXMLLoader створюватиме Controllers через Guice, що автоматично ін'єктує всі залежності.
Рядки 51-54: stop(). Метод stop() викликається при закритті додатку (користувач натиснув "X" або викликав Platform.exit()). Тут закриваємо ресурси: Connection Pool, ExecutorService, файли.
Рядки 56-67: Ініціалізація бази даних. Отримуємо DataSource з Injector та виконуємо міграції через Flyway. Це гарантує, що база даних має актуальну схему перед запуском додатку.
Рядки 69-81: Закриття ресурсів. Отримуємо DataSource та закриваємо його. HikariCP автоматично закриє всі активні з'єднання та звільнить ресурси.
init() у фоновому потоці, щоб не блокувати UI Thread. Це дозволяє виконувати довгі операції (міграції, завантаження конфігурації) без заморожування інтерфейсу. Але будьте обережні: не створюйте JavaFX-об'єкти (Node, Scene) у init() — вони мають створюватися у start().Структура проєкту
AssistedInject: Параметризовані ViewModel
Іноді ViewModel потребує параметр при створенні. Наприклад, AudiobookDetailViewModel має показувати деталі конкретної аудіокниги — йому потрібен Audiobook у конструкторі:
public class AudiobookDetailViewModel {
private final Audiobook audiobook;
private final AudiobookRepository repository;
public AudiobookDetailViewModel(Audiobook audiobook, AudiobookRepository repository) {
this.audiobook = audiobook;
this.repository = repository;
}
}
Але як передати audiobook через Guice? injector.getInstance(AudiobookDetailViewModel.class) не приймає параметрів.
Рішення: AssistedInject
Guice підтримує AssistedInject — механізм для створення об'єктів з параметрами через фабрику.
Крок 1: Анотація @Assisted для параметрів
import com.google.inject.assistedinject.Assisted;
import com.google.inject.Inject;
public class AudiobookDetailViewModel {
private final Audiobook audiobook;
private final AudiobookRepository repository;
@Inject
public AudiobookDetailViewModel(
@Assisted Audiobook audiobook,
AudiobookRepository repository
) {
this.audiobook = audiobook;
this.repository = repository;
}
}
@Assisted означає: "цей параметр буде переданий вручну, не резолви його через Guice".
Крок 2: Створення Factory інтерфейсу
public interface AudiobookDetailViewModelFactory {
AudiobookDetailViewModel create(Audiobook audiobook);
}
Guice автоматично створить реалізацію цього інтерфейсу.
Крок 3: Реєстрація Factory у Module
import com.google.inject.assistedinject.FactoryModuleBuilder;
@Override
protected void configure() {
// ... інші bindings
install(new FactoryModuleBuilder()
.build(AudiobookDetailViewModelFactory.class));
}
Крок 4: Використання Factory
public class AudiobookListController {
private final AudiobookDetailViewModelFactory detailViewModelFactory;
@Inject
public AudiobookListController(AudiobookDetailViewModelFactory factory) {
this.detailViewModelFactory = factory;
}
private void onShowDetails(Audiobook audiobook) {
AudiobookDetailViewModel viewModel = detailViewModelFactory.create(audiobook);
// Відкрити діалог з цим ViewModel
}
}
Guice автоматично ін'єктує AudiobookRepository у AudiobookDetailViewModel, а audiobook передається через factory.create(audiobook).
Повний приклад: Додаток з кількома екранами
Розглянемо повний приклад додатку з трьома екранами: список аудіокниг, форма додавання, деталі аудіокниги.
AudiobookModule: Повна конфігурація
package dev.kostyl.audiobook.infrastructure;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.google.inject.assistedinject.FactoryModuleBuilder;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.kostyl.audiobook.repository.*;
import dev.kostyl.audiobook.repository.jdbc.*;
import dev.kostyl.audiobook.service.AudiobookService;
import dev.kostyl.audiobook.viewmodel.*;
import javax.sql.DataSource;
public class AudiobookModule extends AbstractModule {
@Override
protected void configure() {
// ===== Data Access Layer =====
bind(AudiobookRepository.class).to(JdbcAudiobookRepository.class);
bind(AuthorRepository.class).to(JdbcAuthorRepository.class);
bind(GenreRepository.class).to(JdbcGenreRepository.class);
// ===== Service Layer =====
bind(AudiobookService.class).in(Singleton.class);
// ===== Presentation Layer =====
// Singleton ViewModels (зберігають стан між переходами)
bind(AudiobookListViewModel.class).in(Singleton.class);
// Prototype ViewModels (новий екземпляр для кожного відкриття)
bind(AudiobookFormViewModel.class); // Без Singleton
// AssistedInject Factory для параметризованих ViewModel
install(new FactoryModuleBuilder()
.build(AudiobookDetailViewModelFactory.class));
}
@Provides
@Singleton
DataSource provideDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:audiobook;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setPassword("");
config.setMaximumPoolSize(10);
config.setMinimumIdle(2);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
return new HikariDataSource(config);
}
}
Порівняння: До та після Guice
== Без Guice (ручне створення)
@Override
public void start(Stage primaryStage) {
// Створення всіх залежностей вручну
DataSource dataSource = createDataSource();
AudiobookRepository audiobookRepo = new JdbcAudiobookRepository(dataSource);
AuthorRepository authorRepo = new JdbcAuthorRepository(dataSource);
GenreRepository genreRepo = new JdbcGenreRepository(dataSource);
AudiobookService service = new AudiobookService(
audiobookRepo, authorRepo, genreRepo
);
AudiobookListViewModel viewModel = new AudiobookListViewModel(service);
// Завантаження FXML
FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-list-view.fxml"));
Parent root = loader.load();
// Передача ViewModel у Controller
AudiobookListController controller = loader.getController();
controller.setViewModel(viewModel);
// Налаштування Scene
Scene scene = new Scene(root);
primaryStage.setScene(scene);
primaryStage.show();
}
Проблеми:
- 20+ рядків коду для створення залежностей.
- Жорстке кодування реалізацій.
- Складно тестувати.
- Дублювання для кожного екрану.
== З Guice (автоматична ін'єкція)
@Override
public void start(Stage primaryStage) throws IOException {
// Завантаження FXML з ControllerFactory
FXMLLoader loader = new FXMLLoader(
getClass().getResource("/fxml/AudiobookListView.fxml")
);
loader.setControllerFactory(injector::getInstance);
Parent root = loader.load();
// Налаштування Scene
Scene scene = new Scene(root);
primaryStage.setScene(scene);
primaryStage.show();
}
Переваги:
- 10 рядків коду замість 20+.
- Guice автоматично резолвить всі залежності.
- Легко замінити реалізації у Module.
- Легко тестувати (mock залежності).
Тестування з Guice: Mock залежностей
Одна з найбільших переваг Dependency Injection — легкість тестування. Ми можемо замінити реальні залежності на mock-об'єкти для unit-тестів.
Unit-тестування ViewModel з mock Repository
package dev.kostyl.audiobook.viewmodel;
import dev.kostyl.audiobook.domain.Audiobook;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AudiobookListViewModelTest {
@Mock
private AudiobookRepository repository;
@Mock
private AudiobookService service;
private AudiobookListViewModel viewModel;
@BeforeEach
void setUp() {
// Створення ViewModel з mock залежностями
viewModel = new AudiobookListViewModel(service);
}
@Test
void shouldLoadAudiobooks() {
// Given
List<Audiobook> audiobooks = List.of(
new Audiobook("Title 1", author1, genre1, 3600),
new Audiobook("Title 2", author2, genre2, 7200)
);
when(service.getAllAudiobooks()).thenReturn(audiobooks);
// When
viewModel.loadAudiobooks();
// Then
assertEquals(2, viewModel.getAudiobooks().size());
verify(service).getAllAudiobooks();
}
@Test
void shouldFilterAudiobooksBySearchQuery() {
// Given
viewModel.getAudiobooks().addAll(
new AudiobookViewModel(audiobook1), // title: "1984"
new AudiobookViewModel(audiobook2) // title: "Brave New World"
);
// When
viewModel.searchQueryProperty().set("1984");
// Then
assertEquals(1, viewModel.getFilteredAudiobooks().size());
assertEquals("1984", viewModel.getFilteredAudiobooks().get(0).getTitle());
}
}
Переваги тестування з DI:
- Не потрібно створювати реальну базу даних для unit-тестів.
- Mock залежності передаються через конструктор — просто та зрозуміло.
- Тести швидкі (без IO-операцій) та ізольовані.
Інтеграційне тестування з Test Module
Для інтеграційних тестів створимо окремий Guice Module з тестовими залежностями:
package dev.kostyl.audiobook.infrastructure;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.repository.inmemory.InMemoryAudiobookRepository;
import javax.sql.DataSource;
public class TestAudiobookModule extends AbstractModule {
@Override
protected void configure() {
// Використовуємо InMemory реалізації для тестів
bind(AudiobookRepository.class).to(InMemoryAudiobookRepository.class);
// Решта конфігурації як у production Module
bind(AudiobookService.class).in(Singleton.class);
bind(AudiobookListViewModel.class).in(Singleton.class);
}
@Provides
@Singleton
DataSource provideDataSource() {
// H2 in-memory database для тестів
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
config.setUsername("sa");
config.setPassword("");
return new HikariDataSource(config);
}
}
Використання у тестах:
@ExtendWith(GuiceExtension.class)
@GuiceModules(TestAudiobookModule.class)
class AudiobookListViewModelIntegrationTest {
@Inject
private AudiobookListViewModel viewModel;
@Inject
private AudiobookRepository repository;
@Test
void shouldLoadAudiobooksFromRepository() {
// Given
repository.save(audiobook1);
repository.save(audiobook2);
// When
viewModel.loadAudiobooks();
// Then
assertEquals(2, viewModel.getAudiobooks().size());
}
}
AudiobookModule), тестів (TestAudiobookModule), розробки (DevAudiobookModule з логуванням SQL). Це дозволить легко перемикатися між конфігураціями.Практичні завдання
Рівень 1: Базова інтеграція Guice
Завдання 1.1: Створіть AuthorModule (Guice Module) з bindings для AuthorRepository, AuthorService.
Завдання 1.2: Створіть AuthorListController з Constructor Injection для AuthorListViewModel.
Завдання 1.3: Модифікуйте AudiobookApp, щоб він використовував AuthorModule та завантажував author-list-view.fxml через FXMLLoader з ControllerFactory.
Рівень 2: Scopes та Lifecycle
Завдання 2.1: Створіть AudiobookFormViewModel як Prototype scope (новий екземпляр для кожного відкриття форми). Переконайтеся, що при повторному відкритті форми стан скидається.
Завдання 2.2: Додайте у AudiobookApp.stop() логіку закриття ExecutorService (якщо він використовується у ViewModel для асинхронних операцій).
Завдання 2.3: Створіть Provider для ExecutorService, що конфігурує ThreadPool з параметрами з файлу application.properties.
Рівень 3: AssistedInject та складні сценарії
Завдання 3.1: Створіть AudiobookDetailViewModel, що приймає Audiobook у конструкторі через @Assisted. Створіть Factory для нього та зареєструйте у Module.
Завдання 3.2: Реалізуйте діалог редагування аудіокниги: при натисканні "Edit" у AudiobookListController відкривається діалог з AudiobookFormViewModel, створеним через Factory з параметром Audiobook.
Завдання 3.3: Створіть Navigator клас з методом navigateTo(String screenId), що завантажує FXML через FXMLLoader з ControllerFactory та змінює Root Node у Scene. Ін'єктуйте Navigator у Controllers для навігації між екранами.
Підсумок
У цій статті ми інтегрували MVVM-архітектуру з Google Guice для автоматичної ін'єкції залежностей. Ключові висновки:
Guice вирішує проблему ручного створення об'єктів. Замість 20+ рядків коду для створення графу залежностей у Application.start(), Guice автоматично резолвить всі залежності через конфігурацію у Module.
Guice Module — це центральна конфігурація додатку. Тут визначаємо bindings (інтерфейс → реалізація), scopes (Singleton vs Prototype), Provider Methods для складних об'єктів (DataSource, ExecutorService).
ControllerFactory — це міст між FXMLLoader та Guice. loader.setControllerFactory(injector::getInstance) дозволяє FXMLLoader створювати Controllers через Guice, що автоматично ін'єктує всі залежності (ViewModel, Navigator).
Constructor Injection — рекомендований підхід. Явні залежності у конструкторі, immutability через final, легкість тестування (передача mock через конструктор).
Scopes керують життєвим циклом об'єктів. Singleton для stateless об'єктів (Repository, Service) та глобального стану (ViewModel головних екранів). Prototype для stateful об'єктів (ViewModel діалогів, форм).
AssistedInject для параметризованих ViewModel. Коли ViewModel потребує параметр (наприклад, Audiobook для редагування), використовуємо Factory з @Assisted анотацією. Guice автоматично ін'єктує решту залежностей.
Application lifecycle: init() → start() → stop(). init() — створення Injector та міграції БД (фоновий потік). start() — завантаження UI (JavaFX Thread). stop() — закриття ресурсів (Connection Pool, ExecutorService).
Тестування стає простішим. Unit-тести з mock залежностями (Mockito). Інтеграційні тести з Test Module (InMemory Repository, H2 database). Легко замінити реалізації без зміни коду.
У наступній статті ми розглянемо валідацію та обробку помилок у MVVM: як валідувати введення користувача на рівні ViewModel, як відображати помилки у View, як обробляти винятки з Repository та Service, та як створити централізовану систему валідації через Validator Pattern.
View та Controller: Зв'язування з ViewModel через FXML
Від ViewModel до UI: FXML як декларативний опис інтерфейсу, мінімальний Controller з Bindings, ін'єкція ViewModel, FXMLLoader та ControllerFactory, обробка подій та навігація.
Валідація та обробка помилок у MVVM
Від реактивної валідації Properties до централізованої системи Validators: валідація на рівні ViewModel, Validator Pattern, CompositeValidator, асинхронна валідація унікальності, обробка помилок Repository та Service, відображення помилок у View.