[Из песочницы] И на улицу JavaFX тоже придет Spring

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

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

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

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

kmg20nuz-lmhopxdmw1eeb73kmi.png

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

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

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

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

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

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

Функционал

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

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

main.fxml





	


productTable.fxml






	
		
		
		
		
	



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 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));
	}
}


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;
    }
}


Запускаем, чтобы убедится, что все работает.

xh3mf-dyly8xh6pw0wyuv1-zsru.png

Первая сборка


Пробуем собрать 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
                    
                
            
        
    


pom.xml


	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 поэтому запускаю приложения таким образом.

jw92anfiuggf-z0silip5jjvhrq.png

Кстати, чтобы скрыть терминал вызываем не 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
    


pom.xml


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

application-context.xml



	

	

	
		
		
		
		
	



Напишем абстрактный контроллер 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:

osj1_e20i2x9xafh1y-8vfzrn0a.png

Но загрузка данных у нас еще не реазилована как и отображение подробной информации о продукте.

Реализуем класс 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 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





    
        
            
                
                
            
            
                
                
                
                
                
                
            
            
    




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 row = new TableRow<>();
	row.setOnMouseClicked(event -> {
		if (event.getClickCount() == 2 && (!row.isEmpty())) {
			ProductDetailsModalStage stage = new ProductDetailsModalStage();
			stage.showDetails(row.getItem());
		}
	});
	return row;
});


Запускаем и видим результат:

isgggpf0bromo37scq1zwmuf0bc.png

Проблема долгой инициализация контекста


А вот еще одна интересная тема. Предположим что ваш контекст долго инициализируется, в этом случае, пользователь не поймет идет ли запуск приложения или нет. Поэтому для наглядности необходимо добавить заставку, во время инициализации контекста.
Сцену с заставкой будем писать обычным способом через FXMLLoader. Т.к. контекст как раз в этом время будет инициализироваться. Инициализацию тяжелого контекста сымитируем вызовом Thread.sleep (10000);

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

splash.fxml






	
		
	



Измененный 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
_4sspar8y15iokkb_jmhmdwv3si.gif


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


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

pom.xml — окончательная версия


    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 для таблицы в БД.

© Habrahabr.ru