本文属于 NestJS BBS 实战教程,共 11 节,建议按顺序学习。
1. 这节课要实现的功能是什么
- 让 BBS 项目具备稳定的日志能力,方便排查线上与开发环境中的问题。
- 统一记录关键日志:应用启动、HTTP 请求、业务异常、数据库错误。
- 为每次请求挂上
requestId,把同一次请求中的多条日志串起来。 - 建立“什么时候该打日志、打什么日志、哪些内容不能打”的基本工程习惯。
2. 这节课涉及的 NestJS 知识点有哪些(先讲概念)
2.1 为什么日志是一节单独的课
很多初学者会把日志理解成“在代码里随手写几个 console.log”。这在小 demo 里能工作,但在真实项目里很快就会失控。
日志真正解决的是三个问题:
- 系统现在发生了什么。
- 某个请求是在哪一步失败的。
- 问题发生时,相关上下文是什么。
NestJS 本身提供了日志能力,但更重要的是:Nest 的模块化、依赖注入、Interceptor、Exception Filter 这些机制,天然适合把日志做成一套统一能力,而不是散落在各个 Service 里的临时输出。
2.2 Logger 是什么,为什么 Nest 要提供它
Nest 内置的 Logger 是一个日志工具类,用来输出不同级别的日志。最常见的级别有:
log:普通运行日志,比如应用启动成功。warn:可疑但不一定失败的情况,比如缓存未命中、配置缺失兜底。error:明确的错误,比如数据库操作失败、第三方接口异常。debug:调试阶段使用的细节信息。verbose:比debug更细的运行细节。
它存在的意义,不只是为了替代 console.log,而是为了让你的日志表达更清晰。别人看到一条 error 日志,会立刻知道这是故障线索;看到 warn,就知道这是需要关注但还没完全失败的问题。
2.3 日志应该记录什么,不应该记录什么
在项目里打日志,不是“越多越好”,而是“关键路径要有,敏感信息不能有”。
推荐记录:
- 请求方法、路径、状态码、耗时。
- 关键业务动作,比如“用户注册成功”“主题创建成功”。
- 异常上下文,比如用户 ID、帖子 ID、请求 ID。
不应该记录:
- 明文密码。
- 完整 JWT。
- 数据库密钥、第三方密钥。
- 体积很大的原始请求体。
日志的目标是排查问题,不是泄露数据。
2.4 为什么请求日志通常放在 Interceptor,而不是散落在 Controller 里
请求日志属于典型的横切逻辑。所谓横切逻辑,就是“很多接口都需要,但它不属于某个具体业务模块”。
如果你把请求日志写在每个 Controller 方法里,会有三个问题:
- 每个接口都要重复写一遍。
- 日志格式不统一。
- 后面如果想统一增加耗时、状态码、请求 ID,会非常难改。
Interceptor 更适合这件事,因为它天然包裹 Controller 的执行过程。你可以在进入 Controller 之前记录“请求开始”,也可以在返回响应之后记录“请求结束”和“耗时”。
2.5 requestId 是什么,为什么日志里经常要有它
当系统里只有一两个请求时,看日志很简单。但真实服务里,很多请求会同时到来,日志会交错在一起。
这时就需要一个“请求链路编号”。最常见的做法是给每个请求分配一个 requestId,然后把同一次请求中的日志都带上这个字段。
这样做的价值非常直接:
- 一次请求从入口到异常,能串成一条线。
- 前端、网关、Nest 服务、数据库代理都可以共用同一个链路编号。
- 排查问题时,不需要在一堆混杂日志里盲找。
2.6 应用日志、请求日志、异常日志分别应该放在哪里
这三类日志经常混在一起,但职责不同:
- 应用日志:通常写在
main.ts、模块初始化、定时任务启动等位置。 - 请求日志:通常放在 Interceptor 或 Middleware 中。
- 异常日志:通常放在 Exception Filter 或明确的
catch分支中。
理解这个分层很重要。它能帮助你判断“这条日志应该放在什么地方才合理”,而不是一遇到问题就往 Service 里塞输出语句。
2.7 出现问题时,应该去哪里查 NestJS 的日志
这里要先建立一个很重要的认知:NestJS 默认并没有内置“日志查询系统”。Nest 做的事情,是把日志输出出去;至于你怎么查,取决于日志最终落到了哪里。
最常见的几种位置是:
- 本地开发:直接看终端输出。
- Docker 部署:查看容器日志。
- PM2 部署:查看 PM2 管理的进程日志。
- Linux 服务托管:查看
systemd或宿主机日志。 - 文件输出:直接查看日志文件。
- 日志平台:在 ELK、Loki、Datadog、Sentry 等系统里搜索。
所以排查问题时,更准确的说法不是“我要查 Nest 的日志”,而是“我要查 Nest 输出到哪个介质的日志”。
2.8 真正查日志时,应该按什么维度去找
很多新手排查问题时,习惯凭时间点去翻日志。这种方式在请求少的时候还能用,但一旦并发上来,效率会很低。
更合理的做法,是优先用这些字段去搜索:
requestId- 用户 ID
- 请求路径
- 状态码
- 错误级别
- 异常堆栈
其中最有价值的,通常是 requestId。因为它能把一次请求从入口日志、业务日志、异常日志串成一条线。只要前端、网关、Nest 服务共享同一个 requestId,排查效率会明显提高。
2.9 日志文件越来越大,应该怎么处理
日志文件变大是必然现象,真正的问题不是“文件大”,而是“文件无限增长、难以检索、难以清理”。
工程上常见的处理方式有三种:
- 日志轮转:按天或按大小切分新文件。
- 压缩归档:旧日志自动压缩,减少磁盘占用。
- 集中采集:把日志采集到统一平台做检索和分析。
如果你现在还是文件方案,至少要做到:
- 不让单个日志文件无限增大。
- 保留最近一段时间的日志。
- 旧日志自动压缩或删除。
- 重要日志具备统一字段,方便搜索。
2.10 小项目和正式生产环境,日志治理思路有什么区别
不同阶段,日志方案不需要一开始就上最重的系统,但思路要清楚。
- 开发环境:控制台输出就够用,重点是看得见、调试快。
- 小型部署:文件日志配合轮转已经能解决大部分问题。
- 容器化部署:更常见的做法是输出到标准输出,再交给平台采集。
- 正式生产:更推荐结构化日志和集中式检索平台。
你可以把这件事理解为一个渐进升级过程:先保证“有日志”,再保证“查得到”,最后保证“能关联、能检索、能长期治理”。
3. 实际开发中这些 NestJS 知识点被如何使用(详细代码)
3.1 src/common/logger/app-logger.service.ts - 封装统一日志入口
先看一个最基本、也是最实用的做法:封装一个项目级日志服务,而不是到处直接 new Logger()。
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class AppLoggerService {
private readonly logger = new Logger('App');
log(message: string, context?: string, meta?: Record<string, unknown>) {
this.logger.log(this.format(message, meta), context);
}
warn(message: string, context?: string, meta?: Record<string, unknown>) {
this.logger.warn(this.format(message, meta), context);
}
error(message: string, trace?: string, context?: string, meta?: Record<string, unknown>) {
this.logger.error(this.format(message, meta), trace, context);
}
private format(message: string, meta?: Record<string, unknown>) {
if (!meta) {
return message;
}
return `${message} ${JSON.stringify(meta)}`;
}
}
这里的重点不是“多包了一层”,而是为后续扩展留好位置。真实项目里,你很可能会继续加上:
- 统一 JSON 结构化输出。
- 自动补上
requestId。 - 接入
winston、pino、ELK、Datadog、Sentry。
如果日志散落在每个文件里,后面这些升级会很痛苦。
3.2 常见部署场景下,日志通常怎么查询
这一节不写 Nest 代码,而是帮你建立实际排障时的操作意识。因为很多问题不是“代码不会写”,而是“出故障后不知道去哪里找证据”。
如果你的服务部署方式不同,查日志的入口也不同:
# Docker
docker logs <container-name>
# 实时跟踪
docker logs -f <container-name>
# PM2
pm2 logs
# Linux systemd
journalctl -u nest-app -f
# 文件日志
tail -f logs/app.log
grep "requestId" logs/app.log
这里要注意一个工程判断:这些命令只是“查看入口”,真正提高排障效率的,仍然是日志内容本身是否带了 requestId、路径、状态码、用户 ID 这些关键信息。
3.3 src/common/interceptors/logging.interceptor.ts - 统一记录请求开始与结束
请求日志最适合放在 Interceptor 中,因为它能同时拿到“请求进入”和“响应返回”两个时机。
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { AppLoggerService } from '../logger/app-logger.service';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly logger: AppLoggerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<Request & { requestId?: string }>();
const response = http.getResponse<{ statusCode: number }>();
const startedAt = Date.now();
this.logger.log('HTTP request started', LoggingInterceptor.name, {
method: request.method,
path: request.url,
requestId: request.requestId ?? null,
});
return next.handle().pipe(
tap({
next: () => {
this.logger.log('HTTP request completed', LoggingInterceptor.name, {
method: request.method,
path: request.url,
statusCode: response.statusCode,
durationMs: Date.now() - startedAt,
requestId: request.requestId ?? null,
});
},
error: (error) => {
this.logger.error('HTTP request failed', error?.stack, LoggingInterceptor.name, {
method: request.method,
path: request.url,
statusCode: response.statusCode,
durationMs: Date.now() - startedAt,
requestId: request.requestId ?? null,
});
},
}),
);
}
}
这一段代码要重点理解两个点:
next.handle()代表“继续执行后面的 Controller”。tap()允许你在响应成功或失败后追加日志,而不用改每个业务方法。
这就是 Interceptor 特别适合日志的原因:日志能力被抽到框架层统一处理,业务代码保持干净。
3.4 src/common/middleware/request-id.middleware.ts - 为请求生成 requestId
如果前面的网关或 Nginx 没有传 x-request-id,Nest 应用也可以自己兜底生成。
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
type RequestWithId = Request & { requestId?: string };
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: RequestWithId, res: Response, next: NextFunction) {
const requestId = req.header('x-request-id') ?? randomUUID();
req.requestId = requestId;
res.setHeader('x-request-id', requestId);
next();
}
}
这个中间件做了三件很重要的事:
- 如果客户端已经传了
x-request-id,就沿用它。 - 如果没有传,就生成一个新的 UUID。
- 同时把它放到请求对象和响应头中,方便日志与客户端排查一起对齐。
这也是日志工程里很常见的“前后端共用链路编号”做法。
3.5 src/app.module.ts - 在模块里注册日志相关能力
日志能力真正可用,不是因为你写了类,而是因为你把它们正确注册进了 Nest 的模块系统。
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppLoggerService } from './common/logger/app-logger.service';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
@Module({
providers: [
AppLoggerService,
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
这段代码分别体现了两种 NestJS 机制:
APP_INTERCEPTOR:把LoggingInterceptor注册为全局拦截器。consumer.apply(...).forRoutes('*'):把RequestIdMiddleware应用到所有 HTTP 路由。
这两者配合后,就形成了完整的请求日志链路:
- 请求先进入
RequestIdMiddleware,获得requestId。 - 然后进入
LoggingInterceptor,记录开始日志。 - Controller 和 Service 正常执行业务。
- 响应返回或抛错后,
LoggingInterceptor再记录结束或错误日志。
3.6 文件日志越来越大时,轮转通常怎么做
如果你把日志落到文件中,就一定会遇到文件越来越大的问题。这个问题不能靠“人工定期删文件”解决,而应该交给日志轮转机制。
在 Linux 环境里,一个很常见的方案是 logrotate。例如:
/var/log/nestjs/app.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
}
这段配置的含义是:
- 每天轮转一次日志。
- 最多保留 14 份历史日志。
- 历史日志自动压缩。
- 文件不存在也不要报错。
- 空文件不轮转。
它解决的不是业务问题,而是运维治理问题:防止日志长期无限膨胀,占满磁盘。
3.7 src/users/users.service.ts - 在业务层记录关键动作,而不是记录所有细节
日志不是只放在框架层。业务层也需要记录“关键业务动作”,但要克制,不要把每一步内部细节都打出来。
import { ConflictException, Inject, Injectable } from '@nestjs/common';
import { AppLoggerService } from '../common/logger/app-logger.service';
import { USERS_REPOSITORY, UsersRepository } from './users.repository';
@Injectable()
export class UsersService {
constructor(
@Inject(USERS_REPOSITORY)
private readonly usersRepository: UsersRepository,
private readonly logger: AppLoggerService,
) {}
async create(dto: CreateUserDto) {
const existing = await this.usersRepository.findByEmail(dto.email);
if (existing) {
this.logger.warn('User registration rejected because email already exists', UsersService.name, {
email: dto.email,
});
throw new ConflictException('邮箱已存在');
}
const user = await this.usersRepository.create(dto);
this.logger.log('User created successfully', UsersService.name, {
userId: user.id,
email: user.email,
});
return user;
}
}
这一段体现的是日志粒度控制:
- “用户创建成功”值得记录,因为这是关键业务动作。
- “执行到第 3 行了”“仓储方法刚开始调用”这种日志通常没有长期价值。
日志太少,排查不了问题;日志太多,真正有价值的信息会被噪声淹没。
3.8 src/common/filters/all-exceptions.filter.ts - 在异常出口统一记录错误
异常日志也不应该每个 Controller 自己写一遍。更合理的方式,是在统一异常过滤器里集中记录。
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { AppLoggerService } from '../logger/app-logger.service';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: AppLoggerService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request & { requestId?: string }>();
const response = ctx.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
this.logger.error('Unhandled exception captured', exception instanceof Error ? exception.stack : undefined, AllExceptionsFilter.name, {
method: request.method,
path: request.url,
requestId: request.requestId ?? null,
statusCode: status,
});
response.status(status).json({
statusCode: status,
message: exception instanceof HttpException ? exception.message : '服务器内部错误',
requestId: request.requestId ?? null,
});
}
}
这样做的价值很明显:
- 用户拿到错误响应时,也能看到
requestId。 - 运维或后端同学可以用同一个
requestId去检索日志。 - 即使业务层没来得及记录,异常出口也至少会保留一条故障线索。
3.9 本节落地价值
完成这一节后,你的项目不再只是“能跑”,而是开始具备基本可观测性。
这会带来非常直接的工程收益:
- 本地调试更快,知道请求走到了哪一步。
- 线上排障更快,知道哪一次请求失败、为什么失败。
- 服务协作更顺畅,前端、网关、后端能通过
requestId对齐同一次请求。
4. 总结这节课知识点
- 你理解了 Nest 内置
Logger的作用,以及为什么它比随手写console.log更适合工程项目。 - 你知道了请求日志、业务日志、异常日志应该分别放在什么位置。
- 你掌握了
Middleware + Interceptor + Exception Filter组合实现日志链路的方式。 - 你理解了
requestId的价值:它不是装饰字段,而是排查问题时非常关键的链路标识。 - 你建立了更重要的工程意识:日志不是写给程序看的,而是写给未来排查问题的人看的。
5. 每节课分支
lesson-11-logging-observability- 远程仓库:
https://github.com/Shiuwn/nestjs-tutorial.git