在Thymeleaf和HTMX中使用服务器发送的事件 - Wim


可以使用 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 生成。