消息推送之SSE
一、简介
市面上很多系统都有
以上三种的消息提醒。但大多分为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}")
@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,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。
长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。
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>();
/**
* 链接成功调用的方法
*/
@OnOpen
public 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) {
}
}
/**
* 收到客户端消息后调用的方法
*/
@OnMessage
public 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();
// 替换为你的token
const 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");
}
/**
* 定期清理不活跃客户端
* 容器一加载久会定期清理不活跃的连接,避免内存溢出
*/
@PostConstruct
private 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站内信只是平台上的一个触点而已,短信、邮件、微信公 众号、小程序凡是可以触达到用户的渠道都可以接入进来。
原文地址:https://blog.csdn.net/qq_43652793/article/details/143708885
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!