{T}

异常处理

异常处理是 Node.js 应用程序开发中的核心环节。合理的异常处理策略能够:

  • 提高应用的稳定性和可靠性
  • 快速定位和修复问题
  • 提升用户体验
  • 便于生产环境的问题追踪

Node.js 提供多种异常处理机制:

  • 同步异常try...catch...finally
  • 异步异常:回调函数、Promise、async/await
  • 全局异常uncaughtExceptionunhandledRejection
  • 自定义异常:继承 Error 类创建业务错误

使用 console 基础调试

console 模块是 Node.js 内置的最简单、最直接的调试工具。通过在代码中插入日志输出,可以实时观察程序状态、变量值和执行流程

日志级别

console 对象提供多种方法,用于输出不同级别的日志信息,以便更好地区分和过滤

  • console.log(): 最常用的输出方法,用于打印通用信息
  • console.info(): 与 log 类似,用于输出提示性信息
  • console.warn(): 输出警告信息,通常表示潜在问题
  • console.error(): 输出错误信息,表示已发生错误
  • console.debug(): 用于输出调试信息,比 log 更详尽
javascript
console.log("这是一条普通日志")
console.info("这是一条提示信息")
console.warn("警告:某个函数即将被废弃")
console.error(new Error("发生了一个严重错误"))

格式化输出

console.log 支持 C 语言 printf 风格的占位符,可以更清晰地格式化输出

  • %s: 字符串
  • %d%i: 整数
  • %f: 浮点数
  • %o: 可交互的对象
  • %O: 更详细的对象信息
  • %%: 百分号
javascript
const user = { name: "Alice", age: 30 }
console.log("用户信息: %o", user)
// 输出: 用户信息: { name: 'Alice', age: 30 }

for (let i = 1; i <= 3; i++) {
  console.log("第 %d 次循环", i)
}
// 输出:
// 第 1 次循环
// 第 2 次循环
// 第 3 次循环

计时器

console.time()console.timeEnd() 可以用来测量代码块的执行时间

javascript
console.time("my-timer")
// ... 一段耗时操作 ...
for (let i = 0; i < 100000; i++) {}
console.timeEnd("my-timer")
// 输出: my-timer: 0.81ms (时间因机器而异)

最佳实践: 虽然 console 很方便,但在生产环境中应避免过多使用,因为它会产生大量 I/O 操作,影响性能。推荐使用专门的日志库(如 winstonpino)来管理日志

异常处理机制

异常处理是保证程序健壮性的重要一环。Node.js 中的异常分为同步异常和异步异常

同步异常

同步代码中的异常可以通过 try...catch 语句块来捕获

Error 对象

当错误发生时,Node.js 通常会抛出 Error 对象。Error 对象包含三个核心属性:

  • name: 错误类型 (如 TypeError, ReferenceError)。
  • message: 错误的描述信息。
  • stack: 错误的堆栈跟踪,显示了错误发生的位置和调用路径。
javascript
try {
  // 一段可能出错的代码
  const data = JSON.parse('{ "name": "Alice", }') // 错误的 JSON 格式
} catch (error) {
  console.error("捕获到错误!")
  console.error("错误名称:", error.name) // SyntaxError
  console.error("错误信息:", error.message) // Unexpected token } in JSON at position 24
  console.error("堆栈跟踪:", error.stack)
}

throw 语句

可以使用 throw 关键字主动抛出异常。最佳实践是抛出 Error 对象或其子类的实例

javascript
function divide(a, b) {
  if (b === 0) {
    throw new Error("除数不能为零")
  }
  return a / b
}

try {
  console.log(divide(10, 0))
} catch (error) {
  console.error(error.message) // 输出: 除数不能为零
}

try...catch...finally

finally 代码块中的代码无论是否发生异常,都总是会被执行。这对于释放资源(如关闭文件句柄、数据库连接)非常有用

javascript
const fs = require("fs")

let fileHandle
try {
  fileHandle = fs.openSync("test.txt", "r")
  // ... 对文件进行操作 ...
} catch (error) {
  console.error("文件操作失败:", error.message)
} finally {
  if (fileHandle) {
    fs.closeSync(fileHandle)
    console.log("文件已关闭")
  }
}

异步异常

异步代码中的异常无法被外部的 try...catch 直接捕获,因为异步回调函数在另一个执行上下文中运行

回调函数 (Error-First Pattern)

在 Node.js 的传统回调风格中,回调函数的第一个参数通常是 error 对象。如果操作成功,errornull;如果失败,error 则包含错误信息

javascript
const fs = require("fs")

fs.readFile("non-existent-file.txt", "utf8", (err, data) => {
  if (err) {
    console.error("读取文件失败:", err)
    return
  }
  console.log("文件内容:", data)
})

Promise

Promise 通过 .catch() 方法或 try...catchasync/await 结合来处理异步异常

使用 .catch():

javascript
const fs = require("fs").promises

fs.readFile("non-existent-file.txt", "utf8")
  .then((data) => console.log(data))
  .catch((err) => console.error("Promise 捕获到错误:", err))

使用 async/await:

async/await 让异步代码看起来像同步代码,并且可以直接使用 try...catch 来捕获 await 表达式中的异常

javascript
const fs = require("fs").promises

async function readFileAsync() {
  try {
    const data = await fs.readFile("non-existent-file.txt", "utf8")
    console.log(data)
  } catch (err) {
    console.error("async/await 捕获到错误:", err)
  }
}

readFileAsync()

全局未捕获异常

如果一个异常(无论是同步还是异步)没有被任何地方捕获,它会成为一个“未捕获异常”,并可能导致 Node.js 进程崩溃。可以通过监听 process 对象的事件来处理这类异常,作为最后的防线

  • process.on('uncaughtException', (err, origin) => { ... }):

    • 捕获所有未被 try...catch 捕获的同步异常
    • 警告: 在这里执行异步操作是不安全的。官方建议的用途是记录错误,然后优雅地关闭进程
  • process.on('unhandledRejection', (reason, promise) => { ... }):

    • 捕获所有没有 .catch() 处理的 Promise 拒绝。
javascript
process.on("uncaughtException", (err) => {
  console.error("有一个未捕获的同步异常:", err)
  // 在这里记录日志,然后退出进程
  process.exit(1)
})

process.on("unhandledRejection", (reason, promise) => {
  console.error("有一个未处理的 Promise 拒绝:", reason)
  // 同样,记录日志并考虑是否需要退出
})

// 触发 uncaughtException
throw new Error("这是一个同步错误")

// 触发 unhandledRejection
Promise.reject(new Error("这是一个 Promise 拒绝"))

重要: uncaughtException 是一个粗暴的异常处理机制。依赖它而不是正确地处理错误是一个坏习惯。它的主要目的是在进程崩溃前进行最后的清理和记录

常见错误类型与处理策略

理解 Node.js 中的常见错误类型有助于快速定位问题

Error 子类说明常见场景与处理策略
ReferenceError试图访问一个未定义的变量。场景: 拼写错误、变量作用域问题。<br>策略: 仔细检查变量名和作用域,使用 linter 工具可以有效预防。
TypeError值的类型不符合预期。场景: 对 nullundefined 调用方法、函数参数类型错误。<br>策略: 在使用变量前进行类型检查和空值检查。
RangeError数值超出允许的范围。场景: 无效的数组长度、递归调用没有出口导致栈溢出。<br>策略: 检查边界条件,确保递归有正确的终止条件。
SyntaxError代码不符合 JavaScript 语法。场景: 括号不匹配、关键字错误。<br>策略: 这种错误在代码解析阶段就会抛出,通常由开发环境或构建工具直接报告。
SystemError操作系统级别的错误。场景: 文件不存在 (ENOENT)、权限不足 (EACCES)、端口被占用 (EADDRINUSE)。<br>策略: 根据错误码 (code) 进行相应的处理,例如提示用户检查文件路径或更换端口。

示例:处理 SystemError

javascript
const http = require("http")

const server = http.createServer((req, res) => {
  res.end("Hello World")
})

server.on("error", (err) => {
  if (err.code === "EADDRINUSE") {
    console.error("端口 3000 已被占用,请尝试其他端口。")
  } else {
    console.error("服务器发生未知错误:", err)
  }
})

server.listen(3000, () => {
  console.log("服务器运行在 http://localhost:3000")
})

高级调试工具

除了基础的 console 调试,Node.js 提供了更强大的调试工具。

Node.js 内置调试器

Node.js 内置了调试器,可以通过 inspect 标志启动:

bash
node inspect app.js
# 或使用现代调试协议
node --inspect app.js
node --inspect-brk app.js  # 在第一行断点

然后在 Chrome 浏览器中访问 chrome://inspect 进行可视化调试。

VS Code 调试配置

在项目根目录创建 .vscode/launch.json

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "program": "${workspaceFolder}/app.js",
      "console": "integratedTerminal"
    },
    {
      "type": "node",
      "request": "attach",
      "name": "附加到进程",
      "port": 9229
    }
  ]
}

常用调试技巧

1. 使用 debugger 语句

javascript
function complexCalculation(n) {
  debugger  // 代码会在此处暂停
  let result = 0
  for (let i = 0; i < n; i++) {
    result += i
  }
  return result
}

2. 使用 util.inspect 深度检查对象

javascript
const util = require('util')

const obj = {
  level1: {
    level2: {
      level3: {
        value: 'deep'
      }
    }
  }
}

console.log(util.inspect(obj, {
  depth: null,        // 无限深度
  colors: true,       // 彩色输出
  compact: false      // 格式化输出
}))

3. 性能分析

bash
# 生成 CPU 分析报告
node --prof app.js
# 处理分析报告
node --prof-process isolate-*.log > processed.txt

# 使用 Chrome DevTools 进行性能分析
node --inspect app.js
# 然后在 Chrome DevTools 的 Performance 标签中录制

第三方调试工具

工具说明适用场景
ndbNode.js 调试工具更好的调试体验
node-inspector基于 Chrome 的调试器可视化调试
ironNodeNode.js 调试 IDE复杂应用调试

自定义错误类型

创建自定义错误类

在实际项目中,创建自定义错误类型可以更好地区分和处理不同类型的错误:

javascript
// 基础自定义错误类
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message)
    this.name = this.constructor.name
    this.statusCode = statusCode
    this.isOperational = true  // 标识可预期的错误
    
    Error.captureStackTrace(this, this.constructor)
  }
}

// 数据库错误
class DatabaseError extends AppError {
  constructor(message = '数据库操作失败') {
    super(message, 500)
    this.errorType = 'DATABASE_ERROR'
  }
}

// 验证错误
class ValidationError extends AppError {
  constructor(message = '数据验证失败', errors = []) {
    super(message, 400)
    this.errorType = 'VALIDATION_ERROR'
    this.errors = errors  // 详细错误信息
  }
}

// 认证错误
class AuthenticationError extends AppError {
  constructor(message = '认证失败') {
    super(message, 401)
    this.errorType = 'AUTHENTICATION_ERROR'
  }
}

// 授权错误
class AuthorizationError extends AppError {
  constructor(message = '权限不足') {
    super(message, 403)
    this.errorType = 'AUTHORIZATION_ERROR'
  }
}

// 资源未找到错误
class NotFoundError extends AppError {
  constructor(resource = '资源') {
    super(`${resource}不存在`, 404)
    this.errorType = 'NOT_FOUND_ERROR'
  }
}

使用自定义错误

javascript
// 在业务代码中使用
async function getUserById(id) {
  if (!id || isNaN(id)) {
    throw new ValidationError('用户 ID 格式不正确', [
      { field: 'id', message: 'ID 必须是数字' }
    ])
  }
  
  const user = await db.users.findById(id)
  if (!user) {
    throw new NotFoundError('用户')
  }
  
  return user
}

// 在 Express 中统一处理
app.use((err, req, res, next) => {
  // 操作性错误:可以安全地向客户端返回错误信息
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        type: err.errorType,
        message: err.message,
        errors: err.errors  // 验证错误详细信息
      }
    })
  }
  
  // 编程错误或未知错误:不泄露详细信息
  logger.error('未预期的错误:', err)
  res.status(500).json({
    success: false,
    error: {
      type: 'INTERNAL_ERROR',
      message: '服务器内部错误'
    }
  })
})

错误类型设计最佳实践

javascript
// 1. 带错误码的自定义错误
class ErrorCode {
  static USER_NOT_FOUND = 'USER_001'
  static INVALID_PASSWORD = 'USER_002'
  static USER_ALREADY_EXISTS = 'USER_003'
}

class UserError extends AppError {
  constructor(message, errorCode, statusCode = 400) {
    super(message, statusCode)
    this.code = errorCode
  }
}

// 使用
throw new UserError('用户不存在', ErrorCode.USER_NOT_FOUND, 404)

// 2. 带元数据的错误
class NetworkError extends Error {
  constructor(message, { url, method, statusCode, responseBody }) {
    super(message)
    this.name = 'NetworkError'
    this.url = url
    this.method = method
    this.statusCode = statusCode
    this.responseBody = responseBody
  }
}

// 使用
throw new NetworkError('请求失败', {
  url: 'https://api.example.com/users',
  method: 'GET',
  statusCode: 503,
  responseBody: '{"error": "Service Unavailable"}'
})

错误传播与链式处理

错误链(Error Cause)

从 Node.js v16.9.0 开始,支持错误链,可以在一个错误中包含导致它的原始错误:

javascript
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`)
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`)
    }
    return await response.json()
  } catch (error) {
    // 创建新错误,并保留原始错误信息
    throw new AppError('获取用户数据失败', {
      cause: error  // 原始错误
    })
  }
}

// 捕获后可以追溯错误链
try {
  await fetchUserData(123)
} catch (error) {
  console.error('顶层错误:', error.message)
  if (error.cause) {
    console.error('原始错误:', error.cause.message)
    console.error('原始堆栈:', error.cause.stack)
  }
}

错误包装模式

将底层错误包装为更高级的业务错误:

javascript
class UserRepository {
  async findById(id) {
    try {
      const connection = await db.getConnection()
      const [rows] = await connection.execute(
        'SELECT * FROM users WHERE id = ?',
        [id]
      )
      return rows[0]
    } catch (error) {
      // 包装数据库错误
      if (error.code === 'ECONNREFUSED') {
        throw new DatabaseError('数据库连接失败')
      }
      if (error.code === 'ER_ACCESS_DENIED_ERROR') {
        throw new DatabaseError('数据库访问权限不足')
      }
      // 其他数据库错误
      throw new DatabaseError(`数据库查询失败: ${error.message}`)
    }
  }
}

异步错误边界

创建错误边界函数来统一处理异步错误:

javascript
// 高阶函数:包装异步路由处理器
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next))
      .catch(next)
  }
}

// 使用
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserById(req.params.id)
  res.json(user)
}))

// 或使用 express-async-errors 库(推荐)
// 无需手动包装,直接使用 async/await
require('express-async-errors')

app.get('/users/:id', async (req, res) => {
  const user = await getUserById(req.params.id)
  res.json(user)
})

错误处理策略

重试机制

对于临时性错误(网络抖动、服务暂时不可用),实现自动重试:

javascript
async function retry(fn, options = {}) {
  const {
    maxAttempts = 3,
    delay = 1000,
    backoff = 'exponential',  // 'linear' 或 'exponential'
    shouldRetry = (error) => true  // 判断是否应该重试的函数
  } = options
  
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn()
    } catch (error) {
      if (attempt === maxAttempts || !shouldRetry(error)) {
        throw error
      }
      
      const waitTime = backoff === 'exponential' 
        ? delay * Math.pow(2, attempt - 1)
        : delay * attempt
      
      console.log(`第 ${attempt} 次尝试失败,${waitTime}ms 后重试...`)
      await new Promise(resolve => setTimeout(resolve, waitTime))
    }
  }
}

// 使用
try {
  const data = await retry(
    () => fetchExternalAPI('https://api.example.com/data'),
    {
      maxAttempts: 3,
      delay: 1000,
      backoff: 'exponential',
      shouldRetry: (error) => {
        // 只重试网络错误和 5xx 错误
        return error.code === 'ECONNREFUSED' ||
               (error.statusCode >= 500 && error.statusCode < 600)
      }
    }
  )
} catch (error) {
  console.error('所有重试都失败:', error)
}

断路器模式(Circuit Breaker)

防止故障传播,避免重复请求已知失败的服务:

javascript
class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn
    this.failureThreshold = options.failureThreshold || 5
    this.resetTimeout = options.resetTimeout || 60000  // 1分钟
    this.state = 'CLOSED'  // CLOSED, OPEN, HALF_OPEN
    this.failures = 0
    this.nextAttempt = Date.now()
  }
  
  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('断路器处于打开状态,请求被拒绝')
      }
      this.state = 'HALF_OPEN'
    }
    
    try {
      const result = await this.fn(...args)
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }
  
  onSuccess() {
    this.failures = 0
    this.state = 'CLOSED'
  }
  
  onFailure() {
    this.failures++
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN'
      this.nextAttempt = Date.now() + this.resetTimeout
    }
  }
  
  getState() {
    return {
      state: this.state,
      failures: this.failures,
      nextAttempt: new Date(this.nextAttempt)
    }
  }
}

// 使用
const breaker = new CircuitBreaker(
  () => fetchExternalAPI('https://api.example.com/data'),
  { failureThreshold: 3, resetTimeout: 30000 }
)

try {
  const data = await breaker.call()
} catch (error) {
  if (error.message.includes('断路器')) {
    // 返回降级数据或缓存
    return getCachedData()
  }
  throw error
}

超时处理

为异步操作设置超时,防止无限等待:

javascript
function timeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(`操作超时 (${ms}ms)`)), ms)
    )
  ])
}

// 使用
try {
  const data = await timeout(
    fetchDataFromDatabase(),
    5000  // 5秒超时
  )
} catch (error) {
  if (error.message.includes('超时')) {
    console.error('数据库查询超时')
  }
}

// 或使用 AbortController (Node.js v15+)
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), timeout)
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    })
    clearTimeout(timeoutId)
    return response
  } catch (error) {
    clearTimeout(timeoutId)
    if (error.name === 'AbortError') {
      throw new Error(`请求超时 (${timeout}ms)`)
    }
    throw error
  }
}

错误日志与监控

结构化日志记录

javascript
const winston = require('winston')

// 创建日志记录器
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),  // 记录错误堆栈
    winston.format.json()
  ),
  defaultMeta: { service: 'user-service' },
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
})

// 生产环境添加控制台输出
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }))
}

// 错误日志记录
try {
  await riskyOperation()
} catch (error) {
  logger.error('操作失败', {
    error: {
      message: error.message,
      stack: error.stack,
      code: error.code
    },
    context: {
      userId: '123',
      operation: 'riskyOperation'
    }
  })
  throw error
}

错误追踪集成

集成错误监控服务(如 Sentry):

javascript
const Sentry = require('@sentry/node')

// 初始化 Sentry
Sentry.init({
  dsn: 'https://example@sentry.io/123',
  environment: process.env.NODE_ENV,
  // 设置错误采样率
  tracesSampleRate: 1.0
})

// 捕获异常
try {
  riskyOperation()
} catch (error) {
  Sentry.captureException(error)
  throw error
}

// 添加上下文信息
Sentry.setUser({ id: '123', email: 'user@example.com' })
Sentry.setTag('page_route', '/users/profile')
Sentry.setContext('character', {
  name: 'Mighty Fighter',
  level: 19
})

// Express 集成
app.use(Sentry.Handlers.requestHandler())
// ... 路由 ...
app.use(Sentry.Handlers.errorHandler())

调试与异常处理的最佳实践

开发阶段

  1. 使用 Linter 和 Formatter: 工具如 ESLint 和 Prettier 可以在编码阶段就发现大量潜在的语法和风格问题
  2. 编写单元测试: 测试是检验代码逻辑、模拟边界情况和捕获回归错误的最佳方式
  3. 使用 TypeScript: 类型系统可以在编译时捕获大量错误
  4. 启用严格模式: 在文件开头添加 'use strict' 或使用 --strict 标志
javascript
// 启用严格模式
'use strict'

// 或在 package.json 中配置
{
  "type": "module",
  "engines": {
    "node": ">=18.0.0"
  }
}

生产环境

  1. 日志分级: 在生产环境中使用专门的日志库,并根据重要性设置不同的日志级别(如 info, warn, error),以便于监控和报警
  2. 优雅地处理错误: 不要简单地吞掉错误(catch (e) {})。至少要记录下来。对于可恢复的错误(如网络抖动),可以尝试重试机制;对于不可恢复的错误,应记录详细信息并让应用失败(fail-fast)
  3. 避免 uncaughtException 作为主要错误处理: 它应该是最后的防线,而不是常规的错误处理逻辑
  4. 为异步操作添加超时: 对于网络请求或数据库查询等 I/O 操作,设置合理的超时时间,防止无限等待

错误处理清单

javascript
// ✅ 正确的错误处理示例
async function goodErrorHandling(userId) {
  try {
    // 参数验证
    if (!userId) {
      throw new ValidationError('用户 ID 不能为空')
    }
    
    // 设置超时
    const user = await timeout(
      User.findById(userId),
      5000
    )
    
    if (!user) {
      throw new NotFoundError('用户')
    }
    
    return user
  } catch (error) {
    // 记录错误
    logger.error('获取用户失败', {
      userId,
      error: error.message,
      stack: error.stack
    })
    
    // 重新抛出业务错误
    if (error instanceof AppError) {
      throw error
    }
    
    // 包装未知错误
    throw new AppError('获取用户失败', {
      cause: error
    })
  }
}

// ❌ 错误的错误处理示例
async function badErrorHandling(userId) {
  try {
    const user = await User.findById(userId)
    return user
  } catch (error) {
    // 吞掉错误,不做任何处理
    console.log(error)
    return null
  }
}

## 综合案例:健壮的 Web 服务器

下面是一个简单的 Express 服务器示例,它结合了日志、异步错误处理和全局错误处理中间件:

- 使用 `winston` 进行结构化日志记录
- 使用 `async/await` 和 `try/catch` 处理异步路由的错误
- 创建一个集中的错误处理中间件来标准化错误响应
- 监听全局事件作为最后的保障

```javascript
const express = require("express")
const winston = require("winston") // 一个流行的日志库

// 1. 配置日志记录器
const logger = winston.createLogger({
  level: "info",
  format: winston.format.json(),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: "error.log", level: "error" }),
    new winston.transports.File({ filename: "combined.log" })
  ]
})

const app = express()

// 2. 中间件:记录每个请求
app.use((req, res, next) => {
  logger.info(`${req.method} ${req.url}`)
  next()
})

// 3. 异步路由,并正确处理 Promise 拒绝
app.get("/data", async (req, res, next) => {
  try {
    // 模拟一个异步操作,可能会失败
    const data = await Promise.reject(new Error("无法从数据库获取数据"))
    res.json(data)
  } catch (error) {
    // 将错误传递给 Express 的错误处理中间件
    next(error)
  }
})

app.get("/user/:id", (req, res, next) => {
  const { id } = req.params
  if (isNaN(id)) {
    // 4. 主动抛出特定类型的错误
    const err = new TypeError("用户 ID 必须是数字")
    err.status = 400 // 添加状态码
    return next(err)
  }
  res.send(`用户信息 ${id}`)
})

// 5. 全局错误处理中间件 (必须放在所有路由和中间件之后)
app.use((err, req, res, next) => {
  // 记录错误
  logger.error(
    `${err.status || 500} - ${err.message} - ${req.originalUrl} - ${req.method} - ${
    req.ip
    }`
  )

  // 响应客户端
  res.status(err.status || 500).json({
    error: {
      message: err.message || "服务器内部错误"
    }
  })
})

const PORT = 3000
app.listen(PORT, () => {
  logger.info(`服务器运行在 http://localhost:${PORT}`)
})

// 6. 处理未捕获的全局异常
process.on("unhandledRejection", (reason, promise) => {
  logger.error("未处理的 Promise 拒绝:", reason)
  // 考虑关闭服务器
})

process.on("uncaughtException", (err) => {
  logger.error("未捕获的同步异常:", err)
  process.exit(1)
})

常见问题解答

Q1: try...catch 能捕获异步错误吗?

A: 不能直接捕获。try...catch 只能捕获同步错误。对于异步错误,需要:

javascript
// ❌ 错误:无法捕获 Promise 拒绝
try {
  Promise.reject(new Error('异步错误'))
} catch (error) {
  // 不会执行
  console.log(error)
}

// ✅ 方法1:使用 .catch()
Promise.reject(new Error('异步错误'))
  .catch(error => console.log(error))

// ✅ 方法2:使用 async/await
async function handleAsync() {
  try {
    await Promise.reject(new Error('异步错误'))
  } catch (error) {
    console.log(error)
  }
}

Q2: 如何区分可恢复错误和不可恢复错误?

A: 通过 isOperational 标志区分:

javascript
// 可恢复错误(业务错误)
const operationalError = new ValidationError('参数错误')
operationalError.isOperational = true  // 通常在自定义错误类中设置

// 不可恢复错误(编程错误)
const programmerError = new TypeError('undefined is not a function')

// 在全局错误处理中区分
app.use((err, req, res, next) => {
  if (err.isOperational) {
    // 可恢复错误:返回友好的错误信息
    res.status(err.statusCode || 500).json({
      error: err.message
    })
  } else {
    // 不可恢复错误:记录详细信息,返回通用错误
    logger.error('不可恢复错误:', err)
    res.status(500).json({
      error: '服务器内部错误'
    })
    // 考虑重启进程
    process.exit(1)
  }
})

Q3: 应该在何时使用 uncaughtException?

A: 仅作为最后的防线,用于:

  • 记录错误日志
  • 清理资源(关闭数据库连接、文件句柄)
  • 优雅关闭服务器
  • 然后退出进程
javascript
process.on('uncaughtException', (error) => {
  // 1. 记录错误
  logger.error('未捕获异常:', error)
  
  // 2. 关闭服务器,不再接受新请求
  server.close(() => {
    logger.info('服务器已关闭')
    process.exit(1)
  })
  
  // 3. 强制退出(防止服务器关闭超时)
  setTimeout(() => {
    logger.error('强制退出')
    process.exit(1)
  }, 5000)
})

Q4: 如何处理回调地狱中的错误?

A: 逐步迁移到 Promise 或 async/await:

javascript
// ❌ 回调地狱,难以处理错误
fs.readFile('file1.txt', 'utf8', (err1, data1) => {
  if (err1) throw err1
  fs.readFile('file2.txt', 'utf8', (err2, data2) => {
    if (err2) throw err2
    fs.writeFile('output.txt', data1 + data2, (err3) => {
      if (err3) throw err3
      console.log('完成')
    })
  })
})

// ✅ 使用 Promise
const fs = require('fs').promises

async function mergeFiles() {
  try {
    const [data1, data2] = await Promise.all([
      fs.readFile('file1.txt', 'utf8'),
      fs.readFile('file2.txt', 'utf8')
    ])
    await fs.writeFile('output.txt', data1 + data2)
    console.log('完成')
  } catch (error) {
    console.error('操作失败:', error)
  }
}

Q5: 如何在生产环境中优雅地处理错误?

A: 实施完整的错误处理策略:

  1. 分层错误处理:

    • 数据层:包装数据库错误
    • 业务层:验证业务逻辑,抛出业务错误
    • 控制层:处理 HTTP 请求,返回合适的错误响应
    • 全局层:捕获未处理错误,记录日志
  2. 错误监控:

    • 集成错误追踪服务(Sentry、Bugsnag)
    • 设置错误告警和通知
  3. 降级策略:

    • 返回缓存数据
    • 提供默认值
    • 熔断和限流
  4. 文档和报告:

    • 记录错误上下文
    • 生成错误报告
    • 定期审查和改进

Q6: 如何测试错误处理逻辑?

A: 使用单元测试和集成测试:

javascript
const assert = require('assert')
const { expect } = require('chai')

// 测试同步错误
describe('divide', () => {
  it('应该在除数为零时抛出错误', () => {
    assert.throws(
      () => divide(10, 0),
      Error,
      '除数不能为零'
    )
  })
})

// 测试异步错误
describe('getUserById', () => {
  it('应该在用户不存在时抛出 NotFoundError', async () => {
    try {
      await getUserById(999)
      // 如果没有抛出错误,测试失败
      throw new Error('应该抛出错误')
    } catch (error) {
      expect(error).to.be.instanceOf(NotFoundError)
      expect(error.message).to.equal('用户不存在')
    }
  })
  
  // 使用 Chai 的异步断言
  it('应该在用户不存在时抛出 NotFoundError (优化)', async () => {
    await expect(getUserById(999))
      .to.be.rejectedWith(NotFoundError)
  })
})

异常处理流程图

code
┌─────────────────────────────────────────────────────────────┐
│                      错误发生                                │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │   是同步错误吗?      │
              └──────────┬──────────┘
                         │
           ┌─────────────┴─────────────┐
           │ 是                        │ 否
           ▼                           ▼
   ┌───────────────┐          ┌─────────────────┐
   │ try...catch   │          │ Promise/async   │
   │ 捕获并处理    │          │ await 处理      │
   └───────┬───────┘          └────────┬────────┘
           │                           │
           │                           ▼
           │                  ┌─────────────────┐
           │                  │ 是否有 .catch   │
           │                  │ 或 try...catch? │
           │                  └────────┬────────┘
           │                           │
           │             ┌─────────────┴─────────────┐
           │             │ 是                        │ 否
           │             ▼                           ▼
           │      ┌─────────────┐          ┌─────────────────┐
           │      │ 处理错误    │          │ unhandled       │
           │      └──────┬──────┘          │ Rejection 事件  │
           │             │                 └────────┬────────┘
           │             │                          │
           └─────────────┴──────────────────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │  错误是否可恢复?     │
              └──────────┬──────────┘
                         │
           ┌─────────────┴─────────────┐
           │ 是                        │ 否
           ▼                           ▼
   ┌───────────────┐          ┌─────────────────┐
   │ 重试/降级     │          │ 记录日志        │
   │ 继续运行      │          │ 优雅关闭进程    │
   └───────────────┘          └─────────────────┘

最佳实践清单

✅ 应该做的

  • 使用自定义错误类型区分不同类型的错误
  • 在所有异步操作中使用 try...catch.catch()
  • 记录错误的完整上下文信息(堆栈、参数、环境)
  • 为异步操作设置合理的超时时间
  • 实现重试机制处理临时性错误
  • 使用断路器模式防止故障传播
  • 对输入参数进行验证,主动抛出错误
  • 集成错误监控服务(Sentry、Bugsnag)
  • 编写测试覆盖错误场景
  • 定期审查错误日志,持续改进

❌ 不应该做的

  • 捕获错误后不做任何处理(catch (e) {})
  • 使用 uncaughtException 作为主要错误处理机制
  • 在生产环境中输出敏感信息(密码、密钥)
  • 忽略 Promise 拒绝
  • 混用多种错误处理模式
  • 抛出字符串或其他非 Error 对象
  • 在错误处理中执行复杂的异步操作
  • 过度依赖全局错误处理

总结

Node.js 异常处理是一个系统性工程,需要从多个层面考虑:

  1. 预防阶段: 使用类型系统、Linter、单元测试
  2. 捕获阶段: try...catch、Promise、事件监听
  3. 处理阶段: 重试、降级、熔断、超时
  4. 记录阶段: 结构化日志、错误追踪服务
  5. 改进阶段: 错误分析、优化代码、完善文档

一个健壮的错误处理系统能够:

  • 快速定位和修复问题
  • 提升用户体验
  • 降低运维成本
  • 提高系统可靠性

记住:错误处理不是事后补救,而是设计的一部分。在编码阶段就应该考虑可能的错误场景,并制定相应的处理策略。


Node.js 22+ 错误处理新特性

权限模型错误

Node.js 22.13+ 权限模型稳定后,新增了权限相关的错误类型:

javascript
process.on('uncaughtException', (err) => {
  if (err.code === 'ERR_ACCESS_DENIED') {
    console.error('权限被拒绝:', err.permission, err.resource)
  }
})
bash
# 启用权限模型后,未授权操作会抛出 ERR_ACCESS_DENIED
node --permission --allow-fs-read=./data app.js
# Error: Access to this API has been restricted
#   code: 'ERR_ACCESS_DENIED',
#   permission: 'FileSystemRead',
#   resource: '/etc/passwd'

node:test 中的错误处理

Node.js 22+ 内置测试框架提供了更好的错误处理和断言:

javascript
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'

describe('用户服务', () => {
  it('应该抛出 NotFoundError', async () => {
    await assert.rejects(
      () => getUserById(999),
      (err) => {
        assert.strictEqual(err.name, 'NotFoundError')
        assert.strictEqual(err.statusCode, 404)
        return true
      }
    )
  })

  it('应该匹配错误消息', () => {
    assert.throws(
      () => { throw new Error('无效输入') },
      { message: /无效/ }
    )
  })
})

--watch 模式下的错误恢复

Node.js 22+ 的 --watch 模式在应用崩溃后会自动重启:

bash
# 应用崩溃后自动重启
node --watch app.js

# 配合 --env-file 使用
node --watch --env-file=.env.development app.js

Error.cause 错误链

Node.js 16.9+ 支持错误链,可在新错误中保留原始错误(已在前面章节介绍),Node.js 22+ 中更广泛使用:

javascript
// 内置模块也使用 Error.cause
try {
  await fs.promises.readFile('config.json')
} catch (err) {
  throw new Error('配置文件加载失败', { cause: err })
}

// 遍历完整错误链
function getErrorChain(error) {
  const chain = []
  let current = error
  while (current) {
    chain.push({
      name: current.name,
      message: current.message,
      stack: current.stack?.split('\n')[0]
    })
    current = current.cause
  }
  return chain
}

参考资料