Spring Boot 实现 SSE 服务端推送事件

news2024/11/21 2:22:27

源码地址

关于 SSE

SSE 全程 Server Send Event,是 HTTP 协议中的一种,Content-Typetext/event-stream,是服务端主动向前端推送数据。类似于 WebSocket。

SSE 优势我们可以划分为两个:

  • 长链接
  • 服务端能主动向客户端推送数据

这里我们用 sse 与 websocket 对比下:

ssewebsocket
centered 文本居中right-aligned 文本居右
http 协议独立的websocket 协议轻量,使用简单相对复杂
默认支持断线重连需要本身实现断线重连
文本传输二进制传输
支持自定义发送的消息类型-

另外在 响应式流里面,可以多次返回数据(其实和响应式没有关系),使用的技术就是H5的SSE。

返回的数据有固定的要求格式。

sse 规范

在 html5 的定义中,服务端 sse,通常须要遵循如下要求

请求头

开启长链接 + 流方式传递

Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive

数据格式

服务端发送的消息,由 message 组成,其格式以下:

field:value\n\n

其中 field 有五种可能

  • 空: 即以 : 开头,表示注释,能够理解为服务端向客户端发送的心跳,确保链接不中断
  • data:数据
  • event: 事件,默认值
  • id: 数据标识符用 id 字段表示,至关于每一条数据的编号
  • retry: 重连时间

在 Spring 框架中实现

这里需要允许跨域

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Locale;
import java.util.concurrent.TimeUnit;

/**
 * @author wuq
 * @Time 2022-12-19 15:02
 * @Description
 */
@CrossOrigin(origins = "*", maxAge = 3600)
@Controller
public class SSEController {

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public String home(Locale locale, Model model) {
        return "sse";
    }

    @RequestMapping(value = "push")
    public void push(HttpServletResponse response) throws IOException {
        response.setContentType("text/event-stream");
        response.setCharacterEncoding("utf-8");

        for (int i = 0; i < 5; i++) {
            // 指定事件标识  event: 这个为固定格式
            response.getWriter().write("event:me\n");
            // 格式:data: + 数据 + 2个回车
            response.getWriter().write("data:" + i + "\n\n");
            response.getWriter().flush();

            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

前端测试

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
    <div id="message"></div>
</div>
<script type="text/javascript">
    // 初始化,参数为url
    // 依赖H5
    var sse = new EventSource("http://localhost:8080/push")

    // 监听消息并打印
    sse.onmessage = function (evt) {
        console.log("message", evt.data, evt)
    }

    // 如果指定了事件标识需要用这种方式来进行监听事件流
    sse.addEventListener("me", function (evt) {
        console.log("me event", evt.data)
        // 事件流如果不关闭会自动刷新请求,所以我们需要根据条件手动关闭
        if (evt.data == 3) {
            sse.close();    // 注释掉这个可以看到自动重连的效果
        }

        setMessageInnerHTML(evt.data);
    })

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

一起来看下效果
测试 sse

这里,我们将前端代码中的 sse.close(); 注释掉,再来看下自动重连的机制
自动重连

传统的 Servlet 中实现

至于为什么要关注与 传统的 Servlet 实现,是因为这样子的,之前看过一篇文章,文章上面说离开了 Spring 几乎都不会写代码了,的确是有这样子的感觉,现在离开了 Spring 连传统的 Servlet 都快忘记完了。感觉还是有必要了解下原始的写法,不过下面的工程还是基于 Spring 的工程来的,这样子搭建最简单。

需要在启动类中增加 @ServletComponentScan

@SpringBootApplication
@ServletComponentScan    //  需要增加这个
public class SSEApplication {

    public static void main(String[] args) {
        SpringApplication.run(SSEApplication.class, args);
    }
}

对应的后端代码

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.TimeUnit;

@WebServlet(name = "SSE", urlPatterns = "/SSE")
public class SSE extends HttpServlet {

    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        doGet(request, response);
    }

    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        response.setContentType("text/event-stream");  // 这里可以了解到是使用的 `text/event-stream` 作为响应的 ContentType
        response.setCharacterEncoding("utf-8");

        for (int i = 0; i < 5; i++) {
            // 指定事件标识
            response.getWriter().write("event:me\n");
            // 格式:data: + 数据 + 2个回车
            response.getWriter().write("data:" + i + "\n\n");
            response.getWriter().flush();

            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

使用场景:服务器向客户端推送数据,例如聊天室

封装工具类实现

SpringMVC封装的SSE实现,Controller 中直接返回 SseEmitter,不调用 complete() 方法,即可保持长链接。

创建一个工具类 SseEmitterUtil

package com.demo.test.sse.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;

@Slf4j
public class SseEmitterUtil {

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

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

    /**
     * 创建用户连接并返回 SseEmitter
     *
     * @param userId 用户ID
     * @return SseEmitter
     */
    public static SseEmitter connect(String userId) {
        // 设置超时时间,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
        SseEmitter sseEmitter = new SseEmitter();
        // 注册回调
        sseEmitter.onCompletion(completionCallBack(userId));
        sseEmitter.onError(errorCallBack(userId));
        sseEmitter.onTimeout(timeoutCallBack(userId));

        // 缓存
        sseEmitterMap.put(userId, sseEmitter);

        // 数量+1
        count.getAndIncrement();
        log.info("创建新的sse连接,当前用户:{}", userId);
        return sseEmitter;
    }

    /**
     * 给指定用户发送信息
     */
    public static void sendMessage(String userId, String message) {
        if (!sseEmitterMap.containsKey(userId)) return;

        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 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();
    }

    private static Runnable completionCallBack(String userId) {
        return () -> {
            log.info("结束连接:{}", userId);
            removeUser(userId);
        };
    }

    private static Runnable timeoutCallBack(String userId) {
        return () -> {
            log.info("连接超时:{}", userId);
            removeUser(userId);
        };
    }

    private static Consumer<Throwable> errorCallBack(String userId) {
        return throwable -> {
            log.info("连接异常:{}", userId);
            removeUser(userId);
        };
    }
}

controller 中的写法

package com.demo.test.sse.controller;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("sse")
public class SseRestController {

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

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

    /**
     * 发送给单个人
     *
     * @param message
     * @param userid
     * @return
     */
    @GetMapping("/push_one/{messsage}/{userid}")
    public String pushOne(@PathVariable(name = "message") String message, @PathVariable(name = "userid") String userid) {
        SseEmitterUtil.sendMessage(userid, message);
        return "推送消息给" + userid;
    }

    /**
     * 关闭连接
     */
    @GetMapping("/close/{userid}")
    public String close(@PathVariable("userid") String userid) {
        SseEmitterUtil.removeUser(userid);
        return "连接关闭";
    }
}

前端测试

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>消息推送</title>
</head>
<body>
    <div>
        <button onclick="closeSse()">关闭连接</button>
        <div id="message"></div>
    </div>
</body>

<script>
    let source = null;

    // 用时间戳模拟登录用户
    const userId = new Date().getTime();

    if (window.EventSource) {

        // 建立连接
        source = new EventSource('http://localhost:8080/sse/connect/' + userId);

        /**
         * 连接一旦建立,就会触发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:8080/sse/close/' + userId, true);
        httpRequest.send();
        console.log("close");
    }

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

注意

客户端关闭了连接,不管是调用了event.target.close() 还是关闭了网页,服务端不会触发任何回调。直到服务端调用 send 后才会触发onErroronCompletion回调。

服务端触发了onCompletion回调后,连接就断开了。

参考链接

  • https://www.zhangbj.com/p/1262.html
  • http://www.javashuo.com/article/p-uelldoqc-ds.html

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/106258.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

想要精通算法和SQL的成长之路 - 编辑距离

想要精通算法和SQL的成长之路 - 编辑距离前言一. 编辑距离1.1 定义动态规划数组1.2 定义动态规划方程1.3 定义数组的初始化1.4 最终答案前言 想要精通算法和SQL的成长之路 - 系列导航 一. 编辑距离 原题链接 给你两个单词 word1 和 word2&#xff0c; 请返回将 word1 转换成 …

DataFactory根据字段类型在mysql插入数据

目录 插入Varchar类型数据 场景一&#xff1a;主键使用uuid 场景二&#xff1a;从外部导入数据 场景三&#xff1a;使用组合方式 插入data类型日期数据 插入Varchar类型数据 insert value from an data table &#xff1a;表示可以通过外部数据创建数据表插入字符串值 i…

实验二B 图像的空域与频域滤波(源代码一站式复制粘贴)

实验二B 图像的空域与频域滤波一、实验目的二、实验原理三、实验内容与要求四、实验的具体实现一、实验目的 1.掌握图像滤波的基本定义及目的。 2.理解空间域滤波的基本原理及方法。 3.掌握进行图像的空域滤波的方法。 4.掌握傅里叶变换及逆变换的基本原理方法。 5.理解频域滤…

算法刷题打卡第52天:排序数组---桶排序

排序数组 难度&#xff1a;中等 给你一个整数数组 nums&#xff0c;请你将该数组升序排列。 示例 1&#xff1a; 输入&#xff1a;nums [5,2,3,1] 输出&#xff1a;[1,2,3,5]示例 2&#xff1a; 输入&#xff1a;nums [5,1,1,2,0,0] 输出&#xff1a;[0,0,1,1,2,5]桶排序…

2022 CSDN 客服年终总结

hello&#xff0c;大家好&#xff0c;这里是《听用户心声&#xff0c;解用户之需》之 2022 年终总结篇。 秉承“用户至上”的服务理念&#xff0c;为了给用户提供极致的服务体验而时刻努力着&#xff0c;2022年&#xff0c;在大家的一致努力下&#xff0c;究竟有何成效呢&#…

SpringBoot1:helloword、导入依赖、配置项设置,打包方法、lombok、dev-tools、Spring Initailizr、常见注解

1.简介 简化Spring开发的一个框架。对整个Spring技术栈的大整合 J2EE企业级开发的一站式解决方案。 2.微服务 一个应用应该是一组小型服务&#xff0c;可以通过HTTP的方式来进行互通 每一个功能元素都是可独立替换&#xff0c;和独立升级的软件单元。 3.HelloWord 功能&am…

Stm32标准库函数5——OV2640 PA0-7 F103C8T6 4500000 联合VB 高分辨率

stm32f103c8t6串口发送 OV2640的图像&#xff0c;分辨率可选。网络上资料大部分是低分辨率的&#xff0c;这个可以做高分辨率 完整工程打包&#xff0c;包含VB串口显示界面&#xff1a; https://download.csdn.net/download/fengyuzhe13/87327054https://download.csdn.net/do…

【经典问题:HanoiTower(汉诺塔)】

&#x1f381;HanoiTower&#x1f385;HanoiTower问题描述&#x1f385;&#x1f385;模拟推导&#x1f385;&#x1f385;&#x1f385;问题的两种形式&#x1f384;求解移动总次数&#x1f384;&#x1f384;打印详细的移动过程&#x1f385;HanoiTower问题描述 汉诺塔问题&a…

基于HMM模型实现中文分词

任务描述:在理解中文文本的语义时需要进行分词处理,分词算法包括字符串匹配算法,基于统计的机器学习算法两大类。本案例在前文将说明常用分词库及其简单应用,之后会通过中文分词的例子介绍和实现一个基于统计的中文分词方法——HMM模型,该模型能很好地处理歧义和未登录词问…

[含文档+源码等]基于SSM实现的宿舍公共财产管理系统|寝室

博主介绍&#xff1a;✌在职Java研发工程师、专注于程序设计、源码分享、技术交流、专注于Java技术领域和毕业设计✌ 项目名称 [含文档源码等]基于SSM实现的宿舍公共财产管理系统|寝室 演示视频 [含文档源码等]基于SSM实现的宿舍公共财产管理系统|寝室_哔哩哔哩_bilibili 系统介…

经济低迷形势下,如何降低软件开发成本?

1、选对开发方法 过程决定结果。方法错了&#xff0c;再有经验的人&#xff0c;结果也不会好。例如&#xff0c;软件开发方法从70年代的瀑布&#xff0c;一步步从迭代、快速原型等进化到现在的敏捷、规模化敏捷、DevOps等。统计数字显示&#xff0c;使用敏捷方法&#xff0c;平…

Excel中实现时间相减,得到间隔时间(年月日时分秒)

一、年月日之差 表中有开始日期和结束日期&#xff0c;我们在D3单元格中输入“C3-B3” 于是&#xff0c;得到下面的结果 很显然&#xff0c;结果并不是我们想要的。说明这种方法不行&#xff0c;为了得到两个日期之间的时间间隔&#xff0c;需要用到DATEDIF函数。先来说下DATED…

我国融资性担保行业整体呈减量提质趋势 国家“出手”解决行业痛点

根据观研报告网发布的《中国融资性担保行业发展趋势分析与投资前景预测报告&#xff08;2022-2029年&#xff09;》显示&#xff0c;融资性担保行业是指担保人与银行业金融机构等债权人约定&#xff0c;当被担保人不履行对债权人负有的融资性债务时&#xff0c;由担保人依法承担…

Go:使用 go-micro 构建微服务(一)

一、微服务 什么是微服务&#xff08;microservice&#xff09;&#xff1f;这是企业界正在向计算界提出的问题。一个产品的可持续性取决于它的可修改程度。 大型产品如果不能正常维护&#xff0c;就需要在某个时间点停机维护。而微服务架构用细化的服务取代了传统的单体服务…

语音识别芯片LD3320介绍再续

语音识别芯片LD3320驱动程序 1、芯片复位 复位就是对LD3320芯片的第47腿&#xff08;RSTB*&#xff09;发送低电平&#xff0c;然后需要对片选CS做一次拉低→拉 高的操作&#xff0c;以激活内部DSP。按照以下顺序&#xff1a; void LD_reset() { RSTB1;delay(1);RSTB0;delay…

TencentOS 3.1下安装zabbix 5.0.30

TencentOS是使用官方镜像文件安装的虚拟机。 虚拟机为virtualBox 6.1 zabbix 使用zabbix官方安装包编译安装。 下载地址: Download Zabbix sources zabbix软件包解包,本次安装解包在/opt下 zabbix需要nginx、php、mysql等软件支持,因此先安装它们。 安装mysql如下: yu…

Usaco Training刷怪旅 第二层第二题:Transformations

usaco training 关注我持续更新usaco training A square pattern of size N x N (1 < N < 10) black and white square tiles is transformed into another square pattern. Write a program that will recognize the minimum transformation that has been applied to t…

QCon直击|闲鱼推荐大规模应用背后的工程实践

讲师介绍闲鱼技术部 | 吴白万小勇&#xff08;吴白&#xff09;&#xff0c;闲鱼服务端专家。毕业于南京大学计算机系&#xff0c;目前负责闲鱼技术推荐架构。推荐在闲鱼的应用不同于搜索的确定性&#xff0c;推荐场景面临的问题往往是不确定的。但是正是因为这种不确定&#x…

Kafka无法对外暴露端口的相关解决方案

1、kafka开放外部访问 1.1、修改kafka configMap 修改内容如下 # 修改EXTERNAL_ACCESS_IP变量&#xff0c;值为真实的外网IP export EXTERNAL_ACCESS_IP${真实的外网IP} 1.2、重启kafka # 重启kafka kubectl rollout restart statefulset kafka -n default # 查看kafka状态 k…

盛邦安全将于12月27日上会,2022年前三季度收入1亿元

12月20日&#xff0c;上海证券交易所披露的科创板上市委2022年第112次审议会议公告显示&#xff0c;远江盛邦&#xff08;北京&#xff09;网络安全科技股份有限公司&#xff08;下称“盛邦安全”&#xff09;将于2022年12月27日上会。 目前&#xff0c;盛邦安全已经提交了招股…