前言
服务端推送,这个概念其实它就是我们日常生活中经常遇到的那些“叮咚”一声的通知。想象一下,你正在用手机刷微博,突然手机震动了一下,屏幕上弹出一条新闻推送,告诉你你关注的明星发了新动态。这就是服务端推送的一个典型例子。
为啥需要服务端推送呢?原因有几个:
-
实时通知:就像你不想错过任何一条朋友的微信消息一样,很多应用也需要实时地把信息推送给用户。比如,你正在用的购物APP,突然有个限时折扣,服务端就能立刻把这个好消息推送给你,让你不会错过任何薅羊毛的机会。
-
节省资源:如果没有服务端推送,应用就得不断地“问”服务器:“有新消息吗?”“有新消息吗?”这样不仅烦人,还很浪费电。服务端推送就像是服务器对客户端说:“别问了,有新消息了,快来看!”这样客户端就能在需要的时候才接收信息,既省电又高效。
-
增强用户体验:好的推送能让用户感到贴心。比如,你经常在某音乐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(网络套接字) |
---|---|---|
通信类型 | 单向通信(服务器到客户端) | 双向通信(客户端与服务器之间) |
协议 | HTTP | WebSocket |
自动重连 | 支持(客户端可以自动尝试重新连接) | 不支持(需要客户端自行实现重连机制) |
数据格式 | 默认为文本格式,如果需要二进制数据,需要自行编码转换 | 默认支持二进制数据,也支持文本格式 |
浏览器支持 | 大部分现代浏览器支持,但在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
}
})
}
注意事项
-
异常处理:当客户端断开连接时,
SseEmitter
可能会抛出IOException
。因此,必须捕获并处理这种异常,通常情况下应调用emitter.complete()
或emitter.completeWithError()
来关闭SseEmitter
。 -
持久性连接:SSE连接是持久性的,长时间保持连接可能需要处理超时和重连问题。开发者应该实现适当的超时检测和重连机制,以确保服务的稳定性。
-
资源消耗:对于大量的并发客户端,SSE可能会消耗大量服务器资源。可能需要采用连接池或其他优化策略来管理资源,减少不必要的资源占用。
-
数据完整性:为了保证数据的完整性,建议在每条消息上带上
id
字段。这样,在连接断开时,客户端可以根据Last-Event-ID
属性作为请求头发送给服务器,以便服务器可以据此做出相应的处理。 -
浏览器兼容性:SSE不支持IE浏览器,因此在开发时需要考虑目标用户的浏览器兼容性。对于不支持SSE的浏览器,可能需要提供备选方案。
-
安全考虑:使用HTTPS来保护SSE连接,防止中间人攻击。同时,如果SSE服务端点与提供HTML页面的服务器不同,确保正确配置跨源资源共享(CORS)。
-
性能优化:避免资源泄漏,确保在不再需要时关闭SSE连接。如果面临高流量,考虑使用负载均衡器来分散请求。
-
缩放策略:在实现和使用SSE时,应考虑缩放策略,确保在用户量增加时,服务仍然能够稳定运行。
-
错误处理机制:在
EventSource
连接中断后,可以通过监听error
事件来捕获连接错误,并在错误处理函数中尝试重新连接。例如,可以在error
事件处理函数中调用eventSource.close()
方法关闭连接,然后再调用eventSource.open()
方法重新建立连接。 -
手动重连:在
EventSource
连接中断后,可以显示一个按钮供用户手动触发重新连接操作。这种方法可以让用户自主决定何时重新连接,增加了灵活性。
业务实践
以文件下载功能为例,大文件的下载往往会给服务器带来较大压力,同时用户也需要等待较长时间。为了改善这种状况,我们可以采用SSE(Server-Sent Events)技术,实现异步处理和实时通知,从而提升交互体验。
- SSE连接的建立:首先,我们需要在客户端和服务器之间建立一个SSE连接。这样,服务器就具备了主动向客户端推送消息的能力。SSE连接的建立相对简单,只需要客户端通过HTTP协议订阅服务器的SSE端点即可。
- 异步下载处理:对于耗时较长的下载任务,我们采用异步处理的方式。这意味着用户在提交下载请求后,服务器会立即响应,并将下载任务放入后台处理队列。用户无需在下载页面长时间等待,可以继续进行其他操作。
- 广播并推送完成事件:一旦下载任务完成,服务器需要将这一事件推送给客户端。这里我们面临一个挑战:如果服务是集群部署的,SSE连接信息可能只保存在单个节点的本地内存中。为了解决这个问题,我们可以利用Redis的发布/订阅功能,将消息广播到整个集群。各个节点上的SSE连接会接收到这个消息,但只有与消息匹配的客户端连接会处理这个消息,其他节点则忽略。
- 精准投递的实现:虽然广播是一种简单有效的解决方案,但在某些场景下,我们可能需要实现精准投递。这可以通过在Redis中存储一个映射关系
Map<用户,节点IP>
来实现。在推送消息之前,服务器首先通过这个映射关系找到用户的SSE连接所在节点,然后通过RPC调用将消息直接投递到对应的服务节点,由该节点完成事件推送。
总结
服务端推送是一种提升用户体验的技术,它允许服务器主动向客户端发送消息。传统的HTTP轮询和长轮询方法存在效率和性能的问题。HTML5提供了更高效的解决方案,WebSocket适用于需要双向通信的场景,而SSE适用于单向推送数据的场景。两者各有优势,WebSocket支持全双工通信,而SSE更轻量级,适用于不需要客户端到服务器通信的场景。第三方推送服务如APNs和FCM在移动设备上广泛应用。在业务实践中,SSE技术可以改善文件下载等耗时操作的用户体验,通过建立SSE连接、异步处理下载任务、广播推送完成事件和精准投递,可以让用户在下载大文件时不必长时间等待,同时减轻服务器压力。