NestJS中如何優(yōu)雅的實現(xiàn)接口日志記錄
在我們系統(tǒng)開發(fā)中,通常會需要對接口的請求情況做一些日志記錄,通過詳細的日志記錄,我們可以獲取每個接口請求的關鍵信息,包括請求時間、請求參數(shù)、請求主機、以及用戶身份等。這些信息將為后續(xù)的性能優(yōu)化、故障排查和用戶行為分析提供重要依據(jù)。本篇文章將介紹如何在 NestJS 中優(yōu)雅的實現(xiàn)接口日志記錄。
什么是 AOP
在開始之前,我們需要了解一下什么是 AOP 架構?
我們首先了解一下 NestJS 對一個請求的處理過程。在 NestJS 中,一個請求首先會先經(jīng)過控制器(Controller),然后 Controller 調用服務 (Service)中的方法,在 Service 中可能還會進行數(shù)據(jù)庫的訪問(Repository)等操作,最后返回結果。但是如果我們想在這個過程中加入一些通用邏輯,比如日志記錄,權限控制等該如何做呢?
這時候就需要用到 AOP(Aspect-Oriented Programming,面向切面編程)了,它允許開發(fā)者通過定義切面(Aspects)來對應用程序的各個部分添加橫切關注點(Cross-Cutting Concerns)。橫切關注點是那些不屬于應用程序核心業(yè)務邏輯,但在整個應用程序中多處重復出現(xiàn)的功能或行為。這樣可以讓我們在不侵入業(yè)務邏輯的情況下來加入一些通用邏輯。也就是說 AOP 架構允許我們在請求的不同階段插入代碼,而不需要修改業(yè)務邏輯的代碼。
NestJS 中的五種實現(xiàn) AOP 的方式有Middleware
(中間件)、Guard
(導航守衛(wèi))、Pipe
(管道)、Interceptor
(攔截器)、ExceptionFilter
(異常過濾器),感興趣的可以查看相關資料了解這些AOP。本篇文章將介紹如何使用Interceptor
(攔截器)來實現(xiàn)接口日志記錄。
然后看一下我們的需求,我們需要記錄每個接口的請求情況,包括請求時間、請求參數(shù)、請求主機、以及用戶身份等。我們肯定是不能在每個接口中都去手動的去添加日志記錄的,這樣會非常的麻煩,而且也不優(yōu)雅。所以這時候我們就可以使用 AOP 架構中的Interceptor
(攔截器)來實現(xiàn)接口日志記錄。攔截器可以在請求到達控制器之前或之后執(zhí)行一些操作,我們可以在攔截器中記錄接口的請求情況,這樣就可以實現(xiàn)接口日志記錄了。
日志記錄模塊實現(xiàn)
首先我們需要生成一個日志記錄模塊,用于記錄接口的請求情況。在NestJS
中執(zhí)行nest g res log
就可以自動生成一個模板。然后新建log/entities/operationLog.entity.ts
文件,用于定義日志記錄的實體類。
import * as moment from "moment";
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
//操作日志表
@Entity("fs_operation_log")
export class OperationLog {
@PrimaryGeneratedColumn()
id: number; // 標記為主鍵,值自動生成
@Column({ length: 100, nullable: true })
title: string; //系統(tǒng)模塊
@Column({ length: 20, nullable: true })
operation_type: string; //操作類型
@Column({ length: 20, nullable: true })
method: string; //請求方式
@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;
}
啟動項目后,在數(shù)據(jù)庫中就會自動生成fs_operation_log
表了。
然后在log/log.module.ts
文件中通過@Global
將這個模塊注冊為全局模塊,并導入這個實體類,同時將LogService
導出,這樣就可以在其它模塊中使用了。
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
文件中定義一個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);
}
}
這樣我們就完成了日志記錄模塊的實現(xiàn)了。后面我們會在攔截器中調用這個方法來實現(xiàn)接口日志的記錄。
攔截器實現(xiàn)
新建src/common/interceptor/log.interceptor.ts
文件,用于實現(xiàn)攔截器。在攔截器中可以通過context.switchToHttp().getRequest()
獲取到請求相關信息。同時我們可以通過context.getHandler()
獲取到當前控制器的元數(shù)據(jù),從而獲取到控制器中自定義裝飾器定義的模塊名。
首先看一下自定義裝飾器@LogOperationTitle
。
在src/common/decorator/oprertionlog.decorator.ts
文件中定義了一個@LogOperationTitle
裝飾器,用于標記當前控制器的模塊名。
import { SetMetadata } from "@nestjs/common";
// 操作日志裝飾器,設置操作日志模塊名
export const LogOperationTitle = (title: string) =>
SetMetadata("logOperationTitle", title);
簡單來說就是使用@LogOperationTitle
裝飾器可以定義模塊名稱(logOperationTitle
),然后在攔截器中獲取到這個模塊名稱。然后看下自定義攔截器的實現(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>> {
//獲取請求對象
const request = context.switchToHttp().getRequest();
//獲取當前控制器元數(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;
//請求參數(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);
});
}
));
}
}
這樣我們就完成了攔截器的實現(xiàn)了。
使用攔截器
因為我們需要在每個請求中都用到這個攔截器,所以我們可將其定義為全局攔截器。前面文章中我們介紹過可以在main.ts
文件中通過app.useGlobalInterceptors(new OperationLogInterceptor())
將攔截器注冊為全局攔截器,但是這樣會出現(xiàn)一個問題,就是我們在log/log.module.ts
文件中定義的LogService
服務無法在攔截器中使用,因為攔截器是沒有依賴注入的,所以我們需要在app.module.ts
文件中通過APP_INTERCEPTOR
提供者將攔截器注冊為全局攔截器,這樣才可以在攔截器中使用LogService
服務了。
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { OperationLogInterceptor } from './common/interceptor/log/log.interceptor';
//此處省略其它代碼
@Module({
providers: [AppService,
// 注冊全局攔截器
{
provide: APP_INTERCEPTOR,
useClass: OperationLogInterceptor,
}
],
})
此時啟動項目我們的攔截器就已經(jīng)生效了。比如隨便訪問幾次菜單查詢的接口,就可以在數(shù)據(jù)庫看到日志記錄已經(jīng)成功了。
但是你會發(fā)現(xiàn)模塊名還是空的,因為我們還沒有在控制器中使用@LogOperationTitle
裝飾器來定義模塊名。所以我們需要在控制器中使用@LogOperationTitle
裝飾器來定義模塊名。比如在menu/menu.controller.ts
文件中定義菜單查詢模塊名。
//菜單查詢
@Get()
@LogOperationTitle('菜單查詢')
async findAll() {
return await this.menuService.findAll();
}
再次請求接口,就可以看到模塊名已經(jīng)記錄成功了。
提供查詢日志接口
我們還需要提供一個查詢和導出日志接口給前端使用,用于查詢日志記錄。在log/log.controller.ts
文件中定義一個查詢和導出日志接口。(導出功能前面文章已經(jīng)介紹過了,這里就不詳細介紹了,感興趣的可以查看前面文章)
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);
}
//日志導出
@LogOperationTitle('日志導出')
@ApiOperation({ summary: '日志管理-導出' })
@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: '請求地址',
required: false,
})
@IsOptional()
url?: string;
@ApiProperty({
example: '結束時間',
required: false,
})
end_time: string;
@ApiProperty({
example: '開始時間',
required: false,
})
begin_time: string;
@ApiProperty({
example: '當前頁',
required: false,
})
page_num: number;
@ApiProperty({
example: '每頁條數(shù)',
required: false,
})
page_size: number;
}
前端可以通過這些參數(shù)來查詢日志記錄。
在log/log.service.ts
文件中實現(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);
}
}
//日志導出
async export(findList: FindListDto) {
try {
const { list } = await this.findList(findList)
const excelBuffer = await exportExcel(list, mapLogZh);
return excelBuffer;
} catch (error) {
throw new ApiException('導出失敗', ApiErrorCode.FAIL);
}
}
}
這樣我們就完成了日志的查詢與導出接口。
前端實現(xiàn)
最后在前端調用接口實現(xiàn)日志的查詢與導出功能。最終實現(xiàn)的頁面如下:
感興趣的可以直接去源碼地址(https://github.com/qddidi/fs-admin)查看相關代碼實現(xiàn)。