NestJS实战之开发短链服务(三)

320 阅读9分钟

前言

接上一篇文章,NestJS实战之开发短链服务(二),在上一篇文章中,我们聊清楚了关于数据库的配置以及短链码的生成策略,这一章,我们开始对一些项目的通用配置进行阐述。

一些基本操作主要包括几大类,如统一的接口返回,比如常见的,我们在跟后端联调的时候,返回的结构基本上如下所示:

interface HttpResponse<T> {
    data: T;
    code: number;
    message: string;
}

其中,data是接口请求的返回数据,我们之所以用泛型来表示,因为在不同的业务下,data的结构是不一样的,但是在那个特定的业务场景下,它的结构是确定的,所有不能用any或者unknown类型,而必须是用泛型;code表示状态码,这个跟业务团队的约定有关系,没有绝对的准则;最后是提示信息,这种内容可以供前端友好的展示以使得用户对自己的操作影响有明确的认知。

另外一个稍微显得较为重要的一个点是日志系统,因为良好的日志记录能够在实际生产的过程中帮助我们快速的定位问题,及时止损。

最后一个关键点就是统一的异常捕获,因为NodeJS单线程的性质,使我们在编写服务的时候应该尤为谨慎,所以我们应该谨慎的捕获程序运行过程中的可能出现的错误以防止主进程非异常的终止。

针对上述的三点,我们将依次进行详细的阐述。

基础方法封装

我是通过一定了一个BaseController,将这些公共的方法抽离到这个类里面去,在业务Controller里面只需要继承BaseController即可拥有全部的封装方法。如果你不用继承,定义一个工具类,然后使用依赖注入也是可以的,具体就是个人的编程偏好了,各位读者可以酌情使用。

import { Inject } from '@nestjs/common';
import { StandardResponse } from '../types';

export class BaseController {

  /**
   * 向客户端发送失败的响应
   * @param message 错误信息
   * @param code 错误码
   * @returns
   */
  protected sendErrorResponse(message: string, code = 1) {
    return {
      code,
      message,
      data: null,
    };
  }

  /**
   * 统一封装的执行函数
   * @param fn 待执行的函数
   * @param args 待执行函数的参数
   * @returns
   */
  public async safetyExecuteFn<R>(
    fn: (...params: unknown[]) => Promise<R>,
    args: Array<any> = [],
  ): Promise<StandardResponse<R>> {
    try {
      const resp = await fn.apply(this, args);
      return this.sendSuccessResponse(resp);
    } catch (exp) {
      console.log(exp.stack);
      return this.sendErrorResponse(exp.message || '未知错误', exp.code);
    }
  }

  /**
   * 向客户端发送成功的响应
   * @param data 响应的数据
   * @param message 响应信息
   * @returns
   */
  protected sendSuccessResponse<T>(
    data: T,
    message = '请求成功',
  ): StandardResponse<T> {
    return {
      code: 0,
      message,
      data,
    };
  }
}

日志系统的处理

日志系统的处理有点儿麻烦,看你们的项目需求是什么样的情况,因为在日志系统中,我们往往需要打印一些用户的Request对象上的信息,而日志组件的使用并不是单独局限在API层,所以Request对象的传递可能会让人有点儿头疼。

普通日志处理

在之前的文章中,我们已经对这个痛点有了一定的认识,并且有了解决方案。记录我的NestJS探究历程(十五)——利用线程上下文改善日志设计

我们需要引入nestjs-cls这个包。

import { TrackInterceptor } from './common/interceptors/track.interceptor';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import {
  AppBootstrapConfigId,
  NacosNamespace,
  getEnvFiles,
} from './common/config';
import { ManageModule } from './manage/manage.module';
import { ClsModule } from 'nestjs-cls';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppConfigModule } from './app-config/app-config.module';
import { NacosConfigModule } from 'nestjs-nacos';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: getEnvFiles(),
    }),
    NacosConfigModule.register(
      {
        url: process.env.NACOS_ADDRESS,
        namespace: NacosNamespace,
        timeout: 30000,
      },
      true,
    ),
    AppConfigModule.forRoot({
      dataId: AppBootstrapConfigId,
    }),
    // 注册ClsModule
    ClsModule.forRoot({
      global: true,
      middleware: { mount: true },
    }),
  ],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TrackInterceptor,
    },
  ],
})
export class AppModule {}

然后再在一个拦截器里面挂将Request对象设置给ClsServiceManager

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Observable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class TrackInterceptor implements NestInterceptor {
  constructor(private readonly cls: ClsService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    request.traceId = uuidv4(32);
    this.cls.set('request', request);
    return next.handle();
  }
}

在我们的日志系统里面,就可以直接通过ClsServiceManager获取到Request对象,从而实现更友好的日志记录。

我的日志类是这样定义的,首先是一个基础类:

import * as dayjs from 'dayjs';
import { Request } from 'express';
import { AsyncLocalStorage } from 'async_hooks';
import { FormattedLog, RequestLogCommonStructure } from '../types/logger';
import { Logger } from '@nestjs/common';

export abstract class LoggerService {
  protected reqStorageCtx = new AsyncLocalStorage<
    Request & { traceId: string }
  >();

  /**
   * 子类返回Request上下文对象
   */
  protected abstract getRequest(): Request;

  /**
   * 获取请求上下文的公共数据
   * @returns
   */
  protected getRequestCommonBodyLog(): RequestLogCommonStructure {
    const req = this.getRequest() as Request & { traceId: string };
    if (!req) {
      return {
        traceId: '',
        method: '',
        url: '',
        args: {
          body: '',
          params: '',
          query: '',
        },
      };
    }
    const traceId = req.traceId;
    return {
      traceId,
      method: req.method,
      url: req.route?.path,
      args: {
        body: req.body,
        params: req.params,
        query: req.query,
      },
    };
  }

  private getLogBody(level: string, logContext: Partial<FormattedLog>) {
    const { traceId, method, url, args } = this.getRequestCommonBodyLog();
    return {
      _LEVEL_: level,
      _TS_: dayjs(new Date()).format('YYYY/MM/DD HH:mm:ss'),
      _CALLER_: logContext.caller || 'unknown',
      _MSG_: '',
      // TODO: 这个version随便写的
      _VER_: `v1`,
      _DEPLOY_: process.env.NODE_ENV,
      _APP_: 'tinyurl',
      _COMP_: 'server',
      _TYPE_: logContext.type || 'general',
      traceID: traceId,
      // TODO: 待定
      spanID: '',
      operation: '',
      method: method || 'unknown',
      uri: url || 'unknown',
      args: logContext.args || args || 'unknown',
      code: logContext.code || -1,
      message: logContext.message || 'unknown',
      spend: logContext.spend || '',
    };
  }

  public log(message: Partial<FormattedLog>) {
    const formattedLog = this.getLogBody('log', message);
    Logger.log(JSON.stringify(formattedLog));
  }

  public error(message: Partial<FormattedLog>) {
    const formattedLog = this.getLogBody('error', message);
    Logger.error(JSON.stringify(formattedLog));
  }

  public warn(message: Partial<FormattedLog>) {
    const formattedLog = this.getLogBody('warn', message);
    Logger.warn(formattedLog);
  }

  public debug(message: Partial<FormattedLog>) {
    const formattedLog = this.getLogBody('debug', message);
    Logger.debug(formattedLog);
  }
}

然后用一个单例的类来继承这个基础类,在子类里面通过ClsServiceManager获取到Request对象。

import { Request } from 'express';
import { LoggerService } from './logger.service';
import { ClsServiceManager } from 'nestjs-cls';

/**
 * 标准日志打印器,在无法注入Request对象的时候,手动设置Request对象之后可调用,单例模式
 */
export class SingletonLoggerService extends LoggerService {
  private constructor() {
    super();
  }

  protected getRequest(): Request {
    const cls = ClsServiceManager.getClsService();
    const request: Request = cls.get('request') as Request;
    return request;
  }

  private static _instance: SingletonLoggerService;

  public static getInstance() {
    if (!this._instance) {
      this._instance = new SingletonLoggerService();
    }
    return this._instance;
  }
}

然后在任意地方,都可以通过直接调用SingletonLoggerService这个类实现无痛的日志记录。

数据访问日志处理

这个点,主要是为了我们方便快速查找问题,在底层基础服务调用过程中,我们传递了什么数据,得到了什么样的结果,这样的流程无疑是最清晰的,如果光有一个错误的日志信息打印,没有更细节的信息,这对代码问题的定位是起不到什么实质性的帮助的,因此,我们还需要把日志记录的更加清楚,需要对这些类都增加一个日志记录的切面。

这个问题也是我在之前的一次线上事故中暴露出来的问题,后面通过自定义了一个装饰器得到了解决。

export const APPLY_LOG = Symbol('apply-log');
export const IGNORE_INPUT = Symbol('ignore-input');
export const IGNORE_OUTPUT = Symbol('ignore-output');
export const IGNORE_TRACK = Symbol('ignore-track');

const IGNORE_KEYS = ['constructor'];

/**
 * 完全忽略方法调用时,日志打印
 * @param target 原型
 * @param prop 属性
 * @param descriptor 属性装饰器
 * @returns
 */
export function Ignore(
  target: unknown,
  prop: string,
  descriptor: PropertyDescriptor,
) {
  Reflect.defineMetadata(IGNORE_TRACK, true, target, prop);
  return descriptor;
}

/**
 * 忽略方法调用时,日志打印入参
 * @param target 原型
 * @param prop 属性
 * @param descriptor 属性装饰器
 * @returns
 */
export function IgnoreInput(
  target: unknown,
  prop: string,
  descriptor: PropertyDescriptor,
) {
  Reflect.defineMetadata(IGNORE_INPUT, true, target, prop);
  return descriptor;
}

/**
 * 忽略方法调用时,日志打印返回结果
 * @param target 原型
 * @param prop 属性
 * @param descriptor 属性装饰器
 * @returns
 */
export function IgnoreOutput(
  target: unknown,
  prop: string,
  descriptor: PropertyDescriptor,
) {
  Reflect.defineMetadata(IGNORE_OUTPUT, true, target, prop);
  return descriptor;
}

/**
 * 装饰目标类,被装饰的目标类的每个方法都将会打印日志的输入参数和返回结果,可以根据和其他装饰器配合,决定是否忽略
 * @param target 目标类
 */
// eslint-disable-next-line @typescript-eslint/ban-types
export function CallTrack<T extends { new (...args: any[]): {} }>(target: T) {
  Reflect.defineMetadata(APPLY_LOG, true, target.prototype);
  Reflect.ownKeys(target.prototype).forEach((prop: string) => {
    if (IGNORE_KEYS.includes(prop)) {
      return;
    }
    // 仅仅处理只有value的属性,因为getter可能是被AutoBind装饰过了
    const descriptor = Reflect.getOwnPropertyDescriptor(target.prototype, prop);
    const fn = descriptor.value;
    const isIgnoreInput = Reflect.getMetadata(
      IGNORE_INPUT,
      target.prototype,
      prop,
    );
    const isIgnoreOutput = Reflect.getMetadata(
      IGNORE_OUTPUT,
      target.prototype,
      prop,
    );
    const isIgnoreTrack = Reflect.getMetadata(
      IGNORE_TRACK,
      target.prototype,
      prop,
    );
    if (
      isIgnoreTrack ||
      (isIgnoreInput && isIgnoreOutput) ||
      typeof fn !== 'function'
    ) {
      return;
    }
    // 此处不能使用箭头函数,需要让被修改的函数的this指向预期的this
    target.prototype[prop] = function withLogFn(...args: any[]) {
      const className = target.prototype.constructor?.name || 'unknown';
      let inputArgs: any;
      if (!isIgnoreInput) {
        try {
          inputArgs = JSON.stringify(args);
        } catch (error) {
          inputArgs = 'Cannot Stringify';
        }
      } else {
        inputArgs = 'SKIP';
      }
      const resp = fn.apply(this, args);
      Promise.resolve(resp)
        .then((val) => {
          const outputArgs = !isIgnoreOutput ? JSON.stringify(val) : 'SKIP';
          console.log(
            `类${className}${prop}方法被调用,输入参数:${inputArgs},返回值:${outputArgs}`,
          );
        })
        .catch(() => {
          console.log(
            `类${className}${prop}方法被调用,输入参数:${inputArgs},执行出错`,
          );
        });
      return resp;
    };
  });
}

这个装饰器的思路很简单,通过对类施加装饰,然后对类的每个方法加以改造,在方法的执行过程中打印输入参数和输出参数,并且不会干扰方法本身的行为。

对于一些输入或输出内容比较庞大的方法,还提供了过滤的能力,以防日志降低系统的性能。

对于这个装饰器的使用,直接挂载在某个类上即可,如:

@Injectable()
@CallTrack
export class RuleService {
    /**
   * 根据ID复制一个短链
   * @param id
   */
  async copyRule(id: number, createUser: string) {
    // 未实现
  }
}

copyRule方法在执行的时候将会自动打印调用的输入和输出内容。

全局的异常捕获

在之前的的BaseController中,我们已经定义了一个错误捕获方法,不过那还不够,我们还需要处理全局可能未捕获的异常,或者我们抛出的自定义异常。

在NestJS的原理章节,我们讲过NestJS的代码是包在错误过滤器中执行的,记录我的NestJS探究历程(六)——过滤器,所以对于项目中,我们想提前返回的话,可以直接抛出自定义错误,然后被过滤器捕获到,从而提前返回。

因此,需要定义一个统一的异常捕获来处理:

import {
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { NamedHttpException } from '@/modules/common/exceptions/named.exception';
import { PayloadNamedHttpException } from '@/modules/common/exceptions/payload-named.exception';
import { SingletonLoggerService } from '../services/singleton-logger.service';

@Catch()
export class StandardErrorCaptureExceptionFilter extends BaseExceptionFilter {
  get logger() {
    return SingletonLoggerService.getInstance();
  }

  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    let code: number;
    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'server internal error';
    let data: unknown;
    if (exception instanceof PayloadNamedHttpException) {
      status = exception.status;
      message = exception.message;
      code = exception.code;
      // 没有的话就赋值为undefined为的就是前端看不到这个字段
      data = exception.data instanceof Object ? exception.data : undefined;
    } else if (exception instanceof NamedHttpException) {
      status = exception.status;
      message = exception.message;
      code = exception.code;
    } else if (exception instanceof HttpException) {
      status = exception.getStatus();
      message = exception.message;
    } else if (typeof exception === 'string') {
      message = exception;
    } else if (exception && exception.message) {
      message = exception.message;
    }

    // 此时的logger已经拿到了request对象了
    this.logger.error({
      code: String(status),
      caller: 'global-error-capture',
      message,
    });

    response.status(status).json({
      // INFO: 此处不能用||,不能把0给处理了
      code: code ?? status,
      message,
      data: data ?? null,
    });
  }
}

然后记得在应用入口处绑定这个过滤器。

// 已省略其它代码
@Module({
  imports: [],
  providers: [
    {
      provide: APP_FILTER,
      useClass: StandardErrorCaptureExceptionFilter,
    },
  ],
})
export class AppModule {}

结语

在这篇文章中,我们已经完成了项目的基础配置了,在后续的代码开发中,我们需要考虑的问题就比较少了,就可以傻瓜式的编写业务代码了,在下一节中,我们将开始对短链的CRUD模块进行展示,敬请期待......