NestJS中如何優(yōu)雅的實(shí)現(xiàn)接口日志記錄
在我們系統(tǒng)開發(fā)中,通常會(huì)需要對(duì)接口的請(qǐng)求情況做一些日志記錄,通過詳細(xì)的日志記錄,我們可以獲取每個(gè)接口請(qǐng)求的關(guān)鍵信息,包括請(qǐng)求時(shí)間、請(qǐng)求參數(shù)、請(qǐng)求主機(jī)、以及用戶身份等。這些信息將為后續(xù)的性能優(yōu)化、故障排查和用戶行為分析提供重要依據(jù)。本篇文章將介紹如何在 NestJS 中優(yōu)雅的實(shí)現(xiàn)接口日志記錄。
什么是 AOP
在開始之前,我們需要了解一下什么是 AOP 架構(gòu)?
我們首先了解一下 NestJS 對(duì)一個(gè)請(qǐng)求的處理過程。在 NestJS 中,一個(gè)請(qǐng)求首先會(huì)先經(jīng)過控制器(Controller),然后 Controller 調(diào)用服務(wù) (Service)中的方法,在 Service 中可能還會(huì)進(jìn)行數(shù)據(jù)庫的訪問(Repository)等操作,最后返回結(jié)果。但是如果我們想在這個(gè)過程中加入一些通用邏輯,比如日志記錄,權(quán)限控制等該如何做呢?
這時(shí)候就需要用到 AOP(Aspect-Oriented Programming,面向切面編程)了,它允許開發(fā)者通過定義切面(Aspects)來對(duì)應(yīng)用程序的各個(gè)部分添加橫切關(guān)注點(diǎn)(Cross-Cutting Concerns)。橫切關(guān)注點(diǎn)是那些不屬于應(yīng)用程序核心業(yè)務(wù)邏輯,但在整個(gè)應(yīng)用程序中多處重復(fù)出現(xiàn)的功能或行為。這樣可以讓我們?cè)诓磺秩霕I(yè)務(wù)邏輯的情況下來加入一些通用邏輯。也就是說 AOP 架構(gòu)允許我們?cè)谡?qǐng)求的不同階段插入代碼,而不需要修改業(yè)務(wù)邏輯的代碼。
NestJS 中的五種實(shí)現(xiàn) AOP 的方式有Middleware
(中間件)、Guard
(導(dǎo)航守衛(wèi))、Pipe
(管道)、Interceptor
(攔截器)、ExceptionFilter
(異常過濾器),感興趣的可以查看相關(guān)資料了解這些AOP。本篇文章將介紹如何使用Interceptor
(攔截器)來實(shí)現(xiàn)接口日志記錄。
然后看一下我們的需求,我們需要記錄每個(gè)接口的請(qǐng)求情況,包括請(qǐng)求時(shí)間、請(qǐng)求參數(shù)、請(qǐng)求主機(jī)、以及用戶身份等。我們肯定是不能在每個(gè)接口中都去手動(dòng)的去添加日志記錄的,這樣會(huì)非常的麻煩,而且也不優(yōu)雅。所以這時(shí)候我們就可以使用 AOP 架構(gòu)中的Interceptor
(攔截器)來實(shí)現(xiàn)接口日志記錄。攔截器可以在請(qǐng)求到達(dá)控制器之前或之后執(zhí)行一些操作,我們可以在攔截器中記錄接口的請(qǐng)求情況,這樣就可以實(shí)現(xiàn)接口日志記錄了。
日志記錄模塊實(shí)現(xiàn)
首先我們需要生成一個(gè)日志記錄模塊,用于記錄接口的請(qǐng)求情況。在NestJS
中執(zhí)行nest g res log
就可以自動(dòng)生成一個(gè)模板。然后新建log/entities/operationLog.entity.ts
文件,用于定義日志記錄的實(shí)體類。
import * as moment from "moment";
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
//操作日志表
@Entity("fs_operation_log")
export class OperationLog {
@PrimaryGeneratedColumn()
id: number; // 標(biāo)記為主鍵,值自動(dòng)生成
@Column({ length: 100, nullable: true })
title: string; //系統(tǒng)模塊
@Column({ length: 20, nullable: true })
operation_type: string; //操作類型
@Column({ length: 20, nullable: true })
method: string; //請(qǐng)求方式
@Column({ type: "text", nullable: true })
params: string; //參數(shù)
@Column({ nullable: true })
ip: string; //ip
@Column({ type: "text", nullable: true })
url: string; //地址
@Column({ nullable: true })
user_agent: string; //瀏覽器
@Column({ nullable: true })
username: string; //操作人員
@CreateDateColumn({
transformer: {
to: (value) => {
return value;
},
from: (value) => {
return moment(value).format("YYYY-MM-DD HH:mm:ss");
},
},
})
create_time: Date;
@UpdateDateColumn({
transformer: {
to: (value) => {
return value;
},
from: (value) => {
return moment(value).format("YYYY-MM-DD HH:mm:ss");
},
},
})
update_time: Date;
}
啟動(dòng)項(xiàng)目后,在數(shù)據(jù)庫中就會(huì)自動(dòng)生成fs_operation_log
表了。
然后在log/log.module.ts
文件中通過@Global
將這個(gè)模塊注冊(cè)為全局模塊,并導(dǎo)入這個(gè)實(shí)體類,同時(shí)將LogService
導(dǎo)出,這樣就可以在其它模塊中使用了。
import { Global, Module } from "@nestjs/common";
import { LogService } from "./log.service";
import { LogController } from "./log.controller";
import { OperationLog } from "./entities/operationLog.entity";
import { TypeOrmModule } from "@nestjs/typeorm";
//全局模塊
@Global()
@Module({
controllers: [LogController],
providers: [LogService],
imports: [TypeOrmModule.forFeature([OperationLog])],
exports: [LogService],
})
export class LogModule {}
最后在log/log.service.ts
文件中定義一個(gè)saveLog
方法,用于保存日志記錄。
import { Injectable } from '@nestjs/common';
import { OperationLog } from './entities/operationLog.entity';
import {Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { ApiException } from 'src/common/filter/http-exception/api.exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
@Injectable()
export class LogService {
constructor(
@InjectRepository(OperationLog)
private readonly operationLog: Repository<OperationLog>
) { }
// 保存操作日志
async saveOperationLog(operationLog: OperationLog) {
await this.operationLog.save(operationLog);
}
}
這樣我們就完成了日志記錄模塊的實(shí)現(xiàn)了。后面我們會(huì)在攔截器中調(diào)用這個(gè)方法來實(shí)現(xiàn)接口日志的記錄。
攔截器實(shí)現(xiàn)
新建src/common/interceptor/log.interceptor.ts
文件,用于實(shí)現(xiàn)攔截器。在攔截器中可以通過context.switchToHttp().getRequest()
獲取到請(qǐng)求相關(guān)信息。同時(shí)我們可以通過context.getHandler()
獲取到當(dāng)前控制器的元數(shù)據(jù),從而獲取到控制器中自定義裝飾器定義的模塊名。
首先看一下自定義裝飾器@LogOperationTitle
。
在src/common/decorator/oprertionlog.decorator.ts
文件中定義了一個(gè)@LogOperationTitle
裝飾器,用于標(biāo)記當(dāng)前控制器的模塊名。
import { SetMetadata } from "@nestjs/common";
// 操作日志裝飾器,設(shè)置操作日志模塊名
export const LogOperationTitle = (title: string) =>
SetMetadata("logOperationTitle", title);
簡(jiǎn)單來說就是使用@LogOperationTitle
裝飾器可以定義模塊名稱(logOperationTitle
),然后在攔截器中獲取到這個(gè)模塊名稱。然后看下自定義攔截器的實(shí)現(xiàn)。
//操作日志攔截器
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { LogService } from 'src/log/log.service';
import { OperationLog } from 'src/log/entities/operationLog.entity';
import { Reflector } from '@nestjs/core';
export interface Response<T> {
data: T;
}
@Injectable()
export class OperationLogInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
constructor(
private readonly logService: LogService,
private readonly reflactor: Reflector,
) { }
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
//獲取請(qǐng)求對(duì)象
const request = context.switchToHttp().getRequest();
//獲取當(dāng)前控制器元數(shù)據(jù)中的日志logOperationTitle
const title = this.reflactor.get<string>('logOperationTitle', context.getHandler());
return next
.handle().pipe(tap(() => {
const log = new OperationLog();
log.title = title;
log.method = request.method;
log.url = request.url;
log.ip = request.ip;
//請(qǐng)求參數(shù)
log.params = JSON.stringify({ ...request.query, ...request.params, ...request.body });
//瀏覽器信息
log.user_agent = request.headers['user-agent'];
log.username = request.user?.username;
this.logService.saveOperationLog(log).catch((err) => {
console.log(err);
});
}
));
}
}
這樣我們就完成了攔截器的實(shí)現(xiàn)了。
使用攔截器
因?yàn)槲覀冃枰诿總€(gè)請(qǐng)求中都用到這個(gè)攔截器,所以我們可將其定義為全局?jǐn)r截器。前面文章中我們介紹過可以在main.ts
文件中通過app.useGlobalInterceptors(new OperationLogInterceptor())
將攔截器注冊(cè)為全局?jǐn)r截器,但是這樣會(huì)出現(xiàn)一個(gè)問題,就是我們?cè)?/span>log/log.module.ts
文件中定義的LogService
服務(wù)無法在攔截器中使用,因?yàn)閿r截器是沒有依賴注入的,所以我們需要在app.module.ts
文件中通過APP_INTERCEPTOR
提供者將攔截器注冊(cè)為全局?jǐn)r截器,這樣才可以在攔截器中使用LogService
服務(wù)了。
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { OperationLogInterceptor } from './common/interceptor/log/log.interceptor';
//此處省略其它代碼
@Module({
providers: [AppService,
// 注冊(cè)全局?jǐn)r截器
{
provide: APP_INTERCEPTOR,
useClass: OperationLogInterceptor,
}
],
})
此時(shí)啟動(dòng)項(xiàng)目我們的攔截器就已經(jīng)生效了。比如隨便訪問幾次菜單查詢的接口,就可以在數(shù)據(jù)庫看到日志記錄已經(jīng)成功了。
但是你會(huì)發(fā)現(xiàn)模塊名還是空的,因?yàn)槲覀冞€沒有在控制器中使用@LogOperationTitle
裝飾器來定義模塊名。所以我們需要在控制器中使用@LogOperationTitle
裝飾器來定義模塊名。比如在menu/menu.controller.ts
文件中定義菜單查詢模塊名。
//菜單查詢
@Get()
@LogOperationTitle('菜單查詢')
async findAll() {
return await this.menuService.findAll();
}
再次請(qǐng)求接口,就可以看到模塊名已經(jīng)記錄成功了。
提供查詢?nèi)罩窘涌?/span>
我們還需要提供一個(gè)查詢和導(dǎo)出日志接口給前端使用,用于查詢?nèi)罩居涗?。?/span>log/log.controller.ts
文件中定義一個(gè)查詢和導(dǎo)出日志接口。(導(dǎo)出功能前面文章已經(jīng)介紹過了,這里就不詳細(xì)介紹了,感興趣的可以查看前面文章)
import { Controller, Get, Query, Res } from '@nestjs/common';
import { LogService } from './log.service';
import { FindListDto } from './dto/find-list.dto';
import { LogOperationTitle } from 'src/common/decorators/oprertionlog.decorator';
import { ApiOperation } from '@nestjs/swagger';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Response } from 'express';
@Controller('log')
export class LogController {
constructor(private readonly logService: LogService) { }
//日志查詢
@LogOperationTitle('日志查詢')
@ApiOperation({ summary: '日志管理-查詢' })
@Permissions('system:log:list')
@Get('list')
findLogList(@Query() findListDto: FindListDto) {
return this.logService.findList(findListDto);
}
//日志導(dǎo)出
@LogOperationTitle('日志導(dǎo)出')
@ApiOperation({ summary: '日志管理-導(dǎo)出' })
@Get('export')
async export(@Query() findListDto: FindListDto, @Res() res: Response) {
const data = await this.logService.export(findListDto);
res.send(data);
}
}
其中FindListDto
類型為:
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional } from "class-validator";
export class FindListDto {
@ApiProperty({
example: '模塊名稱',
required: false,
})
@IsOptional()
title?: string;
@ApiProperty({
example: '操作人',
required: false,
})
@IsOptional()
username?: string;
@ApiProperty({
example: '請(qǐng)求地址',
required: false,
})
@IsOptional()
url?: string;
@ApiProperty({
example: '結(jié)束時(shí)間',
required: false,
})
end_time: string;
@ApiProperty({
example: '開始時(shí)間',
required: false,
})
begin_time: string;
@ApiProperty({
example: '當(dāng)前頁',
required: false,
})
page_num: number;
@ApiProperty({
example: '每頁條數(shù)',
required: false,
})
page_size: number;
}
前端可以通過這些參數(shù)來查詢?nèi)罩居涗洝?/span>
在log/log.service.ts
文件中實(shí)現(xiàn)findList
方法和export
方法。
import { Injectable } from '@nestjs/common';
import { OperationLog } from './entities/operationLog.entity';
import { Between, Like, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { FindListDto } from './dto/find-list.dto';
import { ApiException } from 'src/common/filter/http-exception/api.exception';
import { ApiErrorCode } from 'src/common/enums/api-error-code.enum';
import { exportExcel } from 'src/utils/common';
import { mapLogZh } from 'src/config/excelHeader';
@Injectable()
export class LogService {
constructor(
@InjectRepository(OperationLog)
private readonly operationLog: Repository<OperationLog>
) { }
// 保存操作日志
async saveOperationLog(operationLog: OperationLog) {
await this.operationLog.save(operationLog);
}
// 分頁查詢操作日志
async findList(findList: FindListDto) {
const condition = {};
if (findList.title) {
condition['title'] = Like(`%${findList.title}%`);
}
if (findList.username) {
condition['username'] = Like(`%${findList.username}%`);
}
if (findList.url) {
condition['url'] = Like(`%${findList.url}%`);
}
if (findList.begin_time && findList.end_time) {
condition['create_time'] = Between(findList.begin_time, findList.end_time);
}
try {
const [list, total] = await this.operationLog.findAndCount({
skip: (findList.page_num - 1) * findList.page_size,
take: findList.page_size,
order: {
create_time: 'DESC'
},
where: condition
});
return {
list,
total
};
} catch (error) {
throw new ApiException('查詢失敗', ApiErrorCode.FAIL);
}
}
//日志導(dǎo)出
async export(findList: FindListDto) {
try {
const { list } = await this.findList(findList)
const excelBuffer = await exportExcel(list, mapLogZh);
return excelBuffer;
} catch (error) {
throw new ApiException('導(dǎo)出失敗', ApiErrorCode.FAIL);
}
}
}
這樣我們就完成了日志的查詢與導(dǎo)出接口。
前端實(shí)現(xiàn)
最后在前端調(diào)用接口實(shí)現(xiàn)日志的查詢與導(dǎo)出功能。最終實(shí)現(xiàn)的頁面如下:
感興趣的可以直接去源碼地址(https://github.com/qddidi/fs-admin)查看相關(guān)代碼實(shí)現(xiàn)。