本文我们来讨论一个可能比较有趣的问题: Server-Sent Event(服务端推送事件,SSE)。
SSE虽然也是HTTP协议的标准技术,但一直处于比较边缘的状态。笔者也一直知道这个技术,但一直没有找到合适的应用场合来实践。直到前一阵ChatGPT比较热门,有人提到了它的会话过程的响应信息是使用SSE而非大家以为的WS实现的,才有兴趣去了解了一下,并且在认真的体会这个技术的原理和可能的应用场景,遂有了本文和相关的探讨。
另注,本来笔者想复现一下ChatGPT的请求过程的,但在笔者的系统上,确看不懂这个交互过程,不知道是由于OpenAI的技术迭代还是别的什么原因,看不到具体的请求响应数据,下面就只能就笔者自己的程序示例来进行说明。
关于SSE
和所有HTTP协议的衍生技术一样,SSE也有其发展和完善的过程,笔者在网络上找到了如下资料,可以看到它最早的一个版本在2009年就已经有了,所以,它实际上是HTTP的一部分,而非HTML5那一波新技术(大约在2014年左右发布)的组成。
经过相关技术稳定的阅读和理解,笔者理解,SSE是一种服务端数据主动推送的技术,它的出现是为了解决常规Web应用简单请求/响应工作模型的一些限制和局限,从而提供更加丰富的业务能力支持。
与类似的技术和实现相比,它的主要特征和优势包括:
- 单向通信: 从服务器向客户端推送数据,应用中要注意这一点,客户端只有一次建立请求的机会
- 自动重连: 如果连接断开,浏览器会自动重新连接,这个其实是浏览器的实现支持的
- 事件驱动: 服务器通过发送事件来推送新数据
- 轻量级: SSE使用文本格式,协议简单,低延迟
- 兼容性好:主流浏览器都支持
其实,如果运用得当,SSE的使用场景也可以非常广泛:
- 发布式的实时推送: 聊天消息、股票报价、新闻通知等
- 日志流: 服务器日志实时输出到网页
- 数据流展示: 服务器数据流展示到实时更新的图表等
- 业务系统集成: 业务系统之间的单向数据和信息推送
同时,我们也要注意到SSE的一些局限,从而能够在合适的业务和场景中加以运用。
SSE工作原理和流程
下面,我们来讨论一下SSE的工作原理,这有助于我们理解其技术特性。其工作原理和流程如下图所示:
这个过程包括:
- 客户端发送特定请求建立连接
- 服务器通过写入Response发送事件来推送数据
- 客户端通过事件监听接收更新的数据。
大致如此,但真正的魔鬼在细节里。下面我们会有相关的实现代码,通过这些代码的分析和解读,我们将会看到,其实SSE没有任何独特的技术,只是在HTTP协议上进行的扩展。理论上而言,它也不需要客户端或者浏览器的特别支持,就是说其实没有浏览器支持版本的问题,只不过浏览器将其相关的处理封装称为标准功能,可以直接使用,而不需要开发者编写外部程序实现而已。
下面的代码,我们分为服务器、客户端(nodejs程序)和浏览器三个部分讨论,为了方便讨论和研究,笔者编写了相关的示例代码,这些代码的实现基于标准nodejs和浏览器环境,可以独立运行,没有任何第三方程序库和依赖。
服务端
我们先来看一看下面基于nodejs http模块实现的SSE服务端的示例代码,然后再做简单的分析:
server.js
const
http = require("http"),
HOST = "127.0.0.1",
PORT = 8088,
EVHEAD = "data: ",
EVEND = "\n\n";
const srvTime = (res)=>{
res.write( EVHEAD + "servertime - " + (0 | Date.now()/1000) + EVEND );
setTimeout(()=> srvTime(res), 1000 + 30000* Math.random());
}
const handle = (req,res)=>{
console.log("reqest:",req.url);
res.writeHead(200, {
"Access-Control-Allow-Origin": "*",
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
srvTime(res);
};
const start=()=>{
http
.createServer(handle)
.listen(PORT, HOST, null,()=>{
console.log("Service Started", HOST, PORT);
});
}; start();
nodejs内置HTTP Server的相关实现和工作方式,我们假设读者已经非常熟悉,我们这里直接说明SSE的实现要点。
首先,在收到客户端请求的时候,需要通过响应的头进行一些设置:
- 跨域: 因为我们后面的浏览器客户端是HTML文件方式启动,需要跨域
- Content-Type: 这是第一个要点,需要声明响应内容的类型,是event-stream(事件流)
- Cache-Control: 显然应该为无缓存
- Connection: 保持连接,非常好理解,这样才能持续响应
其次,具体响应的内容,就是第二个要点了,就是event-stream,这是一个约定规范的数据格式。它可以是一个字符串,我们这里的格式为:
"data: <内容> \n\n"
表示,这个片段的信息类型是data,\n\n作为数据包间的分隔符。具体的格式,可能需要参考相关的技术文件。它的定义是这样的:
js
stream = [ bom ] *event
event = *( comment / field ) end-of-line
comment = colon *any-char end-of-line
field = 1*name-char [ colon [ space ] *any-char ] end-of-line
end-of-line = ( cr lf / cr / lf / eof )
eof = < matches repeatedly at the end of the stream >
; characters
lf = %x000A ; U+000A LINE FEED (LF)
cr = %x000D ; U+000D CARRIAGE RETURN (CR)
space = %x0020 ; U+0020 SPACE
colon = %x003A ; U+003A COLON (:)
bom = %xFEFF ; U+FEFF BYTE ORDER MARK
name-char = %x0000-0009 / %x000B-000C / %x000E-0039 / %x003B-10FFFF
; a Unicode character other than U+000A LINE FEED (LF), U+000D CARRIAGE RETURN (CR), or U+003A COLON (:)
any-char = %x0000-0009 / %x000B-000C / %x000E-10FFFF
; a Unicode character other than U+000A LINE FEED (LF) or U+000D CARRIAGE RETURN (CR)
这里的data,应该就是一个数据的属性,其他可用的属性还包括id、event、retry等等,可以用于控制推送的过程。
这样,服务器在响应的时候,设置好的头信息和保持连接状态,就可以按照数据格式,分别响应相关的数据,就实现了服务器能够按需主动推送数据的需求。
本例中,响应的具体内容,就是每隔一段随机时间,向客户端推送一个服务器时间的字符串。显然,这里也可以方便的替换为任意业务驱动的业务数据。
客户端
基于前面我们了解到的SSE的工作原理,就知道在客户端方面其实应该也没有任何特别的东西,只需要能够处理分批响应的数据就可以了。如以下代码:
js
const doClient = ()=>{
console.log("Client Starting...");
http
.get(`http://${HOST}:${PORT}`,res=>{
res.setEncoding('utf8');
res
.on('data', (chunk) => {
console.log("Get:", chunk);
})
.on('end', () => {
console.log("Client End");
process.exit(0);
});
})
.on("error",e=>{
})
}; setTimeout(doClient, 2000);
当然,这里的客户端响应,收到和处理的是纯数据,没有做任何格式的处理和转换。这里笔者只是想说明,客户端其实是不需要任何特别的设置,就是可以工作的。如果是实际的业务,可能需要做一些响应数据和信息的处理。
浏览器
在现实的应用中,更多的情况,可能是使用浏览器,在前端适配并应用SSE的场景。下面提供了测试代码,这是一个HTML文件,可以配合前面的服务端代码,直接使用浏览器打开并运行:
sse.html
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title>SSE Test</title>
</head>
<body>
<div id = "id_title"></div>
<script>
const tv_title = document.getElementById("id_title");
const evtSource = new EventSource("http://127.0.0.1:8088/sse");
evtSource.onmessage = (event) => {
console.log(event.data);
tv_title.innerText = event.data;
};
</script>
</body>
</html>
大部分代码都是无效的,只是为了保证文件完整性,其实核心代码就是两段:
- 基于SSE服务地址,创建一个新的EventSource对象实例,这个实例会自动发起HTTP请求并和服务器建立永久连接
- 为这个实例设置一个onmessage回调函数,对服务器的响应进行处理,这里只是简单的打印和显示出来
通过这段浏览器环境的代码,我们应该可以看出来一些窍门了。笔者猜想,这个EventSource对象或者接口,其实就是封装了HTTP请求和响应处理的一个组件,它可以处理eventStream类型的数据并转换称为event对象,其中最重要的信息就是data属性,可以用于承载业务数据。
所以,逻辑上而言,如果浏览器不支持SSE,我们也可以编写js代码,使用标准http请求过程来模拟实现SSE的功能。当然EventSource的实现和封装可能更好更健壮,比如它在中断时,会自动尝试重新建立连接,如果没有特别的原因,肯定是优先使用原生功能的。
多客户端
这里简单的探讨一下SSE在实际应用场景中可能需要考虑的一些问题。我们的示例只有一个客户端,但实际的业务场景,肯定需要支持多个客户端,并有可能需要为它们提供个性化的信息推送。
WS的解决方案是使用一个列表来维护所有的WS连接(Socket),并使用ID和角色来标识它们。SSE也可以使用类似的方式。但需要维护的可能就是一个response列表了(可能代价稍高)。
一个参考的解决方案和流程如下:
- 在创建http服务时,使用一个路径来标识SSE工作的地址
- 客户端连接时,需要使用这个工作路径,并需要提供用户或者客户端的标识和验证信息
- 服务端对客户端的验证通过之后,服务端将response对象,加入一个响应对象列表,并响应连接成功的信息
- 在需要推送信息时,服务端基于业务遍历和搜索响应对象列表,对符合业务条件的响应对象,调用其write方法发送响应数据
- 客户端关闭时,服务端遍历响应列表,找到关闭的响应对象并从列表中清除
显然,这种工作模式也不适用于非常大规模的客户端集合,但用于服务器之间的集成,可能是一个比较合适的方案。另,上述工程设计只是笔者的构想,现在没有现实的实现和实践,仅用于方案的讨论和参考。
和WS的比较
当然,我们已经应该知道,WebSocket也是一种Web服务器和客户端双向通信的机制。限于篇幅和主题,本文不会深入研究WS的机制和实现,只是简单的说明一下笔者对它们的比较和理解。
首先是原理不同,SSE基本上是标准的HTTP协议;而WS应该算是一个HTTP的衍生和升级协议,所以SSE的兼容性和实现的代价而言,其实都比WS要好。其次,从开发的角度,SSE无需第三方库和程序,而WS的使用,由于可能会比较辅助,一般情况下会使用一些第三方库,如服务端的Socket.io等等。第三,WS是完全的双向通信,使用更加灵活;而SSE在初始建立连接之后,就只能从服务端向客户端推送数据,使用场景受到一些限制。当然,这主要看需求和场合,简单的情况,只需要推送的情况,SSE应该更合适,而且显然更安全一点。
所以,在实际的业务场景中,选择WS或者SSE,在开发者了解了业务和技术特点之后,才能根据需求,选择合适的技术方案。
调试过程
这里想再分享一下,在撰写本文,和在编写测试代码的时候,遇到的问题和处理和解决这些问题的过程。
笔者在简单的理解了一下SSE的原理和一些简单的示例代码之后,就着手按照自己的想法编写示例代码。服务端代码和客户端代码的编写和运行都非常顺利,因为都是nodejs环境,这两个部分的逻辑代码,甚至是写在一个js文件当中的。程序很快的跑起来了,执行方式也是预想的那样。
然后稍微增加一点复杂度,就是开始编写浏览器端的代码,为了简单起见(主要是懒,不想自己搭Web服务器),直接使用的是HTML文件方式。发现程序看起来是运行了,而且不出错,却永远不发送HTTP请求。这才想到,可能会存在跨域的问题,现在的浏览器,对安全的设置还是比较严格的,这样,就有了服务端代码中跨域的设置。
跨域设置完成后,浏览器代码确实开始请求了,但响应的数据看不到。而且当时看到的是不停的重复请求,服务端也能看到请求并且响应,但浏览器就是不处理响应的数据。
然后再次仔细阅读了一下相关的技术文档,包括一些别人写的示例代码(他们的代码一般都用在Web框架中),就发现了这些响应数据,怎么好像都是以"data:" 作为开头,"\n\n"作为结尾,再结合text/eventstream,觉得这可能就是规范需要的数据格式,调整程序后,就是现在这样样子,能够正确并按照设想的方式运行。
重复请求的问题也处理好了。因为我写的程序的早期版本,在推送了几个数据后,就调用end方法关闭响应了,但浏览器中的EventSource有重连机制,就会重新建立请求响应过程,这样就看到了重复请求的情况。而自己写的客户端程序,没有重新请求的机制,当时就没有发现这个问题。
在实践和错误中学习,可能的收获和进步更大,这次是有了更深入的体会。
小结
本文探讨了SSE技术的原理和特点,并提供了相关的示例代码进行阐述和说明。这个实现的要点包括:
- SSE是正常的HTTP协议实现和操作
- SSE服务端的实现是在响应头中设置eventStream响应类型和保持连接
- 在浏览器客户端使用EventSource对象实现SSE的连接、响应处理和连接维持
- 可以自行实现和扩展HTTP请求,来达到相同的效果
最后,笔者已经确认,EventSource对象,在nodejs环境中是不支持的,还没有时间研究是否有类似的实现和替代技术。