sse服务端消息推送

学习链接

webflux&webclient
demo-qianfan

AI对话的逐字输出:流式返回才是幕后黑手

【SpringBoot+SseEmitter】 和【Vue3+EventSource】 实时数据推送

Server-Sent Events 教程

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

Spring AI 集成【千帆 QianFan】大模型

如何申请文心一言&文心千帆大模型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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值