Привет,
Как вы уже, наверное, знаете, 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 — стандартный способ навесить свой функционал для экрана: загрузить, сконфигурировать, добавить в отображение и другие инициализационные операции.
На холсте мы сразу что-нибудь нарисуем, чтобы увидеть, что все заработало.
@Subscribe
public void onBeforeShow(final BeforeShowEvent event) {
Canvas canvas = new Canvas(800, 500);
CanvasRenderingContext2D ctx = canvas.getContext();
ctx.setStrokeStyle("red");
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(100, 100);
ctx.closePath();
ctx.stroke();
}
В дескриптор экрана добавим контейнер для холста.
<div id="canvasContainer" />
А в коде программно добавим реализацию холста в контейнер в метод onBeforeShow.
canvasContainer.add(canvas);
Так мы «привязали» компонент, созданный в коде, к объявленному в дескрипторе экрана.
Кстати, при помощи Alt+Enter в пункте меню Inject мы можем быстро и легко добавлять ссылки на элементы из дескриптора в код.
Должна отобразиться пока что только одна красная линия.
Теперь уберем все, что касается линии, кроме стиля, и добавим обработчик движения мышью, реализовав метод класса экрана в коде.
public void onCanvasMouseMove(MouseMoveEvent event) {
ctx.lineTo(event.getOffsetX(), event.getOffsetY());
ctx.stroke();
log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY());
}
В этот раз мне пришлось написать метод вручную, потому что Generate Handler опознает только компоненты, объявленные в дескрипторе и имеющие id.
Также я добавил логгер, чтобы прямо из логов видеть, что событие обрабатывается. Его можно заинжектить из контекстного меню или добавить строчку инициализации на уровне свойств класса.
private static final Logger log = LoggerFactory.getLogger(DrawBoardView.class);
Привязывать добавленный ранее обработчик движения мыши надо будет тоже в методе onBeforeShow.
canvas.addMouseMoveListener(this::onCanvasMouseMove);
Теперь мы можем вернуться в браузер и порисовать, двигая мышью.
Но когда на любое движение мыши без остановки происходит отрисовка линии — это не очень удобно, поэтому мы будем рисовать только когда нажата левая кнопка мыши. Для этого классу экрана добавим признак, определяющий, что рисование сейчас происходит.
protected Boolean drawingEnabled = false;
А на события mousedown и mouseup добавим обработчики его включения и выключения, а также вызовы методов начала и завершения фигуры у контекста. Его следует поднять на уровень свойства класса экрана, чтобы иметь доступ из других методов класса.
canvas.addMouseMoveListener(this::onCanvasMouseMove);
canvas.addMouseDownListener(this::onCanvasMouseDown);
canvas.addMouseUpListener(this::onCanvasMouseUp);
canvasContainer.add(canvas);
}
public void onCanvasMouseMove(MouseMoveEvent event) {
ctx.lineTo(event.getOffsetX(), event.getOffsetY());
ctx.stroke();
log.info("event.x: {}, event.y: {}", event.getOffsetX(), event.getOffsetY());
}
public void onCanvasMouseDown(MouseDownEvent event) {
this.drawingEnabled = true;
ctx.beginPath();
}
public void onCanvasMouseUp(MouseUpEvent event) {
ctx.closePath();
this.drawingEnabled = false;
}
Остается добавить только проверку режима в обработчике движения мыши, и наша рисовалка станет как у людей.
Однако, мы хотим сделать многопользовательское приложение, и в этом нам поможет инструмент для работы с шиной событий 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());
}
А при рисовании мы будем сверять значение с текущим пользователем и менять цвет в зависимости от того сам клиент рисует или получает эвенты рисования от другого пользователя.
@EventListener
public void boardMoveEventHandler(DrawBoardMoveEvent event) {
ctx.setStrokeStyle(currentAuthentication.getUser().getUsername().equals(event.getUsername()) ? "red" : "green");
ctx.lineTo(event.getMouseMoveEvent().getOffsetX(), event.getMouseMoveEvent().getOffsetY());
ctx.stroke();
}
Посмотрим на результат.
Теперь наши пользователи могут различать вклад друг друга, но неплохо было бы еще добавить индикацию, когда один из них пребывает в процессе рисования. Для этого создадим эффект мигания рисунка.
Конечно, в реальном случае это, скорее всего, будет лучше сделать исключительно с помощью анимации CSS, однако у нас демонстрационный проект и мы будем мигать, просто изменяя во времени это свойство. Как это сделать?
Чтобы получился эффект мигания, мы будем изменять свойство opacity у холста, уменьшая и увеличивая его значение. Для этого нам потребуется последовательность значений в интервале от 0.6 до 1.0, изменяющаяся с шагом 0.2, при этом сначала убывая от единицы, а затем возрастая.
Чтобы это свойство изменялось у элемента последовательно во времени, нам потребуется использовать планировщик задач.
Для нашего случая подойдет однопоточный экзекьютор с возможностью выполнять отложенные и повторяющиеся задачи.
static ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
Интерфейс ScheduledExecutorService позволяет создавать отложенные и повторяющиеся с заданным интервалом задачи.
Также надо добавить код, сабмитящий в него задачи по выбрасыванию событий с новым значением opacity.
@EventListener
public void boardMoveEventHandler(DrawBoardMoveEvent event) {
boolean isCurrentUserDrawing = currentAuthentication.getUser().getUsername().equals(event.getUsername());
ctx.setStrokeStyle(isCurrentUserDrawing? "red" : "green");
ctx.lineTo(event.getMouseMoveEvent().getOffsetX(), event.getMouseMoveEvent().getOffsetY());
ctx.stroke();
List<String> users = new ArrayList<>() {{ add(currentAuthentication.getUser().getUsername() );}};
if (!isCurrentUserDrawing) {
Iterator<Integer> it = IntStream.range(0, 5).boxed().iterator();
Stream.of(1.0, 0.8, 0.6, 0.8, 1.0).forEach((i) -> {
long nextTime = Double.valueOf(it.next() * 500.0).longValue();
executorService.schedule(() -> {
uiEventPublisher.publishEventForUsers(new DrawBoardOpacityChangeEvent(i), users);
}, nextTime, TimeUnit.MILLISECONDS);
});
} else {
uiEventPublisher.publishEventForUsers(new DrawBoardOpacityChangeEvent(1.0), users);
}
}
А слушатель события изменения прозрачности будет, собственно, устанавливать значение.
@EventListener
public void opacityChangeEventHandler(DrawBoardOpacityChangeEvent event) {
canvasContainer.getElement().setAttribute("style", "opacity: " + String.valueOf(event.getOpacity()));
}
Почему не сделать это прямо в задаче для ExecutorService? Дело в том, что задачи экзекьютора несмотря на то, что могут выполняться в одном реальном потоке, ведут себя как настоящие потоки. В нашем случае это значит, что они не имеют доступа к контексту выполнения пользовательского интерфейса, и тут нас снова выручает publishEventForUsers, позволяя потокам осуществлять взаимодействие с интерфейсом.
В Java начиная с версии 21 появилась возможность использовать экзекьюторы виртуальных потоков в дополнение к обычным. Cо стороны кода экзекьютор задач в виртуальных потоках имеет тот же интерфейс, что и для реальных, и отличается только реализацией.
Преимущество изоляции виртуальных потоков заключается в том, что, выполняя в них «тяжелые» задачи, мы не будем порождать блокировки интерфейса пользователей. Это особенно важно для операций ввода-вывода, таких, как запросы на сторонние сервисы, чтение и запись данных в файлы и базы. Веб-интерфейс в браузере, так же как в играх, оконных системах, десктопных тулкитах, работает в однопоточном режиме и лучшее, что мы можем сделать для обеспечения его отзывчивости — это выполнять «тяжелые» задачи в отдельных потоках, обрабатывая в UI-потоке только результаты их выполнения. Некоторые виды операций, такие как скачивание ресурсов, браузер сам выполнит в фоновых потоках, другие, например выполнение большого цикла или ресурсоемкого вычисления из JavaScript-контекста завесят интерфейс пользователя.
Говорят, виртуальные потоки как будто предназначены для эффективной работы с блокирующими операциями при высокой их интенсивности. Их создание происходит «с нулевой стоимостью». Заблокировавшись, виртуальный поток освободит контекст реального потока, в котором выполнялся для других задач, не вызвав его блокировки. Единственное исключение — когда вы имеете дело с кодом, в котором присутствует большое количество synchronized-блоков, и это не для всех случаев. В отличие от реальных потоков вы можете предполагать использование сотен и тысяч виртуальных без значительного влияния на общие системные требования приложения. Используя их, можно также перестать беспокоиться о блокировке потоков и лимитах на количество потоков в пулах. Выполнение виртуальных потоков также не привязано только к одному реальному потоку и, как следствие, процессорному ядру, как это происходит со многими имитациями асинхронного кода. Они могут стать настоящим спасением для работы в контексте синхронного кода приложений, позволив вам использовать асинхронность только там, где от нее будет практическая польза, без превращения всего приложения в спагетти обработчиков реактора. Продемонстрируем это все на примере. Добавим в нашу рисовалку возможность делать штампы картинкой с удаленного сервиса. Срабатывать оно будет по двойному клику. Повесить его на сам холст у меня не вышло, но на контейнер вполне получилось при помощи кнопки GenerateHandler. Сначала сделаем простой обработчик, добавляющий картинку урлом в src.
@Subscribe(id = "canvasContainer", subject = "doubleClickListener")
public void onCanvasContainerClick(final ClickEvent<Div> event) {
PendingJavaScriptResult res = canvasContainer.getElement().callJsFunction("getBoundingClientRect");
res.then((rect) -> {
canvasLeft = ((JsonObject) rect).getNumber("left");
canvasTop = ((JsonObject) rect).getNumber("top");
ctx.drawImage("https://upload.wikimedia.org/wikipedia/commons/5/50/Smile_Image.png", event.getClientX() - canvasLeft, event.getClientY() - canvasTop, 100, 100);
});
}
Тут не обошлось без лайфхака: для точного вычисления положения клика мыши относительно границ холоста нам надо знать его собственную позицию. Поэтому я запрашиваю у элемента значения при помощи вызова JavaScript метода getBoundingClientRect, который возвращает эти данные в актуальном виде.
Но, допустим, нам требуется скачивать картинку с удаленного сервера перед добавлением на холст. Для этого объявим для экрана HTTP-клиента.
protected HttpClient client;
И проинициализируем его в методе onBeforeShow.
client = HttpClient.newBuilder()
.executor(httpExecutorService)
.build();
Наш клиент будет использовать экзекьютор виртуальных потоков, который мы проинициализируем аналогичным образом.
static ExecutorService httpExecutorService = Executors.newVirtualThreadPerTaskExecutor();
Все его блокирующие запросы будут происходить как будто в отдельном потоке, не мешая при этом работе интерфейсной логики.
Чтобы убедиться в этом, добавим логирование в колбэк скачивания картинки.
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<Div> event) {
PendingJavaScriptResult res = canvasContainer.getElement().callJsFunction("getBoundingClientRect");
res.then((rect) -> {
canvasLeft = ((JsonObject) rect).getNumber("left");
canvasTop = ((JsonObject) rect).getNumber("top");
try {
HttpRequest request = HttpRequest.newBuilder(new URI("https://upload.wikimedia.org/wikipedia/commons/5/50/Smile_Image.png"))
.version(HttpClient.Version.HTTP_1_1)
.header("Content-type", "image/png")
.build();
client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()).thenApply((response) -> {
uiEventPublisher.publishEventForUsers(new DrawBoardImageAddedEvent(response,
event.getClientX() - canvasLeft, event.getClientY() - canvasTop,
"data:image/png;base64," + Base64.getEncoder().encodeToString(response.body())), null);
return null;
});
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
});
}
Обработчику этого события останется только отрисовать полученные данные на холсте.
@EventListener
public void boardImageAddedHandler(DrawBoardImageAddedEvent event) {
ctx.beginPath();
ctx.drawImage(event.getSrc(), event.getX(), event.getY(), 100, 100);
ctx.closePath();
}
Теперь при двойном нажатии на холсте в точке курсора будут появляться штампы из скачанных картинок.
Итого, нам удалось достаточно легко создать приложение для коллаборации пользователей. Написание аналога при помощи традиционных стеков фронтенд и бекенд технологий могло бы потребовать значительных квалификаций и коммуникаций разработчиков разных специализаций, архитекторов, менеджеров, и возможно, целых отделов девопсов;), тогда как мы справились сами, программируя только на одном языке.
Готовый код проекта можно скачать из вот этого репозитория.
KartonDev
Познавательная статья, спасибо.
Вопрос - если делать нечто большее, чем MVP для интеракции пользователей в рантайме, а полноценное решение для конечного клиента, например, показывать на карте - где сейчас находится курьер, как думаете, не лучше ли будет использовать реактивный стек или Virtual Threads хватит?
ПС полноценный вопрос - не будет ли virtual thread "троттлить" вычисления?
ant1free2e Автор
Виртуальные треды сделали как раз что-бы не троттлить вычисления, т.е. не блокировать платформенные(реальные) потоки лишний раз. Судя по бенчмаркам они никак не ухудшают и не улучшают чистой производительности вычислений, это конечно без учета затрат на создание новых платформенных потоков и как раз этих самых простоев на блокировках. Важный момент тут в том, что виртуальные потоки выполняются на пуле реальных потоков, т.е. они не вместо платформенных, а вместе.
Мне кажется, "реактивный стек", такой как в Vert.x - это как раз то, что стало основой для виртуальных потоков, не уверен, что он будет работать производительнее виртуальных.
Ну а задача рисования точек на карте гораздо менее требовательна чем рисование пользователями на холсте, и там ботлнек будет в частоте поступления данных от GPS.
1q2w1q2w
Судя по последним хайлоадам там ничего общего, project loom никак не был связан с реактивным стеком. Принципы тоже разные, в одном эвент луп, в другом шедулер в jvm вместо os scheduler. Ну и естественно написание и выполнение кода тоже принципиально отличаются.
ant1free2e Автор
общее то, что они работают по принципу авианосца, когда есть пул реальных потоков, на которые распределяется выполнение виртуальных. А эвентлуп у вас будет и в приложении, что управляет задачами виртуальных потоков или вы его сымитируете. Нет, ну можно конечно и подождать джойном или что-то такое ещё использовать, но это получится эвентлуп с одной итерацией;)