Nodejs SSE示例代码和解析

本文我们来讨论一个可能比较有趣的问题: Server-Sent Event(服务端推送事件,SSE)。

SSE虽然也是HTTP协议的标准技术,但一直处于比较边缘的状态。笔者也一直知道这个技术,但一直没有找到合适的应用场合来实践。直到前一阵ChatGPT比较热门,有人提到了它的会话过程的响应信息是使用SSE而非大家以为的WS实现的,才有兴趣去了解了一下,并且在认真的体会这个技术的原理和可能的应用场景,遂有了本文和相关的探讨。

另注,本来笔者想复现一下ChatGPT的请求过程的,但在笔者的系统上,确看不懂这个交互过程,不知道是由于OpenAI的技术迭代还是别的什么原因,看不到具体的请求响应数据,下面就只能就笔者自己的程序示例来进行说明。

关于SSE

和所有HTTP协议的衍生技术一样,SSE也有其发展和完善的过程,笔者在网络上找到了如下资料,可以看到它最早的一个版本在2009年就已经有了,所以,它实际上是HTTP的一部分,而非HTML5那一波新技术(大约在2014年左右发布)的组成。

www.w3.org/TR/2012/WD-...

经过相关技术稳定的阅读和理解,笔者理解,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环境中是不支持的,还没有时间研究是否有类似的实现和替代技术。

相关推荐
追逐时光者38 分钟前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
初晴~1 小时前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581361 小时前
InnoDB 的页分裂和页合并
数据库·后端
滚雪球~1 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语1 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
小_太_阳2 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾2 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
星就前端叭2 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
m0_748234522 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
噢,我明白了3 小时前
同源策略:为什么XMLHttpRequest不能跨域请求资源?
javascript·跨域