流式输出的 Java 实战:SSE 在 Spring 里怎么接
打字机效果前端等着要,后端返回一坨 JSON 就没戏。理论篇讲过,流式就是一次不收尾的 HTTP 响应,一行一块的 data 推给浏览器。这篇把落地的活补上:报文长什么样、Spring 里两种接法、反向代理和超时的坑,最后给你一个单文件的实验台。
先看报文
服务端把 Content-Type 设成 text/event-stream,然后一块一块写,data: 开头是数据行,空行是事件结束。
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"delta": "北京"}
data: {"delta": "25 度"}
data: [DONE]
Java 侧的活就是维持这个响应不关,往里写行。Spring 把这件事封装成了两种姿势。
Spring 的两种接法
第一种,SseEmitter,MVC 的老朋友。方法返回一个 SseEmitter,你拿到它想什么时候发就什么时候发,send 发事件,complete 收尾。适合任何 Spring MVC 工程。
@GetMapping("/chat/stream")
public SseEmitter chatStream(@RequestParam String question) {
SseEmitter emitter = new SseEmitter(60_000L); // 60 秒超时
CompletableFuture.runAsync(() -> {
try {
for (String delta : model.stream(question)) { // 模型的流
emitter.send(SseEmitter.event().data(delta));
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
第二种,Spring AI 的 stream()。框架直接给你 Flux 形状的增量流,WebFlux 或者带 reactor 的工程里最顺手,一行 call 换 stream 的事。
Flux<String> tokens = chatClient.prompt()
.user(question)
.stream()
.content();
(Spring AI 的 stream API 形状按 1.x 之后的口径,方法名以你所用版本文档为准。)
三个坑
第一个,反向代理缓冲。Nginx 默认把上游响应攒够再发,流式就变成「等半天一坨」,要在反向代理配置里关掉缓冲(proxy_buffering off)。这一条在反向代理篇讲过,这里是它的实战现场。
第二个,超时两头设。SseEmitter 有自己的超时,网关和反向代理也有空闲超时;流式响应没有数据流动时容易被中间设备掐断,定时发一个心跳注释行(: ping),连接就能活着。
第三个,错误别裸抛。流推到一半炸了,响应头早就发出去了,HTTP 状态码改不了,只能在流里发一个 error 事件再关,前端要能接住这个形状。
亲手开一个实验台
下面用一段 Java 起一个 SSE 服务,JDK 自带的 HttpServer 写 text/event-stream,客户端连上逐块打印。直接运行 java SseLab.java(JDK 11+),看字符一块一块到。
// 直接运行:java SseLab.java
// SSE 实验台:服务端逐块推送,客户端逐块到达。
import com.sun.net.httpserver.HttpServer;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class SseLab {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/stream", exchange -> {
exchange.getResponseHeaders().add("Content-Type", "text/event-stream");
exchange.sendResponseHeaders(200, 0); // 0 表示长度未知,流式
var out = exchange.getResponseBody();
String[] tokens = {"北京", "今天", "25 度", ",晴"};
for (String t : tokens) {
out.write(("data: " + t + "\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
Thread.sleep(200); // 模拟模型一块块生成
}
out.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8));
out.flush();
out.close();
});
server.start();
int port = server.getAddress().getPort();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + port + "/stream"))
.build();
HttpResponse<InputStream> resp = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofInputStream());
System.out.println("客户端开始收流:");
try (var reader = new java.io.BufferedReader(
new java.io.InputStreamReader(resp.body(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
System.out.println(" 收到一块:" + line.substring(6));
if (line.contains("[DONE]")) {
break;
}
}
}
}
server.stop(0);
System.out.println("流结束。每块之间隔着 200ms,这就是打字机的节奏。");
}
}
记住一句话:流式是不收尾的响应,后端的活是别关连接、别攒缓冲、别裸抛错误。
适用边界
这一篇管 SSE 的 Java 落地。WebSocket 的双向场景、HTTP/2 下的流式,以及前端渲染的增量合并,是另外的题。Spring AI 的 stream 各版本方法名有微调,本文按公开文档口径整理。跑完实验台再接真模型,中间件的每一层(网关、反向代理、CDN)都要过一遍「会不会缓冲」的检查——打字机效果是全链路的体面。
1万+



被折叠的 条评论
为什么被折叠?



