前言
在web中我们常见的服务端主动推送客户端的消息的方式,主要可以分为两种
1、不基于长连接的轮询
2、实现基于长连接的http协议例如sse和ws
项目中有一个需求,就是有一些数据表格需要在后端服务中进行操作,其中涉及到了很多的io操作,非常的耗时,所以需要做成一个等待回调的模式, 这块就需要后端能够在表格处理完成之后能够主动告知前端进行数据下载。在这个功能点上我使用了sse的推送方式,因为它本身基于http,也省去了我做用户认证的多余逻辑
sse (Server-Sent Events)
sse是基于http的一个长连接协议,它通过前端的EventSource进行数据接收,和ws不同的是,它只能通过EventSource接收消息,不能够向后端推送消息,所以在这个长连接的通道中其实是单向的,只能后端向前端推送消息。
实现的方式
- 准备前端的代码
xml
<!DOCTYPE html>
<html lang="en">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div id="message">
</div>
</body>
<script>
let source = null;
// 用时间戳模拟登录用户
const userId = new Date().getTime();
if (window.EventSource) {
// 建立连接
source = new EventSource("http://localhost:8631/sse/connect")
/**
* 连接一旦建立,就会触发open事件
* 另一种写法:source.onopen = function (event) {}
*/
source.addEventListener('open', function (e) {
setMessageInnerHTML("建立连接。。。");
}, false);
/**
* 客户端收到服务器发来的数据
* 另一种写法:source.onmessage = function (event) {}
*/
source.addEventListener('message', function (e) {
setMessageInnerHTML(e.data);
});
/**
* 如果发生通信错误(比如连接中断),就会触发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:8631/sse/close' , true);
httpRequest.send();
console.log("close");
}
// 将消息显示在网页上
function setMessageInnerHTML(innerHTML) {
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
</script>
</html>
2、后端准备代码(基于springboot)
scss
@RestController
@Api(description = "系统 - 消息推送服务")
@RequestMapping("/sse")
@Slf4j
public class SseController extends BaseController{
/**
* 网上的方法 但存在一个问题 运行在tomcat中,tomcat会帮助直接给关掉,而sse有自动重连,所以每次都会重新发起请求。
* @return
*/
/**
* 用于创建连接
*/
@GetMapping("/connect")
public SseEmitter connect() {
String userId = getUserId().toString();
return SseEmitterUtil.connect(userId);
}
@GetMapping("/send")
public void sendMessage() {
String userId = getUserId().toString();
SseEmitterUtil.sendMessage(userId, UUID.randomUUID().toString());
}
@GetMapping("/close")
public void close() {
String userId = getUserId().toString();
SseEmitterUtil.close(userId);
}
/**
* 推送给所有人
* @param message
* @return
*/
@GetMapping("/push/{message}")
public ResponseEntity<String> push(@PathVariable(name = "message") String message) {
// 获取连接人数
int userCount = SseEmitterUtil.getUserCount();
// 如果无在线人数,返回
if (userCount < 1) {
return ResponseEntity.status(500).body("无人在线!");
}
SseEmitterUtil.batchSendMessage(message);
return ResponseEntity.ok("发送成功!");
}
}
@Slf4j
public class SseEmitterUtil {
/**
* 当前连接数
*/
private static AtomicInteger count = new AtomicInteger(0);
/**
* 使用map对象,便于根据userId来获取对应的SseEmitter,或者放redis里面
*/
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
/**
* 创建用户连接并返回 SseEmitter
* @return SseEmitter
*/
public static SseEmitter connect(String clientId) {
// 设置超时时间,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
SseEmitter sseEmitter = new SseEmitter(0L);
// 注册回调
// 注册超时回调,超时后触发
sseEmitter.onTimeout(() -> {
log.info("连接已超时,正准备关闭,clientId = "+clientId);
sseEmitterMap.remove(clientId);
});
// 注册完成回调,调用 emitter.complete() 触发
sseEmitter.onCompletion(() -> {
log.info("连接已关闭,正准备释放,clientId = "+clientId);
sseEmitterMap.remove(clientId);
log.info("连接已释放,clientId = " +clientId);
});
// 注册异常回调,调用 emitter.completeWithError() 触发
sseEmitter.onError(throwable -> {
log.info("连接已异常,正准备关闭,clientId = "+ clientId+"==>"+ throwable);
sseEmitterMap.remove(clientId);
});
sseEmitterMap.put(clientId, sseEmitter);
// 数量+1
count.getAndIncrement();
log.info("创建新的sse连接,当前用户:{}", clientId);
return sseEmitter;
}
/**
* 给指定用户发送信息
*/
public static void sendMessage(String userId, String message) {
if (sseEmitterMap.containsKey(userId)) {
try {
// sseEmitterMap.get(userId).send(message, MediaType.APPLICATION_JSON);
sseEmitterMap.get(userId).send(message);
}
catch (IOException e) {
log.error("用户[{}]推送异常:{}", userId, e.getMessage());
removeUser(userId);
}
}
}
/**
* 群发消息
*/
public static void batchSendMessage(String wsInfo, List<String> ids) {
ids.forEach(userId -> sendMessage(wsInfo, userId));
}
/**
* 群发所有人
*/
public static void batchSendMessage(String wsInfo) {
sseEmitterMap.forEach((k, v) -> {
try {
v.send(wsInfo, MediaType.APPLICATION_JSON);
}
catch (IOException e) {
log.error("用户[{}]推送异常:{}", k, e.getMessage());
removeUser(k);
}
});
}
public static void close(String userId) {
SseEmitter sseEmitter = sseEmitterMap.get(userId);
sseEmitter.complete();
sseEmitterMap.remove(userId);
}
/**
* 移除用户连接
*/
public static void removeUser(String userId) {
sseEmitterMap.remove(userId);
// 数量-1
count.getAndDecrement();
log.info("移除用户:{}", userId);
}
/**
* 获取当前连接信息
*/
public static List<String> getIds() {
return new ArrayList<>(sseEmitterMap.keySet());
}
/**
* 获取当前连接数量
*/
public static int getUserCount() {
return count.intValue();
}
}
后言
springboot 提供了一个封装类SseEmitter 以支持通过sse的协议向前端发送消息,注意的是sse方式不能像ws一样,服务端可以知道前端和自己断开了连接,所以如果是客户端主动断开,最好调用一次http请求通知后端, 另外浏览器支持sse的重连模式,如果意外断开,浏览器会自动尝试重新新建sse消息连接。
如果通过了后端的服务通过了nginx代理,那么需要做额外的配置
perl
# 设置 Nginx 不对 SSE 响应进行缓冲,直接透传给客户端
proxy_buffering off;
# 设置代理读取服务器响应的超时时间
proxy_read_timeout 24h;
# 设置客户端连接的超时时间
proxy_connect_timeout 1h;
# 设置 HTTP 版本,SSE 需要 HTTP/1.1
proxy_http_version 1.1;
# 保持连接活性,不发送连接关闭的信号
proxy_set_header Connection '';
# 配置代理传递的头部,确保 Host 头部正确传递
proxy_set_header Host $host;
# 配置代理的后端服务器地址
proxy_pass url;
# 设置代理的响应头部,保持传输编码为 chunked
proxy_set_header X-Accel-Buffering no;
# 设置跨域资源共享 (CORS),如果你的客户端和服务器不在同一个域上
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Origin,Authorization,Accept,X-Requested-With' always;
if ($request_method = 'OPTIONS') {
# 如果请求方法为 OPTIONS,则返回 204 (无内容)
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Origin,Authorization,Accept,X-Requested-With';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}