各位同学,各位未来的分布式系统架构师,以及所有那些看着控制台报错心都在滴血的兄弟姐妹们,大家下午好。
今天我们不谈什么高并发、微服务架构的虚无缥缈的概念,我们要聊点实实在在的——如何防止你的应用变成一个只会弹窗报错的“玻璃心”。
想象一下,你正在写代码,就像是在玩俄罗斯方块,一个个Bug像方块一样掉下来。你左移、右移、旋转,试图把它们塞进正确的位置。如果你手一抖,所有的方块堆到了天花板,游戏结束。这时候,你不需要什么微服务,你只需要一个足够厚实的“错误捕获系统”,把那些试图冲垮你代码堆砌的积木挡在门外。
今天,我们将深入底层,探讨如何用 NestJS 异常过滤器 在后端筑起长城,用 React 错误边界 在前端布下迷雾,最后构建一个完整的分布式架构下的全栈异常捕获与告警拓扑。别怕,这听起来很吓人,但我会像讲故事一样把它讲清楚。
第一部分:后端防线——NestJS 异常过滤器的艺术
首先,我们把目光投向服务器端。在 NestJS 这个框架里,异常处理不是什么选修课,而是必修课。如果你让 throw new Error("Something went wrong") 直接把错误吐给浏览器,那你就好比在自家门口贴了一张纸条写着“我是傻瓜”。
我们要做的,是优雅地捕获它,记录它,然后告诉用户“稍等,我们在修”。
1. 为什么我们需要过滤器?
在早期的 Web 开发中,错误处理往往是被动的。前端抛个错,后端崩个库,用户看到个 500 Internal Server Error。用户心里想的是:“这破网站,连个水都烧不开。”而开发者在后台看着日志想的是:“救命,谁把我的数据库给删了?”
异常过滤器(Exception Filter)就是那个在后台默默擦屁股的人。
2. 代码实战:构建一个“优雅”的异常过滤器
让我们写一个标准的全局异常过滤器。注意,不要直接把错误暴露给前端,那是安全的大忌。
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// 获取状态码,如果没有指定就用默认的 500
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// 核心技巧:结构化日志
// 不要只打印 Exception,要打印 TraceID,这是分布式追踪的灵魂
const exceptionMessage =
exception instanceof HttpException
? exception.getResponse()
: exception;
console.error({
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
statusCode: status,
error: 'Internal Server Error',
message: exceptionMessage, // 或者是 exception.message
// 在分布式架构中,我们通常会在中间件里把 TraceID 注入到 Request 里
traceId: request.headers['x-trace-id'] || 'unknown',
});
// 告诉用户什么?不要说“服务器炸了”,要说“系统维护中”
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: 'Oops! Something went wrong on our side.',
});
}
}
看,这代码多优雅。我们在这里做了三件事:
- 日志记录:把所有的错误都扔进
console.error,并带上时间戳、URL、TraceID。注意那个traceId,它是我们后面连接前端和后端的纽带。 - 状态码控制:根据异常类型返回 404、403 还是 500。
- 降级响应:给用户展示一个友好的界面,而不是一串看不懂的 JSON。
3. 针对不同异常的精细化管理
有时候,不是所有的错误都应该返回给用户。比如,密码错误,我们可以说“密码错了”,但绝不能说“你输入的 SQL 语句有误”。这种时候,我们就需要自定义异常类。
export class UserNotFoundException extends HttpException {
constructor(userId: string) {
super(
{
statusCode: HttpStatus.NOT_FOUND,
message: `User with ID ${userId} not found`,
error: 'Not Found',
},
HttpStatus.NOT_FOUND,
);
}
}
然后我们的过滤器只需要判断一下 instanceof 就行,这就是多态的威力。
第二部分:前端盾牌——React 错误边界的诞生
好了,后端保住了,现在看前端。在 React 16 以前,如果一个组件内部抛出了错误,整个应用就会崩塌,就像多米诺骨牌一样,后面所有的组件都不渲染了。
React 16 引入了一个新概念——错误边界。这就像是给你的组件穿上了一层防弹衣。防弹衣破了,人没事;组件炸了,只是把那部分切下来,别影响整栋楼。
1. 什么是“受控”的错误?
React 的错误边界是通过生命周期方法 componentDidCatch 和静态方法 getDerivedStateFromError 来工作的。
getDerivedStateFromError 就像是一个开关。当错误发生时,React 会调用这个方法,把错误对象作为参数传进来。在这个方法里,你可以修改 state,通常的做法是把 state.error 设为 true。一旦状态变成 true,React 就不会渲染子组件,而是渲染你指定的“错误 UI”。
2. 代码实战:打造一个全局 Error Boundary
我们写一个通用的 ErrorBoundary 组件。
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
// 这个静态方法负责捕获子组件抛出的错误
static getDerivedStateFromError(error: Error): State {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 这里我们可以把错误上报给监控系统,比如 Sentry
console.error("Uncaught error:", error, errorInfo);
// 比如调用后端的 API 记录日志
// reportErrorToServer(error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
// 你可以在这里渲染你的错误页面,甚至可以放一张可爱的猫猫图片安慰用户
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#ff0000' }}>
<h1>哎呀,这里崩了!</h1>
<p>别担心,工程师正在像打了鸡血一样修复它。</p>
<button onClick={() => window.location.reload()}>
刷新页面
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
3. 嵌套与层级
Error Boundary 不是万能药。它只能捕获其子组件树中的错误。所以,你需要把它包裹在最外层。
<ErrorBoundary>
<AppLayout>
<Navbar />
<MainContent />
<Footer />
</AppLayout>
</ErrorBoundary>
如果 MainContent 崩了,Navbar 和 Footer 还能幸存。这就是“局部崩溃,全局不倒”的高级境界。
第三部分:分布式架构下的“全栈”连接
现在,我们有了前端的盾,后端的墙。但是,这两者之间怎么交流?
在一个真正的分布式架构中,用户发起一个请求,可能会经过网关、认证服务、业务逻辑服务、数据库。前端看到的是 HTTP 响应,后端之间是 RPC 调用。如果中间任何一个环节出错了,如何把它们串起来?
这就是 TraceID(追踪ID) 的作用。
1. 中间件:TraceID 的注入者
我们需要一个 NestJS 中间件,把 TraceID 塞进每个 HTTP 请求的 Request 对象里。
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid'; // 需要 npm install uuid
@Injectable()
export class TraceIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 如果请求头里带了 TraceId,就用它,否则生成一个新的
const traceId = req.headers['x-trace-id'] || uuidv4();
// 把它挂载到 req 对象上,方便后续所有地方访问
req['traceId'] = traceId;
// 同时也设置到响应头里,这样前端也能拿到
res.setHeader('x-trace-id', traceId);
next();
}
}
2. 环绕执行器:全局绑定
有了中间件,我们还要在全局使用它。
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './logging.interceptor'; // 假设我们有一个日志拦截器
@Module({
imports: [...],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor, // 这里面会用到 TraceID
},
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
TraceIdMiddleware, // 全局中间件
],
})
export class AppModule implements NestModule {
configure(consumer: any) {
consumer.apply(TraceIdMiddleware).forRoutes('*');
}
}
3. 前端的 TraceID 携带
前端发起请求时,也要带上这个 ID。这通常在 axios 的拦截器里完成。
// axiosInstance.js
import axios from 'axios';
const axiosInstance = axios.create();
axiosInstance.interceptors.request.use((config) => {
const traceId = localStorage.getItem('traceId') || generateUUID();
localStorage.setItem('traceId', traceId); // 存下来,万一页面刷新也能找回线索
config.headers['x-trace-id'] = traceId;
return config;
});
export default axiosInstance;
现在的拓扑是这样的:
- 前端带着
x-trace-id发起请求。 TraceIdMiddleware拦截请求,把它挂载到req对象上。- 如果后端 A 调用后端 B,后端 B 在日志里也看到了这个
x-trace-id。 - 如果后端 B 报错了,它的
AllExceptionsFilter记录日志时带上这个 ID。 - 如果前端也捕获到了错误,前端也记录日志时带上这个 ID。
- 结果:你在 Sentry 或 ELK 控制台搜索这个 ID,你不仅能看到前端报的错,还能看到后端 A 的日志、后端 B 的日志、数据库的慢查询日志。这就是上帝视角。
第四部分:告警拓扑——别让错误淹没你
捕获错误只是第一步,关键在于响应。如果你把所有错误都发送到 Slack 频道,Slack 会先把你封禁,然后把你踢出公司。告警系统必须具备“嗅觉”。
我们需要设计一个分级告警策略。
1. 什么不该告警?(去噪)
- 404 错误:用户点错了链接,或者黑客在扫你的端口。这种错误多如牛毛,不要告警。
- 5xx 错误(代码抛出的):这是我们要告警的,但要注意频率。
- 5xx 错误(数据库连接超时):有时候数据库挂了,你的应用报 500。这时候先别惊慌,给数据库留点时间。设置一个“静默期”,比如 5 分钟内同一错误不重复告警。
2. 什么是该告警的?
- 未捕获的异常:这就像家里着火了,必须马上叫人。
- 认证失败:有人试图暴力破解你的系统。
- 业务异常:比如支付接口返回了特定的错误码,意味着订单异常。
3. 代码实战:基于装饰器的告警系统
我们可以写一个装饰器 @NotifyError,当抛出这个注解的异常时,自动发送通知。
import { Injectable, BadRequestException } from '@nestjs/common';
import { NotificationService } from './notification.service'; // 假设你有一个发消息的服务
@Injectable()
export class AppExceptionFilter implements ExceptionFilter {
constructor(private readonly notificationService: NotificationService) {}
catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
// 简单的告警逻辑
if (exception instanceof Error && exception.message.includes('Critical')) {
// 发送到 PagerDuty 或 Slack Webhook
this.notificationService.sendAlert(
'CRITICAL ERROR',
exception.stack,
);
}
// ... 原有的过滤器逻辑
response.status(500).json({ message: 'Error' });
}
}
但这还不够优雅。 我们希望代码里看起来是自然的。
// 业务逻辑里
throw new BadRequestException('Payment gateway timeout', {
cause: new Error(),
isOperational: true // 标记这是一个可预期的异常,不需要全员惊慌
});
// 拦截器里判断
if (exception['isOperational']) {
// 只记录日志,不吼一嗓子
} else {
// 疯狂告警
}
第五部分:实战演练——一个完整的分布式故障模拟
让我们模拟一个场景:数据库挂了。
- 前端:用户点击“提交订单”。
- 请求流:前端 -> 网关 -> 订单服务 -> 数据库。
- 故障点:数据库断开了连接。
- 后端(NestJS):
OrderService试图查询数据库。- 数据库返回错误(可能是
ConnectionTimeoutException)。 OrderService抛出异常。AllExceptionsFilter捕获。- 过滤器检查:这是一个预期的数据库异常。
- 过滤器记录日志(带上 TraceID)。
- 过滤器返回 HTTP 503 (Service Unavailable) 给前端。
- 前端(React):
- 收到 503 响应。
- Axios 拦截器捕获这个错误。
- 关键步骤:Axios 拦截器把这次失败的请求详情、TraceID、用户信息记录到本地(例如 IndexedDB)。
- Axios 拦截器弹出一个“系统繁忙,请稍后再试”的 Toast 提示。
- 监控:
- 你的 Sentry/ELK 系统收到了一条带有 TraceID 的日志。
- 点击 TraceID,你看到了完整的调用链:前端请求 -> 网关 -> 订单服务。
- 你没有看到前端具体的报错页面,因为前端捕获了错误并优雅降级了。
- 但你看到了后端的 500/503 日志,这就足够了。
第六部分:深度思考——不仅仅是代码
在分布式架构下,异常处理其实是在管理系统的期望。
当你的服务变得越来越复杂,微服务越来越多,你无法保证每个服务都 100% 健康运行。错误的本质是什么?错误是系统偏离了正常行为。
我们的目标不是消灭所有错误,因为在不完美的现实世界里这是不可能的。我们的目标是:
- 隔离:防止一个服务挂掉拖垮整个系统(熔断器、超时设置)。
- 可见:让错误无处遁形,让运维人员能在出问题的一秒内知道(TraceID、监控)。
- 恢复:让前端用户看到的是一张“正在修复”的图片,而不是一个白屏(Error Boundary、降级 UI)。
1. 超时与重试的艺术
在 NestJS 中,这通常通过 HTTP 客户端(如 HttpService)配置完成。
// 配置重试策略
this.httpService.get(url, {
timeout: 3000, // 3秒超时,别傻等
retry: {
retries: 3,
delay: 1000,
},
});
这不仅仅是重试,这是在给你的系统留出“呼吸”的时间。如果数据库响应慢了,不要一直问它,问三遍然后放弃。
2. 前端的重试机制
当网络波动导致请求失败时,React 的 Error Boundary 是救不了网络错误的。我们需要在 React 侧重试。
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
const fetchData = async () => {
try {
const res = await axios.get('/api/data');
setData(res.data);
setRetryCount(0); // 成功了,重置计数
} catch (err) {
if (retryCount < 3) {
setTimeout(() => setRetryCount(prev => prev + 1), 1000); // 延迟一秒重试
}
}
};
fetchData();
}, [retryCount]); // 依赖 retryCount,每次失败都会重新触发
这看起来很简单,但在实际生产环境中,这是提升用户体验的救命稻草。
结语
我们今天聊了太多技术细节,从 ExceptionFilter 到 getDerivedStateFromError,从 TraceID 到重试策略。但归根结底,这些技术的背后是对用户的尊重。
你写的代码,不仅仅是逻辑的堆砌,更是用户与数字世界交互的桥梁。当桥梁塌了(系统挂了),如果你能用 NestJS 的过滤器把碎片捡起来,用 React 的边界把破碎的桥面重新铺上,你就不仅仅是写代码的,你是数字世界的造桥师。
所以,下一次当你准备把 throw new Error() 撂在那儿不管时,想一想:那个正在等待下单的用户,正盯着屏幕,希望能看到那张“稍等片刻”的图片。这就是我们写代码的动力,也是我们构建分布式架构的终极意义。
好了,下课!别忘了给你的 AppModule 加上 TraceIdMiddleware,别忘了给你的 App 包上 ErrorBoundary。Go build, Go fly