Доброе время суток, хабровчане!

Надеюсь среди Вас найдутся такие же любители делать формочки как и я.
Дело в том, что я всегда был приверженцем дружелюбных интерфейсов. Меня расстраивали приложения, которые мало ориентированны на пользователей, такое особенно бывает в корпоративной разработке. И зачастую клиентские приложения написанные на Java это черные окошки, а к приложениям c GUI относятся со скептицизмом.

Ранее, на Swing или AWT все было очень печально, да наверное и до появления JavaFX 8 написание анонимных классов превращалось в спаггети код. Но с появлением лямбда-выражений все изменилось, код стал проще, понятней, красивее. Использовать JavaFX в своих проектах стало одним удовольствием.

Вот и возникла у меня мысль связать лучший инструмент для Java Spring Framework и удобный в наше время инструмент для создания GUI JavaFX, это даст нам использовать все возможности Spring`а в клиентском десктопном приложении. Собрав всю информацию воеидно, которую я искал по просторам сети, я решил поделиться ей. Прежде всего хочу отметить, что статья предназначена больше для новичков, поэтому некоторые подробности для многих могут оказаться слишком банальными и простыми, но я не хочу их опускать, чтобы не терять целостность статьи.



Жду конструктивной критики, по свои решениям.

Кому интересно, прошу под кат.

Попробуем написать небольшое приложение. Предположим, что есть такое примитивное задание: необходимо написать приложение которое будет загружать из БД данные о продуктах в таблицу на форме, а при клике на каждую строку таблицы открывать дополнительное окно с более подробными данными о продукте. Для наполнения базы данных воспользуемся сервисом. Я сгенерировал фейковые данные для таблицы с продуктами и успешно заполнил ими БД.

Получается следующее.

Главная форма состоит из компонентов:

1. Button с текстом «Загрузить»
2. TableView c полями «ID», «Наименование», «Количество», «Цена»

Функционал

  1. При старте приложения в контексте будет создаваться bean DataSource и происходить подключение к БД. Данные для подключения находятся в файле конфигурации. Необходимо вывести 4 поля из таблицы Products.
  2. При нажатии на кнопку «Загрузить» TableView наполнится данными из таблице.
  3. При двойном клике на строку таблицы, откроется дополнительное окно со всеми полями Products.

Используемый стек:

JavaFX 8
Spring JDBC
SQLite 3
IntelliJ IDEA Community Edition 2017


Создаем JavaFX проект


Создаем новый проект в IDEA, используя архетип Maven. Первоначальную структуру которую мы видим вполне стандартную для maven проекта:

SpringFXExample
+--.idea
+--src
¦  +--main
¦  ¦  +--java
¦  ¦  L--resources
¦  L--test
+------pom.xml
L------SpringFXExample.iml
External Libraries

Выставляем необходимый Language Level для модуля и проекта и изменяем Target bytecode version для нашего модуля в настройках Build, Execution, Deployment -> Compiler -> Java Compiler. В завимисомти от версии вашего JDK.

Теперь необходимо превратить то что получилось, в приложение на JavaFX. Структуру проекта которую я хочу получить привожу ниже, она не претендует на идеал.

SpringFXExample
+--.idea
+--src
¦  +--main
¦  ¦  +--java
¦  ¦  ¦  L--org.name
¦  ¦  ¦     +--app
¦  ¦  ¦     ¦  +--controller
¦  ¦  ¦     ¦  ¦  +--MainController.java
¦  ¦  ¦     ¦  ¦  L--ProductTableController.java
¦  ¦  ¦     ¦  L--Launcher.java
¦  ¦  ¦     L--model
¦  ¦  ¦        +--dao
¦  ¦  ¦        ¦  L-ProductDao.java
¦  ¦  ¦        L--Product.java
¦  ¦  L--resources
¦  ¦     L--view
¦  ¦        +--fxml
¦  ¦        ¦  +--main.fxml
¦  ¦        ¦  L--productTable.fxml
¦  ¦        +--style
¦  ¦        L--image
¦  L--test
+------pom.xml
L------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.

Переходим к содержимому файлов:

main.fxml
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.control.Button?>
<AnchorPane xmlns="http://javafx.com/javafx"
			xmlns:fx="http://javafx.com/fxml"
			fx:controller="org.name.app.controller.MainController"
			prefHeight="400.0" prefWidth="400.0">
	<Button fx:id="load"
			text="Загрузить"
			AnchorPane.topAnchor="10" AnchorPane.leftAnchor="10"
			onMouseClicked="#onClickLoad"/>
	<!-- TableView будет подключаться из другого fxml шаблона -->
	<fx:include
			AnchorPane.topAnchor="40" AnchorPane.leftAnchor="10"
			AnchorPane.bottomAnchor="10"
			source="productTable.fxml"/>
</AnchorPane>


productTable.fxml
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>

<TableView fx:id="productTable" prefWidth="350.0"
		   xmlns="http://javafx.com/javafx/8.0.121"
		   xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.name.app.controller.ProductTableController">
	<columns>
		<TableColumn fx:id="id" prefWidth="30.0" text="ID"/>
		<TableColumn fx:id="name" prefWidth="200.0" text="Наименование"/>
		<TableColumn fx:id="quantity" prefWidth="50.0" text="Кол-во"/>
		<TableColumn fx:id="price" prefWidth="50.0" text="Цена"/>
	</columns>
</TableView>


MainController.java
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: и передать полученный данные в таблицу для отображения
	}
}


ProductTableController.java
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<Integer, Product> id;
	@FXML private TableColumn<String, Product> name;
	@FXML private TableColumn<Integer, Product> quantity;
	@FXML private TableColumn<String, Product> price;
	@FXML private TableView<Product> 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<Product> products) {
		productTable.setItems(FXCollections.observableArrayList(products));
	}
}


Launcher.java
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();
	}
}


Product.java
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, но это не значит что я использую все ее возможности, просто для новых проектов выбираю самые свежие инструменты):

<properties>
    <maven.compiler.source>9</maven.compiler.source>
    <maven.compiler.target>9</maven.compiler.target>
</properties>

и maven-jar-plugin:
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>3.0.2</version>
            <configuration>
                <archive>
                    <manifest>
                        <addClasspath>true</addClasspath>
                        <classpathPrefix>lib/</classpathPrefix>
                        <mainClass>org.name.app.Launcher</mainClass>
                    </manifest>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
		 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>org.name</groupId>
	<artifactId>SpringFXExample</artifactId>
	<version>1.0</version>

	<properties>
		<maven.compiler.source>9</maven.compiler.source>
		<maven.compiler.target>9</maven.compiler.target>
	</properties>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-jar-plugin</artifactId>
				<version>3.0.2</version>
				<configuration>
					<archive>
						<manifest>
							<addClasspath>true</addClasspath>
							<classpathPrefix>lib/</classpathPrefix>
							<mainClass>org.name.app.Launcher</mainClass>
						</manifest>
					</archive>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>


Пробуем запустить получившийся 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


Теперь пришло время для Spring, а именно создадим application-context.xml в resources и напишем немного измененный загрузчик сцен. Сразу отмечу, что идея загрузчика Spring для JavaFX не моя, я уже встречал такое на просторах сети. Но я немного ее переосмыслил.

Редактируем для начала наш pom.xml. Добавляем версию Spring

<properties>
    <maven.compiler.source>9</maven.compiler.source>
    <maven.compiler.target>9</maven.compiler.target>
    <spring.version>5.0.3.RELEASE</spring.version>
</properties>

и зависимости spring-context, spring-jdbc и sqlite-jdbc.

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.xerial</groupId>
        <artifactId>sqlite-jdbc</artifactId>
        <version>3.7.2</version>
    </dependency>
</dependencies>

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
		 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>org.name</groupId>
	<artifactId>SpringFXExample</artifactId>
	<version>1.0</version>

	<properties>
		<maven.compiler.source>9</maven.compiler.source>
		<maven.compiler.target>9</maven.compiler.target>
		<spring.version>5.0.3.RELEASE</spring.version>
	</properties>


	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-jar-plugin</artifactId>
				<version>3.0.2</version>
				<configuration>
					<archive>
						<manifest>
							<addClasspath>true</addClasspath>
							<classpathPrefix>lib/</classpathPrefix>
							<mainClass>org.name.app.Launcher</mainClass>
						</manifest>
					</archive>
				</configuration>
			</plugin>
		</plugins>
	</build>

	<dependencies>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-context</artifactId>
			<version>${spring.version}</version>
		</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-jdbc</artifactId>
			<version>${spring.version}</version>
		</dependency>
		<dependency>
			<groupId>org.xerial</groupId>
			<artifactId>sqlite-jdbc</artifactId>
			<version>3.7.2</version>
		</dependency>
	</dependencies>

</project>


Создаем файл конфигурации 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 в ресурсы со следующим содержанием, если вы хоть немного знакомы со спрингом, то думаю в вас не возникнет проблем в понимании написанного ниже.

application-context.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	   xmlns:context="http://www.springframework.org/schema/context"
	   xsi:schemaLocation="http://www.springframework.org/schema/beans
   http://www.springframework.org/schema/beans/spring-beans.xsd
   http://www.springframework.org/schema/context
	http://www.springframework.org/schema/context/spring-context.xsd">

	<context:property-placeholder location="file:config.properties" ignore-unresolvable="true"/>

	<context:component-scan base-package="org.name"/>

	<bean name="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
		<property name="url" value="${db.url}"/>
		<property name="driverClassName" value="${db.driver}"/>
		<property name="username" value="${db.user}"/>
		<property name="password" value="${db.password}"/>
	</bean>
</beans>


Напишем абстрактный контроллер Controller который расширяет интерфейс ApplicationContextAware, чтобы мы могли получать контекст из любого контроллера.

Controller.java
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. Он будет больше похож на утилитный класс, в котором можно реализовать загрузку различных сцен и окон, поэтому он у меня сразу получился таким объемным.

SpringStageLoader.java
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. А так же добавляем инициализацию контекста и его освобождение.

Launcher.java
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

ProductDao.java
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<Product> getAllProducts(){
        String sql = "SELECT * FROM product";
        return template.query(sql, new BeanPropertyRowMapper<>(Product.class));
    }
}


Теперь осталось дописать пару строк в главном контроллере, чтобы при нажатии на кнопку у нас данные загружались в таблицу

MainController.java
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.

productDetails.fxml
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns="http://javafx.com/javafx/8.0.121" xmlns:fx="http://javafx.com/fxml/1">
    <children>
        <GridPane>
            <columnConstraints>
                <ColumnConstraints prefWidth="150.0"/>
                <ColumnConstraints prefWidth="300.0"/>
            </columnConstraints>
            <rowConstraints>
                <RowConstraints prefHeight="30.0"/>
                <RowConstraints prefHeight="30.0"/>
                <RowConstraints prefHeight="30.0"/>
                <RowConstraints prefHeight="30.0"/>
                <RowConstraints prefHeight="30.0"/>
                <RowConstraints prefHeight="30.0"/>
            </rowConstraints>
            <Label fx:id="name" style="-fx-font-weight: bold;-fx-padding: 3px;"
                   prefWidth="450"
                   GridPane.columnSpan="2" alignment="CENTER"/>
            <Label style="-fx-font-weight: bold; -fx-padding: 3px;"
                   GridPane.rowIndex="1" text="ГУИД:"/>
            <Label fx:id="guid" style="-fx-padding: 3px;" GridPane.rowIndex="1" GridPane.columnIndex="1"/>
            <Label style="-fx-font-weight: bold; -fx-padding: 3px;"
                   GridPane.rowIndex="2" text="Количество на складе:"/>
            <Label fx:id="quantity" style="-fx-padding: 3px;" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
            <Label style="-fx-font-weight: bold; -fx-padding: 3px;"
                   GridPane.rowIndex="3" text="Цена:"/>
            <Label fx:id="price" style="-fx-padding: 3px;" GridPane.rowIndex="3" GridPane.columnIndex="1"/>
            <Label style="-fx-font-weight: bold; -fx-padding: 3px;"
                   GridPane.rowIndex="4" text="Общая стоимость:"/>
            <Label fx:id="costOfAll" style="-fx-padding: 3px;" GridPane.rowIndex="4" GridPane.columnIndex="1"/>
            <Label style="-fx-font-weight: bold; -fx-padding: 3px;"
                   GridPane.rowIndex="5" text="Налог:"/>
            <Label fx:id="tax" style="-fx-padding: 3px;" GridPane.rowIndex="5" GridPane.columnIndex="1"/>
        </GridPane>
    </children>
</AnchorPane>



ProductDetailsModalStage.java
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<Product> 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);

Шаблон с картинкой:

splash.fxml
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.image.Image?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.AnchorPane?>
<AnchorPane xmlns="http://javafx.com/javafx" mouseTransparent="true">
	<ImageView>
		<Image url="@/view/image/splash.png"/>
	</ImageView>
</AnchorPane>


Измененный Launcher для загрузки приложения с заставкой

Launcher.java
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();
    }
}


Собираем, запускаем и получаем то что хотели:

Launch App GIF


Окончательная сборка JAR


Остался последний шаг. Это собрать JAR, но уже со Spring`ом. Для этого необходимо добавить в pom еще один плагин maven-shade-plugin:

pom.xml - окончательная версия
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.name</groupId>
    <artifactId>SpringFXExample</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.source>9</maven.compiler.source>
        <maven.compiler.target>9</maven.compiler.target>
        <spring.version>5.0.3.RELEASE</spring.version>
    </properties>


    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>lib/</classpathPrefix>
                            <mainClass>org.name.app.Launcher</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer
                                        implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                                    <resource>META-INF/spring.handlers</resource>
                                </transformer>
                                <transformer
                                        implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                                    <resource>META-INF/spring.schemas</resource>
                                </transformer>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
            <version>3.7.2</version>
        </dependency>
    </dependencies>

</project>


Вот таким вот простым способом можно подружить Spring и JavaFX. Окончательная структура проекта:

SpringFXExample
+--.idea
+--src
¦  +--main
¦  ¦  +--java
¦  ¦  ¦  L--org.name
¦  ¦  ¦     +--app
¦  ¦  ¦     ¦  +--controller
¦  ¦  ¦     ¦  ¦  +--Controller.java
¦  ¦  ¦     ¦  ¦  +--MainController.java
¦  ¦  ¦     ¦  ¦  +--ProductTableController.java
¦  ¦  ¦     ¦  ¦  L--ProductDetailsModalStage.java
¦  ¦  ¦     ¦  +--Launcher.java
¦  ¦  ¦     ¦  L--SpringStageLoader.java
¦  ¦  ¦     L--model
¦  ¦  ¦        +--dao
¦  ¦  ¦        ¦  L-ProductDao.java
¦  ¦  ¦        L--Product.java
¦  ¦  L--resources
¦  ¦     +--view
¦  ¦     ¦  +--fxml
¦  ¦     ¦  ¦  +--main.fxml
¦  ¦     ¦  ¦  +--productDetails.fxml
¦  ¦     ¦  ¦  +--productTable.fxml
¦  ¦     ¦  ¦  L--splash.fxml
¦  ¦     ¦  +--style
¦  ¦     ¦  L--image
¦  ¦     ¦     L--splash.png
¦  ¦     L--application-context.xml
¦  L--test
+------config.properties.xml
+------pom.xml
+------SpringFXExample.iml
L------test-db.xml
External Libraries

Исходники на GitHub. Там же файл PRODUCTS.sql для таблицы в БД.

Комментарии (23)


  1. Moxa
    12.02.2018 00:23

    за что нам такое счастье? неужели нет другого способа в базу ходить?


    1. ShinRa Автор
      12.02.2018 06:38

      Что вы имеете ввиду? Использование Spring, datasourse и конфига?


  1. PloadyFree
    12.02.2018 06:28

    Может, я невнимательно смотрел, но где здесь Spring Boot?


    1. ShinRa Автор
      12.02.2018 06:29

      Спасибо, мой косяк, как говорится, ляпнул не подумав. Исправил дабы не вводить людей в заблуждение


  1. fogone
    12.02.2018 06:39

    Суровая интеграция, бессмысленная и беспощадная.


  1. tamsus
    12.02.2018 06:59

    Ну для начала у Вас не Spring Boot, а обычный Spring с xml конфигурациями. Boot позволяет как раз таки избегать application-context.xml и так далее. Потом, SQL запросы в коде? Почему бы не использовать JPA и Hibernate для таких вещей?


    1. ShinRa Автор
      12.02.2018 07:00

      К сожалению к Hibernate и JPA не удалось пока прикоснутся.


    1. AstarothAst
      12.02.2018 11:41

      Да бросьте, если запрос не меняется, да к тому же достаточно элементарен, то почему бы не положить его в код ровно там, где он применяется, и где его будет ожидать увидеть тот, кто код читает? jpa, hibernate, гвоздь, микроскоп…


      1. tamsus
        12.02.2018 14:38

        Почему же с таким успехом не написать обычный метод, к примеру findOneByName, без SQL да в интерфейсе, наследуемом от JpaRepository и поместить его на сервисный слой? Что мешает понять этот код и найти его там, где ожидается?


        1. AstarothAst
          13.02.2018 09:56

          Ну, нашли вы этот код, а он читает данные из БД, а у вас проблема как раз в том, что возвращается что-то не то. В итоге все равно вы обречены спуститься на уровень базы, данных и селектов. JPA это абстракция, и как любая абстракция имеет положительные и отрицательные эффекты. В случае таких вот элементарных запросов, имхо, отрицательных эффектов гораздо больше, чем положительных. Запрос + rowmapper — почему бы нет?


  1. Lure_of_Chaos
    12.02.2018 08:42

    Слишком много кода для слишком малых возможностей и гибкости.
    Я делал подобное, в зависимости от потребностей пришлось написать 2-5 классов, которые потом не нужно трогать.

    Писать абстрактный класс контроллера с необходимостью его расширять только чтобы был доступен спринговый контекст?
    До сих пор спринг конфигурируется через xml?
    … вот счастье-то.


    1. ShinRa Автор
      12.02.2018 08:55

      Вы хотите сказать, что конфигурация через xml умерла?


      1. AstarothAst
        12.02.2018 09:16

        И давно.


  1. gotozero
    12.02.2018 09:05

    Есть уже проекты которые делают такое из коробки.
    github.com/roskenet/springboot-javafx-support


  1. PqDn
    12.02.2018 09:44
    +1

    Щас рекомендуется для спринг приложении использовать спринг бут. Во первых легче, во вторых некоторая гарантия, что не будет конфликтов версий, В третих не надо плагины самому прописывать на компилятор и на сингл джар
    Вот дока на последнею версию docs.spring.io/spring-boot/docs/2.0.0.RC1/reference/htmlsingle


    1. APXEOLOG
      12.02.2018 10:55

      Spring Boot это этакая сборная солянка для веб-сервера. Автору веб-сервер не нужен, ему видимо нужен DI и различные плюшки спринга. В таком случае лучше просто добавить те модули, которые нужны


      1. PqDn
        12.02.2018 12:27

        Ну спринг бут можно так и настроить


  1. aol-nnov
    12.02.2018 11:21

    Spring Boot это этакая сборная солянка для веб-сервера.

    Нет.


    "In Spring Boot, to create a non-web application, implement CommandLineRunner and override the run method"


  1. Mogaba
    12.02.2018 16:08

    <fx:include> позволяет делать инъекцию дочернего контроллера:

    main.fxml

    <fx:include source="productTable.fxml" fx:id="table" />
    

    MainController.java
    @FXML
    private ProductTableController tableController;
    


    1. ShinRa Автор
      12.02.2018 16:09

      Не совсем понял, либо тут ошибочка и fx:id должен быть равен tableController?


      1. Mogaba
        12.02.2018 17:22

        Имя поля для контроллера должно быть составлено из значения атрибута fx:id элемента <fx:include> (в данном случае «table») с добавлением слова «Controller». Когда лоадер видит такое поле (с аннотацией @FXML, естественно), он инжектит в него контроллер, который указан в include-файле.


  1. fspare
    12.02.2018 16:20

    Поправьте меня если я не прав, но насколько это хорошее решение делать толстого клиента?

    Может быть было бы резоннее сделать слой который ходит в базу данных не частью JavaFX приложения, а частью веб сервиса который предоставлял бы эту функциональность? А для JavaFX приложения использовать нечто более легковесное, чем Spring? Например, Google Guice или что-то другое для Dependency Injection?

    Просто я пытался использовать Spring для десктопных приложений и пришел к выводу, что он довольно-таки тяжелый(именно стадия инициализации занимает много времени да и куча библиотек за ним тянется из-за чего конечный разме jar файла получается довольно-таки большим, да и памяти отъедается прилично на ПК пользователя) и для десктопа именно я его бы не использовал, хотя на стороне сервера это решение себя хорошо зарекомендовало.

    Все вышесказанное — мое ИМХО — было бы интересно услышать мнение других сторон.


  1. MisterParser
    13.02.2018 15:56

    Спасибо за статью! Как раз позавчера писал своё первое JavaFX приложение и использовал Spring для удобной работы с настройками пользователя. Почерпнул идеи из вашего примера и может быть даже напишу статью о своей разработке.