学习链接
webflux&webclient
demo-qianfan
【SpringBoot+SseEmitter】 和【Vue3+EventSource】 实时数据推送
AICNN-chatgpg-sse-demo-springboot-vue,springboot版本2.6.13,jdk8,同时导入spring-boot-starter-web,spring-boot-starter-webflux
spring-boot-ai-deepseek - hutool简单http调用
DeepSeek4j 已开源,支持思维链,自定义参数,Spring Boot Starter 轻松集成 deepseek
如何申请文心一言&文心千帆大模型API调用资格、获取access_token,并使用SpringBoot接入文心一言API
qianfan-sse-demo - gitee - 流式输出效果 springboot - 2.7.3,jdk8
GptTest 代码 - 实现和OpenAI官网一样的对话信息实时返回的效果,构建本地代理
文章目录
sse简介
前言
服务器向浏览器推送信息,除了 WebSocket,还有一种方法:Server-Sent Events(以下简称 SSE)。
SSE 的本质
严格地说,HTTP 协议无法做到服务器主动推送信息
。但是,有一种变通方法,就是服务器向客户端声明,接下来要发送的是流信息(streaming)
。
也就是说,发送的不是一次性的数据包,而是一个数据流,会连续不断地发送过来
。这时,客户端不会关闭连接,会一直等着服务器发过来的新的数据流,视频播放就是这样的例子。本质上,这种通信就是以流信息的方式,完成一次用时很长的下载。
SSE 就是利用这种机制,使用流信息向浏览器推送信息。它基于 HTTP 协议,目前除了 IE/Edge,其他浏览器都支持。
SSE 的特点
SSE 与 WebSocket 作用相似,都是建立浏览器与服务器之间的通信渠道,然后服务器向浏览器推送信息。
总体来说,WebSocket 更强大和灵活。因为它是全双工通道,可以双向通信;SSE 是单向通道,只能服务器向浏览器发送,因为流信息本质上就是下载。如果浏览器向服务器发送信息,就变成了另一次 HTTP 请求。
但是,SSE 也有自己的优点。
- SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。
- SSE 属于轻量级,使用简单;WebSocket 协议相对复杂。
SSE 默认支持断线重连
,WebSocket 需要自己实现。- SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据。
- SSE 支持自定义发送的消息类型。
web
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-boot-dependencies</artifactId>
<groupId>org.springframework.boot</groupId>
<version>2.1.8.RELEASE</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>demo-sse</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
</dependencies>
</project>
WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.maxAge(3600)
.allowCredentials(true)
.allowedOrigins("*")
.allowedMethods("*")
.allowedHeaders("*")
.exposedHeaders("token", "Authorization")
;
}
}
application.yml
server:
port: 8080
SseController
/*
nginx配置
#eventSource
location /es/ {
proxy_pass http://请求地址/;
#必须要设置当前Connection 属性
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
}
*/
@Slf4j
@RestController
@RequestMapping("sse")
public class TestController {
private static Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();
@Autowired
private HttpServletRequest request;
/**
* 前端传递标识,生成唯一的消息通道
*/
@GetMapping(path = "subscribe", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter push(String id, Long timeout, Long reconnectTime) throws IOException {
// 超时时间设置为3s,用于演示客户端自动重连(设置timeout>0, 最多这么长; 设置timeout <= 0, 一直保持连接, 不会超时;)
SseEmitter sseEmitter = new SseEmitter(timeout);
// 设置前端的重试时间为1s(发送这条消息后,客户端接收到这条消息。如果发生了超时,并且前端不关闭eventSource,那么会在指定的时间后重连)
sseEmitter.send(SseEmitter.event().reconnectTime(reconnectTime).data("连接成功"));
sseCache.put(id, sseEmitter);
log.info("连接: " + id);
sseEmitter.onTimeout(() -> {
log.info("超时: " + id);
});
sseEmitter.onCompletion(() -> {
log.info("完成: " + id);
sseCache.remove(id);
});
// 这个什么时候触发? 目前还没有让这个触发过
// 假设客户端端连接 localhost:8080/sse/subscribe?id=1&timeout=0&reconnectTime=1000后,
// 一直等待服务端数据,然后客户端把页面给关了,此时并不会触发SseEmitter的任何回调方法,这是为什么?
sseEmitter.onError((e) -> {
log.info("错误: " + e);
});
return sseEmitter;
}
/**
* 根据标识传递信息
*/
@GetMapping(path = "push")
public String push(String id, String content) throws IOException {
SseEmitter sseEmitter = sseCache.get(id);
log.info("推送消息给:{}, content: {}, sseEmitter:{}", id, content, sseEmitter);
if (sseEmitter != null) {
// 将会让eventSource的addEventListener('msg',fn)的fn函数触发
// 去掉name将会让eventSource.onmessage触发
sseEmitter.send(SseEmitter.event().name("msg").data("后端发送消息:" + content));
}
return "push";
}
/**
* 根据标识移除SseEmitter
*/
@GetMapping(path = "over")
public String over(String id) {
SseEmitter sseEmitter = sseCache.get(id);
log.info("移除: {}, sseEmitter: {}", id, sseEmitter);
if (sseEmitter != null) {
sseEmitter.complete();
sseCache.remove(id);
}
return "over";
}
}
SseApp
@SpringBootApplication
public class SseApp {
public static void main(String[] args) {
SpringApplication.run(SseApp.class, args);
}
}
测试
连接sse服务:http://localhost:8080/sse/subscribe?id=1&timeout=0&reconnectTime=1000
推送数据:http://localhost:8080/sse/push?id=1&content=111111111
关闭sse:http://localhost:8080/sse/over?id=1
前端
Sse.vue
使用vue连接sse服务端
<template>
<div>
<div>
id: <input v-model="id"/>
</div>
<div>
timeout: <input v-model="timeout"/>
</div>
<div>
reconnectTime: <input v-model="reconnectTime"/>
</div>
<br>
<button @click="connectToSse">连接sse</button>
<button @click="closeSse">关闭sse</button>
</div>
</template>
<script>
export default {
name: 'Sse',
data() {
return {
id: 1,
timeout: 30000,
reconnectTime: 1000,
evtSource: null,
}
},
methods: {
closeSse() {
if(this.evtSource) {
this.evtSource.close()
}
},
connectToSse() {
if (typeof (EventSource) !== 'undefined') {
// 跨域时,可以指定第二个参数,打开withCredentials属性,表示是否一起发送 Cookie。
const evtSource = new EventSource(`http://127.0.0.1:8080/sse/subscribe?id=${this.id}&timeout=${this.timeout}&reconnectTime=${this.reconnectTime}`, { withCredentials: true }) // 后端接口,要配置允许跨域属性
/* EventSource实例的readyState属性,表明连接的当前状态。该属性只读,可以取以下值。
0:相当于常量EventSource.CONNECTING,表示连接还未建立,或者断线正在重连。
1:相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。
2:相当于常量EventSource.CLOSED,表示连接已断,且不会重连。
*/
this.evtSource = evtSource
console.log(evtSource);
// 与事件源的连接刚打开时触发
evtSource.onopen = function (e) {
console.log('onopen', e);
}
// 当从事件源接收到数据时触发
evtSource.onmessage = function (e) {
console.log('onmessage', e);
}
// 与事件源的连接无法打开时触发
evtSource.onerror = function (e) {
console.log('onerror', e);
// evtSource.close(); // 只影响重连。关闭后, 将不再重连;如果没有关闭,默认会重连
}
// 也可以侦听命名事件,即自定义的事件
evtSource.addEventListener('msg', function (e) {
console.log('addEventListener-msg', e);
console.log('addEventListener-msg', e.data)
})
} else {
console.log('当前浏览器不支持使用EventSource接收服务器推送事件!');
}
}
}
}
</script>
Sse.vue2
<template>
<div>
<div>
id: <input v-model="id" />
</div>
<div>
timeout: <input v-model="timeout" />
</div>
<div>
reconnectTime: <input v-model="reconnectTime" />
</div>
<br>
<button @click="connectToSse">连接sse</button>
</div>
</template>
<script>
import subscribeWarnMsg from '@/utils/sse2.js'
export default {
name: 'Sse',
data() {
return {
id: 1,
timeout: 30000,
reconnectTime: 1000,
evtSource: null,
}
},
methods: {
connectToSse() {
subscribeWarnMsg(`http://127.0.0.1:8080/sse/subscribe?id=${this.id}&timeout=${this.timeout}&reconnectTime=${this.reconnectTime}`)
}
}
}
</script>
<style></style>
sse2.js
安装 event-source-polyfill
,来携带请求头
npm install event-source-polyfill
import {EventSourcePolyfill} from "event-source-polyfill";
let eventSource = null;
let reconnectAttempts = 0; // 重连次数
export default function subscribeWarnMsg(url) {
if (eventSource) {
console.log("sse已经存在:", eventSource);
return eventSource;
} else {
eventSource = new EventSourcePolyfill(url, {
heartbeatTimeout: 3 * 60 * 1000,
headers: {
Authorization: 'Bearer 123456' ,
Accept: 'text/event-stream'
},
withCredentials: true,
})
eventSource.onopen = function (e) {
console.log(e, "连接刚打开时触发");
reconnectAttempts = 0; // 重置重连次数
};
eventSource.onmessage = (event) => {
console.log("收到消息内容是:", event.data)
};
/* 监听自定义事件 */
eventSource.addEventListener('msg', (event) => {
console.log("收到消息内容是:", event.data)
});
eventSource.onerror = (event) => {
console.error("SSE 连接出错:", event);
eventSource.close(); // 关闭连接
eventSource = null;
// 自动重连逻辑
reconnectAttempts++;
const reconnectDelay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempts)); // 计算重连延迟,最大延迟为30秒
console.log(`将在 ${reconnectDelay} 毫秒后尝试重连...`);
// 等待一定时间后重连
setTimeout(() => {
if (!eventSource) {
console.log("尝试重连 SSE...");
subscribeWarnMsg(url); // 递归调用重连
}
}, reconnectDelay);
}
return eventSource;
}
}
webflux
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.6</version>
</parent>
<groupId>org.example</groupId>
<artifactId>demo-webflux</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
TestController
@Slf4j
@RestController
public class TestController {
// WebFlux: 向下兼容原来SpringMVC的大多数注解和API;
@GetMapping("/test01")
public String test01() {
return "test01";
}
//现在推荐的方式
//1、返回单个数据Mono: Mono<Order>、User、String、Map
//2、返回多个数据Flux: Flux<Order>
//3、配合Flux,完成SSE: Server Send Event; 服务端事件推送
@GetMapping("/test02")
public Mono<Void> test02() {
return Mono.empty();
}
@GetMapping("/test03")
public Mono<String> test03() {
return Mono.just("test02");
}
@GetMapping("/test04")
public Flux<String> test04() {
return Flux.just("a", "b", "c");
}
// 前端直接访问: http://localhost:8080/sse01, 页面输出10个数字后, 该请求结束
// 要加上produces = "text/event-stream", 否则前端直接访问http://localhost:8080/sse01, 输出的内容将不会换行
@GetMapping(value = "/sse01", produces = "text/event-stream")
public Flux<String> sse01() {
return Flux.range(1, 10)
.delayElements(Duration.ofMillis(500))
.map(i -> "sse:" + i)
.doOnComplete(() -> {
log.info("输出完成");
});
}
// 前端直接访问: http://localhost:8080/sse02, 页面持续输出数字
@GetMapping(value = "/sse02", produces = "text/event-stream")
public Flux<String> sse02() {
return Flux.interval(Duration.ofSeconds(1))
.map(i -> "sse:" + i);
}
// 前端直接访问: http://localhost:8080/sse03, 页面持续输出数字
@GetMapping(value = "/sse03", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> sse() {
return Flux.range(1, 10)
.map(i -> {
//构建一个SSE对象
return ServerSentEvent
.builder("data-" + i)
.id("id" + i)
.event("event" + i)
.comment("comment-" + i)
.build();
})
.delayElements(Duration.ofMillis(500));
}
}
测试
http://localhost:8080/sse01
http://localhost:8080/sse02
http://localhost:8080/sse03
前端
index2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<body>
<div id="app">
</div>
</body>
<!--引入axios的相关依赖-->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
let app = document.getElementById('app')
const instance = axios.create({
baseURL: 'http://localhost:8080',//请求的url的前缀
timeout: -1,//请求超时的时间
})
//get方式请求
instance({
url: '/sse03', // 发送请求的url
responseType: 'stream',//响应的数据类型
onDownloadProgress: function (progressEvent) { //进度
console.log('progressEvent', progressEvent)
// 这个responseText的内容是累加的
console.log('responseText', progressEvent.event.currentTarget.responseText);
// app.innerHTML = progressEvent.event.currentTarget.responseText;
app.innerText = progressEvent.event.currentTarget.responseText;
}
})
.then(function (response) { //响应完全回来时,触发的回调函数
console.log('response', response)
})
.catch(function (err) { //当请求出现错误时回调函数
console.log('err', err)
});
</script>
</html>
index2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
<script>
// 在完成之后, 默认又会去重试(再自动发起1个http://localhost:8080/sse03的请求)
var eventSource = new EventSource("http://localhost:8080/sse03");
eventSource.onmessage = function (event) {
console.log("onmessage: " + event.data, event);
};
eventSource.onopen = function (event) {
console.log("onopen: " + event.data, event);
};
eventSource.onerror = function (event) {
console.log("onerror: " + event.data, event);
};
</script>
</html>