原创

Spring Boot WebSocket 实时通知完整教程:把任务进度推送到页面

页面提交一个耗时任务后,接口返回成功,用户却不知道后台做到哪一步。每隔一秒请求一次状态能够解决一部分问题;如果希望服务端在进度变化时主动推过来,可以给页面建立 WebSocket 连接。

这里把收发链路做完整:浏览器连接后发送 start,服务端按十个模拟工作单元推送进度;任务完成后停掉计时器,浏览器提前断开也要清理。每个连接只启动一次任务,两个页面不会共用一份百分数。

这是一份本机演示,不生成真实报表,也没有断线续传。先把这层边界摆出来,后面的进度才不会被误读成真实业务已经完成。

先约定收到什么,页面才知道怎么更新

客户端只发送文本 start。服务端统一返回 JSON,包含 type、progress 和 message:连接建立时是 ready/0,接受任务时是 started/0,随后 progress 从10递增到90,最后 done/100。未知指令返回 error,重复开始返回 ignored,不会另开一个计时器。

演示每250ms模拟完成一个单元。真实导出任务应该根据已处理记录数、文件数或明确的工作阶段计算进度,不能把这个定时自增直接当成报表完成率。

环境使用 JDK 17、Maven 3.8.8、Spring Boot 3.5.0。前端使用浏览器原生 WebSocket,不需要 SockJS、STOMP 或前端打包工具。新建空目录后,按下列路径保存全部文件。

pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.0</version><relativePath/></parent>
  <groupId>lab</groupId><artifactId>progress-websocket</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
  <dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency></dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

src/main/resources/application.properties:

server.address=127.0.0.1
server.port=31891
spring.main.banner-mode=off

src/main/java/lab/progress/ProgressApplication.java:

package lab.progress;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ProgressApplication {
    public static void main(String[] args) { SpringApplication.run(ProgressApplication.class, args); }
}

注册 /ws/progress 处理器。保持默认同源检查,页面也由同一个应用提供,因此不需要把允许来源改成星号来让示例运行。

src/main/java/lab/progress/WebSocketConfig.java:

package lab.progress;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    private final ProgressHandler handler;
    public WebSocketConfig(ProgressHandler handler) { this.handler = handler; }
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(handler, "/ws/progress"); // 保留默认同源检查。
    }
}

进度必须属于当前连接

Handler 是 Spring 单例,不能把“当前百分数”直接放在 Handler 的一个普通成员变量里。下面用连接 ID 找到各自的 Job,Job 保存本连接的进度、是否启动过以及需要取消的 future。

每个 Job 的状态变化在同一把锁下进行,避免收到重复指令时,检查 started 与创建计时器被拆开。发送会话又包了一层并发发送装饰器,遵守底层会话不能并发发送消息的限制。其缓冲和发送时间参数是资源约束,不应当解释为端到端消息一定在一秒内送达。

src/main/java/lab/progress/ProgressHandler.java:

package lab.progress;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Component
public class ProgressHandler extends TextWebSocketHandler {
    record Event(String type, int progress, String message) {}
    static final class Job {
        final WebSocketSession session;
        int progress;
        boolean started;
        boolean closed;
        ScheduledFuture<?> future;
        Job(WebSocketSession session) { this.session = session; }
    }
    private final ObjectMapper mapper;
    private final Map<String, Job> jobs = new ConcurrentHashMap<>();
    private final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2);
    public ProgressHandler(ObjectMapper mapper) {
        this.mapper = mapper;
        executor.setRemoveOnCancelPolicy(true);
    }
    private void send(Job job, String type, String text) throws IOException {
        job.session.sendMessage(new TextMessage(mapper.writeValueAsString(new Event(type, job.progress, text))));
    }
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        Job job = new Job(new ConcurrentWebSocketSessionDecorator(session, 1000, 65536));
        jobs.put(session.getId(), job);
        synchronized (job) { send(job, "ready", "已连接,等待开始"); }
    }
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        Job job = jobs.get(session.getId());
        if (job == null) return;
        synchronized (job) {
            if (job.closed) return;
            if (!"start".equals(message.getPayload())) { send(job, "error", "只接受 start 指令"); return; }
            if (job.started) { send(job, "ignored", "该连接已经启动过任务"); return; }
            job.started = true;
            send(job, "started", "开始处理十个模拟工作单元");
            job.future = executor.scheduleAtFixedRate(() -> advance(job), 250, 250, TimeUnit.MILLISECONDS);
        }
    }
    private void advance(Job job) {
        synchronized (job) {
            if (job.closed) return;
            try {
                job.progress += 10;
                send(job, job.progress == 100 ? "done" : "progress", job.progress == 100 ? "演示任务完成" : "正在处理");
                if (job.progress == 100) job.future.cancel(false);
            } catch (IOException | RuntimeException error) {
                stop(job);
                try { job.session.close(CloseStatus.SERVER_ERROR); } catch (IOException ignored) {}
            }
        }
    }
    private void stop(Job job) {
        synchronized (job) {
            job.closed = true;
            if (job.future != null) job.future.cancel(false);
        }
    }
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        Job job = jobs.remove(session.getId());
        if (job != null) stop(job);
    }
    public Map<String, Integer> counts() {
        int running = 0;
        for (Job job : jobs.values()) {
            synchronized (job) { if (job.future != null && !job.future.isDone()) running++; }
        }
        return Map.of("connections", jobs.size(), "running", running);
    }
    @PreDestroy
    public void shutdown() {
        for (Job job : jobs.values()) {
            stop(job);
            try { job.session.close(CloseStatus.GOING_AWAY); } catch (IOException ignored) {}
        }
        jobs.clear();
        executor.shutdownNow();
    }
}

到100后仅取消这个演示任务的 future,连接仍可以保持打开。提前断开则从 jobs 移除它,并取消尚未完成的定时任务。应用退出时关闭会话和调度器,不能留下两条线程让进程一直不退。

留一个仅用于本机实验的观察接口,方便确认关闭页面以后没有继续运行的演示任务:

src/main/java/lab/progress/LabController.java:

package lab.progress;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LabController {
    private final ProgressHandler handler;
    public LabController(ProgressHandler handler) { this.handler = handler; }
    @GetMapping("/lab/connections")
    public Map<String, Integer> counts() { return handler.counts(); }
}

浏览器用三种按钮完成一次任务

页面分别提供连接、开始和断开。连接尚未建立就不能发送;开始指令发出后立即禁用按钮,服务端还会独立检查重复指令。两边各有职责,前端禁用按钮不会替服务端消除重复消息。

src/main/resources/static/index.html:

<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>任务进度推送</title><link rel="stylesheet" href="style.css"></head>
<body><main><small>SPRING BOOT / WEBSOCKET</small><h1>任务进度不用反复刷新</h1><p>一个连接对应一次有限演示任务,不生成真实报表,也不保存进度。</p>
<div class="actions"><button id="connect">连接</button><button id="start" disabled>开始任务</button><button id="close" disabled>断开</button></div>
<progress id="progress" max="100" value="0"></progress><p id="percent">0%</p><p id="status" role="status">尚未连接</p><ol id="events"></ol>
</main><script type="module" src="app.js"></script></body></html>

src/main/resources/static/app.js:

const connect = document.querySelector('#connect');
const start = document.querySelector('#start');
const close = document.querySelector('#close');
const status = document.querySelector('#status');
const progress = document.querySelector('#progress');
const percent = document.querySelector('#percent');
const events = document.querySelector('#events');
let socket = null;
connect.addEventListener('click', () => {
  if (socket && socket.readyState <= WebSocket.OPEN) return;
  const url = new URL('ws/progress', location.href);
  url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
  const current = new WebSocket(url);
  socket = current;
  connect.disabled = true;
  start.disabled = true;
  close.disabled = false;
  progress.value = 0;
  percent.textContent = '0%';
  events.replaceChildren();
  status.textContent = '正在连接';
  current.addEventListener('open', () => {
    if (socket !== current) return;
    status.textContent = '已连接,可以开始';
    start.disabled = false;
  });
  current.addEventListener('message', event => {
    if (socket !== current) return;
    try {
      const value = JSON.parse(event.data);
      if (!Number.isInteger(value.progress) || value.progress < 0 || value.progress > 100) throw Error('invalid progress');
      progress.value = value.progress;
      percent.textContent = value.progress + '%';
      status.textContent = value.message;
      const item = document.createElement('li');
      item.textContent = `${value.type} · ${value.progress}% · ${value.message}`;
      events.append(item);
      if (value.type === 'done' || value.type === 'started') start.disabled = true;
    } catch { status.textContent = '收到无法识别的进度消息'; }
  });
  current.addEventListener('error', () => { if (socket === current) status.textContent = '连接失败,请检查服务与地址'; });
  current.addEventListener('close', event => {
    if (socket !== current) return;
    socket = null;
    connect.disabled = false;
    start.disabled = true;
    close.disabled = true;
    status.textContent = `连接已关闭(${event.code}),重新连接会创建新的演示任务`;
  });
});
start.addEventListener('click', () => {
  if (socket?.readyState !== WebSocket.OPEN) return;
  start.disabled = true;
  socket.send('start');
});
close.addEventListener('click', () => socket?.close(1000, 'user closed'));
window.addEventListener('pagehide', () => socket?.close(1000, 'page hidden'));

src/main/resources/static/style.css:

* { box-sizing: border-box; }body { margin: 0; background: #f1f5f2; color: #294539; font: 16px/1.7 system-ui,sans-serif; }
main { max-width: 760px; padding: 30px 22px; margin: auto; }h1 { font-size: clamp(25px,5vw,34px); }small { color: #537963; }p,li { overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; }button { padding: 10px 18px; font: inherit; border: 1px solid #91b2a0; border-radius: 7px; background: white; color: inherit; }
button:disabled { opacity: .45; }progress { display: block; width: 100%; height: 28px; accent-color: #2a825d; }#percent { font-size: 28px; font-weight: 700; }
ol { padding-left: 24px; }#events { max-height: 300px; overflow-y: auto; } :focus-visible { outline: 3px solid #c08b39; outline-offset: 3px; }
@media(max-width:600px) { main { padding: 24px 14px; } }

WebSocket 地址从当前页面的地址生成。HTTP 页面使用 ws,HTTPS 页面改用 wss;部署到真实域名以后,外层代理和证书也要支持相应连接,不能仅换字符串就假定链路可用。

收到消息后更新的是 progress 和文本节点,不把消息拼成 HTML。回调还会核对当前 socket,避免旧连接的延迟回调修改另一份新连接状态。这里只维护当前页面这一条连接,没有实现后台自动重连。

构建后直接在浏览器操作

在项目根目录启动:

mvn clean package
java -jar target/progress-websocket-1.0.0.jar

访问 http://127.0.0.1:31891/。应用只监听本机;如果这个端口已被占用,用 --server.port=另一个空闲端口 启动,并访问对应端口。页面会根据访问地址构造 WebSocket URL,不需要同时修改脚本里的端口。

点击连接后,状态变为等待开始;点击开始,事件列表应依次出现 started、10%、20%等消息,直到100%。此时开始按钮仍不可用,避免把同一连接误当成可以无限创建后台任务的入口。

浏览器在390px视口真实收到30%进度,开始按钮已禁用,消息按序追加。 浏览器在390px视口真实收到30%进度,开始按钮已禁用,消息按序追加。

可以在另一个终端查看状态:

curl -sS http://127.0.0.1:31891/lab/connections

任务运行时,connections 和 running 都是1;完成以后仍保持这条连接,connections 为1、running 应降到0。示例实际等待了完成之后的额外时间,百分数保持100,没有偷偷继续推送110%。

点断开后再次查询,两个数都应归零。重新连接会从0开始,前一次已完成的进度不会被恢复。若要再跑一遍,请断开再连接,而不是指望重复 start 重建原任务。

失败与断开,要按协议理解

服务端对指令的检查可以用浏览器控制台独立观察。页面先断开自己的连接,再执行:

const demo = new WebSocket(`ws://${location.host}/ws/progress`);
demo.addEventListener('message', event => console.log(JSON.parse(event.data)));
demo.addEventListener('open', () => {
  demo.send('unknown');
  demo.send('start');
  demo.send('start');
});
// 观察完成后关闭这条控制台连接,避免继续占用演示会话。
// demo.close(1000);

这段控制台命令用于本文的本机 HTTP 地址。第一个指令得到 error,但连接仍可使用;第一次 start 得到 started,第二次得到 ignored。示例还实际连续发送过20次 start,只有一次 started、19次 ignored,最终只出现一组10至100的进度消息。

打开三个连接并错开开始时间,三份进度也各自从10走到100,而不会互相把对方的百分数推进一截。这项检查比只看一个窗口里的默认结果更能暴露单例共享状态错误。

任务进行到一半点断开,服务端应停止这份演示计时器。页面重新连接是一个新 Job,不是“旧任务继续跑但换了个显示位置”。真实导出系统通常需要把业务任务 ID、持久化状态与浏览器订阅分开:浏览器关掉可以停止推送订阅,但是否取消真实导出应由业务决定,不能照搬这个练习的连接生命周期。

普通 HTTP 请求访问 /ws/progress 不会建立 WebSocket 会话,要在浏览器网络面板的 WebSocket 项里看握手和消息帧。若在握手阶段已经失败,就还没进入 started 或进度协议;同源检查失败与业务指令 error 也不是同一层问题。本例实际验证过不同 Origin 的握手被拒绝,但 Origin 不是用户身份认证,非浏览器客户端也可能自定义它。

最后,把页面缩到390px再连接并开始,进度条和按钮应留在页面内,事件列表独立滚动。这里记录的是桌面 Chrome 的窄视口结果,不替代真实移动网络的断线与后台行为测试。整个演示没有消息存储、遗漏补发或跨节点分发;收到100%说明本连接的十个模拟单元结束,不说明一个外部业务结果已经持久化。

正文到此结束
评论插件初始化中...
Loading...