写在最前

哈喽,各位友友们,我是小特同学 🤚
欢迎来到 AI 辅助趣味学习 SpringAI 专栏。
下面说一下这个专栏的文章体系:
🤖AI 的任务
文章由 AI 生成,但代码我敲、坑我踩。
AI 负责把抽象概念翻译成 人话 + 比喻 ,我负责学懂、跑通、记录。
👨💻我的任务
- ✅ 每篇学完
- ✅ 代码跑通
- ✅ 踩坑必写
📖
文章特色AI 提效输出大白话 + 真实可运行代码
不装不水,诚实学习。看完就能敲,敲完就能用。
想快速上手 SpringAI 的友友们,看完保证不吃亏 💪
一、前言:为什么你点了个外卖,要等30秒才看到"已接单"?😤
兄弟们,你们有没有遇到过这种场景:打开一个AI聊天页面,问它"给我写一篇关于SSE的博客",然后你就在那儿干等,转圈圈转了半天,突然"啪"一下,整篇博客出来了......
是不是感觉像在等对象化妆? 💄😂
其实现在牛逼的AI都是一个字一个字往外蹦 的,就像我打游戏时队友的"救救我救救我救救我"刷屏一样,这叫流式输出。
那这玩意是怎么实现的呢?SSE (Server-Sent Events)就是幕后大功臣!今天就带你把它扒个精光!
单词解释

二、SSE协议:HTTP也能"长舌妇"式推送?🤫
2.1 HTTP的"天生缺陷"
HTTP协议大家都很熟了,请求-响应模型,你问一句,服务器答一句,然后就挂电话了。服务器想说第二句?对不起,您拨打的用户已关机📞。
但是流式输出需要服务器主动哔哔,咋整?
SSE(Server-Sent Events) 说:放着我来!💪
SSE的本质:服务器跟客户端打个招呼------"兄弟,接下来我要给你发流消息了,你连接别关啊,等着收菜就行!"
2.2 SSE的四大金刚特性✨
| 特性 | 解释(人话版) |
|---|---|
| 基于HTTP | 不用搞WebSocket那种新端口,你家路由器不用重新配,省心! |
| 单向通信 | 只能服务器给你发,你不能通过这个连接给服务器发(想发?再开个请求呗) |
| 自动重连 | 网断了?浏览器自己会重连,不用你写一行代码,良心! |
| 自定义消息类型 | 可以发普通消息,也可以发foo、end等自定义事件,像发微信消息还能选"红包"一样 |
2.3 响应头长啥样?🤔
服务器想发SSE,必须设置下面这两个头,缺一不可:咱不用学原理,先用起来呗
bash
Content-Type: text/event-stream;charset=utf-8
Connection: keep-alive
温馨提示⚠️:
text/event-stream就是告诉浏览器"我要开始流式表演了",keep-alive是"别挂电话,我还有话说"。
2.4 SSE数据格式:不是随便发的!📝
SSE的消息格式非常讲究,每个消息由若干行组成,每个message之间用两个换行\n\n分隔。
每行格式:[field]: value\n
field能取啥值?
| field | 是否必需 | 作用 |
|---|---|---|
data |
✅ 必需 | 你要发的真实数据内容 |
event |
❌ 可选 | 自定义事件类型(默认是message) |
id |
❌ 可选 | 每条数据的编号,断线重连时可以告诉服务器从哪条继续 |
retry |
❌ 可选 | 告诉浏览器如果断了,过多少毫秒重新连接 |
还有以冒号
:开头的行,表示注释,浏览器会忽略它。
举个例子(你就当是发朋友圈的格式):
event: foo
data: 这是一条foo事件的数据
data: 这是一条没有event的普通消息
event: end
data: 游戏结束啦
看到没?data是正文,event是类型,每个消息块用空行隔开。
三、原生Java手写SSE?太麻烦了些许,Spring封装的有!🚀
3.1 传统Servlet写法(了解即可)
java
@RequestMapping("/data")
public void data(HttpServletResponse response) throws IOException, InterruptedException {
// 设置响应头为 SSE(服务端推送)格式,编码UTF-8,告诉浏览器这是流式推送
response.setContentType("text/event-stream;charset=utf-8");
// 获取响应输出流:用于向浏览器发送文本数据
PrintWriter writer = response.getWriter();
// 循环20次:一共向浏览器推送20条消息
for (int i = 0; i < 20; i++) {
// 按照 SSE 格式推送数据:固定格式 data: 内容\n\n
// 必须以 data: 开头,以两个换行结尾,浏览器才能识别
writer.write("data: " + new Date() + "\n\n");
// 刷新缓冲区:强制把数据立即发送到浏览器,不刷新会攒在一起发送
writer.flush();
// 线程休眠1秒:每隔1秒推送一次消息
Thread.sleep(1000);
}
}
前端JS用EventSource接收,so easy:
html
<!-- 定义一个div容器,用来展示后端实时推送过来的数据 -->
<div id="sse"></div>
<style>
/* 全局样式:让页面居中、好看一点 */
body {
margin: 0;
padding: 30px;
font-family: "Microsoft YaHei", Arial, sans-serif;
background-color: #f5f7fa;
}
/* SSE 展示容器样式:居中、卡片感、阴影、圆角 */
#sse {
width: 600px;
min-height: 100px;
line-height: 100px;
margin: 50px auto;
padding: 0 20px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
text-align: center;
font-size: 18px;
color: #333;
border: 1px solid #eee;
}
</style>
<script>
// 1. 创建SSE连接对象,请求后端接口路径 /sse/data
// EventSource 是浏览器原生API,专门用来接收服务端SSE流式推送
let eventSource = new EventSource("/sse/data");
// 2. 监听默认message事件(后端未自定义事件名时,默认走这个)
// 后端每发送一次 data: xxx\n\n 格式数据,就会触发此回调
eventSource.onmessage = function(event){
// 获取后端推送过来的数据 event.data,赋值到页面div中展示
// innerHTML 会覆盖更新页面内容
document.getElementById("sse").innerHTML = event.data;
};
// 监听连接错误(比如后端断开)
eventSource.onerror = function() {
document.getElementById("sse").innerHTML = "连接断开,正在重连...";
document.getElementById("sse").style.color = "red";
};
</script>

结果

图解流程🚀(一图胜言)

3.2 Spring 5 + WebFlux:这才是真正的优雅!👨🍳
上面那种写法太原始了,还得自己循环、sleep、flush......累不累啊?
Spring从4.2开始支持SSE,5以后配合WebFlux的Flux,简直爽到飞起!
四、Flux是什么?传送带懂不懂?📦

Flux是Project Reactor的核心API,你就把它想象成一个传送带:
- 异步传送:数据像快递包裹一样,来一个传一个,不用等所有包裹到齐
- 灵活加工:可以在传送带上加过滤、转换的"机器",比如把"苹果"变成"APPLE"
- 弹性控制:接收方能控制速度,不会把接收方撑死(背压机制)
4.1 Flux三板斧:创建→处理→订阅
java
public class FluxDemoTest {
public static void main(String[] args) throws InterruptedException {
// 1. 创建Flux(把水果放上传送带)
Flux<String> fruitFlux = Flux.just("Apple", "Banana", "Cherry")
.delayElements(Duration.ofSeconds(1)); // 每隔1秒掉一个水果下来
// 2. 处理数据 + 3. 订阅(加工并开始传送)
fruitFlux.map(String::toUpperCase) // 转大写
.subscribe(System.out::println); // 每来一个就打印
Thread.sleep(4000); // 防止主线程结束,看不到效果
}
}
输出:
APPLE
BANANA
CHERRY
代码:
java
public static void main(String[] args) throws InterruptedException {
// 1. 创建 Flux 数据流(包含3个字符串元素)
Flux.just("hello", "hi", "haha")
// 2. 每个元素之间延迟 1 秒发出(实现每隔1秒推送一个数据)
.delayElements(Duration.ofSeconds(1))
// 3. 数据转换:把每个字符串 转成 大写
// String::toUpperCase = 方法引用语法
// 等价于:s -> s.toUpperCase()
// 作用:调用 String 类里的 toUpperCase() 方法,把字符串变大写
.map(String::toUpperCase)
// 4. 订阅数据流(订阅 = 开始执行!响应式编程:不订阅不执行)
// System.out::println = 方法引用,等价于 s -> System.out.println(s)
.subscribe(System.out::println);
// 5. 主线程等待,防止程序直接退出
// 因为 delayElements 是异步的,主线程不等待会直接结束
Thread.sleep(4000);
}

4.2 创建Flux的花式玩法🎨
java
// 方式1:just - 直接塞几个元素
Flux<String> flux1 = Flux.just("A", "B", "C");
// 方式2:fromIterable - 从List/Set创建
Flux<Integer> flux2 = Flux.fromIterable(Arrays.asList(1, 2, 3));
// 方式3:range - 生成整数范围
Flux<Integer> flux3 = Flux.range(1, 5); // 1,2,3,4,5
// 方式4:interval - 定时发射(无限流,小心使用)
Flux<Long> flux4 = Flux.interval(Duration.ofSeconds(1));
4.3 操作符:传送带上的"加工机器"🔧
| 操作符 | 作用 | 代码示例 |
|---|---|---|
map() |
一对一转换 | .map(String::toUpperCase) |
filter() |
条件过滤 | .filter(s -> s.length() > 5) |
take() |
只取前N个 | .take(2) |
merge() |
合并多个Flux(不保证顺序) | Flux.merge(fluxA, fluxB) |
concat() |
顺序拼接(保证A完了再B) | Flux.concat(fluxA, fluxB) |
delayElements() |
延迟发射 | .delayElements(Duration.ofSeconds(1)) |
举个栗子🌰:
java
Flux.just("Apple", "Banana", "Avocado", "Cherry")
.map(String::toUpperCase) // 变大写
.filter(s -> s.startsWith("A")) // 只要A开头的
.subscribe(System.out::println);
// 输出:APPLE, AVOCADO
4.4 重要!Flux是"懒汉" - 不订阅不干活💤
记住:Flux就像你女朋友问你"想吃什么",你不说"随便"然后选一个,她是不会行动的。 subscribe()就是那个"选一个"的动作,只有调用了它,数据才会开始流动。
java
Flux<String> flux = Flux.just("A", "B", "C");
// 此时啥都没发生,flux只是个空壳子
flux.subscribe(System.out::println);
// 订阅后,ABC才打印出来
五、Spring中SSE流式响应:一行代码搞定!🎯
有了Flux,写一个每秒推送当前时间的接口,就跟喝水一样简单:
java
/**
* SSE 流式推送接口
* 访问路径:/sse/stream
* produces = MediaType.TEXT_EVENT_STREAM_VALUE
* 作用:设置响应头 Content-Type: text/event-stream;charset=UTF-8
* 告诉浏览器这是 SSE 服务端推送格式
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream() {
// Flux.interval(Duration.ofSeconds(1))
// 作用:创建一个定时器数据流,每隔1秒发出一个数字(0,1,2,3,...无限递增)
// 相当于:每隔1秒触发一次推送
// .map(sequence -> new Date().toString())
// map:把数据流里的元素进行转换
// sequence:就是上面发出的 0,1,2,3...(这里我们用不到它,只是用来触发时间)
// 每次触发,都获取【当前最新时间】并转成字符串返回
return Flux.interval(Duration.ofSeconds(1))
.map(sequence -> new Date().toString());
}
注意! produces = MediaType.TEXT_EVENT_STREAM_VALUE 这个注解不能少,它告诉Spring返回的是SSE流,不是普通JSON【作用:设置响应头 Content-Type: text/event-stream;charset=UTF-8】。
前端代码:
html
<div id="sse"></div>
<script>
let eventSource = new EventSource("/sse/stream");
eventSource.onmessage = function(event){
document.getElementById("sse").innerHTML = event.data;
}
</script>

打开浏览器,你会看到时间每秒自动刷新一次,不用你手动刷新页面,牛逼不? 🤯

六、小特给你总结一张图(脑补)🧠
客户端 服务器
| |
| ----- GET /stream --------> |
| |
| <---- data: 10:00:01 --------|
| <---- data: 10:00:02 --------|
| <---- data: 10:00:03 --------|
| <---- data: 10:00:04 --------|
| |
| (连接一直开着,直到服务器主动关或者客户端离开)
七、补充SSE vs WebSocket 🥊
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器→客户端) | 双向(全双工) |
| 协议 | HTTP/HTTPS | WS/WSS(独立协议) |
| 自动重连 | ✅ 浏览器内置 | ❌ 需要自己实现 |
| 二进制数据 | ❌ 只支持文本 | ✅ 支持 |
| 复杂度 | 简单 | 较复杂 |
| 适用场景 | 服务器推送通知、AI流式对话、股票行情 | 聊天室、在线游戏、协同编辑 |
建议:如果只是服务器往客户端推数据,别整WebSocket那个大炮打蚊子,SSE足矣!
八、踩坑💀
- Nginx代理SSE :默认会缓冲响应,要配置
proxy_buffering off;,否则客户端等半天收不到数据。 - 浏览器限制:同一个域名下EventSource最多同时开6个连接(HTTP/1.1的限制)。
- 断线重连的retry :如果服务器想控制重连间隔,可以发一行
retry: 3000\n\n,告诉浏览器3秒后重连。 - Flux别忘了subscribe:很多人写Flux不调用subscribe,结果毛都没有,debug到怀疑人生。
写在最后
好了,这篇就到这。
如果你跟着敲到了最后,说明你也是真想学 SpringAI 的人 ------ 小特同学敬你是条汉子 🫡
点赞、关注、收藏来一波,不是因为我缺,是因为你点了之后算法会更勤快地把下篇推给你 😏
谢谢老铁阅读,踩坑继续,下期见!🤚




