SSE
我想实现一个需求,消费者下单之后,系统给卖家一个消息通知。以及用户发布博客之后,可以给他的粉丝一个通知。(xxx已下单,xxx已支付,xxx发布了一条新博文)
概况
SSE(Server-Sent Events,服务器推送事件)是一种基于HTTP协议的服务器推送技术。它允许服务器向客户端发送异步的、无限长的数据流,而无需客户端不断地轮询或发起请求。
使用SSE时,客户端通过简单的HTTP请求与服务器建立连接,并在连接保持打开的情况下接收服务器发送的数据。服务器可以随时向客户端发送新的数据(以文本格式),并在数据前面添加特定的标识符,以便客户端能够正确地解析数据。
注:IE浏览器不支持EventSorce,故不支持SSE
传统推送:轮询和长轮询
轮询:这是一种较为传统的方式,客户端会定时地向服务端发送请求,询问是否有新数据。服务端只需要检查数据状态,然后将结果返回给客户端。轮询的优点是实现简单,兼容性好;缺点是可能产生较大的延迟,且对服务端资源消耗较高。
长轮询(Long Polling) :轮询的改进版。客户端向服务器发送请求,服务器收到请求后,如果有新的数据,立即返回给客户端;如果没有新数据,服务器会等待一定时间(比如30秒超时时间),在这段时间内,如果有新数据,就返回给客户端,否则返回空数据。客户端处理完服务器返回的响应后,再次发起新的请求,如此反复。长轮询相较于传统的轮询方式减少了请求次数,但仍然存在一定的延迟。
SSE与websocket
WebSocket:一种双向通信协议,同时支持服务端和客户端之间的实时交互。WebSocket 是基于 TCP 的长连接,和HTTP 协议相比,它能实现轻量级的、低延迟的数据传输,非常适合实时通信场景,主要用于交互性强的双向通信。
SSE:SSE(Server-Sent Events)是一种基于 HTTP 协议的推送技术。基于长轮询机制,服务端可以使用 SSE 来向客户端推送数据,但客户端不能通过SSE向服务端发送数据。相较于 WebSocket,SSE 更简单、更轻量级,能实现断线重连,但只能实现单向通信。
应用场景:
- 实时通知:如邮件通知、系统消息推送。
- 实时更新:如股票行情、新闻更新。
- 监控和仪表盘:实时监控数据的展示。
- 社交媒体更新:如实时评论和点赞。
文档源码解读
1.基本信息
SseEmittier本身是springframework框架下的技术,继承ResponseBodyEmitter类,除了toString(),event(),extendResponse(),和内部的SseEventBuilder()之外,其余的方法都是继承ResponseBodyEmitter类。
2.主要方法
send
调用ResponseBodyEmitter里的send,再由自定义handle里的方法实现
public void send(SseEventBuilder builder) throws IOException {
Set<DataWithMediaType> dataToSend = builder.build();
this.writeLock.lock();
try {
super.send(dataToSend);
}
finally {
this.writeLock.unlock();
}
}
- 调用SseEventBuilder里的builder方法生成
DataWithMediaType对象的集合,这些对象包含了要发送的数据和它们的媒体类型(例如,text/plain或application/json)。 - 在写锁的保护下发送数据,保证在发送数据时,不会有其他线程同时修改或发送数据,从而避免并发问题。
- 调用父类的send方法发送数据
父类send()
public synchronized void send(Object object, @Nullable MediaType mediaType) throws IOException {
Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" +
(this.failure != null ? " with error: " + this.failure : ""));
if (this.handler != null) {
try {
this.handler.send(object, mediaType);
}
catch (IOException ex) {
throw ex;
}
catch (Throwable ex) {
throw new IllegalStateException("Failed to send " + object, ex);
}
}
else {
this.earlySendAttempts.add(new DataWithMediaType(object, mediaType));
}
}
/**
* Write a set of data and MediaType pairs in a batch.
* <p>Compared to {@link #send(Object, MediaType)}, this batches the write operations
* and flushes to the network at the end.
* @param items the object and media type pairs to write
* @throws IOException raised when an I/O error occurs
* @throws java.lang.IllegalStateException wraps any other errors
* @since 6.0.12
*/
public synchronized void send(Set<DataWithMediaType> items) throws IOException {
Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" +
(this.failure != null ? " with error: " + this.failure : ""));
sendInternal(items);
}
private void sendInternal(Set<DataWithMediaType> items) throws IOException {
if (items.isEmpty()) {
return;
}
if (this.handler != null) {
try {
this.handler.send(items);
}
catch (IOException ex) {
throw ex;
}
catch (Throwable ex) {
throw new IllegalStateException("Failed to send " + items, ex);
}
}
else {
this.earlySendAttempts.addAll(items);
}
}
- 使用synchronized锁,方法是同步的,确保了在多线程环境中的线程安全。
- 在发送之前,检查
ResponseBodyEmitter是否已经完成。如果已经完成,则抛出异常。这里的Assert.state是一个条件检查,如果条件为假,则抛出IllegalStateException。 - 如果已经设置了处理器(
this.handler),则调用处理器的send方法来发送对象和媒体类型。 - 如果没有设置处理器,将对象和媒体类型添加到
earlySendAttempts集合中,在每次实例化的时候,都会先尝试发送这里面的数据
handler的send实现
@Override
public void send(Object data, @Nullable MediaType mediaType) throws IOException {
sendInternal(data, mediaType);
this.outputMessage.flush();
}
@Override
public void send(Set<ResponseBodyEmitter.DataWithMediaType> items) throws IOException {
for (ResponseBodyEmitter.DataWithMediaType item : items) {
sendInternal(item.getData(), item.getMediaType());
}
this.outputMessage.flush();
}
@SuppressWarnings("unchecked")
private <T> void sendInternal(T data, @Nullable MediaType mediaType) throws IOException {
for (HttpMessageConverter<?> converter : ResponseBodyEmitterReturnValueHandler.this.sseMessageConverters) {
if (converter.canWrite(data.getClass(), mediaType)) {
((HttpMessageConverter<T>) converter).write(data, mediaType, this.outputMessage);
return;
}
}
throw new IllegalArgumentException("No suitable converter for " + data.getClass());
}
可以看出是使用可用的消息转换器(HttpMessageConverter)来转换和写入数据。遍历可用的消息转换器列表,检查每个转换器是否能够处理给定的数据类型和媒体类型。如果找到合适的转换器,使用它将数据写入ServerHttpResponse。就会响应。
至于具体的ServerHttpResponse怎么相应,之后我再慢慢了解。
两种推送方式
两种推送方式:
- 流式推送:建立连接后,服务器不断地向客户端发送数据
- 重连推送:定一个时间,sse重新连接,发送数据
具体使用
-
添加Spring Web的依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> -
前端代码:
主要就是用EventSource连接服务,EventSource.onmessage获取信息。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Stock Price SSE Demo</title> </head> <body> <h1>Stock Price SSE Demo</h1> <input type="text" id="symbol" placeholder="Enter stock symbol"> <button onclick="connect()">Connect</button> <div id="messages"></div> <script> let eventSource; function connect() { const symbol = document.getElementById("symbol").value; if (eventSource) { eventSource.close(); // 关闭已有的连接 } // 创建一个新的EventSource实例,连接到服务器的/sse/{symbol}端点 eventSource = new EventSource("/sse/" + symbol); // 当收到服务器发送的消息时,执行此函数 eventSource.onmessage = function(event) { // 获取消息展示的div const messagesDiv = document.getElementById("messages"); // 创建一个新的div元素来展示新消息 const newMessage = document.createElement("div"); newMessage.textContent = event.data; // 设置div的文本内容为事件数据 messagesDiv.appendChild(newMessage); // 将新消息添加到消息展示div中 }; // 当发生错误时,执行此函数 eventSource.onerror = function(error) { console.error("EventSource failed: ", error); eventSource.close(); // 关闭EventSource }; } </script> </body> </html> -
后端代码:
@AutoWried private BlogService blogService; @PostMapping("/publisher/{symbol}") public Result saveBlog(@PathVariable("id") String id,@RequestBody Blog blog) throw Exception{ Result result =blogService.saveBlog(blog) //遍历map,一个一个发送 for(Integer key : sseEmitterMap.keySet()){ SseEmittier sseEmittier=sseEmitterMap.get(key); sseEmittier.send(name+"发布了新的博客");//发送信息给粉丝邮箱,显示的是未读消息(根据时间戳来计算) } return result; } private final Map(String,SseEmittier) sseEmitterMap=new CurrentHashMap(); //确定给哪些用户发送 @GetMapping("sse/{id}") public SseEmittier sse(@PathVariable("id") String id){ //根据id获取粉丝信息,或者根据商品id获取卖家id,或者根据博客id获取博主userid List<String> list=blogService.getuserid; for(String uid : list){ SseEmittier sseEmittier =sseEmitterMap.get(uid); sseEmittier.onCompletion(()->sseEmitterMap.remove(uid));//完成事件推送,就移除 sseEmittier.onTimeout(()->sseEmitterMap.remove(uid));//超时,移除 } return sseEmittier; }