Spring Boot WebSocket 实时通知完整教程:把任务进度推送到页面
- 发布时间:2026-10-28 20:00:00
- 本文热度:浏览 6 赞 0 评论 0
- 文章标签: Spring Boot WebSocket 实时通知
- 全文共1字,阅读约需1分钟
页面提交一个耗时任务后,接口返回成功,用户却不知道后台做到哪一步。每隔一秒请求一次状态能够解决一部分问题;如果希望服务端在进度变化时主动推过来,可以给页面建立 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%。此时开始按钮仍不可用,避免把同一连接误当成可以无限创建后台任务的入口。

可以在另一个终端查看状态:
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%说明本连接的十个模拟单元结束,不说明一个外部业务结果已经持久化。