一文探究SSE (Server-sent events)

906 阅读3分钟

什么是SSE ?

SSE(Server-Sent Events)是一种用于实现服务器主动向客户端推送数据的技术,也被称为“事件流”(Event Stream)。它基于 HTTP 协议,利用了其长连接特性,在客户端与服务器之间建立一条持久化连接,并通过这条连接实现服务器向客户端的实时数据推送。

SSE 的优点和适用场景

  • 简单易用: SSE 协议相对于 WebSocket 更简单,实现起来更加轻量级,不需要复杂的握手过程。
  • 实时性: SSE 适合需要实时推送数据的场景,如即时通讯、股票市场报价、实时数据监控等。
  • 基于标准: SSE 是基于 HTTP 的标准协议,与现有的 Web 技术兼容性良好。

SSE 的实现步骤

  • 服务端实现: 使用支持 SSE 的服务器端技术(如Node.js的express框架),在 HTTP 头部添加特定的 Content-Type(text/event-stream)和其他 SSE 相关字段,定期向客户端发送数据。

    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    });
    
    res.write('data: Hello\n\n'); // 发送数据给客户端
    
  • 客户端实现: 使用 JavaScript 创建一个 EventSource 对象,监听从服务器发送的事件。

    var eventSource = new EventSource('/sse-endpoint');
    
    eventSource.onmessage = function(event) {
      console.log('Received event: ', event.data);
      // 处理接收到的数据
    };
    

4. SSE 的局限性

  • 单向通信: SSE 是服务器向客户端的单向通信,客户端无法向服务器端发送数据,因此不适合需要双向通信的应用。
  • 兼容性: 虽然现代浏览器普遍支持 SSE,但是在一些旧版本浏览器和移动设备上的支持可能有限。

5. 基于springboot 实现 SSE

 添加依赖

pom.xml(如果是 Maven 项目)中添加 Spring Web 相关依赖,确保项目能够支持 Web 开发:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

创建 SSE 示例

创建一个操作类,用于处理 SSE 请求并向客户端发送事件流。

@Service
public class SseEmitterServiceImpl implements SseEmitterService {


    private final Logger log = LoggerFactory.getLogger(SseEmitterServiceImpl.class);
    /**
     * 使用map对象,便于根据userId来获取对应的SseEmitter
     */
    private final Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();


    public  SseEmitter connect(String userId) {

        if(sseEmitterMap.containsKey(userId)){
            return sseEmitterMap.get(userId);
        }

    // 设置超时时间,0表示不过期。默认30S,超时时间未完成会抛出异常:AsyncRequestTimeoutException
        SseEmitter sseEmitter = new SseEmitter(5*60*1000L);
        // 注册回调
        sseEmitter.onCompletion(completionCallBack(userId));
        sseEmitter.onError(infoCallBack(userId));
        sseEmitter.onTimeout(timeoutCallBack(userId));
        sseEmitterMap.put(userId, sseEmitter);

  log.info("创建新的 SSE 连接,当前用户 {}, 连接总数 {}", userId, sseEmitterMap.size());
        return sseEmitter;
    }


    /**
     * 给制定用户发送消息
     *
     * @param userId 指定用户名
     * @param message 消息
     */
    @Override
    public void sendMessage(String userId, String message) {

        if(sseEmitterMap.containsKey(userId)){
            try {
                SseEmitter sseEmitter = sseEmitterMap.get(userId);
                sseEmitter.send(message);
            }catch (Exception e){
                removeUser(userId);
            }
        }
    }


    /**
     * 查询当前用户是否在线
     * @return
     */
    @Override
    public boolean isOnline(String userId) {
        return sseEmitterMap.entrySet().stream()
                .anyMatch(entry -> entry.getKey().startsWith(userId + ":"));
    }

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

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

    /**
     * 移除用户连接
     *
     * @param userId 用户 ID
     */
    public  void removeUser(String userId) {
        try {
            if (sseEmitterMap.containsKey(userId)) {
                SseEmitter sseEmitter = sseEmitterMap.get(userId);
                if(sseEmitter!=null){
                    sseEmitter.complete();
                    sseEmitterMap.remove(userId);
                }else {
                    log.info("用户连接不存在");
                }
            }
        }catch (Exception e){
            log.info("用户 {} 推送异常",userId);
        }

    }

    /**
     * 获取当前连接信息
     *
     * @return 所有的连接用户
     */
    public List<String> getIds() {
        return new ArrayList<>(sseEmitterMap.keySet());
    }

    /**
     * 获取当前的连接数量
     *
     * @return 当前的连接数量
     */
    public  int getUserCount() {
        return sseEmitterMap.size();
    }

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

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

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

创建一个控制器类,以下是由于单账号多点登录,需单独区别

@GetMapping("/connect")
@ApiOperation("sse用于创建连接")
public SseEmitter connect(HttpServletRequest request, HttpServletResponse response) {
    String sessionId = ClientIdUtils.getClientKey(request, response);
    Long userId = AdminContext.getCurrentUser().getId();
    String key = RedisKeyUtils.getKey(userId.toString(),sessionId);
    SseEmitter connect = sseEmitterService.connect(key);
    MessageResultVo deferredResultVo = smsService.selectMessageRemindByUserId(userId);
    sseEmitterService.sendMessage(key, JSONObject.toJSONString(deferredResultVo));
    return connect;
}

可能出现的异常

1721013467820.png 这个异常是由于当前连接对象已经终端,由于sse是由后端服务器主动向客服端推送消息,故而不知道连接是否存活,发送消息时导致的,由tomcat内部报错,可忽略,或者业务可以增加心跳连接的方式解决

AsyncRequestTimeoutException异常是由于初始化示例连接时新增了有效时间,导致服务端会到期主动中断并且再次连接,过程中可能会出现AsyncRequestTimeoutException,可自行全局捕获。