Учебник по JavaFX (Русский)

Часть 5: Хранение данных в XML

Screenshot AddressApp Part 5

Часть 5: Содержание

  • Хранение данных в XML
  • Использование компонента JavaFX FileChooser
  • Использование компонента JavaFX Menu
  • Сохранение пути к последнему открытому файлу в пользовательских настройках

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

Сохранение пользовательских настроек

Благодаря классу Preferences, Java позволяет сохранять некоторую информацию о состоянии приложения. В зависимости от операционной системы, Preferences сохраняются в различных местах (например, в файле реестра Windows).

Мы не можем использовать класс Preferences для сохранения всей адресной книги. Но он позволяет сохранять некоторые простые настройки приложения, например, путь к последнему открытому файлу. Имея эти данные, после перезапуска приложения мы всегда сможем восстанавливать состояние нашего приложения.

Следующие два метода обеспечивают сохранение и восстановление настроек нашего приложения. Добавьте их в конец класса MainApp:

MainApp.java
/**
 * Возвращает preference файла адресатов, то есть, последний открытый файл.
 * Этот preference считывается из реестра, специфичного для конкретной
 * операционной системы. Если preference не был найден, то возвращается null.
 * 
 * @return
 */
public File getPersonFilePath() {
    Preferences prefs = Preferences.userNodeForPackage(MainApp.class);
    String filePath = prefs.get("filePath", null);
    if (filePath != null) {
        return new File(filePath);
    } else {
        return null;
    }
}

/**
 * Задаёт путь текущему загруженному файлу. Этот путь сохраняется
 * в реестре, специфичном для конкретной операционной системы.
 * 
 * @param file - файл или null, чтобы удалить путь
 */
public void setPersonFilePath(File file) {
    Preferences prefs = Preferences.userNodeForPackage(MainApp.class);
    if (file != null) {
        prefs.put("filePath", file.getPath());

        // Обновление заглавия сцены.
        primaryStage.setTitle("AddressApp - " + file.getName());
    } else {
        prefs.remove("filePath");

        // Обновление заглавия сцены.
        primaryStage.setTitle("AddressApp");
    }
}

Хранение данных в XML

Почему именно XML?

Один из наиболее распространённых способов хранения данных, это использование баз данных. В то время, как данные, которые мы должны хранить, являются объектами, базы данных содержат их в виде реляционных данных (например, таблиц). Это называется объектно-реляционное рассогласование импендансов. Для того, чтобы привести наши объектные данные в соответствие с реляционными таблицами, требуется выполнить дополнительную работу. Существуют фреймворки, которые помогают приводить объектные данные в соответствие с реляционной базой данных (Hibernate - один из наиболее популярных), но чтобы начать их использовать, также необходимо проделать дополнительную работу и настройку.

Для нашей простой модели данных намного легче хранить данные в виде XML. Для этого мы будем использовать библиотеку JAXB (Java Architechture for XML Binding). Написав всего несколько строк кода, JAXB позволит нам сгенерировать примерно такой XML-файл:

Пример сгенерированного XML-файла
<persons>
    <person>
        <birthday>1999-02-21</birthday>
        <city>some city</city>
        <firstName>Hans</firstName>
        <lastName>Muster</lastName>
        <postalCode>1234</postalCode>
        <street>some street</street>
    </person>
    <person>
        <birthday>1999-02-21</birthday>
        <city>some city</city>
        <firstName>Anna</firstName>
        <lastName>Best</lastName>
        <postalCode>1234</postalCode>
        <street>some street</street>
    </person>
</persons>

Использование JAXB

Библиотека JAXB уже включена в JDK. Это значит, что никаких дополнительных библиотек подключать не придётся.

JAXB предоставляет две основные функции: способность к маршаллированию объектов Java в XML и обратную демаршализацию из xml-файла в объекты Java.

Для того, чтобы с помощью JAXB можно было выполнять подобные преобразования, нам необходимо подготовить нашу модель.

Подготовка класса-модели для JAXB

Данные, которые мы хотим сохранять, находятся в переменной personData класса MainApp. JAXB требует, чтобы внешний класс наших данных был отмечен аннотацией @XmlRootElement (только класс, поле этой аннотацией пометить нельзя). Типом переменной personData является ObservableList, а его мы не можем аннотировать. Для того, чтобы разрешить эту ситуацию, необходимо создать класс-обёртку, который будет использоваться исключительно для хранения списка адресатов, и который мы сможем аннотировать как @XmlRootElement.

Создайте в пакете ch.makery.address.model новый класс PersonListWrapper.

PersonListWrapper.java
package ch.makery.address.model;

import java.util.List;

import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;

/**
 * Вспомогательный класс для обёртывания списка адресатов.
 * Используется для сохранения списка адресатов в XML.
 * 
 * @author Marco Jakob
 */
@XmlRootElement(name = "persons")
public class PersonListWrapper {

    private List<Person> persons;

    @XmlElement(name = "person")
    public List<Person> getPersons() {
        return persons;
    }

    public void setPersons(List<Person> persons) {
        this.persons = persons;
    }
}

Обратите внимание на две аннотации:

  • @XmlRootElement определяет имя корневого элемента.
  • @XmlElement это необязательное имя, которое мы можем задать для элемента.

Чтение и запись данных с помощью JAXB

Сделаем наш класс MainApp ответственным за чтение и запись данных нашего приложения. Для этого добавьте в конец класса MainApp.java два метода:

/**
 * Загружает информацию об адресатах из указанного файла.
 * Текущая информация об адресатах будет заменена.
 * 
 * @param file
 */
public void loadPersonDataFromFile(File file) {
    try {
        JAXBContext context = JAXBContext
                .newInstance(PersonListWrapper.class);
        Unmarshaller um = context.createUnmarshaller();

        // Чтение XML из файла и демаршализация.
        PersonListWrapper wrapper = (PersonListWrapper) um.unmarshal(file);

        personData.clear();
        personData.addAll(wrapper.getPersons());

        // Сохраняем путь к файлу в реестре.
        setPersonFilePath(file);

    } catch (Exception e) { // catches ANY exception
        Alert alert = new Alert(AlertType.ERROR);
        alert.setTitle("Error");
        alert.setHeaderText("Could not load data");
        alert.setContentText("Could not load data from file:\n" + file.getPath());

        alert.showAndWait();
    }
}

/**
 * Сохраняет текущую информацию об адресатах в указанном файле.
 * 
 * @param file
 */
public void savePersonDataToFile(File file) {
    try {
        JAXBContext context = JAXBContext
                .newInstance(PersonListWrapper.class);
        Marshaller m = context.createMarshaller();
        m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);

        // Обёртываем наши данные об адресатах.
        PersonListWrapper wrapper = new PersonListWrapper();
        wrapper.setPersons(personData);

        // Маршаллируем и сохраняем XML в файл.
        m.marshal(wrapper, file);

        // Сохраняем путь к файлу в реестре.
        setPersonFilePath(file);
    } catch (Exception e) { // catches ANY exception
        Alert alert = new Alert(AlertType.ERROR);
        alert.setTitle("Error");
        alert.setHeaderText("Could not save data");
        alert.setContentText("Could not save data to file:\n" + file.getPath());

        alert.showAndWait();
    }
}

Маршаллинг и демаршализация готовы. Теперь для того, чтобы использовать новый функционал, давайте создадим пункты меню для сохранения и загрузки.

Обработка действий меню

Мы уже создавали меню в файле RootLayout.fxml, но пока не использовали его. Перед тем, как мы добавим в наше меню поведение, давайте создадим в нём все необходимые пункты.

В приложении Scene Builder откройте файл RootLayout.fxml и перенесите необходимое количество пунктов меню (MenuItem) из вкладки Library на вкладку Hierarchy. Создайте следующие пункты меню: New, Open…, Save, Save as… и Exit.

Add Menu Items

Подсказка: для установки на пункты меню горячих клавиш спользуйте свойство Accelerator во вкладке Properties.

Класс RootLayoutController

Для обработки поведения меню нам необходим ещё один класс-контроллер. В пакете ch.makery.address.view создайте класс RootLayoutController.

Добавьте новому классу-контроллеру следующее содержание:

RootLayoutController.java
package ch.makery.address.view;

import java.io.File;

import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.stage.FileChooser;
import ch.makery.address.MainApp;

/**
 * Контроллер для корневого макета. Корневой макет предоставляет базовый
 * макет приложения, содержащий строку меню и место, где будут размещены
 * остальные элементы JavaFX.
 * 
 * @author Marco Jakob
 */
public class RootLayoutController {

    // Ссылка на главное приложение
    private MainApp mainApp;

    /**
     * Вызывается главным приложением, чтобы оставить ссылку на самого себя.
     * 
     * @param mainApp
     */
    public void setMainApp(MainApp mainApp) {
        this.mainApp = mainApp;
    }

    /**
     * Создаёт пустую адресную книгу.
     */
    @FXML
    private void handleNew() {
        mainApp.getPersonData().clear();
        mainApp.setPersonFilePath(null);
    }

    /**
     * Открывает FileChooser, чтобы пользователь имел возможность
     * выбрать адресную книгу для загрузки.
     */
    @FXML
    private void handleOpen() {
        FileChooser fileChooser = new FileChooser();

        // Задаём фильтр расширений
        FileChooser.ExtensionFilter extFilter = new FileChooser.ExtensionFilter(
                "XML files (*.xml)", "*.xml");
        fileChooser.getExtensionFilters().add(extFilter);

        // Показываем диалог загрузки файла
        File file = fileChooser.showOpenDialog(mainApp.getPrimaryStage());

        if (file != null) {
            mainApp.loadPersonDataFromFile(file);
        }
    }

    /**
     * Сохраняет файл в файл адресатов, который в настоящее время открыт.
     * Если файл не открыт, то отображается диалог "save as".
     */
    @FXML
    private void handleSave() {
        File personFile = mainApp.getPersonFilePath();
        if (personFile != null) {
            mainApp.savePersonDataToFile(personFile);
        } else {
            handleSaveAs();
        }
    }

    /**
     * Открывает FileChooser, чтобы пользователь имел возможность
     * выбрать файл, куда будут сохранены данные
     */
    @FXML
    private void handleSaveAs() {
        FileChooser fileChooser = new FileChooser();

        // Задаём фильтр расширений
        FileChooser.ExtensionFilter extFilter = new FileChooser.ExtensionFilter(
                "XML files (*.xml)", "*.xml");
        fileChooser.getExtensionFilters().add(extFilter);

        // Показываем диалог сохранения файла
        File file = fileChooser.showSaveDialog(mainApp.getPrimaryStage());

        if (file != null) {
            // Make sure it has the correct extension
            if (!file.getPath().endsWith(".xml")) {
                file = new File(file.getPath() + ".xml");
            }
            mainApp.savePersonDataToFile(file);
        }
    }

    /**
     * Открывает диалоговое окно about.
     */
    @FXML
    private void handleAbout() {
        Alert alert = new Alert(AlertType.INFORMATION);
        alert.setTitle("AddressApp");
        alert.setHeaderText("About");
        alert.setContentText("Author: Marco Jakob\nWebsite: http://code.makery.ch");

        alert.showAndWait();
    }

    /**
     * Закрывает приложение.
     */
    @FXML
    private void handleExit() {
        System.exit(0);
    }
}

Компонент FileChooser

Обратите внимание на методы в классе RootLayoutController, которые используют компонент FileChooser. Сперва мы создаём новый экземпляр класса FileChooser. Потом применяем фильтр расширения - при выборе файлов будут показываться только те, которые имеют расширение .xml. Ну и наконец, мы отображаем данный компонент выше PrimaryStage.

Если пользователь закрывает диалог выбора файлов ничего не выбрав, то возвращается null. В противном случае мы берём выбранный файл и передаём его в методы loadPersonDataFromFile(...) или savePersonDataToFile(...), которые находятся в классе MainApp.

Связывание fxml-представления с классом-контроллером

  1. В приложении Scene Builder откройте файл RootLayout.fxml. Во вкладке Controller в качестве класса-контроллера выберите значение RootLayoutController.

  2. Перейдите на вкладку Hierarchy и выберите пункт меню. Во вкладке Code в качестве значений свойства On Action вы можете увидеть все доступные методы выбранного класса-контроллера. Выберите метод, соответствующий данному пункту меню.
    Menu Actions

  3. Повторите предыдущий шаг для каждого пункта меню.

  4. Закройте приложение Scene Builder и обновите проект (нажмите Refresh (F5) на корневой папке вашего проекта). Это позволит среде разработки Eclipse “увидеть” изменения, сделанные в приложении Scene Builder.

Связывание главного класса с классом RootLayoutController

В некоторых местах кода классу RootLayoutController требуется ссылка на класс MainApp. Эту ссылку мы ещё пока не передали.

Откройте класс MainApp и замените метод initRootLayout() следующим кодом:

/**
 * Инициализирует корневой макет и пытается загрузить последний открытый
 * файл с адресатами.
 */
public void initRootLayout() {
    try {
        // Загружаем корневой макет из fxml файла.
        FXMLLoader loader = new FXMLLoader();
        loader.setLocation(MainApp.class
                .getResource("view/RootLayout.fxml"));
        rootLayout = (BorderPane) loader.load();

        // Отображаем сцену, содержащую корневой макет.
        Scene scene = new Scene(rootLayout);
        primaryStage.setScene(scene);

        // Даём контроллеру доступ к главному прилодению.
        RootLayoutController controller = loader.getController();
        controller.setMainApp(this);

        primaryStage.show();
    } catch (IOException e) {
        e.printStackTrace();
    }

    // Пытается загрузить последний открытый файл с адресатами.
    File file = getPersonFilePath();
    if (file != null) {
        loadPersonDataFromFile(file);
    }
}

Обратите внимание на два изменения: на строки, дающие доступ контроллеру к главному классу приложения и на три последних строки для загрузки последнего открытого файла с записями.

Тестирование

Устроив небольшой тест-драйв своему приложению убедитесь, что вы уже можете использовать меню для сохранения информации об адресатах в файл.

Когда вы откроете xml-файл в текстовом редакторе, то вместо значения дня рождения увидите пустой тег <birthday/>. Дело в том, что JAXB не знает как преобразовать тип LocalDate в XML. Чтобы определить процесс преобразования, мы должны предоставить собственный класс LocalDateAdapter.

Внутри пакета ch.makery.address.util создайте новый класс LocalDateAdapter и скопируйте туда следующий код:

LocalDateAdapter.java
package ch.makery.address.util;

import java.time.LocalDate;

import javax.xml.bind.annotation.adapters.XmlAdapter;

/**
 * Адаптер (для JAXB) для преобразования между типом LocalDate и строковым
 * представлением даты в стандарте ISO 8601, например как '2012-12-03'.
 * 
 * @author Marco Jakob
 */
public class LocalDateAdapter extends XmlAdapter<String, LocalDate> {

    @Override
    public LocalDate unmarshal(String v) throws Exception {
        return LocalDate.parse(v);
    }

    @Override
    public String marshal(LocalDate v) throws Exception {
        return v.toString();
    }
}

Потом откройте класс Person.java и аннотируйте метод getBirthday():

@XmlJavaTypeAdapter(LocalDateAdapter.class)
public LocalDate getBirthday() {
    return birthday.get();
}

Теперь запустите приложение ещё раз. Попытайтесь сохранить и загрузить xml-файл с данными. Приложение должно автоматически загружать последний открытый файл после перезапуска.

Как это работает

Давайте посмотрим как это всё работает вместе:

  1. Приложение запускается через метод main(...) класса MainApp.
  2. Вызывается конструктор public MainApp() и добавляются некоторые тестовые данные.
  3. Дальше в классе MainApp запускается метод start(...), который вызывает метод initRootLayout() для инициализации корневого макета из файла RootLayout.fxml. Файл fxml уже знает, какой контроллер следует использовать и связывает представление с RootLayoutController'ом.
  4. Класс MainApp из fxml-загрузчика получает ссылку на RootLayoutController и передаёт этому контроллеру ссылку на самого себя. Потом, имея эту ссылку, контроллер может обращаться к публичным методам класса MainApp.
  5. В конце метода initRootLayout мы стараемся из настроек Preferences получить путь к последнему открытому файлу адресатов. Если этот файл в настройках описан, то мы загружаем из него данные. Эта процедура перезапишет тестовые данные, которые мы добавляли в конструкторе.

Что дальше?

В 6-й части учебника мы добавим статистический график дней рождений.

Вам могут быть интересны также некоторые другие статьи