Vaadin 14. Модель+контроллер.

· 6 минуты на чтение

Есть относительно крупная форма, со множеством взаимосвязанных компонентов. При выборе какого-нибудь комбобокса нужно получить из БД несколько наборов данных, обновить их в DataProvider-ах других компонентов. В общем - ситуация напоминает большую комнату с большим числом разных объектов со множеством ниточек-связей. И дернув одну ниточку, можно вызвать шквал ответных реакций со всех сторон комнаты.

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

Принято решение переделать работу формы на использование модели и контроллера. Мне не удалось вписать Vaadin в классическую схему работы MVC. Придумал немного измененную свою:

Controller:

  • реализует в виде методов с однозначными названиями, вызовы к которым поступают из разных источников;
  • для методов, в которых производится изменение состояния компонентов UI, нужно использовать двух-этапную реализацию:
    • первый этап - изменение модели;
    • второй этап - синхронизация компонентов с моделью;
  • двунаправленно взаимодействует с UI, внешними сервисами, базой данных;
  • может менять состояние модели;

Model:

  • хранит данные для всех компонентов формы:
    • коллекции значений, например для комбобоксов;
    • выбранные значения для комбобоксов (если не null, то обязательно объект такого значения должен быть в коллекции);
    • одиночные значения, например для Label/Text/Checkbox;
  • содержит методы для перезагрузки данных в коллекциях, по возможности, используя текущие значения других частей модели;
  • предоставляет данные для UI и Controller
  • принимает изменения данных и запросы на перезагрузку коллекций от Controller;

UI:

  • реагирует на события пользовательского интерфейса, это могут быть, например, FocusListener, BlurListener и т.п.;
  • для ValueChangeListener нужно добавлять проверку на поступление события именно от пользователя, используя if (event.isFromClient()) {}. Если условие верно - то обычно следует вызвать в контроллере метод вида asyncChangeValue*(event.getValue()). Приставка async* в данном случае напоминает мне о том, что метод асинхронный, двух-этапный.
  • для исключения ненужных попыток обновлять данные в компонентах UI создал абстрактный класс ModelSyncable, содержащий логику обнаружения измнений в коллекциях и/или выбранном значении компонента. Типичная схема работы с этим классом:
    • враппер компонента наследует от класса ModelSyncable и переопределяет некоторые или все из его методов:
      • syncSupplierModelCollectionIds() - поставщик списка Id на базе коллекции;
      • syncSupplierModelValueId() - метод-поставщик значения из модели;
      • syncSupplierComponentValueId() - метод-поставщик значения из компонента;
      • для определения, нужно ли производить обновление данных в компоненте, используются методы:
        • syncNeedDataProviderRefreshAll() - если возвращает TRUE, значит нужно обновить DataProvider для списка значений (refreshAll());
        • syncNeedComponentValueChange() - если возвращает TRUE, значит нужно обновить выбранное значение компонента;
      • требуется реализация метода syncByModel();

Пример реализованного метода:

@Override
public void syncByModel() {
    if (syncNeedDataProviderRefreshAll())
    	component.getDataProvider().refreshAll();
    if (syncNeedComponentValueChange())
    	component.setValue(lineModel.getEntityAddrss());
}

Картинка, как оно все связано (link):

Враппер компонента - это простой класс, в конструкторе которого производится инициализация компонента, настройка его листенеров и т.д. Геттер компонента getComponent() прилагается.

Асинхронный двух-этапный метод Контроллера - это метод, позволяющий выполнять тяжелые операции в отрыве от основного потока UI. Если выполнять такие операции в основном потоке UI - это будет приводить к торможению работы интерфейса. В этом методе асинхронно (отдельно от вызывающего потока) и последовательно выполняются два блока кода:

  • тяжелые операции по получению данных из БД, обновление модели;
  • вызов методов синхронизации компонентов `syncByModel()`, данные которых были затронуты в первом блоке. Этот блок выполняется при блокировке потока UI, используя ui.access();

Советы самому себе:

  • не создавать раздельные контроллеры для вложенных компонентов, пускай он лучше будет один большой (у меня получился более 700 строк), но все в одном месте. Гораздо сложнее кодить, имея два и более контроллера, которые зависят друг от друга. У меня в форме есть грид, каждая строка которого состоит из 3 ComboBox и одного Div. Сначала я сделал контроллер и модель для грида, потом добавил контроллер и модель для строки грида. Когда все это смешалось и появились вызовы из контроллеров во вложенные контроллеры и наоборот - настал кошмар. Пришлось объединять контроллеры в один - GridController. А вот модели я оставил как было - модель грида и модель строки.
  • для не вложенных компонентов создавать раздельные контроллеры вполне разумно, особенно если в них много кода;
  • двух-этапные асинхронные методы по возможности не должны включать в себя вызовы других методов такого же типа. Иначе начнется что-то типа 'race condition' и станет неясен результат всей операции. Подобные асинхронные методы можно вызывать только для не пересекающихся операций, например, вывод уведомления.

Кое-какой код:

import com.vaadin.flow.component.Component;
import ru.waptaxi.wtwebordermanager.jettyserv.webcontext.views.reception.model.ReceptionModel;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;

/**
 * Абстрактный класс для расширения врапперами компонентов.
 * Основное назначение - для реализации логики обнаружения изменений в данных компонента.
 *
 * @since 2021-04-05
 */
public abstract class ModelSyncable {

    private static final String METHOD_NOT_IMPLEMENTED_STRING = "Не реализован метод '%s' в классе %s";
    private static final String METHOD_NOT_FOUND_STRING = "Ошибка обнаружения метода '%s' в классе %s";

    protected ModelSyncable() {
    }


    /*
     * ***************************************************************************************
     * ***************************************************************************************
     * *********************** СИНХРОНИЗАЦИЯ ДАННЫХ КОМПОНЕНТА И МОДЕЛИ **********************
     * ***************************************************************************************
     * ***************************************************************************************
     * 2021-03-15. Вся эта каша заварена для исключения ненужных передач данных между
     * браузером и сервером. Методы syncNeed*, используя данные поставщиков syncSupplier*,
     * возращают признаки, нужно ли обновлять список данных компонента из модели
     * (для компонентов с коллекциями) и нужно ли обновлять значение в компоненте
     * на значение из модели.
     */

    /**
     * Обновление компонента на основе данных из модели
     */
    public abstract void syncByModel();


    /**
     * ***************************************************************************************
     * Детекция изменений в коллекции, используемой в компоненте
     * ***************************************************************************************
     */
    private static final String SYNC_SUPPLIER_MODEL_COLLECTION_IDS_METHOD = findMethodNameForAnnotation(SyncSupplierModelCollectionIdsMethod.class);
    private int collectionIdsHash = 0; // Последний хэш коллекции


    /**
     * Дефолтный поставщик списка Integer из коллекции объектов для создания хэша
     * Для компонентов Vaadin, которым требуется знать об изменениях в их списке данных,
     * нужно переопределять этот метод, реализуя в нем возврат значения типа List<Integer>,
     * состоящих из ID элементов данных. Без переопределения метода дефолтный поставщик
     * будет всегда выдавать пустой список и хэш считаться не будет.
     *
     * @return поставщик List<Integer> для расчета хэша
     */
    @SyncSupplierModelCollectionIdsMethod
    public Supplier<List<Integer>> syncSupplierModelCollectionIds() {
        return ArrayList::new;
    }


    /**
     * Признак изменения коллекции для компонента
     * Здесь проверяется, где именно реализован метод-поставщик List<Integer>.
     * Если он реализован только в данном абстрактном классе - то никакие
     * изменения детектироваться не будут.
     */
    public final boolean syncNeedDataProviderRefreshAll() {
        // Дефолтное значение (Имя интерфейсного класса) - только для создания реакции "не реализован метод",
        // т.к. возможно исключение и строка останется пустой, что приведет к ложному срабатыванию логики
        String collectionHashSupplierDeclaringClassName;
        try {
            collectionHashSupplierDeclaringClassName = this.getClass().getMethod(SYNC_SUPPLIER_MODEL_COLLECTION_IDS_METHOD)
                    .getDeclaringClass().getSimpleName().intern();
        } catch (NoSuchMethodException e) {
            OperationsLogger.logError(e, String.format(METHOD_NOT_FOUND_STRING, SYNC_SUPPLIER_MODEL_COLLECTION_IDS_METHOD, this.getClass().getSimpleName()));
            return false;
        }

        String abstractClassName = ModelSyncable.class.getSimpleName().intern();
        // Если метод-поставщик хэша не реализован в дочернем классе, то пишем в лог и возвраащаем false,
        // иначе - получаем актуальный хэш для коллекции и сравниваем его с ранее сохраненным
        if (collectionHashSupplierDeclaringClassName.equals(abstractClassName)) {
            OperationsLogger.logWarn(String.format(METHOD_NOT_IMPLEMENTED_STRING, SYNC_SUPPLIER_MODEL_COLLECTION_IDS_METHOD, this.getClass().getSimpleName()));
        } else {
            List<Integer> ids = syncSupplierModelCollectionIds().get();
            int actualCollectionHash = Objects.hash(ids.toArray()); // Хэш на основе ID элементов коллекции
            if (actualCollectionHash != collectionIdsHash) {
                collectionIdsHash = actualCollectionHash;
                return true;
            }
        }
        return false;
    }


    /*
     * ***************************************************************************************
     * Детекция изменений в выбранном значении, используемом в компоненте
     * Сравнение производится между значением в компоненте и значением из модели
     *
     * @see #syncSupplierComponentValueId - поставщик значения из компонента
     * @see #syncSupplierModelValueId()     - поставщик значения из модели
     * ***************************************************************************************
     */

    private static final String SYNC_SUPPLIER_MODEL_VALUE_ID_METHOD = findMethodNameForAnnotation(SyncSupplierModelValueIdMethod.class);
    private static final String SYNC_SUPPLIER_COMPONENT_VALUE_ID_METHOD = findMethodNameForAnnotation(SyncSupplierComponentValueIdMethod.class);


    /**
     * Поставщик ID выбранного значения из модели, например ID
     * Нужен для определения, нужно ли обновлять значение в компоненте...
     * Если текущее значение компонента отличается от текущего значения модели
     * - то нужно обновить значение в компоненте
     *
     * @see #syncSupplierComponentValueId()
     */
    @SyncSupplierModelValueIdMethod
    public Supplier<Object> syncSupplierModelValueId() {
        return () -> 0;
    }


    /**
     * Поставщик текущего значения компонента, например ID
     * Нужен для определения, нужно ли обновлять значение в компоненте...
     * Если текущее значение компонента отличается от текущего значения модели
     * - то нужно обновить значение в компоненте
     *
     * @see #syncSupplierModelValueId()
     */
    @SyncSupplierComponentValueIdMethod
    public Supplier<Object> syncSupplierComponentValueId() {
        return () -> 0;
    }


    /**
     * Метод, возращающий TRUE, если значение в компоненте отличается от значения в модели
     */
    public final boolean syncNeedComponentValueChange() {
        // Имя класса, в котором реализован метод с указанным именем
        String methodImplementsClassName1;
        try {
            methodImplementsClassName1 = this.getClass().getMethod(SYNC_SUPPLIER_MODEL_VALUE_ID_METHOD).getDeclaringClass().getSimpleName().intern();
        } catch (NoSuchMethodException e) {
            OperationsLogger.logError(e, String.format(METHOD_NOT_FOUND_STRING, SYNC_SUPPLIER_MODEL_VALUE_ID_METHOD, this.getClass().getSimpleName()));
            return false;
        }

        // Имя класса, в котором реализован метод с указанным именем
        String methodImplementsClassName2;
        try {
            methodImplementsClassName2 = this.getClass().getMethod(SYNC_SUPPLIER_COMPONENT_VALUE_ID_METHOD).getDeclaringClass().getSimpleName().intern();
        } catch (NoSuchMethodException e) {
            OperationsLogger.logError(e, String.format(METHOD_NOT_FOUND_STRING, SYNC_SUPPLIER_COMPONENT_VALUE_ID_METHOD, this.getClass().getSimpleName()));
            return false;
        }

        String abstractClassName = ModelSyncable.class.getSimpleName().intern();
        // Если метод1 реализован только в абстрактном классе, то пишем в лог
        // иначе, если метод2 реализован только в абстрактном классе, то пишем в лог
        // иначе - считаем хэш значения из модели и сравниваем его с сохраненным для компонента
        if (methodImplementsClassName1.equals(abstractClassName)) {
            OperationsLogger.logWarn(String.format(METHOD_NOT_IMPLEMENTED_STRING, SYNC_SUPPLIER_MODEL_VALUE_ID_METHOD, this.getClass().getSimpleName()));
        } else if (methodImplementsClassName2.equals(abstractClassName)) {
            OperationsLogger.logWarn(String.format(METHOD_NOT_IMPLEMENTED_STRING, SYNC_SUPPLIER_COMPONENT_VALUE_ID_METHOD, this.getClass().getSimpleName()));
        } else {
            int modelValueHash = Objects.hash(syncSupplierModelValueId().get());
            int componentValueHash = Objects.hash(syncSupplierComponentValueId().get());
            if (modelValueHash != componentValueHash)
                return true;
        }
        return false;
    }


    /**
     * Поиск названия метода по аннотации
     */
    private static String findMethodNameForAnnotation(Class<? extends Annotation> annotationName) {
        Method[] methods = ModelSyncable.class.getMethods();
        for (Method method : methods) {
            boolean annotationPresent = method.isAnnotationPresent(annotationName);
            if (annotationPresent)
                return method.getName();
        }
        return "";
    }


}



@Retention(RetentionPolicy.RUNTIME)
public @interface SyncSupplierComponentValueIdMethod {
}
@Retention(RetentionPolicy.RUNTIME)
public @interface SyncSupplierModelCollectionIdsMethod {
}
@Retention(RetentionPolicy.RUNTIME)
public @interface SyncSupplierModelValueIdMethod {
}
public class CityComboBox2 extends ModelSyncable {

    private final MarshrutGridRow marshrutGridRow;
    private final ComboBox<EntityCity> component;
    private final MarshrutGrid2 marshrutGrid2;
    private final MarshrutGridRowModel rowModel;


    /**
     * Конструктор
     */
    public CityComboBox2(MarshrutGridRow marshrutGridRow) {
        this.marshrutGridRow = marshrutGridRow;
        this.marshrutGrid2 = marshrutGridRow.getMarshrutGrid();
        this.rowModel = marshrutGridRow.getRowModel();

        component = new ComboBox<>();
        component.setSizeFull();

        // Подключение и настройка набора данных
        component.setItemLabelGenerator(EntityCity::getName);
        ListDataProvider<EntityCity> dataProvider = new ListDataProvider<>(rowModel.getEntityCityList());
        dataProvider.setSortOrder(EntityCity::getName, SortDirection.ASCENDING);
        component.setDataProvider(dataProvider);

        component.addValueChangeListener(event -> {
            if (event.isFromClient()) marshrutGrid2.getController().asyncChangeCity(marshrutGridRow, event.getValue());
        });

        component.addFocusListener(event -> marshrutGrid2.getController().focusSetLast(component));

    }


    public ComboBox<EntityCity> getComponent() {
        return component;
    }


    @Override
    public Supplier<List<Integer>> syncSupplierModelCollectionIds() {
        return () -> rowModel.getEntityCityList().stream().map(EntityCity::getCityId).collect(Collectors.toList());
    }

    @Override
    public Supplier<Object> syncSupplierModelValueId() {
        return rowModel::getCityId;
    }

    @Override
    public Supplier<Object> syncSupplierComponentValueId() {
        return () -> Objects.nonNull(component.getValue()) ? component.getValue().getCityId() : 0;
    }

    @Override
    public void syncByModel() {
        if (syncNeedDataProviderRefreshAll())
            component.getDataProvider().refreshAll();
        if (syncNeedComponentValueChange())
            component.setValue(rowModel.getEntityCity());

    }
}