一、简介
市面上很多系统都有
以上三种的消息提醒。但大多分为2类,一类移动端,一类web端比,通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。消息推送无非是推(push)和拉(pull)两种形式
然而较常见的几种方案设计分别为:
1.短轮询
轮询(polling)应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。
短轮询很好理解,指定的时间间隔,由浏览器向服务器发出HTTP请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。
一个简单的JS定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。
问题:效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。
2.长轮询
长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如Nacos和apollo配置中心,消息队列kafka、RocketMQ中都有用到长轮询。
这次我使用apollo配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在servelet3.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}")@ResponseBodypublic 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}")@ResponseBodypublic 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,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。
长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。
3.MQTT
什么是 MQTT协议?
MQTT 全称(Message Queue Telemetry Transport):一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。
该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的MQ有点类似。
TCP协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于TCP/IP协议上,也就是说只要支持TCP/IP协议栈的地方,都可以使用MQTT协议。
为什么要用 MQTT协议?
MQTT协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP协议呢?
首先HTTP协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合IOT应用程序。
HTTP是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。
通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP要实现这样的功能不但很困难,而且成本极高。
具体的MQTT协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。
MQTT协议的介绍
我也没想到 springboot + rabbitmq 做智能家居,会这么简单
MQTT实现消息推送
未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~
4.Websocket
websocket应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲SSE的时候也和websocket进行过比较。
WebSocket是一种在TCP连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
springboot整合websocket,先引入websocket相关的工具包
<!-- 引入websocket -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
服务端使用@ServerEndpoint注解标注当前类为一个websocket服务器,客户端可以通过ws://localhost:7777/webSocket/10086来连接到WebSocket服务器端。
@Component
@Slf4j
@ServerEndpoint("/websocket/{userId}")
public class WebSocketServer {//与某个客户端的连接会话,需要通过它来给客户端发送数据private Session session;private static final CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();// 用来存在线连接数private static final Map<String, Session> sessionPool = new HashMap<String, Session>();/*** 链接成功调用的方法*/@OnOpenpublic void onOpen(Session session, @PathParam(value = "userId") String userId) {try {this.session = session;webSockets.add(this);sessionPool.put(userId, session);log.info("websocket消息: 有新的连接,总数为:" + webSockets.size());} catch (Exception e) {}}/*** 收到客户端消息后调用的方法*/@OnMessagepublic void onMessage(String message) {log.info("websocket消息: 收到客户端消息:" + message);}/*** 此为单点消息*/public void sendOneMessage(String userId, String message) {Session session = sessionPool.get(userId);if (session != null && session.isOpen()) {try {log.info("websocket消: 单点消息:" + message);session.getAsyncRemote().sendText(message);} catch (Exception e) {e.printStackTrace();}}}
}
前端初始化打开WebSocket连接,并监听连接状态,接收服务端数据或向服务端发送数据。
<script>var ws = new WebSocket('ws://localhost:7777/webSocket/10086');// 获取连接状态console.log('ws连接状态:' + ws.readyState);//监听是否连接成功ws.onopen = function () {console.log('ws连接状态:' + ws.readyState);//连接成功则发送一个数据ws.send('test1');}// 接听服务器发回的信息并处理展示ws.onmessage = function (data) {console.log('接收到来自服务器的消息:');console.log(data);//完成通信后关闭WebSocket连接ws.close();}// 监听连接关闭事件ws.onclose = function () {// 监听整个过程中websocket的状态console.log('ws连接状态:' + ws.readyState);}// 监听并处理error事件ws.onerror = function (error) {console.log(error);}function sendMessage() {var content = $("#message").val();$.ajax({url: '/socket/publish?userId=10086&message=' + content,type: 'GET',data: { "id": "7777", "content": content },success: function (data) {console.log(data)}})}
</script>
页面初始化建立websocket连接,之后就可以进行双向通信了,效果还不错
然而如果只需要实现上面简单的站内信的消息推送,我这边尝试的是SSE
5.站内信推荐型SSE
服务端向客户端推送消息,其实除了可以用WebSocket这种耳熟能详的机制外,还有一种服务器发送事件(Server-sent events),简称SSE。
SSE它是基于HTTP协议的,我们知道一般意义上的HTTP协议是无法做到服务端主动向客户端推送消息的,但SSE是个例外,它变换了一种思路。
SSE在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。
整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。
具体实现:
Spring中有一个SseEmitter类。这个类就是一个长连接的文件流,它是由父类【ResponseBodyEmitter】继成而来,从SseEmitter中可以看到send()的方法借用了SseEventBuilder接口实现发送,SseEventBuilder中的data默认采用的是Json形式,当然,咱们也可以自己设置一种格式进行数据推送。然后就是SseEmitter的静态块,采用的是:
TEXT_PLAIN = new MediaType(“text”, “plain”, StandardCharsets.UTF_8);
这说明 SseEmitter 使用Text的方式进行通讯推送。
- 编写一个客户端,模拟请求:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>SseEmitter</title>
</head>
<body>
<button onclick="closeSse()">关闭连接</button>
<div id="message"></div>
</body>
<script>// 用时间戳模拟登录用户const userId = new Date().getTime();// 替换为你的tokenconst token = 'x5hVfCwzdaFkxQQrGlDVSR9jGHBNacKO';// 使用Fetch API来发送请求,并监听响应// const url = 'http://localhost:9101/sse/subscribe?id=1679048475536338945';// const url = '/application/api/webapp/message/info/sse?userId=1679048475536338945';const url = 'http://localhost:8080/sse?userId=1679048475536338945';if (!!window.EventSource) {// 创建一个EventSource对象const source = new EventSource(url);/*** 连接一旦建立,就会触发open事件* 另一种写法:source.onopen = function (event) {}*/source.addEventListener('open', function (e) {setMessageInnerHTML("建立连接。。。");}, false);/*** 客户端收到服务器发来的数据* 另一种写法:source.onmessage = function (event) {}*/source.addEventListener('message', function (e) {setMessageInnerHTML(e.data);});//主动断开链接// source.close()/*** 如果发生通信错误(比如连接中断),就会触发error事件* 或者:* 另一种写法:source.onerror = function (event) {}*/source.addEventListener('error', function (e) {if (e.readyState === EventSource.CLOSED) {setMessageInnerHTML("连接关闭");} else {console.log(e);}}, false);} else {setMessageInnerHTML("你的浏览器不支持SSE");}// 监听窗口关闭事件,主动去关闭sse连接,如果服务端设置永不过期,浏览器关闭后手动清理服务端数据window.onbeforeunload = function () {closeSse();};// 关闭Sse连接function closeSse() {source.close();const httpRequest = new XMLHttpRequest();httpRequest.open('GET', 'http://localhost:8080/sse/CloseSseConnect/?clientId=e410d4c1d71c469b8d719de5d39783b7', true);httpRequest.send();console.log("close");}// 将消息显示在网页上function setMessageInnerHTML(innerHTML) {document.getElementById('message').innerHTML += innerHTML + '<br/>';}/** 如果前端采用vue 等框架,需要传递token则可以使用如下代码* 1.引入EventSourcePolyfill包 :npm install event-source-polyfill --save* 2.引入包 import { EventSourcePolyfill } from "event-source-polyfill";* 3.官方文档:https://www.npmjs.com/package/event-source-polyfill*/// const eventSource = new EventSourcePolyfill('/application/api/webapp/message/info/sse?userId=1679048475536338945', {// headers: {// 'token': tool.data.get('TOKEN'),// 'Content-Type': 'text/event-stream',// 'charset': 'UTF-8',// 'Cache-Control':' no-cache',// 'Connection': 'keep-alive'// }// })//// eventSource.addEventListener('open', () => {// console.log('连接成功')// });//// eventSource.addEventListener('message', (event) => {// console.log('消息:', event.data)// })//// eventSource.addEventListener('error', (event) => {// if (event.readyState === EventSource.CLOSED) {// console.error('关闭')// } else {// console.error('连接错误', event)// }// })</script>
</html>
- 编写服务端
package com.example.messagedemo.demo;import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.example.messagedemo.demos.web.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;import javax.annotation.PostConstruct;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;/*** sse推送消息* @author jiangjian*/
@Slf4j
@RestController
public class DevSseMessageController {Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();/*** 建立连接*/@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)@CrossOrigin(value = "*")public ResponseBodyEmitter sseServer(@RequestParam String userId) throws IOException {SseEmitter emitter = null;if ((emitter = emitterMap.get(userId)) != null){emitter.send("重连");return emitter;}// 判断其他服务器有没有对应的连接,有的话,就算了。直接返回。或者直接转发。可以通过直接调用或者通过mq推送之类的emitter = new SseEmitter(300000L);// 当连接超时时触发emitter.onTimeout(()->{emitterMap.remove(userId);log.info("timeout");});// 在连接完成时候触发,可在连接完成时执行一些清理工作emitter.onCompletion(()->{emitterMap.remove(userId);log.info("completion");});// 在客户端断开连接的时候会触发error回调,当连接异常时触发emitter.onError(e->{emitterMap.remove(userId);log.error("error",e);});log.info("create for {}",userId);emitterMap.put(userId, emitter);return emitter;}/*** 推送消息,只需要通过emitter发送即可*/@GetMapping("/send")public ResponseEntity<String> sendMessage(@RequestParam String message) {send(message);return ResponseEntity.ok("ok");}/*** 定期清理不活跃客户端* 容器一加载久会定期清理不活跃的连接,避免内存溢出*/@PostConstructprivate void scheduleCleanup() {new ScheduledThreadPoolExecutor(1).scheduleAtFixedRate(()->{emitterMap.values().removeIf(emitter -> {try{// ping(emitter);ping2(emitter);return false;}catch (IOException e){log.warn("清理一个不活跃的客户端");return true;}});}, 0, 10, TimeUnit.SECONDS);}//定期查询private void ping(SseEmitter emitter) throws IOException {Set<ResponseBodyEmitter.DataWithMediaType> dataWithMediaTypes = SseEmitter.event().id(UUID.randomUUID().toString()).name("ping")//.data("ping").data("哈哈哈哈哈哈哈哈哈哈哈啊哈哈哈哈哈哈哈").comment("comment").build();emitter.send(dataWithMediaTypes);}private void ping2(SseEmitter emitter) throws IOException {User user = new User();user.setAge(RandomUtil.randomInt(100));user.setName("张三");// emitter.send(SseEmitter.event().id(UUID.randomUUID().toString()).data("哈哈哈哈哈:"+ UUID.randomUUID()));emitter.send(user);}/*** 推送消息* @param message*/private void send(String message){emitterMap.values().forEach(emitter -> {try {// doSend(emitter,message);doSend2(emitter,message);} catch (IOException e) {log.warn("客户端断开连接了");}});}/*** 推送消息* @param emitter* @param message* @throws IOException*/private void doSend(SseEmitter emitter,String message) throws IOException {Set<ResponseBodyEmitter.DataWithMediaType> dataWithMediaTypes = SseEmitter.event().id(UUID.randomUUID().toString()).name("message").data(message).build();emitter.send(dataWithMediaTypes);}private void doSend2(SseEmitter emitter,String message) throws IOException {String s = "e410d4c1d71c469b8d719de5d39783b7";emitter.send(SseEmitter.event().id(UUID.randomUUID().toString()).data(message));}
}
如果不使用SseEmitter类,咱们还可以扩展他,自定义一个类去继承SseEmitter类,同时链接接口,的返回类和链接容器都应该使用自定义类 类似于如下
- 问题以及注意事项
- 如果业务服务是集群部署,轮询路由访问服务,避免内存消耗,重复缓存链接。
优化:Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();
改为:Redis 方式。共享链接数据- 服务环境采用Nginx进行反向代理,请求时一直peding:需要新增代理
location /connects/ {
proxy_buffering off; #关闭代理缓冲的
proxy_cache off;
proxy_read_timeout 18000s; #设置读取时间
proxy_send_timeout 18000s; #设置写入时间
proxy_http_version 1.1; # 设置http版本
proxy_pass http://xxxx-gateway-app:31001/; #路由地址
}
其中:
Nginx做反向代理时需要将proxy_buffering关闭
proxy_buffering off
如果经过了多重Nginx代理,还需要加上
加上响应头部x-accel-buffering,这样nginx就不会给后端响应数据加buffer
x-accel-buffering: no
推送最直接的方式就是使用第三推送平台,毕竟钱能解决的需求都不是问题 ,无需复杂的开发运维,直接可以使用,省时、省力、省心,像goEasy、极光推送都是很不错的三方服务商。
一般大型公司都有自研的消息推送平台,像我们本次实现的web站内信只是平台上的一个触点而已,短信、邮件、微信公 众号、小程序凡是可以触达到用户的渠道都可以接入进来。