服务器发送事件sse(server-sent events), EventSource初体验 with NestJS ;)

2,039 阅读7分钟

前言

前段时间公司项目中需要集成一个富文本编辑器, 由于之前使用过ckeditor 4, 然后这回也就依旧选择了ckeditor4是很多年前用的, 那个时候5才刚刚出来, 而现在ckeditor 5已经被开发出来很久了, 于是决定直接使用ckeditor 5, 5有自己的react组件, 使用起来方便快捷, 再也不需要像4一样去封装对应的react组件了, 可就在我愉快地使用ckeditor 5的时候, 我发现了一个问题: 服务端返回的html字符串数据中某些标签会被过滤! 而这些标签如果被过滤了, 那最后提交的内容发生了变更, 标签丢失, 样式就会受到影响, 这就麻烦了, 具体标签被过滤的问题解决方案如下:

Ckeditor removes html5 video block during initialization

Why does the editor filter out my content (styles, classes, elements)? Where is config.allowedContent = true?

解决方法就是需要自己写插件, 可是这个功能比较紧急, 以及我当时手上还有其他的活, 加之之前用过ckeditor 4, 因此没有去研究如何开发插件, 而是直接使用了ckeditor 4, 因为4里面有一个api可以直接控制是否过滤标签, 方便快捷

而在我使用ckeditor 5的过程中, 在ckeditor 5 online-builder中构建符合自己需求的编辑器的时候, 偶然发现了页面和服务端交互的一个细节: 在最后一步构建完成, 点击Start之后, 页面向服务端发起了一个请求, 此时服务端给页面返回了一个content-typetext/event-stream的相应内容, 在谷歌浏览器的Network中查看的时候并没有看到我熟悉的PreviewResponse选项卡, 而是看到了一个我从没见过的选项卡: EventStream, 点开之后里面的内容有点像WebSocket的消息, WebSocket的相关内容有需要的小伙伴可以看看这篇文章: 使用eggjs+websocket(socket.io)处理刷新/关闭页面, 而这激起了我的好奇心, 于是便有了这篇文章

什么是事件源(EventSource)

我个人的理解是响应内容中的一种, 就好像我们常见的content-typeapplication/json那样, 只不过这个的content-typetext/event-stream, 同时它和WebSocket有些类似, 都是长连接, 关闭页面连接会断开, 或者可以主动关闭连接, 但又有一点不同: WebSocket是双向的, 而这个是单向的, 详细的解释参考这篇文章: EventSource

如何使用

客户端

网页一侧使用EventSource api来监听服务端发送的事件:

const evtSource = new EventSource("ssedemo.php");

参数就是服务端生成这个事件的URL, 同时还支持第二个可选参数, 比如跨域的凭证相关配置:

const evtSource = new EventSource(
  "ssedemo.php",
  {
    withCredentials: true
  }
);

创建了EventSource示例之后就可以用它来监听服务端发送过来的事件啦:

evtSource.onmessage = function(event) {
  const newElement = document.createElement("li");
  const eventList = document.getElementById("list");

  newElement.textContent = "message: " + event.data;
  eventList.appendChild(newElement);
}
evtSource.addEventListener("ping", function(event) {
  const newElement = document.createElement("li");
  const eventList = document.getElementById("list");
  const time = JSON.parse(event.data).time;
  newElement.textContent = "ping at " + time;
  eventList.appendChild(newElement);
});

可以看到这里有两段监听的代码, 它们的区别在于: 第一段没有监听具体的事件名称, 而第二段监听了一个叫ping的事件, 这取决于服务端发送事件的时候是否添加event字段, 没有添加则需要使用第一个监听方式, 添加了event字段为ping, 那么我们才能监听叫ping的事件, 而当服务端没有设置event字段的时候, 它会有一个默认的值, 叫message

关于事件的名称字段, mdn文档中说的是event, 而我自己尝试之后发现是type, 可能是这个EventSource的规范后来改过, 而文档是之前的了

服务端

服务端发送事件的时候需要留意这么几点:

  1. 设置content-typetext/event-stream
  2. 每条通知以文本形式发送并且以一对换行符结尾
  3. 发送的内容符合事件流(EventStream)的格式要求

一个可参考的示例代码如下:

date_default_timezone_set("America/New_York");
header("Cache-Control: no-store");
header("Content-Type: text/event-stream");

$counter = rand(1, 10);
while (true) {
  // Every second, send a "ping" event.

  echo "event: ping\n";
  $curDate = date(DATE_ISO8601);
  echo 'data: {"time": "' . $curDate . '"}';
  echo "\n\n";

  // Send a simple message at random intervals.

  $counter--;

  if (!$counter) {
    echo 'data: This is a message at time ' . $curDate . "\n\n";
    $counter = rand(1, 10);
  }

  ob_end_flush();
  flush();

  // Break the loop if the client aborted the connection (closed the page)

  if ( connection_aborted() ) break;

  sleep(1);
}

上面这段代码会每秒生成一个事件, 以及这个事件的event字段为ping, 而且每个事件的data字段都是JSON, 同时这个JSON还包含与事件生成时间相对应的ISO 8601时间戳, 还有就是这段代码在随机的时间间隔内还会发送一个不带event字段的简单消息. 最后, 这个循环将一直运行, 不受连接状态的影响, 因此包含了一个检查, 如果连接关闭(比如客户端关闭页面), 那么这个循环就会终止, 也就是此时将不再生成事件

上方客户端和服务端的示例代码来自于mdn: Using server-sent events, 详情可以点击查看

亲自试一试

了解了概念和基本的用法之后, 接下来我打算亲自尝试一下, 而上一次尝试WebSocket的时候我使用的是EggJS, 这回我打算看看NestJS, 这是最近这几年比较火的NodeJS框架, 同时也是我司数据中台的后端小伙伴使用的技术栈, 而在我看它文档的时候, 我发现了NestJSgitbub中有一个sse的例子, 这里我也贴一下示例中的代码吧, 这样阅读起来也更方便, 包含配置和目录结构的完整示例代码可以点击这个链接查看: nestjs sse sample

app.controller.ts:

import { Controller, Get, MessageEvent, Res, Sse } from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { interval, Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Controller()
export class AppController {
  @Get()
  index(@Res() response: Response) {
    response
      .type('text/html')
      .send(readFileSync(join(__dirname, 'index.html')).toString());
  }

  @Sse('sse')
  sse(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map((_) => ({ data: { hello: 'world' } } as MessageEvent)),
    );
  }
}

app.module.ts:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [],
})
export class AppModule {}

main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
  console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

html:

<script type="text/javascript">
  const eventSource = new EventSource('/sse');
  eventSource.onmessage = ({ data }) => {
    const message = document.createElement('li');
    message.innerText = 'New message: ' + data;
    document.body.appendChild(message);
  }
</script>

核心的代码在app.controller.ts中: http://localhost:3000返回一个html文件, http://localhost:3000/sse则是我们服务端发送事件的服务, 这个服务1秒发一次, 只有data字段, 而没有event字段(实际应该是type字段), 这里我修改一下, 实际应用中前端和后端项目一般不会部署到同一个域下, 那么这里就需要做个跨域的配置, 以及不用服务端返回html文件, 前后端分离, 前端项目前端维护, 后端只需要发送事件过来即可, 综上, 修改之后代码如下:

app.controller.ts:

import { Controller, MessageEvent, Sse, Header } from '@nestjs/common';
import { interval, Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Controller()
export class AppController {
  @Sse('sse')
  @Header('Access-Control-Allow-Origin', 'http://localhost:9090')
  sse(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map((_) => ({
        data: { hello: 'world' }
      } as MessageEvent)),
    );
  }
}

sse.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>sse</title>
</head>
<body>
  <script>
    const evtSource = new EventSource('http://localhost:3000/sse');
    evtSource.addEventListener('message', e => {

      const { data } = e;
      console.log(data);
    });

    setTimeout(
      () => {
        evtSource.close();
      },
      5000
    );
  </script>
</body>
</html>

这段代码是能正常运行的, 服务端发送事件, 客户端监听这个事件并打印data, 然后5秒之后关闭连接

在没有指定type字段的时候默认是message, 所以客户端监听的是叫message的事件, 而当指定type字段的时候, 客户端监听的事件名称就要和type字段一样了:

app.controller.ts:

import { Controller, MessageEvent, Sse, Header } from '@nestjs/common';
import { interval, Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Controller()
export class AppController {
  @Sse('sse')
  @Header('Access-Control-Allow-Origin', 'http://localhost:9090')
  sse(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map((_) => ({
        type: 'sse', //这里设置了type字段的值
        data: { hello: 'world' }
      } as MessageEvent)),
    );
  }
}

sse.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>sse</title>
</head>
<body>
  <script>
    const evtSource = new EventSource('http://localhost:3000/sse');
    //监听的事件名修改为和type字段一样的值: sse
    evtSource.addEventListener('sse', e => {

      const { data } = e;
      console.log(data);
    });

    setTimeout(
      () => {
        evtSource.close();
      },
      5000
    );
  </script>
</body>
</html>

写在最后

我也是第一次接触nestjs, 第一次用它来写demo, 以前接触过一点java, 看了nestjs后发现它的写法有点像java, 但对于nestjs也只是浅尝辄止, 更深入的用法还要再花时间学习和练习

而关于本文的主题sse, 坦白说我并没有能够想到一些实际的使用场景, 服务端向客户端发送消息, 使用常见的application/json形式的数据传输是非常常见通用的一个做法, 而且EventSource还是一个长连接, 如果客户端不手动关闭, 连接则会一直占用资源, 如果遇到客户端需要给服务端回传数据, 那么还是需要用我们常见的诸如application/json这样形式的请求来完成, 这样一看的话EventSource就显得有些鸡肋了

mdn文档中有这样一句话给我了更多的思考:

Unlike WebSockets, server-sent events are unidirectional; that is, data messages are delivered in one direction, from the server to the client (such as a user's web browser). That makes them an excellent choice when there's no need to send data from the client to the server in message form. For example, EventSource is a useful approach for handling things like social media status updates, news feeds, or delivering data into a client-side storage mechanism like IndexedDB or web storage.

WebSocket不同, sse是单向的, 也就是说, 数据信息是单向传递的, 方向是从服务端到客户端(比如从服务端到用户的网页浏览器). 在不需要以消息的形式从客户端向服务端发送数据的时候sse是一个非常好的选择. 比如处理社交媒体的状态更新, 新闻推送或者将数据发送到客户端, 然后由客户端来存储(例如IndexedDB或者web storage)的时候是很有用的

也就是说sse比较适合做社交媒体的消息推送以及新闻推送等从服务端向客户端发送数据的场景, 确切的说是需要服务端通知客户端实时更新数据的场景, 但我个人在实际工作中暂未遇到, 暂未使用过, 有在项目中用过sse的小伙伴欢迎在评论区分享自己的经验心得

最后, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需

参考文献:

  1. EventSource
  2. Using server-sent events