团队内使用 Sentry 监控前端线上报错,但效果一直不太理想,究其原因,主要有以下几点:
- 非故障引起的告警多,导致“狼来了”效应,研发对告警不敏感。比如,一些正常的前端校验,由于抛出了 Error 而被监控到,甚至浏览器插件的报错,也被监控到并告警;
- 通知对象不精准。由于项目很大,由几十个微前端模块组成,并由十几位前端同事分工负责,但告警是以广播的形式,发送给所有人,久而久之,大家都无视了相关告警;
- 告警信息杂乱,重点不清晰。由于 Sentry 默认的告警信息情况较多,是否能快速识别出故障,依赖研发经验。同时,另外一些指示是故障的风险因素,如模块正在灰度中、影响了 S 级用户等,从现有信息中是无法获取的。
针对这些问题,也尝试了 Sentry 自带的过滤功能,但灵活性有限。同时,由于使用的是私有部署的 Sentry,版本较低,一些新特性不支持,所以考虑以集成的方式,来解决这些问题。
Sentry 支持 Api 集成调用,同时支持 WEBHOOKS 接收告警,所以以此方式来做集成。
功能设计
结合现状,共设计四块的功能:降噪、通知、风险识别和信息聚合。
降噪,即过滤掉非实际故障的告警。比如,浏览器插件报错、正常功能的无权限报错、表单校验报错等。
通知,即优化告警的通知触达性。由于原本 Sentry 支持的是邮件方式,但邮件的通知即时性不如即时通信,公司内使用的是企业微信,所以通过企业微信的应用号来通知。同时,解析 Sentry 告警中的错误栈信息,找出关联的前端模块,并将告警定向发送给前端模块分工的负责同事。
风险识别,即根据 Sentry 告警信息,提取相关特征,来评估告警的风险等级。根据业务特点,总结了以下几点风险特征:
-
短时间内相同告警数量激增:通过计算最近 7 天、单个 Issue 的 Event 数量的相对标准差(RSD,即标准差/平均值)来衡量,根据经验,如果 RSD 大于 1.5,则说明该 Issue 最近数量有激增;
-
引发告警的相关 JS 模块的发布时间较短:提取错误栈中的前端模块,结合内部前端灰度系统,如果相关模块的发布时间小于 7 天,则符合该条风险;
-
短时间内相同告警数量激增:同样是提取前端模块,结合前端灰度系统,如果相关模块正在灰度中,则符合该条风险;
-
从报错信息看大概率是前端 Bug 的:如果报错信息中包含一些关键词,如:TypeError/RangeError/SyntaxError/ReferenceError 等,根据经验,是真实的前端故障的可能性较大,符合该条风险;
-
引发告警的相关模块是前端的重要模块:提取错误栈中的前端模块,如果涉及公共模块或重要的业务模块,则说明错误的影响程度可能较大,符合该条风险;
-
影响外部的 S/A 级用户:在我们的 Sentry 配置中,会一同上报用户 ID,在集成的程序中,根据上报的用户 ID,调用内部接口查看用户等级,如果是高级别用户,则符合该条风险;
-
多个不同 ID 的用户遇到的问题:从 Sentry 告警信息中,统计受影响的用户数量(去重),如果大于 3 位,则符合该条风险。
根据以上风险特征匹配下来的结果做统计,如果符合 5 条以上的,判定为 P0 级的告警,2-4 条的,判定为 P1 级告警,其他的为 P2 告警,从而实现对告警的分级。
信息聚合,即将有助于排障的帮助信息聚合。包括,Sentry 主要信息、相关的用户在对应时间段内的用户操作日志、风险因素提示等。
模块设计
根据功能设计,决定基于 NestJS 开发一个服务,实现 Sentry 告警的监听、过滤、信息聚合和推送,并设计以下模块:
- Watch 处理 sentry 发送的告警信息
- Events 对 events 进行处理分析
- Auth 处理 sentry、企业微信等关联系统的鉴权
- Sentry 获取 sentry 相关信息
- Gray 获取前端灰度系统的相关信息
- Matomo 获取 matomo 上用户操作日志信息
- Notification 处理消息的通知发送
- Quene 基于 Bull 消息队列
具体模块设计:
时序图:
实现的告警通知效果:
技术点
在使用 NestJS 开发过程中,发现框架预先集成了诸多设计模式和实用工具,为代码质量和可维护性带来了保障。以下总结了部分技术点,值得后续在其他前端项目中借鉴。
模块
在 NestJS 中,模块是组织代码的基本单位,也是 NestJS 的精髓。这里你可能会问,如今的 JS 不是都以 ESM 或 Commonjs 的模块规范开发了么?是的,但是光有模块还不行,还需要组织好模块间的关系。那 NestJS 是怎么做的呢?
首先,NestJS 中的模块是通过@Module装饰器定义的。由此来规定,一个模块需要包含的依赖、控制器和提供者:
import { Module } from "@nestjs/common";
import { CatsController } from "./cats.controller";
import { CatsService } from "./cats.service";
@Module({
imports: [], // 依赖的模块
controllers: [CatsController], // 控制器
providers: [CatsService], // 提供者
})
export class CatsModule {}
然后,定义一个根模块,作为应用程序的入口:
import { Module } from "@nestjs/common";
import { CatsModule } from "./cats/cats.module";
@Module({
imports: [CatsModule], // 导入其他模块
})
export class AppModule {}
模块可以通过imports属性指定需要导入的模块,也可以通过exports属性导出其提供的服务,使其他模块可以使用:
import { Module } from "@nestjs/common";
import { CommonModule } from "./common/common.module";
import { FeatureService } from "./feature.service";
@Module({
imports: [CommonModule], // 导入其他模块
providers: [FeatureService],
exports: [FeatureService], // 导出服务
})
export class FeatureModule {}
其他的话, NestJS 还支持可配置的动态模块、全局模块、模块生命周期钩子等特性,具体可以参考官方文档。
这里提一下 NestJS 使用的两种设计模式:依赖注入和单例模式。
在模块的导入中,NestJS 会通过依赖注入,将模块的依赖项做解析并注入到构造函数中:
import { Injectable } from "@nestjs/common";
@Injectable()
export class CatsService {
constructor(private readonly commonService: CommonService) {}
}
相比于 esm 直接的导入和导出,使用依赖注入模式,将模块间的依赖关系放在外部声明,而在模块内,只需要调用注入进来的实例提供的方法来实现自己的功能。这对于简单的应用来说,可能有过度设计之嫌;但对于大型应用,比如做单元测试、或者要替换依赖模块则会非常方便。
而依赖注入,底层思想就是控制反转,这对于前端开发来说,再熟悉不过了。比如在 React 中,通过 props 传递函数给子组件,子组件调用函数来改变父组件的 state,这类就是典型的控制反转。通过控制反转,更好地管理模块间的耦合部分,从而提高系统的可维护性。
另一点,关于单例模式:在 NestJS 中的模块,默认都是单例模式。当程序启动后,NestJS 会从根模块开始解析依赖关系,将模块的providers逐级实例化,并将实例缓存,在后续依赖注入中实现复用。
对于前端开发者来说,这也是非常友好的。因为基于 esm 的模块化,很自然地就会运用单例模式,比如要实现一个缓存模块:
const store = new Map();
const cache = {
set(key, value) {
store.set(key, value);
},
get(key) {
return store.get(key);
},
remove(key) {
store.delete(key);
},
clear() {
store.clear();
},
size() {
return store.size;
},
};
export default cache;
单例模式有减少内存开销、便于状态共享、保持数据同步等诸多功能。在前端开发中,随着 TS 的广泛使用,越来越多的项目里,开始使用 Class 的方式来定义模块,这本身没有问题,但要避免的是,过多没必要的对类做实例化,造成多余的开销。使用单例模式,或许会是更好的方式。
装饰器
NestJS 充分利用了 TS 的装饰器功能,通过装饰器简化了依赖注入、路由处理、请求验证等功能的实现。以下是一些常见的 NestJS 装饰器及其用途:
- @Controller():定义一个控制器类。
- @Get(), @Post(), @Put(), @Delete():定义路由处理方法。
- @Injectable():定义一个可注入的服务类。
- @Param(), @Query(), @Body():获取请求参数、查询参数和请求体。
import { Controller, Get, Post, Body, Param } from "@nestjs/common";
@Controller("cats")
export class CatsController {
@Get()
findAll(): string {
return "This action returns all cats";
}
@Get(":id")
findOne(@Param("id") id: string): string {
return `This action returns a #${id} cat`;
}
@Post()
create(@Body() createCatDto: any): string {
return "This action adds a new cat";
}
}
JS 的装饰器从最早提出到目前已经经历了十年,目前仍在 Stage 3 阶段,而由于 TS 很早就引入了装饰器,所以也导致装饰器存在多种版本。
NestJS 项目的 TS 配置里,默认开启了experimentalDecorators,也就是说,采用的装饰器是基于 Stage 1 的版本,基本等同于@babel/plugin-proposal-decorators的version: legacy版本。
装饰器本身也是一种设计模式,即装饰器模式(Decorator Pattern)。它允许在不改变对象接口的情况下,向对象添加新的功能,从而增强了代码的灵活性和可扩展性。
在前端开发中,也可以自定义装饰器来使代码更加简洁。比如,定义一个防抖的装饰器:
function debounce(delay: number): MethodDecorator {
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
let timeout = null;
descriptor.value = function (...args: any[]) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
originalMethod.apply(this, args);
}, delay);
};
return descriptor;
};
}
装饰器可以应用于类声明、方法、访问器、属性或参数上,但不能用于函数,因为存在函数提升。
RxJS
RxJS 是一个用于处理异步编程和事件流的库。它最核心的,是提供了一个可观察对象(Observables)来处理异步数据流、以及各种操作符来进行数据流的转换、过滤和组合。
关于创建 RxJS 的意义,官方文档在介绍 Observables 中,对于拉取与推送两种模式的对比一节里,已经讲的很清晰了,具体可以阅读拉取与推送。
NestJS 的项目,默认已经安装了 RxJS,并且在官方提供的、涉及异步逻辑的模块中,已经很好地集成了 RxJS。
比如,要处理 HTTP 请求:
import { Injectable } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { map } from "rxjs/operators";
import { Observable, catchError } from "rxjs";
import { AxiosError } from "axios";
@Injectable()
export class DataService {
constructor(private readonly httpService: HttpService) {}
fetchData(): Observable<any> {
return this.httpService.get("https://api.example.com/data").pipe(
map((response) => response.data),
catchError((error: AxiosError) => {
console.error(error.message);
throw "An error happened!";
})
);
}
}
在控制器中使用:
import { Controller, Get } from "@nestjs/common";
import { DataService } from "./data.service";
import { Observable, lastValueFrom } from "rxjs";
@Controller("data")
export class DataController {
constructor(private readonly dataService: DataService) {}
@Get()
async getData(): Promise<any> {
const data = await lastValueFrom(this.dataService.fetchData());
return data;
}
}
在前端项目中,我们也可以引入 RxJS,帮助简化代码的异步逻辑。比如,创建一个带搜索的列表组件,用户在输入搜索内容时,输入框的变化将通过 RxJS 管道处理,去抖动后进行 API 请求并更新结果列表。同时,借助switchMap操作符,实现在每次新查询到来时取消前一个未完成的 API 请求,避免了异步请求的竞争问题:
import React, { useState, useEffect } from "react";
import { Subject } from "rxjs";
import { debounceTime, switchMap } from "rxjs/operators";
const fetchSearchResults = (query) => {
// 模拟API请求
return new Promise((resolve) => {
setTimeout(() => {
resolve(
["Result 1", "Result 2", "Result 3"].filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
)
);
}, 500);
});
};
const SearchComponent = () => {
const [searchQuery, setSearchQuery] = useState("");
const [results, setResults] = useState([]);
const searchSubject = new Subject();
useEffect(() => {
const subscription = searchSubject
.pipe(
debounceTime(300), // 去抖动,延迟300毫秒
switchMap((query) => fetchSearchResults(query))
)
.subscribe(setResults);
return () => subscription.unsubscribe();
}, []);
const handleInputChange = (event) => {
const query = event.target.value;
setSearchQuery(query);
searchSubject.next(query);
};
return (
<div>
<input
type="text"
value={searchQuery}
onChange={handleInputChange}
placeholder="Search..."
/>
<ul>
{results.map((result, index) => (
<li key={index}>{result}</li>
))}
</ul>
</div>
);
};
export default SearchComponent;
以上是在使用 NestJS 开发实践中,个人觉得比较有价值的技术点。在我们使用各种优秀的框架时,除了享受框架给我们带来的便捷和高效外,学习其设计思路并应用于其他项目,这样能发挥框架的更大价值,也能帮助我们技术快速提升。