Skip to content

全局拦截器使用说明

概述

GlobalInterceptor 是 NestJS 应用的全局请求拦截器,负责处理请求日志记录、响应数据格式化、错误处理和超时控制等功能。

核心功能

1. 请求日志记录

  • 自动记录所有请求的 URL、方法、参数信息
  • 将请求日志保存到每日日志文件中
  • 支持查询参数、请求体、路径参数的完整记录

2. 响应数据格式化

  • 统一响应数据格式
  • 自动包装业务数据
  • 支持特殊标记数据的处理

3. 错误日志记录

  • 记录请求处理过程中的错误信息
  • 将错误日志保存到日志文件
  • 保持错误的原始传播

4. 超时控制(可选)

  • 支持请求超时控制(默认注释状态)
  • 超时自动抛出 RequestTimeoutException

基础使用

注册全局拦截器

main.ts 中注册全局拦截器:

typescript
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { GlobalInterceptor } from './common/interceptor/GlobalInterceptor'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  // 注册全局拦截器
  app.useGlobalInterceptors(new GlobalInterceptor())

  await app.listen(3000)
}
bootstrap()

模块级别注册

在特定模块中注册:

typescript
import { Module } from '@nestjs/common'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { GlobalInterceptor } from './common/interceptor/GlobalInterceptor'

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

API 参考

Interceptor 方法

intercept(context, next)

处理请求和响应的拦截逻辑

参数:

  • context: ExecutionContext 对象,包含请求上下文信息
  • next: CallHandler 对象,用于继续执行请求处理链

返回值: Observable

日志文件格式

日志文件保存在 log/ 目录下,按日期命名:

log/
  ├── 2024-01-01.log
  ├── 2024-01-02.log
  └── 2024-01-03.log

日志内容格式:

/api/users --- {"query":{"page":"1"},"body":{"name":"John"},"params":{"id":"123"}}
/api/users/1 --- {"message":"User not found","statusCode":404}

完整示例

基础控制器示例

typescript
// users.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common'

@Controller('users')
export class UsersController {
  @Get()
  findAll() {
    return [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id: parseInt(id), name: 'Alice' }
  }

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return { id: 3, ...createUserDto }
  }
}

带特殊标记的响应

typescript
// 返回特殊格式数据(不包装)
@Get('special')
getSpecialData() {
  return {
    _flag: true,
    customData: 'some data',
    additionalInfo: 'more info'
  }
}

// 返回普通数据(自动包装)
@Get('normal')
getNormalData() {
  return {
    users: [{ id: 1, name: 'Alice' }],
    totalCount: 1
  }
}

高级用法

自定义日志目录

typescript
// 修改日志保存路径
@Injectable()
export class GlobalInterceptor implements NestInterceptor {
  private readonly logDir = process.env.LOG_DIR || 'logs'

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest()

    mkdir(join(this.logDir), { recursive: true }).then(() => {
      fs.appendFileSync(
        `${this.logDir}/${dayjs().format('YYYY-MM-DD')}.log`,
        `${req.url} --- ${JSON.stringify({ query: req.query, body: req.body, params: req.params })}\n`,
      )
    })

    // ... 其余逻辑
  }
}

启用超时控制

typescript
// 启用 8 秒超时控制
return next.handle().pipe(
  timeout(8000), // 启用这行
  catchError((err) => {
    if (err instanceof TimeoutError) {
      return throwError(() => new RequestTimeoutException())
    }
    // ... 错误处理
  }),
  // ... 其他操作符
)

条件性日志记录

typescript
@Injectable()
export class GlobalInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest()

    // 只记录特定路由的日志
    const shouldLog = !req.url.includes('/health') && !req.url.includes('/metrics')

    if (shouldLog) {
      console.log('--> 请求数据:', req.url, req.method, {
        query: req.query,
        body: req.body,
        params: req.params,
      })

      mkdir(join('log'), { recursive: true }).then(() => {
        fs.appendFileSync(
          `log/${dayjs().format('YYYY-MM-DD')}.log`,
          `${req.url} --- ${JSON.stringify({ query: req.query, body: req.body, params: req.params })}\n`,
        )
      })
    }

    return next.handle().pipe(
      catchError((err) => {
        if (shouldLog) {
          fs.appendFileSync(`log/${dayjs().format('YYYY-MM-DD')}.log`, `${req.url} --- ${JSON.stringify(err)}\n`)
        }
        return throwError(() => err)
      }),
      map((data) => {
        let res = { code: 200, msg: 'success', ...(data?._flag ? data : { data }) }
        delete res._flag
        return res
      }),
    )
  }
}

响应时间统计

typescript
@Injectable()
export class GlobalInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest()
    const startTime = Date.now()

    console.log('--> 请求开始:', req.url, req.method)

    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - startTime
        console.log(`<-- 响应完成:${req.url} (${duration}ms)`)
      }),
      catchError((err) => {
        const duration = Date.now() - startTime
        console.log(`<-- 请求失败:${req.url} (${duration}ms)`, err.message)
        return throwError(() => err)
      }),
      map((data) => {
        const res = {
          code: 200,
          msg: 'success',
          ...(data?._flag ? data : { data }),
          timestamp: new Date().toISOString(),
          duration: Date.now() - startTime,
        }
        delete res._flag
        return res
      }),
    )
  }
}

最佳实践

1. 日志级别管理

typescript
@Injectable()
export class GlobalInterceptor implements NestInterceptor {
  private readonly isDevelopment = process.env.NODE_ENV === 'development'

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest()

    // 开发环境记录详细日志
    if (this.isDevelopment) {
      console.log('--> 请求详情:', {
        url: req.url,
        method: req.method,
        headers: req.headers,
        query: req.query,
        body: req.body,
        params: req.params,
        timestamp: new Date().toISOString(),
      })
    }

    // 生产环境只记录基本信息
    mkdir(join('log'), { recursive: true }).then(() => {
      const logData = this.isDevelopment
        ? { query: req.query, body: req.body, params: req.params }
        : { query: req.query, params: req.params } // 不记录 body 敏感信息

      fs.appendFileSync(
        `log/${dayjs().format('YYYY-MM-DD')}.log`,
        `${new Date().toISOString()} - ${req.method} ${req.url} - ${JSON.stringify(logData)}\n`,
      )
    })

    // ... 其余逻辑
  }
}

2. 响应数据标准化

typescript
// 统一的成功响应格式
map((data) => {
  // 处理特殊标记数据
  if (data?._flag) {
    delete data._flag
    return {
      code: 200,
      msg: 'success',
      ...data
    }
  }

  // 处理分页数据
  if (data?.data && data?.total !== undefined) {
    return {
      code: 200,
      msg: 'success',
      data: data.data,
      pagination: {
        total: data.total,
        page: data.page,
        pageSize: data.pageSize
      }
    }
  }

  // 普通数据包装
  return {
    code: 200,
    msg: 'success',
    data: data
  }
}),

3. 错误分类处理

typescript
catchError((err) => {
  const req = context.switchToHttp().getRequest()

  // 记录错误日志
  const errorLog = {
    timestamp: new Date().toISOString(),
    url: req.url,
    method: req.method,
    error: {
      name: err.name,
      message: err.message,
      stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
    }
  }

  fs.appendFileSync(
    `log/${dayjs().format('YYYY-MM-DD')}-error.log`,
    JSON.stringify(errorLog) + '\n'
  )

  // 根据错误类型返回不同的响应
  if (err instanceof ValidationError) {
    return throwError(() => new HttpException({
      code: 400,
      msg: '参数验证失败',
      details: err.message
    }, HttpStatus.BAD_REQUEST))
  }

  return throwError(() => err)
}),

注意事项

  1. 性能考虑:日志写入是同步操作,大量请求时可能影响性能
  2. 磁盘空间:定期清理旧日志文件,避免占用过多磁盘空间
  3. 敏感信息:生产环境中避免记录敏感的请求体数据
  4. 并发安全:文件写入需要考虑并发安全问题
  5. 目录权限:确保应用有创建日志目录和写入文件的权限

常见问题

Q: 如何排除健康检查等特定路由的日志记录?

A: 在拦截器中添加路由过滤逻辑:

typescript
const excludePaths = ['/health', '/metrics', '/favicon.ico']
const shouldLog = !excludePaths.some((path) => req.url.startsWith(path))

if (shouldLog) {
  // 执行日志记录逻辑
}

Q: 如何实现异步日志记录以提高性能?

A: 使用异步文件写入:

typescript
// 使用异步写入
await fs.promises.appendFile(`log/${dayjs().format('YYYY-MM-DD')}.log`, `${req.url} --- ${JSON.stringify(logData)}\n`)

Q: 如何按不同级别记录日志?

A: 实现多级别日志记录:

typescript
private log(level: 'info' | 'error' | 'debug', message: string, data?: any) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    data
  }

  const filename = level === 'error'
    ? `log/${dayjs().format('YYYY-MM-DD')}-error.log`
    : `log/${dayjs().format('YYYY-MM-DD')}.log`

  fs.appendFileSync(filename, JSON.stringify(logEntry) + '\n')
}

Q: 如何集成第三方日志服务?

A: 可以集成 Winston、Bunyan 等日志框架:

typescript
import { createLogger, transports, format } from 'winston'

@Injectable()
export class GlobalInterceptor implements NestInterceptor {
  private readonly logger = createLogger({
    level: 'info',
    format: format.combine(format.timestamp(), format.json()),
    transports: [
      new transports.File({ filename: 'log/combined.log' }),
      new transports.File({ filename: 'log/error.log', level: 'error' }),
    ],
  })

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest()

    this.logger.info('Incoming request', {
      url: req.url,
      method: req.method,
      query: req.query,
    })

    // ... 其余逻辑
  }
}

Q: 如何处理大文件上传的日志记录?

A: 对于文件上传请求,可以选择性记录:

typescript
// 检测是否为文件上传
const isFileUpload = req.headers['content-type']?.includes('multipart/form-data')

if (!isFileUpload) {
  // 正常记录日志
  fs.appendFileSync(logFile, logData)
} else {
  // 只记录基本信息,避免记录文件内容
  const basicInfo = {
    url: req.url,
    method: req.method,
    fileSize: req.headers['content-length'],
  }
  fs.appendFileSync(logFile, JSON.stringify(basicInfo))
}

配置示例

完整的 main.ts 配置

typescript
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import { AppModule } from './app.module'
import { GlobalInterceptor } from './common/interceptor/GlobalInterceptor'
import { GlobalExceptionsFilter } from './common/filters/GlobalExceptionsFilter'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  // 全局管道配置
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  )

  // 全局拦截器
  app.useGlobalInterceptors(new GlobalInterceptor())

  // 全局异常过滤器
  app.useGlobalFilters(new GlobalExceptionsFilter())

  // CORS 配置
  app.enableCors()

  const port = process.env.PORT || 3000
  await app.listen(port)

  console.log(`Application is running on: http://localhost:${port}`)
}
bootstrap()