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

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

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

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

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

例如:

  • 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

ts 复制代码
@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。

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

ts 复制代码
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

ts 复制代码
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

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 快速实现。

ts 复制代码
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 RecordLog(name, '新增');
};

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

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

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

ts 复制代码
@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() {}
}

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

ts 复制代码
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 应用程序实例的方法注册
ts 复制代码
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LogRecordInterceptor());
  1. 使用 Nest 模块服务注入方式
ts 复制代码
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 种方式注册。

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

ts 复制代码
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"") 效果,就必须使用动态模板。文章里面说 java 有 SpEL(Spring Expression Language,Spring 表达式语言)。js 没有这个玩意,但是 ES6 有个模板字符串。

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

ts 复制代码
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 一样使用了:

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

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

# 本文作者是 jiayi 年龄 18.

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

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

ts 复制代码
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了。

数据处理

ts 复制代码
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 使用它:

ts 复制代码
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-validator 和 class-transformer 做请求参数验证

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

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

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

ts 复制代码
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 上使用(移除验证干扰):

ts 复制代码
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:

ts 复制代码
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);
};

在路由里使用:

ts 复制代码
@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,

ts 复制代码
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,可能是我姿势不对。

ts 复制代码
Reflect.getMetadata(
      '__routeArguments__',
      context.getClass().prototype.constructor,
      context.getHandler().name
    );

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

更新处理

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

ts 复制代码
// 不要离开,精彩马上回来

// 额外辅助信息
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 数据,这个就是耶稣来了也不会变。

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

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

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

  • 找到错字了?下面评论
  • 如果有问题吗?下面评论
  • 对你有用吗?表达你的支持并分享它。
相关推荐
金銀銅鐵3 小时前
[Java] 用 GUI 展示 EnumSet 增减元素时的内部变化(上)
java·后端·ai编程
念何架构之路4 小时前
go-grpc设计思想
开发语言·后端·golang
system_error_x4 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn6 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈7 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780517 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123457 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒7 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记8 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598788 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端