[Из песочницы] И на улицу JavaFX тоже придет Spring
Доброе время суток, хабровчане!
Надеюсь среди Вас найдутся такие же любители делать формочки как и я.
Дело в том, что я всегда был приверженцем дружелюбных интерфейсов. Меня расстраивали приложения, которые мало ориентированны на пользователей, такое особенно бывает в корпоративной разработке. И зачастую клиентские приложения написанные на Java это черные окошки, а к приложениям c GUI относятся со скептицизмом.
Ранее, на Swing или AWT все было очень печально, да наверное и до появления JavaFX 8 написание анонимных классов превращалось в спаггети код. Но с появлением лямбда-выражений все изменилось, код стал проще, понятней, красивее. Использовать JavaFX в своих проектах стало одним удовольствием.
Вот и возникла у меня мысль связать лучший инструмент для Java Spring Framework и удобный в наше время инструмент для создания GUI JavaFX, это даст нам использовать все возможности Spring`а в клиентском десктопном приложении. Собрав всю информацию воеидно, которую я искал по просторам сети, я решил поделиться ей. Прежде всего хочу отметить, что статья предназначена больше для новичков, поэтому некоторые подробности для многих могут оказаться слишком банальными и простыми, но я не хочу их опускать, чтобы не терять целостность статьи.
Жду конструктивной критики, по свои решениям.
Кому интересно, прошу под кат.
Попробуем написать небольшое приложение. Предположим, что есть такое примитивное задание: необходимо написать приложение которое будет загружать из БД данные о продуктах в таблицу на форме, а при клике на каждую строку таблицы открывать дополнительное окно с более подробными данными о продукте. Для наполнения базы данных воспользуемся сервисом. Я сгенерировал фейковые данные для таблицы с продуктами и успешно заполнил ими БД.
Получается следующее.
Главная форма состоит из компонентов:
1. Button с текстом «Загрузить»
2. TableView c полями «ID», «Наименование», «Количество», «Цена»
Функционал
- При старте приложения в контексте будет создаваться bean DataSource и происходить подключение к БД. Данные для подключения находятся в файле конфигурации. Необходимо вывести 4 поля из таблицы Products.
- При нажатии на кнопку «Загрузить» TableView наполнится данными из таблице.
- При двойном клике на строку таблицы, откроется дополнительное окно со всеми полями Products.
Используемый стек:
JavaFX 8
Spring Boot
Spring JDBC
SQLite 3
IntelliJ IDEA Community Edition 2017
Создаем JavaFX проект
Создаем новый проект в IDEA, используя архетип Maven. Первоначальную структуру которую мы видим вполне стандартную для maven проекта:
SpringFXExample
├──.idea
├──src
│ ├──main
│ │ ├──java
│ │ └──resources
│ └──test
├──────pom.xml
└──────SpringFXExample.iml
External Libraries
Выставляем необходимый Language Level для модуля и проекта и изменяем Target bytecode version для нашего модуля в настройках Build, Execution, Deployment → Compiler → Java Compiler. В завимисомти от версии вашего JDK.
Теперь необходимо превратить то что получилось, в приложение на JavaFX. Структуру проекта которую я хочу получить привожу ниже, она не претендует на идеал.
SpringFXExample
├──.idea
├──src
│ ├──main
│ │ ├──java
│ │ │ └──org.name
│ │ │ ├──app
│ │ │ │ ├──controller
│ │ │ │ │ ├──MainController.java
│ │ │ │ │ └──ProductTableController.java
│ │ │ │ └──Launcher.java
│ │ │ └──model
│ │ │ ├──dao
│ │ │ │ └─ProductDao.java
│ │ │ └──Product.java
│ │ └──resources
│ │ └──view
│ │ ├──fxml
│ │ │ ├──main.fxml
│ │ │ └──productTable.fxml
│ │ ├──style
│ │ └──image
│ └──test
├──────pom.xml
└──────SpringFXExample.iml
External Libraries
Создаем пакет org.name (или просто используете тот же значение как и в groupId) в директории java. Точка входа приложения, контроллеры, кастомные элементы и утилиты для интерфейса будут расположены в пакете app. Все остальное что касается непосредственно сущностей используемых в приложении в пакете model. В resources я создаю директорию view и храню *.fxml в папке fxml, *.css в папке style и изображение в папке image.
В FXML шаблоне main задаем шаблон внешнего вида приложения. Он будет включать в себя шаблон productTable, в котором задан внешний вид таблицы. MainController это наш главный котроллер и он будет пока с одним методом обработки нажатия кнопки загрузки. ProductTableController котроллер для таблицы. Launcher расширяем от Application и загружаем в методе start наш main.fxml обычным способом. Класс ProductDao оставим на потом. А вот Product напишем по концепции JavaBean.
Переходим к содержимому файлов:
package org.name.app.controller;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
public class MainController {
@FXML private Button load;
/**
* Обработка нажатия кнопки загрузки товаров
*/
@FXML
public void onClickLoad() {
System.out.println("Загружаем...");
// TODO: Реализовать получение данный из БД с помощью DAO класса
// TODO: и передать полученный данные в таблицу для отображения
}
}
package org.name.app.controller;
import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import org.name.model.Product;
import java.util.List;
public class ProductTableController {
@FXML private TableColumn id;
@FXML private TableColumn name;
@FXML private TableColumn quantity;
@FXML private TableColumn price;
@FXML private TableView productTable;
/**
* Устанавливаем value factory для полей таблицы
*/
public void initialize() {
id.setCellValueFactory(new PropertyValueFactory<>("id"));
name.setCellValueFactory(new PropertyValueFactory<>("name"));
quantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
price.setCellValueFactory(new PropertyValueFactory<>("price"));
}
/**
* Заполняем таблицу данными из БД
* @param products список продуктов
*/
public void fillTable(List products) {
productTable.setItems(FXCollections.observableArrayList(products));
}
}
package org.name.app;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class Launcher extends Application {
public static void main(String[] args) {
launch(args);
}
public void start(Stage stage) throws Exception {
Parent root = FXMLLoader.load(getClass()
.getResource("/view/fxml/main.fxml"));
stage.setTitle("JavaFX Maven Spring");
stage.setScene(new Scene(root));
stage.show();
}
}
package org.name.model;
public class Product {
private int id;
private String name;
private int quantity;
private String price;
private String guid;
private int tax;
public Product() {
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
public String getPrice() {
return price;
}
public void setPrice(String price) {
this.price = price;
}
public String getGuid() {
return guid;
}
public void setGuid(String guid) {
this.guid = guid;
}
public int getTax() {
return tax;
}
public void setTax(int tax) {
this.tax = tax;
}
}
Запускаем, чтобы убедится, что все работает.
Первая сборка
Пробуем собрать JAR с помощью maven package. Добавив в наш pom.xml следующую конфигурацию (В проекте у меня Java 9, но это не значит что я использую все ее возможности, просто для новых проектов выбираю самые свежие инструменты):
9
9
и maven-jar-plugin:
org.apache.maven.plugins
maven-jar-plugin
3.0.2
true
lib/
org.name.app.Launcher
4.0.0
org.name
SpringFXExample
1.0
9
9
org.apache.maven.plugins
maven-jar-plugin
3.0.2
true
lib/
org.name.app.Launcher
Пробуем запустить получившийся jar-ник, если у вас должным образом настроены переменные среды:
start java -jar target\SpringFXExample-1.0.jar
Или с помощью run.bat со следующим содержанием:
set JAVA_HOME=PATH_TO_JDK\bin
set JAVA_CMD=%JAVA_HOME%\java
start %JAVA_CMD% -jar target\SpringFXExample-1.0.jar
Лично я использую на своем ПК разные JDK поэтому запускаю приложения таким образом.
Кстати, чтобы скрыть терминал вызываем не java, а javaw просто для текущего случая нам необходимо было проверить вывод текста при нажатии на кнопку.
Добавляем Spring Boot
Теперь пришло время для Spring, а именно создадим application-context.xml в resources и напишем немного измененный загрузчик сцен. Сразу отмечу, что идея загрузчика Spring для JavaFX не моя, я уже встречал такое на просторах сети. Но я немного ее переосмыслил.
Редактируем для начала наш pom.xml. Добавляем версию Spring
9
9
5.0.3.RELEASE
и зависимости spring-context, spring-jdbc и sqlite-jdbc.
org.springframework
spring-context
${spring.version}
org.springframework
spring-jdbc
${spring.version}
org.xerial
sqlite-jdbc
3.7.2
4.0.0
org.name
SpringFXExample
1.0
9
9
5.0.3.RELEASE
org.apache.maven.plugins
maven-jar-plugin
3.0.2
true
lib/
org.name.app.Launcher
org.springframework
spring-context
${spring.version}
org.springframework
spring-jdbc
${spring.version}
org.xerial
sqlite-jdbc
3.7.2
Создаем файл конфигурации config.properties. Он содержит следующие данные:
#Заголовок главной сцены
title=JavaFX & Spring Boot!
#Конфигурация подключения к БД
db.url=jdbc: sqlite: PATH_TO_DB/test_db
db.user=user
db.password=password
db.driver=org.sqlite.JDBC
Добавляем application-context.xml в ресурсы со следующим содержанием, если вы хоть немного знакомы со спрингом, то думаю в вас не возникнет проблем в понимании написанного ниже.
Напишем абстрактный контроллер Controller который расширяет интерфейс ApplicationContextAware, чтобы мы могли получать контекст из любого контроллера.
package org.name.app.controller;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
public abstract class Controller implements ApplicationContextAware {
private ApplicationContext context;
public ApplicationContext getContext() {
return context;
}
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
this.context = context;
}
}
Теперь реализуем загрузчик сцен SpringStageLoader. Он будет больше похож на утилитный класс, в котором можно реализовать загрузку различных сцен и окон, поэтому он у меня сразу получился таким объемным.
package org.name.app;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class SpringStageLoader implements ApplicationContextAware {
private static ApplicationContext staticContext;
//инъекция заголовка главного окна
@Value("${title}")
private String appTitle;
private static String staticTitle;
private static final String FXML_DIR = "/view/fxml/";
private static final String MAIN_STAGE = "main";
/**
* Загрузка корневого узла и его дочерних элементов из fxml шаблона
* @param fxmlName наименование *.fxml файла в ресурсах
* @return объект типа Parent
* @throws IOException бросает исключение ввода-вывода
*/
private static Parent load(String fxmlName) throws IOException {
FXMLLoader loader = new FXMLLoader();
// setLocation необходим для корректной загрузки включенных шаблонов, таких как productTable.fxml,
// без этого получим исключение javafx.fxml.LoadException: Base location is undefined.
loader.setLocation(SpringStageLoader.class.getResource(FXML_DIR + fxmlName + ".fxml"));
// setLocation необходим для корректной того чтобы loader видел наши кастомные котнролы
loader.setClassLoader(SpringStageLoader.class.getClassLoader());
loader.setControllerFactory(staticContext::getBean);
return loader.load(SpringStageLoader.class.getResourceAsStream(FXML_DIR + fxmlName + ".fxml"));
}
/**
* Реализуем загрузку главной сцены. На закрытие сцены стоит обработчик, которых выходит из приложения
* @return главную сцену
* @throws IOException бросает исключение ввода-вывода
*/
public static Stage loadMain() throws IOException {
Stage stage = new Stage();
stage.setScene(new Scene(load(MAIN_STAGE)));
stage.setOnHidden(event -> Platform.exit());
stage.setTitle(staticTitle);
return stage;
}
/**
* Передаем данные в статические поля в реализации метода интерфейса ApplicationContextAware,
т.к. методы их использующие тоже статические
*/
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
SpringStageLoader.staticContext = context;
SpringStageLoader.staticTitle = appTitle;
}
}
Немного переписываем метод start в классе Launcher. А так же добавляем инициализацию контекста и его освобождение.
package org.name.app;
import javafx.application.Application;
import javafx.stage.Stage;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import java.io.IOException;
public class Launcher extends Application {
private static ClassPathXmlApplicationContext context;
public static void main(String[] args) {
launch(args);
}
/**
* Инициализируем контекст
*/
@Override
public void init() {
context = new ClassPathXmlApplicationContext("application-context.xml");
}
@Override
public void start(Stage stage) throws IOException {
SpringStageLoader.loadMain().show();
}
/**
* Освобождаем контекст
*/
@Override
public void stop() throws IOException {
context.close();
}
}
Не забываем унаследовать класс MainController от Controller и всем контроллерам добавить аннотацию Component, это позволит добавить их в контекст через component-scan и получать любые контроллеры из контекста, как бины, или инжектить их. Иначе получим исключение
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.name.app.controller.MainController' available
Запускаем и видим что текст заголовок окна стал таким который мы прописали в property:
Но загрузка данных у нас еще не реазилована как и отображение подробной информации о продукте.
Реализуем класс ProductDao
package org.name.model.dao;
import org.name.model.Product;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.util.List;
@Component
public class ProductDao {
private JdbcTemplate template;
/**
* Инжектим dataSource и создаем объект JdbcTemplate
*/
@Autowired
public ProductDao(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
/**
* Получаем весь список продуктов из таблицы. Т.к. класс Product построен на концепции JavaBean
* мы можем воспользоваться классом BeanPropertyRowMapper.
*/
public List getAllProducts(){
String sql = "SELECT * FROM product";
return template.query(sql, new BeanPropertyRowMapper<>(Product.class));
}
}
Теперь осталось дописать пару строк в главном контроллере, чтобы при нажатии на кнопку у нас данные загружались в таблицу
package org.name.app.controller;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import org.name.model.dao.ProductDao;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class MainController extends Controller {
@FXML private Button load;
private ProductTableController tableController;
private ProductDao productDao;
@Autowired
public MainController(ProductTableController tableController,
ProductDao productDao) {
this.tableController = tableController;
this.productDao = productDao;
}
/**
* Обработка нажатия кнопки загрузки товаров
*/
@FXML
public void onClickLoad() {
tableController.fillTable(productDao.getAllProducts());
load.setDisable(true);
}
}
и реализовать открытие нового окна с деталями продукта. Для этого используем шаблон productDetails и сцену ProductDetailsModalStage.
package org.name.app.controller;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.name.app.SpringStageLoader;
import org.name.model.Product;
import java.io.IOException;
public class ProductDetailsModalStage extends Stage {
private Label name;
private Label guid;
private Label quantity;
private Label price;
private Label costOfAll;
private Label tax;
public ProductDetailsModalStage() {
this.initModality(Modality.WINDOW_MODAL);
this.centerOnScreen();
try {
Scene scene = SpringStageLoader.loadScene("productDetails");
this.setScene(scene);
name = (Label) scene.lookup("#name");
guid = (Label) scene.lookup("#guid");
quantity = (Label) scene.lookup("#quantity");
price = (Label) scene.lookup("#price");
costOfAll = (Label) scene.lookup("#costOfAll");
tax = (Label) scene.lookup("#tax");
} catch (IOException e) {
e.printStackTrace();
}
}
public void showDetails(Product product) {
name.setText(product.getName());
guid.setText(product.getGuid());
quantity.setText(String.valueOf(product.getQuantity()));
price.setText(product.getPrice());
costOfAll.setText("$" + getCostOfAll(product));
tax.setText(String.valueOf(product.getTax()) + " %");
setTitle("Детали продукта: " + product.getName());
show();
}
private String getCostOfAll(Product product) {
int quantity = product.getQuantity();
double priceOfOne = Double.parseDouble(product
.getPrice()
.replace("$", ""));
return String.valueOf(quantity * priceOfOne);
}
}
В SpringStageLoader допишем еще один метод:
public static Scene loadScene(String fxmlName) throws IOException {
return new Scene(load(fxmlName));
}
, а в метод инициализации ProductTableController добавить несколько строчек:
productTable.setRowFactory(rf -> {
TableRow row = new TableRow<>();
row.setOnMouseClicked(event -> {
if (event.getClickCount() == 2 && (!row.isEmpty())) {
ProductDetailsModalStage stage = new ProductDetailsModalStage();
stage.showDetails(row.getItem());
}
});
return row;
});
Запускаем и видим результат:
Проблема долгой инициализация контекста
А вот еще одна интересная тема. Предположим что ваш контекст долго инициализируется, в этом случае, пользователь не поймет идет ли запуск приложения или нет. Поэтому для наглядности необходимо добавить заставку, во время инициализации контекста.
Сцену с заставкой будем писать обычным способом через FXMLLoader. Т.к. контекст как раз в этом время будет инициализироваться. Инициализацию тяжелого контекста сымитируем вызовом Thread.sleep (10000);
Шаблон с картинкой:
Измененный Launcher для загрузки приложения с заставкой
package org.name.app;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import java.io.IOException;
public class Launcher extends Application {
private static ClassPathXmlApplicationContext context;
private Stage splashScreen;
public static void main(String[] args) {
launch(args);
}
/**
* Контекст инициализируется не в UI потоке. Поэтому в методе init() UI поток вызывается через Platform.runLater()
* @throws Exception
*/
@Override
public void init() throws Exception {
Platform.runLater(this::showSplash);
Thread.sleep(10000);
context = new ClassPathXmlApplicationContext("application-context.xml");
Platform.runLater(this::closeSplash);
}
@Override
public void start(Stage stage) throws IOException {
SpringStageLoader.loadMain().show();
}
/**
* Освобождаем контекст
*/
@Override
public void stop() {
context.close();
}
/**
* Загружаем заставку обычным способом. Выставляем везде прозрачность
*/
private void showSplash() {
try {
splashScreen = new Stage(StageStyle.TRANSPARENT);
splashScreen.setTitle("Splash");
Parent root = FXMLLoader.load(getClass().getResource("/view/fxml/splash.fxml"));
Scene scene = new Scene(root, Color.TRANSPARENT);
splashScreen.setScene(scene);
splashScreen.show();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* Закрывает сцену с заставкой
*/
private void closeSplash() {
splashScreen.close();
}
}
Собираем, запускаем и получаем то что хотели:
Окончательная сборка JAR
Остался последний шаг. Это собрать JAR, но уже со Spring`ом. Для этого необходимо добавить в pom еще один плагин maven-shade-plugin:
4.0.0
org.name
SpringFXExample
1.0
9
9
5.0.3.RELEASE
org.apache.maven.plugins
maven-jar-plugin
3.0.2
true
lib/
org.name.app.Launcher
org.apache.maven.plugins
maven-shade-plugin
3.1.0
shade
META-INF/spring.handlers
META-INF/spring.schemas
org.springframework
spring-context
${spring.version}
org.springframework
spring-jdbc
${spring.version}
org.xerial
sqlite-jdbc
3.7.2
Вот таким вот простым способом можно подружить Spring и JavaFX. Окончательная структура проекта:
SpringFXExample
├──.idea
├──src
│ ├──main
│ │ ├──java
│ │ │ └──org.name
│ │ │ ├──app
│ │ │ │ ├──controller
│ │ │ │ │ ├──Controller.java
│ │ │ │ │ ├──MainController.java
│ │ │ │ │ ├──ProductTableController.java
│ │ │ │ │ └──ProductDetailsModalStage.java
│ │ │ │ ├──Launcher.java
│ │ │ │ └──SpringStageLoader.java
│ │ │ └──model
│ │ │ ├──dao
│ │ │ │ └─ProductDao.java
│ │ │ └──Product.java
│ │ └──resources
│ │ ├──view
│ │ │ ├──fxml
│ │ │ │ ├──main.fxml
│ │ │ │ ├──productDetails.fxml
│ │ │ │ ├──productTable.fxml
│ │ │ │ └──splash.fxml
│ │ │ ├──style
│ │ │ └──image
│ │ │ └──splash.png
│ │ └──application-context.xml
│ └──test
├──────config.properties.xml
├──────pom.xml
├──────SpringFXExample.iml
└──────test-db.xml
External Libraries
Исходники на GitHub. Там же файл PRODUCTS.sql для таблицы в БД.