Server-Sent Events服务器信息推送

3,920 阅读8分钟

如果你需要处理社交媒体状态更新类似的场景,比如好友登录,订阅号更新,需要在用户和订阅者更新状态,或者提醒,那么Server-Sent Events可能可以帮助到你

介绍

EventSource 对象

SSE(Server-Sent Events) 的客户端 API 部署在EventSource对象上, EventSource是服务器推送的一个网络事件接口。一个EventSource实例会对HTTP服务开启一个持久化的连接,以text/event-stream格式发送事件, 会一直保持开启直到被要求关闭。

与 WebSockets,不同的是,服务端推送是单向的。数据信息被单向从服务端到客户端分发. 当不需要以消息形式将数据从客户端发送到服务器时,这使它们成为绝佳的选择。

一旦连接开启,来自服务端传入的消息会以事件的形式分发至你代码中。如果接收消息中有一个事件字段,触发的事件与事件字段的值相同。如果没有事件字段存在,则将触发通用事件。

EventSource详情查看

实践

我们以 订阅动态实时更新功能 来具体展开 我们先开启一个简单的服务

//server.js
const express = require("express");
const app = express();
app.use(express.static(__dirname));
app.listen(3000, () => {
  console.log("server open in 3000");
});

并在当前文件夹下创建index.html,充当客户端,也就是用户,我们设置一个开启和关闭这项服务的按钮(在实际开发应用中,其实可以默认开启),

微信截图_20220313172647.png

//index.html
<button onclick="openServerPush()">开启动态实时更新</button>
<button onclick="closeServerPush()">关闭此功能功能</button>
<h3>动态</h3>
<ul id="box"></ul>

定义绑定事件,使用EventSource()构造函数创建实例,参数是接口地址,并定义好相关函数,onmessage是通用的事件接受函数,如果后端返回的stream没有指定事件的话,将通过这个函数接受,否则需要添加对应事件函数,后续会涉及到

//index.html
let es
const openServerPush = () => {
  es = new EventSource("/sse");
  es.onopen = () => {
    console.log("已开启。。。");
  };
  es.onmessage = (e, me) => {
    console.log("默认推送:" + e.data);
  };
  es.onerror = (err) => {
    console.log(err);
  };
};
const closeServerPush = () => {
  if (es) {
    es.close();
  }
};

后端实现get对应接口,并且设置响应头"Content-Type": "text/event-stream; charset=utf-8"

//server.js
app.get("/sse", (req, res) => {});

其他http方法不行,EventSource是以get请求发起的

image.png

原理

EventSource是以stream传输数据的,我们都知道在node中,虽然回调函数中req和res都是继承流,但是它们不是同一个流,通俗的讲,我们的需求是在服务端和客户端之间建立一个流,然后再服务端写入数据,最后客户端通过事件函数拿到数据,为了后端处理方便,需要中间转换成流格式的处理函数,node中stream.Transform 类实现了 Readable和 Writable 接口,并且中间具有处理流的过程函数_transform(),可以根据我们的需求进行改造数据

//server.js
const stream = require("stream");
let sse
app.get("/sse", (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream; charset=utf-8"
  });
  sse = new stream.Transform({ objectMode: true });
  sse.pipe(res);
});

如果res的状态不是 200,或者resContent-Type不是text/event-stream,则连接失败

流的实例在创建流时使用 objectMode 选项切换到对象模式。

Node.js API 创建的所有流都只对字符串和 Buffer(或 Uint8Array)对象进行操作。 但是,流的实现可以使用其他类型的 JavaScript 值(除了 null,它在流中具有特殊用途)。 这样的流被认为是在"对象模式"下运行的。

将创建Transform流的实例放在全局,方便其他接口调用,实际应用可以不必这么做。

这里sse.pipe(res)通俗点将就是把传递数据的管道架设好,等待sse.write写入数据传递给客户端

在写入数据之前我们先来了解一下text/event-stream是什么样的格式传递的,这里推荐看一下HTML Standard,如果看不太明白可以看看这篇文章,结合阅读可能会好理解一点。

每个事件由类型数据两部分组成,同时每个事件可以有一个可选的标识符。不同事件的内容之间通过仅包含回车符和换行符的空行(“\r\n”)来分隔。每个事件的数据可能由多行组成。

先明确我们需要传入的数据,所有字段都不是比传的,因为是流,客户端读取每一段流,拆分对应字段返回给事件函数而已,有东西就处理,没有就放空,但我们在使用的时候还是传入基本字段为好,保证功能完整性,下面是伪数据流

id: 数据流id 
event: 用来指定触发的事件
retry: 断开连接后多少毫秒重连
data: 数据

id: 数据流id 
event: 用来指定触发的事件
retry: 断开连接后多少毫秒重连
data: 数据

id: 数据流id 
event: 用来指定触发的事件
retry: 断开连接后多少毫秒重连
data: 数据

这里就可以用到_transform()来处理数据,使得其写入的流呈现上面的格式,让客户端可以识别

function dataString(data) {
    if (typeof data === 'object')
        return dataString(JSON.stringify(data));
    return data.split(/\r\n|\r|\n/).map(line => `data: ${line}\n`).join('');
}
sse._transform = (message, encoding, callback) => {

  if (message.comment) sse.push(`: ${message.comment}\n`);
  if (message.event) sse.push(`event: ${message.event}\n`);
  if (message.id) sse.push(`id: ${message.id}\n`);
  if (message.retry) sse.push(`retry: ${message.retry}\n`);
  if (message.data) sse.push(dataString(message.data));
  
  sse.push("\n");
  callback();
};
sse.write(':ok\n\n');

上面代码中message就是我们调用sse.write()传入的对象,可以传入对象格式的数据是由于我们在创建实例的时候开启了对象模式

message.comment作用在于添加注释,如果message只有comment是不会触发事件的

sse.push("\n")的作用在于每次流的最后都需要加入空行,不然客户端不能确定这个流单位是否结束,就不会触发对应事件。

最后让我们在这个请求最后执行sse.write({comment: 'ok'});,确保客户端es.onopen事件能被触发,由于传入的对象只有comment属性所以不会触发任何事件,因为在一些浏览器中好像在创建EventSource实例后不会触发onopen事件,所以在后端可以做一下兼容处理,保证一致性。

当我们点击开启按钮时,就可以看到控制台有对应输出了,说明连接已经成功搭建起来了,

image.png

image.png

让我们回到 订阅动态实时更新功能 的实现中来,现在我们需要一个触发更新动态的功能,简单实现一下,在当前文件夹下新建up.html,主要内容如下

//up.html
<input type="text" id="content" />
<button onclick="releaseDynamics()">发布动态</button>
<script>
  const Content = document.getElementById("content");
  const releaseDynamics = () => {
    fetch("http://localhost:3000/pushDynamics", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ content: Content.value }),
    })
      .then((res) => {
        return res.json();
      })
      .then((res) => {
        console.log(res);
      });
  };
</script>

事件处理

后端添加对应接口

const bodyParser = require("body-parser");
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
...
let contentId = 0
app.post("/pushDynamics", (req, res) => {
  const { content } = req.body;
  const message = {
    data: { name: "blanca", content },
    event: "dynamicUpdate", // 事件类型,需要客户端添加对应的事件监听
    id: ++contentId,
    retry: 10000, // 告诉客户端,如果断开连接后,10秒后再重试连接
  };
  sse?.write(message);
  res.json({ code: 0, data: "发布成功" }).end();
});

可以看到我们定义了message.eventdynamicUpdate那么,我们需要在客户端绑定对应的事件,

//index.html
const box = document.querySelector("#box"); // ul元素
const openServerPush = () => {
  es = new EventSource("/sse");
  es.addEventListener("dynamicUpdate", (e) => {
    const li = document.createElement("li");
    let user = JSON.parse(e.data)
    li.innerText = `${user.name}: ${user.content}`
    box.appendChild(li);
  });
  es.onopen = () => {
    console.log("已开启。。。");
  };
  es.onmessage = (e, me) => {
    console.log("默认推送:" + e.data);
  };
  es.onerror = (e) => {
    console.log("服务器推送出现错误", e);
  };
}

现在可以尝试一下开启服务node server.js,index.html页面点击开启功能,up.html页面输入内容,点击发布按钮

image.png

image.png

如果没有指定event事件,那么会启用默认通用的es.onmessage,我们修改一下代码试试看,

//server.js
app.post("/pushDynamics", (req, res) => {
  const { content } = req.body;
  sse?.write({
    data: content,
  });
  res.json({ code: 0, data: "发布成功" }).end();
}

image.png

image.png

重连情况

如果由于某种原因断开了连接,客户端会在一定时间后重新发起连接,现在我们模拟一下,切断连接的接口,并且恢复之前的代码

//index.html
<button onclick="cutServerPush()">模拟服务器切断当前连接</button>
<script>
const cutServerPush = () => {
  fetch("/cutLink", { method: "POST" })
    .then((res) => {
      return res.json();
    })
    .then((res) => {
      console.log(res);
    });
};
</script>
//server.js
app.post("/cutLink", (req, res) => {
  sse.end()
  res.json({ code: 0, data: "切断成功" }).end();
});
app.post("/pushDynamics", (req, res) => {
  const { content } = req.body;
  const message = {
    data: { name: "blanca", content },
    event: "dynamicUpdate", // 事件类型,需要客户端添加对应的事件监听
    id: ++contentId,
    retry: 10000, // 告诉客户端,如果断开连接后,10秒后再重试连接
  };
  sse?.write(message);
  res.json({ code: 0, data: "发布成功" }).end();
});

让我们刷新页面,点击开启按钮,并在up.html页面发布动态,然后再index.html页面点击切断连接,打开控制台查看网络变化,不出意外的话,在点击断开10s(retry规定时间)后浏览器会重新发起连接,并在请求头Last-Event-ID带上最后一次数据的id

image.png 为了保证数据完整性,id正确,我们有必要在后端处理这个重新字段,实现重连的数据id能接上上一次的id

app.get("/sse",() => {
    contentId = req.headers["last-event-id"] || 0
    ...
})

image.png

image.png

保持长连接?

在参考的文章里面有些说为了保持长连接,需要添加以下代码

//发送注释保持长连接
setInterval(() => {
    res.write(': \n\n');
}, 15000);

但在我的测试中,即使没有上面代码的处理,隔了一个很长时间再推送,浏览器还是能收到数据,最后再规范中才得到原因:兼容旧代理在超时后会断开连接

image.png

源码

事实上已经有一个现成的npm包实现了这个功能,SseStream,本文也是参照其源码进行书写的,接下来我们再看看这个npm包源码还有哪些优点

点击查看此处源码

req.socket.setKeepAlive(true); // 启用保持活动功能
req.socket.setNoDelay(true); // 创建 TCP 连接时,它将启用 Nagle 算法。Nagle 的算法在数据通过网络发送之前延迟数据。 它试图以延迟为代价来优化吞吐量。
req.socket.setTimeout(0); // 禁用现有空闲超时

点击查看此处源码

destination.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Transfer-Encoding': 'identity', // 用于指代自身(例如:未经过压缩和修改)。除非特别指明,这个标记始终可以被接受。默认是chunk
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
});
destination.flushHeaders(); // 刷新响应头,有些情况下可能会使用历史头,导致连接失败

关于Cache-Control缓存其实规范是建议设置no-store

SSE 一些问题与不足

  1. SSE 只支持从服务器到客户端的单向实时通信
  2. Server-Sent Events 在IE不支持 image.png
  3. SSE会一直保持连接状态,如果服务端一段较长的时间内没有推送数据,那么SSE相当于浪费了资源,我们可以设置一个事件,当服务端认为在未来的一段时间内不会推送信息,推送一个暂停服务的事件,并规定下次重连的时间,客户端接收到之后关闭连接,开启定时重连时间
//index.html
<button onclick="pauseServerPush()">模拟服务器暂停服务</button>
<script>
const pauseServerPush = () => {
  fetch("/pauseLink", { method: "POST" })
    .then((res) => {
      return res.json();
    })
    .then((res) => {
      console.log(res);
    });
};
let timeout
const openServerPush = () => {
    clearTimeout(timeout) // 避免由于用户主动开启时,重复发起连接
    ...
    es.addEventListener("pause", (e) => {
      es.close();
      timeout = setTimeout(() => {
        openServerPush();
      }, +e.data - Date.now());
   });
}    
</script>
//server.js
app.post("/pauseLink", (req, res) => {
  sse?.write({
    event: "pause",
    id: ++contentId,
    data: Date.now() + 10*1000 + '', // 规定重传时间点,10s后
  });
  res.json({ code: 0, data: "暂停成功" }).end();
});

image.png

写在最后

这个实践的代码还是有很多错误逻辑的,比如后端sse放全局会导致只能有一个连接在服务,因为第二个连接进来的时候会替换掉之前的,所以这里需要做处理,可以针对用户验证创建不同的sse实例,并保存在一个对象或者数组里面,这样实现多用户都能使用,实际业务需求肯定也有很多修改的地方。

如果存在跨域,需要做其他对应的跨域处理,同时可以指定第二个参数,打开withCredentials属性,表示是否一起发送 Cookie。

new EventSource(url, { withCredentials: true });

这里的示例代码的仓库

参考