Привет, 

Как вы уже, наверное, знаете, 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(); 
} 

Теперь при двойном нажатии на холсте в точке курсора будут появляться штампы из скачанных картинок. 

Итого, нам удалось достаточно легко создать приложение для коллаборации пользователей. Написание аналога при помощи традиционных стеков фронтенд и бекенд технологий могло бы потребовать значительных квалификаций и коммуникаций разработчиков разных специализаций, архитекторов, менеджеров, и возможно, целых отделов девопсов;), тогда как мы справились сами, программируя только на одном языке.  

Готовый код проекта можно скачать из вот этого репозитория.

Комментарии (13)


  1. KartonDev
    21.08.2024 10:25

    Познавательная статья, спасибо.
    Вопрос - если делать нечто большее, чем MVP для интеракции пользователей в рантайме, а полноценное решение для конечного клиента, например, показывать на карте - где сейчас находится курьер, как думаете, не лучше ли будет использовать реактивный стек или Virtual Threads хватит?
    ПС полноценный вопрос - не будет ли virtual thread "троттлить" вычисления?


    1. ant1free2e Автор
      21.08.2024 10:25

      Виртуальные треды сделали как раз что-бы не троттлить вычисления, т.е. не блокировать платформенные(реальные) потоки лишний раз. Судя по бенчмаркам они никак не ухудшают и не улучшают чистой производительности вычислений, это конечно без учета затрат на создание новых платформенных потоков и как раз этих самых простоев на блокировках. Важный момент тут в том, что виртуальные потоки выполняются на пуле реальных потоков, т.е. они не вместо платформенных, а вместе.
      Мне кажется, "реактивный стек", такой как в Vert.x - это как раз то, что стало основой для виртуальных потоков, не уверен, что он будет работать производительнее виртуальных.
      Ну а задача рисования точек на карте гораздо менее требовательна чем рисование пользователями на холсте, и там ботлнек будет в частоте поступления данных от GPS.


      1. 1q2w1q2w
        21.08.2024 10:25

        Судя по последним хайлоадам там ничего общего, project loom никак не был связан с реактивным стеком. Принципы тоже разные, в одном эвент луп, в другом шедулер в jvm вместо os scheduler. Ну и естественно написание и выполнение кода тоже принципиально отличаются.


        1. ant1free2e Автор
          21.08.2024 10:25

          общее то, что они работают по принципу авианосца, когда есть пул реальных потоков, на которые распределяется выполнение виртуальных. А эвентлуп у вас будет и в приложении, что управляет задачами виртуальных потоков или вы его сымитируете. Нет, ну можно конечно и подождать джойном или что-то такое ещё использовать, но это получится эвентлуп с одной итерацией;)