在Thymeleaf和HTMX中使用伺服器傳送的事件 - Wim

banq發表於2021-11-24

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

 

相關文章