SSE协议-由浅入深
# 前言
说起客户端服务端的通信协议,大家最熟悉的当属
http/https,websocket这两个家伙了。前者是客户端主动发起,服务端响应的单项通信协议,后者则是客户端服务端双向通信协议,也就是双方维持一个长连接可互相发送消息。随着业务的不断发展,还衍生出了服务端主动向客户端单向推送数据的协议,那就是本文的主角SSE(Server-Sent Events)
# 核心概念
Server-Sent Events(服务器发送事件)是HTML5规范中的一个功能,允许服务器主动向浏览器推送实时数据。它是一种单向通信协议,数据只能从服务器流向客户端。
# 核心特点
- 单向通信:数据传输方向为服务器→客户端
- 基于
HTTP:使用标准HTTP协议,服务端保持连接打开(由于是基于http的协议所以不仅可以通过浏览器提供的EventSource Api调用,还可以通过普通的fetch请求调用) - 自动重连:浏览器内置重连机制
- 简单格式:使用纯文本格式传输数据
# SSE消息格式
data: 这是一条消息
data: 这是另一条消息
id: 123
event: customEvent
data: {"key": "value"}
retry: 5000
2
3
4
5
6
7
8
# 主要优势
- 自动重连:连接断开后自动重试
- 内置缓冲:浏览器处理数据缓冲
- 事件类型:支持不同类型的消息
- 连接管理:浏览器自动管理连接状态
# 局限性
- 单向通信:无法从客户端向服务器发送数据
- 协议限制:仅支持
HTTP GET方法 - 参数固定:
URL参数在连接建立时确定,无法动态更改 - 请求头限制:无法添加自定义请求头(如认证信息)
介绍完了基本概念,接下来就让我们开始手撸一个感受一下。
目前市面上的pc端ai对话层出不穷,我们可以注意到ai在回复消息的时候是有一个打字机的效果的,并不是直接渲染全部内容。这里我们就可以借助SSE协议来实现。
# 服务端实践
首先我们启动一个nodejs服务,然后写入如下代码
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
const {doc} = require('./data')
app.listen(port, () => {
console.log(`express服务已启动,端口号: ${port}`);
console.log('\x1b[36m%s\x1b[0m', `👉 点击这里访问: http://localhost:${port}/getReqText`); // 青色显示
console.log('\x1b[32m%s\x1b[0m', `🌐 或者访问: http://127.0.0.1:${port}/getReqText`); // 绿色显示
})
app.get('/',(req,res)=>{
res.send('<!DOCTYPE html>\n' +
'<html lang="zh-CN">\n' +
'<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>无脚小鸟</title></head>' +
'<body>' +
'<div>express服务,3000端口</div>' +
'</body>' +
'</html>')
})
app.get('/getReqText', (req, res) => {
// 设置响应头
res.set({
"Content-type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection":"keep-alive",
})
// 模拟从某个地方获取用户消息(实际应从 req.body 中读取)
const userMessage = req.body?.message || '你好';
let index = 0;
const textArray = [...doc];
// 发送响应头
res.flushHeaders()
const timer = setInterval(() => {
if (index < textArray.length) {
// 构造 SSE 数据块:data: JSON字符串\n\n
const data = {
content: textArray[index],
finish_reason: null,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
index++;
} else {
// 发送结束标志(可选,很多 API 会发送一个特殊的 [DONE] 消息)
res.write(`data: {"content":"","finish_reason":"stop"}\n\n`);
clearInterval(timer);
res.end(); // 关闭响应
}
}, 100);
// 如果客户端断开了连接,清除定时器避免内存泄漏
req.on('close', () => {
clearInterval(timer);
res.end();
});
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
我们主要吧视角聚焦在getReqText这个接口上,主要关注点有如下两个:
- 设置响应头
Content-type为text/event-stream。也就是告诉浏览器这个接口将响应流式数据 - 不一次性响应所有内容,而是通过一个定时器一点一点的响应,直到所有内容响应完成之后在结束这个连接
接下来看看客户端代码
// 链接sse
const handleConnectEvents = () => {
eventSource = new EventSource('/api/getReqText')
const aiResponse = {
id: Date.now() + 1,
// content: getAIResponse(userMessage.content),
content: '',
sender: 'ai',
timestamp: new Date()
};
messages.value.push(aiResponse);
eventSource.onmessage = event=>{
console.log("👉 ~ connectEvent ~ event:", event);
messages.value[messages.value.length - 1].content += JSON.parse(event.data).content;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
通过new EventSource去调用这个接口,非常的简单,我们建立好连接之后就可以在eventSource.onmessage的回调里接收到服务端推送的消息
- 注意:服务端必须响应正确的
SSE协议数据格式才能出发onmessage回调,也就是上文中的res.write(`data: ${JSON.stringify(data)}\n\n`);,必须是data:xxx \n\n这种。
在 SSE协议中,每条消息可以由多个字段组成,其中:
event: 字段用于指定事件的类型(名称)data: 字段包含实际的数据内容- 消息以
\n\n结尾
当客户端收到消息时:
- 如果消息中有
event: xxx,则会触发名为xxx的自定义事件 - 如果没有
event字段,则默认触发message事件
我们可以在浏览器中看到 能识别的数据会在eventStream里列出来
可以看到由于我们接口没有指定响应的type类型,所以这里接收到的全部都是message类型。
到这里我们就完成了一个最简单的SSE连接,但是我在仔细观察deepseek的接口调用的时候发现,他并不是通过EventSource的方式去调用的接口。而是一个普通的http请求,回顾上文说的SSE协议的一些特点:
- 只支持
get请求 - 无法携带参数
- 无法设置请求头内容
但是要做ai对话的肯定得需要把用户发送的消息传给服务端,那显然这里就不适合通过EventSource的方式来调用了,而是当作普通的http请求一样去调用。这里有一个思路误区: (SSE并不是一种新的通信协议,他是基于http协议的一种数据格式协议,也就是上文提到的每个消息以data:开头,以\n\n结尾,可以包含事件类型、ID等。这种格式本身并不限定使用什么客户端API来消费。你可以用任何能读取HTTP响应的方式(比如fetch的ReadablStream)来接收并解析它)
而EventSource是浏览器内置的一个专用客户端API,它是专门用来消费SSE格式的数据流,但仅限于Get请求,且无法自定义请求头。它的设计初衷是为了简单场景:比如单纯的服务器推送,客户端只需要监听一个固定的URL,无需发送额外的数据,EventSource开箱即用,自动处理重连、事件分发。
由于我们这里做的AI对话是需要客户端将用户消息携带发送给服务端的,所以显然EventSource并不适用。
也就是说SSE协议和EventSource并不是强绑定关系,这个思想误区一定要打开。
那接下来我们就通过普通的fetch请求来试一试吧
const sendMessage = async () => {
if (!newMessage.value.trim()) return;
// 添加用户消息
const userMessage = {
id: Date.now(),
content: newMessage.value,
sender: 'user',
timestamp: new Date()
};
messages.value.push(userMessage);
newMessage.value = '';
// 模拟AI思考
isTyping.value = true;
const response = await fetch('/api/getReqText', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userMessage })
});
const aiResponse = {
id: Date.now() + 1,
// content: getAIResponse(userMessage.content),
content: '',
sender: 'ai',
timestamp: new Date()
};
messages.value.push(aiResponse);
isTyping.value = false;
let buffer = '';
let updateTimer = null;
// 获取可读流
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
console.log(chunk);
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6);
if (jsonStr === '[DONE]') {
// 对话结束
return;
}
try {
const data = JSON.parse(jsonStr);
const content = data.content; // 提取文本片段
// 追加到 UI
messages.value[messages.value.length - 1].content += content;
if (data.finish_reason === 'stop') {
// 回复结束,可以做些清理
}
} catch (e) {
console.error('解析失败', e);
}
}
}
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
这里就是通过创建fetch响应体的一个可读流,然后通过循环去不断的读取,直到这个流响应结束。
所以以后面试官问起你SSE相关的东西,一定要把它和EventSource区分开哦。SSE是一种数据格式协议,而EventSource是专门用来消费SSE协议的Api。所谓的服务端主动通知服务端其实就是服务端保持一个长连接不断开,然后向客户端推送消息
以上就是对SSE协议的详细解读以及实操拉,注意其中几个概念,上手还是很简单的!