MVP в JavaFX, часть 3

О формате JSON

И так, одним из пунктов нашей курсовой является прикручивание JSON сериализации. Мы уже знакомы с XML сериализацией, то есть преобразование объектов в текстовый XML-формат и обратно. Так вот тут тоже самое, только в качестве формата используется JSON.

JSON – это дефакто стандарт передачи данных в веб приложениях. Всякие вконтактики/инстачи/фейсбуки/ютюбы/телеграммы и прочие порождения 21 века используют его направо и налево чтобы передавать данные со своих серверов на ваши устройства и обратно.

Википедия предлагает следующую структуру в качестве примера

{
   "firstName": "Иван",
   "lastName": "Иванов",
   "address": {
       "streetAddress": "Московское ш., 101, кв.101",
       "city": "Ленинград",
       "postalCode": "101101"
   },
   "phoneNumbers": [
       "812 123-1234",
       "916 123-4567"
   ]
}

и в противовес предлагает XML решение для такой же структуры данных:

<person>
  <firstName>Иван</firstName>
  <lastName>Иванов</lastName>
  <address>
    <streetAddress>Московское ш., 101, кв.101</streetAddress>
    <city>Ленинград</city>
    <postalCode>101101</postalCode>
  </address>
  <phoneNumbers>
    <phoneNumber>812 123-1234</phoneNumber>
    <phoneNumber>916 123-4567</phoneNumber>
  </phoneNumbers>
</person>

очевидно, что JSON вариант в разы читабельнее и более компактен.

В силу особенностей развития интернетов, эра использования JSON формата пришла сильно позже XML.

И в виду консервативности java, функционал для работы с этим форматом не попала в набор стандартных библиотек.

Поэтому чтобы использовать JSON-сериализатор мы будем использовать стороннюю библиотечку именуемую Jackson

Подключаем библиотечку для работы с JSON

Чтобы начать ее использовать, потребуется скачать три файлика:

чтобы скачать jar-архив надо кликнуть кнопочку bundle

Imgur

создаем папку libs в корне проекта и сохраняем их в эту папку

Imgur

теперь подключим эту папку к проекту.

Заходим в настройки проекта:

Imgur

далее выбираем Modules и тыкаем плюсик

Imgur

выбираем папку libs и жмем Ok

Imgur

и еще раз жмем OK.

Готово!)

Правим интерфейс

Добавим теперь раздел меню, который позволит сохранять данные в файл (ну и заодно загружать)

Imgur

подцепим функции к пунктам меню

Imgur

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

public class FoodModel {
    // ...
    
    public void saveToFile(String path) {

    }
}

и добавим вызов этого метода в функции нажатия на «Сохранить»:

public class MainFormController implements Initializable {
    // ...
    
    public void onSaveToFileClick(ActionEvent actionEvent) {
        foodModel.saveToFile("data.json");
    }
}

Сохраняем в файл

а теперь воспользуемся JSON-сериализатором, который мы подключили чтобы сохранить данные в файл, идем обратно в модель и правим метод

public class FoodModel {
    // ...
    
    public void saveToFile(String path) {
        // открываем файл для чтения
        try (Writer writer =  new FileWriter(path)) {
            // создаем сериализатор
            ObjectMapper mapper = new ObjectMapper();
            // записываем данные списка foodList в файл
            mapper.writerWithDefaultPrettyPrinter().writeValue(writer, foodList);
            // усё
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

запускаем и проверяем

появился файлик data.json со следующим содержимым:

[ {
  "kkal" : 100,
  "title" : "Яблоко",
  "id" : 1,
  "isFresh" : true,
  "description" : "Фрукт свежий"
}, {
  "kkal" : 200,
  "title" : "шоколад Аленка",
  "id" : 2,
  "type" : "milk",
  "description" : "Шоколад молочный"
}, {
  "kkal" : 300,
  "title" : "сладкая булочка с Маком",
  "id" : 3,
  "withSugar" : true,
  "withPoppy" : true,
  "withSesame" : false,
  "description" : "Булочка с сахаром, с маком"
} ]

то есть видим тут упакованный в файл список продуктов.

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

Поэтому чтобы наш сериализатор начал корректно работать на чтение, надо добавить информацию о классе в объект.

Для этого идем в класс Food и добавляем аннотацию к классу

// тут написано что создай свойство @class и пропиши в нем имя класса
@JsonTypeInfo(use=JsonTypeInfo.Id.CLASS, include=JsonTypeInfo.As.PROPERTY, property="@class")
public class Food
{
    // ...
}

если попробовать еще раз записать в файл, то увидим тот же самый json-файл.

Как же так?

Дело в том, что мы передаем на сериализацию список, а сериализатор работает с обобщенным списком и поэтому не может определить какой объект он сохраняет в файл. Хотя поля он таки ведь узнает…

В общем, чтобы оно начало работать корректно надо указать сериализатору какой именно список мы ему подсовываем:

public void saveToFile(String path) {
    try (Writer writer =  new FileWriter(path)) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.writerFor(new TypeReference<ArrayList<Food>>() { }) // указали какой тип подсовываем
                .withDefaultPrettyPrinter() // кстати эта строчка чтобы в файлике все красиво печаталось
                .writeValue(writer, foodList); // а это непосредственно запись
    } catch (IOException e) {
        e.printStackTrace();
    }
}

запускаем, пробуем сохранить, и видим вот такое:

[ {
  "@class" : "sample.models.Fruit",
  "kkal" : 100,
  "title" : "Яблоко",
  "id" : 1,
  "isFresh" : true,
  "description" : "Фрукт свежий"
}, {
  "@class" : "sample.models.Chocolate",
  "kkal" : 200,
  "title" : "шоколад Аленка",
  "id" : 2,
  "type" : "milk",
  "description" : "Шоколад молочный"
}, {
  "@class" : "sample.models.Cookie",
  "kkal" : 300,
  "title" : "сладкая булочка с Маком",
  "id" : 3,
  "withSugar" : true,
  "withPoppy" : true,
  "withSesame" : false,
  "description" : "Булочка с сахаром, с маком"
} ]

ура! Появилось поле @class в котором указан тип.

Чтение из файла

Попробуем теперь загрузить этот файл. Создадим метод в модели для десериализации файла:

public class FoodModel {
    // ...
    
    public void loadFromFile(String path) {
        try (Reader reader =  new FileReader(path)) {
            // создаем сериализатор
            ObjectMapper mapper = new ObjectMapper();
            
            // читаем из файла
            foodList = mapper.readerFor(new TypeReference<ArrayList<Food>>() { })
                    .readValue(reader);
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        // оповещаем что данные загрузились
        this.emitDataChanged();
    }
}

добавим вызов этого метода в событие клика по пункту меню загрузить:

public class MainFormController implements Initializable {
    // ...
    
    public void onLoadFromFileClick(ActionEvent actionEvent) {
        foodModel.loadFromFile("data.json");
    }
}

запускаем и жмем загрузить.

Чет выскочила такая ошибка:

Imgur

если попробовать разобрать что тут написано, то мы узнаем, что пришла жалоба на отсутствие конструктора по умолчанию у класса Fruit.

Конструктор по умолчанию – это конструктор без параметров.

Ну раз уж просят, то добавим. Сыграем на опережение и добавим его не только в класс Fruit, но и вообще во все классы, связанные с едой включая Food, вот так:

public class Food
{
    // ...
    
    public Food() {};
    
    // ...
}
public class Fruit
{
    // ...
    
    public Fruit() {};
    
    // ...
}
public class Chocolate
{
    // ...
    
    public Chocolate() {};
    
    // ...
}
public class Cookie
{
    // ...
    
    public Cookie() {};
    
    // ...
}

пробуем запустить еще раз, жмем загрузить.

Да что ж такое! Новая ошибка

Imgur

в ней говорится, что у нас там какое-то неизвестное свойство description. И правда, у нас ведь есть метод getDescription, который выдает описание еды. Судя по всему, библиотечка Jackson приняла его за свойство, но только для чтения.

Короче, чтобы убрать ошибку надо повесить очередную аннотацию на класс Food, вот так:

@JsonIgnoreProperties({"description"}) // указали что свойство description нужно игнорировать
@JsonTypeInfo(use=JsonTypeInfo.Id.CLASS, include=JsonTypeInfo.As.PROPERTY, property="@class")
public class Food
{
    // ...
}

теперь давайте закомментим вызов метода load в главном контроллере, чтобы при загрузке был пустой список:

public class MainFormController implements Initializable {
    // ...

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        foodModel.addDataChangedListener(foods -> {
            mainTable.setItems(FXCollections.observableArrayList(foods));
        });
        // foodModel.load(); закоментили
    }
    // ...
}

и пробуем запустить в очередной раз:

отлично, десериализация готова! =)

Напоследок добавим возможность выбора файла:

public void onSaveToFileClick(ActionEvent actionEvent) {
    FileChooser fileChooser = new FileChooser();
    fileChooser.setTitle("Сохранить данные");
    fileChooser.setInitialDirectory(new File("."));
    
    
    // тут вызываем диалог для сохранения файла
    File file = fileChooser.showSaveDialog(mainTable.getScene().getWindow());

    if (file != null) {
        foodModel.saveToFile(file.getPath());
    }
}

public void onLoadFromFileClick(ActionEvent actionEvent) {
    FileChooser fileChooser = new FileChooser();
    fileChooser.setTitle("Загрузить данные");
    fileChooser.setInitialDirectory(new File("."));

    // а тут диалог для открытия файла
    File file = fileChooser.showOpenDialog(mainTable.getScene().getWindow());

    if (file != null) {
        foodModel.loadFromFile(file.getPath());
    }
}

о, и еще нам надо чтобы счетчик пересчитывался в соответствии с максимальным значением, но это просто, идем в метод FoodModel::loadFromFile и добавляем:

public void loadFromFile(String path) {
    try (Reader reader =  new FileReader(path)) {
        ObjectMapper mapper = new ObjectMapper();
        foodList = mapper.readerFor(new TypeReference<ArrayList<Food>>() { })
                .readValue(reader);
                
        // рассчитываем счетчик как максимальное значение id плюс 1
        this.counter = foodList.stream()
                .map(food -> food.id)
                .max(Integer::compareTo)
                .orElse(0) + 1;
    } catch (IOException e) {
        e.printStackTrace();
    }

    this.emitDataChanged();
}

Реализуем фильтрацию по типу еды

Дорабатываем форму

Давайте сначала добавим на форму селектор с типом еды:

Imgur

я назову мой комобобокс cmbFoodType

<HBox>
   <Label text="Тип:">
       <padding>
           <Insets bottom="4.0" left="4.0" right="4.0" top="4.0"/>
       </padding>
   </Label>
   <ComboBox fx:id="cmbFoodType"/>
   <padding>
       <Insets bottom="4.0" left="4.0" right="4.0" top="4.0"/>
   </padding>
</HBox>

привяжу его к контроллеру:

public class MainFormController implements Initializable {
    public TableView mainTable;
    public ComboBox cmbFoodType; // добавил комбобокс
}

теперь создадим список с возможным набором значений, так как у меня типы объектов по сути типы классов, то я воспользуюсь возможность использовать класс как обыкновенный тип, получится вот так:

public class MainFormController implements Initializable {
    // ...
    public ComboBox cmbFoodType;

    // ...
    ObservableList<Class> foodTypes = FXCollections.observableArrayList(
            Food.class,
            Fruit.class,
            Chocolate.class,
            Cookie.class
    );

то есть у меня получается список классов, для того чтобы использовать именно объект класс, я добавляю к имени класса .class

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

Делается это так:

ObservableList<Class<? extends Food>> foodTypes = FXCollections.observableArrayList(
            Food.class,
            Fruit.class,
            Chocolate.class,
            Cookie.class
    );

Class<? extends Food> – означает любой класс который является наследником Food, включая сам Food

теперь пойдем в initialize и подключим список к комобоксу:

@Override
public void initialize(URL location, ResourceBundle resources) {
    // ...
    
    // привязали список
    cmbFoodType.setItems(foodTypes);
    // выбрали первый элемент в списке
    cmbFoodType.getSelectionModel().select(0);
    
    // переопределил метод преобразования имени класса в текст
    cmbFoodType.setConverter(new StringConverter<Class>() {
        @Override
        public String toString(Class object) {
            // просто перебираем тут все возможные варианты
            if (Food.class.equals(object)) {
                return "Все";
            } else if (Fruit.class.equals(object)) {
                return "Фрукты";
            } else if (Chocolate.class.equals(object)) {
                return "Шоколад";
            } else if (Cookie.class.equals(object)) {
                return "Булочка";
            }
            return null;
        }
        
        @Override
        public Class fromString(String string) {
            return null;
        }
    });
}

запускаем, проверяем:

Imgur

Правим модель

Самый простой способ реализовать фильтрацию это добавить метод в модель, который позволит указать фильтр. Добавим сначала поле:

public class FoodModel {
    // ...
    
    // поле фильтр, по умолчанию используем базовый класс
    //
    Class<? extends Food> foodFilter = Food.class;
    
    // ...
}

добавим метод для смены фильтра

public class FoodModel {
    // ...
    public void setFoodFilter(Class<? extends Food> foodFilter) {
        this.foodFilter = foodFilter;
        
        this.emitDataChanged();
    }
}

подкрутим emitDataChanged чтобы он фильтровал список с учетом this.foodFilter

private void emitDataChanged() {
    for (DataChangedListener listener : dataChangedListeners) {
        ArrayList<Food> filteredList = new ArrayList<>(
                foodList.stream() // запускаем стрим
                        .filter(food -> foodFilter.isInstance(food)) // фильтруем по типу
                        .collect(Collectors.toList()) // превращаем в список
        );
        listener.dataChanged(filteredList); // подсовывам сюда список
    }
}

и добавим реакцию на переключения выбранного значения в MainFormController

public class MainFormController implements Initializable {
    // ...

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // ...
        cmbFoodType.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
            this.foodModel.setFoodFilter((Class<? extends Food>) newValue);
        });
    }
}

проверяем:

собственно и все =)