Server-Sent Events(SSE)单向消息推送实现(SpringBoot + Vue)

前言

        服务端推送,这个概念其实它就是我们日常生活中经常遇到的那些“叮咚”一声的通知。想象一下,你正在用手机刷微博,突然手机震动了一下,屏幕上弹出一条新闻推送,告诉你你关注的明星发了新动态。这就是服务端推送的一个典型例子。

为啥需要服务端推送呢?原因有几个:

  1. 实时通知:就像你不想错过任何一条朋友的微信消息一样,很多应用也需要实时地把信息推送给用户。比如,你正在用的购物APP,突然有个限时折扣,服务端就能立刻把这个好消息推送给你,让你不会错过任何薅羊毛的机会。

  2. 节省资源:如果没有服务端推送,应用就得不断地“问”服务器:“有新消息吗?”“有新消息吗?”这样不仅烦人,还很浪费电。服务端推送就像是服务器对客户端说:“别问了,有新消息了,快来看!”这样客户端就能在需要的时候才接收信息,既省电又高效。

  3. 增强用户体验:好的推送能让用户感到贴心。比如,你经常在某音乐APP上听周杰伦的歌,服务端就能根据你的喜好,推送周杰伦的新专辑信息给你。这种个性化的服务能让用户感到被重视,自然就更愿意留在你的应用里。

        对于我们自己的应用来说,推送场景也很丰富。比如,用户在下载文件时,我们可以推送下载进度;用户在等待连线时,我们可以推送连线请求;还有直播提醒,告诉用户他们关注的主播已经开始直播了。这些都是提升用户体验的小细节,让用户感觉到我们的服务是及时、贴心和高效的。

服务端推送解决方案

一、传统实时处理方案的丰富内容

1. HTTP 不断轮询

       最常见的解决方案是,网页的前端代码里不断定时发 HTTP 请求到服务器,服务器收到请求后给客户端响应消息。这其实只是一种「伪」服务器推的形式。它其实并不是服务器主动发消息到客户端,而是客户端自己不断偷偷请求服务器,只是用户无感知而已。

        用这种方式的场景也有很多,最常见的就是扫码登录。登录页面二维码出现之后,前端网页根本不知道用户扫没扫,于是不断去向后端服务器询问,看有没有人扫过这个码。而且是以大概 1 到 2 秒的间隔去不断发出请求,这样可以保证用户在扫码后能在 1 到 2 秒内得到及时的反馈,不至于等太久。

但这样,会有两个比较明显的问题:

  • 当你打开 F12 页面时,你会发现满屏的 HTTP 请求。虽然很小,但这其实也消耗带宽,同时也会增加下游服务器的负担。
  • 最坏情况下,用户在扫码后,需要等个 1~2 秒,正好才触发下一次 HTTP 请求,然后才跳转页面,用户会感到明显的卡顿。

2. 长轮询(Long Polling)

        HTTP 请求发出后,一般会给服务器留一定的时间做响应,比如 3 秒,规定时间内没返回,就认为是超时。如果我们的 HTTP 请求将超时设置的很大,比如 30 秒,在这 30 秒内只要服务器收到了扫码请求,就立马返回给客户端网页。如果超时,那就立马发起下一次请求。这样就减少了 HTTP 请求的个数,对于像扫码登录这样的简单场景还能用用。但如果是需要长时间监听呢,总不能创建一个高达几小时的超时链接吧。所以就有了下面的几种专门的服务器推送技术。

二、HTML5 实时处理方案

1. WebSocket

        WebSocket是一种支持双向通信的协议,适用于需要实时交互的场景。例如,在在线游戏中,玩家的动作能够即时反映在其他玩家的屏幕上,这就需要WebSocket来实现低延迟的数据传输。

        WebSocket完美继承了 TCP 协议的全双工能力,并且还贴心的提供了解决粘包的方案(采用TCP消息头规定消息体长度的方式)。它适用于需要服务器和客户端(浏览器)频繁交互的大部分场景,比如网页/小程序游戏,网页聊天室,以及一些类似飞书这样的网页协同办公软件。   

2. SSE(Server-Sent Events)

        尽管WebSocket已经能满足我们对实时推送的需求了,但是我们会发现,除了聊天室、联机游戏这种双向传输(客户端实时推服务端,服务端实时推送客户端)的场景外,我们还会有像点赞提醒、最新股票数据更新这种追求实时但是只需要服务端推送给客户端的单向传输的情况,那对于这一类场景,采用全双工的WebSocket就显得笨重了许多。为此,我们可以参考一项基于HTTP的新技术:SSE。

        SSE是HTML5引入的一种轻量级的服务器向浏览器客户端单向推送实时数据的基于HTTP的技术。在Spring Boot框架中,我们可以很容易地集成并利用SSE来实现实时通信。适用于服务端向客户端推送数据的场景。相较于 WebSocket,SSE 更简单、更轻量级,但只能实现单向通信。

主要区别

WebSocket支持双向通信,适合需要客户端和服务端频繁交互的场景,而SSE仅支持服务端向客户端的单向通信,适合如新闻推送、股票行情更新等场景。

特性SSE(服务器发送事件)WebSocket(网络套接字)
通信类型单向通信(服务器到客户端)双向通信(客户端与服务器之间)
协议HTTPWebSocket
自动重连支持(客户端可以自动尝试重新连接)不支持(需要客户端自行实现重连机制)
数据格式默认为文本格式,如果需要二进制数据,需要自行编码转换默认支持二进制数据,也支持文本格式
浏览器支持大部分现代浏览器支持,但在Internet Explorer及早期的Edge浏览器中不被支持主流浏览器(包括移动端)的支持较好,兼容性好
应用场景适用于服务器向客户端单向推送数据的场景,如新闻订阅、股票价格更新等适用于需要客户端与服务器之间实时双向通信的场景,如在线聊天、游戏等
连接建立通过标准的HTTP请求建立,然后升级为SSE连接通过WebSocket握手协议建立连接
服务器资源消耗相对较低,因为连接是单向的,且可以复用HTTP连接相对较高,因为需要维护双向的持久连接
安全性依赖于HTTP的安全性,可以通过HTTPS增强支持WSS(WebSocket Secure),提供加密传输

三、第三方推送的丰富内容

        第三方推送服务如APNs、FCM等,广泛应用于移动设备的消息推送。例如,天气应用通过这些服务向用户推送最新的天气变化,确保用户能够及时获得重要的天气信息。此外,跨平台推送服务如个推、极光推送等,帮助开发者在不同平台上实现统一的推送功能,例如,一款社交应用可能需要在iOS和Android平台上同时向用户推送新消息提醒,这些服务能够简化开发流程。        

SSE 服务端推送具体代码实现

SpringBoot后端

Sse工具实体

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

@Slf4j
@Component
public class SseClient {
    private static final Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
    private static final int RECONNECT_DELAY = 5000; // 重连延迟时间,5秒

    /**
     * 创建SSE连接
     * @param uid 用户唯一标识
     * @return SseEmitter 实例
     */
    public SseEmitter createSse(String uid) {
        // 创建一个永不超时的SseEmitter
        SseEmitter sseEmitter = new SseEmitter(0L);
        sseEmitterMap.put(uid, sseEmitter);

        // 完成后的回调,记录日志并从map中移除
        sseEmitter.onCompletion(() -> {
            log.info("[{}]结束连接...................", uid);
            sseEmitterMap.remove(uid);
        });

        // 超时回调,记录日志
        sseEmitter.onTimeout(() -> {
            log.info("[{}]连接超时,准备重连...................", uid);
            scheduleReconnect(uid);
        });

        // 异常回调,记录日志并发送错误事件,然后重试
        sseEmitter.onError(throwable -> {
            log.error("[{}]连接异常,{}", uid, throwable.toString(), throwable);
            try {
                sseEmitter.send(SseEmitter.event()
                        .id(uid)
                        .name("发生异常!")
                        .data("发生异常请重试!")
                        .reconnectTime(RECONNECT_DELAY));
            } catch (IOException e) {
                log.error("[{}]发送错误事件失败", uid, e);
            }
            scheduleReconnect(uid);
        });

        try {
            // 发送初始化事件,设置重连时间
            sseEmitter.send(SseEmitter.event().reconnectTime(RECONNECT_DELAY));
        } catch (IOException e) {
            log.error("[{}]发送初始化事件失败", uid, e);
        }

        log.info("[{}]创建sse连接成功!", uid);
        return sseEmitter;
    }

    /**
     * 给指定用户发送消息(可以定时或在事件发生时调用sseEmitter.send()方法来发送事件。)
     * @param uid 用户唯一标识
     * @param messageId 消息ID
     * @param message 消息内容
     * @return 是否发送成功
     */
    public boolean sendMessage(String uid, String messageId, String message) {
        if (StrUtil.isBlank(message)) {
            log.warn("参数异常,msg为null或空字符串", uid);
            return false;
        }
        SseEmitter sseEmitter = sseEmitterMap.get(uid);
        if (sseEmitter == null) {
            log.info("消息推送失败uid:[{}],没有创建连接,请重试。", uid);
            return false;
        }
        try {
            sseEmitter.send(SseEmitter.event().id(messageId).reconnectTime(1 * 60 * 1000L).data(message));
            log.info("用户{},消息id:{},推送成功:{}", uid, messageId, message);
            return true;
        } catch (Exception e) {
            log.error("用户{},消息id:{},推送异常:{}", uid, messageId, e.getMessage(), e);
            sseEmitter.complete();
            scheduleReconnect(uid);
            return false;
        }
    }

    /**
     * 断开SSE连接
     * @param uid 用户唯一标识
     */
    public void closeSse(String uid) {
        SseEmitter sseEmitter = sseEmitterMap.remove(uid);
        if (sseEmitter != null) {
            sseEmitter.complete();
            log.info("用户{} 连接已关闭", uid);
        } else {
            log.info("用户{} 连接已关闭,或连接不存在", uid);
        }
    }

    /**
     * 计划重连
     * @param uid 用户唯一标识
     */
    private void scheduleReconnect(String uid) {
        // 这里可以添加一个定时任务来尝试重连
        // 例如使用Spring的@Scheduled注解或者ScheduledExecutorService
        log.info("[{}]计划在{}毫秒后重连", uid, RECONNECT_DELAY);
        // 模拟重连操作,实际应用中应根据业务需求实现
        try {
            Thread.sleep(RECONNECT_DELAY);
            createSse(uid);
        } catch (InterruptedException e) {
            log.error("[{}]重连操作被中断", uid, e);
        }
    }
}

Controller类 

/**
 * SSE控制器,用于处理SSE相关的请求。
 */
@Slf4j
@RestController
public class SseController {
    @Autowired
    private SseClient sseClient;


    /**
     * 创建SSE连接。
     * @param uid 用户唯一标识。
     * @return SseEmitter对象,用于与客户端建立SSE连接。
     */
    @GetMapping("/createSse")
    public SseEmitter createConnect(@PathVariable String uid) {
        if (uid == null || uid.isEmpty()) {
            log.warn("创建SSE连接失败,UID为空");
            throw new IllegalArgumentException("UID不能为空");
        }
        SseEmitter sse = sseClient.createSse(uid);
        log.info("为用户UID: {} 创建SSE连接", uid);
        return sse;
    }

    /**
     * 通过SSE接收消息。
     * @param uid 用户唯一标识。
     * @return 操作结果。
     * @throws InterruptedException 如果线程在等待时被中断。
     */
    @GetMapping("/receive")
    public String sseChat(@PathVariable String uid) throws InterruptedException {
        if (uid == null || uid.isEmpty()) {
            log.warn("接收消息失败,UID为空");
            throw new IllegalArgumentException("UID不能为空");
        }
        for (int i = 0; i < 1000; i++) {
            try {
                sseClient.sendMessage(uid, "no" + i, IdGenerator.generateRandomId());
                Thread.sleep(2000L);
            } catch (Exception e) {
                log.error("发送消息失败,用户UID: {}", uid, e);
                // 可以在这里添加重试逻辑
            }
        }
        return "ok";
    }

    /**
     * 关闭SSE连接。
     * @param uid 用户唯一标识。
     */
    @GetMapping("/closeSse")
    public void closeConnect(@PathVariable String uid) {
        if (uid == null || uid.isEmpty()) {
            log.warn("关闭SSE连接失败,UID为空");
            throw new IllegalArgumentException("UID不能为空");
        }
        sseClient.closeSse(uid);
        log.info("为用户UID: {} 关闭SSE连接", uid);
    }

    /**
     * 错误处理方法,当SSE连接发生错误时调用。
     * @param exception 异常对象。
     * @return 错误信息。
     */
    @ExceptionHandler(IOException.class)
    public String handleIOException(IOException exception) {
        log.error("SSE连接发生IO异常", exception);
        return "SSE连接发生IO异常,请稍后重试。";
    }

    /**
     * 错误处理方法,当发生非法参数异常时调用。
     * @param exception 异常对象。
     * @return 错误信息。
     */
    @ExceptionHandler(IllegalArgumentException.class)
    public String handleIllegalArgumentException(IllegalArgumentException exception) {
        log.error("参数校验失败", exception);
        return "参数校验失败:" + exception.getMessage();
    }
}


Vue前端接收与处理

index.vue

<script lang="ts" setup>
import { closeSseAPI, receiveAPI } from '@/api'
import { onMounted, onUnmounted, ref } from 'vue'

const txtArr = ref<string[]>([])

const eventSource = ref<EventSource | null>(null)

const initSSE = () => {
  eventSource.value = new EventSource('/api/createSse?uid=dsuhghsjgds')

  eventSource.value.onmessage = event => {
    console.log('收到消息内容是:', event.data)
    txtArr.value.push(event.data)
  }

  eventSource.value.onerror = error => {
    console.error('SSE 连接出错:', error)
    eventSource.value!.close()
  }
}

const handleClose = () => {
  closeSseAPI('dsuhghsjgds')
}

const handleStart = () => {
  receiveAPI('dsuhghsjgds')
}

onMounted(() => {
  initSSE()
})

onUnmounted(() => {
  if (eventSource.value) {
    eventSource.value.close()
  }
})
</script>

<template>
  <div class="sse-container">
    <n-button @click="handleStart">start</n-button>
    <n-button @click="handleClose">close</n-button>
    <ul>
      <li v-for="item in txtArr" :key="item">
        {{ item }}
      </li>
    </ul>
  </div>
</template>

<style lang="scss" scoped></style>

统一接口封装

import http from '@/utils/request'

export const receiveAPI = (uid: string) => {
  return http({
    url: '/receive',
    params: {
      uid
    }
  })
}
export const closeSseAPI = (uid: string) => {
  return http({
    url: '/closeSse',
    params: {
      uid
    }
  })
}

注意事项

  1. 异常处理:当客户端断开连接时,SseEmitter可能会抛出IOException。因此,必须捕获并处理这种异常,通常情况下应调用emitter.complete()emitter.completeWithError()来关闭SseEmitter

  2. 持久性连接:SSE连接是持久性的,长时间保持连接可能需要处理超时和重连问题。开发者应该实现适当的超时检测和重连机制,以确保服务的稳定性。

  3. 资源消耗:对于大量的并发客户端,SSE可能会消耗大量服务器资源。可能需要采用连接池或其他优化策略来管理资源,减少不必要的资源占用。

  4. 数据完整性:为了保证数据的完整性,建议在每条消息上带上id字段。这样,在连接断开时,客户端可以根据Last-Event-ID属性作为请求头发送给服务器,以便服务器可以据此做出相应的处理。

  5. 浏览器兼容性:SSE不支持IE浏览器,因此在开发时需要考虑目标用户的浏览器兼容性。对于不支持SSE的浏览器,可能需要提供备选方案。

  6. 安全考虑:使用HTTPS来保护SSE连接,防止中间人攻击。同时,如果SSE服务端点与提供HTML页面的服务器不同,确保正确配置跨源资源共享(CORS)。

  7. 性能优化:避免资源泄漏,确保在不再需要时关闭SSE连接。如果面临高流量,考虑使用负载均衡器来分散请求。

  8. 缩放策略:在实现和使用SSE时,应考虑缩放策略,确保在用户量增加时,服务仍然能够稳定运行。

  9. 错误处理机制:在EventSource连接中断后,可以通过监听error事件来捕获连接错误,并在错误处理函数中尝试重新连接。例如,可以在error事件处理函数中调用eventSource.close()方法关闭连接,然后再调用eventSource.open()方法重新建立连接。

  10. 手动重连:在EventSource连接中断后,可以显示一个按钮供用户手动触发重新连接操作。这种方法可以让用户自主决定何时重新连接,增加了灵活性。

业务实践

        以文件下载功能为例,大文件的下载往往会给服务器带来较大压力,同时用户也需要等待较长时间。为了改善这种状况,我们可以采用SSE(Server-Sent Events)技术,实现异步处理和实时通知,从而提升交互体验。

  1. SSE连接的建立:首先,我们需要在客户端和服务器之间建立一个SSE连接。这样,服务器就具备了主动向客户端推送消息的能力。SSE连接的建立相对简单,只需要客户端通过HTTP协议订阅服务器的SSE端点即可。
  2. 异步下载处理:对于耗时较长的下载任务,我们采用异步处理的方式。这意味着用户在提交下载请求后,服务器会立即响应,并将下载任务放入后台处理队列。用户无需在下载页面长时间等待,可以继续进行其他操作。
  3. 广播并推送完成事件:一旦下载任务完成,服务器需要将这一事件推送给客户端。这里我们面临一个挑战:如果服务是集群部署的,SSE连接信息可能只保存在单个节点的本地内存中。为了解决这个问题,我们可以利用Redis的发布/订阅功能,将消息广播到整个集群。各个节点上的SSE连接会接收到这个消息,但只有与消息匹配的客户端连接会处理这个消息,其他节点则忽略。
  4. 精准投递的实现:虽然广播是一种简单有效的解决方案,但在某些场景下,我们可能需要实现精准投递。这可以通过在Redis中存储一个映射关系Map<用户,节点IP>来实现。在推送消息之前,服务器首先通过这个映射关系找到用户的SSE连接所在节点,然后通过RPC调用将消息直接投递到对应的服务节点,由该节点完成事件推送。

总结

        服务端推送是一种提升用户体验的技术,它允许服务器主动向客户端发送消息。传统的HTTP轮询和长轮询方法存在效率和性能的问题。HTML5提供了更高效的解决方案,WebSocket适用于需要双向通信的场景,而SSE适用于单向推送数据的场景。两者各有优势,WebSocket支持全双工通信,而SSE更轻量级,适用于不需要客户端到服务器通信的场景。第三方推送服务如APNs和FCM在移动设备上广泛应用。在业务实践中,SSE技术可以改善文件下载等耗时操作的用户体验,通过建立SSE连接、异步处理下载任务、广播推送完成事件和精准投递,可以让用户在下载大文件时不必长时间等待,同时减轻服务器压力。

### Java后端推送消息到前端的实现方式 在现代Web开发中,Java后端Vue或其他前端框架推送实时消息的需求非常普遍。以下是几种常见的技术方案及其具体实现细节。 #### 一、基于WebSocket的消息推送 WebSocket是一种全双工通信协议,允许服务器主动向客户端推送数据。以下是一个典型的实现流程: 1. **建立WebSocket连接** 前端通过JavaScript创建WebSocket实例并发起连接请求。 ```javascript const socket = new WebSocket('ws://localhost:8080/websocket'); socket.onopen = function() { console.log("Connection established"); }; socket.onmessage = function(event) { console.log("Message from server ", event.data); }; socket.onerror = function(error) { console.error("Error occurred", error); }; socket.onclose = function() { console.log("Connection closed"); }; ``` 2. **后端处理WebSocket连接** 使用Spring Boot配置WebSocket支持,在`MyWebSocketHandler`类中管理会话和消息发送逻辑[^2]。 ```java @Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new MyWebSocketHandler(), "/websocket").setAllowedOrigins("*"); } } public class MyWebSocketHandler extends TextWebSocketHandler { private final List<WebSocketSession> sessions = Collections.synchronizedList(new ArrayList<>()); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.add(session); // 存储已连接的会话 } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); System.out.println("Received Message: " + payload); broadcast(payload); // 广播消息给所有连接的客户端 } public void sendMessageToAll(String message) { synchronized (sessions) { for (WebSocketSession sess : sessions) { try { if (sess.isOpen()) { sess.sendMessage(new TextMessage(message)); // 发送消息 } } catch (IOException e) { e.printStackTrace(); } } } } } ``` 3. **优点与局限性** - WebSockets提供低延迟、双向通信能力,适合实时应用如聊天室或在线游戏[^1]。 - 缺点在于维护大量并发连接可能增加服务器负载。 --- #### 二、基于SSEServer-Sent Events)的消息推送 如果仅需单向推送(即从服务端到客户端),可以考虑使用SSE作为替代方案。它比WebSocket更简单且资源消耗更低。 1. **前端接收SSE事件** 利用EventSource API监听来自后端的数据流。 ```javascript const source = new EventSource('http://localhost:8080/sse'); source.onmessage = function(event) { console.log("New message received:", event.data); }; source.onerror = function(err) { console.error("Error with SSE connection:", err); }; ``` 2. **后端实现SSE推送** Spring Boot提供了`SseEmitter`来简化SSE集成过程[^3]。 ```java @RestController public class SseController { @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamEvents() { return Flux.interval(Duration.ofSeconds(1)) // 定期生成事件 .map(sequence -> "event-" + sequence); } } ``` 3. **特点分析** - SSE适用于只需要从服务器到客户端的单向通信场景。 - 不像WebSocket那样复杂,但功能也相对有限。 --- #### 三、其他补充方案 除了上述两种主流方法外,还有如下选项可供选择: - **轮询(Polling)**: 客户端定期向服务器发出HTTP请求以获取最新状态更新。这种方式容易实现但效率低下。 - **长轮询(Long Polling)**: 类似于标准轮询,不过每次请求保持打开直到有新数据返回再关闭。相比短轮询减少了不必要的网络流量浪费。 --- ### 总结对比表 | 方案 | 双向通信 | 连接开销 | 易用程度 | |--------------|-------------|--------------|---------------| | WebSocket | 是 | 较高 | 中等难度 | | SSE | 否 | 较低 | 简单易上手 | | 轮询/长轮询 | 否 | 高(频繁重连)| 最基础最直观 | 根据实际需求权衡选用合适的解决方案即可满足不同业务场景下的即时通讯要求。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学徒630

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值