前言
之前有一篇文章写过 # 前端错误监控sdk初步实践,这是一个关于日志上报的 SDK。再次写文章时才发现,时间一转居然四年时间过去了!!! ,之前完成了客户端日志上报客户端的部分,今天想主要聊聊在我们上报日志以后服务端的一些处理。
日志上报流程设计
【图1】
- 日志接收:当服务端接收到来自客户端的日志请求时,系统会首先验证请求的合法性。合法的日志数据将被直接写入Redis队列中,以缓解高并发下直接写入数据库的压力。
- Redis队列缓存:
Redis
作为内存数据库,具有极高的读写性能。系统将日志数据作为消息存入Redis队列,等待后续处理。同时,Redis的持久化机制也能确保即使系统发生故障,数据也不会丢失。 - 定时任务处理:系统使用
node-schedule
模块创建一个或多个定时任务,这些任务会按照设定的时间间隔执行。当定时任务被触发时,它会从Redis队列中取出一定数量的日志数据,准备进行数据库写入操作。 - 数据库写入:通过Nest框架集成的TypeORM库,系统可以方便地操作数据库。在本项目中,我们使用TypeORM来管理SQLite3数据库,并通过
better-sqlite3
这一高性能的SQLite3实现来提供底层支持。定时任务会将Redis队列中的日志数据批量写入到SQLite3数据库中。
服务端
Nest项目搭建
接下来会说一些 Nest 框架的使用,如果你对这个比较熟悉可以跳到下一小节 Redis 消息队列
nest-cli 快速创建项目
创建项目
nest new project-name
创建 controller
nest g resource cat
【图2】
创建 service
nest g service cats
启动 debug 模式
nest start --debug --watch
配置 vscode launch.json 文件,添加 attch 模式,在启动 debug 模式后开启 attch 模式就可以在项目中设置断点了。
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach NestJS WS",
"port": 9229,
"restart": true,
},
]
}
【图3】
核心基础知识
controller
请求对象
- req.params
app.get('/users/:id', function(req, res) {
// req.params.id 就是 URL 中 `:id` 对应的值
console.log(req.params.id);
});
- req.query URL:/search?q=hello&page=2
在对应的请求处理函数中,你可以通过 req.query.q 访问到 q 参数的值 hello,通过 req.query.page 访问到 page 参数的值 2
响应对象
@Get()
getWithCustomHeader(@Res() res: Response) {
// 设置响应头
res.setHeader('X-Custom-Header', 'some value');
// 发送 json 响应,
res.json(data);
}
其他装饰器
请求对象 @Req() 可以获取整个请求对象,也可以使用其他装饰器快捷获取具体的对象值 @Param() @Query() @Ip() 等等
Provider
服务通常包含与业务逻辑相关的代码,并且可以被 Controller 控制器或其他 Service 服务使用。
- 你可以将业务逻辑写在不同的 Service 中,通过依赖注入的方式,使得其他 Controller 和 Service 可以调用。而不是直接引用,实现解耦。
- 通过依赖注入,你可以轻松地替换或修改依赖项的实现。
什么是 Provider?
Provider
是一个可以被 Nest 的依赖注入系统识别、创建和管理的对象。这些对象可以是服务(Service
)、值(Value
)、工厂(Factory
)、类(Class
)。以一个 Service 作为 Provider 为例。
- 定义 Service, 使用@Injectable()装饰器来标记一个类作为可注入的服务。这告诉NestJS框架,这个类是一个服务,并可以被其他类(如控制器、其他服务等)通过依赖注入来使用。
import { Injectable } from '@nestjs/common';
// 用于标记一个类作为可以注入到其他类中的服务。
@Injectable()
export class CatsService {
constructor() {
this.cats = [];
}
create(cat) {
this.cats.push(cat);
}
findAll() {
return this.cats;
}
}
- 在模块中注册 Provider
import { Module } from '@nestjs/common';
import { CatService } from './cat.service';
import { CatController } from './cat.controller';
@Module({
controllers: [CatController],
// 注册 Provider
providers: [CatService],
})
export class CatModule {}
- 在 Controller 构造函数中注入 Service 依赖项
export class CatController {
// 注入
constructor(private readonly catService: CatService) {}
}
依赖注入的执行流程
-
当NestJS框架启动并加载模块时,它会解析模块中定义的providers数组,并创建这些服务的实例(或获取已存在的实例)。
-
当框架实例化一个控制器或其他服务时,它会查看该类的构造函数参数,并尝试匹配已提供的服务实例。如果找到匹配的服务,它会将该实例注入到目标类的构造函数中。
Modules
@Module()装饰器提供了Nest用来组织应用程序结构的元数据。用于组织整个项目结构
如图4所示,一个应用树结构通过 Modules 组织起来,包括 providers
、controllers
、imports
和 exports
。可以使得他们之前也可以产生关联,比如 Users Module 通过 imports Orders Module 也可以注入它调用的 Service。
【图4】
使用规则如注释:
@Module({
// 包含模块中定义的控制器集合。这些控制器负责处理 HTTP 请求并返回响应。
controllers: [UserController],
// 定义了将由 NestJS 注入器实例化的提供者(通常是服务)。
// 这些提供者可以在模块内部共享,也可以被其他模块通过 `imports` 引入后使用。
providers: [UserService],
// 导入其他模块,你可以使用它们提供的 Service 服务、守卫、拦截器等。
imports: [CatModule],
// 导出本 Module 中 Service 使 UserService 可在其他模块中使用
exports: [UserService]
})
使用消息队列
图1中展示了 controller 接受到 HTTP 请求后,会将数据存入 redis 队列,然后通过定时任务中取出队列中的消息进行消费。这里就设计了 redis 的数据结构
,以及数据的存取
。
redis 环境
// 安装 redis
brew install redis
// 安装完以后,启动Redis服务
brew services start redis
// redis-cli 连接 redis
当你连接成功可以看到本地服务地址及端口号。接下来使用 nest 框架连接 reids 服务。
nest 中使用 redis
这里我们定义一个 ReidsService 服务,并在当前 Module Provider中注册,这样我们就可以在其他 Service 中对 redis 进行操作了。
接下来我们看下 RedisService 的实现:
- 首先我们在构造函数创建 reids client,这一步是使用代码的方式连接本地启动的 redis 服务。也就是跟我们上面执行
reids-cli
命令,这样后面我们可以通过 client 进行数据读写。 - client.lPush(key,value) 我们这里使用的是 redis 的 list 数据结构,它的写入方式是
lPush
。
⚠️需要注意的是,在命令行中 lpush lPush 都可以,node redis 是需要区分大小写的。
并且写入的必须是字符串,所以如果要保存一个对象是序列化为字符串。
- client.lRange(key,start, end) 读取队列中数据
import { Injectable, OnModuleInit } from '@nestjs/common';
import { createClient } from 'redis';
@Injectable()
export class RedisService implements OnModuleInit {
private readonly client: any;
// 每次写入数据库条数
count = 1000
constructor() {
this.client = createClient({
socket: {
host: 'localhost',
port: 6379
}
});
}
// onModuleInit生命周期钩子是在模块(Module)初始化完成后自动执行的
async onModuleInit() {
await this.client.connect();
console.log('Redis client connected');
}
async lPush(key:string, value: string) {
await this.client.lPush(key, JSON.stringify(value))
}
async lRange(key:string, start: number, end: number) {
return this.client.lRange(key,start, end)
}
async rPop(key: string ) {
return this.client.rPop(key)
}
// ... 其他操作
disconnect() {
this.client.disconnect();
}
}
定时任务
当我们处理好写的操作,我们接下来需要开启定时任务对数据进行读取,并写入数据库中。同样我们定义一个 ScheduleService
在模块初始化后开启定时任务
- redisService.lRange(key, 0, -1) 取出 redis 队列数据
- 开启事务 transaction 进行数据存储
- 数据存储完以后将数据从 redis 中清除
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import * as schedule from 'node-schedule';
import { EntityManager } from 'typeorm';
@Injectable()
export class ScheduleService implements OnModuleInit, OnModuleDestroy {
private job: schedule.Job | null = null;
private jobs: { [projectId: string]: schedule.Job } = {};
constructor(
private readonly entityManager: EntityManager
// ...
) { }
// 创建一个用于执行任务的函数,该函数返回一个Promise
executeTasksForProjectId = (projectId) => {
return new Promise<void>(async (resolve, reject) => {
try {
const taskDataList = await this.redisService.getTaskFromRedis(key);
if (taskDataList.length === 0) return
await this.entityManager.transaction(async transactionalEntityManager => {
for (let taskData of taskDataList) {
const data = JSON.parse(taskData);
// 在事务中处理每个任务
await this.processTaskInTransaction(transactionalEntityManager, data);
}
});
// 事务执行完,清空 taskDataList redis 数据
// ...
resolve();
} catch (error) {
console.error('Error processing task:', error);
reject(error)
}
});
}
onModuleInit() {
const projectIds = this.getProjectIds(); // 假设这里获取项目列表
// 定时任务,每10秒执行一次
schedule.scheduleJob('*/10 * * * * *', async () => {
const startTime = Date.now(); // 记录周期开始时间
// 为每个projectId创建一个Promise,并执行任务
const taskPromises = projectIds.map(projectId => {
return this.executeTasksForProjectId(projectId)
});
try {
// 等待所有任务完成
await Promise.all(taskPromises);
} catch (error) {
console.error('An error occurred while executing tasks:', error);
}
});
}
private async processTaskInTransaction(transactionalEntityManager: EntityManager, data: any) {
await this.errorService.createEvent(data, transactionalEntityManager);
}
onModuleDestroy() {
// 当模块销毁时,取消定时任务(如果需要)
if (this.job) {
this.job.cancel();
}
}
}
Typeorm
在 Node.js 中,使用 ORM(对象关系映射)来处理数据存储是一种常见做法。ORM 提供了一种更高级、更抽象的方法来与数据库进行交互,而无需直接编写复杂的 SQL 查询。它允许开发者使用 JavaScript 对象来表示数据库中的数据,并提供了一系列方法来执行常见的数据库操作,如增删改查等。
使用 Typeorm、sqlite3
Entity 创建实体类
在TypeORM中,实体类表示数据库中的表。你需要为你的数据模型创建相应的实体类。
以 Error 和 Event 表为例,Event 表负责记录每一条日志数据, Error 表则根据 name 进行分组记录其他维度的信息,便于我们在做数据分析的时候提供更多的维度。
【图5】
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm';
import { ErrorsEntity } from './Errors';
@Entity()
export class EventEntity {
// 自动创建一个主键,并且这个主键通常是自动增长
@PrimaryGeneratedColumn()
id: number;
@Column()
sdkName: string;
@Column()
sdkVersion: string;
@Column({nullable: false})
trackerId: string;
// 接口地址、错误类型:http、状态码、请求方法进行hashCode生成errorId
@Column()
errorId: number;
// 错误等级
@Column()
level: string;
@Column()
message: string;
@Column()
name: string;
@Column()
time: number; // Assuming it's a timestamp in milliseconds
@Column()
type: string;
@Column()
url: string;
// 一个 event 事件属于一个 error 错误,而一个错误类型可以对应多个 event 事件
// 一个从当前实体到 ErrorsEntity 的多对一关系。
@ManyToOne(type => ErrorsEntity, error => error.events, {
cascade: true //
})
@JoinColumn()
error: ErrorsEntity;
@Column({ type: 'json', nullable: true }) // 对于其他支持 JSON 的数据库
breadcrumb: any[]
}
表关联
在关系型数据库中,当我们谈论一对多的关系时,我们通常将“一”的表称为主表(或父表),而“多”的表称为关联表(或子表)。
我们在子表中开启 cascade,当子表 event 添加一条数据的时候也会自动变更主表数据。
Entity 定义
@Entity()
export class ErrorsEntity {
@PrimaryGeneratedColumn()
id: number;
// ... 省略其他字段
@OneToMany(type => EventEntity, event => event.error)
events: EventEntity[];
}
// 子表
@Entity()
export class EventEntity {
@PrimaryGeneratedColumn()
id: number;
// ... 省略其他字段
@ManyToOne(type => ErrorsEntity, error => error.events, {
cascade: true
})
@JoinColumn()
error: ErrorsEntity;
@Column({ type: 'json', nullable: true }) // 对于其他支持 JSON 的数据库
breadcrumb: any[]
}
创建 event 数据
async createEvent(dto, entityManager:EntityManager) {
// 获取数据 ...
// 创建 EventEntity 的实例
const enventEntity = new EventEntity();
enventEntity.sdkName = sdkName;
enventEntity.sdkVersion = sdkVersion;
enventEntity.trackerId = trackerId;
enventEntity.errorId = errorId; // 应当与 ErrorsEntity 中的 errorId 相同
enventEntity.level = level;
enventEntity.message = message;
enventEntity.name = name;
enventEntity.time = time; // 当前时间戳
enventEntity.type = type;
enventEntity.url = url;
enventEntity.breadcrumb = breadcrumb
const error = await entityManager.getRepository(ErrorsEntity).findOne({where: {name}})
if (error) {
// 直接修改 error 数据,由于开启 cascade 会自动取更新 error 表
error.eventCount++
enventEntity.error = error
} else {
// 未创建对应错误类型则创建
const errorEntity = await this.errorService.createError(dto)
enventEntity.error = errorEntity
}
await entityManager.getRepository(EventEntity).save(enventEntity)
}
开启事务
事务(Transaction)是一种确保数据完整性和一致性的重要机制。事务允许你将多个数据库操作组合成一个逻辑单元,这些操作要么全部成功执行,要么全部不执行(即回滚到事务开始之前的状态)。
通过开启事务的方式,可以保证我们数据的完整性和一致性。
this.entityManager.transaction(async transactionalEntityManager => {
for (let taskData of taskDataList) {
const data = JSON.parse(taskData);
// 在事务中处理每个任务
await this.processTaskInTransaction(transactionalEntityManager, data);
}
});
配置数据库
import { Module } from "@nestjs/common";
import { LogsModule } from "./logs/logs.module";
import { TypeOrmModule } from "@nestjs/typeorm";
import { dbConfig } from "./database/config";
import { ErrorsEntity } from "./entities/Errors";
import { EventEntity } from "./entities/Events";
@Module({
imports: [
// TypeORM 数据库连接的全局方法
TypeOrmModule.forRoot({
// 定义了数据库连接的名称
name: 'default',
// 指定了使用的数据库类型
type: "better-sqlite3",
// 这指定了 SQLite 数据库文件的路径
database:"./db/logs.db"
// 定义了这个数据库连接应该使用的实体,也就是表
entities: [ErrorsEntity, EventEntity],
// 正式环境中需要编写迁移脚本来定义和管理数据库结构的变化
synchronize: true, // 自动同步表结构,谨慎使用,开发环境使用
}),
// 日志上报模块
LogsModule
],
})
export class AppModule {}
总结
通过使用Nest.js、Redis消息队列、Node-Schedule和TypeORM,我们成功构建了一个监控平台服务端。Nest.js提供了稳定的基础框架,Redis 确保异步任务的高效处理,Node-Schedule实现定时任务调度,TypeORM简化了数据库操作。可以去我的 gitee 查看完整的代码。
参考
# 前端监控平台系列:服务端功能设计与实现
# node redis
# 快速入门 Redis 并在 Node.js 里操作它