服务器端实时推送技术之SSE

原创
2020/06/23 18:48
阅读数 1.7W

前言

在讲Server-Sent Events (SSE) 之前,我们先来看看 HTTP 请求- 响应。一个标准的 HTTP 请求- 响应,需要客户端打开一个连接,将一个 HTTP 请求(如 HTTP GET 请求)发送到服务端,然后接收到 HTTP 回来的响应,如果该响应被完全发送或者接收,服务端就会把连接关闭。通常是由某个客户发起,客户端才会需要请求所有数据。

然而, Server-Sent Events (SSE) 与 HTTP 请求- 响应背道而驰,它是一种机制,客户端一旦建立起客户机-服务器的连接,就能让服务端将数据以异步的方式从服务器推到客户端。当连接由客户端建立完成,服务端就提供数据,并决定新数据“块"可用时将其发送到客户端。当一个新的数据事件发生在服务端时,这个事件被服务端发送到客户端。因此,名称被称为 Server-Sent Events(服务器推送事件)。下面是支持服务端到客户端交互的技术总览:

  • 插件提供 socket 方式:比如利用 Flash XMLSocket,Java Applet 套接口,Activex 包装的 socket。

    • 优点:原生 socket 的支持,与 PC 端的实现方式相似;
    • 缺点:浏览器端需要装相应的插件;与 js 进行交互时复杂
  • Polling:轮询,重复发送新的请求到服务端。如果服务端没有新的数据,就发送适当的指示并关闭连接。然后客户端等待一段时间后,发送另一个请求(例如,一秒后)

    • 优点:实现简单,无需做过多的更改
    • 缺点:轮询的间隔过长,会导致用户不能及时接收到更新的数据;轮询的间隔过短,会导致查询请求过多,增加服务器端的负担。

  • Long-polling:长轮询,客户端发送一个请求到服务端,如果服务端没有新的数据,就保持住这个连接直到有数据。一旦服务端有了数据(消息)给客户端,它就使用这个连接发送数据给客户端。接着连接关闭。
    •  优点:比 Polling 做了优化,有较好的时效性
    • 缺点:需第三方库支持,实现较为复杂;每次连接只能发送一个数据,多个数据发送时耗费服务器性能

  • 基于 iframe 及 htmlfile 的流(streaming)方式:iframe 流方式是在页面中插入一个隐藏的 iframe,利用其src属性在服务器和客户端之间创建一条长链接,服务器向 iframe 传输数据(通常是 HTML,内有负责插入信息的 javascript),来实时更新页面。
    • 优点:消息能够实时到达;
    • 缺点:服务器维持着长连接期会消耗资源;iframe 不规范的用法;数据推送过程会有加载进度条显示,界面体验不好

  • Server-Sent events:SSE 与 长轮询机制类似,区别是每个连接不只发送一个消息。客户端发送一个请求,服务端就保持这个连接直到有一个新的消息已经准备好了,那么它将消息发送回客户端,同时仍然保持这个连接是打开,这样这个连接就可以用于另一个可用消息的发送。一旦准备好了一个新消息,通过同一初始连接发送回客户端。客户端单独处理来自服务端传回的消息后不关闭连接。所以,SSE 通常重用一个连接处理多个消息(称为事件)。SSE 还定义了一个专门的媒体类型 text/event-stream,描述一个从服务端发送到客户端的简单格式。SSE 还提供在大多数现代浏览器里的标准 javascript 客户端 API 实现。关于 SSE 的更多信息,请参见 SSE API 规范
    • 优点:HTML5 标准;实现较为简单;一个连接可以发送多个数据
    • 缺点:IE 不支持 EventSource(可以使用第三方的 js 库来解决,具体可以本章中的源码) ;服务器只能单向推送数据到客户端

  • WebSocket: WebSocket 与上述技术都不同,因为它提供了一个真正的全双工连接。发起者是一个客户端,发送一个带特殊 HTTP 头的请求到服务端,通知服务器, HTTP 连接可能“升级”到一个全双工的 TCP/IP WebSocket 连接。如果服务端支持 WebSocket,它可能会选择升级到 WebSocket。一旦建立 WebSocket 连接,它可用于客户机和服务器之间的双向通信。客户端和服务器可以随意向对方发送数据。此时,新的 WebSocket 连接上的交互不再是基于 HTTP 协议了。 WebSocket 可以用于需要快速在两个方向上交换小块数据的在线游戏或任何其他应用程序。(示例可以参考http://www.waylau.com/netty-websocket-chat/)
    • 优点:HTML5 标准;大多数浏览器支持;真正全双工;性能强
    • 缺点:实现相对复杂;ws 协议

SSE vs. WebSocket

用比较笼统的一个说法,就是WebSocket能做的,SSE也能做,反之亦然,但是它们还是有差别的,特别是在完成某些任务方面。WebSocket 是一种更为复杂的服务端实现技术,但它是真正的双向传输技术,既能从服务端向客户端推送数据,也能从客户端向服务端推送数据。WebSocket 和 SSE 的浏览器支持率差不多,除了IE。IE是个例外,即便IE11都还不支持原生 SSE,IE10 添加了WebSocket 支持,可见上图。与 WebSocket 相比,SSE 有一些显著的优势。我认为它最大的优势就是便利:不需要添加任何新组件,用任何你习惯的后端语言和框架就能继续使用。你不用为新建虚拟机、弄一个新的IP或新的端口号而劳神,就像在现有网站中新增一个页面那样简单。我喜欢把这称为既存基础设施优势。

SSE 的第二个优势是服务端的简洁。我们将在下节中看到,服务端代码只需几行。相对而言,WebSocket 则很复杂,不借助辅助类库基本搞不定。因为 SSE 能在现有的 HTTP/HTTPS 协议上运作,所以它能直接运行于现有的代理服务器和认证技术。而对 WebSocket 而言,代理服务器需要做一些开发(或其他工作)才能支持,在写这本书时,很多服务器还没有(虽然这种状况会改善)。SSE还有一个优势:它是一种文本协议,脚本调试非常容易。事实上,在本书中,我们会在开发和测试时用 curl,甚至直接在命令行中运行后端脚本。不过,这就引出了 WebSocket 相较 SSE 的一个潜在优势:WebSocket 是二进制协议,而 SSE 是文本协议(通常使用UTF-8编码)。当然,我们可以通过SSE连接传输二进制数据:在 SSE 中,只有两个具有特殊意义的字符,它们是 CR 和LF,而对它们进行转码并不难。但用 SSE 传输二进制数据时数据会变大,如果需要从服务端到客户端传输大量的二进制数据,最好还是用 WebSocket。

WebSocket 相较 SSE 最大的优势在于它是双向交流的,这意味向服务端发送数据就像从服务端接收数据一样简单。用 SSE时,一般通过一个独立的 Ajax 请求从客户端向服务端传送数据。相对于 WebSocket,这样使用 Ajax 会增加开销,但也就多一点点而已。如此一来,问题就变成了“什么时候需要关心这个差异?”如果需要以1次/秒或者更快的频率向服务端传输数据,那应该用 WebSocket。0.2次/秒到1次/秒的频率是一个灰色地带,用 WebSocket 和用 SSE 差别不大;但如果你期望重负载,那就有必要确定基准点。频率低于0.2次/秒左右时,两者差别不大。

从服务端向客户端传输数据的性能如何?如果是文本数据而非二进制数据(如前文所提到的),SSE和WebSocket没什么区别。它们都用TCP/IP套接字,都是轻量级协议。延迟、带宽、服务器负载等都没有区别。在旧版本浏览器上的兼容,WebSocket 难兼容,SSE 易兼容。

服务器端实时推送技术之SSE用法

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 服务器端实时推送技术之 SseEmitter 的用法测试
 * <p>
 * 测试步骤:
 * 1.请求http://localhost:8888/sse/start?clientId=111接口,浏览器会阻塞,等待服务器返回结果;
 * 2.请求http://localhost:8888/sse/send?clientId=111接口,可以请求多次,并观察第1步的浏览器返回结果;
 * 3.请求http://localhost:8888/sse/end?clientId=111接口结束某个请求,第1步的浏览器将结束阻塞;
 * 其中clientId代表请求的唯一标志;
 *
 * @author syj
 */
@RestController
@RequestMapping("/sse")
public class SseEmitterController {
    private static final Logger logger = LoggerFactory.getLogger(SseEmitterController.class);

    // 用于保存每个请求对应的 SseEmitter
    private Map<String, Result> sseEmitterMap = new ConcurrentHashMap<>();

    /**
     * 返回SseEmitter对象
     *
     * @param clientId
     * @return
     */
    @RequestMapping("/start")
    public SseEmitter testSseEmitter(String clientId) {
        // 默认30秒超时,设置为0L则永不超时
        SseEmitter sseEmitter = new SseEmitter(0L);
        sseEmitterMap.put(clientId, new Result(clientId, System.currentTimeMillis(), sseEmitter));
        return sseEmitter;
    }

    /**
     * 向SseEmitter对象发送数据
     *
     * @param clientId
     * @return
     */
    @RequestMapping("/send")
    public String setSseEmitter(String clientId) {
        try {
            Result result = sseEmitterMap.get(clientId);
            if (result != null && result.sseEmitter != null) {
                long timestamp = System.currentTimeMillis();
                result.sseEmitter.send(timestamp);
            }
        } catch (IOException e) {
            logger.error("IOException!", e);
            return "error";
        }

        return "Succeed!";
    }

    /**
     * 将SseEmitter对象设置成完成
     *
     * @param clientId
     * @return
     */
    @RequestMapping("/end")
    public String completeSseEmitter(String clientId) {
        Result result = sseEmitterMap.get(clientId);
        if (result != null) {
            sseEmitterMap.remove(clientId);
            result.sseEmitter.complete();
        }
        return "Succeed!";
    }

    private class Result {
        public String clientId;
        public long timestamp;
        public SseEmitter sseEmitter;

        public Result(String clientId, long timestamp, SseEmitter sseEmitter) {
            this.clientId = clientId;
            this.timestamp = timestamp;
            this.sseEmitter = sseEmitter;
        }
    }
}

前端JS接受

<script>
	//记录加载次数
	var time=1;
	if (typeof (EventSource) !== "undefined") {
		var source = new EventSource("/sse/start?clientId=111");
		console.log(source);
		source.addEventListener("事件名", function(e) {
			document.getElementById("result").innerHTML = e.data;
		}, false);//使用false表示在冒泡阶段处理事件,而不是捕获阶段。
	} else {
		document.getElementById("result").innerHTML = "抱歉,你的浏览器不支持 server-sent 事件...";
	}
</script>

展开阅读全文
加载中
点击加入讨论🔥(2) 发布并加入讨论🔥
打赏
2 评论
14 收藏
0
分享
返回顶部
顶部