Статьи

Управление данными с JavaFX и GraniteDS

JavaFX 2 — это новая, но очень привлекательная технология для создания клиентских корпоративных приложений. Еще более привлекательно то, что он уже включен в JRE начиная с Java 1.7.0_07.

После выпуска GraniteDS для JavaFX 2 приведена версия учебного руководства по управлению данными для JavaFX. То, что мы называем управлением данными, представляет собой набор функций, которые позволяют легко работать с объектами данных на клиенте и упрощают прокладку каналов между клиентом и сервером.

Проект размещен на GitHub по адресу https://github.com/graniteds/shop-admin-javafx  и требует Maven 3.x для сборки. Также необходимо использовать JDK 1.7.0_07 или лучше, чтобы JavaFX уже был установлен в вашей среде выполнения Java. Возможно, вам придется изменить переменную среды JAVA_HOME, если у вас установлено много JDK.

Может быть полезно следовать руководству по установке Eclipse, в идеале с установленными плагинами Git и M2E (для автоматического обновления зависимостей Maven). Spring Tool Suite — хороший выбор, тем более что мы будем использовать среду Spring как на сервере, так и на клиенте.

Для нетерпеливых, вы можете просто клонировать проект и построить его.

С консоли введите следующее:

git clone git://github.com/graniteds/shop-admin-javafx.git 
cd shop-admin-javafx 
mvn install

Чтобы запустить встроенный сервер Jetty, введите:

cd webapp
mvn jetty:run

Чтобы запустить клиентское приложение JavaFX, откройте вторую консоль и введите следующее:

cd shop-admin-javafx/javafx
java -jar target/shop-admin-javafx.jar

Когда приложение появится, просто войдите в систему как admin / admin. Это простой пример CRUD, который позволяет создавать, модифицировать и искать виноградники и производимые ими вина.

Приложение не имеет причудливого дизайна и графики, но его цель — просто продемонстрировать следующие функции:

  • Базовый CRUD с репозиторием Spring Data JPA
  • Поддержка отложенной загрузки ассоциаций JPA x-to-many
  • Грязная проверка / Отмена
  • Проверка клиента с интеграцией API Bean Validation
  • Безопасность (аутентификация, авторизация)
  • Передача данных в реальном времени

Для простоты у этого руководства есть несколько известных ограничений :

  • Поиск чувствителен к регистру
  • Ошибки валидации отображаются в виде простых красных рамок без соответствующего сообщения
  • Кнопка «Сохранить» неправильно включена, когда что-то вводится в поисковой строке

Каждый шаг в руководстве соответствует тегу в проекте GitHub, поэтому вы можете легко увидеть, что было изменено на каждом шаге.

Итак, теперь давайте начнем с нуля:

Шаг 1: Создайте проект из архетипа Maven

Если вы клонировали проект из GitHub, просто сделайте это:

git checkout step1

Этот первый шаг был просто создан с помощью следующей команды:

mvn archetype:generate
-DarchetypeGroupId=org.graniteds.archetypes
-DarchetypeArtifactId=org.graniteds-tide-spring-jpa
-DarchetypeVersion=2.0.0.M1
-DgroupId=com.wineshop
-DartifactId=shop-admin-javafx
-Dversion=1.0-SNAPSHOT
-Dpackage=com.wineshop

Если вы посмотрите на результат, архетип создал проект Maven с тремя модулями:

  • Серверный модуль Spring
  • Модуль веб-приложения
  • Клиентский модуль JavaFX

Серверный модуль Spring, сгенерированный архетипом, включает в себя подходящий JPA-дескриптор persistence.xml и базовую модель и службу домена (Welcome + WelcomeService).

Модуль Webapp включает в себя необходимую конфигурацию Spring для JPA с простым источником данных HSQL, Spring Security и GraniteDS. Он также включает в себя файл web.xml, настроенный с помощью сервлета-диспетчера Spring, и конфигурации GraniteDS Comet и WebSocket для Jetty 8. Файл pom.xml этого модуля также включает необходимую конфигурацию для запуска встроенного Jetty 8.

Наконец, модуль JavaFX включает файл pom.xml с конфигурацией для генерации клиентской модели JavaFX из модели JPA с задачей Ant GraniteDS gfx и для упаковки приложения в виде исполняемого файла jar. Он также включает в себя скелетное клиентское приложение JavaFX с необходимыми конфигурациями Spring и GraniteDS.

Сгенерированное приложение по умолчанию представляет собой слегка улучшенный Hello World, в котором отправляемые имена хранятся в базе данных и отправляются всем клиентам.

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

Чтобы запустить встроенный сервер Jetty, введите:

cd webapp
mvn jetty:run

Чтобы запустить клиентское приложение JavaFX, откройте другую консоль и введите следующее:

cd shop-admin-javafx/javafx
java -jar target/shop-admin-javafx.jar

Вы можете войти через admin / admin или user / user.

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

Тег step1b содержит очищенную версию проекта, которую вы можете импортировать в Eclipse (фактически вы получите 4 проекта).

git checkout step1b

Шаг 2: Реализация базовой функциональности CRUD

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

Серверное приложение

Сначала мы должны построить серверное приложение. Для удобства и поскольку это не учебник по серверу, мы будем использовать новую поддержку репозиториев Spring Data JPA и просто определим модель JPA.

ком / винный погребок / организаций / Vineyard.java:

@Entity
public class Vineyard extends AbstractEntity {
    private static final long serialVersionUID = 1L;
    @Basic
    private String name;
    @Embedded
    private Address address = new Address();
    @OneToMany(cascade=CascadeType.ALL, mappedBy="vineyard",
        orphanRemoval=true)
    private Set wines;
    public String getName() {
        return name;
    }
    public void setName(String nom) {
        this.name = nom;
    }
    public Address getAddress() {
        return address;
    }
    public void setAddress(Address address) {
        this.address = address;
    }
    public Set getWines() {
        return wines;
    }
    public void setWines(Set wines) {
        this.wines = wines;
    }
}

ком / винный погребок / организаций / Wine.java:

@Entity
public class Wine extends AbstractEntity {
    private static final long serialVersionUID = 1L;
    public static enum Type {
        RED,
        WHITE,
        ROSE
    }
    @ManyToOne
    private Vineyard vineyard;
    @Basic
    private String name;
    @Basic
    private Integer year;
    @Enumerated(EnumType.STRING)
    private Type type;
    public Vineyard getVineyard() {
        return vineyard;
    }
    public void setVineyard(Vineyard vineyard) {
        this.vineyard = vineyard;
    }
    public Integer getYear() {
        return year;
    }
    public void setYear(Integer annee) {
        this.year = annee;
    }
    public String getName() {
        return name;
    }
    public void setName(String nom) {
        this.name = nom;
    }
    public Type getType() {
        return type;
    }
    public void setType(Type type) {
        this.type = type;
    }
}

ком / винный погребок / организаций / Address.java:

@Embeddable
public class Address implements Serializable {
    private static final long serialVersionUID = 1L;
    @Basic
    private String address;
    public String getAddress() {
        return address;
    }
    public void setAddress(String adresse) {
        this.address = adresse;
    }
}

Обратите внимание, что наши сущности расширяют класс AbstractEntity, предоставляемый архетипом. AbstractEntity просто имеет длинный идентификатор, поле длинной версии и поле uid. В основном мы заменим его на AbstractPersistable из Spring Data, но мы должны сохранить его из-за свойства uid. Поле uid является глобальным постоянным идентификатором, который должен быть уникальным среди всех клиентских и серверных уровней и поэтому сохраняется в базе данных (но не обязательно в ключе базы данных). Платформа управления данными GraniteDS может работать без определенного поля uid, но с некоторыми ограничениями (дальнейшие пояснения см. В документации).

Далее мы собираемся определить репозиторий Spring Data, но сначала мы должны добавить Spring Data в наши зависимости в java / pom.xml.

<dependency>
  <groupId>org.springframework.data</groupId>
  <artifactId>spring-data-jpa</artifactId>
  <version>1.2.0.RELEASE</version>
</dependency>

Затем мы меняем AbstractEntity так, чтобы он расширял AbstractPersistable (не обязательно полезно, но больше для Spring Data-esque):

@MappedSuperclass
@EntityListeners({AbstractEntity.AbstractEntityListener.class, DataPublishListener.class})
public abstract class AbstractEntity extends AbstractPersistable {
    private static final long serialVersionUID = 1L;
  
    /* "UUID" and "UID" are Oracle reserved keywords -> "ENTITY_UID" */
    @Column(name="ENTITY_UID", unique=true, nullable=false, updatable=false, length=36)
    private String uid;
  
    @Version
    private Integer version;
  
    public Integer getVersion() {
        return version;
    }
  
    @Override
    public boolean equals(Object o) {
        return (o == this || (o instanceof AbstractEntity
            && uid().equals(((AbstractEntity)o).uid())));
    }
  
    @Override
    public int hashCode() {
        return uid().hashCode();
    }
  
    public static class AbstractEntityListener {
        @PrePersist
        public void onPrePersist(AbstractEntity abstractEntity) {
            abstractEntity.uid();
        }
    }
  
    private String uid() {
        if (uid == null)
            uid = UUID.randomUUID().toString();
        return uid;
    }
}

И определить интерфейс репозитория:

ком / винный погребок / услуги / VineyardRepository.java

@RemoteDestination
@DataEnabled
public interface VineyardRepository extends FilterableJpaRepository {
}

Как вы можете видеть, этот репозиторий расширяет специфичный для GraniteDS FilterableJpaRepository, который является расширением стандартного Spring JpaRepository, который добавляет дополнительный метод поиска findByFilter. Этот findByFilter является своего рода реализацией поиска на примере. Это просто для удобства, и вы можете вручную реализовать свой собственный метод поиска, если вы не хотите полагаться на GraniteDS на сервере (мы могли бы также рассмотреть возможность добавления этого кода в Spring Data JPA или полностью отказаться от него, если Spring Data поставляется с чем-то похожим в будущем выпуске).

Аннотация @RemoteDestination указывает, что репозиторий включен удаленно, то есть будет доступен через наш клиент GraniteDS. В общем, это не то, что вы бы сделали по очевидным причинам безопасности (и, например, создали бы Службу перед хранилищем), но мы хотим, чтобы здесь все было просто.

Аннотация @DataEnabled указывает, что GraniteDS должен отслеживать обновления данных JPA, происходящие во время выполнения методов службы, и распространять их на клиент.

Наконец, мы регистрируем наш репозиторий в webapp / src / main / webapp / WEB-INF / spring / app-jpa-config.xml:

<jpa:repositories
    base-package="com.wineshop.services"
    factory-class="org.granite.tide.spring.data.FilterableJpaRepositoryFactoryBean"/>

В проекте git есть тег step2a, чтобы вы могли увидеть, что было изменено с шага 1.

git checkout step2a

Вот сравнение для просмотра на GitHub: https://github.com/graniteds/shop-admin-javafx/compare/step1b…step2a

Теперь вы можете перестроить и перезапустить сервер Jetty:

mvn install
cd webapp
mvn jetty:run

Возможно, вы заметили, что генератор gfx запускается как часть сборки maven. Если вы посмотрите на модуль JavaFX, вы можете увидеть некоторые недавно созданные классы для клиентских сущностей и клиентский прокси для репозитория Spring Data в пакетах com.wineshop.client.entities и com.wineshop.client.services. Это будет полезно для следующей части, посвященной разработке клиента.

Клиент JavaFX

Теперь самая интересная часть, клиент JavaFX. Чтобы упростить задачу, мы собираемся сохранить некоторые элементы скелетного приложения, главный экран и экран входа в систему. В основном мы будем работать на экране Home.fxml и контроллере Home.java.

Наш пользовательский интерфейс будет очень простым: табличное представление для отображения списка виноградников и форма для их создания или изменения.

Представление таблицы

Сначала мы добавим таблицу, вот соответствующая часть Home.fxml:

<!-- Search Bar -->
<HBox spacing="10">
    <children>
        <TextField fx:id="fieldSearch" prefColumnCount="20"
            onAction="#search"/>
        <Button text="Search" onAction="#search"/>
    </children>
</HBox>




<TableView fx:id="tableVineyards" layoutX="10" layoutY="40"
    items="$vineyards">
    <columns>
        <TableColumn fx:id="columnName" text="Name" 
            prefWidth="320" sortable="true">
            <cellValueFactory>
                <PropertyValueFactory property="name"/>
            </cellValueFactory>
        </TableColumn> 
    </columns>
</TableView>

Ничего особенного, мы просто определяем элемент управления табличным представлением с одним столбцом, сопоставленным со свойством name нашего объекта Vineyard, и панелью поиска с полем поиска и кнопкой поиска.

Источник данных для табличного представления определяется как $ виноградники, что обычно означает, что мы должны привязать его к коллекции в контроллере. Если вы посмотрите на класс Main, полученный из архетипа, он использует пользовательский TideFXMLLoader, который автоматически выставляет все bean-компоненты в контексте Spring как переменные FXML. Поэтому мы создаем бин Spring с именем виноградники в конфигурации приложения в Main.java:

@Bean
public PagedQuery vineyards(ServerSession serverSession) throws Exception {
    PagedQuery vineyards = new PagedQuery(serverSession);
    vineyards.setMethodName("findByFilter");
    vineyards.setMaxResults(25);
    vineyards.setRemoteComponentClass(VineyardRepository.class);
    vineyards.setElementClass(Vineyard.class);
    vineyards.setFilterClass(Vineyard.class);
    return vineyards;
}

Мы используем компонент GraniteDS PagedQuery, который связывает список наблюдаемых клиентов (сам по себе) с методом поиска сервера, здесь это метод findByFilter из репозитория Spring Data. Этот компонент также автоматически обрабатывает страницы, поэтому мы можем определить свойство maxResults, которое определяет максимальное количество элементов, которые будут получены с сервера при каждом удаленном вызове. PagedQuery также обрабатывает удаленную фильтрацию и сортировку, поэтому нам нужно подключить его к представлению при инициализации контроллера Home.java:

@Inject
private PagedQuery vineyards;

PagedQuery — это bean-компонент Spring, также контроллер Home, поэтому мы можем внедрять одно в другое по мере необходимости.

@Override
public void initialize(URL url, ResourceBundle rb) {
    vineyards.getFilter().nameProperty().bindBidirectional(fieldSearch.textProperty());
    vineyards.setSort(new TableViewSort(tableVineyards, Vineyard.class));
    ...
}

Эти объявления связывают свойство имени фильтра с полем поиска и определяют адаптер сортировки между элементом управления TableView и компонентом PagedQuery. Наконец, мы определяем действие поиска на контроллере, ему просто нужно вызвать метод refresh для компонента PagedQuery, который вызовет удаленный вызов для получения актуального набора данных:

@FXML
private void search(ActionEvent event) {
    vineyards.refresh();
}

С этой довольно простой настройкой у нас есть полностью функциональное табличное представление на нашем удаленном объекте Vineyard.

Форма редактирования

Вот описание формы в Home.fxml:

<Label fx:id="labelFormVineyard" text="Create vineyard"/>
<GridPane fx:id="formVineyard" hgap="4" vgap="4">
    <children>
        <Label text="Name" 
            GridPane.columnIndex="1" GridPane.rowIndex="1"/>
        <TextField fx:id="fieldName" 
            GridPane.columnIndex="2" GridPane.rowIndex="1"/>
       
        <Label text="Address" 
            GridPane.columnIndex="1" GridPane.rowIndex="2"/>
        <TextField fx:id="fieldAddress" 
            GridPane.columnIndex="2" GridPane.rowIndex="2"/>
    </children>
</GridPane>
<!-- Button Bar -->
<HBox spacing="10">
    <children>
        <Button fx:id="buttonSave" text="Save" 
            onAction="#save"/>
        <Button fx:id="buttonDelete" text="Delete" 
            onAction="#delete" visible="false"/>
        <Button fx:id="buttonCancel" text="Cancel"
            onAction="#cancel" visible="false"/>
    </children>
</HBox>

Еще раз, ничего особенного. Теперь мы должны подключить его к контроллеру, поэтому сначала добавим переменную экземпляра виноградника, в которой будет храниться отредактированный объект.

@FXML
private Vineyard vineyard;

Теперь мы должны правильно управлять этой переменной. Мы сделаем это в методе, который свяжет форму с существующим экземпляром или создаст новый:

private void select(Vineyard vineyard) {
    if (vineyard == this.vineyard && this.vineyard != null)
        return;
   
    if (this.vineyard != null) {
        fieldName.textProperty()
            .unbindBidirectional(this.vineyard.nameProperty());
        fieldAddress.textProperty()
            .unbindBidirectional(this.vineyard.getAddress().addressProperty());
    }
   
    if (vineyard != null)
        this.vineyard = vineyard;
    else {
        this.vineyard = new Vineyard();
        this.vineyard.setName("");
        this.vineyard.setAddress(new Address());
        this.vineyard.getAddress().setAddress("");
    }
   
    fieldName.textProperty()
        .bindBidirectional(this.vineyard.nameProperty());
    fieldAddress.textProperty()
        .bindBidirectional(this.vineyard.getAddress().addressProperty());
    labelFormVineyard.setText(vineyard != null
        ? "Edit vineyard" : "Create vineyard");
    buttonDelete.setVisible(vineyard != null);
    buttonCancel.setVisible(vineyard != null);
}

Кроме того, этот метод изменяет заголовок формы на «редактировать» или «создавать» и делает видимыми кнопки удаления и отмены при работе с существующим экземпляром. Не то чтобы мы широко использовали двунаправленную привязку данных.

Мы просто вызовем этот метод при инициализации экрана и определим слушателя выбора в таблице, чтобы связать выбор с формой:

public void initialize(URL url, ResourceBundle rb) {
    ...
    select(null);
    tableVineyards.getSelectionModel().selectedItemProperty()
        .addListener(new ChangeListener() {
        @Override
        public void changed(ObservableValue<!--? extends Vineyard--> property,
            Vineyard oldSelection, Vineyard newSelection) {
            select(newSelection);
        }
    });
}

Мы почти закончили, наконец, мы должны определить действия трех кнопок:

@FXML
private void save(ActionEvent event) {
    final boolean isNew = vineyard.getId() == null;
    vineyardRepository.save(vineyard,
        new SimpleTideResponder() {
            @Override
            public void result(TideResultEvent tre) {
                if (isNew)
                    select(null);
                else
                    tableVineyards.getSelectionModel()
                        .clearSelection();
            }
           
            @Override
            public void fault(TideFaultEvent tfe) {
                System.out.println("Error: "
                    + tfe.getFault().getFaultDescription());
            }
        }
    );
}

По сути, мы сохраняем сущность, вызывая удаленный репозиторий Spring Data, который мы внедрили в Spring. Обратите внимание, что подходящий подход был сгенерирован для репозитория и определен как bean-компонент Spring. При успешном возвращении мы либо создаем новую пустую сущность с помощью select (null), либо просто очищаем выбор таблицы, что впоследствии очистит форму и сбросит ее в режиме создания.

Вы, вероятно, заметите, что удаленный вызов обрабатывается респондентом асинхронно. Это очень важно, поэтому поток пользовательского интерфейса не обрабатывается, пока сервер обрабатывает действие.

Действие удаления очень похоже:

@FXML
private void delete(ActionEvent event) {
    vineyardRepository.delete(vineyard.getId(),
        new SimpleTideResponder() {
            @Override
            public void result(TideResultEvent tre) {
                tableVineyards.getSelectionModel().clearSelection();
            }
        }
    );
}

Операция отмены пока очень проста:

@FXML
private void cancel(ActionEvent event) {
    tableVineyards.getSelectionModel().clearSelection();
}

Вы, возможно, заметили, что мы вызываем удаленный репозиторий и просто ничего не делаем с фактическим результатом операции. На самом деле нам это не нужно, потому что GraniteDS прослушивает JPA-события сохранения / обновления / удаления и передает их клиенту как события приложения Spring. PagedQuery автоматически прослушивает эти клиентские события и обновляет себя при необходимости. Конечно, если вам нужен доступ к объектам результата, вы все равно можете сделать это в обработчике результатов респондента.

Первый шаг клиентского приложения теперь готов. Вы можете получить его с помощью тега step2 в репозитории git:

git checkout step2

Вот пример сравнения на GitHub: https://github.com/graniteds/shop-admin-javafx/compare/step2a…step2

Теперь вы можете собрать и запустить его, предполагая, что ваш сервер Jetty все еще работает в другой консоли:

cd javafx
mvn clean install
java -jar target/shop-admin-javafx.jar

Шаг 3: Поддержка JPA ленивых ассоциаций

Если вы еще не уснули, возможно, вы заметили, что мы просто не позаботились об ассоциации вин. Он никогда не заполняется, не сохраняется и не отображается, и это не создает никаких проблем для приложения. GraniteDS действительно способен правильно сериализовать и десериализовать все ленивые ассоциации, поэтому вам просто не нужно заботиться о них. То, что лениво на сервере, остается ленивым на клиенте.

Однако сейчас мы хотели бы отредактировать список вин для наших виноградников. Сначала мы добавим представление списка в форму редактирования:

<Label text="Wines" GridPane.columnIndex="1" GridPane.rowIndex="3" />
<HBox spacing="5" GridPane.columnIndex="2" GridPane.rowIndex="3">
    <children>
        <ListView fx:id="listWines" maxHeight="150"/>
       
        <VBox spacing="5">
            <children>
                <Button text="+" onAction="#addWine"/>
                <Button text="-" onAction="#removeWine"/>
            </children>
        </VBox>
    </children>
</HBox>

Теперь в контроллере мы должны связать список вин текущего отредактированного виноградника с этим списком:

@FXML
private ListView listWines;
private void select(Vineyard vineyard) {
    ...
    listWines.setItems(this.vineyard.getWines());
    ...
}

And add the actions to add and remove a wine from the list:

@FXML
private void addWine(ActionEvent event) {
    Wine wine = new Wine();
    wine.setVineyard(this.vineyard);
    wine.setName("");
    wine.setYear(Calendar.getInstance().get(Calendar.YEAR)-3);
    wine.setType(Wine$Type.RED);
    this.vineyard.getWines().add(wine);
}
@FXML
private void removeWine(ActionEvent event) {
    if (!listWines.getSelectionModel().isEmpty())
        this.vineyard.getWines().remove(listWines.getSelectionModel().getSelectedIndex());
}

Finally we have to setup the list to display and edit the properties of the Wine objects:

listWines.setCellFactory(new Callback, ListCell>() {
    public ListCell call(ListView listView) {
        return new WineListCell();
    }
});
private static class WineListCell extends ListCell {
    private ChoiceTypeListener choiceTypeListener = null;
    protected void updateItem(Wine wine, boolean empty) {
        Wine oldWine = getItem();
        if (oldWine != null && wine == null) {
            HBox hbox = (HBox)getGraphic();
            TextField fieldName = (TextField)hbox.getChildren().get(0);
            fieldName.textProperty()
                .unbindBidirectional(getItem().nameProperty());
            TextField fieldYear = (TextField)hbox.getChildren().get(1);
            fieldYear.textProperty()
                .unbindBidirectional(getItem().yearProperty());
            getItem().typeProperty().unbind();
            getItem().typeProperty().removeListener(choiceTypeListener);
            choiceTypeListener = null;
            setGraphic(null);
        }
        super.updateItem(wine, empty);
        if (wine != null && wine != oldWine) {
            TextField fieldName = new TextField();
            fieldName.textProperty()
                .bindBidirectional(wine.nameProperty());
            TextField fieldYear = new TextField();
            fieldYear.setPrefWidth(40);
            fieldYear.textProperty()
                .bindBidirectional(wine.yearProperty(), new IntegerStringConverter());
            ChoiceBox choiceType = new ChoiceBox(
                FXCollections.observableArrayList(Wine$Type.values())
            );
            choiceType.getSelectionModel()
                .select(getItem().getType());
            getItem().typeProperty()
                .bind(choiceType.getSelectionModel().selectedItemProperty());
            choiceTypeListener = new ChoiceTypeListener(choiceType);
            getItem().typeProperty()
                .addListener(choiceTypeListener);
            HBox hbox = new HBox();
            hbox.setSpacing(5.0);
            hbox.getChildren().add(fieldName);
            hbox.getChildren().add(fieldYear);
            hbox.getChildren().add(choiceType);
            setGraphic(hbox);
        }
    }
    private final static class ChoiceTypeListener
        implements ChangeListener {
        private ChoiceBox choiceBox;
        public ChoiceTypeListener(ChoiceBox choiceBox) {
            this.choiceBox = choiceBox;
        }
        @Override
        public void changed(ObservableValue<!--? extends Wine$Type--> property,
                Wine$Type oldValue, Wine$Type newValue) {
            choiceBox.getSelectionModel().select(newValue);
        }
    }
}

Ouch!

This cell implementation looks intimidating but in fact we simply create 3 text and choice fields for the values we want to edit in the Wine object.  Then we set bidirectional binding between each field and the corresponding property of the Wine class.  ChoiceBox is the most complex because we can’t bind directly from the selectedItem property (?),  so we have to define a change listener to achieve the same result.

There is nothing else to change, this is purely client code.  The persistence on the server will be ensured by the cascading options we have defined on the JPA entity.

Interestingly we don’t have to handle the loading of the collection,  Tide will lazily trigger a remote loading of the collection content when the content is first requested,  for example when a UI control tried to display the data.

As before, build and run:

git checkout step3

Compare view on GitHub: https://github.com/graniteds/shop-admin-javafx/compare/step2…step3

cd javafx
mvn clean install
java -jar target/shop-admin-javafx.jar

Step 4: Dirty Checking / Undo

If you have played with the application you may have noticed that using bidirectional bindings  leads to a weird behaviour. Even without saving your changes, the local objects are still modified  and keep the modifications made by the user.

To fix this, we can use the fact that GraniteDS tracks all updates made on the managed entities  and is able to easily restore the last known stable state of the objects (usually the last fetch  from the server).

We need to inject the local entity manager (EntityManager is a GraniteDS client component):

@Inject
private EntityManager entityManager;

And use it to restore the persistent state of the object when the user selects another element without saving:

private void select(Vineyard vineyard) {
    if (vineyard == this.vineyard && this.vineyard != null)
        return;
   
    if (this.vineyard != null) {
        fieldName.textProperty().unbindBidirectional(this.vineyard.nameProperty());
        fieldAddress.textProperty().unbindBidirectional(this.vineyard.getAddress().addressProperty());
        entityManager.resetEntity(this.vineyard);
    }
    ...
}

We can also enable or disable the ‘Save’ button depending on the fact that the user has modified  something or not. Tide provides the JavaFXDataManager component.

@Inject
private JavaFXDataManager dataManager;
buttonSave.disableProperty()
    .bind(Bindings.not(dataManager.dirtyProperty()));

If you try this, you will notice that it works fine when modifying existing data but not  with newly created elements. This is because these new elements are not known by the entity manager,  and thus not tracked by the dirty checking process.  To make this work, we have to merge the new entities in the client entity manager:

else {
    this.vineyard = new Vineyard();
    this.vineyard.setName("");
    this.vineyard.setAddress(new Address());
    this.vineyard.getAddress().setAddress("");
    entityManager.mergeExternalData(this.vineyard);
}

As before, there is a tag step4 on the git repository.

git checkout step4

Compare view on GitHub: https://github.com/graniteds/shop-admin-javafx/compare/step3…step4

cd javafx
mvn clean install
java -jar target/shop-admin-javafx.jar

Validation

We can now create, edit and search in our database.  We would now like to ensure that our data in consistent. The Bean Validation API is our friend and  as we are in Java on both sides we can use it on both the server JPA entities and on the client data objects.

Going back to the JPA model, we add a few validation annotations, here the Wine class:

@Basic
@Size(min=5, max=100,
    message="The name must contain between {min} and {max} characters")
private String name;




@Basic
@Min(value=1900,
    message="The year must be greater than {value}")
@Past
private Integer year;




@Enumerated(EnumType.STRING)
@NotNull
private Type type;

With these annotations we ensure that we cannot save incorrect values.  However we would also like to notify the user that something went wrong.  The brutal way would be to add a special handling of validation error in each and  every fault handler of the application.

A better way would be to define a global exception handler that will handle all validation faults.  Indeed GraniteDS already provides such a thing, and it takes server exceptions and propagates  them as events on the faulty property of the target data object.  We would then have to listen to these events and display some message or trigger  some notification to the user.

GraniteDS provides a special component, the FormValidator, that will further simplify our work.  We will simply bind it to the form containing the fields that we want to validate after  the entity to validate has been bound:

private FormValidator formValidator = new FormValidator();
...
private void select(Vineyard vineyard) {
    if (vineyard == this.vineyard && this.vineyard != null)
        return;
    formValidator.setForm(null);
    ...
    formValidator.setForm(formVineyard);
    ...
}

Finally we have to define a UI behaviour when a validation event occurs,  for example setting a red border on the faulty fields:

formVineyard.addEventHandler(ValidationResultEvent.ANY, new EventHandler() {
    @Override
    public void handle(ValidationResultEvent event) {
        if (event.getEventType() == ValidationResultEvent.INVALID)
            ((Node)event.getTarget()).setStyle("-fx-border-color: red");
        else if (event.getEventType() == ValidationResultEvent.VALID)
            ((Node)event.getTarget()).setStyle("-fx-border-color: null");
    }
});

Of course you could do whatever you want in this handler and apply a more suitable display,  for example display the error message in a tooltip or something.

If you test the application now, that should work fine, but the user is still able to submit  the save button even with invalid data. It’s easy to block the remote call:

@FXML
private void save(ActionEvent event) {
    if (!formValidator.validate(this.vineyard))
        return;
    ...
}

Tag step5 on the git repository.

git checkout step5

Compare view on GitHub: https://github.com/graniteds/shop-admin-javafx/compare/step4…step5

cd javafx
mvn clean install
java -jar target/shop-admin-javafx.jar

Step 6: Security

The application already has a basic security with the login page.  If you look how this works, you will find the component Identity which acts a gateway  between the client and the Spring Security framework.

Just as an exercise, we can add a logout button to our application:

<Button text="Logout" onAction="identity.logout(null)"/>

With a tiny bit of JavaScript (but we could also use a Java method in the controller),  we can call the logout method of identity.  As we have defined a change listener on the property loggedIn of identity in the Main class,  the current view will be destroyed and replaced by the login screen.

Компонент Identity также обрабатывает авторизации, поэтому при инициализации контроллера Home мы могли бы также решить, что только администраторы могут удалять объекты:

buttonDelete.disableProperty().bind(Bindings.not(identity.ifAllGranted("ROLE_ADMIN")));

identity.ifAllGranted (role) выполнит удаленный вызов для проверки прав пользователя в первый раз, а затем кеширует результат, чтобы последующие вызовы не требовали доступа к серверу. Кэш безопасности может быть аннулирован в любое время, чтобы вызвать перепроверку на сервере.

Отметьте step6 в репозитории git.

git checkout step6

Сравните представление на GitHub: <a href=»https://github.com/graniteds/shop-admin-javafx/compare/step5…step6″> https://github.com/graniteds/shop-admin-javafx /compare/step5…step6 </a>

cd javafx
mvn clean install
java -jar target/shop-admin-javafx.jar

Step 7: Real-time data push

Until now, we have used only one client at a time.  We are going to configure GraniteDS to push JPA data updates from the server to all connected clients.  We have almost already everything in place, the archetype has setup a complete configuration  with Jetty 8 websockets. When deploying on another container, you might need to change the  configuration to use the specific websocket support of Tomcat 7+ or GlassFish 3.1.2+,  or fallback to simple long-polling with the portable Servlet 3 implementation.

First we need to declare a messaging destination in the server configuration app-config.xml:

<graniteds:messaging-destination id="wineshopTopic" no-local="true" session-selector="true"/>

Declare the topic and enable automatic publishing on the Spring Data repository @DataEnabled annotation:

@RemoteDestination
@DataEnabled(topic="wineshopTopic", publish=PublishMode.ON_SUCCESS)
public interface VineyardRepository extends FilterableJpaRepository {
}

Declare a client DataObserver in the Spring configuration and subscribe this topic when the user logs in:

@Bean(initMethod="start", destroyMethod="stop")
public DataObserver wineshopTopic(ServerSession serverSession,
    EntityManager entityManager) {
    return new DataObserver("wineshopTopic", serverSession, entityManager);
}

We listen to the LOGIN and LOGOUT events in the Login controller to subscribe and unsubscribe the topic:

if (ServerSession.LOGIN.equals(event.getType())) {
    wineshopTopic.subscribe();
}
else if (ServerSession.LOGOUT.equals(event.getType())) {
    wineshopTopic.unsubscribe();
}
...

Now you can build the project and run two or more instances of the application in different consoles.  Changes made on a client should be propagated to all other subscribed clients.

Tag step7 on the git repository.

git checkout step7

Compare view on GitHub: https://github.com/graniteds/shop-admin-javafx/compare/step6…step7

cd javafx
mvn clean install
java -jar target/shop-admin-javafx.jar

Conclusion

This tutorial is now finished.

There are still a few more interesting features to show such as conflict detection and resolution  but the goal of this tutorial is to show the iterations needed to build a full featured  JavaFX application with the help of the GraniteDS JavaFX integration.

JavaFX is still a moving target and some parts of this tutorial might be simplified  with future releases of either JavaFX or GraniteDS, notably as the support for expression bindings in FXML improves.