最近做一个管理后台,需要有一个操作日志功能,一开始没想好怎么弄,通过搜索发现两篇文章和一个实现仓库:
仔细研读了一下,茅塞顿开。
用户在操作我们系统的过程中,针对一些重要的业务数据进行增删改查的时候,我们希望记录一下用户的操作行为,以便发生问题时能及时的找到证据,这种日志就是业务系统的操作日志。
操作日志主要记录某一时间下谁对什么做了什么事情,操作日志一般限定于创建、更新和删除操作,而查询并不是什么敏感操作,所以一般无需记录操作日志。其中最重要的是更新操作,创建和删除都是单向的,只需要操作人创建或删除了并记录操作人,操作时间等标识信息即可,更新操作就比较复杂了,需要有操作之前数据值,更新之后数据值,才构成一条数据属性操作记录。
例如:
- 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
有一些潜在的缺点,需要考虑以下因素:
- 性能开销:使用
Scope.REQUEST
范围的服务注入会导致每个请求都创建一个新的服务实例。这可能会增加内存和处理时间的开销,特别是在高并发的情况下。如果应用程序的请求量很大,可能会对性能产生负面影响。- 内存管理:每个请求都会创建一个新的服务实例,这意味着在请求结束后,这些实例可能会被丢弃,但仍然占用内存。如果请求频繁且服务实例较大,可能会导致内存占用过高,影响应用程序的整体性能。
- 上下文管理:使用
Scope.REQUEST
范围的服务注入可以访问当前请求的上下文信息,如请求头、请求参数等。然而,这也意味着在服务中使用这些上下文信息时需要小心处理,以避免在不正确的上下文中使用或共享数据。- 依赖关系管理:当一个服务被注入到
Scope.REQUEST
范围时,它的依赖关系也会被注入到相同的范围中。这可能会导致依赖关系的生命周期与请求的生命周期紧密耦合,增加了代码的复杂性和维护成本。- 并发问题:在多线程或并发环境中,使用
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())
挂载对应位置即可。
使用全局:
- 使用 Nest 应用程序实例的方法注册
ts
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LogRecordInterceptor());
- 使用 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字符反转义 < => <
* @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
数据,这个就是耶稣来了也不会变。
我正在写一个项目,其中一个功能包含这个完整代码和使用场景。如果对这个功能感兴趣,欢迎关注我。
今天就到这里吧,伙计们,玩得开心,祝你好运
谢谢你读到这里。下面是你接下来可以做的一些事情:
- 找到错字了?下面评论
- 如果有问题吗?下面评论
- 对你有用吗?表达你的支持并分享它。