SSE服务端消息推送

  1. 什么是SSE(Server side event) 服务器端事件

  2. SSE是单工模式的,只能服务器端向客户端推送消息。如果需要全双工的双向并行通信,可以用wobsocket。

  3. SSE只支持utf8编码的纯文本,如果是别的数据需要转换成utf8编码的纯文本才能传输。

  4. SSE 是 html5 对服务器端推送轮询和长连接方式的一种官方实现。它使用的http 协议,和wosocket 使用的套接字有明显区别。

  5. SSE自带客户端重启,在和服务器端失去连接,或者服务器端返回服务器端超时的时候会自动重连。不需要手动维护重连和重试。

  6. 如果服务器端保持长连接,那么可以一直向客服端实时的推送消息(实时推送)。如果服务器端是短连接,那么客户端会按照服务器端下发的时间自动轮询的获取消息(轮询查询)。

  7. SEE响应格式要求

    • 长连接要求 响应类型是:Content-Type: text/event-stream,包括异步请求超时处理异常的情况。
    • 推送的消息只能是纯文本utf8编码的。
    • 消息格式,一条消息的字段可以有4个字段,data代表数据,event代表事件名称,id代表id,retry代表多少时间轮训一次。 返回的字符串用\n表示分割字段,两个\n表示一条消息的结束。event默认是message,在客户端按照 message监听,如果指定了别的,客户端也要对应的监听别的事件。
    • spring 对 SSE的支持类 SseEmitter 可以方便的维持长连接和拼装消息格式。如下图的对应关系。
      image-20230223095603891
  8. SEE 服务器端例子代码

    package com.lomi.controller;
    
    import cn.hutool.Hutool;
    import cn.hutool.extra.spring.SpringUtil;
    import cn.hutool.json.JSONObject;
    import cn.hutool.json.JSONUtil;
    import com.lomi.annotation.ShowParam;
    import io.swagger.annotations.Api;
    import io.swagger.annotations.ApiOperation;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.web.bind.MethodArgumentNotValidException;
    import org.springframework.web.bind.annotation.*;
    import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
    import org.springframework.web.servlet.ModelAndView;
    import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.io.PrintWriter;
    import java.util.Enumeration;
    import java.util.HashMap;
    import java.util.Map;
    import java.util.concurrent.ConcurrentHashMap;
    
    
    /**
     *
     * 关于 SSE 的一些要求
     *
     * 传输的数据编码格式只能是UTF8
     * 返回的字符串用\n表示分割字段,两个\n表示一条消息的结束
     *
     * 一条消息的字段可以有个 data代表数据,event代表事件名称,id代表id,retry代表多少时间轮训一次。
     * 如 data:数据 \n retry:3000\n\n
     * event 默认值是 message
     * retry 默认值是 3秒
     *
     *
     *
     */
    @Api(tags="SSE推送")
    @RestController
    @RequestMapping(value = "/sse")
    public class SSEController extends BaseController {
    	private static final Logger logger = LoggerFactory.getLogger(SSEController.class);
    
    	/**
    	 * 存放 token 和 SSE发射器的关系
    	 */
    	Map<String,SseEmitter> tokenMap = new ConcurrentHashMap<>();
    
    	//标记消息ID,后面重连可以更具消息Id来取上次读到的消息
    	long messageId = 0;
    
    	/**
    	 *  长连接方式,这种方式等价于请求不释放,然后向前端输出数据
    	 *
    	 *
    	 *  SSE 默认只能带URL参数,不能带 header,EventSourcePolyfill 可以让 SSE 带header
    	 *	使用 SseEmitter,创建的是一个长连接,后端不会把这个连接中,除非设置超时时间,但是这个超时时间是绝对的
    	 *
    	 *  响应类型是:Content-Type: text/event-stream,包括处理异常的情况
    	 *  如果放回的不是一个长连接,那么前端认为连接端口,默认指定时间以后重新请求。类似轮询,http1.0 只能是 轮询,http1.1 是长连接
    	 *
    	 *
    	 * @return
    	 */
    	@ApiOperation(value = "SSE端点")
    	@GetMapping("/push")
    	public SseEmitter push(HttpServletRequest request, HttpServletResponse response, String token ) throws IOException {
    
    		Enumeration<String> parameterNames = request.getParameterNames();
    		System.out.println( "parameterNames:" + JSONUtil.toJsonStr( parameterNames ));
    
    		//允许所有跨域,便于测试
    		response.setHeader("Access-Control-Allow-Origin","*");
    
    		//
    		if(  "token".equals( token ) ){
    			throw new RuntimeException("token错误");
    		}
    
    		// 时间监听
    		if( !tokenMap.keySet().contains( token ) ){
    			//这里的超时需要指定,不知道默认大概10多秒,这个超时时间是后端异步请求保存的时间,正常我们可以把它设置长一点,比如5分钟或者30分钟
    			tokenMap.put( token, new SseEmitter( 30*1000L ));
    
    			//一堆监听事件,没需求就不指定
    			tokenMap.get( token ).onTimeout( ()->{
    				System.out.println("超时了");
    				tokenMap.remove( token );
    			} );
    			tokenMap.get( token ).onError( (e)->{
    				System.out.println( "出异常了------------------" );
    				logger.error("出异常了",e);
    			} );
    			tokenMap.get( token ).onCompletion( ()->{
    				System.out.println( "完成了------------------" );
    			} );
    
    			//很重要,发送以后激活用户客户端,否者过期以后不会重连
    			//send( "用户连接" );
    		}
    
    		return tokenMap.get( token );
    	}
    
    	/**
    	 * 长连接 推送消息
    	 * @param message
    	 * @return
    	 * @throws IOException
    	 */
    	@ApiOperation(value = "发送消息",notes = "发送给有效的所有用户")
    	@GetMapping("/send")
    	@ShowParam
    	public String send(String message) throws IOException {
    		System.out.println("tokenMap:" + JSONUtil.toJsonStr( tokenMap ));
    
    		//群发消息,也可以更具 token userId的 对应关系给 前端发送消息。
    		tokenMap.values().forEach( item->{
    			try {
    				SseEmitter.SseEventBuilder sseEventBuilder = SseEmitter
    						.event()
    						.name("message")
    						.data( message )
    						.id( ( messageId++ )+"" )
    						.reconnectTime(3*1000L);  //这个时间是前端感知到后端连接断开或者请求过期的时候重连的时间
    				item.send( sseEventBuilder );
    			} catch (IOException e) {
    				e.printStackTrace();
    			}
    		});
    
    		return "ok";
    	}
    
    
    
    
    
    	/**
    	 *
    	 * 轮询方式((都轮询了实际上就不需要推送了,轮询查询就行了))
    	 *
    	 * 传输的数据编码格式只能是UTF8
    	 *
    	 * 返回的字符串用\n表示分割字段,两个\n表示一条消息的结束
    	 *
    	 * 一条消息的字段可以有4个字段 data代表数据,event代表事件名称,id代表id,retry代表多少时间轮训一次。
    	 * 如 data:数据 \n retry:3000\n\n
    	 *
    	 *
    	 * @param request
    	 * @param response
    	 * @param token
    	 * @return
    	 * @throws IOException
    	 */
    	@ApiOperation(value = "see端点2")
    	@GetMapping(value="/push2")
    	public void push2(HttpServletRequest request, HttpServletResponse response, String token ) throws IOException {
    		response.setContentType( "text/event-stream" );
    		response.setCharacterEncoding( "UTF-8" );
    
    		String s =  "event:myEvent\ndata:数据\nid:1\n\n";
    
    
    		response.getWriter().write( s );
    		response.getWriter().flush();
    	}
    
    
    
    
    
    
    
    	/**
    	 * 处理请求异步请求超时问题
    	 * 异步请求超时的目的是避免服务器端一直占用资源
    	 * 超时以后,前端会在指定时间内连接
    	 * 超时的时候一定要放回 Content-Type=text/event-stream 给前端,否者前端会报错,放弃重试
    	 * @param request
    	 * @param response
    	 * @param handler
    	 * @param ex
    	 * @return
    	 */
    	@ExceptionHandler(value = AsyncRequestTimeoutException.class)
    	public ModelAndView timeout(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    		if( ex instanceof AsyncRequestTimeoutException){
    			//SEE连接超时,这是正常的,让前端重连
    			logger.warn("内部消化请求异步请求超时异常"  );
    			response.setHeader("Content-Type","text/event-stream");
    		}else{
    			ex.printStackTrace();
    		}
    
    
    		return new ModelAndView();
    	}
    
    
    
    
    
    
    }
    
  9. SEE 前端例子(长连接方式),连接上面服务器端例子的 sse/push接口,这种方式只有失去连接,或者服务器端连接超时,客户端才会在指定的时间后重新发起一次连接。

    <!DOCTYPE HTML>
    <html>
    <head>
        <title>My WebSocket</title>
        <meta charset="utf8">
    </head>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
    <body>
    Welcome<br/>
    
    <input id="text" type="text"/>
    <button onclick="send()">Send</button>
    <button onclick="closeWebSocket()">Close</button>
    
    <div id="message">
    </div>
    
    </body>
    
    <script type="text/javascript">
        var eventSource = null;
    
        //判断是否支持SSE
        if ('EventSource' in window) {
            // new一个EventSource对象,第一个参数是后端的访问地址;第二个参数是可选的,如果要填就只能填{withCredentials:true}或{withCredentials:true},表示发送或不发送Cookie
            var url = "http://127.0.0.1/sse/push?token=token1";
            eventSource = new EventSource(url);
    
        } else {
            alert("不支持")
        }
    
        //开启时调用
        eventSource.onopen = (event) => {
            console.log("open SSE");
            setMessageInnerHTML("open SSE");
        }
    
        // 监听接受数据的事件
        eventSource.onmessage = (event) => {
            console.log("message:" + event.data + ";messageID:" + event.lastEventId);
            setMessageInnerHTML("message:" + event.data + ";messageID:" + event.lastEventId);
        }
    
        eventSource.onerror = (event) => {
            console.log(event);
            //异常以后自动重连,如果没有自动重连大概是因为后端放回响应类型不是Content-Type: text/event-stream
            //如果不清楚连不上,几次会放弃,大不了在这方法里面延迟几秒手动重连
            setMessageInnerHTML("重连.........:");
            //带上消息Id去重连,让服务器端重新推送消息,这里可以吧 原来的 eventSource 销毁了,带上消息Id去请求数据
            //eventSource = new EventSource(url);
        }
    
    
        //将消息显示在网页上
        function setMessageInnerHTML(innerHTML) {
            document.getElementById('message').innerHTML += innerHTML + '<br/>';
        }
    
    
        //发送消息
        function send() {
            var message = document.getElementById('text').value;
    
            var url = "http://127.0.0.1/sse/send?message=" + message;
            $.get(url, function (res) {
                setMessageInnerHTML("send message:" + res);
            });
    
        }
    
    </script>
    </html>
    
    
  10. SEE前端例子(轮询方式),连接上面服务器端例子的 sse/push2接口,这种方式是每隔指定时间就会请求服务器端 sse/push2接口一次。

    <!DOCTYPE HTML>
    <html>
    <head>
        <title>My WebSocket</title>
        <meta charset="utf8">
    </head>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
    <body>
    Welcome<br/>
    
    <input id="text" type="text"/>
    <button onclick="send()">Send</button>
    <button onclick="closeWebSocket()">Close</button>
    
    <div id="message">
    </div>
    
    </body>
    
    <script type="text/javascript">
        var eventSource = null;
    
        //判断是否支持SSE
        if ('EventSource' in window) {
            // new一个EventSource对象,第一个参数是后端的访问地址;第二个参数是可选的,如果要填就只能填{withCredentials:true}或{withCredentials:true},表示发送或不发送Cookie
            var url = "http://127.0.0.1/sse/push2?token=token1";
            eventSource = new EventSource(url);
    
        } else {
            alert("不支持")
        }
    
        //开启时调用
        eventSource.onopen = (event) => {
            console.log("open SSE");
            setMessageInnerHTML("open SSE");
        }
    
        // 监听接受数据的事件
        eventSource.onmessage = (event) => {
            console.log("message:" + event.data + ";messageID:" + event.lastEventId);
            setMessageInnerHTML("message:" + event.data + ";messageID:" + event.lastEventId);
        }
    
        eventSource.onerror = (event) => {
            console.log(event);
            //异常以后自动重连,如果没有自动重连大概是因为后端放回响应类型不是Content-Type: text/event-stream
            //如果不清楚连不上,几次会放弃,大不了在这方法里面延迟几秒手动重连
            setMessageInnerHTML("重连.........:");
            //带上消息Id去重连,让服务器端重新推送消息,这里可以吧 原来的 eventSource 销毁了,带上消息Id去请求数据
            //eventSource = new EventSource(url);
        }
    
        //监听myEvent事件
        eventSource.addEventListener('myEvent',function(event){
            var data=event.data;
            setMessageInnerHTML("like:" + data);
        },false);
    
    
        //将消息显示在网页上
        function setMessageInnerHTML(innerHTML) {
            document.getElementById('message').innerHTML += innerHTML + '<br/>';
        }
    
    
        //发送消息
        function send() {
            var message = document.getElementById('text').value;
    
            var url = "http://127.0.0.1/sse/send?message=" + message;
            $.get(url, function (res) {
                setMessageInnerHTML("send message:" + res);
            });
    
        }
    
    </script>
    </html>
    
    
  11. 没有spring的SseEmitter 封装的,可以简单的处理收到请求以后通过等待,自旋等方式保持request不返回,保持连接,同样也指定超时多久以后就过期。并且把用户和response对应关系存起来,以便推送消息。响应contentType,编码,和类型格式 符合 SSE标准就行了。

  12. jmeter没有SSE的支持,所以没测试过性能

  13. websocket 基本使用 - zhangyukun - 博客园 (cnblogs.com)

posted on 2023-02-23 11:19  zhangyukun  阅读(1426)  评论(0编辑  收藏  举报

导航