前端日志监控系统-后端

1,390 阅读35分钟

image.png

前言

前端日志监控系统是一种用于帮助前端开发人员快速追踪和排查问题的工具,它可以收集前端应用程序中产生的各种错误、警告、崩溃等问题信息,并将这些信息汇总并展示给开发者,以便快速定位和解决问题。

本篇后端文章,将介绍使用Nest、Mongoose、Redis、@typegoose/typegoose技术架构来实现前端日志监控系统。

系列文章传送门

前端日志监控系统-上报SDK

前端日志监控系统-后端

后端架构

在此之前我们先了解下后端架构的整体功能流程: image.png

开发日志管理系统使用设计方案:

  1. 数据库设计:使用Nest框架的 Mongoose 模块和 Redis 数据库,@typegoose/typegoose 库来进行数据库设计和开发。根据需求分析,确定需要哪些数据和数据表。
  2. 模型设计:基于数据库设计,使用 Mongoose 模块和 @typegoose/typegoose 库进行模型设计,包括创建模型类、定义属性和方法等操作。
  3. API 开发:使用 Nest 框架的控制器和路由来开发 RESTful API 接口,提供增删改查等操作,API 的请求和响应都是 JSON 格式的。
  4. 日志管理模块开发:基于 API 接口,开发日志管理模块,实现对日志记录的增删改查聚合等操作,从而管理整个系统中的日志信息。
  5. 前端开发:使用 EMP基于React 框架开发前端页面,通过 API 接口调用后端数据,并进行展示和交互。
  6. 部署:可以使用 PM2 管理应用程序。

接下来我们开始详细说明Nest、Mongoose、Redis、 @typegoose/typegoose的开发步骤。

入口配置

Nest.js 应用程序中,入口文件通常称为main.ts。它是应用程序的启动点,其中包含了应用程序所需的所有配置和附加组件。

主要功能是使用NestFactory实例化应用程序,并在端口上启动它。在这里,AppModule 是应用程序的根模块,是用来收集和组织所有的组件和模块的地方。

入口文件还可以包括其他配置,例如中间件、全局拦截器、全局管道以及响应的格式化器等。这些配置可以在创建应用程序实例之后添加到应用程序中。本项目中相应的配置有以下:

  1. 静态文件: 用于存储静态文件, 
  2. 模板引擎:views 将包含模板, ejs 使用模板引擎来呈现 HTML 输出。
  3. helmet: 在 Node.js 应用程序中添加安全性头,以防止某些类型的攻击。它可以帮助防止跨站点脚本 (XSS)跨站点请求伪造 (CSRF) 等攻击
  4. compression: 可以在传输 HTTP 响应时对其进行压缩,以减少传输数据的大小。compression 可以帮助加快网络传输速度,节省带宽和降低服务器响应时间
  5. body-parser: 用于解析 HTTP 请求体将其解析成 HTTP 对象,方便在 Web 应用程序中使用。body-parser 支持多种请求体格式,如 JSON、url-encoded 格式、multipart/form-data 格式等,十分灵活,可以帮助简化从 HTTP 请求体中获取数据的操作
  6. cookie-parser: 用于处理 HTTP 请求中的 cookies,将其解析为 JavaScript 对象或字符串,方便在 Web 应用程序中使用。cookie-parser 可以帮助简化 Web 应用程序中处理 cookies 的操作,方便将 cookies 存储、读取和删除等操作
  7. morgan: 用于记录 HTTP 请求和响应的日志,方便进行调试和分析。morgan.js 提供了多种预定义格式供选择,也可以自定义日志输出格式。
  8. useGlobalFilters: 全局过滤器用于整个应用程序、每个控制器和每个路由处理程序;该方法不会为网关和混合应用程序设置过滤器。该项目是用于接口、页面、权限等异常拦截进行处理。
  9. useGlobalInterceptors: 全局拦截器用于整个应用程序、每个控制器和每个路由处理程序;用于接口、路由、接口错误、请求时间等拦截处理。
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { getServerIp } from '@app/utils/util';
import { join, resolve } from 'path';
import { isDevEnv } from '@app/app.env';
import { Request } from 'express';
import { get } from 'lodash'

import { COOKIE_KEY, APP } from '@app/config';
import { AppModule } from '@app/app.module';
import { LoggingInterceptor } from '@app/interceptors/logging.interceptor';
import { ErrorInterceptor } from '@app/interceptors/error.interceptor';
import { TransformInterceptor } from '@app/interceptors/transform.interceptor';
import { HttpExceptionFilter } from '@app/filters/error.filter';

import logger from '@app/utils/logger';
import bodyParser from 'body-parser'
import compression from 'compression'
import cookieParser from 'cookie-parser'
import morgan from 'morgan'
import helmet from 'helmet'
import ejs from 'ejs'

async function bootstrap() {
    const app = await NestFactory.create<NestExpressApplication>(AppModule, {
        rawBody: true
    });

    app.useStaticAssets(resolve(__dirname, '../../dist/client'))

    // 这里是单页应用
    // app.setBaseViewsDir(join(__dirname, '../..', 'views'));
    app.setBaseViewsDir(join(__dirname, '../../dist/views'));
    app.setViewEngine('html');
    app.engine('html', ejs.renderFile);

    app.use(helmet())
    app.use(compression())
    app.use(bodyParser.json({ limit: '1mb' }))
    // app.use(rateLimit({ max: 1000, windowMs: 15 * 60 * 1000 }))
    app.use(cookieParser(COOKIE_KEY))

    morgan.token('userId', (req: Request) => {
        return get(req, 'cookies.userId') || get(req, 'session.user.userId') || ''
    })

    // ":req[cookie]" 获取cookie 
    app.use(morgan(':remote-addr - [:userId] - :remote-user ":method :url HTTP/:http-version" ":referrer" ":user-agent" :status :res[content-length] - :response-time ms'))

    app.useGlobalFilters(new HttpExceptionFilter()) // 这里可以分为路由异常过滤器和API异常过滤器
    app.useGlobalInterceptors(new TransformInterceptor(), new LoggingInterceptor(), new ErrorInterceptor())

    await app.listen(APP.PORT);

    if (isDevEnv) {
        logger.info(`Application is running on: http://${getServerIp()}:${APP.PORT}`);
    }
}

bootstrap();

app.module.ts 是 Nest.js 中的根模块,其中定义了应用程序运行所需的模块和组件。

  1. imports: 表示当前 Module 依赖的其他 Module 列表。
  2. controllers: 可以定义所需的控制器的数组;控制器中的方法可以处理特定的 HTTP 请求,包括 GET、POST、PUT、DELETE和其他 HTTP 方法
  3. providers: 模块提供的提供者;提供者是一个可注入的对象,它可以是任何类、常量、工厂函数或提供自定义提供者的字符串标记。
  4. exports: 由本模块提供并应在其他模块中可用的提供者的子集。

中间件不能在 @Module() 装饰器中列出。我们必须使用模块类的 configure() 方法来设置它们。包含中间件的模块必须实现 NestModule 接口。我们将相应的中间件配置到这里。在配置中间件时将包含路由路径的对象和请求方法传递给forRoutes()方法

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'

// connect
import { DatabaseModule } from '@app/processors/database/database.module';

// redis and session
import { RedisModule } from '@app/processors/redis/redis.module';
import { RedisServer } from '@app/processors/redis/redis.server';
import { AppController } from './app.controller';
import { SESSION } from '@app/config';
import RedisStore from 'connect-redis';
import session from 'express-session';

// middlewares
import { CorsMiddleware } from '@app/middlewares/core.middleware';
import { OriginMiddleware } from '@app/middlewares/origin.middleware';
import { DevMiddleware } from '@app/middlewares/dev.middleware';

// API and Auth and Router and Model
import modules from '@app/modules/index';

// Pipe
import { APP_GUARD, APP_PIPE } from '@nestjs/core';
import { ValidationPipe } from './pipes/validation.pipe';

// helper
import { HelperModule } from './processors/helper/helper.module';

@Module({
    imports: [
        // 用于在根模块中启用限速器;根据 IP 地址、用户 ID 或 Cookie 值等标识符来限制 API 请求
        ThrottlerModule.forRoot({
            ttl: 60 * 5, // 5 minutes 限制时间周期为5分钟
            limit: 300, // 300 limit 每个IP限制为300次请求
        }),
        DatabaseModule,
        RedisModule,
        HelperModule,

        ...modules
    ],
    controllers: [AppController],
    providers: [
        {
            provide: APP_GUARD,
            useClass: ThrottlerGuard,
        },
        {
            provide: APP_PIPE,
            useClass: ValidationPipe
        }
    ]
})
export class AppModule implements NestModule {
    private redis: any

    constructor(private readonly redisStore: RedisServer) {
        this.redis = this.redisStore.cacheStore.client
    }
    configure(consumer: MiddlewareConsumer) {
        consumer
            .apply(
                CorsMiddleware,
                OriginMiddleware,
                session({
                    store: new (RedisStore(session))({ client: this.redis }),
                    ...SESSION
                }),
                DevMiddleware
            )
            .forRoutes('*');
    }
}

以上就是所有模块的起点,是整个应用程序的核心。

Mongoose 连接

使用 connect() 函数建立与数据库的连接。connect() 函数返回一个 Promise,因此必须创建一个异步提供者。

  1. provide: 提供商是一个对象.将字符串值令牌(DB_CONNECTION_TOKEN)与从外部文件导入的已存在的连接对象相关联
  2. useFactory: 语法允许动态创建提供程序
  3. inject: 属性指定了工厂函数依赖的提供者
import { config } from '@app/config'
import { connection, disconnect, connect, set } from 'mongoose'
import { DB_CONNECTION_TOKEN } from '@app/constants/system.constant'
import logger from '@app/utils/logger';

export const databaseProviders = [
    {
        provide: DB_CONNECTION_TOKEN,
        useFactory: async () => {
            let reconnectionTask: any = null;
            const RECONNECT_INTERVAL = 6000
            set('strictQuery', true)
            function dbConnect() {
                return connect(config.dbUrl)
            }

            connection.on('disconnected', () => {
                logger.error(`数据库失去连接!尝试 ${RECONNECT_INTERVAL / 1000}s 后重连`)
                reconnectionTask = setTimeout(dbConnect, RECONNECT_INTERVAL)
            })

            connection.on('open', () => {
                logger.info('mongodb数据库连接成功')
                clearTimeout(reconnectionTask)
                reconnectionTask = null
            })

            connection.on('error', (error) => {
                logger.error('数据库连接异常', error)
                disconnect();
            })

            return dbConnect();
        },
    }
]

以上就是mongoose连接,同时监听了disconnectedopenerror等事件。

import { Connection as MongodbConnection } from 'mongoose'
import { Inject, Provider } from '@nestjs/common'
import { REPOSITORY, DB_CONNECTION_TOKEN } from '@app/constants/system.constant';
import { getModelForClass } from '@typegoose/typegoose';

export interface TypeClass {
    new(...args: [])
}

// provider名称
export function getModelName(name: string): string {
    return name.toLocaleUpperCase() + REPOSITORY
}

// mongoose 工厂提供者
export function getProviderByTypegoose(typegooseClass: TypeClass): Provider {
    return {
        provide: getModelName(typegooseClass.name),
        useFactory: (connection: MongodbConnection) => {
            return getModelForClass(typegooseClass, { existingConnection: connection })
        },
        inject: [DB_CONNECTION_TOKEN]
    }
}

// Model 注入器
export function InjectModel(model: TypeClass) {
    return Inject(getModelName(model.name))
}

现在可以使用 @Inject() 装饰器注入 Connection 对象。依赖于 Connection 异步提供者的每个类都将等待 Promise 被解析。

Redis 连接

Nest为各种缓存存储提供程序提供了统一的 API,本项目中使用Redis,为了启用缓存,首先导入 CacheModule 并调用它的 register() 方法。

通过 useClass 选项可以指定一个类,该类将被注入到 CacheModule 供其使用。在 RedisConfigServer 类中我们需要提供一个异步的工厂方法 createCacheConfig(),该方法将返回一个包含 Redis 配置选项的 JavaScript 对象。RedisConfigServer 类的构造函数应当接收需要的依赖项,在项目中传递了类本身,用于注入。

  1. useClass:用于指定依赖项和工厂方法的类
  2. inject:用于指定要注入的类或提供者
import { CacheModule as NestCacheModule, Global, Module } from '@nestjs/common'
import { RedisConfigServer } from './redis.config.server';
import { RedisServer } from './redis.server';

/**
 * Redis
 * @export
 * @class RedisModule
 */
@Global()
@Module({
    imports: [
        NestCacheModule.registerAsync({
            useClass: RedisConfigServer,
            inject: [RedisConfigServer]
        })
    ],
    providers: [RedisConfigServer, RedisServer],
    exports: [RedisServer]
})

export class RedisModule { }

RedisConfigService 类使用了 ConfigService 来获取 Redis 的主机和端口配置,然后将这些选项合并到一个 Redis 配置选项的对象中并返回。最终 createCacheConfig() 方法会被 CacheModule 调用,返回的 Redis 配置选项会被用于缓存服务。

import { REDIS } from '@app/config'
import logger from '@app/utils/logger'
import { CacheModuleOptions, CacheOptionsFactory, Injectable } from '@nestjs/common'
import redisStore, { RedisStoreOptions } from './redis.store'

@Injectable()
export class RedisConfigServer implements CacheOptionsFactory {
    // 重试策略
    private retryStrategy(retries: number): number | Error {
        const errorMessage = ['[Redis]', `retryStrategy!retries: ${retries}`]
        logger.error(...(errorMessage as [any]))
        // 这里可以加上告警
        if (retries > 6) {
            return new Error('[Redis] 尝试次数已达极限!')
        }
        return Math.min(retries * 1000, 3000)
    }

    public createCacheOptions(): CacheModuleOptions<Record<string, any>> | Promise<CacheModuleOptions<Record<string, any>>> {
        const redisOptions: RedisStoreOptions = {
            host: REDIS.host as string,
            port: REDIS.port as number,
            retry_strategy: this.retryStrategy.bind(this),
        }
        if (REDIS.password) {
            redisOptions.password = REDIS.password
        }
        return {
            isGlobal: true,
            store: redisStore,
            redisOptions,
        }
    }
}

构造函数中使用了 Dependency Injection(依赖注入) 的方式注入了 Cache 对象,然后将其转换为 RedisCacheStore 对象并赋值到 cacheStore 属性中。

在构造函数中,监听了一些 Redis 客户端事件,例如连接成功、重新连接、准备就绪等事件,同时在事件处理函数中打印了一些日志信息,以便于在程序运行时进行诊断和调试。

@Injectable()
export class RedisServer {
    public cacheStore!: RedisCacheStore
    private isReadied = false

    constructor(@Inject(CACHE_MANAGER) cacheManager: Cache) {
        this.cacheStore = cacheManager.store as RedisCacheStore
        this.cacheStore.client.on('connect', () => {
            logger.info('[Redis]', 'connecting...')
        })
        this.cacheStore.client.on('reconnecting', () => {
            logger.warn('[Redis]', 'reconnecting...')
        })
        this.cacheStore.client.on('ready', () => {
            this.isReadied = true
            logger.info('[Redis]', 'readied!')
        })
        this.cacheStore.client.on('end', () => {
            this.isReadied = false
            logger.error('[Redis]', 'Client End!')
        })
        this.cacheStore.client.on('error', (error) => {
            this.isReadied = false
            logger.error('[Redis]', `Client Error!`, error.message)
        })
    }
}

模块

模块包含:控制器服务提供者等依赖对象。这些依赖对象逐渐形成组件树,并通过依赖注入到根模块中。

用户模块

用户模块用于创建的用户和用户相关操作以及身份验证授权验证的服务和控制器。

用户模型

  1. @typegoose/auto-increment: 插件来为一个名为userId的字段自动生成唯一标识符的Mongoose模型定义。
  • incrementBy: 表示每次递增的数量,这里设定为1。
  • startAt: 表示计数从哪个数字开始,这里设定为1000000000。
  • trackerCollection: 表示要在哪个MongoDB集合中存储计数器的信息。
  • trackerModelName: 指定用于生成该集合的模型名称。
  1. mongoose-paginate-v2: 通过将查询结果分为多个页面,以提高查询效率和响应速度
  2. modelOptions:参数提供了 Mongoose 模式选项的配置。schemaOptions.toObject 对属性进行转换时启用 getters,timestamps 的配置用于在模型创建和更新时自动更新 create_atupdate_at 字段
import { getProviderByTypegoose } from "@app/transformers/model.transform"
import { AutoIncrementID } from "@typegoose/auto-increment"
import { modelOptions, plugin, prop } from "@typegoose/typegoose"
import { IsDefined, IsNumberString, IsOptional, IsString, IsNumber } from "class-validator"
import paginate from 'mongoose-paginate-v2';

@plugin(AutoIncrementID, {
    field: 'userId',
    incrementBy: 1,
    startAt: 1000000000,
    trackerCollection: 'identitycounters',
    trackerModelName: 'identitycounter',
})
@plugin(paginate)
@modelOptions({
    schemaOptions: {
        toObject: { getters: true },
        timestamps: {
            createdAt: 'create_at',
            updatedAt: 'update_at',
        },
    }
})
export class Auth {

    @prop({ unique: true }) // 设置唯一索引
    userId: number

    @IsString({ message: "what's your account?" })
    @IsDefined()
    @prop({ required: true })
    account: string

    @IsString()
    @IsOptional()
    @prop({ default: '' })
    avatar: string

    @IsString()
    @prop({ type: String, select: false })
    password: string

    @IsNumber()
    @IsNumberString()
    @IsOptional()
    @prop({ type: Number, default: 0 })
    role: number

    @prop({ default: Date.now, index: true, immutable: true })
    create_at?: Date

    @prop({ default: Date.now })
    update_at?: Date

}
export const AuthProvider = getProviderByTypegoose(Auth)

使用了基于 TypeScriptODM 框架 Typegoose 来定义 MongoDB 数据库中的 Auth 模型。注册 Auth 模型的 Typegoose SchemaNest.jsDI 容器中。getProviderByTypegoose 是一个工具函数,它会利用 TypegoosegetModelForClass 方法将 Auth 类转化为 mongoose 模型,并返回一个提供器对象,这个对象可以在 Nest.js 的模块中使用 @Inject() 装饰器进行依赖注入。

用户服务

AuthService类主要用于用户认证和授权等操作。其中包含的方法有:

  • creatToken: 生成JWT Token令牌;
  • validateUser: 验证用户是否存在;
  • getFindUserId: 根据userId查找用户信息;
  • login: 用户登录验证;
  • findById: 根据ID查询用户信息;
  • createUser: 创建新的账号并存储在数据库中

该类通过@InjectModel装饰器注入了MongooseModel,用于操作MongoDB数据库的Auth文档模型。此外,它还使用了@nestjs/jwt中的JwtService,用于生成JWT令牌

import { Injectable } from "@nestjs/common";
import { AUTH } from "@app/config";
import { JwtService } from '@nestjs/jwt'
import { AuthDTO } from "./auth.dto";
import { InjectModel } from "@app/transformers/model.transform";
import { MongooseID, MongooseModel } from "@app/interfaces/mongoose.interface";
import { decodeBase64, decodeMd5 } from "@app/utils/util";
import { Auth } from "./auth.model";
import { LoginInfo, TokenInfo } from "./auth.interface";
import { UserInfo } from "@app/decorators/params.decorator";

@Injectable()
export class AuthService {
    constructor(
        @InjectModel(Auth) private authModel: MongooseModel<Auth>,
        private readonly jwtService: JwtService
    ) { }

    /**
     * 生成token
     * @param {*} data
     * @return {*}  {TokenInfo}
     * @memberof AuthService
     */
    creatToken(data): TokenInfo {
        const token = {
            access_token: this.jwtService.sign({ data }),
            expires_in: AUTH.expiresIn as number,
        }
        return token
    }

    /**
     * 验证用户
     * @param {UserInfo} { userId }
     * @return {*} 
     * @memberof AuthService
     */
    public async validateUser({ userId }: UserInfo) {
        return await this.getFindUserId(userId);
    }

    /**
     * 根据userId 查找用户信息
     * @param {number} userId
     * @return {*} 
     * @memberof AuthService
     */
    public async getFindUserId(userId: number) {
        return await this.authModel.findOne({ userId: userId }).exec()
    }

    /**
     * login
     * @param {AuthDTO} auth
     * @return {*}  {Promise<LoginInfo>}
     * @memberof AuthService
     */
    public async login(auth: AuthDTO): Promise<LoginInfo> {
        const existAuth = await this.authModel.findOne({ account: auth.account }, '+password')
        const password = decodeMd5(decodeBase64(auth.password))
        if (existAuth?.password !== password) {
            throw 'account error'
        }
        const token = this.creatToken({ account: existAuth.account, userId: existAuth.userId })
        return { ...token, account: existAuth.account, userId: existAuth.userId }
    }

    /**
     * 根据ID查询用户
     * @param {MongooseID} id
     * @return {*}  {(Promise<Auth | null>)}
     * @memberof AuthService
     */
    public async findById(id: MongooseID): Promise<Auth | null> {
        const userInfo = await this.authModel.findById(id)
        return userInfo
    }

    /**
     * 新建账号
     * @param {AuthDTO} auth
     * @return {*} 
     * @memberof AuthService
     */
    public async createUser(auth: AuthDTO) {
        const newPassword = decodeMd5(decodeBase64(auth.password))
        const existedAuth = await this.authModel.findOne({ account: auth.account }).exec()
        if (existedAuth) {
            throw '账户已存在'
        }
        return await this.authModel.create({ account: auth.account, password: newPassword })
    }
}

用户控制器

AuthController 目前只有处理了三个路由接口:

  • /api/login:用于登录,不需要鉴权,接收一个 POST 请求,发送一个带有 JWT Token 和用户信息的响应,并将 JWT Token 存储在 cookie 中。
  • /api/user:获取用户信息,需要鉴权,接收一个 GET 请求,发送一个带有用户信息的响应。
  • /api/createUser:创建用户,需要鉴权,接收一个 POST 请求,发送一个创建完成的响应。
import { Body, Controller, Get, Param, Post, Req, Res, UseGuards } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { Request, Response } from 'express'
import { ResponseStatus } from "@app/interfaces/response.interface";
import { Responsor } from "@app/decorators/responsor.decorator";
import { ApiGuard } from "@app/guards/api.guard";
import { AuthDTO } from "./auth.dto";

@Controller('api')
export class AuthController {

    constructor(
        private readonly authService: AuthService
    ) { }

    /**
     * 登录接口 不需要鉴权
     * @param {Request} req
     * @param {HttpRequest} data
     * @param {Response} res
     * @return {*} 
     * @memberof AuthController
     */
    @Post('login')
    @Responsor.api()
    @Responsor.handle('登录')
    public async adminLogin(@Req() req: Request, @Body() data: AuthDTO, @Res() res: Response) {
        const { access_token, ...result } = await this.authService.login(data)
        res.cookie('jwt', access_token);
        res.cookie('userId', result.userId);
        req.session.user = result;
        return res.status(200).send({
            result: result,
            status: ResponseStatus.Success,
            message: '登录成功',
        })
    }

    /** 
     * 获取用户信息
     * @param {string} id
     * @return {*} 
     * @memberof AuthController
     */
    @Get('user')
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.handle('获取用户信息')
    public async getUserInfo(@Param('id') id: number) {
        return await this.authService.getFindUserId(id)
    }

    /**
     * create account
     * @param {AuthDTO} auth
     * @return {*} 
     * @memberof AuthController
     */
    @Post('createUser')
    @UseGuards(ApiGuard) // 新建必须要有权限,不提供对外
    @Responsor.api()
    @Responsor.handle('create account')
    public async create(@Body() auth: AuthDTO) {
        return this.authService.createUser(auth)
    }
}

以上每个接口都被一个装饰器修饰,@Responsor.api() 是一个自定义装饰器,用于处理响应的格式,并且包含必要的返回状态码和数据信息。@UseGuards(ApiGuard) 是一个鉴权装饰器,用于限制路由接口只能被有对应权限的用户所访问。

JWT认证

JwtStrategy 是一个 Passport 策略类,用于基于 JSON Web Token (JWT) 的身份验证。该类通过继承 PassportStrategy 类和 Strategy 类来进行实现

在构造函数中,使用 super() 方法来调用 Strategy 类的构造函数,并传递一个对象作为参数,其中对象包括两个属性:

  1. jwtFromRequest 方法用于从 HTTP 请求中获取 JWT Token。本项目中是通过获取 Cookie 来从 HTTP 请求中检索 JWT Token
  2. secretOrKey 属性用于生成 JWT Token 的密钥。

validate() 方法用于验证 JWT Token中的用户信息。通过调用 AuthService 类中的 validateUser() 方法,验证传入的用户信息并返回结果。

import { AUTH } from "@app/config";
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from '@nestjs/passport'
import { Strategy } from 'passport-jwt'
import { AuthService } from "./auth.service";
import { Request } from 'express'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(private readonly authService: AuthService) {
        super({
            jwtFromRequest: (req: Request) => { // cookie 获取token
                return req.cookies['jwt']
            },
            secretOrKey: AUTH.jwtTokenSecret
        })
    }

    /**
     * 验证用户
     * @param {*} payload
     * @return {*} 
     * @memberof JwtStrategy
     */
    async validate(payload: any) {
        const res = await this.authService.validateUser(payload.data);
        return res
    }
}

注册 Auth 模块

该模块导入了两个外部的模块:PassportModuleJwtModulePassportModule 是一个用于身份验证的,JwtModule 是一个用于创建和验证 JSON Web Token(JWT)。

该模块还导入了三个文件:AuthControllerAuthServiceJwtStrategyAuthControllerAuthService 是用于处理身份验证的控制器和业务逻辑,JwtStrategy 是用于验证和解码 JWT 的策略。AuthProviderAuth模型提供者,使用 @Inject() 装饰器进行依赖注入。

最后,该模块还导出了一个 AuthService 服务类,以便其他模块可以使用它进行身份验证。

import { Module } from "@nestjs/common";
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt'
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { AUTH } from "@app/config";
import { AuthProvider } from "./auth.model";

@Module({
    imports: [
        PassportModule.register({ defaultStrategy: 'jwt' }),
        JwtModule.register({
            privateKey: AUTH.jwtTokenSecret,
            signOptions: {
                expiresIn: AUTH.expiresIn as number,
            },
        })
    ],
    controllers: [AuthController],
    providers: [AuthProvider, AuthService, JwtStrategy],
    exports: [AuthService]

})
export class AuthModule { }

路由模块

Nest.js是一个支持服务器端渲染(SSR,Server-side rendering),且可以结合使用不同的模板引擎。在使用React框架的前提下,通常会使用Webpack等工具将React代码打包为静态文件,然后通过服务器端将这些静态文件渲染为HTML页面。

在本项目中使用EMP2+Nest来构建SSR,具体说明请看这篇文章:Nest + Emp2 构建BFF层

路由控制器

import { Controller, Get, Render, Req, UseGuards, Header } from '@nestjs/common';
import { RouterGuard } from '@app/guards/router.guard';
import { Request } from 'express'
import { SessionPipe } from '@app/pipes/session.pipe';
import { QueryParams } from '@app/decorators/params.decorator';
import { RouterSercive } from './router.service';

@Controller()
export class RouterController {
    constructor(
        private readonly routeService: RouterSercive
    ) { }
    /**
   * 渲染页面
   * @param {Request} req
   * @return {*} 
   * @memberof AppController
   */
    @Get('login')
    @Header('content-type', 'text/html')
    @Render('index')
    login(@QueryParams('request', new SessionPipe()) req: Request) {
        if (req.isLogin) {
            // 重定向
            return { redirectUrl: '/site' }
        } else {
            return { data: 121212 }
        }
    }

    /**
     * 错误页面
     * @return {*} 
     * @memberof AppController
     */
    @Get('error')
    @Header('content-type', 'text/html')
    @Render('error')
    getError() {
        return { msg: '1212' }
    }

    /**
     * 门户页面
     * @param {Request} req
     * @return {*} 
     * @memberof AppController
     */
    @Get()
    @UseGuards(RouterGuard)
    @Header('content-type', 'text/html')
    @Render('index')
    homePage(@Req() req: Request) {
        const data = this.routeService.getCommonData(req)
        return { ...data }
    }

    /**
     * 站点页面
     * @param {Request} req
     * @return {*} 
     * @memberof AppController
     */

    @Get('site')
    @UseGuards(RouterGuard)
    @Header('content-type', 'text/html')
    @Render('index')
    sitePage(@Req() req: Request) {
        const data = this.routeService.getCommonData(req)
        return { ...data }
    }

    /**
     * 通用页面渲染,需要验证siteId 是否存在不然后续获取数据是全部的
     * @param {Request} req
     * @return {*} 
     * @memberof AppController
     */
    @Get("/:siteId/*")
    @UseGuards(RouterGuard)
    @Header('content-type', 'text/html')
    @Render('index')
    logPage(@Req() req: Request) {
        const data = this.routeService.getCommonData(req)
        return { ...data }
    }

    /**
     * 通用页面渲染
     * @param {Request} req
     * @return {*} 
     * @memberof AppController
     */
    @Get("*")
    @UseGuards(RouterGuard)
    @Header('content-type', 'text/html')
    @Render('index')
    allPage(@Req() req: Request) {
        const data = this.routeService.getCommonData(req)
        return { ...data }
    }
}

以上是一个路由控制RouterController类,里面包含了多个路由处理方法,每个方法都对应不同的路由 URL。

  • login: 方法处理 /login 路由,使用 @Get 注解表示 HTTP GET 请求,@Header 注解表示在响应头中加入 content-type 属性,值为 text/html@Render 注解表示渲染对应的 ejs 模板视图并返回给客户端。同时,该方法通过 @QueryParams 注解获取参数 request
  • homePage: 方法处理 / 路由,使用 @UseGuards(RouterGuard) 注解告诉 NestJS 在进入该方法之前必须通过 RouterGuard 类型的路由守卫,@Req 注解用于获取 Request 对象,调用 routeService.getCommonData() 方法获取一些公共的数据返回给客户端,并渲染对应的视图。sitePagelogPageallPage方法处理逻辑一样,只是针对不同路由而已。

login路由中我们使用了管道(Pipe),而它常用于处理请求时中间进行预处理、转化或验证数据。

SessionPipe 它主要是进行 session 解析,检查当前请求中是否有用户的登录信息,并将 isLogin 标志设置为 truefalse

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { get } from 'lodash';
import { Request } from 'express'
import { User } from '@app/interfaces/request.interface';

/**
 * session 解析
 * @export
 * @class SessionPipe
 * @implements {PipeTransform<IRequest, IRequest>}
 */
@Injectable()
export class SessionPipe implements PipeTransform<Request, Request> {
    transform(req: Request, metadata: ArgumentMetadata): Request {
        const user = get(req, 'session.user') as User
        req.isLogin = !!user?.userId
        return req
    }
}

路由服务

RouterSercive用于处理路由下各种数据。目前只是简单实现,后续会引用各个服务来获取缓存数据。

import { TransportCategory } from "@app/constants/enum.contant";
import { Injectable } from "@nestjs/common";
import { Request } from 'express'
import { CategoryItem, SitePageInfo } from "./router.interface";

/**
 * 处理路由下各种数据
 * @export
 * @class RouterSercive
 */
@Injectable()
export class RouterSercive {
    constructor() { }
    private categoryList: Array<CategoryItem> = [
        {
            value: TransportCategory.API,
            label: '接口上报'
        },
        {
            value: TransportCategory.CUSTOM,
            label: '自定义上报'
        },
        {
            value: TransportCategory.ERROR,
            label: '错误上报'
        },
        {
            value: TransportCategory.EVENT,
            label: '埋点上报'
        },
        {
            value: TransportCategory.PREF,
            label: '性能上报'
        },
        {
            value: TransportCategory.PV,
            label: 'PV上报'
        },
    ]

    public getCommonData(req: Request): SitePageInfo {
        return {
            categoryList: this.categoryList,
            userInfo: req.session?.user

        }
    }
}

image.png

当访问页面时,ejs模板引擎会自动将页面所需数据注入到window.INIT_DATA对象中。结合Node的缓存和高效查询,可以快速地呈现页面信息,有效提升性能。

Site(站点)模块

为了日志监控系统支持多站点,可以根据不同应用程序的需求来进行配置和管理定义一个站点模块。

Site模型

Site模型表示一个具有名称、发布状态、上报URL、是否上报API告警等属性,可以自己需求添加相应配置。该模型使用Typegoose库中的装饰器定义,该库提供了一个TypeScript接口,用于在Node.js中处理Mongoose模型。

import { getProviderByTypegoose } from "@app/transformers/model.transform";
import { prop, plugin, modelOptions, index } from '@typegoose/typegoose'
import { AutoIncrementID } from '@typegoose/auto-increment'
import paginate from 'mongoose-paginate-v2';
import {
    IsString,
    IsNotEmpty,
    IsIn,
    IsInt,
    IsDefined,
    IsUrl,
} from 'class-validator'
import { PublishState, ReportStatus } from "@app/constants/enum.contant";

export const SITE_PUBLISH_STATES = [PublishState.Draft, PublishState.Published, PublishState.Recycle] as const
export const SITE_REPOST_STATES = [ReportStatus.NotReport, ReportStatus.Report] as const

@index({ name: 'text', reportUrl: 'text' })
@plugin(AutoIncrementID, {
    field: 'id',
    incrementBy: 1,
    startAt: 1000000000,
    trackerCollection: 'identitycounters',
    trackerModelName: 'identitycounter',
})
@plugin(paginate)
@modelOptions({
    schemaOptions: {
        toObject: { getters: true },
        timestamps: {
            createdAt: 'create_at',
            updatedAt: 'update_at',
        },
    }
})
export class Site {

    @prop({ unique: true })
    id?: number

    @IsDefined()
    @IsString()
    @IsNotEmpty({ message: '站点名称不能为空?' })
    @prop({ required: true, validate: /\S+/, text: true, index: true }) // 添加索引
    name: string

    @IsDefined()
    @IsIn(SITE_REPOST_STATES)
    @IsInt()
    @prop({ enum: ReportStatus, default: ReportStatus.Report })
    isApi: ReportStatus

    @IsDefined()
    @IsString()
    @IsNotEmpty({ message: '上报告警接口不能为空?' })
    @IsUrl()
    @prop({ required: true, validate: /\S+/, text: true })
    reportUrl: string

    @IsDefined()
    @IsIn(SITE_PUBLISH_STATES)
    @IsInt()
    @prop({ enum: PublishState, default: PublishState.Published, index: true })
    state: PublishState

    @prop({ default: Date.now, index: true, immutable: true })
    create_at?: Date

    @prop({ default: Date.now })
    update_at?: Date
}

export const SiteProvider = getProviderByTypegoose(Site)

Site模型具有多个属性,这些属性使用装饰器进行了注释,指定了其验证和持久化选项。在模型中使用了一些装饰器

  • @prop:此装饰器用于在模型中定义属性。它可以用于指定验证规则,如requiredvalidateenumtext
  • @plugin:此装饰器用于向模型模式添加插件。在本例中,AutoIncrementIDpaginate插件已添加到模式中。
  • @modelOptions:此装饰器用于设置模型模式的选项。在本项目中,指定了选项,如toObjecttimestampsschemaOptions

Site 控制器

站点管理模块的控制器,用于Site的增删查改等功能开发。

import { Body, Controller, Post, Get, UseGuards, Query, Delete, Put } from "@nestjs/common";
import { Site } from "./site.model";
import { SiteService } from "./site.service";
import { Throttle } from '@nestjs/throttler'
import { Responsor } from "@app/decorators/responsor.decorator";
import { ApiGuard } from "@app/guards/api.guard";
import { SitePaginateDTO } from "./site.dto";
import { PaginateOptions, PaginateResult } from "mongoose";
import { PaginateQuery } from "@app/interfaces/paginate.interface";
import { QueryParams, QueryParamsResult } from "@app/decorators/params.decorator";
import lodash from 'lodash'

@Controller('/api/site')
export class SiteController {
    constructor(
        private readonly siteService: SiteService,
    ) { }

    /**
     * 创建站点
     * @param {Site} data
     * @return {*}  {Promise<Site>}
     * @memberof SiteController
     */
    @Post()
    @Throttle(6, 30)
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.handle('创建站点')
    createSite(@Body() data: Site): Promise<Site> {
        return this.siteService.createSite(data)
    }

    /**
     * 获取所有站点
     * @param {SitePaginateDTO} query
     * @return {*} 
     * @memberof SiteController
     */
    @Get()
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.paginate()
    @Responsor.handle('获取站点列表')
    getSites(@Query() query: SitePaginateDTO): Promise<PaginateResult<Site>> {
        const { page, size, sort, ...filters } = query
        const paginateQuery: PaginateQuery<Site> = {}
        const paginateOptions: PaginateOptions = { page, limit: size }
        if (!lodash.isUndefined(sort)) {
            paginateOptions.sort = { _id: sort }
        }
        if (!!filters.kw) {
            const trimmed = lodash.trim(filters.kw)
            const keywordRegExp = new RegExp(trimmed, 'i')
            paginateQuery.$or = [{ name: keywordRegExp }]
        }
        paginateOptions.select = '-__v'
        return this.siteService.paginate(paginateQuery, paginateOptions)
    }

    /**
     * 根据ID删除
     * @param {QueryParamsResult} { params }
     * @return {*} 
     * @memberof SiteController
     */
    @Delete(':id')
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.handle('删除站点')
    deleteSiteId(@QueryParams() { params }: QueryParamsResult) {
        return this.siteService.deleteId(params.id)
    }

    /**
     * 更新站点
     * @param {QueryParamsResult} { params }
     * @param {Site} site
     * @return {*} 
     * @memberof SiteController
     */
    @Put(':id')
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.handle('更新站点')
    putSite(@QueryParams() { params }: QueryParamsResult, @Body() site: Site) {
        return this.siteService.update(params.id, site)
    }

}
  1. createSite() 方法用于创建一个新的站点;
  2. getSites() 方法用于获取所有站点的列表,并且支持分页、排序和关键字搜索等功能;
  3. deleteSiteId() 方法用于根据站点的 ID 删除指定的站点;
  4. putSite() 方法用于根据站点的 ID 更新指定的站点。

image.png

Site 服务

SiteService。它包括一个构造函数,使用 @InjectModel 注入了各种服务和一个 Mongoose 模型(Site)。该服务提供了用于在 MongoDB 数据库中管理 Site 文档的 CRUD 操作。

import { MongooseDoc, MongooseID, MongooseModel } from "@app/interfaces/mongoose.interface";
import { PaginateQuery } from "@app/interfaces/paginate.interface";
import { InjectModel } from "@app/transformers/model.transform";
import { Injectable } from "@nestjs/common";
import { PaginateOptions, PaginateResult } from "mongoose";
import { Site } from './site.model'
import { ApiLogService } from "../apiLog/apiLog.service";
import { CustomLogService } from "../customLog/customLog.service";
import { ErrorLogService } from "../errorLog/errorLog.service";
import { EventLogService } from "../eventLog/eventLog.service";
import { PrefService } from "../perfLog/pref.service";
import { PvLogService } from "../pvLog/pvLog.service";
import { WebLogService } from "../webLog/webLog.service";
import { RedisServer } from "@app/processors/redis/redis.server";

@Injectable()
export class SiteService {
    constructor(
        @InjectModel(Site) private readonly siteModel: MongooseModel<Site>,
        private readonly apiLogService: ApiLogService,
        private readonly eventLogService: EventLogService,
        private readonly errorLogService: ErrorLogService,
        private readonly customLogService: CustomLogService,
        private readonly pvLogService: PvLogService,
        private readonly prefLogService: PrefService,
        private readonly webLogService: WebLogService,
        private readonly cacheService: RedisServer,
    ) { }

    /**
     * 站点关联的日志删除
     * @private
     * @param {MongooseID} siteId
     * @memberof SiteService
     */
    private deleteSiteLog(siteId: MongooseID) {
        this.webLogService.siteIdRemove(siteId)
        this.apiLogService.siteIdRemove(siteId)
        this.eventLogService.siteIdRemove(siteId)
        this.errorLogService.siteIdRemove(siteId)
        this.customLogService.siteIdRemove(siteId)
        this.pvLogService.siteIdRemove(siteId)
        this.prefLogService.siteIdRemove(siteId)
    }
    /**
     * 创建站点
     * @param {Site} site
     * @return {*}  {Promise<MongooseDoc<Site>>}
     * @memberof SiteService
     */
    public async createSite(site: Site): Promise<MongooseDoc<Site>> {
        const existedSite = await this.siteModel.findOne({ name: site.name }).exec()
        if (existedSite) {
            throw `${site.name}站点已存在`
        }
        const res = await this.siteModel.create({
            ...site
        })
        this.cacheService.set(res._id.toString(), res)
        return res
    }

    /**
     *  获取所有的数据
     * @param {PaginateQuery<Site>} paginateQuery
     * @param {PaginateOptions} paginateOptions
     * @return {*}  {Promise<PaginateResult<Site>>}
     * @memberof SiteService
     */
    public paginate(paginateQuery: PaginateQuery<Site>, paginateOptions: PaginateOptions): Promise<PaginateResult<Site>> {
        return this.siteModel.paginate(paginateQuery, paginateOptions)
    }


    /**
     * 删除站点
     * @param {Object} id
     * @return {*}  {Promise<PaginateResult<string>>}
     * @memberof SiteService
     */
    public async deleteId(id: MongooseID): Promise<MongooseDoc<Site>> {
        const site = await this.siteModel.findByIdAndRemove(id).exec()
        if (!site) {
            throw `站点${id}没有找到`
        }
        // 删除缓存
        this.cacheService.delete(id.toString())
        this.deleteSiteLog(id)
        // 缓存删除
        return site
    }

    /**
     * 更新站点
     * @param {MongooseID} id
     * @param {Site} data
     * @memberof SiteService
     */
    public async update(id: MongooseID, data: Site) {
        const site = this.siteModel.findByIdAndUpdate(id, data, { new: true }).exec()
        if (!site) {
            throw `更新站点不存在`
        }
        // 更新缓存
        this.cacheService.set(id.toString(), site)
        // 更新缓存
        return site
    }
}

createSite: 方法在数据库中创建新的 Site 文档,并使用 RedisServer 将其存储在 Redis 缓存中。

paginate: 方法使用分页选项从数据库中检索 Site 文档。

deleteId: 方法从数据库中删除一个 Site 文档,将其从 Redis 缓存中移除,并使用其他服务(webLogServiceapiLogService 等)删除与站点相关的任何日志。

update: 方法更新数据库中的现有 Site 文档,并更新 Redis 缓存。

日志模块

该模块用于记录当前站点下所有上报日志信息数据,用于展示和聚合统计数据。 image.png

日志模型

Log模型表示了应用程序中的各种日志记录,包括API请求日志事件日志错误日志性能日志PV/UV日志自定义日志等。Log模型具有id、siteId、onModel、doce、category、userId、title、path、href、method、url、body、params、value、ip、create_at和update_at等属性。

  • id属性是用于记录Log实例的唯一标识,
  • siteId属性是用于关联应用程序的Site模型的引用,
  • onModel属性用于标识Log模型中doce属性的引用类型,
  • doce属性用于保存与日志相关的内容,如API请求日志、事件日志等。
import { getProviderByTypegoose } from "@app/transformers/model.transform";
import { prop, plugin, modelOptions, index, Ref } from '@typegoose/typegoose';
import { AutoIncrementID } from '@typegoose/auto-increment';
import paginate from 'mongoose-paginate-v2';
import { IsNotEmpty, IsIn, IsString, IsNumber, IsNumberString, IsOptional, IsUrl, IsIP } from 'class-validator';
import { Site } from "../site/site.model";
import { ApiLog } from "../apiLog/apiLog.model";
import { EventLog } from "../eventLog/eventLog.model";
import { ErrorLog } from "../errorLog/errorLog.model";
import { PrefLog } from "../perfLog/pref.model";
import { PvLog } from "../pvLog/pvLog.model";
import { CustomLog } from "../customLog/customLog.model";
import { indexWeights, LOG_CATEGORY } from "@app/utils/report";
import { TransportCategory } from "@app/constants/enum.contant";
import { Method } from "axios";

export enum RefType {
    ApiLog = 'ApiLog',
    EventLog = 'EventLog',
    ErrorLog = 'ErrorLog',
    PrefLog = 'PrefLog',
    PvLog = 'PvLog',
    CustomLog = 'CustomLog'
}

export const LOG_REF_TYPE = [RefType.ApiLog, RefType.EventLog, RefType.ErrorLog, RefType.EventLog, RefType.PrefLog, RefType.PvLog]
export type Content = ApiLog | EventLog | ErrorLog | PrefLog | PvLog | CustomLog

@index({ title: 'text', href: 'text', url: 'text', params: 'text', value: 'text', body: 'text' }, {
    name: 'SearchIndex',
    weights: {
        ...indexWeights,
        url: 5,
        params: 20,
        value: 4,
        body: 18,
    },
})
@plugin(AutoIncrementID, {
    field: 'id',
    incrementBy: 1,
    startAt: 1000000000,
    trackerCollection: 'identitycounters',
    trackerModelName: 'identitycounter',
})
@plugin(paginate)
@modelOptions({
    schemaOptions: {
        toObject: { getters: true },
        timestamps: {
            createdAt: 'create_at',
            updatedAt: 'update_at',
        },
    }
})
export class Log {
    @prop({ unique: true }) // 设置唯一索引
    id: number

    @IsNotEmpty()
    @prop({ ref: () => Site, required: true, index: true })
    siteId: Ref<Site>

    @IsIn([LOG_REF_TYPE])
    @prop({ required: true, enum: RefType, addNullToEnum: true })
    onModel: RefType

    @IsNotEmpty()
    @prop({ refPath: 'onModel', index: true, required: true })
    doce: Ref<Content>

    @IsIn([LOG_CATEGORY])
    @IsString()
    @prop({ enum: TransportCategory, required: true, default: TransportCategory.API })
    category: TransportCategory

    @IsNumber()
    @IsNumberString()
    @IsOptional()
    @prop({ type: String, default: null, index: true })
    userId: string | null

    @IsString()
    @prop({ default: null, validate: /\S+/, text: true, type: String })
    title: string | null

    @IsString()
    @prop({ type: String, default: null, index: true })
    path: string | null

    @IsString()
    @IsUrl()
    @IsOptional()
    @prop({ type: String, default: null, index: true, text: true })
    href: string | null

    @IsString()
    @prop({ type: String, index: true, default: null })
    method?: Method | null;

    @IsString()
    @IsUrl()
    @IsOptional()
    @prop({ type: String, default: null, index: true, text: true })
    url: string | null;

    @IsString()
    @prop({ type: String, default: null, text: true })
    body: string | null

    @IsString()
    @prop({ type: String, default: null, text: true, index: true })
    params: string | null

    @IsString()
    @prop({ type: String, default: null, index: true, text: true })
    value?: string | null // 错误信息

    @IsIP()
    @IsOptional()
    @prop({ default: null, type: String })
    ip: string | null


    @prop({ default: Date.now, index: true, immutable: true })
    create_at?: Date

    @prop({ default: Date.now })
    update_at?: Date
}


export const LogProvider = getProviderByTypegoose(Log)

Log模型定义了各种验证规则和索引,以便进行查询和过滤操作。

日志服务

日志服务负责处理Web日志数据,具体来说是报告日志数据和检索日志数据以进行分页等操作。

  • create: 改方法接收一个包含日志数据和siteId的对象,然后根据日志的类别使用相应的日志模型将其保存到数据库中。并且根据TransportCategory.API类型值,插入不同的关联表中。此方法还根据给定的siteId查询站点,如果站点不存在,则会抛出错误
  • paginate: 根据给定的分页选项和查询检索日志数据,使用日志模型的paginate方法。
  • siteIdRemove: 删除与给定siteId相关联的所有日志。
  • batchDelete: 批量删除,根据传递的ids数组删除日志。
  • aggregation: 聚合查询,根据参数聚合统计日志数据。
  • saveErrorRecord: 错误录制更新保存。
import { isDevEnv } from "@app/app.env";
import { TransportCategory } from "@app/constants/enum.contant";
import { MongooseID, MongooseModel } from "@app/interfaces/mongoose.interface";
import { PaginateQuery } from "@app/interfaces/paginate.interface";
import { RecordVideo } from "@app/interfaces/record.interface";
import { HelperServiceIp } from "@app/processors/helper/helper.service.ip";
import { InjectModel } from "@app/transformers/model.transform";
import logger from "@app/utils/logger";
import { Injectable } from "@nestjs/common";
import { PipelineStage, PaginateOptions, PaginateResult } from "mongoose";
import { ApiLog } from "../apiLog/apiLog.model";
import { ApiLogService } from "../apiLog/apiLog.service";
import { CustomLog } from "../customLog/customLog.model";
import { CustomLogService } from "../customLog/customLog.service";
import { ErrorLog } from "../errorLog/errorLog.model";
import { ErrorLogService } from "../errorLog/errorLog.service";
import { EventLog } from "../eventLog/eventLog.model";
import { EventLogService } from "../eventLog/eventLog.service";
import { PrefLog } from "../perfLog/pref.model";
import { PrefService } from "../perfLog/pref.service";
import { PvLog } from "../pvLog/pvLog.model";
import { PvLogService } from "../pvLog/pvLog.service";
import { Site } from "../site/site.model";
import { LogData } from "./webLog.dto";
import { Log, RefType } from "./webLog.model";

@Injectable()
export class WebLogService {
    constructor(
        @InjectModel(Log) private readonly logModel: MongooseModel<Log>,
        @InjectModel(Site) private readonly siteModel: MongooseModel<Site>, // 避免循环依赖
        private readonly apiLogService: ApiLogService,
        private readonly eventLogService: EventLogService,
        private readonly errorLogService: ErrorLogService,
        private readonly customLogService: CustomLogService,
        private readonly pvLogService: PvLogService,
        private readonly prefLogService: PrefService,
        private readonly ipService: HelperServiceIp,
    ) { }


    /**
     * 上报
     * @param {(LogData & Report)} { siteId, ...log }
     * @return {*}  {Promise<any>}
     * @memberof WebLogService
     */
    public async create(data: LogData): Promise<any> {
        const site = await this.siteModel.findById(data.siteId)
        if (!site) {
            throw `站点已删除`
        }
        const log: Partial<Log> = {
            siteId: data.siteId,
            category: data.category,
            userId: data.userId,
            title: data.title,
            path: data.path,
            href: data.href,
            method: data.method,
            url: data.url,
            body: data.body,
            params: data.params,
            value: data.value,
            ip: data.ip
        }
        !isDevEnv && data.ip && this.ipService.queryLocation(data.ip)
        switch (data.category) {
            case TransportCategory.EVENT: // 事件上报
                log.onModel = RefType.EventLog
                log.doce = await this.eventLogService.create(data as unknown as EventLog)
                break;
            case TransportCategory.API: // API上报
                log.onModel = RefType.ApiLog
                log.doce = await this.apiLogService.create(data as unknown as ApiLog)
                break;
            case TransportCategory.ERROR: // 错误上报
                log.onModel = RefType.ErrorLog
                log.doce = await this.errorLogService.create(data as unknown as ErrorLog)
                break;
            case TransportCategory.CUSTOM: // 自定义上报
                log.onModel = RefType.CustomLog
                log.doce = await this.customLogService.create(data as unknown as CustomLog)
                break;
            case TransportCategory.PV: // PV和UV上报
                log.onModel = RefType.PvLog
                log.doce = await this.pvLogService.create(data as unknown as PvLog)
                break;
            case TransportCategory.PREF:
                // 性能上报 perf
                log.onModel = RefType.PrefLog
                log.doce = await this.prefLogService.create(data as unknown as PrefLog)
                break;
        }
        try {
            // 此处需要优化,当大量请求过来就会有问题。
            return await this.logModel.create(log)
        } catch (error) {
            logger.error(`上报错误`, error)
            throw '报错了'
        }
    }

    /**
     * 获取上报列表
     * @param {PaginateQuery<Log>} paginateQuery
     * @param {PaginateOptions} paginateOptions
     * @return {*}  {Promise<PaginateResult<Log>>}
     * @memberof WebLogService
     */
    public paginate(paginateQuery: PaginateQuery<Log>, paginateOptions: PaginateOptions): Promise<PaginateResult<Log>> {
        return this.logModel.paginate(paginateQuery, paginateOptions)
    }


    /**
     * 根据站点ID删除相关站点
     * @param {MongooseID} siteId
     * @return {*} 
     * @memberof PvLogService
     */
    public async siteIdRemove(siteId: MongooseID) {
        const logResult = await this.logModel.deleteMany({ siteId: siteId }).exec()
        return logResult
    }

    /**
     * 批量删除
     * @param {MongooseID[]} ids
     * @return {*} 
     * @memberof PvLogService
     */
    public async batchDelete(ids: MongooseID[]) {
        const logResult = await this.logModel.deleteMany({ _id: { $in: ids } }).exec()
        return logResult
    }

    /**
     * 聚合查询统计数据
     * @param {PipelineStage[]} pipeParams
     * @memberof WebLogService
     */
    public async aggregation(pipeParams: PipelineStage[]) {
        return this.logModel.aggregate(pipeParams)
            .then((data) => {
                return data
            })
            .catch((err) => {
                logger.error('Log日志聚合查询错误', err)
                Promise.reject(err)
            })
    }

    /**
     *  报错错误录制
     * @param {RecordVideo} data
     * @return {*} 
     * @memberof WebLogService
     */
    public saveErrorRecord(data: RecordVideo) {
        return this.errorLogService.saveRecordData(data)
    }
}

日志控制器

日志控制器下存在方法:

  • postLog()函数处理用于日志上报接口。它使用Throttle装饰器来限制每30秒内的请求数量为50个。它接收Partial<LogData>作为请求正文和QueryVisitor作为请求查询参数,然后将数据发送到WebLogService,该服务将其保存到数据库中。
  • getLogs()函数处理用于检索日志的GET请求。它接收LogPaginateQueryDTO作为请求查询参数,然后将数据发送到WebLogService,该服务检索日志并将其作为PaginateResult对象返回。
  • getLogsChart()函数处理用于检索图表数据的GET请求。它接收LogChartQueryDTO作为请求查询参数,然后将数据发送到WebLogService,该服务检索数据并将其作为表示特定时间段数据的对象数组返回。此函数使用$match$group$project$sort操作符聚合数据库中的数据。
import { PlainBody } from "@app/decorators/body.decorator";
import { Responsor } from "@app/decorators/responsor.decorator";
import { ApiGuard } from "@app/guards/api.guard";
import { Controller, Post, Body, Get, UseGuards, Query } from "@nestjs/common";
import { QueryParams, QueryVisitor } from "@app/decorators/params.decorator";
import { PaginateOptions, PaginateResult, PipelineStage } from "mongoose";
import { LogChartQueryDTO, LogData, LogPaginateQueryDTO } from "./webLog.dto";
import { KW_KEYS } from "@app/constants/value.constant";
import { WebLogService } from "./webLog.service";
import { Throttle } from "@nestjs/throttler";
import { Log } from "./webLog.model";
import lodash from 'lodash'
import { groupHourOption, handleSearchKeys, projectHourOption } from "@app/utils/searchCommon";
import { TransportCategory } from "@app/constants/enum.contant";

@Controller('/api/log')
export class WeblogControll {
    constructor(private readonly logService: WebLogService) { }

    /**
     * 上报日志
     * @return {*} 
     * @memberof WeblogControll
     */
    @Post()
    @Throttle(50, 30) // 30s -> 50 每30秒内最多发生50个请求
    @Responsor.api()
    @Responsor.handle('日志上报')
    postLog(@PlainBody() data: Partial<LogData>, @Body() body: Partial<LogData>, @QueryParams('visitor') visitor: QueryVisitor) {
        const logData = (data || body) as Required<LogData>
        return logData.category === TransportCategory.RV ?
            this.logService.saveErrorRecord(logData as any) :
            this.logService.create({ ...logData, ip: visitor.ip, ua_result: visitor.ua_result })
    }

    /**
     * 获取所有日志
     * @param {SitePaginateDTO} query
     * @return {*} 
     * @memberof SiteController
     */
    @Get()
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.paginate()
    @Responsor.handle('获取日志列表')
    getLogs(@Query() query: LogPaginateQueryDTO): Promise<PaginateResult<Log>> {
        const { page, size, sort, ...filters } = query
        const paginateQuery = handleSearchKeys<LogPaginateQueryDTO>(query, KW_KEYS)
        if (query.category) {
            paginateQuery.category = query.category
        }
        const paginateOptions: PaginateOptions = { page: page || 1, limit: size || 20 }
        if (!lodash.isUndefined(sort)) {
            paginateOptions.sort = { _id: sort }
        } else {
            paginateOptions.sort = { id: -1 }
        }
        if (filters.category) {
            paginateQuery.category = filters.category
        }
        paginateOptions.select = '-href -path -params -url -method -body -title -value'
        paginateOptions.populate = {
            path: 'doce',
            select: '-siteId -events -stackTrace -breadcrumbs', //返回的数据过大导致,接口返回request content was evicted from inspector cache
        }
        return this.logService.paginate(paginateQuery, paginateOptions)
    }

    /**
     * 获取图表数据
     * @param {LogPaginateQueryDTO} query
     * @return {*}  {Promise<any>}
     * @memberof WeblogControll
     */
    @Get('chart')
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.handle('获取图表数据')
    getLogsChart(@Query() query: LogChartQueryDTO): Promise<any> {
        const matchFilter = handleSearchKeys<LogChartQueryDTO>(query, KW_KEYS)
        if (query.category) {
            matchFilter.category = query.category
        }
        const isGreaterEight = query.timeSlot > 8 * 60 * 60 * 1000
        const projectOption = projectHourOption()
        const groupOption = groupHourOption({
            apiList: { $push: { create_at: "$create_at", hour: '$hour' } },
            count: { $sum: 1 }
        }, query.timeSlot === 24 * 60 * 60 * 1000)
        const dayPipe: PipelineStage[] = [
            { $match: matchFilter },
            { ...projectOption },
            { ...groupOption },
            { $project: { _id: 0, startTime: '$_id.time', hour: '$_id.hour', apiList: 1, count: 1 } },
            { $sort: { startTime: 1 } },
        ]
        const pipe: PipelineStage[] = [
            { $match: matchFilter },
            {
                $group: {
                    _id: {
                        "$subtract": [
                            { "$subtract": ["$create_at", new Date(0)] },
                            {
                                "$mod": [
                                    { "$subtract": ["$create_at", new Date(0)] },
                                    query.timeSlot
                                ]
                            }
                        ]
                    },
                    apiList: { $push: { create_at: "$create_at" } }, //查看时间段内的数据
                    count: { $sum: 1 },
                },
            },
            { $project: { _id: 0, apiList: 1, count: 1, startTime: { "$add": [new Date(0), "$_id"] } } },
            { $sort: { startTime: 1 } }
        ]
        return this.logService.aggregation(isGreaterEight ? dayPipe : pipe)
    }

postLog方法使用了装饰器 @Throttle,限制了每30秒内最多只能有50个请求,如果大量请求过来,超过了这个限制,那么多余的请求将会被阻塞或者被拒绝,那么服务器可能会变得非常慢或者宕机,导致无法处理更多的请求。后续会使用 RedisList 数据结构。我们将通过使用 lpush 命令将任务推入队列的左端,并通过 llen 命令获取队列长度来判断是否有待处理的任务。如果有任务存在,我们将从队列的右端使用 rpop 命令弹出任务进行处理。通过这种方式,我们可以更好地管理和处理任务队列,从而提高系统的性能和可靠性,同时返回状态码为204。 如果有好的建议可以加微信沟通。

image.pnggetLogsChart 方法中,我们使用聚合查询统计日志数据。如果查询时间条件为 30 天,且统计时间间隔为 12 小时,则需要进行特殊处理。因为统计间隔大于 8 小时且小于 24 小时的数据时,此时统计的数据开始时间不是以当天的 0 点作为开始统计时间。为了满足这个要求,我们需要对聚合统计方式进行单独处理。


    /**
     * 获取图表数据
     * @param {LogPaginateQueryDTO} query
     * @return {*}  {Promise<any>}
     * @memberof WeblogControll
     */
    @Get('chart')
    @UseGuards(ApiGuard)
    @Responsor.api()
    @Responsor.handle('获取图表数据')
    getLogsChart(@Query() query: LogChartQueryDTO): Promise<any> {
        const matchFilter = handleSearchKeys<LogChartQueryDTO>(query, KW_KEYS)
        if (query.category) {
            matchFilter.category = query.category
        }
        const isGreaterEight = query.timeSlot > 8 * 60 * 60 * 1000
        const projectOption = projectHourOption()
        const groupOption = groupHourOption({
            apiList: { $push: { create_at: "$create_at", hour: '$hour' } },
            count: { $sum: 1 }
        }, query.timeSlot === 24 * 60 * 60 * 1000)
        const dayPipe: PipelineStage[] = [
            { $match: matchFilter },
            { ...projectOption },
            { ...groupOption },
            { $project: { _id: 0, startTime: '$_id.time', hour: '$_id.hour', apiList: 1, count: 1 } },
            { $sort: { startTime: 1 } },
        ]
        const pipe: PipelineStage[] = [
            { $match: matchFilter },
            {
                $group: {
                    _id: {
                        "$subtract": [
                            { "$subtract": ["$create_at", new Date(0)] },
                            {
                                "$mod": [
                                    { "$subtract": ["$create_at", new Date(0)] },
                                    query.timeSlot
                                ]
                            }
                        ]
                    },
                    apiList: { $push: { create_at: "$create_at" } }, //查看时间段内的数据
                    count: { $sum: 1 },
                },
            },
            { $project: { _id: 0, apiList: 1, count: 1, startTime: { "$add": [new Date(0), "$_id"] } } },
            { $sort: { startTime: 1 } }
        ]
        // 在统计时间间隔为12小时时,返回开始统计时间不是以当天的0时作为开始时间
        return this.logService.aggregation(pipe)
    }
/**
 * 通用分段处理12小时以及每天的时间分配
 * @export
 * @param {object} params
 * @param {boolean} isDay
 * @return {*}  {PipelineStage.Group}
 */
export function groupHourOption(params: object, isDay: boolean, idParmas = {}): PipelineStage.Group {
    if (!isDay) {
        idParmas = {
            hour: { "$subtract": ['$hour', { "$mod": ['$hour', 12] }] },
            ...idParmas,
        }
    }
    return {
        $group: {
            _id: { time: '$day', ...idParmas },
            ...params,
        },
    }
}

image.png

前端图表

后端统计返回数据只包含有数据的时间段;前端为了展示统计开始时间和结束时间内所有时间段数据,需要填充空白时间段数据。定义一个chartDate的函数。该函数接受一个包含多个属性的对象作为参数,并返回处理后的数据进行图表渲染展示。

下面是参数对象的属性说明:

  • startTime:表示数据的开始时间。
  • endTime:表示数据的结束时间。
  • timeSlot表示每个时间段的大小(以毫秒为单位)。
  • data:一个包含要处理的数据的对象数组。
  • format:表示输出日期的所需格式。
  • splitKeys(可选):表示每个对象中应该基于时间间隔拆分为多个对象的属性键(如PV/UV图表)。
import dayjs from "dayjs"

/**
 * 处理echart时间分片数据格式
 * @export
 * @param {number} startTime
 * @param {number} endTime
 * @param {number} timeSlot
 */
export interface ChartDateProps<T> {
    startTime: number
    endTime: number
    timeSlot: number
    data: Array<T & ChartDateItem>
    format: string
    splitKeys?: Array<string>
}

export interface ChartDateItem {
    startTime: string
    hour?: number
    [key: string]: any
}

export function chartDate<T>(arg: ChartDateProps<T>): Array<T>
export function chartDate<T>(...arg: ChartDateProps<T>[]): Array<T> {
    const option = arg[0]
    const { startTime, endTime, timeSlot, data, format, splitKeys } = option
    const startT = dayjs(startTime).startOf('day').valueOf()
    const nLen = Math.floor((startTime - startT) / timeSlot)
    const nStartTime = startT + (nLen * timeSlot)
    const len = Math.ceil((endTime - nStartTime) / timeSlot)
    let list: Array<T> = []
    Array.from({ length: len }).map((_, index) => {
        const sTime = dayjs(nStartTime).add(timeSlot * index)
        const eTime = dayjs(nStartTime).add(timeSlot * (index + 1))
        const curData = data.find((item) => {
            return dayjs(item?.startTime).add(item.hour || 0, 'hour').valueOf() === dayjs(sTime).valueOf()
        })
        if (splitKeys && curData) {
            const curList = splitKeys.map((type) => (
                {
                    ...curData,
                    startTime: index == 0 ? dayjs(startTime).format(format) : sTime.format(format),
                    endTime: eTime.format(format),
                    type: type,
                    value: curData?.[type],
                }
            ))
            list = [...list, ...curList]
        } else {
            list.push({
                ...curData,
                startTime: sTime.format(format),
                endTime: eTime.format(format),
            } as unknown as T)
        }
    })
    return list.map((item, index) => ({ ...item, index }))
}

错误日志模块

错误日志模块处理流程和日志模块大致一样,这里不过细讲,可以查看代码。在这里重点讲下代码错误如何进行源码映射得到错误的文件行列号等信息,并且展示前端监控平台和通知告警群等业务中

打包上传Sourcemap

Sourcemap(源代码映射)是一种文件,用于将编译后的代码映射回其原始源代码。它包含了编译后代码与源代码之间的映射关系,可以帮助开发者在调试 JavaScriptCSSTypeScript等代码时更方便地定位错误。

Sourcemap 文件包含以下信息:

  1. version:表示SourceMap的版本号,目前常用的版本号是3。
  2. sources:表示编译前的源文件路径列表,可以是相对路径或绝对路径。
  3. names:表示编译前的变量名和函数名列表。
  4. sourceRoot:表示源文件的根路径,可以是相对路径或绝对路径。
  5. mappings:表示映射关系,它是一个字符串,包含一个或多个逗号分隔的段,每个段表示一行编译后的代码与编译前的代码之间的映射关系。
  6. file:表示编译后的文件名。
  7. sourcesContent:表示编译前的源代码内容,可选。

通过 Sourcemap 文件,开发者可以在调试时方便地查看源代码中的变量名、函数名和行号等信息,从而更容易地定位和修复代码中的错误。Sourcemap 文件通常由编译工具自动生成,并可以与编译后的代码一起部署到生产环境中,但出于安全考虑,在生成环境中,不包含Sourcemap文件。

image.png

首先我们开发一个自定义插件用于自动将Webpack生成的源映射文件上传到远程服务器,上传完成后删除源映射文件,防止在线上暴露源码。源映射文件是允许开发人员将Webpack生成的代码映射回原始源代码的文件,这对于后续错误上报代码错误溯源提供源码信息。

const fs = require('fs')
const p = require('path')
const axios = require('axios')
const FormData = require('form-data')
const initPatterns = [/\.map$/]
const PLUGIN_NAME = 'UploadSourceMapPlugin'
const archiver = require('archiver')

class UploadSourceMapPlugin {
    constructor(options) {
        this.options = options
        this.pathName = `./${Date.now()}.zip`
    }

    /**
     * 上传
     * @param {*} {
     *         url,
     *         path,
     *         requestOption // 配置参数
     *     }
     * @memberof UploadSourceMapPlugin
     */
    async uploadFile({
        url,
        path,
        requestOption // 配置参数
    }) {
        try {
            const {
                data = {}, header = {}, other = {}
            } = requestOption
            let formData = new FormData()
            if (Object.keys(data).length > 0) {
                for (let key in data) {
                    formData.append(key, data[key])
                }
            }
            formData.append('file', fs.createReadStream(path))
            const res = await axios({
                ...other,
                url,
                method: 'post',
                data: formData,
                headers: {
                    ...formData.getHeaders(),
                    ...header
                },
            })
        } catch (error) {
            throw error
        }
    }

    /**
     * 读取目录
     * @memberof UploadSourceMapPlugin
     */
    readDir(path, patterns) {
        const filesContent = []

        function readSingleFile(path) {
            const files = fs.readdirSync(path)
            files.forEach(filePath => {
                const wholeFilePath = p.resolve(path, filePath)
                const fileStat = fs.statSync(wholeFilePath)
                // determine whether it is a directory or a file
                if (fileStat.isDirectory()) {
                    readSingleFile(wholeFilePath)
                }
                const _patterns = patterns || initPatterns
                if (
                    fileStat.isFile() &&
                    _patterns.some(r => r.test(filePath))
                ) {
                    filesContent.push(wholeFilePath)
                }
            })
        }
        readSingleFile(path)
        return filesContent
    }

    /**
     * 删除文件
     * @param {*} path
     * @memberof UploadSourceMapPlugin
     */
    deleteFile(path) {
        fs.unlink(path, () => {})
    }

    /** 
     * 验证
     * @param {*} obj
     * @return {*} 
     * @memberof UploadSourceMapPlugin
     */
    typeOf(obj) {
        const s = Object.prototype.toString.call(obj)
        return s.match(/\[object (.*?)\]/)[1].toLowerCase()
    }

    apply(compiler) {
        const {
            url,
            uploadPath,
            patterns,
            requestOption
        } = this.options

        if (!url || !uploadPath) {
            throw Error('Missing necessary parameters')
        }
        if (!this.typeOf(url) === 'string') {
            throw Error('The "url" parameter type is incorrect')
        }

        if (!this.typeOf(uploadPath) === 'string') {
            throw Error('The "uploadPath" parameter type is incorrect')
        }

        if (patterns && !this.typeOf(patterns) === 'array') {
            throw Error('The "patterns" parameter type is incorrect')
        }
        compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, async () => {
            const archive = archiver('zip', {
                gzip: true,
                zlib: {
                    level: 9
                },
            })
            archive.on('error', err => {
                throw Error(err)
            })
            // 压缩完成后,上传文件
            archive.on('end', async () => {
                console.info('Packed successfully, uploading files now...')
                await this.uploadFile({
                    url,
                    path: this.pathName,
                    requestOption
                })
                // 删除打包文件
                this.deleteFile(this.pathName)
                // 删除.map文件
                sourceMapPaths.forEach(this.deleteFile)
            })

            archive.pipe(fs.createWriteStream(this.pathName))
            // 获取路径下所有的.map文件
            const sourceMapPaths = this.readDir(uploadPath, patterns)
            // 遍历所有文件,追加到zip 中
            sourceMapPaths.forEach(p => {
                archive.append(fs.createReadStream(p), {
                    name: `./${p.replace(uploadPath, '')}`,
                })
            })
            archive.finalize()
        })
    }
}

module.exports = {
    UploadSourceMapPlugin
}

该插件接受一个带有以下属性的选项对象:

  • url:要上传源映射文件的远程服务器的URL。
  • uploadPath:包含源映射文件的目录的路径。
  • patterns(可选):一个正则表达式数组,用于匹配源映射文件的文件名。如果未提供,则默认模式为/\.map$/
  • requestOption(可选):一个包含要传递给用于上传源映射的axios请求的其他选项的对象。

该插件通过侦听webpack编译器的afterEmit钩子工作。一旦编译器已经发出输出文件,插件就使用readDir方法读取由uploadPath指定的目录,并查找与由patterns指定的模式匹配的所有文件。然后,它使用archiver库创建所有匹配文件的zip归档,并将其保存到当前目录中具有唯一名称的文件中。最后,它使用axios库和uploadFile方法将zip文件上传到远程服务器。上传完成后,插件会删除zip文件以及包含在归档中的所有源映射文件。这样打包后的文件中不包含.map文件。

文件上传

前面讲到了Sourcemap打包上传,这里讲下服务端处理上传的逻辑。

以下代码是用于处理文件上传请求,在这个请求中使用@UploadedFile()装饰器和FileInterceptor('file')拦截器来解析上传的文件内容,将其转换为一个JavaScript对象,并将其作为该方法的第一个参数file传递给uploadZip()方法。

 /**
 * 文件zip上传
 * @param {*} files
 * @param {*} body
 * @return {*} 
 * @memberof ExpansionController
 */
@Post('upload-zip')
@UseGuards(ApiGuard)
@Responsor.api()
@Responsor.handle('上传文件')
@UseInterceptors(FileInterceptor('file'))
public async uploadZipFile(@UploadedFile() file, @Body() body) {
    return await this.uploadService.uploadZip(file, body.siteId)
}

以下代码是上传服务类的方法,该方法首先获取上传的文件的后缀名,检查文件是否为zip文件,如果不是则抛出一个异常。然后创建一个目录,用于存储解压后的文件。使用AdmZip库读取zip文件,并获取zip文件中的所有文件项。如果目录不存在,则创建该目录。根据文件项的相对路径和目录的绝对路径计算出文件在服务器上的绝对路径。使用Readable.from()方法从zip文件中获取文件数据流,使用createWriteStream()方法创建一个可写流,并将数据写入服务器上的文件。在写入文件完成后,将zip文件从临时目录中删除。

/**
 * 上传
 * @param {*} file
 * @param {string} siteId
 * @memberof ExpansionServiceUpload
 */
public async uploadZip(file, siteId: string) {
    // 获取文件的后缀
    const tempFilePath = file.path;
    const fileName = file.originalname;
    const fileExt = fileName.split('.').pop().toLowerCase();
    if (fileExt !== 'zip') {
        throw new Error('Invalid file format. Only .zip file allowed.');
    }
    const path = `${this.UPLOAD_DIR}/${siteId}`
    const zip = new AdmZip(tempFilePath);
    const zipEntries = zip.getEntries();

    if (!existsSync(path)) {
        mkdirSync(path)
    }
    for (const zipEntry of zipEntries) {
        if (zipEntry.isDirectory) {
            continue;
        }
        const fileRelativePath = relative('./', zipEntry.entryName);
        const unzipFilePath = join(path, fileRelativePath);
        const fileStream = Readable.from(zipEntry.getData());
        const writeStream = createWriteStream(unzipFilePath);
        fileStream.pipe(writeStream);
        await new Promise((resolve, reject) => {
            writeStream.on('finish', resolve);
            writeStream.on('error', reject);
        });
    }
    unlinkSync(tempFilePath);
    return { msg: "上传成功" }
}

这里存在一个问题,就是多次上传文件到服务器时,服务器时磁盘空间会占用大量资源。为了解决这个问题,后续上传文件将直接发送到云存储服务上。

错误代码映射

image.png

当错误上报时,就开始处理错误日志中的堆栈跟踪信息,尝试查找源代码文件,并解析出出错位置的源码信息,并保存到数据库中,同时可以增加告警通知(如企微告警、邮箱)。如果是上传到云服务器上,就要另行处理逻辑。

/**
 * 处理错误文件
 * @private
 * @param {ErrorLog['stackTrace']} stackTrace
 * @param {ErrorLog['siteId']} siteId
 * @return {*}  {(Promise<SourceInfo | null>)}
 * @memberof ErrorLogService
 */
private async handleFileMap(stackTrace: ErrorLog['stackTrace'], siteId: ErrorLog['siteId']): Promise<SourceInfo | null> {
    let starkFirst!: StackTrace
    const list = stackTrace || []
    if (Array.isArray(list) && !!list.length && list[0] && list[0].filename) {
        starkFirst = list[0]
    }
    if (starkFirst && starkFirst.filename && /\.(js)/.test(starkFirst.filename)) {
        const ext = starkFirst.filename.split('/js/')[1]
        if (ext) {
            // 获取错误文件URL,如果上传到服务器云上直接拉取
            const url = join(__dirname, `../../../public/sourcemap/${siteId}`, `/${ext}.map`)
            if (existsSync(url)) {
                const rawSourceMap = JSON.parse(
                    readFileSync(url, 'utf-8').toString()
                );
                return await this.sourceMapAnalysis(rawSourceMap, starkFirst.lineno, starkFirst.colno, 5)
            }
        }
    }
    return null
}

sourceMapAnalysis用于解析源码映射(source map),查找出出错位置在源码文件中的对应位置,并返回上下文信息。

/**
 *  SourceMap解析错误信息
 * @private
 * @param {*} sourceMapFile
 * @param {number} line
 * @param {number} column
 * @param {number} offset
 * @return {*}  {(Promise<SourceInfo | null>)}
 * @memberof ErrorLogService
 */
private async sourceMapAnalysis(sourceMapFile, line: number, column: number, offset: number): Promise<SourceInfo | null> {
    const consumer = await new SourceMapConsumer(sourceMapFile);
    const sm: NullableMappedPosition = consumer.originalPositionFor({
        line: Number(line),
        column: Number(column),
    })
    if (!!sm) {
        const { sources } = consumer;
        const smIndex = sm?.source && sources.indexOf(sm?.source) || 0;
        const smContent = consumer.sourcesContent[smIndex];
        const rawLines = smContent && smContent.split(/\r?\n/g);
        const line = sm?.line || 0
        let begin = line - offset;
        const end = line + offset + 1;
        begin = begin < 0 ? 0 : begin;
        const context = rawLines && rawLines.slice(begin, end);
        consumer.destroy();
        return {
            context,
            originLine: line + 1,
            source: sm.source,
        }
    }
    return null
}

保存在数据库中,通过查询数据返回错误信息,用于前端告警平台展示。

PV/UV模块

PV/UV以及其他模块的功能将不会详细讲解。这里重点讲下UV是怎么统计的。

image.png

UV统计是根据userIdIP统计的,如果存在userId就是使用userId,如果不存在就填充IP

const pipe: PipelineStage[] = [
    { $match: matchFilter },
    // 日期处理成YYYY-MM-DD 根据这里相同的值,统计每一天的数据
    { $project: { day: { $dateToString: { date: '$create_at', format: '%Y-%m-%d', timezone: 'GMT' } }, userId: { $ifNull: ['$userId', '$ip'] } } }, // 如果userID 为空使用IP填充值
    { $group: { _id: { time: '$day', userId: '$userId' }, count: { $sum: 1 } } }, // 根据用户和时间统计每个用户的PV
    { $group: { _id: '$_id.time', pv: { $sum: '$count' }, uv: { $sum: 1 } } }, // 在根据时间统计每一天所有PV和UV
    { $project: { _id: 0, startTime: '$_id', pv: 1, uv: 1 } },
    { $sort: { startTime: 1 } },
]
  1. $match: 使用给定的筛选条件进行数据筛选。
  2. $project: 用于将输入文档中的字段重新格式化为新的文档,其中$dateToStringcreate_at字段的日期格式化为YYYY-MM-DD格式,以便按照日期分组统计。如果userID为空,则使用ip填充值。
  3. $group: 将文档分组到一个集合中,以便进行统计。在此阶段,将按照日期和用户将PV数统计为1,用于计算UV数。
  4. $group: 再次按日期对文档进行分组,并将每天的所有PV和UV数求和。
  5. $project: 从输出文档中删除_id字段,将_id.time作为startTime输出。
  6. $sort: 将输出文档按照日期升序排序。

总的来说,这段代码的作用是将输入的原始日志数据进行处理和筛选,然后统计每个用户的PV和UV,并按照日期分组计算所有用户的PV和UV总数。最后,输出格式化后的数据以便于分析和展示。

中间件

在 Nest.js 中,中间件是用于处理 HTTP 请求和响应的函数。中间件可以拦截请求、修改请求对象和响应对象,以及在请求处理完成后执行一些操作。中间件可以用于处理身份验证、日志记录、缓存、错误处理等任务。

在 Nest.js 中,中间件可以是全局中间件或局部中间件。全局中间件将会在每个请求上都执行,而局部中间件则只会在特定路由或控制器上执行。

本地开发登录中间件

本地开发登录中间件它的作用是在开发环境下,自动注入登录状态的信息,以方便本地调试和测试。

import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from 'express'
import { isDevEnv } from "@app/app.env";
import { AuthService } from "@app/modules/auth/auth.service";

/**
 * 用于本地开发登录
 * @export
 * @class DevMiddleware
 * @implements {NestMiddleware}
 */
@Injectable()
export class DevMiddleware implements NestMiddleware {
    constructor(
        private readonly authService: AuthService
    ) { }

    /**
     * 接口和API访问都会生产session, 支持本地访问API和页面
     * @param {Request} request
     * @param {Response} response
     * @param {NextFunction} next
     * @return {*} 
     * @memberof DevMiddleware
     */
    use(request: Request, response: Response, next: NextFunction) {
        if (isDevEnv) {
            const userInfo = {
                account: 'admin',
                userId: 1000000001,
            }
            const token = this.authService.creatToken(userInfo)
            response.cookie('jwt', token.access_token);
            response.cookie('userId', userInfo.userId);
            // 强制注入cookie
            request.cookies['jwt'] = token.access_token
            request.session.user = userInfo;
        }
        return next()
    }
}

在具体实现上,该中间件判断当前是否处于开发环境,如果是的话,就生成一个模拟用户信息的对象,包括账号和用户 ID,并通过调用 AuthServicecreateToken 方法生成一个 JWT token。然后将这个 token 和用户 ID 存储在浏览器的 cookie 中,并将用户信息存储在 request.session.user 中,以便在后续的请求中可以访问到这些信息。最后调用 next() 方法,将请求传递给下一个中间件或路由处理器。

需要注意的是,该中间件仅在开发环境下生效,因为在生产环境中,不应该将这样的登录信息直接注入到请求中。

拦截器

Nest.js 拦截器(Interceptors)是一种在处理请求和响应之前、之后或期间执行操作的中间件。它们提供了一种对控制器行为进行全局性的处理的机制,可以用于:

  1. 转换请求数据或响应数据的格式,例如将 JSON 请求体转换为类实例,或者将控制器返回的对象转换为 JSON 响应体。
  2. 在请求或响应上附加元数据或头信息。
  3. 在请求处理之前或之后执行某些操作,例如验证请求,记录日志,处理缓存等。
  4. 根据条件来控制请求的流程,例如基于请求的参数或头信息来动态选择控制器方法。
  5. 在发生异常时执行一些操作,例如记录错误日志、发送错误邮件等。

拦截器可以定义在全局级别,应用于所有的路由器和控制器,也可以定义在特定的路由器或控制器级别。可以定义多个拦截器,并按顺序应用它们。在 Nest.js 中,拦截器使用装饰器来定义。

import {
    Injectable,
    NestInterceptor,
    ExecutionContext,
    CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Response, Request } from 'express'
import { HttpResponseSuccess, ResponseStatus } from '@app/interfaces/response.interface';
import { getResponsorOptions } from '@app/decorators/responsor.decorator';


/**
 * 拦截, 这里可以分成两种拦截 API和路由。单独处理会更好
 * @export
 * @class TransformInterceptor
 * @implements {NestInterceptor<T, HttpResponse<T>>}
 * @template T
 */
@Injectable()
export class TransformInterceptor<T>
    implements NestInterceptor<T, T | HttpResponseSuccess<T>>
{
    async intercept(context: ExecutionContext, next: CallHandler<T>): Promise<Observable<T | HttpResponseSuccess<T>> | any> {
        const req = context.switchToHttp().getRequest<Request>();
        const res = context.switchToHttp().getResponse<Response>()
        const target = context.getHandler()
        const { isApi, successMessage, transform, paginate } = getResponsorOptions(target)
        if (!isApi) {
            // 访问页面是,即时刷新session过期时间
            req.session.touch();
            // res.contentType('html')
        } else if (!transform) {
            return next.handle()
        }
        return next.handle()
            .pipe(
                map((data: any) => {
                    if (data?.redirectUrl) return res.status(301).redirect(data.redirectUrl)
                    const result = isApi ? {
                        status: ResponseStatus.Success,
                        message: successMessage || '请求成功',
                        result: paginate
                            ? {
                                data: data.docs,
                                pagination: {
                                    total: data.totalDocs,
                                    current_page: data.page,
                                    per_page: data.prevPage,
                                    total_page: data.totalPages,
                                },
                            }
                            : data,
                    } : ({ data })
                    return result
                })
            );
    }
}

该拦截器的作用是将请求的响应结果进行统一的处理,例如将返回的数据格式化成标准的 API 响应格式,或者判断是否需要进行重定向等操作。具体来说,该拦截器会做以下几件事情:

  1. 获取请求的 RequestResponse 对象。
  2. 获取请求处理器的装饰器配置信息,例如是否是 API 接口,需要进行哪些格式化处理等。
  3. 对于非 API 请求,刷新 session 过期时间。
  4. 对于 API 请求,进行响应格式化处理。
  5. 如果返回的数据中包含重定向 URL,则进行重定向操作。
  6. 最终返回格式化后的响应数据。

总体来说,该拦截器可以帮助我们减少代码重复,提高代码复用性,让代码更加简洁易懂。

守卫

Nest.js 守卫(Guard)是一个拦截器,用于控制某些路由是否可以被访问。它类似于中间件,但是它可以返回一个布尔值,用于决定是否允许用户访问路由。如果守卫返回 true,则允许用户访问路由,否则将被拒绝访问。

守卫可以用于验证用户身份、权限、请求参数等方面。可以通过实现 CanActivate 接口来创建一个守卫。如果、想要处理异步逻辑,则可以实现 CanActivate 接口的 canActivate 方法返回一个 Promise 对象。在 canActivate 方法中,可以访问当前请求的上下文对象,并执行必要的验证逻辑。

API守卫

import { ExecutionContext, Injectable } from "@nestjs/common";
import { LoggedInGuard } from "./logged-in.guard";
import { HttpUnauthorizedError } from "@app/errors/unauthorized.error";
import { Request } from 'express'
import { ApiWhiteList } from "@app/constants/api.contant";

@Injectable()
export class ApiGuard extends LoggedInGuard {

    canActivate(context: ExecutionContext) {
        const req = context.switchToHttp().getRequest<Request>()
        return super.canActivate(context)
    }

    handleRequest(error, authInfo, errInfo) {
        const validToken = Boolean(authInfo)
        const emptyToken = !authInfo && errInfo?.message === 'No auth token'
        if ((!error && (validToken || emptyToken))) {
            return authInfo || {}
        } else {
            throw error || new HttpUnauthorizedError()
        }
    }
}

ApiGuard 守卫类,该守卫用于保护 API 的访问权限。具体来说,它继承自 LoggedInGuard,并重写了 canActivate 方法和 handleRequest 方法。

canActivate 方法是 NestJS 中所有守卫必须实现的方法之一,它会在请求到达被保护路由时自动被调用。在这个方法中,首先通过 context.switchToHttp().getRequest<Request>() 获取当前请求的 Request 对象,然后调用 super.canActivate(context) 方法来检查用户是否已经登录。这里之所以要调用 super.canActivate(context) 方法,是因为 ApiGuard 继承自 LoggedInGuard,并且 LoggedInGuard 中已经实现了登录检查的逻辑。

handleRequest 方法是在用户已经登录的情况下,用于处理请求的方法。在这个方法中,首先判断 authInfo 是否存在,如果存在则表示用户已经通过了登录检查。否则,再检查 errInfo?.message 是否等于 'No auth token',如果是的话,则表示用户没有提供有效的认证 token。根据这两种情况,可以判断出用户是否具有访问权限。

如果用户具有访问权限,则返回 authInfo,否则抛出 HttpUnauthorizedError 异常,表示用户没有权限访问 API。

管道(Pipe)

Nest.js 管道(Pipes)是一种用于对数据进行验证、转换和过滤的抽象概念。管道是一种重要的功能,因为它们可以用来处理传入应用程序的所有数据,并确保数据在进入应用程序之前经过验证和转换。

管道可以用于以下几种情况:

  1. 数据验证:可以使用管道来验证传入的数据是否符合预期。
  2. 数据转换:管道可以用来将传入的数据转换为不同的格式。例如,可以使用管道将字符串转换为数字或将日期字符串转换为日期对象。
  3. 数据过滤:管道可以用来过滤传入的数据。例如,可以使用管道来删除对象中的某些字段,或从数组中删除某些元素。

要创建管道,可以创建一个实现 PipeTransform 接口的类。这个接口要求实现一个 transform() 方法,该方法将被用来对传入的数据进行处理。在管道中,可以使用 transform()方法来验证、转换或过滤数据。

import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';

/**
 * 类装饰器数据类型校验
 * @export
 * @class ValidationPipe
 * @implements {PipeTransform<any>}
 */
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
    async transform(value: any, { metatype }: ArgumentMetadata) {
        if (this.toValidate(metatype)) {
            return value
        }
        const object = plainToClass(metatype, value)
        const errors = await validate(object)
        if (errors.length > 0) {
            const messages: string[] = []
            const pushMessage = (constraints = {}) => {
                messages.push(...Object.values<any>(constraints))
            }
            errors.forEach((error) => {
                if (error.constraints) {
                    pushMessage(error.constraints)
                }
                if (error.children) {
                    error.children.forEach((e) => pushMessage(e.constraints))
                }
            })
            throw new BadRequestException('参数错误:' + messages.join(', '));
        }
        return object
    }

    private toValidate(metatype: any): metatype is undefined {
        const types = [String, Boolean, Number, Array, Object];
        return !metatype || types.includes(metatype);
    }
}

上述用于验证传入的数据是否符合指定的类装饰器定义的类型。具体来说,该管道使用 class-validatorclass-transformer 库对传入的数据进行验证和转换。

该管道的 transform() 方法接收两个参数:valuemetadata。其中,value 是传入的数据,metadata 包含有关参数的元数据,例如参数类型等。

transform() 方法中,首先通过 toValidate() 方法检查是否需要验证传入的数据。如果传入的数据的类型属于 JavaScript 的原始类型,则无需验证,直接返回即可。否则,管道将使用 class-transformer 库将传入的数据转换为指定的类型。

一旦数据被转换为指定类型,管道将使用 class-validator 库对数据进行验证。如果存在任何验证错误,则将抛出 BadRequestException 异常,异常消息包含所有验证错误的详细信息。

总之,这段代码是一个非常实用的管道,可以用于验证传入的数据是否符合指定的类装饰器定义的类型,并返回转换后的数据或抛出验证错误异常。这对于开发基于 Nest.jsREST API 很有帮助。

以上就是前端日志服务后台相关业务逻辑,目前只是完成一些基本功能,能满足基本业务需求。

后续优化

  1. 上报机制优化,目前上报是通过Throttle限制的,这种方式在大量数据请求下很容易丢失。
  2. 优化路由页面缓存。
  3. 功能完善,目前后端接口还有很多接口未开发完成。如总览页面相关接口、页面性能相关接口、IP/设备分析相关接口、曝光和埋点相关总览接口等等。
  4. node层监控,后续可能会使用prometheus + granafa + prom-cilent作为node层监控(还处于研究中)。
  5. 优化sourcemap上传至云服务。
  6. pm2-logrotate管理Node层日志。

相对于专业的后端人员技能,我的水平只能算是业余,如有不妥之处,敬请指正。

文笔有限、才疏学浅,文中如有不正之处,万望告知。

以往相关文章链接

Nest + Emp2 构建BFF层

Nest + Emp2 实现BFF能做什么?