NestJS 异常过滤器与 React 错误边界:实现分布式架构下的全栈异常捕获与告警拓扑

各位同学,各位未来的分布式系统架构师,以及所有那些看着控制台报错心都在滴血的兄弟姐妹们,大家下午好。

今天我们不谈什么高并发、微服务架构的虚无缥缈的概念,我们要聊点实实在在的——如何防止你的应用变成一个只会弹窗报错的“玻璃心”

想象一下,你正在写代码,就像是在玩俄罗斯方块,一个个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.',
    });
  }
}

看,这代码多优雅。我们在这里做了三件事:

  1. 日志记录:把所有的错误都扔进 console.error,并带上时间戳、URL、TraceID。注意那个 traceId,它是我们后面连接前端和后端的纽带。
  2. 状态码控制:根据异常类型返回 404、403 还是 500。
  3. 降级响应:给用户展示一个友好的界面,而不是一串看不懂的 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 崩了,NavbarFooter 还能幸存。这就是“局部崩溃,全局不倒”的高级境界。


第三部分:分布式架构下的“全栈”连接

现在,我们有了前端的盾,后端的墙。但是,这两者之间怎么交流?

在一个真正的分布式架构中,用户发起一个请求,可能会经过网关、认证服务、业务逻辑服务、数据库。前端看到的是 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;

现在的拓扑是这样的:

  1. 前端带着 x-trace-id 发起请求。
  2. TraceIdMiddleware 拦截请求,把它挂载到 req 对象上。
  3. 如果后端 A 调用后端 B,后端 B 在日志里也看到了这个 x-trace-id
  4. 如果后端 B 报错了,它的 AllExceptionsFilter 记录日志时带上这个 ID。
  5. 如果前端也捕获到了错误,前端也记录日志时带上这个 ID。
  6. 结果:你在 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 {
  // 疯狂告警
}

第五部分:实战演练——一个完整的分布式故障模拟

让我们模拟一个场景:数据库挂了。

  1. 前端:用户点击“提交订单”。
  2. 请求流:前端 -> 网关 -> 订单服务 -> 数据库。
  3. 故障点:数据库断开了连接。
  4. 后端(NestJS)
    • OrderService 试图查询数据库。
    • 数据库返回错误(可能是 ConnectionTimeoutException)。
    • OrderService 抛出异常。
    • AllExceptionsFilter 捕获。
    • 过滤器检查:这是一个预期的数据库异常。
    • 过滤器记录日志(带上 TraceID)。
    • 过滤器返回 HTTP 503 (Service Unavailable) 给前端。
  5. 前端(React)
    • 收到 503 响应。
    • Axios 拦截器捕获这个错误。
    • 关键步骤:Axios 拦截器把这次失败的请求详情、TraceID、用户信息记录到本地(例如 IndexedDB)。
    • Axios 拦截器弹出一个“系统繁忙,请稍后再试”的 Toast 提示。
  6. 监控
    • 你的 Sentry/ELK 系统收到了一条带有 TraceID 的日志。
    • 点击 TraceID,你看到了完整的调用链:前端请求 -> 网关 -> 订单服务。
    • 你没有看到前端具体的报错页面,因为前端捕获了错误并优雅降级了。
    • 但你看到了后端的 500/503 日志,这就足够了。

第六部分:深度思考——不仅仅是代码

在分布式架构下,异常处理其实是在管理系统的期望

当你的服务变得越来越复杂,微服务越来越多,你无法保证每个服务都 100% 健康运行。错误的本质是什么?错误是系统偏离了正常行为

我们的目标不是消灭所有错误,因为在不完美的现实世界里这是不可能的。我们的目标是:

  1. 隔离:防止一个服务挂掉拖垮整个系统(熔断器、超时设置)。
  2. 可见:让错误无处遁形,让运维人员能在出问题的一秒内知道(TraceID、监控)。
  3. 恢复:让前端用户看到的是一张“正在修复”的图片,而不是一个白屏(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,每次失败都会重新触发

这看起来很简单,但在实际生产环境中,这是提升用户体验的救命稻草。

结语

我们今天聊了太多技术细节,从 ExceptionFiltergetDerivedStateFromError,从 TraceID 到重试策略。但归根结底,这些技术的背后是对用户的尊重。

你写的代码,不仅仅是逻辑的堆砌,更是用户与数字世界交互的桥梁。当桥梁塌了(系统挂了),如果你能用 NestJS 的过滤器把碎片捡起来,用 React 的边界把破碎的桥面重新铺上,你就不仅仅是写代码的,你是数字世界的造桥师

所以,下一次当你准备把 throw new Error() 撂在那儿不管时,想一想:那个正在等待下单的用户,正盯着屏幕,希望能看到那张“稍等片刻”的图片。这就是我们写代码的动力,也是我们构建分布式架构的终极意义。

好了,下课!别忘了给你的 AppModule 加上 TraceIdMiddleware,别忘了给你的 App 包上 ErrorBoundary。Go build, Go fly

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注