SSE-ChatGPT 流推送原理解析
1. 什么是SSE
SSE(Server-Sent Events)是一种用于实现服务器主动向客户端推送数据的技术,也被称为“事件流”(Event Stream)。它基于 HTTP 协议,利用了其长连接特性,在客户端与服务器之间建立一条持久化连接,并通过这条连接实现服务器向客户端的实时数据推送。
服务器返回的 content-type 是 text/event-stream,这是一个可以多次返回内容的流。服务器端事件通过这种消息类型随时推送数据。我们把它叫做 SSE。
在ChatGPT 中。每次回答问题时,它不会立即给出所有答案,而是逐步加载部分。这也是基于SSE的。
2. SSE技术的基本原理
- 客户端向服务器发送一个GET请求,带有指定的header,表示可以接收事件流类型,并禁用任何的事件缓存。
- 服务器返回一个响应,带有指定的header,表示事件的媒体类型和编码,以及使用分块传输编码(chunked)来流式传输动态生成的内容。
- 服务器在有数据更新时,向客户端发送一个或多个名称:值字段组成的事件,由单个换行符分隔。事件之间由两个换行符分隔。服务器可以发送事件数据、事件类型、事件ID和重试时间等字段。
- 客户端使用EventSource接口来创建一个对象,打开连接,并订阅onopen、onmessage和onerror等事件处理程序来处理连接状态和接收消息。
- 客户端可以使用GET查询参数来传递数据给服务器,也可以使用close方法来关闭连接
3. SSE和Socket的区别
SSE(Server-Sent Events)和 WebSocket 都是实现服务器向客户端实时推送数据的技术,但它们在某些方面还是有一定的区别。
- 技术实现:
SSE 基于 HTTP 协议,利用了其长连接特性,通过浏览器向服务器发送一个 HTTP 请求,建立一条持久化的连接。而 WebSocket 则是通过特殊的升级协议(HTTP/1.1 Upgrade 或者 HTTP/2)建立新的 TCP 连接,与传统 HTTP 连接不同。 - 数据格式:
SSE 可以传输文本和二进制格式的数据,但只支持单向数据流,即只能由服务器向客户端推送数据。WebSocket 支持双向数据流,客户端和服务器可以互相发送消息,并且没有消息大小限制。 - 连接状态:
SSE 的连接状态仅有三种==:已连接、连接中、已断开==。连接状态是由浏览器自动维护的,客户端无法手动关闭或重新打开连接。而 WebSocket 连接的状态更灵活,可以手动打开、关闭、重连等。 - 兼容性:
SSE 是标准的 Web API,可以在大部分现代浏览器和移动设备上使用。但如果需要兼容老版本的浏览器(如 IE6/7/8),则需要使用 polyfill 库进行兼容。而 WebSocket 在一些老版本 Android 手机上可能存在兼容性问题,需要使用一些特殊的 API 进行处理。 - 安全性:
SSE 的实现比较简单,都是基于 HTTP 协议的,与普通的 Web 应用没有太大差异,因此风险相对较低。WebSocket 则需要通过额外的安全措施(如 SSL/TLS 加密)来确保数据传输的安全性,避免被窃听和篡改,否则可能会带来安全隐患。
总体来说,SSE 和 WebSocket 都有各自的优缺点,适用于不同的场景和需求。如果只需要服务器向客户端单向推送数据,并且应用在前端的浏览器环境中,则 SSE 是一个更加轻量级、易于实现和维护的选择。而如果需要双向传输数据、支持自定义协议、或者在更加复杂的网络环境中应用,则WebSocket 可能更加适合。
4. SSE适用于场景
SSE适用场景是指服务器向客户端实时推送数据的场景,例如:
股票价格更新
:服务器可以根据股市的变化,实时地将股票价格推送给客户端,让客户端能够及时了解股票的走势和行情。新闻实时推送
:服务器可以根据新闻的更新,实时地将新闻内容或标题推送给客户端,让客户端能够及时了解最新的新闻动态和信息。在线聊天
:服务器可以根据用户的发送,实时地将聊天消息推送给客户端,让客户端能够及时收到和回复消息。实时监控
:服务器可以根据设备的状态,实时地将监控数据或报警信息推送给客户端,让客户端能够及时了解设备的运行情况和异常情况。
SSE适用场景的特点是:
数据更新频繁
:服务器需要不断地将最新的数据推送给客户端,保持数据的实时性和准确性。低延迟
:服务器需要尽快地将数据推送给客户端,避免数据的延迟和过期。单向通信
:服务器只需要向客户端推送数据,而不需要接收客户端的数据。
chatGPT 返回的数据 就是使用的SSE 技术
实时数据大屏 如果只是需要展示 实时的数据可以使用SSE技术 而不是非要使用webSocket.
5. SSE的用法
EventSource这个api是一个用于接收服务器发送事件(Server-Sent Events,SSE)的Web API接口。服务器发送事件是一种让服务器端能够主动向客户端发送数据的技术,它使用HTTP协议,并且遵循一定的格式
要使用EventSource这个api,首先需要创建一个EventSource对象,并指定一个URL作为数据源。例如:
//创建一个EventSource对象,用于从sse.php页面接收事件
const evtSource = new EventSource("sse.php");
然后,需要为EventSource对象添加一些事件监听器,用于处理从服务器端接收到的数据。EventSource对象可以接收以下几种事件
- open:当与服务器端的连接打开时触发。
- message:当从服务器端接收到未命名的事件时触发。
- error:当连接失败或关闭时触发。
- 具名事件:当从服务器端接收到指定了event字段的事件时触发,事件名称与event字段的值相同。
例如:
//为open事件添加一个监听器,打印连接状态
evtSource.addEventListener("open", (e) => {
console.log("Connection opened");
});
//为message事件添加一个监听器,打印数据内容
evtSource.addEventListener("message", (e) => {
console.log("Data: " + e.data);
});
//为error事件添加一个监听器,打印错误信息
evtSource.addEventListener("error", (e) => {
console.log("Error: " + e.message);
});
//为notice事件添加一个监听器,打印通知内容
evtSource.addEventListener("notice", (e) => {
console.log("Notice: " + e.data);
});
在服务器端,需要使用text/event-stream作为响应的Content-Type,并按照以下格式发送数据
event: <event name>
data: <data content>
id: <event id>
retry: <reconnection time>
其中,event字段是可选的,用于指定事件的名称;data字段是必须的,用于指定数据的内容;id字段是可选的,用于指定事件的标识符;retry字段是可选的,用于指定客户端在连接断开后重新连接的时间间隔(以毫秒为单位)。每个字段都必须以换行符(\n)结尾,并且每个消息都必须以两个换行符(\n\n)结尾。例如:
event: notice
data: Hello, world!
id: 1
Chatgpt的类似DEMO
前端界面采用Vue
<template>
<div id="app">
<h1>简单的聊天应用程序</h1>
<div class="chat-box">
<div class="messages" ref="messages">
</div>
<div class="input-area">
<input type="text" v-model="input" @keyup.enter="sendMessage" placeholder="输入消息并按回车键发送" />
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "Chat",
data() {
return {
input: "",
messages: "",
eventSource: null,
};
},
mounted() {
this.initSSE();
},
beforeDestroy() {
this.eventSource.close();
},
methods: {
initSSE() {
// 创建一个SSE对象,连接到后端的/chat接口
this.eventSource = new EventSource("http://localhost:8081/chat");
// 监听message事件,接收后端发送的消息
this.eventSource.addEventListener("message", (event) => {
//将返回data插入元素
this.$refs.messages.innerHTML += event.data
});
},
},
},
};
</script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
h1 {
text-align: center;
}
.chat-box {
width: 600px;
height: 400px;
margin: 20px auto;
border: 1px solid #ccc;
}
.messages {
height: 360px;
overflow-y: auto;
text-align: left;
}
.message {
padding: 10px;
}
.message.user {
text-align: right;
}
.message.user .content {
display: inline-block;
background-color: #f0f0f0;
border-radius: 10px;
}
.message.user .time {
display: block;
color: #999;
}
.message.bing {
text-align: left;
}
.message.bing .content {
display: inline-block;
background-color: #e6f7ff;
border-radius: 10px;
}
.message.bing .time {
display: block;
color: #999;
}
.input-area {
height: 40px;
}
.input-area input {
width: calc(100% - 20px);
height: calc(100% - 10px);
margin: 5px auto;
}
</style>
后端使用SpringBoot
import org.reactivestreams.Publisher;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.*;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.function.Function;
import java.util.function.Supplier;
@Controller
public class ChatController {
// 定义一个常量字符串,作为要发送的数据
private static final String SAY="千万刀锋之力,万丈烈焰之怒在我心中鼓荡。 我不惧怕圣火,我必须以身为信。 我携来光的怒火。 星辰间的国度在召唤我。 忤逆者。 我诞生于灼烧罪人的火焰中。
归顺光明,我就饶恕你的灵魂。 不留情面。 你是会说话的动物还是个小矮人?为什么你这么软乎。 他们将在我的铁翼面前溃败。 邪恶惧怕火焰,而约德尔人他们到底是什么东西。 忍受。 我携来烈怒之光。 你的剑在我手上你想要回去吗?
我曾望进母亲的眼睛,看到一处充满荣耀与正义的圣地,我正是为此而战这个世界不配有你。 今天,似乎我们都要暂时放下成见。 你的罪赎清了。 正义不死。 莫甘娜,你的能力本可拯救世界,可惜它却毁了你。 我要是摔倒了就会堕落,因此我必须飞翔。
折磨生出苦难,苦难又会加剧折磨,凡间这无穷的循环将由我来终结光芒给予你救赎。你该当此罪。";
// 处理GET请求,返回一个SSE对象,用于向前端发送消息
// 使用@CrossOrigin注解允许跨域请求
// 使用@GetMapping注解指定请求路径和返回类型
@CrossOrigin
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public @ResponseBody Flux<String> chat() {
// 使用Flux.usingWhen方法创建一个响应式流对象
// 第一个参数是一个Mono对象,用于提供资源(即Sinks.Many对象)
// 第二个参数是一个函数对象,用于根据资源生成数据流(即字符串的每个字符)
// 第三个参数是一个函数对象,用于释放资源(即关闭Sinks.Many对象)
return Flux.usingWhen(
// 使用Mono.fromSupplier方法创建一个Mono对象,用于提供资源
// 使用Supplier接口实现get方法,返回一个Sinks.Many对象
Mono.fromSupplier(new Supplier<Sinks.Many<String>>() {
@Override
public Sinks.Many<String> get() {
// 使用Sinks.many().unicast().onBackpressureBuffer()方法创建一个Sinks.Many对象
// 这个对象可以向多个订阅者发送数据,并缓存未消费的数据
return Sinks.many().unicast().onBackpressureBuffer();
}
}),
// 使用Function接口实现apply方法,根据资源生成数据流
new Function<Sinks.Many<String>, Flux<String>>() {
@Override
public Flux<String> apply(Sinks.Many<String> sink) {
// 使用sink.asFlux()方法获取数据流对象,并与另一个数据流合并
// 另一个数据流使用Flux.interval方法创建,每隔100毫秒生成一个序号
// 使用takeWhile方法限制序号的范围,不能超过字符串的长度
// 使用map方法将序号映射为字符串的对应字符,并加上换行符
return sink.asFlux().mergeWith(Flux.interval(Duration.ofMillis(100))
.takeWhile(seq -> seq.intValue() < SAY.length()).map(seq -> SAY.charAt(seq.intValue()) + "\n\n"));
}
},
// 使用Function接口实现apply方法,释放资源
new Function<Sinks.Many<String>, Publisher<Void>>() {
@Override
public Publisher<Void> apply(Sinks.Many<String> sink) {
// 使用Mono.fromRunnable方法创建一个Publisher对象,用于执行一个任务
// 使用Runnable接口实现run方法,调用sink.tryEmitComplete()方法关闭Sinks.Many对象
return Mono.fromRunnable(new Runnable() {
@Override
public void run() {
sink.tryEmitComplete();
}
});
}
});
}
}
这段代码定义了一个名为 ChatController
的类,它使用 @Controller
注解来标记为一个控制器类。这个类包含一个名为 chat()
的方法,它使用 @GetMapping
注解来处理 GET 请求,并返回一个 Flux<String>
对象。这个方法的主要目的是向前端发送消息。
在 chat()
方法中,代码使用了 Flux.usingWhen()
方法来创建一个 Flux 对象。这个方法接受三个参数:resourceSupplier
、fluxFunction
和 resourceCleanup
。
resourceSupplier
参数是一个 Mono
对象,它用于提供一个资源。在这段代码中,资源是一个 Sinks.Many<String>
对象,它用于向前端发送消息。
fluxFunction 参数是一个函数,它接受一个资源作为输入,并返回一个 Flux 对象。在这段代码中,这个函数使用了两个操作符:asFlux()
和 mergeWith()
。
asFlux()
操作符用于将 sink
转换为一个 Flux
对象。
mergeWith()
操作符用于将两个 Flux 对象合并在一起。在这段代码中,它将 sink.asFlux()
和另一个 Flux
对象合并在一起。
这个另一个 Flux 对象使用了三个操作符:interval()
、takeWhile()
和 map()
。
interval()
操作符用于创建一个每隔一定时间间隔发出序列号的 Flux 对象。
takeWhile()
操作符用于只取满足条件的元素。
map()
操作符用于将序列号转换为字符串。
resourceCleanup
参数是一个函数,它接受一个资源作为输入,并返回一个 Mono<Void>
对象。在这段代码中,这个函数调用了 sink::tryEmitComplete
方法来关闭 `
chat()还有一种实现方式,但是Sink无法关闭且没有信息发送时空载,造成资源浪费
Sinks.Many<String> sink=Sinks.many().unicast().onBackpressureBuffer();
Flux<String> flux= sink.asFlux();
Flux.interval(Duration.ofMillis(100)).takeWhile(seq->seq.intValue()<SAY.length()).subscribe((seq)->{
sink.tryEmitNext(SAY.charAt(seq.intValue()) + "\n\n");
});
return flux;
最终实现效果