Простые коммуникации в Java-приложении21.08.2024 09:00
Привет,
Как вы уже, наверное, знаете, Jmix — это такая платформа для разработки корпоративных приложений, построенная на основе фреймворков Spring, Vaadin и других классных технологий с открытым исходным кодом.
Ее использование позволяет абстрагироваться от многих сложностей фронтенд-разработки. Разработчикам не обязательно учить JavaScript/TS, погружаться в особенности популярных фронтенд-фреймворков, тренироваться в верстке, чтобы иметь возможность создавать полнофункциональные веб-приложения. Достаточно просто писать код на Java и немного компоновать экраны в XML. При разработке интерфейса для Jmix под капот уходят также некоторые механики, связанные с «перекладыванием джейсонов», что открывает дополнительные возможности для написания интерактивных веб-приложений с использованием готовых компонентов и дополнений.
Сегодня мы попробуем убедиться в этом на примере, создав MVP приложения для взаимодействия пользователей.
Для начала работы нам потребуется IntelliJ IDEA или GIGA IDE с установленным плагином Jmix (https://plugins.jetbrains.com/plugin/14340-jmix).
Создадим новый проект, выбрав тип Jmix, Full-Stack Application и назовем его jmix-colab.
Добавим новый пустой экран DrawBoardView.
Экран состоит из XML-дескриптора, в котором описывается его общий дизайн (или лейаут) и Java-класса, в котором пишется бизнес-логика, относящаяся к экрану и взаимодействию с его дочерними компонентами.
Изобретать компонент для работы с Canvas мы сегодня не будем, а вместо этого просто пройдем в реестр дополнений Vaadin, в котором, кажется, есть готовые решения на любой случай, и по слову canvas найдем там вот такой вариант.
Добавим его зависимость в build.gradle.
implementation 'org.parttio:canvas-java:2.0.0'
Для начала программно добавим холст на экран. Для генерации метода-обработчика удобно использовать меню Generate Handler.
onBeforeShow и onInit — стандартный способ навесить свой функционал для экрана: загрузить, сконфигурировать, добавить в отображение и другие инициализационные операции.
На холсте мы сразу что-нибудь нарисуем, чтобы увидеть, что все заработало.
В этот раз мне пришлось написать метод вручную, потому что Generate Handler опознает только компоненты, объявленные в дескрипторе и имеющие id.
Также я добавил логгер, чтобы прямо из логов видеть, что событие обрабатывается. Его можно заинжектить из контекстного меню или добавить строчку инициализации на уровне свойств класса.
private static final Logger log = LoggerFactory.getLogger(DrawBoardView.class);
Привязывать добавленный ранее обработчик движения мыши надо будет тоже в методе onBeforeShow.
Теперь мы можем вернуться в браузер и порисовать, двигая мышью.
Но когда на любое движение мыши без остановки происходит отрисовка линии — это не очень удобно, поэтому мы будем рисовать только когда нажата левая кнопка мыши. Для этого классу экрана добавим признак, определяющий, что рисование сейчас происходит.
protected Boolean drawingEnabled = false;
А на события mousedown и mouseup добавим обработчики его включения и выключения, а также вызовы методов начала и завершения фигуры у контекста. Его следует поднять на уровень свойства класса экрана, чтобы иметь доступ из других методов класса.
Остается добавить только проверку режима в обработчике движения мыши, и наша рисовалка станет как у людей.
Однако, мы хотим сделать многопользовательское приложение, и в этом нам поможет инструмент для работы с шиной событий uiEventPublisher.
Чтобы им воспользоваться, его надо заинжектить в класс экрана так же, как мы это проделывали с компонентом-контейнером.
Вместо рисования в текущем контексте будем отправлять событие рисующего передвижения мыши, в нашем упрощенном варианте — всем пользователям.
Теперь обработчик передвижения будет выглядеть вот так:
public void onCanvasMouseMove(MouseMoveEvent event) {
if (drawingEnabled) {
uiEventPublisher.publishEventForUsers(new DrawBoardMoveEvent(event), null);
}
log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY());
}
Он посылает событие мыши, обернутое в событие уровня всего приложения, которое пока что просто оборачивает его.
package com.company.jmixcolab.event;
import org.springframework.context.ApplicationEvent;
import org.vaadin.pekkam.event.MouseMoveEvent;
public class DrawBoardMoveEvent extends ApplicationEvent {
protected MouseMoveEvent mouseMoveEvent;
public DrawBoardMoveEvent(MouseMoveEvent event) {
super(event);
this.mouseMoveEvent = event;
}
public MouseMoveEvent getMouseMoveEvent() {
return mouseMoveEvent;
}
}
А обработчик этого события будет уже рисовать на холсте.
@EventListener
public void boardMoveEventHandler(DrawBoardMoveEvent event) {
ctx.lineTo(event.getMouseMoveEvent().getOffsetX(), event.getMouseMoveEvent().getOffsetY());
ctx.stroke();
}
Теперь мы можем открыть холст в разных окнах браузера и порисовать синхронно. Работать это все может не идеально, у меня иногда «забывала» отключиться функция рисования и наблюдались некоторые задержки, что вероятно связанно с параметрами debounce для события движения мыши и особенностями сетевого обмена. Однако, мы сейчас работаем с упрощенными примерами, рассчитанными на демонстрацию возможностей, а не на оптимальные режимы работы.
Правда, получается не понятно, кто где нарисовал. Для того, чтобы стало понятно, в класс эвента добавим поле username в событие DrawBoardMoveEvent и будем заполнять его из контекста текущего пользователя в обработчике движения.
public void onCanvasMouseMove(MouseMoveEvent event) {
if (drawingEnabled) {
uiEventPublisher.publishEventForUsers(new DrawBoardMoveEvent(event, currentAuthentication.getUser().getUsername()), null);
}
log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY());
}
А при рисовании мы будем сверять значение с текущим пользователем и менять цвет в зависимости от того сам клиент рисует или получает эвенты рисования от другого пользователя.
Теперь наши пользователи могут различать вклад друг друга, но неплохо было бы еще добавить индикацию, когда один из них пребывает в процессе рисования. Для этого создадим эффект мигания рисунка.
Конечно, в реальном случае это, скорее всего, будет лучше сделать исключительно с помощью анимации CSS, однако у нас демонстрационный проект и мы будем мигать, просто изменяя во времени это свойство. Как это сделать?
Чтобы получился эффект мигания, мы будем изменять свойство opacity у холста, уменьшая и увеличивая его значение. Для этого нам потребуется последовательность значений в интервале от 0.6 до 1.0, изменяющаяся с шагом 0.2, при этом сначала убывая от единицы, а затем возрастая.
Чтобы это свойство изменялось у элемента последовательно во времени, нам потребуется использовать планировщик задач.
Для нашего случая подойдет однопоточный экзекьютор с возможностью выполнять отложенные и повторяющиеся задачи.
Почему не сделать это прямо в задаче для ExecutorService? Дело в том, что задачи экзекьютора несмотря на то, что могут выполняться в одном реальном потоке, ведут себя как настоящие потоки. В нашем случае это значит, что они не имеют доступа к контексту выполнения пользовательского интерфейса, и тут нас снова выручает publishEventForUsers, позволяя потокам осуществлять взаимодействие с интерфейсом.
В Java начиная с версии 21 появилась возможность использовать экзекьюторы виртуальных потоков в дополнение к обычным. Cо стороны кода экзекьютор задач в виртуальных потоках имеет тот же интерфейс, что и для реальных, и отличается только реализацией.
Преимущество изоляции виртуальных потоков заключается в том, что, выполняя в них «тяжелые» задачи, мы не будем порождать блокировки интерфейса пользователей. Это особенно важно для операций ввода-вывода, таких, как запросы на сторонние сервисы, чтение и запись данных в файлы и базы. Веб-интерфейс в браузере, так же как в играх, оконных системах, десктопных тулкитах, работает в однопоточном режиме и лучшее, что мы можем сделать для обеспечения его отзывчивости — это выполнять «тяжелые» задачи в отдельных потоках, обрабатывая в UI-потоке только результаты их выполнения. Некоторые виды операций, такие как скачивание ресурсов, браузер сам выполнит в фоновых потоках, другие, например выполнение большого цикла или ресурсоемкого вычисления из JavaScript-контекста завесят интерфейс пользователя.
Говорят, виртуальные потоки как будто предназначены для эффективной работы с блокирующими операциями при высокой их интенсивности. Их создание происходит «с нулевой стоимостью». Заблокировавшись, виртуальный поток освободит контекст реального потока, в котором выполнялся для других задач, не вызвав его блокировки. Единственное исключение — когда вы имеете дело с кодом, в котором присутствует большое количество synchronized-блоков, и это не для всех случаев. В отличие от реальных потоков вы можете предполагать использование сотен и тысяч виртуальных без значительного влияния на общие системные требования приложения. Используя их, можно также перестать беспокоиться о блокировке потоков и лимитах на количество потоков в пулах. Выполнение виртуальных потоков также не привязано только к одному реальному потоку и, как следствие, процессорному ядру, как это происходит со многими имитациями асинхронного кода. Они могут стать настоящим спасением для работы в контексте синхронного кода приложений, позволив вам использовать асинхронность только там, где от нее будет практическая польза, без превращения всего приложения в спагетти обработчиков реактора. Продемонстрируем это все на примере. Добавим в нашу рисовалку возможность делать штампы картинкой с удаленного сервиса. Срабатывать оно будет по двойному клику. Повесить его на сам холст у меня не вышло, но на контейнер вполне получилось при помощи кнопки GenerateHandler. Сначала сделаем простой обработчик, добавляющий картинку урлом в src.
@Subscribe(id = "canvasContainer", subject = "doubleClickListener")
public void onCanvasContainerClick(final ClickEvent
Тут не обошлось без лайфхака: для точного вычисления положения клика мыши относительно границ холоста нам надо знать его собственную позицию. Поэтому я запрашиваю у элемента значения при помощи вызова JavaScript метода getBoundingClientRect, который возвращает эти данные в актуальном виде.
Но, допустим, нам требуется скачивать картинку с удаленного сервера перед добавлением на холст. Для этого объявим для экрана HTTP-клиента.
Все его блокирующие запросы будут происходить как будто в отдельном потоке, не мешая при этом работе интерфейсной логики.
Чтобы убедиться в этом, добавим логирование в колбэк скачивания картинки.
client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()).thenApply((response) -> {
log.info("Image received for event.x: {}, event.y: {}", event.getClientX(), event.getClientY());
...
});
И мы увидим, что в логах маркировка текущего потока у контекста колбэка будет другой.
Тогда наш обработчик двойного нажатия вместо непосредственного рисования на холсте будет запрашивать картинку при помощи асинхронного HTTP-запроса и при ее успешном скачивании выбрасывать эвент, содержащий в себе вычисленные координаты изображения и его данные, закодированные в base64.
@Subscribe(id = "canvasContainer", subject = "doubleClickListener")
public void onCanvasContainerClick(final ClickEvent
Теперь при двойном нажатии на холсте в точке курсора будут появляться штампы из скачанных картинок.
Итого, нам удалось достаточно легко создать приложение для коллаборации пользователей. Написание аналога при помощи традиционных стеков фронтенд и бекенд технологий могло бы потребовать значительных квалификаций и коммуникаций разработчиков разных специализаций, архитекторов, менеджеров, и возможно, целых отделов девопсов;), тогда как мы справились сами, программируя только на одном языке.
Готовый код проекта можно скачать из вот этого репозитория.