从chatgpt的流式传输获得启发
起初以为,chatgpt的回答是通过websocket分段传输过来的,后来在浏览器控制台上看到并不是,研究了一下,发现使用的是流式传输。
使用流式传输非常简单,服务端的响应头中只要添加以下信息即可:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
content-type必须是text/event-stream,表示流式传输,no-cache表示不使用缓存,keep-alive表示不断开http连接
下面写一个示例
后端定一个get接口,每隔100ms向流中写入一个字符串
@GetMapping("/sse")
public void sse(HttpServletResponse response) {
String str = " 春江花月夜 \n" +
"春江潮水连海平,海上明月共潮生。\n" +
"滟滟随波千万里,何处春江无月明!\n" +
"\n";
// 响应流
response.setHeader("Content-Type", "text/event-stream");
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
response.setHeader("Pragma", "no-cache");
ServletOutputStream out = null;
try {
out = response.getOutputStream();
for (int i = 0; i < str.length(); i++) {
out.write(String.valueOf(str.charAt(i)).getBytes());
// 更新数据流
out.flush();
Thread.sleep(100);
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
try {
if (out != null) {
out.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
前端代码:
// 创建 XMLHttpRequest 对象
const xhr = new XMLHttpRequest();
// 设置请求的 URL
xhr.open(
"GET",
`http://localhost:9093/openai/sse`
);
// 设置响应类型为 text/event-stream
xhr.setRequestHeader("Content-Type", "text/event-stream");
// 监听 readyStateChange 事件
xhr.onreadystatechange = () => {
// 如果 readyState 是 3,表示正在接收数据
if (xhr.readyState === 3) {
// 将数据添加到文本框中
console.log('xhr.responseText :>> ', xhr);
// reply("images/touxiang.png", xhr.responseText, randomStr)
$('#content').text(xhr.responseText);
var height = $("#content").height();
$("html").scrollTop(height)
}
};
// 发送请求
xhr.send();
至此,就实现了类似chatgpt一样的打印机效果。本来以为到这里就结束了,后来查了一下资料,发现Server-Sent Events(SSE)就是基于这种流式传输封装成的。
SSE介绍
一般来说,实现服务端主动向浏览器推送消息,一般有以下几个方案:
1、前端轮询(最常用的场景:扫码登录)
2、websocket(最常用的场景:即时通讯)
3、MQTT(物联网常用,浏览器中还是依托websocket协议)
上面提到的chatgpt使用的event-stream传输方式,本质是就是下载,把数据分批传输到客户端,如果封装一下,让前后端一直保持连接,那么后端就可以随时向前端推送信息,这项技术就叫Server-Sent Events,简称SSE,他已经被列入html5标准之中,因此大部分的浏览器都对SSE做了支持(IE除外)。Spring也对SSE做了相关的封装。
代码实现
spring封装了SseEmitter可以直接使用,代码如下:
//存储所有的SSE连接
private static final ConcurrentHashMap SSE_POOL = new ConcurrentHashMap<>();
//创建SSE连接
@GetMapping("createSse")
public SseEmitter createSse(String clientId) {
//不过期
SseEmitter sseEmitter = new SseEmitterUTF8(0L);
SSE_POOL.put(clientId, sseEmitter);
sseEmitter.onCompletion(() -> {
log.info("onCompletion!");
SSE_POOL.remove(clientId);
});
sseEmitter.onError((e) -> {
log.error("onError!", e);
SSE_POOL.remove(clientId);
});
sseEmitter.onTimeout(() -> {
log.info("onTimeout!");
SSE_POOL.remove(clientId);
});
return sseEmitter;
}
//触发消息推送
@GetMapping("getMsg")
public void getMsg(String clientId) throws IOException {
SSE_POOL.get(clientId).send("这是一条来自SseEmitter的消息");
}
//自定义SSE客户端,实现utf-8编码传输
public static class SseEmitterUTF8 extends SseEmitter {
public SseEmitterUTF8(Long timeout) {
super(timeout);
}
@Override
protected void extendResponse(ServerHttpResponse outputMessage) {
super.extendResponse(outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
headers.setContentType(new MediaType(MediaType.TEXT_EVENT_STREAM, StandardCharsets.UTF_8));
}
}
这里有个注意事项,spring提供的SseEmitter默认不是使用的utf-8编码,传输到浏览器会中文乱码,所以需要自定义一个
浏览器提供了EventSource对象
var source = new EventSource('http://localhost:9093/openai/createSse?clientId=zjh');
source.onopen = function (event) {
$('#status').append(`建立连接成功!`)
};
source.onmessage = function (event) {
var data = event.data;
$('#msg').append(data + '')
};
source.onerror = function (event) {
console.log(event)
$('#status').append(`出错了!`)
};
$('#btn').click(() => {
// 创建 XMLHttpRequest 对象
const xhr = new XMLHttpRequest();
// 设置请求的 URL
xhr.open(
"GET",
`http://localhost:9093/openai/getMsg?clientId=zjh`
);
// 发送请求
xhr.send();
})
其实也没什么好讲的,和websocket一样,都是封装了onopen,onmessage,onerror等生命周期函数,用法很类似
SSE对比websocket
1、传输协议:SSE还是普通的http协议,不受平台限制;而websocket是基于TCP的独立协议,不是所有的客户端都支持
2、使用难度:SSE比websocket简单
3、性能:websocket更胜一筹
4、单双工:SSE是单工,只能服务端向客户端发送消息;websocket是双工,服务器和客户端可以互相收发消息;
5、数据传输:SSE只能传文本,二进制数据需要base64编码后传输;websocket可以直接传输二进制数据
6、事件:SSE可以自定义事件,即除了onopen、onmessage、onerror外,还可以自定义其他事件;websocket不支持
SSE使用场景
1、实时数据更新但客户端又不多的场景:例如监控大屏。
2、响应速度比较慢的接口:可以让接口先返回一部分数据,前端拿到数据后立即渲染页面,用户友好。
高谈阔论