Web 端简单的实时日志实现

背景

cron service 在执行定时任务时,需要能够实时 查看该任务的执行日志以确保程序正确的工作。为了能够在尽可能短的时间内实现该功能,我们需要一个足够简单的方案。

方案如何选择?

我相信大多数开发者第一个想到的就是 WebSocket ,然后是 HTTP/SSE 。WebSocket 在很多情况下是实至名归的万金油选择。但这这里他太重了,对服务端和客户端具有侵入性需要花费额外的时间来集成到项目中。

那么 SSE 呢,为什么不是他? 虽然 SSE 即轻量又实时,但 SSE 无法设置请求头。这导致无法使用缓存和通过请求头设置的 Token。而且作为长链接他还一直占用并发额度。

  • WebSocket:❌
    • 优势:
      • 实时性高
      • 不会占用 HTTP 并发额度
    • 劣势:
      • 复杂度较高,需要在客户端和服务器端都进行特殊的处理
      • 消耗更多的服务器资源。
  • SSE(Server-Sent Events):❌
    • 优势:
      • 基于HTTP协议,不需要在服务端和客户端做额外的处理
      • 实时性高
    • 劣势:
      • 无法设置请求头
      • 占用 HTTP 并发额度
  • HTTP:✅
    • 优势:
      • 简单易用,不需要在服务端和客户端做额外的处理。
      • 支持的功能丰富,如缓存,压缩,认证等功能。
    • 劣势:
      • 实时性差,取决于轮询时间间隔。
      • 每次HTTP请求都需要建立新的连接,这可能会导致性能问题。

以上列出的优缺点是仅对于本文所讨论的场景(即Web 端的实时日志)而言,这三种数据交互技术在其他场景中的优缺点不在本文讨论范围内。

实现

HTTP 轮询已经是老熟人了,不再做介绍。本文的着重于实时日志的实现及优化。

sequenceDiagram participant 浏览器 participant 服务器 Note right of 浏览器: 首先,获取日志文件最后 X bytes 的内容 浏览器->>服务器: 最新的日志文件有多大? 服务器->>浏览器: 日志文件大小: Y bytes 浏览器->>服务器: 从 Y - X bytes 处返回内容 服务器->>浏览器: 日志文件大小: Y1 bytes, 日志内容: XXXX loop 持续轮询获取最新的日志 浏览器->>服务器: 从 Y1 bytes 处返回内容 服务器->>浏览器: 日志文件大小: Y2 bytes, 日志内容: XXXX end

上方是基本工作原理的流程图。 实现的关键点在于

  • 前端如何知道日志文件当前的大小
  • 服务端如何从指定位置获取日志文件内容

这两点都可以通过 HTTP Header 来解决。Content-Range HTTP 响应头会包含完整文件的大小,而 Range HTTP 请求头可以指示服务器返回指定位置的文件内容。

因此在服务端不需要额外的逻辑,仅通过 Web 端代码就可以实现实时日志功能。

代码实现

篇幅限制,代码不会处理异常情况

首先,根据上述流程图。我们需要获取日志文件的大小。

javascript 复制代码
const res = await fetch(URL, {  
  method: "GET",  
  headers: {  Range: "bytes=0-0"  }
});

const contentRange = res.headers.get("Content-Range") || "";  
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);  

// 日志文件大小
const total = Number.parseInt(match[3], 10);

Range: "bytes=0-0" 指定仅获取第一个字节的内容,以免较大的日志文件导致响应时间过长。

我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=0-0 如果服务器能够正确处理 Range 请求头,响应中将包含一个 Content-Range 列其中将包含日志文件的完整大小,可以通过正则解析拿到。

现在我们已经拿到了日志文件的大小并存储在名为 total 的变量中。然后根据 total 获取到最后 10 KB 的日志内容。

javascript 复制代码
const res = await fetch(url, {  
  method: "GET",  
  headers: {  
    Range: `bytes=${total - 1000 * 10}-`
  }
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
const start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

现在我们发起了一个 GET 请求并将 Range 请求头设置为 bytes=${total - 1000 * 10}- 以获取最后 10 KB 的日志内容。并且通过正则解析拿到了下一次请求的起始位置。

现在我们已经拿到了日志文件的大小和最后 10 KB 的日志内容。接下来就是持续轮询去获取最新的日志。轮询代码区别仅在于将 Range 标头设置为 bytes=${start}- 以便获取最新的日志。

javascript 复制代码
const res = await fetch(url, {  
  method: "GET",  
  headers: {  
    Range: `bytes=${start}-`
  }
});

const contentRange = res.headers.get("Content-Range") || "";
const match = /bytes (\d+)-(\d+)\/(\d+)/.exec(contentRange);

// 下一次请求的起始位置
start = Number.parseInt(match[2], 10) + 1;

// 日志内容
const content = await res.text();

以上,基本的功能已经实现了。日志内容保存在名为 content 的变量中。

优化

HTTP 轮询常因其高延迟为人诟病,我们可以通过指数退避的方式来尽可能的降低延时且不会显著的增加服务器负担。

指数退避是一种常见的网络重试策略,它会在每次重试时将等待时间乘以一个固定的倍数。这样做的好处是,当网络出现问题时,重试的时间间隔会逐渐增加,直到达到最大值。

一个简单的实现如下:

typescript 复制代码
/**
 * 使用指数退避策略获取日志.
 */
export class ExponentialBackoff {
  private readonly base: number;
  private readonly max: number;
  private readonly factor: number;
  private retries: number;

  /**
   * @param base 基础延迟时间 默认 1000ms (1秒)
   * @param max 最大延迟时间 默认 60000ms (1分钟)
   * @param factor 延迟时间增长因子 默认 2
   */
  constructor(base: number = 1000, max: number = 60000, factor: number = 2) {
    this.base = base;
    this.max = max;
    this.factor = factor;
    this.retries = 0;
  }

	/**
	 * 获取下一次重试的延迟时间.
	 */
	next() {
    const delay = Math.min(this.base * Math.pow(this.factor, this.retries), this.max);
    this.retries++;
    return delay;
  }

  /**
	 * 重置重试次数.
	 */
  reset() {
    this.retries = 0;
  }
}

值得一提的是带有 Range 标头的请求成功时会返回 206 Partial Content 状态码。而在请求的范围超出文件大小时会返回 416 Range Not Satisfiable 状态码。我们可以通过这两个状态码来判断请求是否成功。

成功时调用 reset 方法重置重试次数,失败时调用 next 方法获取下一次重试的延迟时间。

总结

即使再优秀的方案也不是银弹,真正合适的方案需要考虑的不仅仅是技术本身,还有业务场景,团队技术栈,团队技术水平等等。在选择技术方案时,我们需要权衡各种因素,而不是盲目的选择最流行的技术。

当我们责备现有的技术方案为何如此糟糕时,或许在那个时间点上,这个方案是最合适的。

相关推荐
Myli_ing13 分钟前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风15 分钟前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟24 分钟前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾1 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧1 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm1 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒2 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript