你学废了吗:Server-Sent Events

如果需要实现一个站点的消息通知功能,例如通知用户收到私信、站内信等信息,像这样的:

前端实现上,你第一时间的思路是什么呢?

定时器轮询

最简单的做法就是写个定时器,在固定的时间间隔内去请求消息通知接口:

js 复制代码
setInterval(() => {
  getMessageCount().then((data) => {
    updateCount(data);
  });
}, 2000)

一个简单的轮询,每2秒就会去请求一次接口,然后更新数量。这个方法足够简单,但是缺点也很明显,消息的产生不是定时的,可能会出现大多数时间的请求都数量都没有变化,或者是2秒的间隔内产生大量的消息会出现更新不及时的情况,而且这样的定时轮询对服务端的压力也很大,会浪费掉大量的服务端资源。另外,定时请求,会导致不断地建立链接或者一直保持Http链接,浪费客户端的资源。

Websoket

对比起前面的定时器,Websocket的明显是一个更高级的做法。为了避免定时器遇到的问题,使用Websocket建立一个双端的长链接,仅需要做一次握手,两边就可以一直保持通讯。

服务端代码

js 复制代码
const webSocket = require('ws');

const  WebSocketServer = webSocket.Server;
const wsServer = new WebSocketServer({port: 8082}); //创建服务端链接,定义端口号

//链接
wsServer.on('connection', function (ws) {
    console.log('client connected');
    
    let enventId  = 0;
    let intervalId;

    setInterval(() => {
      const message = {
        id: enventId,
        data: `这是第${enventId}条消息`
      };
  
      ws.send(JSON.stringify(message));
      enventId++;
    }, 1000);      


    ws.on('close', function() {
      clearInterval(intervalId);
    });
});

客户端代码

html 复制代码
<!DOCTYPE html>
<html lang="zh">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>WebSocket Client</title>
    </head>
    <body>
      <ul id="message-list"></ul>
      <script>
          const wsServer = 'ws://localhost:8082';
          const webSocket = new WebSocket(wsServer); //创建WebSocket对象

          webSocket.onopen = function (event) {
              //已经建立连接
              console.log("已经建立连接");
          };
          webSocket.onclose = function (event) {
              //已经关闭连接
              console.log("已经关闭连接");
          };
          webSocket.onmessage = function (event) {
              //收到服务器消息,使用event.data提取
              event.stopPropagation();
              const data = JSON.parse( event.data);
              const eventList = document.getElementById("message-list");
              const eventItem = document.createElement("li");
              eventItem.innerHTML = "message: " + data.data;
              eventList.appendChild(eventItem);
          };
          webSocket.onerror = function (event) {
              //异常
              console.error(event.message);
          };

      </script>
  </body>
</html>

Server-Sent Events(SSE)

SSE是一种服务端单向推送的技术,让服务端与客户端可以通过http协议建立链接并持续传输数据流。注意这里面发送的不是一次性的数据包,而是一个数据流。这个数据流会连续传输,因此客户端不会关闭这个链接,会一直等待到服务端的数据传输。相当于就是一个请求时间超长的链接。

相对于Websocket,SSE会显得更加轻量:

  • SSE是基于HTTP协议,现有的服务器基本都支持。而Websocket是独立的协议,需要额外支持
  • SSE是单向的,比双向的Websocket更简单
  • SSE支持断线重连,Webscoket需要自己实现

当然SSE也有自己的问题:

  • 只支持文本数据,不支持二进制数据
  • 受浏览器最大连接数的限制(不过可以使用HTTP2.0来扩展数量)
  • 浏览器支持没有Websocket那么广:

Webscoket

SSE

相对于Websocket,在上面提到的单方面的消息通知的场景,使用SSE实现会更轻便。

SSE的实现

要使用SSE,需要在服务端的response设置以下的HTTP头:

vbnet 复制代码
Content-Type: text/event-stream

必须设置Content-Type类型为event-stream。

服务端推送的消息,由一个或多个消息体组成,每个消息体由一行或多行文本组成,列出该消息体的字段。每个字段由字段名表示,后面是冒号,然后是该字段值的文本数据,最后由\n分隔。每个消息体结尾都要带上\n\n实现分隔(因为SSE传递的是文本信息,所以需要通过这种方式来实现分隔)

每个message按照下面的格式组装数据:

vbnet 复制代码
event: <event-name>
data: <event-data>
id: <event-id>
retry: <retry-time>

这里面event、data、id和retry都是SSE的关键字。含义如下:

  • event:可选,指定事件名称,可以用来区分不同类型的事件。
  • data:必填,指定事件数据,可以是任何字符串。
  • id:可选,指定事件ID,用于客户端重连时从上次断开的位置继续接收事件。
  • retry:可选,指定重试时间间隔,单位为毫秒,如果连接中断,客户端将在指定的时间间隔后重新连接。

在服务端推送数据时,按照上述格式构造数据,然后通过响应流(response stream)发送给客户端。客户端收到数据后,可以通过解析响应流来获取事件名称、事件数据和事件ID等信息。

一个消息体大概是这样的:

bash 复制代码
id:eventId\nevent:count\ndata: 这是第n条消息\nretry:1000\n\n

注意:

  • 如果一行文本中不包含冒号,则整行文本会被解析成为字段名,其字段值为空
  • 不管是一个消息体还是多个,每个消息体结尾都需要加上\n\n,不然客户端不会解析

在客户端则需要使用内置的EventSource对象来进行操作。

服务端

node实现

js 复制代码
const http = require('http');

http.createServer((req, res) => {
  // 设置响应头,指定Content-Type为text/event-stream,以及其他必要的头部信息
 res.setHeader('Access-Control-Allow-Origin', '*'); // 允许跨域
 res.setHeader( 'Content-Type', 'text/event-stream');
 res.setHeader( 'Cache-Control', 'no-cache');
 res.setHeader( 'Connection', 'keep-alive');

  res.writeHead(200);

  // 定义一个计数器,用于模拟推送的事件ID
  let eventId = 0;

  // 每隔1秒向客户端推送一条消息
  const intervalId = setInterval(() => {
    // 构造SSE格式的数据,并发送给客户端
    res.write(`id: ${eventId}\n`); // 事件的id
    res.write('event: MessageCount\n'); // 自定义事件名称为MessageCount
    res.write(`data: 这是第${eventId+1}条消息\n\n`);
    // 也可以这么写
    res.write(`id:${eventId}\nevent:MessageCount\ndata:这是第${eventId+1}条消息\n\n`);
    eventId++;
  }, 1000);

  // 当客户端关闭连接时,清除定时器
  req.on('close', () => {
    clearInterval(intervalId);
    res.end();
  });

}).listen(8989, () => {
  console.log('Server running');
});

服务端的实现需要注意一下,因为建立的是一个长链接,会将当前进程占住,导致其他请求无法被响应,所以如果需要使用SSE,可以将其作为一个独立服务来实现或者在独立进程中启动。

客户端

按照EventSource对象提供的能力,我们需要创建一个EventSource的对象,通过这个对象来处理链接的生命周期。

创建对象

js 复制代码
const eventSource = new EventSource('http://localhost:8989');

在跨域的情况下,可以添加withCredentials配置是否发送cookie、Authorization 等信息。

js 复制代码
const eventSource = new EventSource('http://localhost:8989', {withCredentials:true});

建立链接

创建对象之后,EventSource会开始建立链接,一旦链接建立完成,就会触发open事件:

js 复制代码
eventSource.onopen = function(event) {
  console.log('SSE open');
};

// 也可以使用监听的方式
eventSource.addEventListener('open', function(event) {
   console.log('SSE open');
});

对于每个生命周期的方法,EventSource都提供类似dom事件的方式,既可以使用.on**=function的方式,亦可以使用addEventListener来监听事件。

接受消息

链接建立成功后就可以开始接受消息了,如果没有自定义事件的话,就使用message事件来获取事件信息:

js 复制代码
 eventSource.onmessage = function (event) {
    console.log('onmessage', event);
};

// 监听的方式
 eventSource.addEventListener(
    'messaage',
    function (event) {
        console.log('onmessage', event);
    }, 
    false
);

这里需要注意的是,如果是自定义事件的话,使用message事件就无法获取到事件,例如上面服务端的代码,将事件命名为:MessageCount

js 复制代码
 res.write('event: MessageCount\n');

这里message事件就无法监听到,需要使用自定义事件监听:

js 复制代码
 eventSource.addEventListener(
    'MessageCount',
    function (event) {
        console.log('MessageCount', event);
    }, 
    false
);

处理错误

同上,我们可以通过error事件来捕获链接的错误:

js 复制代码
eventSource.onerror = function(err) {
  console.log('error', err);
}

eventSource.addEventListener(
    'error',
    function (err) {
        console.log('error', err);
    }, 
    false
);

关闭链接

SSE由客户端主动发起请求,服务端推送事件流,客户端无法主动停止服务端的事件流,同时服务端无法主动关闭链接。客户端可以主动关闭链接,如果服务端想要关闭链接,只能关闭http请求。

使用EventScource关闭链接(这个是方法):

js 复制代码
eventSource.close();

EventSource的属性

  • readyState,表示链接状态的数字:
    • 0:CONNECTING
    • 1:OPEN
    • 2:CLOSED
  • url,事件源的url
  • withCredentials,是否开启withCredentials

客户端完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSE Client</title>
  </head>
  <body>
    <ul id="message-list"></ul>
    <script type="text/javascript">
      // 是否可以使用EventSource
      if (window.EventSource) {
          const eventSource = new EventSource("http://127.0.0.1:8989");
          console.log(eventSource);
          eventSource.onopen = function(event) {
              console.log('SSE open');
          };
      
          eventSource.onerror = function(err) {
              console.log('error', err);
          }
      
          eventSource.addEventListener(
              'MessageCount',
              function (event) {
                 	const eventList = document.getElementById("message-list");
                  const eventItem = document.createElement("li");
                  eventItem.innerHTML = "message: " + event.data;
                  eventList.appendChild(eventItem);
              }, 
              false
          );
      }
    </script>
  </body>
</html>

最终的效果

复杂消息体

上面的服务端实现是简单的消息体传递,假如要实现这样的消息传递:

不定的更新信息、点赞、收藏的消息数量,需要传递比较复杂的消息体。

我们可以把消息的data字段换成json字符串:

js 复制代码
const count = {
  message: 1,
  like: 2,
  collect: 3
};

res.write(`id:${eventId}\n`);
res.write('event:MessageCount\n');
res.write(`data:${JSON.stringify(count)}\n\n`);

在客户端接收到消息后,再转一下:

js 复制代码
 eventSource.addEventListener(
    'MessageCount',
    function (event) {
      const data = JSON.parse(event.data);
      // ...do something
    }, 
    false
);

也可以使用复合的消息体来实现数量的单独更新:

js 复制代码
const messageCount =`id:message-${eventId}\nevent:MessageCount\ndata:1\n\n`;
const likeCount = `id:like-${eventId}\nevent:LikeCount\ndata:2\n\n`;
const collectCount = `id:collect-${eventId}\nevent:CollectCount\ndata:3\n\n`;
res.write(messageCount);
res.write(likeCount);
res.write(collectCount);

客户端分别接收处理:

js 复制代码
 eventSource.addEventListener(
    'MessageCount',
    function (event) {
      const data = JSON.parse(event.data);
      // ...do something
    }, 
    false
);

eventSource.addEventListener(
    'LikeCount',
    function (event) {
      const data = JSON.parse(event.data);
      // ...do something
    }, 
    false
);

eventSource.addEventListener(
    'CollectCount',
    function (event) {
      const data = JSON.parse(event.data);
      // ...do something
    }, 
    false
);

总结

本文介绍了SSE,我们了解了下SSE实现的基本原理,基于SSE实现了一个简单的消息数量更新的demo。相对于WebSocket,SSE更加轻量一些,如果是服务端单向的数据推送的话,可以尝试SSE来实现。

完整的例子

我写了一个完整的例子,有需要可以查看:github.com/chens01/SSE...

相关推荐
VX:Fegn08951 天前
计算机毕业设计|基于springboot + vue敬老院管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
文刀竹肃1 天前
DVWA -SQL Injection-通关教程-完结
前端·数据库·sql·安全·网络安全·oracle
算法与双吉汉堡1 天前
【短链接项目笔记】Day2 用户注册
java·redis·笔记·后端·spring
LYFlied1 天前
【每日算法】LeetCode 84. 柱状图中最大的矩形
前端·算法·leetcode·面试·职场和发展
Bigger1 天前
Tauri(21)——窗口缩放后的”失焦惊魂”,游戏控制权丢失了
前端·macos·app
Victor3561 天前
Netty(18)Netty的内存模型
后端
Victor3561 天前
Netty(17)Netty如何处理大量的并发连接?
后端
Bigger1 天前
Tauri (20)——为什么 NSPanel 窗口不能用官方 API 全屏?
前端·macos·app
bug总结1 天前
前端开发中为什么要使用 URL().origin 提取接口根地址
开发语言·前端·javascript·vue.js·html
码事漫谈1 天前
C++共享内存小白入门指南
后端