1 实时消息推送
1.1 消息推送
推送的场景比较多,比如有人关注公众号,这时就会收到一条推送消息,以此来吸引点击打开应用。
消息推送(push
)通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备APP
进行的主动消息推送。
消息推送一般又分为web端消息推送
和移动端消息推送
。
在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),web页面的通知小红点就会实时的+1就可以了。
通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。
1.2 准备sql
CREATE TABLE `message_record` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`template_id` bigint unsigned NOT NULL COMMENT '消息模板ID',
`type` int NOT NULL DEFAULT '1' COMMENT'推送渠道1短信2邮件3微信 4APP',
`receiver` varchar(128) NOT NULL DEFAULT '' COMMENT'消息接收者(手机号,邮箱号,微信openid等) ',
`device_info`varchar(128) NOT NULL DEFAULT '' COMMENT 'APP推送终端设备信息',
`content` varchar(1024) NOT NULL COMMENT'''消息推送内容',
`deleted`tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:1删除;0未删除',
`create_by` bigint unsigned NOT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` bigint unsigned NOT NULL COMMENT'修改人',
`update_time` datetime NOT NULL COMMENT'修改时间',
PRIMARY KEY (`id`),
KEY `idx_template_id`(`template_id`),
KEY `idx_receiver`(`receiver`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='消息推送包录表'
1.3 短轮询
轮询(polling
)应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询
和长轮询
。
短轮询很好理解,指定的时间间隔,由浏览器向服务器发出HTTP
请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。
一个简单的JS定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。
setInterval(() => {
// 方法请求
messageCount().then((res) => {
if (res.code === 200) {
this.messageCount = res.data
}
})
}, 1000);
效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。
1.4 长轮询
1.4.1 简介
长轮询
是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如Nacos和apollo
配置中心,消息队列kafka、RocketMQ
中都有用到长轮询。
这次使用apollo配置中心实现长轮询的方式,应用了一个类DeferredResult
,它是在servelet3.0
后经过Spring
封装提供的一种异步请求机制,直意就是延迟结果
。
DeferredResult
可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)
提交响应结果。
1.4.2 代码示例
下边我们用长轮询来实现消息推送。
因为一个ID可能会被多个长轮询请求监听,所以采用了guava
包提供的Multimap
结构存放长轮询,一个key
可以对应多个value
。一旦监听到key发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。
@Controller
@RequestMapping("/polling")
public class PollingController {
// 存放监听某个Id的长轮询集合
// 线程同步结构
public static Multimap<String, DeferredResult<String>> watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create());
/**
* 设置监听
*/
@GetMapping(path = "watch/{id}")
@ResponseBody
public DeferredResult<String> watch(@PathVariable String id) {
// 延迟对象设置超时时间
DeferredResult<String> deferredResult = new DeferredResult<>(TIME_OUT);
// 异步请求完成时移除 key,防止内存溢出
deferredResult.onCompletion(() -> {
watchRequests.remove(id, deferredResult);
});
// 注册长轮询请求
watchRequests.put(id, deferredResult);
return deferredResult;
}
/**
* 变更数据
*/
@GetMapping(path = "publish/{id}")
@ResponseBody
public String publish(@PathVariable String id) {
// 数据变更 取出监听ID的所有长轮询请求,并一一响应处理
if (watchRequests.containsKey(id)) {
Collection<DeferredResult<String>> deferredResults = watchRequests.get(id);
for (DeferredResult<String> deferredResult : deferredResults) {
deferredResult.setResult("我更新了" + new Date());
}
}
return "success";
}
}
当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException
异常,这里直接用@ControllerAdvice
全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。
@ControllerAdvice
public class AsyncRequestTimeoutHandler {
@ResponseStatus(HttpStatus.NOT_MODIFIED)
@ResponseBody
@ExceptionHandler(AsyncRequestTimeoutException.class)
public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) {
System.out.println("异步请求超时");
return "304";
}
}
测试一下,首先页面发起长轮询请求/polling/watch/10086
监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。
长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。
1.5 iframe流
iframe
流就是在页面中插入一个隐藏的<iframe>
标签,通过在src
中请求消息数量API
接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe
传输数据。
传输的数据通常是HTML
、或是内嵌的javascript
脚本,来达到实时更新页面的效果。
这种方式实现简单,前端只要一个<iframe>
标签搞定了
<iframe src="/iframe/message" style="display:none"></iframe>
服务端直接组装html、js
脚本数据向response
写入就行了
@Controller
@RequestMapping("/iframe")
public class IframeController {
@GetMapping(path = "message")
public void message(HttpServletResponse response) throws IOException, InterruptedException {
while (true) {
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-cache,no-store");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().print(" <script type=\"text/javascript\">\n" +
"parent.document.getElementById('clock').innerHTML = \"" + count.get() + "\";" +
"parent.document.getElementById('count').innerHTML = \"" + count.get() + "\";" +
"</script>");
}
}
}
但个人不推荐,因为它在浏览器上会显示请求未加载完,图标会不停旋转,简直是强迫症杀手。
1.6 SSE
1.6.1 简介
很多人可能不知道,服务端向客户端推送消息,其实除了可以用 WebSocket
这种耳熟能详的机制外,还有一种服务器发送事件(Server-sent events
),简称SSE
。
SSE
它是基于HTTP
协议的,我们知道一般意义上的HTTP
协议是无法做到服务端主动向客户端推送消息的,但SSE
是个例外,它变换了一种思路。
SSE
在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream
类型的数据流信息,在有数据变更时从服务器流式传输到客户端。
整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。
1.6.2 与 WebSocket 区别
SSE
与WebSocket
作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:
-
SSE
是基于HTTP
协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket
需单独服务器来处理协议。 -
SSE
单向通信,只能由服务端向客户端单向通信;webSocket
全双工通信,即通信的双方可以同时发送和接受信息。 -
SSE
实现简单开发成本低,无需引入其他组件;WebSocket
传输数据需做二次解析,开发门槛高一些。 -
SSE
默认支持断线重连;WebSocket
则需要自己实现。 -
SSE
只能传送文本消息,二进制数据需要经过编码后传送;WebSocket
默认支持传送二进制数据。
SSE
好像一直不被大家所熟知,一部分原因是出现了WebSockets
,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。
但是,在某些情况下,不需要从客户端发送数据。而只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE
不管是从实现的难易和成本上都更加有优势。此外,SSE
具有WebSockets
在设计上缺乏的多种功能,例如:自动重新连接、事件ID和发送任意事件的能力。
1.6.3 与轮询、WebSocket 对比
特性 | SSE (Server-Sent Events) | 轮询 (Polling) | WebSocket |
---|---|---|---|
通讯方向 | 单向:服务器向客户端推送数据 | 单向:客户端定时请求服务器获取数据 | 双向:客户端与服务器双向通讯 |
连接类型 | 长连接:使用 HTTP/1.1 或 HTTP/2 长连接 | 短连接:每次请求都是独立的 HTTP 请求 | 长连接:通过 TCP 建立的持久连接 |
传输协议 | HTTP/1.1 或 HTTP/2 | HTTP | TCP(通过 WebSocket 协议升级) |
浏览器支持 | 广泛支持(HTML5 原生支持) | 全面支持,所有浏览器均支持 | 广泛支持(需通过 WebSocket API 实现) |
消息发送频率 | 服务器可随时推送数据 | 客户端主动定时请求,频率取决于客户端设置 | 实时、即时双向发送 |
服务器开销 | 较低:只需保持一个长连接 | 高:每次请求都需要重新建立 HTTP 连接 | 较高:需保持 TCP 连接,管理心跳包 |
客户端开销 | 低:只需处理服务器推送的数据 | 高:每次请求都会产生额外的资源消耗 | 中等:需保持与服务器的连接 |
传输数据格式 | 纯文本、JSON 等 | 纯文本、JSON 等 | 任意格式(二进制、文本等) |
连接恢复机制 | 自动恢复:连接中断时,客户端会自动重新连接 | 无自动恢复,需客户端重新发起请求 | 需开发者自行实现重连机制 |
适用场景 | 适合实时数据推送、通知、监控等单向通讯场景 | 适用于低频率、对实时性要求不高的场景 | 适合即时通讯、协作应用等双向通讯场景 |
复杂度 | 简单:实现轻量且代码量少 | 简单:代码量少,但性能较差 | 复杂:需处理更多逻辑,如握手、心跳等 |
实时性 | 高:服务器可立即推送消息 | 低:依赖客户端的请求频率 | 高:双向即时通讯 |
防火墙/代理兼容性 | 高:使用标准 HTTP 协议,通常无额外配置需求 | 高:使用标准 HTTP 协议 | 可能较低:需配置防火墙或代理来允许 TCP 连接 |
带宽消耗 | 较低:保持长连接,但只在有数据时传输 | 较高:每次轮询都会消耗资源和带宽 | 较低:保持长连接,数据按需传输 |
1.6.4 代码示例
SseEmitter 和 Flux 具体对比
特性 | SseEmitter 实现 | Flux 实现 |
---|---|---|
连接管理 | 手动管理,每个用户对应一个连接 | 由框架自动管理,无需手动维护 |
消息粒度 | 精确到用户,支持个性化推送 | 广播式推送,所有用户接收相同的流数据 |
错误处理 | 手动处理超时、错误等情况 | 框架支持自动恢复/错误处理 |
扩展性 | 用户数增加时,管理成本和内存占用变高 | 基于 Reactor,具备良好的并发支持 |
复杂度 | 较高,需要额外逻辑处理 | 较低,关注数据流的生成即可 |
适用场景 | 单用户定制化推送,例如个人通知 | 广播消息,例如系统状态或统计数据的更新 |
1.6.4.1 基于 SseEmitter
前端只需进行一次HTTP
请求,带上唯一ID,打开事件流,监听服务端推送的事件就可以了
<script>
let source = null;
let userId = 7777
if (window.EventSource) {
// 建立连接
source = new EventSource('http://localhost:7777/sse/sub/'+userId);
setMessageInnerHTML("连接用户=" + 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);
});
} else {
setMessageInnerHTML("你的浏览器不支持SSE");
}
</script>
服务端的实现更简单,创建一个SseEmitter
对象放入sseEmitterMap
进行管理
@RestController
@RequestMapping("/sse")
public class SseController {
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
/**
* 创建连接
*/
@GetMapping("/connect/{userId}")
public SseEmitter connect(@PathVariable String userId) {
try {
// 设置超时时间,0表示不过期。默认30秒
SseEmitter sseEmitter = new SseEmitter(0L);
sseEmitter.onCompletion(() -> {
System.out.println("连接完成:" + userId);
sseEmitterMap.remove(userId);
});
sseEmitter.onTimeout(() -> {
System.out.println("连接超时:" + userId);
sseEmitterMap.remove(userId);
});
sseEmitter.onError((e) -> {
System.out.println("连接出错:" + userId);
sseEmitterMap.remove(userId);
});
sseEmitterMap.put(userId, sseEmitter);
return sseEmitter;
} catch (Exception e) {
log.info("创建新的sse连接异常,当前用户:{}", userId);
}
return null;
}
/**
* 给指定用户发送消息
*/
@PostMapping("/send/{userId}")
public ResponseEntity<String> sendMessage(@PathVariable String userId, @RequestBody String message) {
SseEmitter sseEmitter = sseEmitterMap.get(userId);
if (sseEmitter == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("用户未连接");
}
try {
sseEmitter.send(SseEmitter.event().data(message));
return ResponseEntity.ok("消息发送成功");
} catch (IOException e) {
sseEmitterMap.remove(userId); // 发送失败,移除用户
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("消息发送失败");
}
}
/**
* 关闭连接
*/
@DeleteMapping("/disconnect/{userId}")
public ResponseEntity<String> disconnect(@PathVariable String userId) {
SseEmitter sseEmitter = sseEmitterMap.get(userId);
if (sseEmitter != null) {
sseEmitter.complete();
sseEmitterMap.remove(userId);
return ResponseEntity.ok("连接已断开");
}
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("用户未连接");
}
}
我们模拟服务端推送消息,看下客户端收到了消息,和我们预期的效果一致。
注意
: SSE
不支持IE浏览器,对其他主流浏览器兼容性做的还不错
1.6.4.2 基于 Flux 的实现
@GetMapping(value = "/user-activity-sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Integer> streamUserActivitySSE() {
// 创建一个持续时间为指定秒数的 Flux 流
return Flux.interval(Duration.ofSeconds(5))
.flatMap(sequence -> Mono
.fromCallable(onlineUserService::getUserActivityNum) // 返回在线用户人数
.onErrorReturn(0)
);
}