Nestjs 如何优雅地记录操作日志?

2,013 阅读14分钟

最近做一个管理后台,需要有一个操作日志功能,一开始没想好怎么弄,通过搜索发现两篇文章和一个实现仓库:

仔细研读了一下,茅塞顿开。

用户在操作我们系统的过程中,针对一些重要的业务数据进行增删改查的时候,我们希望记录一下用户的操作行为,以便发生问题时能及时的找到证据,这种日志就是业务系统的操作日志。

操作日志主要记录某一时间下谁对什么做了什么事情,操作日志一般限定于创建、更新和删除操作,而查询并不是什么敏感操作,所以一般无需记录操作日志。其中最重要的是更新操作,创建和删除都是单向的,只需要操作人创建或删除了并记录操作人,操作时间等标识信息即可,更新操作就比较复杂了,需要有操作之前数据值,更新之后数据值,才构成一条数据属性操作记录。

例如:

  • 2024-01-20 jiayi 创建一篇文章《如何优雅地记录操作日志?》
  • 2024-01-21 jiayi 更新文章标题《如何优雅地记录操作日志?》为《Nestjs 如何优雅地记录操作日志?》
  • 2024-01-22 jiayi 删除一篇文章《Nestjs 如何优雅地记录操作日志?》

常见的操作日志类型

  • 用户登录日志
  • 重要数据查询日志(如电商可能不重要的数据也做埋点,比如你搜索什么商品,即使不买,一段时间内首页也会给你推荐类似的东西)
  • 重要数据变更日志(如密码变更,权限变更,数据修改等)
  • 数据删除日志

总结来说,就是重要的增删改查根据业务的需要来做操作日志的埋点

实现方案思路

最初想实现通过日志文件的方式记录,实现一遍发现比较麻烦,选择放弃了。

然后想通过 Logger 服务的方式记录日志,每个需要打操作日志地方,注入服务,调用对应方法传递参数。感觉很方便,写了一遍发现很麻烦。

有没有简单的方式了,在群里聊天,有人说 AOP 实现,正巧阅读上面两篇文章。刚好 Nest 也是支持 Decorator + Interceptor

基于 AOP(切面)实现的方式,对代码的侵入性不强,通常记录 ip、业务模块、操作账号、操作场景、操作来源等等,一般在 Decorator + Interceptor 里这些值都拿得到。

我实现方案和《如何优雅地记录操作日志?》文章里面步骤基本一致。

常见的在通用方法都可以处理,但是在数据变更方面,一直没有较好的实现方式,比如数据在变更前是多少,变更后是多少。

接下来,我们就来优雅地利用 AOP 实现生成的操作日志吧。

创建操作日志 Entity

@Entity("records")
export class Record {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({
    type: "string",
    comment: "操作人ID",
  })
  operatorId: string;

  @Column({
    type: "varchar",
    comment: "操作人账号",
  })
  operatorName: string;

  @Column({
    type: "varchar",
    length: 256,
    comment: "记录动作",
  })
  action: string;

  @Column({
    type: "varchar",
    length: 256,
    comment: "记录模块",
  })
  module: string;

  @Column({ type: "varchar", length: 256, comment: "信息", nullable: false })
  message: string;

  @Column({ type: "langtext", comment: "详情", nullable: false })
  detail: string;

  @CreateDateColumn({
    type: "timestamp",
    comment: "创建时间",
  })
  createdAt: Date;

  @UpdateDateColumn({
    type: "timestamp",
    comment: "更新时间",
  })
  updatedAt: Date;
}

怎么获取操作人信息

操作人即当前用户,怎么拿用户信息。我们使用 passport 时,默认用户信息都会挂载到 Express.request.user 上。

控制器自定义装饰器 - Custom decorators

在 Nest 里控制器自定义装饰器主要分两类:

  • 一类是方法和类:通过 applyDecorators + SetMetadata 实现
  • 一类是方法参数:通过 createParamDecorator

获取它方式可通过在请求方法里面使用 @Request(), @Req() 装饰器拿到 Express.request,就可以获取 user

还可以使用文档里面介绍的自定义方法参数装饰器快捷获取:

import { createParamDecorator, ExecutionContext } from "@nestjs/common";

export const User = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    return data ? user?.[data] : user;
  }
);

这两种方式都是需要通过路由传递服务获取。

Nestjs v8 版本里面添加一个服务注入作用域 - Injection scopes

import { Injectable, Scope } from "@nestjs/common";
import { Request } from "express";

@Injectable({ scope: Scope.REQUEST })
export class CatsService {
  constructor(@Inject(REQUEST) private request: Request) {}
}

拿到 Request 对象,然后就可以在服务里面愉快使用 user

对于使用服务注入范围 Scope.REQUEST 有一些潜在的缺点,需要考虑以下因素:

  1. 性能开销:使用 Scope.REQUEST 范围的服务注入会导致每个请求都创建一个新的服务实例。这可能会增加内存和处理时间的开销,特别是在高并发的情况下。如果应用程序的请求量很大,可能会对性能产生负面影响。
  2. 内存管理:每个请求都会创建一个新的服务实例,这意味着在请求结束后,这些实例可能会被丢弃,但仍然占用内存。如果请求频繁且服务实例较大,可能会导致内存占用过高,影响应用程序的整体性能。
  3. 上下文管理:使用 Scope.REQUEST 范围的服务注入可以访问当前请求的上下文信息,如请求头、请求参数等。然而,这也意味着在服务中使用这些上下文信息时需要小心处理,以避免在不正确的上下文中使用或共享数据。
  4. 依赖关系管理:当一个服务被注入到 Scope.REQUEST 范围时,它的依赖关系也会被注入到相同的范围中。这可能会导致依赖关系的生命周期与请求的生命周期紧密耦合,增加了代码的复杂性和维护成本。
  5. 并发问题:在多线程或并发环境中,使用 Scope.REQUEST 范围的服务注入可能会引发并发问题。由于每个请求都有自己的服务实例,可能会出现数据竞争、资源争用等问题,需要额外的同步机制来处理。 综上所述,使用 Scope.REQUEST 范围的服务注入可以提供请求级别的上下文和隔离,但也需要权衡性能、内存管理、上下文管理、依赖关系管理和并发问题等因素。在设计和实现时,需要根据具体的应用场景和需求来选择合适的注入范围,并进行适当的优化和管理。

Nodejs v16 版本里新增异步资源状态共享功能 - AsyncLocalStorage

关于如何在 Nestjs 中使用 AsyncLocalStorage,关于提供了一篇文档。我在网上也搜索到一个代码片段 request-context

矮油,这个好像不错哦。不过它是用 asyncctx,算是 AsyncLocalStorage 早期库版本实现。

request-context.ts

import { AsyncLocalStorage } from 'async_hooks';
import { Request, Response, NextFunction } from 'express';

export interface AppRequestContextRequest extends Request {
  requestId: string;
}

export class RequestContext {
  static storage = new AsyncLocalStorage<RequestContext>();

  static get currentContext() {
    return this.cls.getStore();
  }

  constructor(public readonly req: Request, public readonly res: Response) {}
}

@Injectable()
export class RequestContextMiddleware implements NestMiddleware<Request, Response> {
  use(req: Request, res: Response, next: NextFunction) {
    RequestContext.storage.run(new RequestContext(req, res), next);
  }
}

@Module({
  providers: [RequestContextMiddleware],
})
export class RequestContextModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(RequestContextMiddleware).forRoutes('*');
  }
}

RequestContextModule 导入到 AppModule 里。

这样我们就可以通过 RequestContext.currentContext.req 拿到 Request 对象,然后就可以使用 user

我们介绍 3 种不同的方式,可以在服务里面获取 Request 对象,使用 user,得到操作人信息。接下来我们需要关联操作及如何生成操作日志。

在服务里获取到 Request 对象上下文,装饰器 @guards@filters@interceptors 通过 Execution context 也可以获取到 Request 对象上下文,刚好我需要使用 interceptors,这个后面再介绍。

方法装饰器实现操作日志

让操作日志和业务逻辑解耦,我们需要使用装饰器。

Nestjs 为控制器提供方法和类自定义装饰器,可以通过 applyDecorators + SetMetadata 快速实现。

import { applyDecorators, SetMetadata } from "@nestjs/common";
/** 操作模块 */
export const LOG_RECORD_MODULE = 'LOG_RECORD_MODULE';

/** 操作方法 */
export const LOG_RECORD_META = 'LOG_RECORD_META';

/**
 * @description 使用在类上的装饰器
 * @param {string} name
 */
export const LogRecordModule = (name: string): ClassDecorator => {
  return applyDecorators(SetMetadata(LOG_RECORD_MODULE, name));
};

/**
 * @description 使用在类方法上的装饰器
 * @param {string} message 信息
 * @param {string} action 动作
 */
export const LogRecord = (message: string, action: string): MethodDecorator => {
  return applyDecorators(SetMetadata(LOG_RECORD_META, [message, action]));
};

/**
 * @description 使用在类方法上的装饰器
 * @param {string} name
 */
export const CreateLogRecord = (name: string): MethodDecorator => {
  return LogRecord(name, '新增');
};

/**
 * @description 使用在类方法上的装饰器
 * @param {string} name
 */
export const UpdateLogRecord = (name: string): MethodDecorator => {
  return LogRecord(name, '修改');
};

/**
 * @description 使用在类方法上的装饰器
 * @param {string} name
 */
export const DeleteLogRecord = (name: string): MethodDecorator => {
  return LogRecord(name, '删除');
};
  • applyDecorators 接收一组装饰器作为参数,返回装饰器函数。实现在装饰器函数里直接调用循环装饰器数组并调用
  • SetMetadata 是对 Reflect.defineMetadata 封装,自动帮我们处理 ClassDecoratorMethodDecorator 设置

这样我们就可以使用在装饰器上:

@Controller({
   path: '/users'
})
@LogRecordModule('用户管理')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get(':id')
  findOne() {}

  @Get()
  findAll() {}

  @Post()
  @CreateLogRecord('创建用户 ${entity.name}')
  create() {}

  @Put(':id')
  @UpdateLogRecord('更新用户 ${entity.name}')
  update() {}

  @Delete(':id')
  @DeleteLogRecord('删除用户 ${entity.name}')
  remove() {}
}

通过拦截器拿到装饰器数据

import { randomUUID } from 'crypto';
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map, catchError, tap } from 'rxjs/operators';

@Injectable()
export class LogRecordInterceptor implements NestInterceptor<unknown> {
  private logger: Logger = new Logger(RecordInterceptor.name);
  constructor(private reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const call$ = next.handle();

    const record_module = this.reflector.get<string>(RECORD_MODULE, context.getClass());
    // 如果拿不到操作日志模块,说明我们不需要操作日志
    if (record_module == null) {
       return call$;
    }
    const record_meta = this.reflector.get<[string, string, LogRecordMetadata]>(RECORD_META, context.getHandler());
    // 如果拿不到操作日志方法,说明我们不需要操作日志
    if (record_meta == null) {
      this.logger.warn(
        `操作日志模块:${record_module}[class ${
          context.getClass().name
        }] 没有提供操作日志方法,如果不需要请删除操作日志模块装饰器 "@LogRecordModule('${record_module}')"`
      );
      return call$;
    }

    // 拦截器 pro
    return call$.pipe( // 拦截器 post
      tap((data: unknown) => {  // 这里可以处理响应成功
      }),
      map((data: unknown) => data),  // 这里可以处理修改响应成功的值
      catchError((err) => {  // 这里可以处理响应失败
        return err;
      })
    );
  }
}

拦截器执行顺序,根据 Request lifecycle 文档描述:

  • 传入请求(Request)
  • 中间件(Middleware)
    • 全局
    • 模块
  • 守卫(Guards)
    • 全局
    • 控制器
    • 路由
  • 拦截器在路由执行之前(Interceptors pro)
    • 全局
    • 控制器
    • 路由
  • 管道(Pipes)
    • 全局
    • 控制器
    • 路由
    • 路由参数
  • 路由(Controller 控制器类的方法 )
  • 服务(Service 如果存在注入就会执行)
  • 拦截器在路由执行之后(Interceptors post)
    • 路由
    • 控制器
    • 全局
  • 过滤器(Exception filters)
    • 路由
    • 控制器
    • 全局
  • 服务响应(Response)

我们可以把拦截器放在路由(控制器方法),控制器(控制器类),全局,位置不同作用也不同。

只想控制某个单独路由或控制器,使用 @UseInterceptors(new LoggingInterceptor()) 挂载对应位置即可。

使用全局:

  1. 使用 Nest 应用程序实例的方法注册
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LogRecordInterceptor());
  1. 使用 Nest 模块服务注入方式
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR } from "@nestjs/core";

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LogRecordInterceptor,
    },
  ],
})
export class AppModule {}

两种有什么区别,实例方法注册需要手动注入依赖,服务注入方式可以解决这个问题。没有依赖推荐 1,如果有依赖推荐 2。我们的 LogRecordInterceptor 因为使用了 Reflector 依赖,所以需要使用第 2 种方式注册。

我们接下来就要对拿到装饰器数据进行处理,这个处理是在响应成功之后,所以我们需要在 tapmap 里处理,如果不需要改变响应数据,推荐 tap,否则就 map

intercept(context: ExecutionContext) {
    ...
    // 拦截器 pro
    const request = context.switchToHttp().getRequest();
    return call$.pipe( // 拦截器 post
      tap((data: unknown) => {  // 这里可以处理响应成功
         // 这里就可以通过 request.user 拿到当前用户信息
         this.reported(record_module, record_meta, request.user, data);
      })
    );
}

private reported(module: string, meta: [string, string, LogRecordMetadata], user: User, raw: unknown) {
}

接下来就是重头戏了,如果 reported() 让装饰器 LogRecordMetadata 关联,形成我们最终的预期效果。

动态模板

按照文章里面说,实现 @LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”") 效果,就必须使用动态模板。文章里面说 javaSpEL(Spring Expression Language,Spring 表达式语言)。js 没有这个玩意,但是 ES6 有个模板字符串

古人常说自己动手丰衣足食,那我们就来造一个轮子:

type TemplateLiteralContext = Record<string, any>;

class TemplateLiteral {
  private template: string;

  constructor(template?: string) {
    if (template) {
      this.parserExpression(template);
    }
  }

  /**
   * 设置模板表达式
   * @param template
   * @returns
   */
  parserExpression(template: string) {
    this.template = this.parser(template);
    return this;
  }

  /**
   * 指定上下文值通过模板获取解析值
   * @param context 模板上下文
   * @param fallbackValue 模板解析失败回退值
   * @returns
   */
  getValue(context: TemplateLiteralContext, fallbackValue?: string): string {
    const names = Object.keys(context);
    const values = Object.values(context).map((value) => (typeof value === 'function' ? value() : value));
    const result = new Function(...names, `return \`${this.template}\`;`)(...values);
    return result === undefined ? fallbackValue : result;
  }

  /**
   * 处理模板表达式 
   * @param template
   * @returns
   */
  private parser(template: string) {
    // 例如字符反转义
    template = this.escape2Html(template);
    // 验证变量,是否合法等
    return template;
  }

  /**
   * HTML字符反转义 &lt;  =>  <
   * @param str
   * @returns
   */
  private escape2Html(str: string) {
    const arrEntities = { lt: '<', gt: '>', nbsp: ' ', amp: '&', quot: '"' };
    return str.replace(/&(lt|gt|nbsp|amp|quot);/gi, function (all, t) {
      return arrEntities[t];
    });
  }
}

我们就可以和 SpEL 一样使用了:

const parser = new TemplateLiteral();
parser.parserExpression('本文作者是 ${data.name} 年龄 ${data.age}.');

console.log(parser.getValue({data: {name: 'jiayi', age: 18}})); 

# 本文作者是 jiayi 年龄 18.

解决模板问题,我们思考一下,getValue 数据从哪里来?

如果你没有忘记 reported() 方法,你一定还记得 raw 参数,它就是当前响应数据值。

private reported(module: string, meta: [string, string, LogRecordMetadata], operator: User, raw: unknown) {
    // 这里 data 根据你的处理的响应值有关
    const { data } = raw as { data: unknown };
    const [message, action, metadata] = meta;
    const parser = new TemplateLiteral(message);
    // 建一个 dto 数据
    const recordDto = {
        // 我们的数据挂载到 entity 上,模板里面可以使用 ${entity.xxxx}
        message: parser.getValue({entity: data ?? {}}, message),
        module,
        action,
        operator,
        detail: {},
    }
    // 接下来就可以交予 LogRecordService 操作了。
}

是不是很简单,等一下,metadata 是干嘛的,定义了一个寂寞吗?

如果我们只需要简单的数据输出这个就已经够,如果需要更新操作怎么办,也就是需要对比数据,更新之前和之后的值都需要保存起来。显然我上面代码就不够用了。还有我前面为什么要搞出一个 RequestContext 对象,你会发现它一直没有使用。

86 的极限到了, 该看我的 FC了。

数据处理

import { AppRequestContextRequest, RequestContext } from './request-context';

// 缓存数据
const RecordCache = new Map();
export class RecordContext {
  /**
   * 获取当前上下文
   * @returns
   */
  static getContext(): AppRequestContextRequest {
    return RequestContext.currentContext.req as unknown as AppRequestContextRequest;
  }

  /**
   * 设置当前上下文标识
   * @returns
   */
  static setRequestId(id: string): void {
    const ctx = this.getContext();
    ctx.requestId = id;
  }

  /**
   * 获取当前上下文标识
   * @returns
   */
  static getRequestId(): string {
    return this.getContext().requestId;
  }

  /**
   * 获取当前上下文用户
   * @returns
   */
  static getUser<T>() {
    return this.getContext()['user'] as T;
  }
  
  /**
   * 消费记录上下文
   * @param recordKey 记录key
   * @returns
   */
  static get<T>(recordKey: string) {
    const values = RecordCache.get(recordKey);
    RecordCache.delete(recordKey);
    return (values as T[]) ?? [];
  }

  static put<T>(recordKey: string, mapObject: T) {
    const values = RecordMap.get(recordKey);
    if(values == null) {
      RecordCache.set(recordKey, [mapObject]);
    } else {
      if (values.length > 1) {
        throw new RangeError('期望添加更新之前和之后的数据,数据已经存在');
      }
      RecordCache.set(recordKey, [...values, mapObject]);
    }
  }
}

有这个上下文对象之后,我们就可以在 LogRecordInterceptor 使用它:

import { randomUUID } from 'crypto';

intercept(context: ExecutionContext) {
    ...
    // 拦截器 pro
    RecordContext.setRequestId(randomUUID());
    return call$.pipe( // 拦截器 post
      tap((data: unknown) => {  // 这里可以处理响应成功
         // 这里就可以通过 request.user 拿到当前用户信息
         this.reported(record_module, record_meta, data);
      })
    );
}
private reported(module: string, meta: [string, string, LogRecordMetadata], raw: unknown) {
    // 这里 data 根据你的处理的响应值有关
    const { data } = raw as { data: unknown };
    const requestId = RecordContext.getRequestId();
    const records = RecordContext.get(requestId);
    const operator = RecordContext.getUser<User>();
    const [message, action, metadata] = meta;
    const parser = new TemplateLiteral(message);
    // 处理更新
    if(metadata != null && records.length === 2) {
       // 不要离开,精彩马上回来
    } else {
       const entity = records[0] ?? data ?? {};
       // 建一个 dto 数据
       const recordDto = {
            message: parser.getValue({ entity }, message),
            module,
            action,
            operator,
            detail: {},
       }
    }
    // 接下来就可以交予 LogRecordService 操作了。
}

对比数据

Nest 里路由规范约定新增和修改都是有一个入参叫 DTO(Data Transfer Object),它作用:

  • 一个是 ts 类型定义
  • 二个方便配合 class-validatorclass-transformer 做请求参数验证

我们在更新路由里都会使用 xxxUpdateDto 的对象,里面有更新的几个字段,将这个对象通过路由传递到服务进行处理。这时候操作日志要记录的是:这个对象中修改所有字段的值。

在文章里面提到怎么实现修改对象 DIFF 功能,如果友好提示,比如:状态值是 truefalse,程序员都看得懂,其他人呢?需要友好提示:true => 启用false => 停用

这个该如何实现了,作为重度装饰器爱好者 Nest,当然是用装饰器来解决问题。

const RECORD_DIFF_FIELD = 'RECORD_DIFF_FIELD';
const recordFieldKey = 'custom:diff:';
/**
 * 自定义记录比较字段
 * @param name 中文名
 * @param transform 数据转换可阅读显示,比如 true | false => 启用 | 停用。为 null 不显示转换内容,方便特殊字段处理,比如 password
 * @param diff 自定义比较函数 source 旧值 target 新值 patch 补丁上下文
 * @returns
 */
export const DiffRecordField = (
  name: string,
  transform?: ((value: unknown, entity: unknown) => string) | null,
  diff?: (source: unknown, target: unknown, patch: Record<string, unknown>) => boolean
): PropertyDecorator => {
  return (target: object, field: string) => {
    const constructor = target.constructor;
    let __DIFF__ = getDiffRecordFieldMetadata(constructor);

    if (!__DIFF__) {
      __DIFF__ = [];
    }

    __DIFF__.push({
      field,
      name,
      transform,
      diff,
    });

    const propertyKey = recordFieldKey + constructor.name;

    Reflect.defineMetadata(RECORD_DIFF_FIELD, __DIFF__, constructor, propertyKey);
  };
};

// eslint-disable-next-line @typescript-eslint/ban-types
export const getDiffRecordFieldMetadata = (constructor: Function) =>
  Reflect.getMetadata(RECORD_DIFF_FIELD, constructor, recordFieldKey + constructor.name);

参数详解:

  • name 字段中文名
  • field 当前字段名
  • transform 字段值转换,如果 null,不转换显示,如果 undefined 直接输出,如果提供转换函数,返回处理字符串
  • diff 对比字段值变化,默认 Object.is 处理,遇到特殊值,比如数组,对象,需要自定义比较,修改上下文 patch,返回给显示使用的。如果新增 xxx,删除了 xxx,大大加强日志显示灵活性。

整体实现思路就比较简单:

我们把修改的属性都收集到一个 diff 数组里面,后面使用时候直接循环遍历即可。

这样就可以在 DTO 上使用(移除验证干扰):

export class UserUpdateDto {
  @DiffRecordField(
    "权限",
    null,
    (
      origin: RoleResponseDto,
      current: RoleResponseDto,
      patch: Record<string, unknown>
    ) => {
      if (origin.permissions.length !== current.permissions.length) {
        return true;
      }
    }
  )
  permissions: number[];

  @DiffRecordField("状态", (value: Role["active"], entity: Role) =>
    value ? "启用" : "停用"
  )
  active?: boolean;

  @DiffRecordField(
    "角色",
    (_, entity: ManagerResponseDto) => entity.roleName,
    (
      origin: ManagerResponseDto,
      current: ManagerResponseDto,
      patch: Record<string, unknown>
    ) => {
      if (origin.roleId !== current.roleId) {
        return true;
      }
    }
  )
  role: number;

  @DiffRecordField("备注")
  remark?: string;

  @DiffRecordField(
    "密码",
    null,
    (
      origin: ManagerResponseDto,
      current: ManagerResponseDto,
      patch: Record<string, unknown>
    ) => {
      const isValid = origin["password"] !== current["password"];
      Reflect.deleteProperty(origin, "password");
      Reflect.deleteProperty(current, "password");
      return isValid;
    }
  )
  password: string;
}

怎么和 LogRecordInterceptor 关联了,那就需要改造一下装饰器 @LogRecord

export interface LogRecordMetadata {
  context?: Record<string, string>;
  template?: string;
  target?: new (...args: any[]) => any;
}

export const LogRecord = (name: string, action: string, metadata?: string | LogRecordMetadata): MethodDecorator {
   let _metadata: LogRecordMetadata;  
   if (context) {
      if(typeof context === 'string') {
         _metadata = {
           context: metadata 
         }
      } else if(typeof context === 'object') {
         _metadata = metadata
      }
   }
   return applyDecorators(SetMetadata(LOG_RECORD_META, [message, action, _metadata]));
}

/**
 * @description 使用在类方法上的装饰器
 * @param name
 * @param metadata
 */
export const UpdateLogRecord = (name: string, metadata?: LogRecordMetadata): MethodDecorator => {
  return LogRecord(name, '修改', metadata);
};

在路由里使用:

@Controller({
   path: '/users'
})
@LogRecordModule('用户管理')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get(':id')
  findOne() {}

  @Get()
  findAll() {}

  @Post()
  @CreateLogRecord('创建用户 ${entity.name}')
  create() {}

  @Put(':id')
  @UpdateLogRecord(
    '更新用户 ${entity.name}',
    {  
       template: '用户名字 ${entity.name} 更新字段 ${patch.name} 内容从 ${patch.origin} 修改到 ${patch.current}',
       target: UserUpdateDto
    }
  )
  update() {}

  @Delete(':id')
  @DeleteLogRecord('删除用户 ${entity.name}')
  remove() {}
}

我最初想法是通过改写控制器方法直接拿到 DTO,

const decoratorFactory = function (...args: unknown[]) {
  for (const arg of args) {
    if (
      arg instanceof Object &&
      getDiffRecordFieldMetadata(arg.constructor)
    ) {
      // 修改dto
      metadata.getRecordDiff = () => {
        return {
          target: arg,
          diff: getDiffRecordFieldMetadata(arg.constructor),
        };
      };
    }
  }

  const result = targetFunc.apply(this, args);
  return result;
};

还有一种方式就是通过 @Body 方式去拿,这个需要读取 Next 内部信息 getMetadata,通过这方法可以拿到 @Body 信息,我按照里面获取拿到的是 undefined,可能是我姿势不对。

Reflect.getMetadata(
      '__routeArguments__',
      context.getClass().prototype.constructor,
      context.getHandler().name
    );

可以拿到信息,没有实际意义。

更新处理

我们接着前面 reported 精彩部分继续书写

// 不要离开,精彩马上回来

// 额外辅助信息
const { context, template, target } = metadata;

// 修改新旧值
const [source, record] = records;

let diffs: DiffRecordFieldMetadata[] | null = null;
// 处理 message 信息
const _message = parser.getValue({ entity: record }, message);
const detail = {};
// 处理对比显示默认信息
parser.parserExpression(
  typeof template === 'string'
    ? template
    : '${entity.name} 更新字段 ${patch.name} 内容从 ${patch.origin} 修改到 ${patch.current}'
);
// 自定义字段模板信息
const contextToObject = context && typeof context === 'object' ? context : {};
// 要提供需要 diff 的 dto
if (target) {
  try {
    diffs = getDiffRecordFieldMetadata(target);
    // diffs = dto;
  } catch (error) {
    console.log('获取 DTO 失败');
  }
  // 没有对比就没有伤害 直接忽略了
  if (Array.isArray(diffs) && diffs.length) {
    const defaultDiff = (
      source: object,
      target: object,
      patch: { name: string; field: string } & Record<string, unknown>
    ) => {
      return !Object.is(source[patch.field], target[patch.field]);
    };

    const defaultTransform = (value: unknown) => `${value}`;
    const describes: string[] = [];

    for (const { field, name, transform, diff } of diffs) {
      const patch = {
        name,
        field,
      };

      // 不需要转换显示
      if (transform === null) {
        continue;
      }

      const _diff = typeof diff === 'function' ? diff : defaultDiff;

      try {
        if (_diff(source, record, patch)) {
          const contextParser =
            (contextToObject[field] && new TemplateLiteral(contextToObject[field])) || parser;
          const _transform = typeof transform === 'function' ? transform : defaultTransform;
          patch['origin'] = _transform(source[field], source);
          patch['current'] = _transform(record[field], record);
          describes.push(
            contextParser.getValue({
              entity: record,
              patch,
            })
          );
        }
      } catch (error) {
        console.log(name, error);
      }
    }
    if(describes.length) {
      Reflect.set(detail, 'describes', describes);
    }
  }
 // 建一个 dto 数据
const recordDto = {
  message: _message,
  module,
  action,
  operator,
  detail,
};

console.log('recordDto', action, recordDto);

这样一个完整的日志操作的功能就大功告成了,接下来就是把 recordDto 存到数据库里,这就不是重点内容,就交给在座各位实现了。

写在最后

其整个实现都是利用 AOP 思想,借用拦截器,装饰器,中间件,AsyncLocalStorage 等技术点。把他们融合在一起,当然这里之上大致实现了功能,还有许多细节需要优化,比如缓存 metadata 数据,这个就是耶稣来了也不会变。

我正在写一个项目,其中一个功能包含这个完整代码和使用场景。如果对这个功能感兴趣,欢迎关注我。

今天就到这里吧,伙计们,玩得开心,祝你好运

谢谢你读到这里。下面是你接下来可以做的一些事情:

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。