记录我的NestJS探究历程(十六)——教你如何使用装饰器改善设计

前言

因为最近这段时间基本上没有写公司的BFF的业务,所以已经很久没有更新NestJS专题的系列文章了,在之前的2-3天,我们的BFF出现了一个P3事故,因为日志记录的不够准确,导致排查bug太慢,因此,在跟公司的后端同事聊了一些关于他们的日志记录的方案之后,决定对目前的日志系统进行进一步的改造。

在之前已经出过一篇改善日志的文章了:记录我的NestJS探究历程(十五)——利用线程上下文改善日志设计 – 掘金 (juejin.cn),但是这次不是解决缺陷,而是改善设计以在将来遇到问题的时候能够快速排查bug。

在本文,主要涵盖的两个知识点是装饰器和ES6新增的Reflect API的实际应用,向大家阐述如何利用装饰器在不侵入业务的前提下渐进的改善代码的设计,因此具有一定的实战意义,大家可以借鉴这种设计思路,哈哈哈。

已知问题

通过和后端的同事交流,我发现我们的服务目前存在两个问题。

一是目前的代码在报错的时候,没有打印出报错的堆栈信息,没有堆栈信息不知道问题出在哪儿,这个很简单,加一行代码就可以了。

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: object = {},
  ): Promise<StandardResponse<R>> {
    try {
      const resp = await fn.call(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,
    };
  }
}

因为我用控制器统一封装的方法处理的话,错误捕获的范围是一个预期可控的,但是又不想把这个错误交给过滤器处理,因此就采用的是这种方式处理的。

二是目前函数的方法调用不够明确,比如我的某个方法通过gRPC调用业务后端的微服务,我传入的参数是什么,gRPC服务返回给我的是什么,这些都是不够明确的,在紧急的时候这无法定位系统的缺陷,因此,需要对数据访问层追加一个能力,某些方法在执行的时候,需要具备自动打印日志的能力。这个问题,实现起来就不是那么简单了,反正三言两语肯定是说不清楚的了,以下篇幅我们就围绕这个问题分析。

思考如何设计

针对之前提到的函数的参数记录不够明确的问题,这种肯定是一种基于AOP的设计思想来完成,只有这样才能不会对业务造成侵入。而我的代码基本上都是基于类的,很显而易见,这就可以使用装饰器来进行非侵入式的编程场景。

另外,一个数据访问类里面,这些方法们肯定大部分被调用时都是需要记录日志的,只有少数的不需要,如果编写针对方法的装饰器的话,那就会对每个方法都去挂载一个装饰器,这样肯定会比较麻烦,所以考虑设计一个装饰类的装饰器,在装饰器内部去操作类的原型上的每个方法,相对来说使用起来要舒服一些。

还有一个关键点,打印参数会牵涉到序列化,这会导致系统的吞吐效率下降,所以需要权衡什么时候需要打印入参,什么时候需要打印返回,比如一个接口返回庞大的数据,一下子业务后端返回100条数据,这个时候再做序列化,那是相当消耗性能的,因此,必须一些额外的装饰器来配合,实现控制不打印入参和返回结果的能力。

另外,还有一个点,有些系统中已经存在属性装饰器把类的方法装饰之后,变成了一个getter,这样的场景需要小心处理一下。

关于装饰器的知识点,本文不做任何阐述,有兴趣的同学可以参考我早期的文章:从babel的编译结果来学习装饰器 – 掘金 (juejin.cn)

编码实现

如果大家没有阅读过前面几篇我在分析NestJS源码装饰器处理过程的同学,可以先看一下。

因为我们会用到一个非常好用的第三方库(reflect-metadata)扩展在Reflect上的API,比如Reflect.defineMetadataReflect.getMetadata等。

编写忽略入参和返回结果的装饰器

这两个装饰器很简单,其实就是给类的方法定义一些元数据,一会儿,我们在函数执行的时候,取出来看看有没有定义这些元数据,有的话,就不处理了。

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

/**
 * 完全忽略方法调用时,日志打印
 * @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;
}

编写打印日志的类装饰器

对于ES6类相关的知识点,我之前有写过关于它的文章,有兴趣的同学可以参考我早期的文章:从babel编译结果来学习ES6-class – 掘金 (juejin.cn),它不是本文的重点,就不详细介绍了。

我们编写的装饰器装饰的对象是类,因此,我们操作的对象就是类的原型,在定义元数据的时候,也要针对target.prototype定义,这是需要非常小心的地方。

因为类的方法都是定义在原型上的,并且它们都是不可枚举的(即Descriptor属性的enumerable),所以在这儿我们就无法使用ES6新增的Object.entriesObject.valuesObject.keys,在这个位置只能使用Reflect.ownKeys

以下是实现:

/**
 * 装饰目标类,被装饰的目标类的每个方法都将会打印日志的输入参数和返回结果,可以根据和其他装饰器配合,决定是否忽略
 * @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,
    );
    if ((isIgnoreInput && isIgnoreOutput) || typeof fn !== 'function') {
      return;
    }
    // 此处不能使用箭头函数,需要让被修改的函数的this指向预期的this
    target.prototype[prop] = function withLogFn(...args: any[]) {
      const className = target.prototype.constructor?.name || 'unknown';
      const inputArgs: any = !isIgnoreInput ? JSON.stringify(args) : '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}`,
        );
      });
      return resp;
    };
  });
}

在上面的代码中,我们在修改了类的属性,给它重新绑定增强的函数时,一定只能使用普通函数而不能使用箭头函数,因为我们不能改变函数的this指向,而需要由运行时确定,否则编写业务的同学通过this调类中的其它函数时就会报错的,这就对业务造成了侵入,这是不可接受的。

处理其它可能带有干扰的装饰器

恰好,我的项目就有干扰的装饰器,因为以前,我为了防止类的方法this在调用时可能出现指向不正确的问题,我编写过一个AutoBind的装饰器来处理this的问题。

AutoBind本来的代码如下:

/**
 * 自动绑定方法的上下文,在某些Service的this可能会被改变的时候,强制绑定类定义的时候所在的this上下文
 * @param target
 * @param prop
 * @param descriptor
 * @returns
 */
export function AutoBind(
  target: any,
  prop: string,
  descriptor: PropertyDescriptor,
): PropertyDescriptor {
  const originalMethod = descriptor.value;
  const adjustedDescriptor: PropertyDescriptor = {
    configurable: true,
    get() {
      const bindFn = originalMethod.bind(this);
      return bindFn;
    },
  };
  return adjustedDescriptor;
}

使用过AutoBind装饰器装饰之后,类的属性变成了一个getter,所以之前我的类装饰器是无法处理到它的,因为我们不太好对getter就行修改。所以,这种情况,我们就只能对getter进行改写了,即修改这个AutoBind装饰器。

在之前,我们特意留了一个心眼,我们在对类处理的时候,定义了一个元数据,知道这个类是否是被CallTrack装饰过,现在,刚好可以利用这个标记。

因此,改写之后,代码设计成这样:

import { APPLY_LOG, IGNORE_INPUT, IGNORE_OUTPUT } from './call-track.decorator';

/**
 * 自动绑定方法的上下文,在某些Service的this可能会被改变的时候,强制绑定类定义的时候所在的this上下文
 * @param target
 * @param prop
 * @param descriptor
 * @returns
 */
export function AutoBind(
  target: any,
  prop: string,
  descriptor: PropertyDescriptor,
): PropertyDescriptor {
  const originalMethod = descriptor.value;
  const adjustedDescriptor: PropertyDescriptor = {
    configurable: true,
    get() {
      const isLog = Reflect.getMetadata(APPLY_LOG, target);
      // 主要是用来处理可能序列化的参数太长的问题
      const isIgnoreInput = Reflect.getMetadata(IGNORE_INPUT, target, prop);
      const isIgnoreOutput = Reflect.getMetadata(IGNORE_OUTPUT, target, prop);
      // 如果不需要打印日志的话,直接就可以返回了
      if (!isLog || (isIgnoreInput && isIgnoreOutput)) {
        return originalMethod.bind(this);
      }
      const className = target.constructor?.name || 'unknown';
      const bindFn = (...args: unknown[]) => {
        const inputArgs = !isIgnoreInput ? JSON.stringify(args) : 'SKIP';
        const resp = originalMethod.apply(this, args);
        Promise.resolve(resp).then((val) => {
          const outputArgs = !isIgnoreOutput ? JSON.stringify(val) : 'SKIP';
          console.log(
            `类${className}${prop}方法被调用,输入参数:${inputArgs},返回值:${outputArgs}`,
          );
        });
        return resp;
      };
      return bindFn;
    },
  };
  return adjustedDescriptor;
}

在这个位置,为什么我又必须要使用箭头函数呢?因为函数在执行的时候,需要绑定到getter的this上去执行,这样就跟之前的效果是一致的了,最终运行的时候会随调用者的函数上下文自动切换指向。

我们只能在getter内部去获取是否被IgnoreInputIgnoreOutput装饰器装饰过,为什么必须是这样呢?因为这牵扯到一个装饰器执行的先后顺序问题,如果在外层处理的话,那就是在类还在初始化阶段就需要去拿元数据,写在里面,就可以在getter执行的时候再获取,这样就避免了挂载装饰器先后顺序的要求,从而提升API的友好性。内层作用域引用了外层的target相关变量,很显然,这还是一个闭包的应用场景。

另外,因为在属性装饰器里面,装饰器的第一个参数直接指向的就是类的原型对象,所以,我们定义元数据的时候,只需要操作target即可,这就是为什么我们写的是Reflect.getMetadata(IGNORE_INPUT, target, prop),请各位同学注意区别并加以理解。

实际使用

我们只需要需要记录日志的类上追加CallTrack装饰器即可。

@CallTrack
export class DemoRepository {
  test() {
    console.log('test');
  }

  @AutoBind
  test2() {
    this.test();
    return 'hello';
  }

  getDataList(a: string, b: string) {
    this.test();
    return {
      a: a.repeat(10),
      b: b.repeat(10),
    };
  }
}

记录我的NestJS探究历程(十六)——教你如何使用装饰器改善设计
如果我们不需要打印入参,仅仅需要给方法追加装饰器IgnoreInput,不需要打印返回结果,仅仅需要给方法追加装饰器IgnoreOutput

@CallTrack
export class DemoRepository {
  // 打印日志时忽略函数的返回结果 
  @IgnoreOutput
  getDataList(a: string, b: string) {
    this.test();
    return {
      a: a.repeat(10),
      b: b.repeat(10),
    };
  }
}

记录我的NestJS探究历程(十六)——教你如何使用装饰器改善设计

@CallTrack
export class DemoRepository {
  // 打印日志时忽略函数的参数 
  @IgnoreInput
  getDataList(a: string, b: string) {
    this.test();
    return {
      a: a.repeat(10),
      b: b.repeat(10),
    };
  }
}

记录我的NestJS探究历程(十六)——教你如何使用装饰器改善设计
如果直接都不想打印日志,那就直接使用之前定义的Ignore装饰器即可。

总结

本文结合一个实际的业务场景,向大家展示了一个具有说服力的装饰器的使用场景。在装饰器的实际应用中reflect-metadata这个库能够算的上是核武器级别的神器,能够极大的方便我们的开发。

关于装饰器,我给大家的建议是不要为了用而用,因为某个语法的出现,一定是有它的道理的,如果单纯的只是为了用而用,会让我们的设计变的复杂,反而不利于代码的维护,就适得其反了。

装饰器其实就是AOP的语法的天然实现而已,关于AOP的实现,除了装饰器,还可以使用高阶函数实现,所以装饰器在前端开发中几乎不会用到,前端开发中都是用高阶函数实现。

但是并不是说装饰器完全没有用武之地,装饰器的使用场景有一个强有力的标志,那就是类,如果一旦出现了类+AOP的需求,那就是装饰器的再合适不过的使用场景了,请各位读者加以体会。(根据我的个人开发经验,在开发脚手架,开发Node服务的时候装饰器往往能派上用场

本文结合了很多ES6新增的API,并向大家解释了为什么要用它,大家可以通过这些点窥一斑而知全豹,进而了解这些知识点的其它API,从而可以实现融会贯通。根据我的成长经验来说,这种效果是非常好的,不仅能够记得住是什么,还知道怎么实际怎么应用。

对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

原文链接:https://juejin.cn/post/7341288089494355995 作者:HsuYang

(0)
上一篇 2024年3月2日 下午4:21
下一篇 2024年3月2日 下午4:31

相关推荐

发表评论

登录后才能评论