基于前端视角的web服务端搭建(二)

264 阅读6分钟

接上篇 juejin.cn/post/739586…

五.日志

服务端经常要排查问题慢sql等。不像客户端可以直接debug,服务端得依赖日志来分析。我们是否能准确快捷的查询到我们想要知道的信息,分析的效率对服务端来说非常的重要。所以在选用日志架构时我们必须重视日志的收集检索

下面我们来集成Winston收集日志,cls-rtracer生成全链路traceId方便检索,阿里日志服务查找分析日志

阿里日志服务大家也可以根据实际情况替换(例如sentryelasticsearch

1.日志格式

type LogInfo = {
    level: 'info' | 'debug' | 'warn' | 'error',
    context: any,
    message: string,
    'X-Request-Id': string,
    timestamp: string,
    stack?: string,
}

// 使用 
logger.log('测试', { a: 1 });
// 打印
{
    message: '测试',
    context: { a: 1 },
    level: 'info',
    ...
}
其中level,X-Request-Id,timestamp会自动生成
stack 在logger.error时会自动记录

2.日志收集

一.首先下载我们所需依赖包

pnpm i nest-winston winston cls-rtracer

二.配置日志组件winston

app.modules.ts

import { WinstonModule, utilities  } from 'nest-winston';
import * as winston from 'winston';
//...略
@Module({
  imports: [
    WinstonModule.forRoot({
      transports: [new winston.transports.Console()],
    })
  ]
})
//...

main.ts

import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER));
  await app.listen(3000);
}
bootstrap();

配置好了之后,我们就能在业务代码中使用winston来记录日志了

三.记录请求响应日志

1.新建拦截器 src\common\interceptor\http-record\http-record.interceptor.ts 2.使用winston记录日志

import { Inject, LoggerService, CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { Response } from 'express';
import { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { StreamableFile } from '@nestjs/common';

@Injectable()
export class HttpRecordInterceptor implements NestInterceptor {
  @Inject(WINSTON_MODULE_NEST_PROVIDER)
  private readonly logger: LoggerService;
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    const response = context.switchToHttp().getResponse<Response>();
    this.logger.log('请求参数', {
      url: request.url,
      method: request.method,
      body: request.body,
      query: request.query,
      params: request.params
    })
    return next.handle().pipe(
      tap((res: Response) => {
        if (res instanceof StreamableFile) return
        this.logger.log('响应参数', {
          response: res,
          statusCode: response.statusCode
        })
      })
    )
  }
}

3.在app.module.ts中注册为全局拦截器

  import { APP_INTERCEPTOR } from '@nestjs/core';
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: HttpRecordInterceptor,
    }
    // ...
  ],

四.生成全链路ID与自动记录全链路id

1.自动为每个请求生成唯一id

在根模块去配置链路ID,在该请求链路上,通过rTracer.id()能拿到此链路id。同时自动给响应头加上链路id

app.modules.ts

import * as rTracer from 'cls-rtracer';
// ...
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(rTracer.expressMiddleware({
        echoHeader: true, // 自动给响应头加上x-request-id
      })).forRoutes('*');
  }
}

2.自动给每个打印方法绑定该链路id

新建winston插件 src\utils\log\console-trace.ts

const { format } = require('logform');
import * as rTracer from 'cls-rtracer';

export default format((info, opts) => {
    const rid = rTracer.id()
    if (rid) {
        info['X-Request-Id'] = rid;
    }
    return info;
  });

然后在winston配置该插件,同时配置时间戳,及其它格式化插件

app.modules.ts

import { WinstonModule, utilities  } from 'nest-winston';
import * as winston from 'winston';
import consoleTrace from './utils/log/console-trace';
import context2String from './utils/log/context-2-string';
import consoleReport from './utils/log/console-report';

const isDev = !process.env.npm_lifecycle_script.includes('nest start');
const combine = [
    winston.format.timestamp(), 
    winston.format.ms(), 
    consoleTrace(), 
    context2String()
]
isDev && combine.push(...[utilities.format.nestLike('MyApp', {
  colors: true,
  prettyPrint: true,
  processId: true,
  appName: true,
})]) // 开发模式下打印带颜色
!isDev && combine.push(consoleReport())

@Module({
  imports: [
    WinstonModule.forRoot({
      transports: [new winston.transports.Console({
        format: winston.format.combine(
            ...combine
          ),
      })],
    })
  ]
})

五.如何在业务代码里面引用winston

1.导入下面的包

import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { LoggerService } from '@nestjs/common';

2.注入属性

@Inject(WINSTON_MODULE_NEST_PROVIDER) 
private readonly logger: LoggerService;

3.使用

this.logger.log('ossExpiration', yourdata)

6.验证日志打印与全链路id

使用POSTMAN并发两个请求,看看他们的链路ID是否和请求绑定

image.png

结果:验证成功

同时我们的响应头也分别带上了链路ID

响应一

image.png

响应二 image.png

三.日志上报

日志服务SDK提供全流程的日志管理。其使用基本流程大致如下:

  1. 开通日志服务。
  2. 获取访问密钥。
  3. 创建项目Project和日志库Logstore。
  4. 日志采集并存储至Logstore。
  5. 为日志创建索引。
  6. 查询和分析日志,可视化展示。
  7. 对日志数据进行加工、投递和告警等操作

1.阿里云日志服务环境搭建

阿里云产品都有一个月免费试用期,我们可以来白嫖一个日志服务。

  1. 首先进入官网使用日志服务 free.aliyun.com/?spm=5176.2…

image.png

2.然后在日志服务中创建project sls.console.aliyun.com/lognext/pro… (project,logstore全部点下一步就好,直接用默认配置就行)

image.png

3.然后在创建logstore

image.png

4.创建好后会来到查询与分析配置面板,我们稍等在来配置

image.png

5.先来给我们的角色授权(角色授权步骤可见《基于前端视角的web服务端搭建(一)》,这里只是给的权限点不一样)

image.png

2.node sdk 采集日志

授权完后我们来创建winston插件来自动上报数据

阿里云上报有格式要求,所以我们先创建winston插件处理格式 src\utils\log\context-2-string.ts

const { format } = require('logform');
const jsonStringify = require('safe-stable-stringify');

export default format((info, opts) => {
    info.context && (info.context = jsonStringify(info.context))
    info.stack && (info.stack = jsonStringify(info.stack))
    return info;
  });

然后在创建上报插件src\utils\log\console-report.ts

const ALY = require('aliyun-sdk')
const { format } = require('logform');
let arr = [] // 缓存日志,用来批量上传,这里推荐用文件存储来代替,否则可能会出现内存问题
var sls = new ALY.SLS({
    // 本示例从环境变量中获取AccessKey ID和AccessKey Secret。
    "accessKeyId": process.env.ACCESS_KEY_ID,
    "secretAccessKey": process.env.ACCESS_KEY_SECRET,
    //日志服务的域名。此处以杭州为例,其它地域请根据实际情况填写。 
    endpoint: 'http://cn-shenzhen.log.aliyuncs.com',
    //SDK版本号,固定值。
    apiVersion: '2015-06-01'
})
// 必选,Project名称。
const projectName = process.env.LOG_PROJECT_NAME
// 必选,Logstore名称。
const logstoreName = process.env.LOG_STORE_NAME

// 写入日志。
function writeLog(logs) {
    const param = {
        projectName,
        logStoreName: logstoreName,
        logGroup: {
            // 必选,写入的日志数据。格式固定
            logs: logs.map(info => {
                return {
                    time: info.timestamp ? Math.floor(new Date(info.timestamp).getTime() / 1000) : Math.floor(new Date().getTime() / 1000),
                    contents: Object.keys(info).map((key) => {
                            return {
                                key,
                                value: info[key]
                            }
                    })
                }
            }),
            topic: 'nest-logs',
            source: '127.0.0.1'
        }
    }

    sls.putLogs(param, function (err, data) {
        if (err) {
            console.error('error:', err)
            arr.push(logs)
        } else {
            // console.log('写入日志成功', data)
        }
    })
}

// 定时批量上报
setInterval(() => {
        const logs = arr.splice(0, 100)
        if (logs.length) {
            writeLog(logs)
        }
}, 5000)

export default format((info, opts) => {
    arr.push(info) // 每次打印时添加日志到缓存
    return info;
});

创建完后配置给winston即可自动处理日志上报

import consoleTrace from './utils/log/console-trace';
import context2String from './utils/log/context-2-string';
import consoleReport from './utils/log/console-report';
const isDev = !process.env.npm_lifecycle_script.includes('nest start');
const combine = [
    winston.format.timestamp(), 
    winston.format.ms(), 
    consoleTrace(), 
    context2String()
]
isDev && combine.push(...[utilities.format.nestLike('MyApp', {
  colors: true,
  prettyPrint: true,
  processId: true,
  appName: true,
})]) // 开发模式下打印带颜色
!isDev && combine.push(consoleReport())

@Module({
  imports: [
    WinstonModule.forRoot({
      transports: [new winston.transports.Console({
        format: winston.format.combine(
            ...combine
          ),
      })],
    })
  ]
})

3.检验日志上报

接着我们来打印并上报一条请求, 此时我们可以看到控制台打印了日志,并上报成功

image.png

然后回到查询分析配置面板,可以看到面板成功收集到了我们的请求,接着我们再来配置索引,方便查询日志

(可以直接根据请求自动生成索引,然我们再来做微调就好,非常方便)

image.png

image.png

image.png

索引配置好后,我们点击下一步,此时一个完整的日志收集与查询服务就搭建好了。我们多造几条数据,来查询看看

image.png

总结

本节演示了日志的采集与查询。实际项目中我们还有很多优化的地方,比如慢sql的采集,全局错误的采集等。采集方式也有优化的地方,使用文件来缓存打印而不是内存来缓存,或者使用一些第三方工具来采集都可以。最后我们也可以通过监控日志来进行告警来完成整个闭环。本文对这些优化就不一一演示了,大家感兴趣可以自己去看文档来一一完善。

未完待续~