Node.js 错误处理与调试

学习目标

  • 理解 Node.js 中的错误类型
  • 掌握错误捕获与处理的方法
  • 学会创建和使用自定义错误
  • 能够实现错误处理中间件
  • 熟悉 Node.js 调试工具和技巧

错误处理基础

什么是错误?

错误是程序执行过程中遇到的异常情况,可能导致程序无法正常运行。在 Node.js 中,错误通常是 Error 对象的实例。

错误的重要性

  1. 提高代码健壮性:合理的错误处理可以防止程序崩溃
  2. 提升用户体验:友好的错误提示可以提高用户体验
  3. 便于调试:详细的错误信息有助于快速定位问题
  4. 增强系统可靠性:即使遇到错误,系统也能优雅降级

Node.js 中的错误类型

1. 内置错误类型

错误类型 描述 示例
Error 所有错误的基类 new Error('通用错误')
SyntaxError 语法错误 const a = ;
ReferenceError 引用错误 console.log(undefinedVariable)
TypeError 类型错误 'string'.map()
RangeError 范围错误 const arr = new Array(-1)
URIError URI 错误 decodeURI('%')
EvalError eval() 错误 很少见,已被废弃

2. 系统错误

系统错误是由底层操作系统或 Node.js 运行时产生的错误,通常与 I/O 操作相关。

const fs = require('fs');

// 文件不存在错误
fs.readFile('不存在的文件.txt', (err, data) => {
  if (err) {
    console.error('系统错误:', err);
    // Error: ENOENT: no such file or directory, open '不存在的文件.txt'
  }
});

3. 自定义错误

开发者可以创建自定义错误类型来表示特定的业务逻辑错误。

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

// 使用自定义错误
function validateUser(user) {
  if (!user.name) {
    throw new ValidationError('用户名不能为空', 'name');
  }
  if (!user.email) {
    throw new ValidationError('邮箱不能为空', 'email');
  }
}

错误捕获与处理

1. try/catch 语句

用于捕获同步代码中的错误。

try {
  // 可能抛出错误的代码
  const result = JSON.parse('invalid json');
  console.log(result);
} catch (error) {
  // 错误处理
  console.error('捕获到错误:', error.message);
  // 捕获到错误: Unexpected token i in JSON at position 0
}

2. 回调函数中的错误处理

在 Node.js 早期,回调函数是处理异步操作的主要方式,通常使用错误优先的回调模式。

const fs = require('fs');

// 错误优先回调
fs.readFile('file.txt', (err, data) => {
  if (err) {
    // 错误处理
    console.error('读取文件失败:', err);
    return;
  }
  
  // 成功处理
  console.log('文件内容:', data.toString());
});

3. Promise 中的错误处理

使用 .catch() 方法捕获 Promise 链中的错误。

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

fs.readFile('file.txt')
  .then(data => {
    console.log('文件内容:', data.toString());
    return JSON.parse('invalid json'); // 这里会抛出错误
  })
  .catch(error => {
    console.error('捕获到错误:', error.message);
  });

4. async/await 中的错误处理

结合 try/catch 捕获 async/await 中的错误。

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

async function readFile() {
  try {
    const data = await fs.readFile('file.txt');
    console.log('文件内容:', data.toString());
    const result = JSON.parse('invalid json'); // 这里会抛出错误
    return result;
  } catch (error) {
    console.error('捕获到错误:', error.message);
    // 可以选择重新抛出错误
    // throw error;
  }
}

readFile();

自定义错误

创建自定义错误类

继承自 Error 类创建自定义错误。

class AppError extends Error {
  constructor(message, statusCode, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = isOperational;
    
    // 捕获堆栈跟踪
    Error.captureStackTrace(this, this.constructor);
  }
}

// 使用自定义错误
function divide(a, b) {
  if (b === 0) {
    throw new AppError('除数不能为零', 400);
  }
  return a / b;
}

try {
  const result = divide(10, 0);
  console.log(result);
} catch (error) {
  console.error('错误状态码:', error.statusCode);
  console.error('错误消息:', error.message);
  console.error('错误状态:', error.status);
}

业务逻辑错误

为特定业务场景创建自定义错误。

// 验证错误
class ValidationError extends AppError {
  constructor(message, field) {
    super(message, 400);
    this.field = field;
    this.name = 'ValidationError';
  }
}

// 认证错误
class AuthError extends AppError {
  constructor(message) {
    super(message, 401);
    this.name = 'AuthError';
  }
}

// 权限错误
class ForbiddenError extends AppError {
  constructor(message) {
    super(message, 403);
    this.name = 'ForbiddenError';
  }
}

// 资源不存在错误
class NotFoundError extends AppError {
  constructor(message) {
    super(message, 404);
    this.name = 'NotFoundError';
  }
}

错误处理中间件

Express 错误处理中间件

在 Express 应用中,错误处理中间件可以捕获和处理路由处理函数中抛出的错误。

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

// 普通中间件
app.use(express.json());

// 路由
app.get('/api/users/:id', (req, res) => {
  const { id } = req.params;
  
  if (id === '0') {
    throw new AppError('无效的用户 ID', 400);
  }
  
  if (id === '100') {
    throw new NotFoundError('用户不存在');
  }
  
  res.json({ id, name: '张三', email: 'zhangsan@example.com' });
});

// 错误处理中间件(四个参数)
app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';
  
  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
    // 在开发环境中显示错误堆栈
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});

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

全局错误处理

对于未被捕获的错误,可以使用全局错误处理器。

// 捕获未处理的 Promise 拒绝
process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的 Promise 拒绝:', reason);
  // 可以在这里进行日志记录、告警等操作
});

// 捕获未捕获的异常
process.on('uncaughtException', (error) => {
  console.error('未捕获的异常:', error);
  // 注意:在生产环境中,应该优雅地关闭服务器
  // process.exit(1); // 非零退出码表示错误
});

调试工具与技巧

1. console 方法

最简单的调试方法是使用 console 对象的方法。

// 打印普通信息
console.log('调试信息:', variable);

// 打印警告信息
console.warn('警告信息');

// 打印错误信息
console.error('错误信息');

// 打印对象的详细信息
console.dir(object, { depth: null });

// 打印调用栈
console.trace('调用栈');

// 计算执行时间
console.time('计时器');
// 执行代码
console.timeEnd('计时器');

2. Node.js 调试器

Node.js 内置了调试器,可以通过 --inspect--inspect-brk 选项启动。

启动调试模式

# 启动调试模式
node --inspect app.js

# 启动调试模式并在第一行暂停
node --inspect-brk app.js

使用 Chrome DevTools 调试

  1. 启动调试模式后,打开 Chrome 浏览器
  2. 访问 chrome://inspect
  3. 在 "Remote Target" 部分找到你的 Node.js 应用
  4. 点击 "inspect" 开始调试

3. VS Code 调试

VS Code 内置了强大的 Node.js 调试功能。

配置调试

  1. 在 VS Code 中打开项目
  2. 点击左侧的调试图标(或按 Ctrl+Shift+D
  3. 点击 "创建 launch.json 文件"
  4. 选择 "Node.js" 环境

调试配置示例

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/app.js"
    },
    {
      "type": "node",
      "request": "attach",
      "name": "附加到进程",
      "processId": "${command:PickProcess}"
    }
  ]
}

调试技巧

  • 设置断点:点击代码行号左侧的空白区域
  • 条件断点:右键点击断点,设置条件
  • 监视变量:在监视面板中添加变量
  • 单步执行:使用 F10(单步跳过)、F11(单步进入)、Shift+F11(单步退出)
  • 查看调用栈:在调用栈面板中查看函数调用链

4. 第三方调试工具

ndb

ndb 是 Google 开发的增强型 Node.js 调试工具,基于 Chrome DevTools。

# 安装
npm install -g ndb

# 使用
ndb app.js

debug 模块

debug 模块是一个轻量级的调试工具,可以根据环境变量控制调试信息的输出。

# 安装
npm install debug
// 使用 debug 模块
const debug = require('debug')('app:server');
const dbDebug = require('debug')('app:db');

// 启动服务器
debug('服务器启动在端口 3000');

// 数据库操作
dbDebug('连接到数据库');
# 运行时指定调试命名空间
DEBUG=app:server node app.js

# 运行时指定多个调试命名空间
DEBUG=app:server,app:db node app.js

# 运行时指定所有调试命名空间
DEBUG=* node app.js

实战案例:实现错误处理中间件

项目结构

error-handling-example/
├── src/
│   ├── errors/
│   │   └── appError.js        # 自定义错误类
│   ├── middleware/
│   │   └── errorHandler.js    # 错误处理中间件
│   ├── routes/
│   │   └── userRoutes.js      # 用户路由
│   └── app.js                 # 应用入口
├── package.json
└── README.md

实现步骤

  1. 创建自定义错误类
// src/errors/appError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;
    
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;
  1. 实现错误处理中间件
// src/middleware/errorHandler.js
const AppError = require('../errors/appError');

const handleCastErrorDB = (err) => {
  const message = `无效的 ${err.path}: ${err.value}`;
  return new AppError(message, 400);
};

const handleDuplicateFieldsDB = (err) => {
  const value = err.errmsg.match(/\"([^\"]*)\"/)[1];
  const message = `重复的字段值: ${value}。请使用其他值`;
  return new AppError(message, 400);
};

const handleValidationErrorDB = (err) => {
  const errors = Object.values(err.errors).map(el => el.message);
  const message = `验证失败: ${errors.join('. ')}`;
  return new AppError(message, 400);
};

const handleJWTError = () => {
  return new AppError('无效的令牌。请重新登录', 401);
};

const handleJWTExpiredError = () => {
  return new AppError('令牌已过期。请重新登录', 401);
};

const sendErrorDev = (err, res) => {
  res.status(err.statusCode).json({
    status: err.status,
    error: err,
    message: err.message,
    stack: err.stack
  });
};

const sendErrorProd = (err, res) => {
  // 只向客户端发送可操作的错误
  if (err.isOperational) {
    res.status(err.statusCode).json({
      status: err.status,
      message: err.message
    });
  } else {
    // 记录错误
    console.error('错误:', err);
    
    // 发送通用错误消息
    res.status(500).json({
      status: 'error',
      message: '服务器内部错误'
    });
  }
};

module.exports = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';
  
  if (process.env.NODE_ENV === 'development') {
    sendErrorDev(err, res);
  } else if (process.env.NODE_ENV === 'production') {
    let error = { ...err };
    error.message = err.message;
    
    // 处理数据库错误
    if (err.name === 'CastError') error = handleCastErrorDB(error);
    if (err.code === 11000) error = handleDuplicateFieldsDB(error);
    if (err.name === 'ValidationError') error = handleValidationErrorDB(error);
    
    // 处理 JWT 错误
    if (err.name === 'JsonWebTokenError') error = handleJWTError();
    if (err.name === 'TokenExpiredError') error = handleJWTExpiredError();
    
    sendErrorProd(error, res);
  }
};
  1. 创建用户路由
// src/routes/userRoutes.js
const express = require('express');
const router = express.Router();
const AppError = require('../errors/appError');

// 模拟用户数据
const users = [
  { id: 1, name: '张三', email: 'zhangsan@example.com' },
  { id: 2, name: '李四', email: 'lisi@example.com' },
  { id: 3, name: '王五', email: 'wangwu@example.com' }
];

// 获取所有用户
router.get('/', (req, res) => {
  res.json({ status: 'success', data: { users } });
});

// 获取单个用户
router.get('/:id', (req, res, next) => {
  const { id } = req.params;
  const user = users.find(u => u.id === parseInt(id));
  
  if (!user) {
    return next(new AppError('用户不存在', 404));
  }
  
  res.json({ status: 'success', data: { user } });
});

// 创建用户
router.post('/', (req, res, next) => {
  const { name, email } = req.body;
  
  if (!name || !email) {
    return next(new AppError('姓名和邮箱不能为空', 400));
  }
  
  const newUser = {
    id: users.length + 1,
    name,
    email
  };
  
  users.push(newUser);
  res.status(201).json({ status: 'success', data: { user: newUser } });
});

// 更新用户
router.patch('/:id', (req, res, next) => {
  const { id } = req.params;
  const { name, email } = req.body;
  
  const user = users.find(u => u.id === parseInt(id));
  
  if (!user) {
    return next(new AppError('用户不存在', 404));
  }
  
  if (name) user.name = name;
  if (email) user.email = email;
  
  res.json({ status: 'success', data: { user } });
});

// 删除用户
router.delete('/:id', (req, res, next) => {
  const { id } = req.params;
  const index = users.findIndex(u => u.id === parseInt(id));
  
  if (index === -1) {
    return next(new AppError('用户不存在', 404));
  }
  
  users.splice(index, 1);
  res.json({ status: 'success', data: null });
});

module.exports = router;
  1. 创建应用入口
// src/app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const errorHandler = require('./middleware/errorHandler');
const AppError = require('./errors/appError');

const app = express();

// 中间件
app.use(express.json());

// 路由
app.use('/api/users', userRoutes);

// 处理未定义的路由
app.all('*', (req, res, next) => {
  next(new AppError(`无法找到 ${req.originalUrl} 路径`, 404));
});

// 错误处理中间件
app.use(errorHandler);

// 全局错误处理
process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的 Promise 拒绝:', reason);
  // 可以在这里进行日志记录、告警等操作
});

process.on('uncaughtException', (error) => {
  console.error('未捕获的异常:', error);
  // 注意:在生产环境中,应该优雅地关闭服务器
  // process.exit(1);
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

常见问题与解决方案

1. 如何区分可操作错误和编程错误?

  • 可操作错误:由用户输入或系统状态引起的错误,如无效的请求参数、数据库连接失败等
  • 编程错误:由代码逻辑错误引起的错误,如引用未定义变量、调用不存在的方法等

2. 如何处理异步错误?

  • 回调函数:使用错误优先的回调模式
  • Promise:使用 .catch() 方法
  • async/await:结合 try/catch 语句
  • 全局处理器:使用 process.on(&#39;unhandledRejection&#39;) 捕获未处理的 Promise 拒绝

3. 如何避免错误处理中间件被跳过?

  • 确保在所有路由之后定义错误处理中间件
  • 在路由处理函数中使用 next(err) 传递错误
  • 不要在错误处理中间件之前使用 res.send()res.json() 等方法

4. 如何在生产环境中保护错误信息?

  • 生产环境中不要向客户端暴露详细的错误堆栈
  • 使用不同的错误响应格式(开发环境 vs 生产环境)
  • 对错误进行分类,只向客户端暴露可操作的错误

代码优化建议

  1. 统一错误处理:使用集中式的错误处理中间件
  2. 详细的错误信息:在开发环境中提供详细的错误信息
  3. 适当的错误日志:记录错误信息以便排查问题
  4. 错误分类:根据错误类型采取不同的处理策略
  5. 优雅降级:即使遇到错误,也应该尽可能保持系统可用
  6. 使用调试工具:合理使用调试工具提高开发效率
  7. 错误预防:在可能出错的地方添加防御性代码

学习总结

  1. 错误类型:内置错误类型、系统错误、自定义错误
  2. 错误捕获:try/catch、回调函数、Promise.catch()、async/await
  3. 自定义错误:继承自 Error 类创建自定义错误
  4. 错误处理中间件:Express 错误处理中间件、全局错误处理器
  5. 调试工具:console 方法、Node.js 调试器、VS Code 调试、第三方调试工具
  6. 调试技巧:设置断点、监视变量、单步执行、查看调用栈

动手练习

  1. 实现一个完整的错误处理系统,包括自定义错误类和错误处理中间件
  2. 使用 VS Code 调试器调试一个包含错误的 Node.js 应用
  3. 实现一个基于 debug 模块的日志系统
  4. 为一个 RESTful API 添加完善的错误处理

进阶学习

  • 错误监控:使用 Sentry、LogRocket 等工具监控生产环境中的错误
  • 性能分析:使用 Clinic.js 等工具分析应用性能
  • 混沌工程:通过故意引入错误来测试系统的弹性
  • 容错设计:设计具有容错能力的系统架构
  • 分布式追踪:使用 OpenTelemetry 等工具进行分布式追踪

通过本教程的学习,你已经掌握了 Node.js 中的错误处理机制和调试技巧,能够编写更加健壮和可维护的代码。在实际开发中,合理的错误处理和调试技巧将大大提高你的开发效率和代码质量。

« 上一篇 Node.js GraphQL 基础 下一篇 » Node.js 日志系统