Как подружить JavaFX и Spring Boot

  • Tutorial

Не так давно появился такой замечательный фреймворк как Spring Boot, без которого я уже не представляю себе разработку на Java. Освещая неосвещенное, хочу рассмотреть интеграцию Spring Boot и всех его «плюшек» с JavaFX 2.

Всех заинтересованных приглашаю под кат.

Преабмула


Spring Boot — прекрасный фреймворк, без которого невозможно обойтись попробовав лишь раз (рекомендую сделать это каждому!). Я хочу затронуть тему не совсем тривиальную для него, а именно — интеграцию с JavaFX. Ну и чтобы не было скучно, напишу простой справочник с блэкджеком и... подключением к БД.

Приступим


Конфигурация Maven проекта ничем не отличается от самого обычного приложения Spring Boot.

pom.xml
<dependencies>
    <!-- Spring Boot starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <!-- Spring Boot JPA - для работы с БД, очевидно -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- H2 БД -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Тесты -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

В файле настроек приложения также ничего особенного.

application.properties
# Параметры UI
ui.title = Spring Boot - JavaFX

# JMX нам не нужен, а его отключение позволит ускорить запуск
spring.jmx.enabled=false

# Настройки подключения к БД и JPA
spring.datasource.test-on-borrow=true
spring.datasource.validation-query=SELECT 1
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create

А вот с точкой входа в приложение все гораздо интересней!
Нам необходимо инициализировать Spring контекст и сделать это можно в двух разных местах:
  • Если Вам потребуется создать экземпляры типов Scene, Stage, открыть popup, то делать это нужно в методе start(), т.к. он вызывается в UI потоке.
  • В противном случае можете воспользоваться методом init() (как в примере ниже), который вызывается не в UI потоке перед вызовом метода start().

Напишем абстрактный класс следующего содержания:

AbstractJavaFxApplicationSupport.java
package ru.habrahabr;

import javafx.application.Application;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;

public abstract class AbstractJavaFxApplicationSupport extends Application {

    private static String[] savedArgs;

    protected ConfigurableApplicationContext context;

    @Override
    public void init() throws Exception {
        context = SpringApplication.run(getClass(), savedArgs);
        context.getAutowireCapableBeanFactory().autowireBean(this);
    }

    @Override
    public void stop() throws Exception {
        super.stop();
        context.close();
    }

    protected static void launchApp(Class<? extends AbstractJavaFxApplicationSupport> clazz, String[] args) {
        AbstractJavaFxApplicationSupport.savedArgs = args;
        Application.launch(clazz, args);
    }
}

Хочу обратить внимание на переопределенный метод init().
Именно на момент инициализации JavaFX мы запускаем инициализацию Spring контекста:

context = SpringApplication.run(getClass(), savedArgs);

Ну и следующей строкой заполняем текущий объект бинами:

context.getAutowireCapableBeanFactory().autowireBean(this);

Наследуя абстрактный класс описанный выше, укажем поведение нашего JavaFX приложения. На этом этапе мы уже можем использовать DI и все остальные «плюшки» спринга:

Application.java
@Lazy
@SpringBootApplication
public class Application extends AbstractJavaFxApplicationSupport {

    @Value("${ui.title:JavaFX приложение}")//
    private String windowTitle;

    @Autowired
    private ControllersConfig.View view;

    @Override
    public void start(Stage stage) throws Exception {
        stage.setTitle(windowTitle);
        stage.setScene(new Scene(view.getParent()));
        stage.setResizable(true);
        stage.centerOnScreen();
        stage.show();
    }

    public static void main(String[] args) {
        launchApp(Application.class, args);
    }

}

Ну и теперь к самому интересному.
JavaFX предоставляет возможность разделять код (controller) и представление (view), причем представление хранится в XML формате, в файле с расширением *.fxml. Для самой вьюхи есть прекрасный UI редактор — Scene Builder.
У меня получился примерно такой файл представления (view):

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

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="284.0" prefWidth="405.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" fx:controller="ru.habrahabr.ui.MainController">
   <children>
      <TableView fx:id="table" editable="true" prefHeight="200.0" prefWidth="405.0" tableMenuButtonVisible="true" AnchorPane.bottomAnchor="50.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
          <columnResizePolicy><TableView fx:constant="CONSTRAINED_RESIZE_POLICY" /></columnResizePolicy>
      </TableView>
      <HBox alignment="CENTER" layoutX="21.0" layoutY="207.0" prefHeight="50.0" prefWidth="300.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="10.0" AnchorPane.rightAnchor="10.0">
         <children>
            <TextField fx:id="txtName" promptText="Имя">
               <HBox.margin>
                  <Insets right="3.0" />
               </HBox.margin>
            </TextField>
            <TextField fx:id="txtPhone" promptText="Телефон">
               <HBox.margin>
                  <Insets right="3.0" />
               </HBox.margin>
            </TextField>
            <TextField fx:id="txtEmail" promptText="E-mail">
               <HBox.margin>
                  <Insets right="3.0" />
               </HBox.margin>
            </TextField>
            <Button minWidth="-Infinity" mnemonicParsing="false" onAction="#addContact" text="Добавить" />
         </children>
      </HBox>
   </children>
</AnchorPane>

Листинг этого файла трудночитаемый, но обратите внимание, что у корневого элемента указан атрибут fx:controller=«ru.habrahabr.ui.MainController». Он указывает на то, какой класс-контроллер использовать для этого компонента представления. А у вложенных элементов атрибут fx:id=«txtEmail» указывает на то, к какому полю контроллера делать инъекцию. Проблема как раз-таки в том, чтобы подружить инъекции контроллера от JavaFX (которые определяются аннотацией @FXML) и инъекции от спринга. Потому что, если использовать стандартный FXML загрузчик, то спринг не узнает о новом объекте-контроллере, и, соответственно, не сделает своих инъекций.
Напишем сам контроллер:

MainController.java
package ru.habrahabr.ui;

import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.PropertyValueFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import ru.habrahabr.entity.Contact;
import ru.habrahabr.service.ContactService;

import javax.annotation.PostConstruct;
import java.util.List;

public class MainController {

    // Инъекции Spring
    @Autowired private ContactService contactService;

    // Инъекции JavaFX
    @FXML private TableView<Contact> table;
    @FXML private TextField txtName;
    @FXML private TextField txtPhone;
    @FXML private TextField txtEmail;

    // Переменные
    private ObservableList<Contact> data;

    /**
     * Инициализация контроллера от JavaFX.
     * Метод вызывается после того как FXML загрузчик произвел инъекции полей.
     *
     * Обратите внимание, что имя метода <b>обязательно</b> должно быть "initialize",
     * в противном случае, метод не вызовется.
     *
     * Также на этом этапе еще отсутствуют бины спринга
     * и для инициализации лучше использовать метод,
     * описанный аннотацией @PostConstruct.
     * Который вызовется спрингом, после того, 
     * как им будут произведены все оставшиеся инъекции.
     * {@link MainController#init()}
     */
    @FXML
    public void initialize() {
    }

    /**
     * На этом этапе уже произведены все возможные инъекции.
     */
    @PostConstruct
    public void init() {
        List<Contact> contacts = contactService.findAll();
        data = FXCollections.observableArrayList(contacts);

        // Добавляем столбцы к таблице
        TableColumn<Contact, String> idColumn = new TableColumn<>("ID");
        idColumn.setCellValueFactory(new PropertyValueFactory<>("id"));

        TableColumn<Contact, String> nameColumn = new TableColumn<>("Имя");
        nameColumn.setCellValueFactory(new PropertyValueFactory<>("name"));

        TableColumn<Contact, String> phoneColumn = new TableColumn<>("Телефон");
        phoneColumn.setCellValueFactory(new PropertyValueFactory<>("phone"));

        TableColumn<Contact, String> emailColumn = new TableColumn<>("E-mail");
        emailColumn.setCellValueFactory(new PropertyValueFactory<>("email"));

        table.getColumns().setAll(idColumn, nameColumn, phoneColumn, emailColumn);

        // Добавляем данные в таблицу
        table.setItems(data);
    }

    /**
     * Метод, вызываемый при нажатии на кнопку "Добавить".
     * Привязан к кнопке в FXML файле представления.
     */
    @FXML
    public void addContact() {
        Contact contact = new Contact(txtName.getText(), txtPhone.getText(), txtEmail.getText());
        contactService.save(contact);
        data.add(contact);

        // чистим поля
        txtName.setText("");
        txtPhone.setText("");
        txtEmail.setText("");
    }
}

Осталось разобраться как у нас получилось заставить Spring произвести свои инъекции в незнакомом ему объекте. А секрет кроется в еще одном классе конфигурации Spring Boot:

ConfigurationControllers.java
@Configuration
public class ConfigurationControllers {

    @Bean(name = "mainView")
    public View getMainView() throws IOException {
        return loadView("fxml/main.fxml");
    }

    /**
     * Именно благодаря этому методу мы добавили контроллер в контекст спринга,
     * и заставили его произвести все необходимые инъекции.
     */
    @Bean
    public MainController getMainController() throws IOException {
        return (MainController) getMainView().getController();
    }

    /**
     * Самый обыкновенный способ использовать FXML загрузчик.
     * Как раз-таки на этом этапе будет создан объект-контроллер,
     * произведены все FXML инъекции и вызван метод инициализации контроллера.
     */
    protected View loadView(String url) throws IOException {
        InputStream fxmlStream = null;
        try {
            fxmlStream = getClass().getClassLoader().getResourceAsStream(url);
            FXMLLoader loader = new FXMLLoader();
            loader.load(fxmlStream);
            return new View(loader.getRoot(), loader.getController());
        } finally {
            if (fxmlStream != null) {
                fxmlStream.close();
            }
        }
    }

    /**
     * Класс - оболочка: контроллер мы обязаны указать в качестве бина,
     * а view - представление, нам предстоит использовать в точке входа {@link Application}.
     */
    public class View {
        private Parent view;
        private Object controller;

        public View(Parent view, Object controller) {
            this.view = view;
            this.controller = controller;
        }

        public Parent getView() {
            return view;
        }

        public void setView(Parent view) {
            this.view = view;
        }

        public Object getController() {
            return controller;
        }

        public void setController(Object controller) {
            this.controller = controller;
        }
    }

}

Вот и все, мы получили JavaFX приложение, интегрированное со Spring Boot, и открывающее все его колоссальные возможности.



Ссылка на исходники: github.com/ruslanys/sample-springboot-javafx

P.S. Буду счастлив, если кому-нибудь пригодится этот пост. Буду благодарен за подсказки и исправления.

Хочу выразить благодарность xolvo за то, что заставил меня попробовать Spring Boot и Stephen Chin, за эту прекрасную статью, описывающую все тонкости интеграции JavaFX и Spring Boot.

UPD. Спасибо ISergius за правку.
Поддержать автора
Поделиться публикацией

Комментарии 20

    +2
    Чем обусловлена обязательная инициализация в потоке UI?
    Возможно, стоит инициализировать в стороннем потоке, а потом обращатся вызовами?
      +2
      Хороший вопрос, ждал его.

      На самом деле концептуально Вы не обязаны инициализировать спринг контекст в UI потоке, но это избавит Вас от проблем. Так, например, если на момент инициализации бинов спринга Вы будете взаимодействовать с UI, Вы получите труднопредсказуемые ошибки. Если посмотреть на пример из статьи, то MainController#init() либо вывалится с исключениями, либо просто уйдет в дедлок. Именно поэтому я настоятельно рекомендую инициализировать спринг контекст в UI потоке. Ну а если какая-то инициализация требует дополнительного времени, то лучше точечно выносить ее в другой поток.
        0
        Почему deadlock? там же npe будет(.
        И вроде как обязаны использовать в том же потоке, поскольку все контроллеры и вьюхи должны быть уже в контексте Spring и Fx должен работать уже с проинициализированными в Spring объектами. Иначе просто не получится.
          0
          Почему NPE? В каком месте? Если в классе Application, то да. Но само собой, нужно следить за порядком инициализации, и в таком случае концептуально менять архитектуру описанного примера.

          А вот любое взаимодействие с UI не из UI потока — это ошибка априори, за исключением тех случаев, в которых взаимодействие с UI обернуто в Platform.runLater(...);.
            0
            Я наверное не правильно выразился, поскольку maincontroller вы получить без уже созданного контекста не сможете. Т.е. скорее всего если и обернете context = SpringApplication.run(getClass(), savedArgs); в другой поток, то
            getBean exception кинет при попытке отрисовать MainView. Кстати я не совсем понял, почему у вас и View не в контексте? У fx можно же factory переделать.
              0
              поскольку maincontroller вы получить без уже созданного контекста не сможете. Т.е. скорее всего если и обернете context = SpringApplication.run(getClass(), savedArgs); в другой поток, то
              getBean exception кинет при попытке отрисовать MainView

              Все верно. NPE скорее всего выбросится в Application классе, при вызове view.getParent(). Как я писал выше нужно следить за порядком инициализации, и в таком случае концептуально менять архитектуру описанного примера.

              Кстати я не совсем понял, почему у вас и View не в контексте? У fx можно же factory переделать.

              А вот тут я не понял вопрос.
          0
          А не может получится ситуации, когда спринг контекст просто повесит UI? (простите, у меня нет опыта в работе со Spting Boot)
            0
            Сам по себе спринг — исключено. А вот если при инициализации Ваших бинов происходят длительные операции в UI потоке, то приложению потребуется завершить для начала все операции, и только потом появится пользовательский интерфейс. Как писал выше — длительные операции инициализации лучше точечно переводить в другой поток.

            А вообще, обязательно попробуйте Spring Boot — лучший Java фреймворк современности, по моему мнению.
              0
              Раз уж вы тут, если не сложно, ответьте пожалуйста на нестолько вопросов:
              — Имеет ли смысл использовать Spring Boot, если у вас приложение не использует базы данных и DI ему совсем не нужен?
              — Существует ли возможность как-то делать конфигурационные класы (настройки которых бы автоматически сохранялись и загружались с файла)?
                0
                С удовольствием.

                Имеет ли смысл использовать Spring Boot, если у вас приложение не использует базы данных и DI ему совсем не нужен?

                Это Вам решать, интеграция со Spring Boot — дело, скорее, добровольное. Spring Framework сам по себе предоставляет массу полезных вещей, которые Вы можете внедрить в клиентское приложение. Например, тот же Spring Security. Но если Вы прекрасно обходитесь без него — почему нет?

                Существует ли возможность как-то делать конфигурационные класы (настройки которых бы автоматически сохранялись и загружались с файла)?

                Вам придется хранить файл настроек где-нибудь. Смотрите в сторону java.util.Properties, обычно используют именно его (методы load(..) и store(...)).
                  +1
                  Спасибо большое за ваши ответы
                    0
                    А Вам за вопросы.
        +1
        Я правильно понимаю, что все вью и их контроллеры придётся прописывать в конфигурацию руками?
          0
          Также хороший вопрос, спасибо.

          Если следовать примеру из статьи — да, но Вы можете написать базовый класс для контроллеров, с конструктором по умолчанию, который будет регистрировать контроллер в спринг контексте, и подгружать все необходимые бины. Смотрите в сторону org.springframework.context.ApplicationContextAware (статическим методом Вы будете получать контекст спринга). Но необходимо учесть, что спринг контекст, в данном случае, должен инициализироваться раньше, чем Вы соберетесь использовать FXML загрузчик.
          0
          Может глупый вопрос спрошу: а вообще-то на JavaFx кто-то сейчас пишет в промышленных масштабах?
            0
            Трудно сказать. Но я сейчас работаю в билетном агенстве, и мы успешно внедрили сервис билетопечати на JavaFX.
            +2
            Так же можно попробовать перейти с maven на gradle.
            Этапа адаптации достаточно короткий.
            Но работа с ним намного прозрачнее и удобнее (это IMHO, но слышал это от многих).
              0
              Кстати да, спасибо, обязательно попробую! Gradle — новый тренд сборки Java проектов, который, кстати, вполне перспективен.
              +1
              Нам обязательно необходимо инициализировать Spring контекст в UI потоке (в том же потоке, что и JavaFX).

              JavaDoc к методу javafx.application.Application.init():
              NOTE: This method is not called on the JavaFX Application Thread.

              Т.е. у вас все равно контекст спринга инициализируется не в потоке JavaFX UI.
                0
                Да, действительно, я допустил ошибку в примере (класс AbstractJavaFxApplicationSupport.java). Метод init() вызывается не в UI потоке, и если Вам потребуется создать экземпляры типов Scene, Stage, открыть popup, то вы получите ошибку на момент инициализации. Поэтому я рекомендую запускать инициализацию Spring контекста в методе start(), он как раз из UI вызывается.

                В текущем примере в процессе инициализации создаются лишь JavaFX компоненты, да инициализируются контроллеры, поэтому проблем не возникло.

                Большое спасибо за замечание, сейчас внесу правки.

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

                Самое читаемое