SpringBoot整合SSE(附详细案例代码)


什么是 SSE

  • SSE(Server-Sent Events,服务器发送事件)是一种允许服务器向浏览器推送实时更新的技术。它是一个基于HTTP的轻量级协议,专门用于从服务器到客户端的单向消息传递
  • 与 WebSocket 不同,SSE 是单向的,服务器可以主动向客户端发送数据,而客户端只能接收数据。
  • 简单来说,就是客户端向服务器发起一个请求,服务器会保持这个连接,并在合适的时候不断地给客户端发送新的数据。
  • 这意味着它非常适合那些需要服务器主动向客户端推送数据的应用场景,比如实时监控、动态通知等。

SSE 协议规范

SSE 协议基于 HTTP,服务器通过 HTTP 响应向客户端推送数据。SSE 的数据格式是纯文本,每条消息以换行符(\n)分隔
HTTP 响应头

  • Content-Type: text/event-stream:指定响应内容为事件流。
  • Cache-Control: no-cache:禁用缓存,确保客户端每次都能接收到最新的数据。
  • Connection: keep-alive:保持长连接,服务器可以持续推送数据。

示例:
在这里插入图片描述
消息格式
SSE 消息由多行组成,每行以 field: value 的形式表示。每条消息以两个换行符(\n\n)分隔。
示例:
在这里插入图片描述

SSE的工作原理

客户端发起请求

客户端通过创建一个 EventSource 对象来发起一个 HTTP 请求到服务器,该请求使用的是 text/event-stream 类型。这个请求就像是客户端向服务器打开了一个 “数据接收通道”,告诉服务器:“我准备好了,你有新数据就发给我。”

服务器保持连接并推送数据

服务器接收到请求后,会保持连接打开。当有新的数据需要发送时,服务器就将数据以特定的格式发送给客户端(不需要客户端不断地轮询服务器是否有新的数据)。这个格式通常是包含 data 字段的文本流,每个数据块之间用两个换行符分隔。服务器就像一个忙碌的快递员,不断地通过这个 “通道” 把新的数据包裹送到客户端。

客户端接收数据

客户端的 EventSource 对象会监听服务器发送的数据,一旦接收到新的数据(通常是文本格式),就会触发相应的事件,开发者可以在这些事件的处理函数中对数据进行处理,比如更新网页上的内容。
自动重连机制:如果连接意外断开,浏览器会自动尝试重新连接服务器(SSE规范)。

SSE的特点

  • 单向通信:SSE仅支持从服务器到客户端的单向通信。如果您需要双向通信,请考虑使用WebSocket。
  • 简单易用:相比WebSocket等其他技术,SSE更易于实现,因为它基于HTTP协议,不需要额外的握手过程或复杂的API。客户端使用 EventSource API,简单易用。
  • 文本数据为主:SSE主要用于传输UTF-8编码的文本数据,虽然可以通过Base64等方式传输二进制数据,但不是最佳选择。
  • 自动重连:当连接中断时,浏览器会自动尝试重新连接,简化了错误处理。
  • 多路复用:一个SSE连接上可以接收多个不同类型的事件,每种事件可以触发不同的回调函数。
  • 基于 HTTP 协议:SSE 是基于 HTTP 协议的,这意味着它可以很好地与现有的 Web 基础设施兼容,不需要额外的端口或代理配置。在大多数情况下,服务器和客户端都可以直接使用现有的 HTTP 服务器和浏览器来实现 SSE 通讯。

SSE 的局限性

单向通讯

SSE 是单向通讯协议,只能由服务器向客户端发送数据,客户端不能向服务器发送数据。如果需要双向通讯,就需要结合其他技术,如使用表单提交或 AJAX 请求。

浏览器兼容性

虽然现代浏览器大多支持 SSE,但在一些旧版本的浏览器中可能存在兼容性问题。在使用 SSE 时,需要考虑目标用户的浏览器版本。

连接数限制

浏览器对每个域的 SSE 连接数有限制(通常为 6 个)。

文本协议

SSE 支持文本数据,不适合传输二进制数据。

SSE vs WebSocket

特性SSEWebSocket
通信方向单向(服务器 → 客户端)双向(服务器 ↔ 客户端)
协议基于 HTTP基于 WebSocket 协议
数据格式文本文本或二进制
自动重连支持需要手动实现
兼容性支持大多数现代浏览器支持大多数现代浏览器
适用场景服务器向客户端推送实时数据双向实时通信

在这里插入图片描述

代码示例

pom.xml

在Spring Boot项目中,无需额外引入特定的依赖,因为Spring Web MVC模块已经内置了对SSE的支持。

<!--lombok插件-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

<!--hutool工具类-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.3.5</version>
</dependency>

相关实体类

用户实体类

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;

/**
 * <p>
 * 用户实体类
 * </p>
 *
 * @author qf
 * @since 2024-06-25
 */
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class SysUser implements Serializable {

    private static final long serialVersionUID = 1L;

    private String id;

    private String createBy;

    /**
     * 用户昵称
     */
    private String nickName;

    /**
     * 联系方式
     */
    private String phone;

    /**
     * 用户登录账号
     */
    private String userName;

    /**
     * 密码
     */
    private String password;

    /**
     * 所属系统
     */
    private String systemId;
}
import lombok.Data;

@Data
public class SseMessage {

    /**
     * 状态码 200为成功,其他为失败
     * HttpServletResponse.SC_OK
     * HttpServletResponse.SC_INTERNAL_SERVER_ERROR
     */
    private Integer code;

    /**
     * 消息类型
     *
     */
    private Integer type;
    /**
     * 数据
     */
    private String data;

    /**
     * 推送时间
     */
    private String putTime;
}

自定义异常类

public class ServiceException extends RuntimeException {

    private Integer code;

    private String msg;

    public ServiceException(Integer code, String msg) {
        super();
        this.msg = msg;
        this.code = code;
    }

    public ServiceException(String msg) {
        this(1, msg);
    }

    @Override
    public String getMessage() {
        return msg;
    }
    
    public Integer getCode() {
        return code;
    }
}

服务类

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

public interface SseService {
    SseEmitter connect(String clientId);

    void activeSendMessage(SseMessage sseMessage);

    void sendMessage();
}
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
import com.qf.config.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 *
 * @author qf
 * @since 2024-06-25
 */
@Slf4j
@Service
public class CommonSseServiceImpl implements SseService {
    /**
     * k:客户端id  v:SseEmitter
     */
    private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
    /**
     * k:客户端id  v:SysUser
     */
    private static Map<String, SysUser> sseUserMap = new ConcurrentHashMap<>();

    /**
     * 连接sse
     *  clientId用于区分客户端
     *
     * @param clientId
     * @return
     */
    @Override
    public SseEmitter connect(String clientId) {
        //6小时后断开
        SseEmitter sseEmitter = new SseEmitter(6 * 60 * 60 * 1000L);
        SseMessage sseMessage = new SseMessage();
        if (sseEmitterMap.containsKey(clientId)) {
            return SseExceptionHandle(sseMessage, sseEmitter, "该clientId已绑定指定的客户端!clientId:" + clientId);
        }

        if (sseEmitterMap.size() > 1000) {
            return SseExceptionHandle(sseMessage, sseEmitter, "客户端连接过多,请稍后重试!");
        }
        // 连接成功需要返回数据,否则会出现待处理状态
        try {
            sseMessage.setCode(HttpServletResponse.SC_OK);
            sseMessage.setData("连接成功!");
            sseEmitter.send(sseMessage, MediaType.APPLICATION_JSON);
        } catch (IOException e) {
            log.error("sse连接发送数据时出现异常:", e);
            throw new ServiceException("sse连接发送数据时出现异常!");
        }

        // 连接断开
        sseEmitter.onCompletion(() -> {
            log.info("sse连接断开,clientId为:{}", clientId);
            sseEmitterMap.remove(clientId);
        });

        // 连接超时
        sseEmitter.onTimeout(() -> {
            log.info("sse连接已超时,clientId为:{}", clientId);
            sseEmitterMap.remove(clientId);
        });

        // 连接报错
        sseEmitter.onError((throwable) -> {
            log.info("sse连接异常:", throwable);
            sseEmitterMap.remove(clientId);
        });
        sseEmitterMap.put(clientId, sseEmitter);
        //模拟拿到登录用户信息
        SysUser user = getUser(clientId);
        sseUserMap.put(clientId, user);
        return sseEmitter;
    }

    public SysUser getUser(String clientId) {
        SysUser user = new SysUser();
//        user.setId(DateUtil.format(new Date(),"yyyyMMddHHmmss"));
        user.setId(clientId);
        user.setNickName(clientId);
        user.setSystemId("1001");
        return user;
    }

    private static SseEmitter SseExceptionHandle(SseMessage sseMessage, SseEmitter sseEmitter, String exceptionMessage) {
        log.error(exceptionMessage);
        sseMessage.setCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        sseMessage.setData(exceptionMessage);
        try {
            sseEmitter.send(sseMessage, MediaType.APPLICATION_JSON);
        } catch (IOException e) {
            log.error("sse连接发送数据时出现异常:", e);
            throw new ServiceException("sse连接发送数据时出现异常!");
        }
        return sseEmitter;
    }

    @Override
    public void activeSendMessage(SseMessage sseMessage) {
        if (ObjectUtil.isEmpty(sseEmitterMap)) {
            return;
        }
        for (Map.Entry<String, SseEmitter> entry : sseEmitterMap.entrySet()) {
            SseEmitter sseEmitter = entry.getValue();
            try {
                sseEmitter.send(sseMessage, MediaType.APPLICATION_JSON);
            } catch (Exception e) {
                log.error("告警sse推送json转换异常,数据为:{}", sseMessage, e);
            }
        }
    }

    /**
     * 发送消息
     */
    @Override
    public void sendMessage() {
        if (ObjectUtil.isEmpty(sseEmitterMap)) {
            return;
        }
        for (Map.Entry<String, SseEmitter> entry : sseEmitterMap.entrySet()) {
            String clientId = entry.getKey();
            SseEmitter sseEmitter = entry.getValue();
            if (ObjectUtil.isEmpty(sseUserMap) || !sseUserMap.containsKey(clientId)) {
                continue;
            }
            SysUser user = sseUserMap.get(clientId);
            try {
                //先直接从redis中拿取数据
                String data = DateUtil.formatDateTime(new Date()) + "systemId-" + user.getSystemId() + " userId-" + user.getId();
                SseMessage message = new SseMessage();
                message.setCode(HttpServletResponse.SC_OK);
                message.setType(1);
                message.setPutTime(DateUtil.formatDateTime(new Date()));
                message.setData(data);
                sseEmitter.send(message, MediaType.APPLICATION_JSON);
            } catch (Exception e) {
                log.error("告警sse推送json转换异常,用户信息:{}", user.toString(), e);
                continue;
            }
        }
    }
}

定时任务

用于给客户端推送消息

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 *
 * @author qf
 * @since 2024-06-25
 */
@Slf4j
@Component
public class SendMessageTask {
    @Autowired
    private SseService sseService;

    /**
     * 定时发送消息
     */
    @Scheduled(cron = "*/2 * * * * *")  // 每2秒发一次
    public void sendMessageTask() {
        sseService.sendMessage();
    }
}

Controller

用于建立SSE连接和主动推送消息

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.constraints.NotNull;

/**
 *
 * @author qf
 * @since 2024-06-25
 */
@Slf4j
@Controller
@RequestMapping("/sse/common")
public class CommonController {

    @Autowired
    private SseService sseService;

    /**
     * 创建SSE连接
     *
     * @return
     */
    @GetMapping(path = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter sse(@RequestParam @NotNull(message = "clientId不能为空") String clientId) {
        log.info("sse实时推送新用户连接:{}", clientId);
        return sseService.connect(clientId);
    }

    /**
     * 主动广播消息
     *
     * @param sseMessage
     */
    @PostMapping("/sendMessage")
    @ResponseBody
    public void sendMessage(@RequestBody SseMessage sseMessage) {
        sseService.activeSendMessage(sseMessage);
    }

    /**
     * 执行定时任务
     * @return
     */
    @GetMapping("/sendMessageTimer")
    @ResponseBody
    public String sendMessageTimer() {
        sseService.sendMessage();
        return true+"";
    }
}

前端代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Demo</title>
</head>

<body>
    <div id="result"></div>
 <!-- 添加输入框和按钮 -->
 <div>
    <input type="text" id="message" placeholder="输入消息内容">
    <button onclick="sendMessage()">发送消息</button>
</div>
    <script>
        // 连接服务器
        var timestamp = Date.now();
        var currentDate = new Date(timestamp);
        var formattedDate = currentDate.getFullYear() +
                    (currentDate.getMonth() + 1).toString().padStart(2, '0') + 
                    currentDate.getDate().toString().padStart(2, '0') +
                    currentDate.getHours().toString().padStart(2, '0') +
                    currentDate.getMinutes().toString().padStart(2, '0');
        console.log(formattedDate);
        var sseSource = new EventSource("http://localhost:8089/sse/common/connect?clientId="+formattedDate);

        // 连接打开
        sseSource.onopen = function () {
            console.log("连接打开");
        }

        // 连接错误
        sseSource.onerror = function (err) {
            console.log("连接错误:", err);
        }

        // 接收到数据
        sseSource.onmessage = function (event) {
            console.log("接收到数据:", event);
            handleReceiveData(JSON.parse(event.data))
        }

        // 关闭链接
        function handleCloseSse() {
            sseSource.close()
        }

        // 处理服务器返回的数据
        function handleReceiveData(data) {
            let div = document.createElement('div');
            div.innerHTML = data.data;
            document.getElementById('result').appendChild(div);
        }

        // 通过http发送消息
        function sendMessage() {
            console.log("发送消息")
            const message = document.querySelector("#message")
            const data = {
                data: message.value
            }

            message.value = ''

            fetch('http://localhost:8089/sse/common/sendMessage', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json;charset=utf-8'
                },
                body: JSON.stringify(data)
            })
        }
    </script>
</body>
</html>

注意修改端口号

总结

  • SSE 是一种基于 HTTP 的服务器向客户端推送实时数据的技术。
  • SSE 协议通过简单的文本格式实现数据推送,支持自动重连和断点续传。
  • SSE 的工作原理是通过保持 HTTP 连接打开,服务器持续向客户端推送数据。
  • SSE 的优点是简单易用、兼容性好,适合单向实时数据推送场景。
  • SSE 的缺点是单向通信,不适合双向实时通信场景。

参考文章:
SSE实现后端主动向前端推送数据

### Spring Boot 中集成 SSE (服务器发送事件) 在现代 Web 应用程序中,实时数据更新是一个常见的需求。通过使用 Server-Sent Events(SSE),可以实现从服务器到客户端的单向通信通道。这使得浏览器能够接收来自服务器的通知而无需轮询。 为了在 Spring Boot 项目中启用 SSE 功能,需要创建一个控制器来处理 HTTP 请求并返回 `text/event-stream` 类型的内容[^1]: ```java import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class SseController { @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamEvents() { return Flux.interval(Duration.ofSeconds(1)) .map(sequence -> "event-" + sequence); } } ``` 上述代码片段展示了如何定义 RESTful API 来持续不断地向订阅者推送消息。这里利用了 Project Reactor 的 `Flux` 对象作为响应体的一部分,它允许异步流式传输多个元素给客户端[^2]。 对于前端部分,则可以通过 JavaScript 原生支持的方式轻松连接至该端点,并监听传入的消息: ```javascript const eventSource = new EventSource('/stream'); eventSource.onmessage = function(event) { console.log('New message:', event.data); }; ``` 当建立好这种长链接之后,每当有新的通知产生时就会触发相应的回调函数,在控制台打印最新的信息[^3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值