
本文介绍一种不依赖大型框架、兼容 war 部署的纯 servlet 方案:利用 http 202 accepted 状态码配合 server-sent events(sse),实现“先显示加载提示、后刷新结果”的两次响应式页面更新。
本文介绍一种不依赖大型框架、兼容 war 部署的纯 servlet 方案:利用 http 202 accepted 状态码配合 server-sent events(sse),实现“先显示加载提示、后刷新结果”的两次响应式页面更新。
在传统同步 Servlet 请求中,HTTP 响应只能发送一次,无法满足“先返回加载提示、再推送最终结果”的交互需求。强行复用响应流或切换为 WebSocket 会引入部署复杂性(如需额外 WebSocket 容器支持)、破坏 WAR 可移植性,且与 Servlet 规范中 ServletResponse.getOutputStream()/getWriter() 的单次提交语义冲突。
推荐采用 HTTP 状态码分层 + SSE 推送 的轻量组合方案,完全基于标准 Servlet API,无需 Spring、Vert.x 或 Netty 等框架,可直接打包为标准 WAR 文件部署至 Tomcat、Jetty 或 WildFly。
✅ 核心流程设计
- 客户端发起请求 → Servlet 接收后立即启动后台线程执行耗时任务;
- 首次响应(即时) → 返回 HTTP 202 Accepted,附带唯一 jobId(如 {"status":"accepted","jobId":"abc123"});
- 客户端监听 SSE → 使用 EventSource 连接 /events?jobId=abc123;
- 服务端推送事件 → 后台任务完成后,通过 HttpServletResponse.getWriter() 向该 SSE 连接写入 data: {...} 格式消息;
- 结果获取(按需) → 若结果数据较大,SSE 中仅推送完成信号,前端再发 GET 请求 /result?jobId=abc123 获取完整数据。
? 关键代码示例
1. 主处理 Servlet(/process)
@WebServlet("/process")
public class ProcessServlet extends HttpServlet {
private static final Map<string jobresult> JOB_STORE = new ConcurrentHashMap();
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String jobId = UUID.randomUUID().toString();
// 启动异步任务(注意:生产环境建议使用 ManagedExecutorService)
new Thread(() -> {
try {
Thread.sleep(5000); // 模拟耗时操作
String result = "Final result for " + jobId;
JOB_STORE.put(jobId, new JobResult(true, result));
// 触发 SSE 推送(见下文 EventServlet)
} catch (InterruptedException e) {
JOB_STORE.put(jobId, new JobResult(false, "Failed"));
}
}).start();
// 立即返回 202
resp.setStatus(HttpServletResponse.SC_ACCEPTED);
resp.setContentType("application/json");
resp.getWriter().write("{"status":"accepted","jobId":"" + jobId + ""}");
}
}</string>
2. SSE 推送 Servlet(/events)
@WebServlet("/events")
public class EventServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String jobId = req.getParameter("jobId");
if (jobId == null) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
resp.setContentType("text/event-stream");
resp.setHeader("Cache-Control", "no-cache");
resp.setHeader("Connection", "keep-alive");
resp.setCharacterEncoding("UTF-8");
PrintWriter writer = resp.getWriter();
// 发送初始化心跳(可选)
writer.write(": SSE connected
");
writer.flush();
// 轮询检查任务状态(生产环境建议改用 BlockingQueue 或回调注册)
while (!Thread.currentThread().isInterrupted()) {
JobResult result = JOB_STORE.get(jobId);
if (result != null) {
String data = String.format("data: {"done":%b,"result":"%s"}
",
result.success, result.data.replace(""", "\""));
writer.write(data);
writer.flush();
break; // 任务完成,结束流
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
break;
}
}
}
}
3. 前端 JavaScript 调用
async function startLongTask() {
const res = await fetch('/process', { method: 'POST' });
const { jobId } = await res.json();
// 显示加载提示
document.getElementById('dialog').style.display = 'block';
// 监听 SSE
const eventSource = new EventSource(`/events?jobId=${jobId}`);
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.done) {
document.getElementById('dialog').style.display = 'none';
document.getElementById('result').textContent = data.result;
eventSource.close();
}
};
}
⚠️ 注意事项与最佳实践
- 线程安全:后台任务共享状态(如 JOB_STORE)必须使用线程安全容器(ConcurrentHashMap);
- 资源清理:SSE 连接超时或关闭后,应移除对应 jobId 避免内存泄漏;
- 超时控制:为 SSE 连接设置合理超时(如 Nginx 中配置 proxy_read_timeout),并由前端重连机制兜底;
- WAR 兼容性:本方案仅依赖 javax.servlet.* API,Tomcat 8.5+ / Jetty 9+ 原生支持,无需额外依赖;
- 替代方案对比:WebSocket 虽实时性更高,但需容器级支持、增加握手开销,而 SSE 本质是长连接 HTTP 流,更轻量、更易调试、天然支持自动重连。
此方案以最小侵入性达成两次更新目标:一次瞬时反馈建立用户信任,一次异步推送交付真实结果,兼顾标准性、可维护性与部署简洁性。











