Клиентские события (ClientEvents)
Назначение
Клиентские события (ru.bgcrm.event.client.ClientEvent и наследники) — механизм доставки
UI-сигналов пользователю: показать уведомление (ShowInfoEvent/ShowErrorEvent),
индикатор загрузки (ShowLoadingEvent/HideLoadingEvent), открыть вкладку процесса
(ProcessOpenEvent) и т.д. Фронтенд обрабатывает их через processEvent — компоненту
для этого ничего делать не нужно.
Документ описывает, каким каналом отправлять события в разных контекстах и как пользоваться
фасадом ClientEvents, который убирает типовой boilerplate.
Два канала доставки
| Контекст | API | Как доходит до пользователя |
|---|---|---|
Внутри HTTP-запроса (синхронно) |
|
событие уезжает в |
Вне запроса (фон, |
|
событие кладётся в Redis-очередь |
|
Предусловие для внеполосного канала: события через Redis работают только при включённой
настройке |
|
userId в асинхронном контексте передавайте явно. По умолчанию задача в |
Фасад ClientEvents
ru.bgcrm.event.client.ClientEvents — статические хелперы над pushEvent с форматированием
сообщений (String.format, если переданы аргументы):
ClientEvents.showInfo(userId, "Устройство заведено по процессу %s", processId);
ClientEvents.showError(userId, "Ошибка по процессу %s", processId);
ClientEvents.openProcess(userId, processId);
ClientEvents.showLoading(userId, uuid, "Отправка данных...");
ClientEvents.hideLoading(userId, uuid);
Если аргументы не переданы, строка отправляется как есть (без String.format) —
символ % в тексте безопасен.
|
Loading — индикатор загрузки с гарантированным закрытием
ClientEvents.loading(…) возвращает Loading (AutoCloseable): uuid генерируется
внутри, close() гарантированно шлёт HideLoadingEvent — в том числе при исключении,
если использовать try-with-resources. «Вечные часики» и рассинхрон Show/Hide становятся
невозможны.
try (var loading = ClientEvents.loading(userId, "Отправка в UGIN по процессу %s", processId)) {
ResponsePerimeter r = perimeterResponseHandler(processId);
loading.info("Устройство заведено по процессу %s", processId)
.openProcess(processId);
} // HideLoadingEvent уйдёт и при успехе, и при исключении
AsyncWorker.submitWithLoading
Типовой сценарий «долгий внешний вызов + лоадер» закрыт одним примитивом
ru.bgcrm.context.async.AsyncWorker#submitWithLoading:
-
показывает лоадер до старта задачи;
-
гарантированно скрывает его после завершения (успех или исключение);
-
при исключении дополнительно шлёт пользователю
ShowErrorEventи пробрасывает исключение дальше (телеметрияAsyncWorkerфиксирует FAILURE).
AsyncWorker.submitWithLoading(
String.format("Отправка информации в UGIN по процессу %s", processId),
"AccessControlDeviceAutomation", // статический тип для телеметрии, без ID/UUID
user, // получатель лоадера и контекст выполнения задачи
() -> {
Connection con = ServerContext.get().getConnection(ERP_MASTER); // локально, не в поле!
ResponsePerimeter r = perimeterResponseHandler(con, processId);
responseStatusHandler(user.getId(), con, processId, r);
});
user — это одновременно получатель уведомлений и пользователь, под которым выполняется
задача: submitWithLoading пробрасывает его в AsyncWorker.submit, поэтому записи в БД
атрибутируются реальному пользователю, а не системному.
user обязателен по смыслу, хотя формально у него есть значение по умолчанию
(USER_BGCRM). Лоадер показывать можно только реальному пользователю — тому, кто читает
свою очередь клиентских событий. У синтетических пользователей id неположительный
(User.USER_SYSTEM_ID = 0, User.USER_CUSTOMER_ID = -1; USER_BGCRM конструируется без
id и потому тоже равен 0), поэтому вызов с таким пользователем отклоняется с
IllegalArgumentException. Захватывайте пользователя до вызова: внутри задачи
в контексте уже системный пользователь.
|
Не сохраняйте Connection, processId и прочее состояние в полях
слушателя-синглтона из асинхронной лямбды — это гонка и шаринг JDBC-соединения между
потоками. Всё передавайте параметрами лямбды локальными переменными.
|
Пример: до и после
Сценарий: по смене статуса процесса выполнить долгий вызов внешней системы, показать пользователю лоадер и результат.
user.getId(), Hide можно потерять при исключении)event.getForm().getResponse().addEvent(
new ShowLoadingEvent(PREFIX + processId, "Отправка информации в UGIN по процессу " + processId));
AsyncWorker.submit("AccessControlDeviceAutomation", () -> {
ResponsePerimeter r;
try {
r = perimeterResponseHandler(processId);
} catch (Exception e) {
r = ResponsePerimeter.error(e.getMessage());
}
if (r.getSuccess()) {
ClientEventNotification.pushEvent(user.getId(),
new ShowInfoEvent(String.format("Устройство успешно заведено по процессу %s", processId)));
} else {
ClientEventNotification.pushEvent(user.getId(),
new ShowErrorEvent(String.format("Ошибка при заведении устройства по процессу %s", processId)));
}
ClientEventNotification.pushEvent(user.getId(), new ProcessOpenEvent(processId));
ClientEventNotification.pushEvent(user.getId(), new HideLoadingEvent(PREFIX + processId)); // забыли try/finally — «вечные часики»
});
submitWithLoading + ClientEventsint userId = user.getId(); // захват ДО submit — в async контексте системный пользователь
AsyncWorker.submitWithLoading(
String.format("Отправка информации в UGIN по процессу %s", processId),
"AccessControlDeviceAutomation",
user,
() -> {
ResponsePerimeter r = perimeterResponseHandler(processId);
if (r.getSuccess()) {
ClientEvents.showInfo(userId, "Устройство успешно заведено по процессу %s", processId);
} else {
ClientEvents.showError(userId, "Ошибка при заведении устройства по процессу %s", processId);
}
ClientEvents.openProcess(userId, processId);
});
// лоадер показан до старта и гарантированно скрыт после; при исключении — авто-ShowError
Батч-отправка
Несколько событий одному пользователю можно отправить за один round-trip в Redis:
ClientEventNotification.pushEvents(userId, List.of(
new ShowInfoEvent("Готово"),
new ProcessOpenEvent(processId)
));
Порядок событий сохраняется, пустой список игнорируется.
Справка
| Что | Где |
|---|---|
База событий |
|
Внеполосный канал |
|
Фасад |
|
Async + лоадер |
|
Настройки |
|
При добавлении нового типа события не забудьте зарегистрировать его в
ClientEventMixin (@JsonSubTypes) — иначе popEvent не сможет его десериализовать
и событие будет молча отброшено.
|