Есть относительно крупная форма, со множеством взаимосвязанных компонентов. При выборе какого-нибудь комбобокса нужно получить из БД несколько наборов данных, обновить их в 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());
}
}