Nest 实现监控平台服务端

590 阅读11分钟

前言

之前有一篇文章写过 # 前端错误监控sdk初步实践,这是一个关于日志上报的 SDK。再次写文章时才发现,时间一转居然四年时间过去了!!! ,之前完成了客户端日志上报客户端的部分,今天想主要聊聊在我们上报日志以后服务端的一些处理。

日志上报流程设计

日志上报.jpg

【图1】

  1. 日志接收:当服务端接收到来自客户端的日志请求时,系统会首先验证请求的合法性。合法的日志数据将被直接写入Redis队列中,以缓解高并发下直接写入数据库的压力。
  2. Redis队列缓存Redis作为内存数据库,具有极高的读写性能。系统将日志数据作为消息存入Redis队列,等待后续处理。同时,Redis的持久化机制也能确保即使系统发生故障,数据也不会丢失。
  3. 定时任务处理:系统使用node-schedule模块创建一个或多个定时任务,这些任务会按照设定的时间间隔执行。当定时任务被触发时,它会从Redis队列中取出一定数量的日志数据,准备进行数据库写入操作。
  4. 数据库写入:通过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 为例。

  1. 定义 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;
  }
}
  1. 在模块中注册 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 {}

  1. 在 Controller 构造函数中注入 Service 依赖项
export class CatController {
   // 注入 
  constructor(private readonly catService: CatService) {}
}

依赖注入的执行流程

  • 当NestJS框架启动并加载模块时,它会解析模块中定义的providers数组,并创建这些服务的实例(或获取已存在的实例)。

  • 当框架实例化一个控制器或其他服务时,它会查看该类的构造函数参数,并尝试匹配已提供的服务实例。如果找到匹配的服务,它会将该实例注入到目标类的构造函数中。

Modules

@Module()装饰器提供了Nest用来组织应用程序结构的元数据。用于组织整个项目结构

如图4所示,一个应用树结构通过 Modules 组织起来,包括 providerscontrollersimports 和 exports。可以使得他们之前也可以产生关联,比如 Users Module 通过 imports Orders Module 也可以注入它调用的 Service。 image.png

【图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 服务。

image.png

nest 中使用 redis

这里我们定义一个 ReidsService 服务,并在当前 Module Provider中注册,这样我们就可以在其他 Service 中对 redis 进行操作了。

接下来我们看下 RedisService 的实现:

  1. 首先我们在构造函数创建 reids client,这一步是使用代码的方式连接本地启动的 redis 服务。也就是跟我们上面执行 reids-cli 命令,这样后面我们可以通过 client 进行数据读写。
  2. client.lPush(key,value) 我们这里使用的是 redis 的 list 数据结构,它的写入方式是 lPush

⚠️需要注意的是,在命令行中 lpush lPush 都可以,node redis 是需要区分大小写的。
并且写入的必须是字符串,所以如果要保存一个对象是序列化为字符串。

  1. 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

在模块初始化后开启定时任务

  1. redisService.lRange(key, 0, -1) 取出 redis 队列数据
  2. 开启事务 transaction 进行数据存储
  3. 数据存储完以后将数据从 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 进行分组记录其他维度的信息,便于我们在做数据分析的时候提供更多的维度。

yuque_diagram (1).jpg

【图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 里操作它