21-11-24
banq
可以使用 Websockets 或 Server-Sent Events 将信息从 Spring Boot 后端推送到 UI。
这篇博文将展示如何将 Thymeleaf 与 HTMX 结合使用,通过 Server-Sent Events 将信息从服务器推送到 UI。
前往start.spring.io使用 Spring Web、Spring Security 和 Thymeleaf 启动器创建一个新的 Java 17 项目。
还要手动将以下依赖项添加到pom.xml:
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>31.0.1-jre</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>webjars-locator</artifactId> <version>0.42</version> </dependency> <dependency> <groupId>org.webjars.npm</groupId> <artifactId>htmx.org</artifactId> <version>1.6.0</version> </dependency> |
要使用服务器发送事件,我们需要在 Spring MVC 控制器中有一个 GET 方法,它返回一个SseEmitter实例。
客户端有责任首先调用这个 GET 方法来“打开通道”,以便服务器可以通过它推送事件。事件是文本,因此它们可以是 JSON 或 HTML。在这里,我们将使用 HTML,以便 htmx 可以使用更新的片段更新 DOM。作为服务器,您需要跟踪返回的SseEmitter实例,以便知道将信息发送到何处。
这是控制器中的 GET 映射:
@Controller public class PdfGenerationController { private final Multimap<String, SseEmitter> sseEmitters = MultimapBuilder.hashKeys().arrayListValues().build(); @GetMapping("/progress-events") public SseEmitter progressEvents(@AuthenticationPrincipal UserDetails userDetails) { SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE); sseEmitters.put(userDetails.getUsername(), sseEmitter); System.out.println("Adding SseEmitter for user: " + userDetails.getUsername()); sseEmitter.onCompletion(() -> LOGGER.info("SseEmitter is completed")); sseEmitter.onTimeout(() -> LOGGER.info("SseEmitter is timed out")); sseEmitter.onError((ex) -> LOGGER.info("SseEmitter got error:", ex)); return sseEmitter; } } |
在这个例子中,我们使用登录用户的用户名作为Multimap的键来跟踪SseEmitter用户登录的所有实例(例如不同的浏览器)。
在控制器中,我们还将有一个 POST 方法来模拟长时间运行的操作,例如生成 PDF 文档。这是代码:
@PostMapping public String generatePdf(@AuthenticationPrincipal UserDetails userDetails) { Collection<SseEmitter> sseEmitter = sseEmitters.get(userDetails.getUsername()); pdfGenerator.generatePdf(new SseEmitterProgressListener(sseEmitter)); return "index"; } |
PdfGenerator只是每100毫秒做一些随机的进度:
import java.util.random.RandomGenerator; import java.util.random.RandomGeneratorFactory; @Component public class PdfGenerator { private static final Logger LOGGER = LoggerFactory.getLogger(PdfGenerator.class); private final RandomGenerator randomGenerator = RandomGeneratorFactory.getDefault().create(); public void generatePdf(ProgressListener listener) { LOGGER.info("Generating PDF..."); int progress = 0; listener.onProgress(progress); do { sleep(); progress += randomGenerator.nextInt(10); LOGGER.info("Progress: {} ", progress); listener.onProgress(progress); } while (progress < 100); LOGGER.info("Done!"); listener.onCompletion(); } private void sleep() { try { Thread.sleep(100); } catch (InterruptedException e) { } } } |
每次进度发生变化时,ProgressListener都会调用回调。
我们在控制器中使用它通过 Server-Sent 事件将更新发送到客户端:
private static class SseEmitterProgressListener implements ProgressListener { private final Collection<SseEmitter> sseEmitters; public SseEmitterProgressListener(Collection<SseEmitter> sseEmitter) { this.sseEmitters = sseEmitter; } @Override public void onProgress(int value) { String html = """ <div id="progress-container" class="progress-container"> \ <div class="progress-bar" style="width:%s%%"></div> \ </div> """.formatted(value); sendToAllClients(html); } @Override public void onCompletion() { String html = "<div><a href=\"#\">Download PDF</div>"; sendToAllClients(html); } private void sendToAllClients(String html) { for (SseEmitter sseEmitter : sseEmitters) { try { sseEmitter.send(html); } catch (IOException e) { LOGGER.error(e.getMessage()); } } } } |
当有进度时,动态发送到浏览器的 DOM 中的 HTML 片段。
PDF 生成完成后,发送允许用户下载 PDF 的 HTML。
我们需要为发生的每个发送捕获异常,因为客户端可能突然不再存在并且这不会影响其他客户端。
客户端实现
我们需要显示一个按钮来开始模拟 PDF 生成并显示进度的 HTML 是这样的:
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <head> <meta charset="UTF-8"> <title>Title</title> <link rel="stylesheet" href="/css/application.css"> </head> <body> <h1>Server Sent Events Demo</h1> <div>Current user: <span sec:authentication="name"></span></div> <div hx-sse="connect:/progress-events"> <button hx-post="/" hx-swap="none">Generate PDF</button> <div style="margin-bottom: 2rem;"></div> <div id="progress-wrapper" hx-sse="swap:message"> </div> </div> <script type="text/javascript" th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script> </body> </html> |
<div hx-sse="connect:/progress-events"> 中使用hx-sse属性通过/progress-eventsURL连接到 SSE 通道。
<div id="progress-wrapper" hx-sse="swap:message"> 每次收到消息时,将 this 的 innerHTML与通过 SSE 通道收到的 HTML交换。
运行
有关此示例的完整源代码,请参阅GitHub 上的htmx-sse。
启动 Spring Boot 应用程序并在http://localhost:8080 上打开浏览器。您将被要求登录,您可以使用user1/p1或user2/进行登录p2。
还尝试使用同一用户打开几个浏览器。您应该会看到所有进度条都会更新,即使您只按下其中一个按钮来开始 PDF 生成。