错误处理与调试

在应用程序开发中,错误处理和调试是确保应用稳定性和可维护性的关键环节。Node.js 提供了多种机制来处理运行时错误,同时也配备了强大的调试工具。本章将全面介绍错误处理的最佳实践以及如何使用各种工具高效调试 Node.js 应用。

1. 错误处理概述

Node.js 中的错误可以大致分为两类:操作错误(如文件不存在、网络连接失败)和程序错误(如引用未定义变量、类型错误)。操作错误通常是可预见的,需要优雅处理;程序错误往往是 bug,应修复代码。合理的错误处理可以防止应用崩溃,并提供有意义的反馈。

2. 同步代码的错误处理

对于同步代码,可以使用 try/catch 块捕获异常。例如:

const fs = require('fs');

try {
  const data = fs.readFileSync('nonexistent.txt', 'utf8');
  console.log(data);
} catch (err) {
  console.error('读取文件出错:', err.message);
}

3. 异步代码的错误处理

异步代码的错误处理方式取决于编程模式:回调、Promise 或 async/await。

3.1 回调模式

遵循“错误优先”回调约定,第一个参数为错误对象。务必检查错误:

const fs = require('fs');

fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('读取失败:', err);
    return;
  }
  console.log(data);
});

3.2 Promise 模式

使用 .catch() 捕获错误,或在 then 中提供第二个回调(不推荐)。

const fs = require('fs').promises;

fs.readFile('file.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error('读取失败:', err));

3.3 async/await 模式

使用 try/catch 包裹 await 表达式,语法简洁:

async function readFile() {
  try {
    const data = await fs.readFile('file.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error('读取失败:', err);
  }
}

4. 全局未捕获异常

如果未捕获的异常传播到事件循环,Node.js 进程会退出。可以监听 uncaughtException 事件进行最后处理,但应该仅用于日志记录和清理,然后优雅退出,因为应用可能处于不稳定状态。

process.on('uncaughtException', (err) => {
  console.error('未捕获的异常:', err);
  // 执行必要的清理,然后退出
  process.exit(1);
});

5. 未处理的 Promise 拒绝

未处理的 Promise 拒绝(unhandledRejection)默认会触发警告,但未来版本可能会抛出错误。建议总是添加 .catch() 或使用 try/catch。可以监听该事件:

process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的 Promise 拒绝:', reason);
});

6. 错误对象与自定义错误

JavaScript 内置的 Error 类可用于创建错误实例。可以通过扩展它创建自定义错误类型:

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ValidationError';
    this.statusCode = 400;
  }
}

throw new ValidationError('输入无效');

7. 在 Express 中统一处理错误

Express 应用可以使用错误处理中间件集中处理错误。错误处理中间件有四个参数 (err, req, res, next),应放在所有路由之后。

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({
    error: err.message || '服务器内部错误'
  });
});

在异步路由中,必须将错误传递给 next,例如:

app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      const err = new Error('用户不存在');
      err.statusCode = 404;
      throw err;
    }
    res.json(user);
  } catch (err) {
    next(err);
  }
});

8. 调试基础:console 家族

console.log 是最简单的调试工具,但 console 还提供了其他有用方法:

  • console.error():输出到 stderr,适合错误信息。
  • console.warn():警告信息。
  • console.table():以表格形式打印数组/对象。
  • console.time() / console.timeEnd():测量执行时间。
  • console.trace():打印当前位置的堆栈跟踪。
console.time('fetchData');
// 异步操作
console.timeEnd('fetchData');

9. 使用 debugger 语句

在代码中插入 debugger 语句,然后使用 node inspect 启动调试器,程序会在该处暂停。

function add(a, b) {
  debugger;
  return a + b;
}
add(1, 2);

启动:node inspect app.js。然后在调试 REPL 中使用 contnextrepl 等命令。

10. 使用 Node.js 内置调试器

Node.js 内置了一个基于 V8 Inspector 的调试器,支持 Chrome DevTools。启动应用时添加 --inspect 标志:

node --inspect app.js

然后在 Chrome 浏览器中打开 chrome://inspect,点击“Open dedicated DevTools for Node”即可调试,可以设置断点、单步执行、查看变量等。

若需在代码第一行暂停,使用 --inspect-brk

11. 使用 VS Code 调试

VS Code 内置了对 Node.js 调试的支持。创建 .vscode/launch.json 配置文件:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/app.js"
    }
  ]
}

之后就可以按 F5 启动调试,设置断点,体验 IDE 级的调试功能。

12. 日志管理

在生产环境中,使用控制台日志可能不够。推荐使用日志库如 winstonpinobunyan,它们支持多级日志、文件输出、结构化 JSON 日志等。

const winston = require('winston');
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

logger.info('应用启动');

13. 监控与告警

对于生产应用,错误监控不可或缺。可以使用 SentryDatadog 等服务捕获错误并实时告警。

14. 错误处理最佳实践

  • 不要吞没错误:至少记录错误日志,避免静默失败。
  • 区分操作错误和程序错误:操作错误应优雅处理,程序错误应修复代码。
  • 使用自定义错误类型:便于区分错误类别,如数据库错误、验证错误。
  • 对异步代码始终使用 .catch()try/catch
  • 在 Express 中使用统一的错误处理中间件,并确保所有错误都传递给它。
  • 生产环境不要将堆栈信息暴露给客户端,但应记录到日志。
  • 使用调试工具而不是 console.log 进行复杂调试

15. 综合示例:包含错误处理的 API

下面是一个使用了统一错误处理和异步 catch 包装的 Express API 示例:

const express = require('express');
const app = express();

// 异步路由错误包装辅助函数
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// 模拟数据库操作
const findUser = async (id) => {
  if (id === '1') return { id: '1', name: 'Alice' };
  throw new Error('用户不存在');
};

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

// 404 处理
app.use((req, res, next) => {
  res.status(404).json({ error: '未找到' });
});

// 错误处理中间件
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: '服务器内部错误' });
});

app.listen(3000);

小结

错误处理和调试是 Node.js 开发中不可忽视的部分。通过本章学习,你应该掌握了同步/异步错误处理的方法、全局异常处理、自定义错误、Express 中的错误处理,以及多种调试技巧。合理运用这些知识,可以显著提高应用的稳定性和可维护性。下一章我们将探讨 Node.js 的性能优化策略。