Клиентские события (ClientEvents)
Назначение
Клиентские события (ru.bgcrm.event.client.ClientEvent и наследники) — механизм доставки
UI-сигналов пользователю: показать уведомление (ShowInfoEvent/ShowErrorEvent),
индикатор загрузки (ShowLoadingEvent/HideLoadingEvent), открыть вкладку процесса
(ProcessOpenEvent) и т.д. Фронтенд обрабатывает их через processEvent — компоненту
для этого ничего делать не нужно.
Документ описывает, каким каналом отправлять события в разных контекстах и как пользоваться
фасадом ClientEvents, который убирает типовой boilerplate.
Два канала доставки
| Контекст | API | Как доходит до пользователя |
|---|---|---|
Внутри HTTP-запроса (синхронно) |
|
событие уезжает в |
Вне запроса (фон, |
|
событие кладётся в Redis-очередь |
|
Предусловие для внеполосного канала: события через Redis работают только при включённой
настройке |
ClientEventListener извлекает очередь только для pool.do?activity=active. Неактивная
вкладка не забирает события; они остаются в Redis до следующего активного опроса либо
до истечения TTL.
|
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.submitWithProgress: задача получает AsyncProgress с методами
info, error и openProcess, адресованными тому же пользователю.
AsyncWorker.submitWithLoading(
String.format("Отправка информации в UGIN по процессу %s", processId),
"AccessControlDeviceAutomation", // статический тип для телеметрии, без ID/UUID
user, // получатель лоадера и контекст выполнения задачи
() -> {
ResponsePerimeter r = perimeterResponseHandler(processId); // сначала внешний вызов
responseStatusHandler(user.getId(), 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. Захватывайте пользователя и его id до вызова, не полагаясь
на неявный контекст фонового сценария.
|
Не сохраняйте Connection, processId и прочее состояние в полях
слушателя-синглтона из асинхронной лямбды — это гонка и шаринг JDBC-соединения между
потоками. Всё передавайте параметрами лямбды локальными переменными.
|
Не получайте JDBC-соединение до долгого внешнего вызова: транзакционный контекст worker удержит его до завершения задачи. Если БД нужна и до, и после сетевой операции, разделите работу на короткие контексты по схеме нескольких фаз БД.
Пример: до и после
Сценарий: по смене статуса процесса выполнить долгий вызов внешней системы, показать пользователю лоадер и результат.
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);
});
// после старта задачи лоадер скрывается в finally; при исключении — авто-ShowError
Задача регистрируется после commit текущего транзакционного контекста. Если commit не
состоялся, задача не стартует и её finally не выполнится. В обычном HTTP-сценарии
успешный response с лоадером также не отправляется; при внеполосном показе через Redis
индикатор может оставаться до истечения TTL. Полный lifecycle описан на странице
AsyncWorker.
Батч-отправка
Несколько событий одному пользователю можно отправить за один round-trip в Redis:
ClientEventNotification.pushEvents(userId, List.of(
new ShowInfoEvent("Готово"),
new ProcessOpenEvent(processId)
));
Порядок событий сохраняется, пустой список игнорируется.
Справка
| Что | Где |
|---|---|
База событий |
|
Внеполосный канал |
|
Фасад |
|
Async + лоадер |
|
Async + лоадер + уведомления по ходу |
|
Настройки |
|
При добавлении нового типа события не забудьте зарегистрировать его в
ClientEventMixin (@JsonSubTypes) — иначе popEvent не сможет его десериализовать
и событие будет отброшено с записью ошибки в серверный лог.
|
Архитектурная схема фонового обращения, фиксации результата и уведомления пользователя: Асинхронная работа с внешними сервисами.