SSE推送技术

379 阅读6分钟

SSE

我想实现一个需求,消费者下单之后,系统给卖家一个消息通知。以及用户发布博客之后,可以给他的粉丝一个通知。(xxx已下单,xxx已支付,xxx发布了一条新博文)

概况

SSE(Server-Sent Events,服务器推送事件)是一种基于HTTP协议的服务器推送技术。它允许服务器向客户端发送异步的、无限长的数据流,而无需客户端不断地轮询或发起请求。

使用SSE时,客户端通过简单的HTTP请求与服务器建立连接,并在连接保持打开的情况下接收服务器发送的数据。服务器可以随时向客户端发送新的数据(以文本格式),并在数据前面添加特定的标识符,以便客户端能够正确地解析数据。

注:IE浏览器不支持EventSorce,故不支持SSE

SSE官方文档

SSE源码GitHub地址

传统推送:轮询和长轮询

轮询:这是一种较为传统的方式,客户端会定时地向服务端发送请求,询问是否有新数据。服务端只需要检查数据状态,然后将结果返回给客户端。轮询的优点是实现简单,兼容性好;缺点是可能产生较大的延迟,且对服务端资源消耗较高。

长轮询(Long Polling) :轮询的改进版。客户端向服务器发送请求,服务器收到请求后,如果有新的数据,立即返回给客户端;如果没有新数据,服务器会等待一定时间(比如30秒超时时间),在这段时间内,如果有新数据,就返回给客户端,否则返回空数据。客户端处理完服务器返回的响应后,再次发起新的请求,如此反复。长轮询相较于传统的轮询方式减少了请求次数,但仍然存在一定的延迟。

SSE与websocket

WebSocket:一种双向通信协议,同时支持服务端和客户端之间的实时交互。WebSocket 是基于 TCP 的长连接,和HTTP 协议相比,它能实现轻量级的、低延迟的数据传输,非常适合实时通信场景,主要用于交互性强的双向通信。

SSE:SSE(Server-Sent Events)是一种基于 HTTP 协议的推送技术。基于长轮询机制,服务端可以使用 SSE 来向客户端推送数据,但客户端不能通过SSE向服务端发送数据。相较于 WebSocket,SSE 更简单、更轻量级,能实现断线重连,但只能实现单向通信。

img

应用场景:

  • 实时通知:如邮件通知、系统消息推送。
  • 实时更新:如股票行情、新闻更新。
  • 监控和仪表盘:实时监控数据的展示。
  • 社交媒体更新:如实时评论和点赞。

文档源码解读

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();
		}
	}
  1. 调用SseEventBuilder里的builder方法生成DataWithMediaType对象的集合,这些对象包含了要发送的数据和它们的媒体类型(例如,text/plainapplication/json)。
  2. 在写锁的保护下发送数据,保证在发送数据时,不会有其他线程同时修改或发送数据,从而避免并发问题。
  3. 调用父类的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);
		}
	}
  1. 使用synchronized锁,方法是同步的,确保了在多线程环境中的线程安全。
  2. 在发送之前,检查ResponseBodyEmitter是否已经完成。如果已经完成,则抛出异常。这里的Assert.state是一个条件检查,如果条件为假,则抛出IllegalStateException
  3. 如果已经设置了处理器(this.handler),则调用处理器的send方法来发送对象和媒体类型。
  4. 如果没有设置处理器,将对象和媒体类型添加到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怎么相应,之后我再慢慢了解。

两种推送方式

两种推送方式:

  1. 流式推送:建立连接后,服务器不断地向客户端发送数据
  2. 重连推送:定一个时间,sse重新连接,发送数据

具体使用

  1. 添加Spring Web的依赖:

     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
     </dependency>
    
  2. 前端代码:

    主要就是用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>
    
  3. 后端代码:

         @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;
         }