【技能】前后端的数据交互-sse

前言

在web中我们常见的服务端主动推送客户端的消息的方式,主要可以分为两种

1、不基于长连接的轮询

2、实现基于长连接的http协议例如sse和ws

项目中有一个需求,就是有一些数据表格需要在后端服务中进行操作,其中涉及到了很多的io操作,非常的耗时,所以需要做成一个等待回调的模式, 这块就需要后端能够在表格处理完成之后能够主动告知前端进行数据下载。在这个功能点上我使用了sse的推送方式,因为它本身基于http,也省去了我做用户认证的多余逻辑

sse (Server-Sent Events)

sse是基于http的一个长连接协议,它通过前端的EventSource进行数据接收,和ws不同的是,它只能通过EventSource接收消息,不能够向后端推送消息,所以在这个长连接的通道中其实是单向的,只能后端向前端推送消息。

实现的方式

  1. 准备前端的代码
xml 复制代码
<!DOCTYPE html>
<html lang="en">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="message">
</div>
</body>
<script>
    let source = null;
    // 用时间戳模拟登录用户
    const userId = new Date().getTime();
    if (window.EventSource) {
        // 建立连接
        source = new EventSource("http://localhost:8631/sse/connect")
        /**
         * 连接一旦建立,就会触发open事件
         * 另一种写法:source.onopen = function (event) {}
         */
        source.addEventListener('open', function (e) {
            setMessageInnerHTML("建立连接。。。");
        }, false);

        /**
         * 客户端收到服务器发来的数据
         * 另一种写法:source.onmessage = function (event) {}
         */
        source.addEventListener('message', function (e) {
            setMessageInnerHTML(e.data);
        });


        /**
         * 如果发生通信错误(比如连接中断),就会触发error事件
         * 或者:
         * 另一种写法:source.onerror = function (event) {}
         */
        source.addEventListener('error', function (e) {
            if (e.readyState === EventSource.CLOSED) {
                setMessageInnerHTML("连接关闭");
            } else {
                console.log(e);
            }
        }, false);

    } else {
        setMessageInnerHTML("你的浏览器不支持SSE");
    }

    // 监听窗口关闭事件,主动去关闭sse连接,如果服务端设置永不过期,浏览器关闭后手动清理服务端数据
    window.onbeforeunload = function () {
        closeSse();
    };

    // 关闭Sse连接
    function closeSse() {
        source.close();
        const httpRequest = new XMLHttpRequest();
        httpRequest.open('GET', 'http://localhost:8631/sse/close' , true);
        httpRequest.send();
        console.log("close");
    }

    // 将消息显示在网页上
    function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }
</script>
</html>

2、后端准备代码(基于springboot)

scss 复制代码
@RestController
@Api(description = "系统 - 消息推送服务")
@RequestMapping("/sse")
@Slf4j
public class SseController extends BaseController{

    /**
     * 网上的方法 但存在一个问题 运行在tomcat中,tomcat会帮助直接给关掉,而sse有自动重连,所以每次都会重新发起请求。
     * @return
     */

    /**
     * 用于创建连接
     */
    @GetMapping("/connect")
    public SseEmitter connect() {
        String userId = getUserId().toString();
        return SseEmitterUtil.connect(userId);
    }

    @GetMapping("/send")
    public void sendMessage() {
        String userId = getUserId().toString();
        SseEmitterUtil.sendMessage(userId, UUID.randomUUID().toString());
    }

    @GetMapping("/close")
    public void close() {
        String userId = getUserId().toString();
        SseEmitterUtil.close(userId);
    }


    /**
     * 推送给所有人
     * @param message
     * @return
     */
    @GetMapping("/push/{message}")
    public ResponseEntity<String> push(@PathVariable(name = "message") String message) {
        // 获取连接人数
        int userCount = SseEmitterUtil.getUserCount();
        // 如果无在线人数,返回
        if (userCount < 1) {
            return ResponseEntity.status(500).body("无人在线!");
        }
        SseEmitterUtil.batchSendMessage(message);
        return ResponseEntity.ok("发送成功!");
    }
}



@Slf4j
public class SseEmitterUtil {

    /**
     * 当前连接数
     */
    private static AtomicInteger count = new AtomicInteger(0);

    /**
     * 使用map对象,便于根据userId来获取对应的SseEmitter,或者放redis里面
     */
    private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();

    /**
     * 创建用户连接并返回 SseEmitter
     * @return SseEmitter
     */
    public static SseEmitter connect(String clientId) {
        // 设置超时时间,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
        SseEmitter sseEmitter = new SseEmitter(0L);
        // 注册回调
        // 注册超时回调,超时后触发
        sseEmitter.onTimeout(() -> {
            log.info("连接已超时,正准备关闭,clientId = "+clientId);
            sseEmitterMap.remove(clientId);
        });
        // 注册完成回调,调用 emitter.complete() 触发
        sseEmitter.onCompletion(() -> {
            log.info("连接已关闭,正准备释放,clientId = "+clientId);
            sseEmitterMap.remove(clientId);
            log.info("连接已释放,clientId = " +clientId);
        });
        // 注册异常回调,调用 emitter.completeWithError() 触发
        sseEmitter.onError(throwable -> {
            log.info("连接已异常,正准备关闭,clientId = "+ clientId+"==>"+ throwable);
            sseEmitterMap.remove(clientId);
        });
        sseEmitterMap.put(clientId, sseEmitter);
        // 数量+1
        count.getAndIncrement();
        log.info("创建新的sse连接,当前用户:{}", clientId);
        return sseEmitter;
    }

    /**
     * 给指定用户发送信息
     */
    public static void sendMessage(String userId, String message) {
        if (sseEmitterMap.containsKey(userId)) {
            try {
                // sseEmitterMap.get(userId).send(message, MediaType.APPLICATION_JSON);
                sseEmitterMap.get(userId).send(message);
            }
            catch (IOException e) {
                log.error("用户[{}]推送异常:{}", userId, e.getMessage());
                removeUser(userId);
            }
        }
    }

    /**
     * 群发消息
     */
    public static void batchSendMessage(String wsInfo, List<String> ids) {
        ids.forEach(userId -> sendMessage(wsInfo, userId));
    }

    /**
     * 群发所有人
     */
    public static void batchSendMessage(String wsInfo) {
        sseEmitterMap.forEach((k, v) -> {
            try {
                v.send(wsInfo, MediaType.APPLICATION_JSON);
            }
            catch (IOException e) {
                log.error("用户[{}]推送异常:{}", k, e.getMessage());
                removeUser(k);
            }
        });
    }


    public static void close(String userId) {
        SseEmitter sseEmitter = sseEmitterMap.get(userId);
        sseEmitter.complete();
        sseEmitterMap.remove(userId);
    }

    /**
     * 移除用户连接
     */
    public static void removeUser(String userId) {
        sseEmitterMap.remove(userId);
        // 数量-1
        count.getAndDecrement();
        log.info("移除用户:{}", userId);
    }

    /**
     * 获取当前连接信息
     */
    public static List<String> getIds() {
        return new ArrayList<>(sseEmitterMap.keySet());
    }

    /**
     * 获取当前连接数量
     */
    public static int getUserCount() {
        return count.intValue();
    }
}

后言

springboot 提供了一个封装类SseEmitter 以支持通过sse的协议向前端发送消息,注意的是sse方式不能像ws一样,服务端可以知道前端和自己断开了连接,所以如果是客户端主动断开,最好调用一次http请求通知后端, 另外浏览器支持sse的重连模式,如果意外断开,浏览器会自动尝试重新新建sse消息连接。

如果通过了后端的服务通过了nginx代理,那么需要做额外的配置

perl 复制代码
            # 设置 Nginx 不对 SSE 响应进行缓冲,直接透传给客户端
            proxy_buffering off;
            
            # 设置代理读取服务器响应的超时时间
            proxy_read_timeout 24h;
            
            # 设置客户端连接的超时时间
            proxy_connect_timeout 1h;
            
            # 设置 HTTP 版本,SSE 需要 HTTP/1.1
            proxy_http_version 1.1;
            
            # 保持连接活性,不发送连接关闭的信号
            proxy_set_header Connection '';

            # 配置代理传递的头部,确保 Host 头部正确传递
            proxy_set_header Host $host;

            # 配置代理的后端服务器地址
            proxy_pass url;

            # 设置代理的响应头部,保持传输编码为 chunked
            proxy_set_header X-Accel-Buffering no;
            
            # 设置跨域资源共享 (CORS),如果你的客户端和服务器不在同一个域上
            add_header 'Access-Control-Allow-Origin' '*' always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Origin,Authorization,Accept,X-Requested-With' always;
            if ($request_method = 'OPTIONS') {
                # 如果请求方法为 OPTIONS,则返回 204 (无内容)
                add_header 'Access-Control-Allow-Origin' '*';
                add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
                add_header 'Access-Control-Allow-Headers' 'Origin,Authorization,Accept,X-Requested-With';
                add_header 'Access-Control-Max-Age' 1728000;
                add_header 'Content-Type' 'text/plain charset=UTF-8';
                add_header 'Content-Length' 0;
                return 204;
            }
相关推荐
叫我:松哥1 分钟前
基于Python flask的医院管理学院,医生能够增加/删除/修改/删除病人的数据信息,有可视化分析
javascript·后端·python·mysql·信息可视化·flask·bootstrap
海里真的有鱼4 分钟前
Spring Boot 项目中整合 RabbitMQ,使用死信队列(Dead Letter Exchange, DLX)实现延迟队列功能
开发语言·后端·rabbitmq
工业甲酰苯胺15 分钟前
Spring Boot 整合 MyBatis 的详细步骤(两种方式)
spring boot·后端·mybatis
新知图书1 小时前
Rust编程的作用域与所有权
开发语言·后端·rust
wn5312 小时前
【Go - 类型断言】
服务器·开发语言·后端·golang
希冀1232 小时前
【操作系统】1.2操作系统的发展与分类
后端
GoppViper3 小时前
golang学习笔记29——golang 中如何将 GitHub 最新提交的版本设置为 v1.0.0
笔记·git·后端·学习·golang·github·源代码管理
爱上语文4 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people4 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
罗政9 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端