推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。
消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。
消息推送一般又分为 Web 端消息推送和移动端消息推送。
移动端消息推送示例:
移动端消息推送示例
Web 端消息推送示例:
Web 端消息推送示例
在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1
就可以了。
通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。
消息推送表
消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。
# 消息推送常见方案
# 短轮询
轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。
短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。
一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。
setInterval(() => { // 方法请求 messageCount().then((res) => { if (res.code === 200) { this.messageCount = res.data; } }); }, 1000);
效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。
# 长轮询
长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。
Nacos 配置中心交互模型是 push 还是 pull?open in new window一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。
长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。
这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult
,它是在 Servlet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。
长轮询示意图
DeferredResult
可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)
提交响应结果。
下边我们用长轮询来实现消息推送。
因为一个 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
,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。
长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。