兰 亭 墨 苑
期货 · 量化 · AI · 终身学习
首页
归档
编辑文章
标题 *
URL 别名 *
内容 *
(支持 Markdown 格式)
### **第 14 节:数据库集成(MongoDB & Mongoose) — 让你的应用有‘记忆’!** **好了,同学们,前面咱们的用户管理系统,数据都存在一个数组里,服务器一重启,数据就没了!** 这种“临时记忆”的应用可不行,咱们得给它一个“永久记忆”的大脑——**数据库**! 今天,咱们要学习如何将 Node.js 应用与一个非常流行的 NoSQL 数据库——**MongoDB** 连接起来,并使用一个超级好用的 ODM (Object Data Modeling) 库——**Mongoose**,来轻松地操作数据! --- #### **14.1 NoSQL 数据库简介:MongoDB — 数据库界的‘自由派’!** 在传统的数据库世界里,我们经常听到**关系型数据库**(Relational Database,比如 MySQL, PostgreSQL, Oracle)。它们把数据存储在严格定义的表格里,就像 Excel 表格一样,每一行都是一条记录,每一列都有固定的类型,而且表格之间还有复杂的关联关系(用 SQL 语句操作)。 而 **NoSQL (Not only SQL)** 数据库,顾名思义,“不仅仅是 SQL”。它们提供了更灵活、更具扩展性的数据存储方式。**MongoDB** 就是其中一种最受欢迎的 **文档型 (Document-oriented)** NoSQL 数据库! * **数据存储:** MongoDB 将数据存储为 **BSON (Binary JSON)** 文档。啥是 BSON?就是二进制版的 JSON!你可以把它想象成跟 JavaScript 里的 JSON 对象几乎一模一样的数据结构! * **无模式 (Schema-less) 或灵活模式:** 这是 MongoDB 最吸引人的地方之一!在同一个集合 (Collection,你可以把它理解为关系型数据库里的“表”) 里,不同的文档(也就是记录)可以有不同的字段和结构,或者字段的顺序不一样,或者有些文档有这个字段,有些文档没有!这为开发提供了极大的灵活性,尤其是在数据结构不确定、经常变化,或者需要快速迭代产品时,简直是“神器”!当然,你也可以通过应用层面的工具(比如 Mongoose)来强制一些模式。 * **可伸缩性:** MongoDB 非常容易进行水平扩展 (sharding),也就是通过增加更多的服务器来分担负载,处理海量数据和高并发请求。 * **高性能:** 它通常适用于大数据量和高并发场景。 * **与 Node.js 的契合度:** 这点很重要!因为 MongoDB 使用 JSON 格式存储数据,而 Node.js 使用 JavaScript,JavaScript 天然就支持 JSON 对象!这使得 Node.js 开发者在操作 MongoDB 数据时,感觉就像在操作普通的 JavaScript 对象一样,几乎没有“转换成本”,非常自然和流畅! --- #### **14.2 安装 MongoDB — 把‘记忆中心’建起来!** 安装 MongoDB 有多种方式,你可以根据自己的习惯和环境选择: 1. **官方安装包:** 最直接的方式就是访问 MongoDB 官网下载,然后按照官方指南一步步安装适合你操作系统的版本。 * [MongoDB Community Edition Download](https://www.mongodb.com/try/download/community) 2. **Docker:** **对于开发环境,强烈推荐使用 Docker!** 简直是“神器”!你只需要几行命令,就能把 MongoDB 跑在一个独立的容器里,不污染你本地的开发环境,而且启动停止都非常方便。 ```bash # 1. 拉取 MongoDB 镜像 (第一次会下载,以后就很快了) docker pull mongo # 2. 运行一个 MongoDB 容器 (命名为 my-mongo,端口 27017 映射到本地 27017,后台运行) docker run --name my-mongo -p 27017:27017 -d mongo # docker run: 运行容器 # --name my-mongo: 给容器起个名字叫 my-mongo # -p 27017:27017: 把容器的 27017 端口映射到你本地的 27017 端口 # -d mongo: 在后台运行 mongo 镜像 ``` * **常用 Docker 命令:** * `docker ps`: 查看正在运行的容器 * `docker stop my-mongo`: 停止容器 * `docker start my-mongo`: 启动容器 * `docker rm my-mongo`: 删除容器 (需要先停止) * `docker logs my-mongo`: 查看容器日志 3. **云服务:** 在生产环境中,咱们通常不会自己部署和维护数据库。而是会使用专业的云数据库服务,比如 **MongoDB Atlas** (MongoDB 官方提供的云数据库服务),或者 AWS、Google Cloud、Azure 等云提供商的 MongoDB 服务。它们帮你搞定了高可用、备份、扩容等一系列复杂的运维工作。 **安装完成后,请务必确保 MongoDB 服务正在运行!** 默认情况下,MongoDB 运行在 `localhost:27017` 这个地址和端口上。 --- #### **14.3 使用 Mongoose ODM (Object Data Modeling) 连接 MongoDB — 你的‘超级翻译官’!** 直接使用 MongoDB 的原生驱动程序来操作数据库可能会有点“裸奔”和繁琐。这时候,咱们就需要一个“超级翻译官”兼“数据守护者”——**Mongoose**! **Mongoose** 是一个流行的 Node.js **ODM (Object Data Modeling)** 库。它在 MongoDB 驱动程序之上提供了一个更高级别的抽象,使得与 MongoDB 的交互更加简单、结构化,而且还提供了强大的**模式验证**(Schema Validation)功能!这就像给灵活的 MongoDB 加上了“软约束”,确保你存入的数据符合你应用程序的预期。 **安装 Mongoose:** ```bash npm install mongoose ``` **连接 MongoDB(非常简单!):** 咱们通常会把数据库连接的代码单独放在一个文件里,方便管理。 ```javascript // db.js (在你项目的根目录创建一个 db.js 文件) const mongoose = require('mongoose'); const connectDB = async () => { // 用 async/await 连接,更优雅 try { // 使用 mongoose.connect 连接数据库 // 第一个参数是 MongoDB 的连接 URI (Uniform Resource Identifier) // 'mongodb://localhost:27017/mydatabase' 表示连接本地 27017 端口的 MongoDB,数据库名称是 mydatabase const conn = await mongoose.connect('mongodb://localhost:27017/mydatabase', { // ⚠️ 注意:从 Mongoose 6.0 版本开始,以下这些选项已经是默认值了,所以可以省略! // useNewUrlParser: true, // 新的 URL 解析器 // useUnifiedTopology: true, // 新的统一拓扑引擎 // useCreateIndex: true, // 确保在 Mongoose v5.x 时创建索引不会报错,v6.x 已移除 // useFindAndModify: false // 确保 findAndModify 系列方法不报错,v6.x 已移除 }); console.log(`MongoDB 连接成功: ${conn.connection.host}`); // 打印连接成功的主机名 } catch (err) { console.error(`MongoDB 连接失败: ${err.message}`); // 如果数据库连接失败,通常意味着程序无法正常工作,所以咱们直接退出进程! process.exit(1); // 退出进程,并返回一个非零状态码,表示异常退出 } }; module.exports = connectDB; // 导出这个连接函数 ``` **在 Express 应用中使用 `connectDB()`:** 你需要在你的 Express 应用启动之前,调用这个 `connectDB()` 函数。 ```javascript // app.js (你的 Express 应用主文件) const express = require('express'); const connectDB = require('./db'); // 引入咱们刚刚写的数据库连接函数 const app = express(); const PORT = 3000; // 启用 Express 内置的 JSON 解析中间件,非常重要,用于解析客户端发送的 JSON 请求体 app.use(express.json()); // 连接数据库!确保在你的路由和业务逻辑之前连接成功 connectDB(); // ... (这里将是你所有的 Express 路由和中间件代码) app.get('/', (req, res) => { res.send('Welcome to Node.js & MongoDB App!'); }); app.listen(PORT, () => { console.log(`Express 服务器运行在 http://localhost:${PORT}`); console.log('请确保 MongoDB 服务正在运行在 localhost:27017,并且数据库名称为 mydatabase。'); }); ``` **测试连接:** 1. 确保你的 MongoDB 服务已经启动并运行在 `localhost:27017`。 2. 保存 `db.js` 和 `app.js` 文件。 3. 在终端中运行 `node app.js`。 4. 如果你看到控制台输出 `MongoDB 连接成功: localhost`,那就说明你的 Node.js 应用已经成功连接到 MongoDB 数据库了!如果报错,请检查 MongoDB 服务是否启动,或者连接 URI 是否正确。 --- #### **14.4 Mongoose Schema 和 Model 的定义 — 数据的‘蓝图’和‘操作接口’!** 好了,数据库连上了,那怎么往里面存数据呢?难道随便存吗? 虽然 MongoDB 是“无模式”的,但咱们写应用程序可不能乱来!你需要对要存储的数据有一个“预期”,比如用户应该有哪些字段,它们的类型是什么,有没有必填项等等。Mongoose 的 **Schema (模式)** 和 **Model (模型)** 就是用来干这个的! **1. Schema (模式) — 数据的‘蓝图’或者‘身份证’!** * Schema 定义了 MongoDB 文档的**结构**、每个字段的**数据类型**、**验证规则**和**默认值**。 * 它不是数据库中的实际“表结构”(MongoDB 没有“表”的概念),而是 Mongoose 在应用程序层面对你数据的一种“软约束”和“描述”。它保证了你通过 Mongoose 插入或更新的数据符合你定义的规则。 **2. Model (模型) — 数据的‘操作接口’或者‘特种兵小队’!** * Model 是 Schema 的“编译版本”。它是一个构造函数,你可以用它来创建新的文档实例。 * 更重要的是,它提供了与数据库交互的各种方法(比如 `find()`, `save()`, `update()`, `findByIdAndDelete()` 等等),让你能够方便地进行 CRUD 操作。 **示例:定义一个用户 Schema 和 Model** 咱们来为用户数据定义一个 Schema 和 Model。通常这些定义会放在一个单独的 `models` 文件夹里。 ```javascript // models/User.js (在你项目的根目录创建一个 models 文件夹,然后在里面创建 User.js 文件) const mongoose = require('mongoose'); // 定义用户 Schema (数据的蓝图!) const UserSchema = new mongoose.Schema({ name: { // 用户名 type: String, // 类型是字符串 required: [true, '用户名是必填项'], // 必填项,如果没填就报错,并提供错误消息 trim: true, // 自动去除字符串两端的空白字符 minlength: [3, '用户名至少需要3个字符'] // 最小长度验证 }, email: { // 邮箱 type: String, required: [true, '邮箱是必填项'], unique: true, // 邮箱必须是唯一的!如果重复插入会报错 lowercase: true, // 存储前自动转换为小写(方便搜索和避免重复) match: [/.+@.+\..+/, '请输入有效的邮箱地址'] // 使用正则表达式验证邮箱格式 }, age: { // 年龄 type: Number, // 类型是数字 min: [0, '年龄不能为负数'], // 最小值验证 max: [120, '年龄不能超过120'] // 最大值验证 }, createdAt: { // 创建时间 type: Date, // 类型是日期 default: Date.now // 默认值为当前时间,如果没有提供,就自动填上 } }); // 创建并导出 User Model (数据的操作接口!) // 'User' 是集合的名称。Mongoose 会自动将其转换为小写并复数化,所以实际在 MongoDB 中会看到一个名为 'users' 的集合。 module.exports = mongoose.model('User', UserSchema); ``` **小提示:** 在上面的 `UserSchema` 中,我们定义了各种验证规则(`required`, `unique`, `minlength`, `match` 等)。这些验证会在你尝试保存数据到数据库时自动执行。如果验证失败,Mongoose 会抛出 `ValidationError`,你可以捕获它并返回给客户端。 --- #### **14.5 基本 CRUD (创建、读取、更新、删除) 操作 — 让你的应用学会‘增删改查’!** 好了,数据库连上了,Model 也定义好了,现在咱们就来把之前那个“临时记忆”的用户管理系统,升级成一个能真正把数据存起来的 RESTful API!我们将把 Mongoose 的操作集成到 Express.js 路由中。 ```javascript // app.js (一个相对完整的 Express 应用示例) const express = require('express'); const connectDB = require('./db'); // 引入数据库连接函数 const User = require('./models/User'); // 引入 User 模型 const app = express(); const PORT = 3000; // 连接数据库 (确保在路由定义之前连接成功) connectDB(); // --- 中间件 --- // 启用 Express 内置的 JSON 解析中间件,用于解析客户端发送的 JSON 请求体 app.use(express.json()); // 启用 Express 内置的 URL-encoded 解析中间件 app.use(express.urlencoded({ extended: true })); // --- RESTful 用户 API 路由 --- // GET /api/users - 获取所有用户 app.get('/api/users', async (req, res) => { try { const users = await User.find(); // 使用 User Model 的 find() 方法查找所有用户 res.status(200).json(users); // 返回 200 OK 和用户列表 } catch (err) { // 错误处理:通常返回 500 Internal Server Error console.error('获取用户失败:', err.message); res.status(500).json({ message: '获取用户失败', error: err.message }); } }); // GET /api/users/:id - 获取单个用户 app.get('/api/users/:id', async (req, res) => { try { const user = await User.findById(req.params.id); // 使用 findById() 根据 ID 查找用户 if (!user) { return res.status(404).json({ message: '用户未找到' }); // 如果没找到,返回 404 Not Found } res.status(200).json(user); // 返回 200 OK 和用户数据 } catch (err) { // 错误处理:比如 ID 格式不正确,Mongoose 会抛出 CastError if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); // 400 Bad Request } console.error('获取单个用户失败:', err.message); res.status(500).json({ message: '获取用户失败', error: err.message }); } }); // POST /api/users - 创建新用户 app.post('/api/users', async (req, res) => { const { name, email, age } = req.body; // 创建一个新的 User 文档实例 (注意这里还没有保存到数据库,只是内存中的一个对象) const newUser = new User({ name, email, age }); try { const savedUser = await newUser.save(); // 使用 save() 方法将新用户保存到数据库 res.status(201).json(savedUser); // 返回 201 Created 和创建成功的用户数据 } catch (err) { // 错误处理:Mongoose 验证错误或唯一性错误 if (err.name === 'ValidationError') { // 如果数据不符合 Schema 验证规则 const errors = Object.values(err.errors).map(el => el.message); // 提取所有验证错误信息 return res.status(400).json({ message: '数据验证失败', errors: errors }); // 返回 400 Bad Request } if (err.code === 11000) { // 如果是 MongoDB 的唯一性约束错误 (例如邮箱重复) return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱', field: 'email' }); // 409 Conflict } console.error('创建用户失败:', err.message); res.status(500).json({ message: '服务器内部错误', error: err.message }); } }); // PUT /api/users/:id - 更新用户 (完全替换) app.put('/api/users/:id', async (req, res) => { try { // findByIdAndUpdate 根据 ID 查找并更新文档 // 第一个参数是 ID // 第二个参数是更新的数据 // { new: true }:返回更新后的文档(默认返回更新前的) // { runValidators: true }:确保在更新时也运行 Schema 中定义的验证规则 const updatedUser = await User.findByIdAndUpdate( req.params.id, req.body, { new: true, runValidators: true } ); if (!updatedUser) { return res.status(404).json({ message: '用户未找到' }); } res.status(200).json(updatedUser); } catch (err) { // 错误处理同上 if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); } if (err.name === 'ValidationError') { const errors = Object.values(err.errors).map(el => el.message); return res.status(400).json({ message: '数据验证失败', errors: errors }); } if (err.code === 11000) { return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱' }); } console.error('更新用户失败:', err.message); res.status(500).json({ message: '服务器内部错误', error: err.message }); } }); // DELETE /api/users/:id - 删除用户 app.delete('/api/users/:id', async (req, res) => { try { const deletedUser = await User.findByIdAndDelete(req.params.id); // 根据 ID 查找并删除文档 if (!deletedUser) { return res.status(404).json({ message: '用户未找到' }); } res.status(204).send(); // 204 No Content: 表示请求成功,但没有返回任何内容 } catch (err) { if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); } console.error('删除用户失败:', err.message); res.status(500).json({ message: '服务器内部错误', error: err.message }); } }); // 启动服务器 app.listen(PORT, () => { console.log(`Express 服务器运行在 http://localhost:${PORT}`); console.log('请确保 MongoDB 服务正在运行在 localhost:27017,并且数据库名称为 mydatabase!'); console.log('\n使用 Postman/Insomnia 或 curl 命令测试这些 API:'); console.log(' GET /api/users'); console.log(' POST /api/users (Body: {"name": "John Doe", "email": "john@example.com", "age": 30})'); console.log(' GET /api/users/:id'); // 使用 POST 返回的 ID console.log(' PUT /api/users/:id (Body: {"name": "Jane Doe", "email": "jane.d@example.com"})'); console.log(' DELETE /api/users/:id'); }); // --- 全局错误处理中间件 (放在所有路由和常规中间件之后) --- // 它会捕获所有未被前面 try...catch 捕获的错误,或者通过 next(err) 传递过来的错误 app.use((err, req, res, next) => { console.error('未捕获的错误或内部错误:', err.stack); // 打印错误堆栈到服务器控制台 const statusCode = err.statusCode || 500; // 如果错误有自定义状态码,就用它,否则默认为 500 res.status(statusCode).json({ status: 'error', message: err.message || '服务器内部错误,请稍后再试。' // 在开发环境中,你可以考虑暴露更多错误信息,生产环境不要! // stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); }); ``` **运行步骤:** 1. **确保 MongoDB 服务正在运行!** (如果你用 Docker,执行 `docker start my-mongo`) 2. 在你项目的根目录,确保你已经执行了 `npm init -y`。 3. 安装必要的依赖:`npm install express mongoose` 4. 创建 `db.js` 文件,并粘贴咱们前面讲的 MongoDB 连接代码。 5. 创建 `models` 文件夹,然后在里面创建 `User.js` 文件,并粘贴咱们前面讲的 Mongoose Schema 和 Model 定义代码。 6. 创建 `app.js` 文件,并粘贴上面完整的 Express 应用代码。 7. 在终端中运行 `node app.js`。 8. 使用 Postman、Insomnia 或者 `curl` 命令来测试这些 API! **测试示例:** * **创建用户 (POST `http://localhost:3000/api/users`)** * Body (raw, JSON): ```json { "name": "测试用户", "email": "test@example.com", "age": 28 } ``` * 响应应该是一个 201 Created 状态码,并返回创建成功的用户数据,里面会有一个 `_id` 字段(MongoDB 自动生成的唯一 ID),记住这个 ID! * **获取所有用户 (GET `http://localhost:3000/api/users`)** * 你会看到你刚刚创建的用户。 * **获取单个用户 (GET `http://localhost:3000/api/users/<刚刚记住的ID>`)** * 比如:`http://localhost:3000/api/users/60d5ec49f8e7c20015f8e7c2d` * **更新用户 (PUT `http://localhost:3000/api/users/<要更新的ID>`)** * Body (raw, JSON): ```json { "name": "更新后的测试用户", "email": "updated_test@example.com", "age": 30 } ``` * **删除用户 (DELETE `http://localhost:3000/api/users/<要删除的ID>`)** * 如果删除成功,会返回 204 No Content 状态码,没有响应体。 --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * 深入理解了 Express.js 中间件的各种类型(应用级、路由级、错误处理),并学会了如何编写自定义中间件来实现特定的功能。 * 认识并学会了使用 `morgan`、`cors`、`helmet`、`express-session` 等常用的第三方中间件,让你的 Express 应用更加强大和安全。 * **最重要的是,你学会了如何将 Node.js 应用程序与 MongoDB 数据库集成,使用 Mongoose 进行数据建模(Schema 和 Model 的定义),并能够轻松地执行基本的 CRUD (创建、读取、更新、删除) 操作!** 你现在已经具备了构建一个功能完善、能够存储和管理数据的 Node.js 后端应用的能力了!这可是全栈开发中至关重要的一步! **下节预告:** 数据有了,API 也有了,但是,用户怎么登录呢?怎么保护你的 API 不被随便访问呢?下节课,咱们就要学习用户认证与授权的核心机制——**JWT (JSON Web Token)**!让你的 API 变得“聪明”又“安全”!准备好了吗?咱们下节课,不见不散! 好的,同学们,欢迎回来! 上节课咱们搞定了 Express.js 的路由和请求处理,学会了从 URL 里“挖”数据,也能优雅地处理 POST 请求体,甚至还搭建了一个简单的 RESTful 用户管理 API。是不是感觉自己的 Web 开发能力又上了一个台阶? 今天,咱们要继续深入 Express.js 的“内功心法”——**中间件**!它可是 Express.js 最强大、最灵活的“武器”!同时,咱们还要正式把你的 Node.js 应用和**数据库**连接起来,让你的应用拥有“永久记忆”!没有数据库的应用,就像没有大脑的人,啥都记不住! --- ### **第 13 节:Express.js 中间件深入 — 你的 Web 应用‘变形金刚’!** **学习目标:** 在本节课中,你将深入理解 Express.js 中间件的各种类型(应用级、路由级、错误处理),学会如何编写自定义中间件来实现特定的功能,并认识一些常用的第三方中间件,让你的 Express 应用变得更加强大、安全和易于管理。 --- #### **13.1 应用级中间件、路由级中间件、错误处理中间件 — 中间件的‘兵种分类’!** **各位老铁,同学们好啊!** 还记得咱们在 Express.js 入门时,提到中间件就像流水线上的“工人”吗?实际上,这些“工人”还分不同的“兵种”和“级别”,它们在流水线上承担着不同的职责和作用范围。 1. **应用级中间件 (Application-level Middleware):** * **作用范围:** 这是最常见的中间件!使用 `app.use()` 或 `app.METHOD()` (比如 `app.app.get()`, `app.post()`) 绑定到 `app` 这个 Express 应用实例上。 * **执行时机:** * 如果使用 `app.use()` 且**没有指定路径**:那么这个中间件将应用于**所有**进入 Express 应用的请求(无论什么路径,什么 HTTP 方法)。它就像你公司门口的保安,每个进公司的人都要过他那关。 * 如果使用 `app.use('/path', callback)`:这个中间件只应用于以该 `path` 开头的所有请求。比如 `app.use('/api', ...)`,那么只有 `api` 开头的请求才会经过它。 * `app.METHOD(path, callback)`:这些也是应用级中间件,只不过它们绑定到了特定的 HTTP 方法和路径。它们通常是请求处理链的**终点**(也就是处理完就直接响应了,不用 `next()` 了)。 * **示例:** ```javascript const express = require('express'); const app = express(); const PORT = 3000; // 应用级中间件 1:对所有请求生效的日志记录器 // 就像公司大门口的打卡机,每个人进来都要打卡 app.use((req, res, next) => { console.log(`[应用级中间件] 收到请求: ${req.method} ${req.url} at ${new Date().toISOString()}`); next(); // 必须调用 next(),否则请求会在这里停止! }); // 应用级中间件 2:只对 /api 路径生效的中间件 // 就像公司的“API 部门”的门禁,只有去 API 部门的人才需要刷卡 app.use('/api', (req, res, next) => { console.log('[应用级中间件] 请求进入 /api 部门。'); next(); }); // 路由处理函数 (也是一种特殊的应用级中间件) app.get('/', (req, res) => { res.send('首页'); }); app.get('/api/data', (req, res) => { res.json({ message: 'API 部门的数据' }); }); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`)); ``` 2. **路由级中间件 (Router-level Middleware):** * **作用范围:** 它不是直接绑定到 `app` 实例,而是绑定到 `express.Router()` 实例。你可以把 `express.Router()` 想象成 Express.js 应用里的小型“子应用程序”或者“模块化的路由器”。 * **目的:** 当你的应用变得庞大,路由逻辑复杂时,你可以把相关的路由和中间件组织到一个独立的路由模块里,提高代码的模块化和可维护性。 * **示例:** ```javascript // usersRoutes.js (一个新的文件,专门管理用户相关的路由和中间件) const express = require('express'); const router = express.Router(); // 创建一个路由实例 // 路由级中间件:只对通过此 router 处理的请求生效 // 就像“用户管理部门”的内部规定,只有进入这个部门的人才需要遵守 router.use((req, res, next) => { console.log(`[路由级中间件] 请求到用户部门: ${req.method} ${req.url}`); next(); }); router.get('/', (req, res) => { res.send('用户列表页'); }); router.get('/:id', (req, res) => { res.send(`用户 ID: ${req.params.id}`); }); module.exports = router; // 导出这个路由实例 // app.js (在你的主应用里引入并使用这个路由模块) const express = require('express'); const app = express(); const usersRoutes = require('./usersRoutes'); // 引入用户路由模块 const PORT = 3000; // ... (其他应用级中间件,比如日志中间件) // 将用户路由模块挂载到应用程序的 /users 路径下 // 任何访问 /users/xx 的请求都会先经过 usersRoutes 里的中间件 app.use('/users', usersRoutes); app.get('/', (req, res) => res.send('首页')); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`)); ``` **测试:** 访问 `http://localhost:3000/users/123`,你会看到应用级和路由级中间件的日志都打印出来了。 3. **错误处理中间件 (Error-handling Middleware):** * **特点:** 这是最特殊的中间件!它有**四个参数**:`(err, req, res, next)`。其他中间件只有三个参数。 * **执行时机:** 它们必须定义在**所有其他路由和常规中间件的后面**!当你的任何路由或中间件中发生错误(例如,你调用了 `next(err)` 并传入一个错误对象)时,Express 会非常智能地跳过所有常规中间件,直接把控制权交给这个错误处理中间件!它就像你公司的“急诊室”,专门处理各种突发状况。 * **示例:** ```javascript const express = require('express'); const app = express(); const PORT = 3000; // ... (省略常规中间件和路由) app.get('/error-test', (req, res, next) => { // 模拟一个错误发生 const error = new Error('这是一个测试错误!我故意抛出来的!'); error.statusCode = 500; // 可以自定义错误属性,比如状态码 next(error); // 将错误传递给下一个错误处理中间件 }); // 404 处理中间件 (放在所有路由之后,错误处理之前) // 如果前面所有路由和中间件都没匹配到请求,就会走到这里 app.use((req, res, next) => { res.status(404).send('<h1>404 Not Found</h1><p>您访问的页面不存在。</p>'); }); // 错误处理中间件 (必须有四个参数:err, req, res, next) // 且必须放在所有路由和常规中间件的最后面! app.use((err, req, res, next) => { console.error('哎呀!捕获到错误了:', err.stack); // 打印错误堆栈到服务器控制台,方便调试 const statusCode = err.statusCode || 500; // 如果错误有自定义状态码就用,否则默认为 500 res.status(statusCode).send(` <h1>${statusCode} - 服务器出错了!</h1> <p>${err.message}</p> <pre>${process.env.NODE_ENV === 'development' ? err.stack : ''}</pre> <p>生产环境不会显示堆栈信息,怕泄露隐私!</p> `); // 注意:错误处理中间件里通常不需要调用 next(),因为它是处理请求的终点 }); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`)); ``` **测试:** * 访问 `http://localhost:3000/error-test`,你会看到自定义的错误页面。 * 访问 `http://localhost:3000/nonexistent-path`,你会看到 404 页面。 --- #### **13.2 自定义中间件的编写 — 打造你自己的‘特种兵’!** 编写自定义中间件非常简单,你只需要定义一个函数,并确保它的参数是 `(req, res, next)` 就行了。然后,在这个函数里写你的业务逻辑,最后别忘了调用 `next()` 或发送响应来结束请求。 **示例:身份验证中间件** 这个中间件可以用来检查用户是否登录,或者是否有权限访问某个资源。 ```javascript // authMiddleware.js (一个新的文件,专门放你的自定义中间件) function authenticate(req, res, next) { // 假设我们从请求头中获取一个 API Key 来进行简单认证 const apiKey = req.headers['x-api-key']; if (apiKey === 'MY_SECRET_API_KEY_123') { // 假设这是你的秘密 API Key // 认证成功!可以在 req 对象上添加用户信息,方便后续路由使用 req.user = { id: 101, name: '认证用户_张三', role: 'admin' }; console.log('[认证中间件] 认证成功!用户:', req.user.name); next(); // 认证通过,继续处理请求,交给下一个中间件或路由 } else { // 认证失败!直接发送 401 Unauthorized 响应,并结束请求 console.log('[认证中间件] 认证失败!无效的 API Key。'); res.status(401).send('未授权: 请提供有效的 API Key!'); // 这里没有调用 next(),因为已经发送了响应,请求流程结束 } } module.exports = authenticate; // 导出这个中间件函数 // app.js (在你的主应用里使用这个自定义中间件) const express = require('express'); const app = express(); const authenticate = require('./authMiddleware'); // 引入自定义中间件 const PORT = 3000; app.use(express.json()); // 用于解析请求体 // 应用到所有以 /secure 开头的路径的请求 // 只有带了正确的 API Key 的请求才能进入 /secure 内部的路由 app.use('/secure', authenticate); // 放在 /secure 路由定义之前 // 受保护的路由 app.get('/secure/data', (req, res) => { // 只有通过 authenticate 中间件的请求才能到达这里 res.json({ message: `欢迎, ${req.user.name}! 这是受保护的秘密数据。`, data: '这是只有 VIP 才能看的信息!' }); }); // 公开的路由,不需要认证 app.get('/public/data', (req, res) => { res.send('这是公开数据,无需认证,任何人都可以访问。'); }); app.listen(PORT, () => console.log(`服务器运行在 http://localhost:${PORT}`)); ``` **测试:** * `GET http://localhost:3000/public/data` (成功) * `GET http://localhost:3000/secure/data` (你会得到 401 未授权错误,因为没带 API Key) * `GET http://localhost:3000/secure/data` (用 Postman 或 curl,添加请求头 `X-API-Key: MY_SECRET_API_KEY_123`,你会成功获取数据!) --- #### **13.3 常用的第三方中间件介绍 — NPM 上的‘宝藏’!** Express.js 的强大之处,很大一部分要归功于其庞大的中间件生态系统。NPM 上有无数的第三方中间件,它们帮你解决了各种常见的问题,让你不用“重复造轮子”。 这里介绍几个你在实际项目中肯定会用到的“明星中间件”: 1. **`morgan` (HTTP 请求日志):** * **作用:** 自动帮你记录 HTTP 请求的详细信息,比如请求方法、URL、状态码、响应时间等等。非常适合开发时调试和生产环境监控。 * **安装:** `npm install morgan` * **使用:** ```javascript const morgan = require('morgan'); // ... app.use(morgan('dev')); // 'dev' 是一种预定义的日志格式,它会输出简洁的带颜色的日志到控制台 // 还有 'tiny', 'short', 'common', 'combined' 等多种格式 // 你也可以自定义格式: app.use(morgan(':method :url :status :response-time ms - :res[content-length]')); ``` 运行后,每次请求都会在你的服务器控制台打印一行日志,清晰明了! 2. **`cors` (跨域资源共享):** * **作用:** 当你的前端应用和后端 API 不在同一个域名下时,就会遇到**跨域问题**(CORS 错误)。`cors` 中间件可以帮你轻松地设置 CORS 策略,允许或限制来自不同源的 Web 应用程序访问你的服务器资源。 * **安装:** `npm install cors` * **使用:** ```javascript const cors = require('cors'); // ... app.use(cors()); // 最简单粗暴的方式:允许所有来源的跨域请求 (开发环境常用,生产环境慎用!) // 生产环境通常需要配置特定的来源,更安全: // app.use(cors({ // origin: 'http://your-frontend-domain.com', // 只允许来自这个域名的请求 // methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的 HTTP 方法 // allowedHeaders: ['Content-Type', 'Authorization'] // 允许的请求头 // })); ``` 3. **`helmet` (安全):** * **作用:** “安全帽”中间件!它通过设置各种 HTTP 响应头,来帮助你的 Express 应用程序抵御一些常见的 Web 漏洞和攻击。比如 XSS (跨站脚本攻击)、Clickjacking (点击劫持) 等。 * **安装:** `npm install helmet` * **使用:** ```javascript const helmet = require('helmet'); // ... app.use(helmet()); // 启用所有默认的 Helmet 中间件(通常就够用了) // 你也可以选择性地启用或禁用其中的某些模块,比如: // app.use(helmet.contentSecurityPolicy()); // 内容安全策略 // app.use(helmet.xssFilter()); // XSS 过滤 ``` 4. **`express-session` (会话管理):** * **作用:** 如果你不想用 JWT,或者需要传统的基于 Session 的会话管理,`express-session` 提供了强大的会话管理功能,允许你在用户请求之间存储用户特定的数据(比如用户是否登录,购物车里有啥)。 * **安装:** `npm install express-session` * **使用:** (需要一个 `secret` 字符串来签名会话 ID 的 Cookie,这个 `secret` 必须保密!) ```javascript const session = require('express-session'); // ... app.use(session({ secret: 'your_secret_key_for_session_security', // 必须提供一个秘密字符串,用于签名会话 ID resave: false, // 强制会话保存,即使它在请求期间没有被修改(通常设置为 false) saveUninitialized: true, // 强制未初始化的会话保存到存储(通常设置为 true) cookie: { secure: false } // 在生产环境中,如果使用 HTTPS,应设置为 true! })); app.get('/set-session', (req, res) => { // req.session 对象就是存储会话数据的地方 req.session.views = (req.session.views || 0) + 1; // 统计访问次数 res.send(`你访问了此页面 ${req.session.views} 次`); }); ``` --- ### **第 14 节:数据库集成(MongoDB & Mongoose) — 让你的应用有‘记忆’!** **好了,同学们,前面咱们的用户管理系统,数据都存在一个数组里,服务器一重启,数据就没了!** 这种“临时记忆”的应用可不行,咱们得给它一个“永久记忆”的大脑——**数据库**! 今天,咱们要学习如何将 Node.js 应用与一个非常流行的 NoSQL 数据库——**MongoDB** 连接起来,并使用一个超级好用的 ODM (Object Data Modeling) 库——**Mongoose**,来轻松地操作数据! --- #### **14.1 NoSQL 数据库简介:MongoDB — 数据库界的‘自由派’!** 在传统的数据库世界里,我们经常听到**关系型数据库**(Relational Database,比如 MySQL, PostgreSQL, Oracle)。它们把数据存储在严格定义的表格里,就像 Excel 表格一样,每一行都是一条记录,每一列都有固定的类型,而且表格之间还有复杂的关联关系(用 SQL 语句操作)。 而 **NoSQL (Not only SQL)** 数据库,顾名思义,“不仅仅是 SQL”。它们提供了更灵活、更具扩展性的数据存储方式。**MongoDB** 就是其中一种最受欢迎的 **文档型 (Document-oriented)** NoSQL 数据库! * **数据存储:** MongoDB 将数据存储为 **BSON (Binary JSON)** 文档。啥是 BSON?就是二进制版的 JSON!你可以把它想象成跟 JavaScript 里的 JSON 对象几乎一模一样的数据结构! * **无模式 (Schema-less) 或灵活模式:** 这是 MongoDB 最吸引人的地方之一!在同一个集合 (Collection,你可以把它理解为关系型数据库里的“表”) 里,不同的文档(也就是记录)可以有不同的字段和结构,或者字段的顺序不一样,或者有些文档有这个字段,有些文档没有!这为开发提供了极大的灵活性,尤其是在数据结构不确定、经常变化,或者需要快速迭代产品时,简直是“神器”!当然,你也可以通过应用层面的工具(比如 Mongoose)来强制一些模式。 * **可伸缩性:** MongoDB 非常容易进行水平扩展 (sharding),也就是通过增加更多的服务器来分担负载,处理海量数据和高并发请求。 * **高性能:** 它通常适用于大数据量和高并发场景。 * **与 Node.js 的契合度:** 这点很重要!因为 MongoDB 使用 JSON 格式存储数据,而 Node.js 使用 JavaScript,JavaScript 天然就支持 JSON 对象!这使得 Node.js 开发者在操作 MongoDB 数据时,感觉就像在操作普通的 JavaScript 对象一样,几乎没有“转换成本”,非常自然和流畅! --- #### **14.2 安装 MongoDB — 把‘记忆中心’建起来!** 安装 MongoDB 有多种方式,你可以根据自己的习惯和环境选择: 1. **官方安装包:** 最直接的方式就是访问 MongoDB 官网下载,然后按照官方指南一步步安装适合你操作系统的版本。 * [MongoDB Community Edition Download](https://www.mongodb.com/try/download/community) 2. **Docker:** **对于开发环境,强烈推荐使用 Docker!** 简直是“神器”!你只需要几行命令,就能把 MongoDB 跑在一个独立的容器里,不污染你本地的开发环境,而且启动停止都非常方便。 ```bash # 1. 拉取 MongoDB 镜像 (第一次会下载,以后就很快了) docker pull mongo # 2. 运行一个 MongoDB 容器 (命名为 my-mongo,端口 27017 映射到本地 27017,后台运行) docker run --name my-mongo -p 27017:27017 -d mongo # docker run: 运行容器 # --name my-mongo: 给容器起个名字叫 my-mongo # -p 27017:27017: 把容器的 27017 端口映射到你本地的 27017 端口 # -d mongo: 在后台运行 mongo 镜像 ``` * **常用 Docker 命令:** * `docker ps`: 查看正在运行的容器 * `docker stop my-mongo`: 停止容器 * `docker start my-mongo`: 启动容器 * `docker rm my-mongo`: 删除容器 (需要先停止) * `docker logs my-mongo`: 查看容器日志 3. **云服务:** 在生产环境中,咱们通常不会自己部署和维护数据库。而是会使用专业的云数据库服务,比如 **MongoDB Atlas** (MongoDB 官方提供的云数据库服务),或者 AWS、Google Cloud、Azure 等云提供商的 MongoDB 服务。它们帮你搞定了高可用、备份、扩容等一系列复杂的运维工作。 **安装完成后,请务必确保 MongoDB 服务正在运行!** 默认情况下,MongoDB 运行在 `localhost:27017` 这个地址和端口上。 --- #### **14.3 使用 Mongoose ODM (Object Data Modeling) 连接 MongoDB — 你的‘超级翻译官’!** 直接使用 MongoDB 的原生驱动程序来操作数据库可能会有点“裸奔”和繁琐。这时候,咱们就需要一个“超级翻译官”兼“数据守护者”——**Mongoose**! **Mongoose** 是一个流行的 Node.js **ODM (Object Data Modeling)** 库。它在 MongoDB 驱动程序之上提供了一个更高级别的抽象,使得与 MongoDB 的交互更加简单、结构化,而且还提供了强大的**模式验证**(Schema Validation)功能!这就像给灵活的 MongoDB 加上了“软约束”,确保你存入的数据符合你应用程序的预期。 **安装 Mongoose:** ```bash npm install mongoose ``` **连接 MongoDB(非常简单!):** 咱们通常会把数据库连接的代码单独放在一个文件里,方便管理。 ```javascript // db.js (在你项目的根目录创建一个 db.js 文件) const mongoose = require('mongoose'); const connectDB = async () => { // 用 async/await 连接,更优雅 try { // 使用 mongoose.connect 连接数据库 // 第一个参数是 MongoDB 的连接 URI (Uniform Resource Identifier) // 'mongodb://localhost:27017/mydatabase' 表示连接本地 27017 端口的 MongoDB,数据库名称是 mydatabase const conn = await mongoose.connect('mongodb://localhost:27017/mydatabase', { // ⚠️ 注意:从 Mongoose 6.0 版本开始,以下这些选项已经是默认值了,所以可以省略! // useNewUrlParser: true, // 新的 URL 解析器 // useUnifiedTopology: true, // 新的统一拓扑引擎 // useCreateIndex: true, // 确保在 Mongoose v5.x 时创建索引不会报错,v6.x 已移除 // useFindAndModify: false // 确保 findAndModify 系列方法不报错,v6.x 已移除 }); console.log(`MongoDB 连接成功: ${conn.connection.host}`); // 打印连接成功的主机名 } catch (err) { console.error(`MongoDB 连接失败: ${err.message}`); // 如果数据库连接失败,通常意味着程序无法正常工作,所以咱们直接退出进程! process.exit(1); // 退出进程,并返回一个非零状态码,表示异常退出 } }; module.exports = connectDB; // 导出这个连接函数 ``` **在 Express 应用中使用 `connectDB()`:** 你需要在你的 Express 应用启动之前,调用这个 `connectDB()` 函数。 ```javascript // app.js (你的 Express 应用主文件) const express = require('express'); const connectDB = require('./db'); // 引入咱们刚刚写的数据库连接函数 const app = express(); const PORT = 3000; // 启用 Express 内置的 JSON 解析中间件,非常重要,用于解析客户端发送的 JSON 请求体 app.use(express.json()); // 连接数据库!确保在你的路由和业务逻辑之前连接成功 connectDB(); // ... (这里将是你所有的 Express 路由和中间件代码) app.get('/', (req, res) => { res.send('Welcome to Node.js & MongoDB App!'); }); app.listen(PORT, () => { console.log(`Express 服务器运行在 http://localhost:${PORT}`); console.log('请确保 MongoDB 服务正在运行在 localhost:27017,并且数据库名称为 mydatabase。'); }); ``` **测试连接:** 1. 确保你的 MongoDB 服务已经启动并运行在 `localhost:27017`。 2. 保存 `db.js` 和 `app.js` 文件。 3. 在终端中运行 `node app.js`。 4. 如果你看到控制台输出 `MongoDB 连接成功: localhost`,那就说明你的 Node.js 应用已经成功连接到 MongoDB 数据库了!如果报错,请检查 MongoDB 服务是否启动,或者连接 URI 是否正确。 --- #### **14.4 Mongoose Schema 和 Model 的定义 — 数据的‘蓝图’和‘操作接口’!** 好了,数据库连上了,那怎么往里面存数据呢?难道随便存吗? 虽然 MongoDB 是“无模式”的,但咱们写应用程序可不能乱来!你需要对要存储的数据有一个“预期”,比如用户应该有哪些字段,它们的类型是什么,有没有必填项等等。Mongoose 的 **Schema (模式)** 和 **Model (模型)** 就是用来干这个的! **1. Schema (模式) — 数据的‘蓝图’或者‘身份证’!** * Schema 定义了 MongoDB 文档的**结构**、每个字段的**数据类型**、**验证规则**和**默认值**。 * 它不是数据库中的实际“表结构”(MongoDB 没有“表”的概念),而是 Mongoose 在应用程序层面对你数据的一种“软约束”和“描述”。它保证了你通过 Mongoose 插入或更新的数据符合你定义的规则。 **2. Model (模型) — 数据的‘操作接口’或者‘特种兵小队’!** * Model 是 Schema 的“编译版本”。它是一个构造函数,你可以用它来创建新的文档实例。 * 更重要的是,它提供了与数据库交互的各种方法(比如 `find()`, `save()`, `update()`, `findByIdAndDelete()` 等等),让你能够方便地进行 CRUD 操作。 **示例:定义一个用户 Schema 和 Model** 咱们来为用户数据定义一个 Schema 和 Model。通常这些定义会放在一个单独的 `models` 文件夹里。 ```javascript // models/User.js (在你项目的根目录创建一个 models 文件夹,然后在里面创建 User.js 文件) const mongoose = require('mongoose'); // 定义用户 Schema (数据的蓝图!) const UserSchema = new mongoose.Schema({ name: { // 用户名 type: String, // 类型是字符串 required: [true, '用户名是必填项'], // 必填项,如果没填就报错,并提供错误消息 trim: true, // 自动去除字符串两端的空白字符 minlength: [3, '用户名至少需要3个字符'] // 最小长度验证 }, email: { // 邮箱 type: String, required: [true, '邮箱是必填项'], unique: true, // 邮箱必须是唯一的!如果重复插入会报错 lowercase: true, // 存储前自动转换为小写(方便搜索和避免重复) match: [/.+@.+\..+/, '请输入有效的邮箱地址'] // 使用正则表达式验证邮箱格式 }, age: { // 年龄 type: Number, // 类型是数字 min: [0, '年龄不能为负数'], // 最小值验证 max: [120, '年龄不能超过120'] // 最大值验证 }, createdAt: { // 创建时间 type: Date, // 类型是日期 default: Date.now // 默认值为当前时间,如果没有提供,就自动填上 } }); // 创建并导出 User Model (数据的操作接口!) // 'User' 是集合的名称。Mongoose 会自动将其转换为小写并复数化,所以实际在 MongoDB 中会看到一个名为 'users' 的集合。 module.exports = mongoose.model('User', UserSchema); ``` **小提示:** 在上面的 `UserSchema` 中,我们定义了各种验证规则(`required`, `unique`, `minlength`, `match` 等)。这些验证会在你尝试保存数据到数据库时自动执行。如果验证失败,Mongoose 会抛出 `ValidationError`,你可以捕获它并返回给客户端。 --- #### **14.5 基本 CRUD (创建、读取、更新、删除) 操作 — 让你的应用学会‘增删改查’!** 好了,数据库连上了,Model 也定义好了,现在咱们就来把之前那个“临时记忆”的用户管理系统,升级成一个能真正把数据存起来的 RESTful API!我们将把 Mongoose 的操作集成到 Express.js 路由中。 ```javascript // app.js (一个相对完整的 Express 应用示例) const express = require('express'); const connectDB = require('./db'); // 引入数据库连接函数 const User = require('./models/User'); // 引入 User 模型 const app = express(); const PORT = 3000; // 连接数据库 (确保在路由定义之前连接成功) connectDB(); // --- 中间件 --- // 启用 Express 内置的 JSON 解析中间件,用于解析客户端发送的 JSON 请求体 app.use(express.json()); // 启用 Express 内置的 URL-encoded 解析中间件 app.use(express.urlencoded({ extended: true })); // --- RESTful 用户 API 路由 --- // GET /api/users - 获取所有用户 app.get('/api/users', async (req, res) => { try { const users = await User.find(); // 使用 User Model 的 find() 方法查找所有用户 res.status(200).json(users); // 返回 200 OK 和用户列表 } catch (err) { // 错误处理:通常返回 500 Internal Server Error console.error('获取用户失败:', err.message); res.status(500).json({ message: '获取用户失败', error: err.message }); } }); // GET /api/users/:id - 获取单个用户 app.get('/api/users/:id', async (req, res) => { try { const user = await User.findById(req.params.id); // 使用 findById() 根据 ID 查找用户 if (!user) { return res.status(404).json({ message: '用户未找到' }); // 如果没找到,返回 404 Not Found } res.status(200).json(user); // 返回 200 OK 和用户数据 } catch (err) { // 错误处理:比如 ID 格式不正确,Mongoose 会抛出 CastError if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); // 400 Bad Request } console.error('获取单个用户失败:', err.message); res.status(500).json({ message: '获取用户失败', error: err.message }); } }); // POST /api/users - 创建新用户 app.post('/api/users', async (req, res) => { const { name, email, age } = req.body; // 创建一个新的 User 文档实例 (注意这里还没有保存到数据库,只是内存中的一个对象) const newUser = new User({ name, email, age }); try { const savedUser = await newUser.save(); // 使用 save() 方法将新用户保存到数据库 res.status(201).json(savedUser); // 返回 201 Created 和创建成功的用户数据 } catch (err) { // 错误处理:Mongoose 验证错误或唯一性错误 if (err.name === 'ValidationError') { // 如果数据不符合 Schema 验证规则 const errors = Object.values(err.errors).map(el => el.message); // 提取所有验证错误信息 return res.status(400).json({ message: '数据验证失败', errors: errors }); // 返回 400 Bad Request } if (err.code === 11000) { // 如果是 MongoDB 的唯一性约束错误 (例如邮箱重复) return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱', field: 'email' }); // 409 Conflict } console.error('创建用户失败:', err.message); res.status(500).json({ message: '服务器内部错误', error: err.message }); } }); // PUT /api/users/:id - 更新用户 (完全替换) app.put('/api/users/:id', async (req, res) => { try { // findByIdAndUpdate 根据 ID 查找并更新文档 // 第一个参数是 ID // 第二个参数是更新的数据 // { new: true }:返回更新后的文档(默认返回更新前的) // { runValidators: true }:确保在更新时也运行 Schema 中定义的验证规则 const updatedUser = await User.findByIdAndUpdate( req.params.id, req.body, { new: true, runValidators: true } ); if (!updatedUser) { return res.status(404).json({ message: '用户未找到' }); } res.status(200).json(updatedUser); } catch (err) { // 错误处理同上 if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); } if (err.name === 'ValidationError') { const errors = Object.values(err.errors).map(el => el.message); return res.status(400).json({ message: '数据验证失败', errors: errors }); } if (err.code === 11000) { return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱' }); } console.error('更新用户失败:', err.message); res.status(500).json({ message: '服务器内部错误', error: err.message }); } }); // DELETE /api/users/:id - 删除用户 app.delete('/api/users/:id', async (req, res) => { try { const deletedUser = await User.findByIdAndDelete(req.params.id); // 根据 ID 查找并删除文档 if (!deletedUser) { return res.status(404).json({ message: '用户未找到' }); } res.status(204).send(); // 204 No Content: 表示请求成功,但没有返回任何内容 } catch (err) { if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); } console.error('删除用户失败:', err.message); res.status(500).json({ message: '服务器内部错误', error: err.message }); } }); // 启动服务器 app.listen(PORT, () => { console.log(`Express 服务器运行在 http://localhost:${PORT}`); console.log('请确保 MongoDB 服务正在运行在 localhost:27017,并且数据库名称为 mydatabase!'); console.log('\n使用 Postman/Insomnia 或 curl 命令测试这些 API:'); console.log(' GET /api/users'); console.log(' POST /api/users (Body: {"name": "John Doe", "email": "john@example.com", "age": 30})'); console.log(' GET /api/users/:id'); // 使用 POST 返回的 ID console.log(' PUT /api/users/:id (Body: {"name": "Jane Doe", "email": "jane.d@example.com"})'); console.log(' DELETE /api/users/:id'); }); // --- 全局错误处理中间件 (放在所有路由和常规中间件之后) --- // 它会捕获所有未被前面 try...catch 捕获的错误,或者通过 next(err) 传递过来的错误 app.use((err, req, res, next) => { console.error('未捕获的错误或内部错误:', err.stack); // 打印错误堆栈到服务器控制台 const statusCode = err.statusCode || 500; // 如果错误有自定义状态码,就用它,否则默认为 500 res.status(statusCode).json({ status: 'error', message: err.message || '服务器内部错误,请稍后再试。' // 在开发环境中,你可以考虑暴露更多错误信息,生产环境不要! // stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); }); ``` **运行步骤:** 1. **确保 MongoDB 服务正在运行!** (如果你用 Docker,执行 `docker start my-mongo`) 2. 在你项目的根目录,确保你已经执行了 `npm init -y`。 3. 安装必要的依赖:`npm install express mongoose` 4. 创建 `db.js` 文件,并粘贴咱们前面讲的 MongoDB 连接代码。 5. 创建 `models` 文件夹,然后在里面创建 `User.js` 文件,并粘贴咱们前面讲的 Mongoose Schema 和 Model 定义代码。 6. 创建 `app.js` 文件,并粘贴上面完整的 Express 应用代码。 7. 在终端中运行 `node app.js`。 8. 使用 Postman、Insomnia 或者 `curl` 命令来测试这些 API! **测试示例:** * **创建用户 (POST `http://localhost:3000/api/users`)** * Body (raw, JSON): ```json { "name": "测试用户", "email": "test@example.com", "age": 28 } ``` * 响应应该是一个 201 Created 状态码,并返回创建成功的用户数据,里面会有一个 `_id` 字段(MongoDB 自动生成的唯一 ID),记住这个 ID! * **获取所有用户 (GET `http://localhost:3000/api/users`)** * 你会看到你刚刚创建的用户。 * **获取单个用户 (GET `http://localhost:3000/api/users/<刚刚记住的ID>`)** * 比如:`http://localhost:3000/api/users/60d5ec49f8e7c20015f8e7c2d` * **更新用户 (PUT `http://localhost:3000/api/users/<要更新的ID>`)** * Body (raw, JSON): ```json { "name": "更新后的测试用户", "email": "updated_test@example.com", "age": 30 } ``` * **删除用户 (DELETE `http://localhost:3000/api/users/<要删除的ID>`)** * 如果删除成功,会返回 204 No Content 状态码,没有响应体。 --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * 深入理解了 Express.js 中间件的各种类型(应用级、路由级、错误处理),并学会了如何编写自定义中间件来实现特定的功能。 * 认识并学会了使用 `morgan`、`cors`、`helmet`、`express-session` 等常用的第三方中间件,让你的 Express 应用更加强大和安全。 * **最重要的是,你学会了如何将 Node.js 应用程序与 MongoDB 数据库集成,使用 Mongoose 进行数据建模(Schema 和 Model 的定义),并能够轻松地执行基本的 CRUD (创建、读取、更新、删除) 操作!** 你现在已经具备了构建一个功能完善、能够存储和管理数据的 Node.js 后端应用的能力了!这可是全栈开发中至关重要的一步! **下节预告:** 数据有了,API 也有了,但是,用户怎么登录呢?怎么保护你的 API 不被随便访问呢?下节课,咱们就要学习用户认证与授权的核心机制——**JWT (JSON Web Token)**!让你的 API 变得“聪明”又“安全”!准备好了吗?咱们下节课,不见不散! 好的,同学们,欢迎回来! 上节课咱们成功地把 Express.js 应用和 MongoDB 数据库连接起来了,并且学会了用 Mongoose 来操作数据,实现了 API 的 CRUD 功能。现在你的应用已经有了“永久记忆”! 但是,一个能够“增删改查”的应用,如果没有用户登录和权限管理,那可是“裸奔”的状态!谁都能访问,谁都能操作,那还得了?!所以,今天这节课,咱们就要给你的 API 加上“安全门”和“通行证”!咱们要深入学习用户认证与授权的核心机制——**JWT (JSON Web Token)**! --- ### **第 15 节:构建 RESTful API — 优雅地与数据交互(你的后端‘艺术品’!)** **学习目标:** 本节课,你将再次回顾和强化 RESTful API 的设计原则,理解如何利用这些原则来构建清晰、标准化的 API 接口。同时,咱们还会深入学习如何对客户端请求进行**数据验证**,以及如何提供清晰、有意义的**错误处理响应**,让你的 API 不仅功能强大,而且“彬彬有礼”! --- #### **15.1 RESTful API 设计原则 — API 的‘江湖规矩’!** **各位老铁,同学们好啊!** 咱们前面已经初步接触了 RESTful API 的概念,但今天咱们要更系统地聊聊它。 **REST (Representational State Transfer)**,中文叫“表征状态转移”,它其实不是一个具体的“标准”,而是一种**架构风格**,一套用于设计网络应用程序(特别是 Web 服务)的**指导原则**。遵循这些原则设计的 API,我们就称之为 **RESTful API**。 为什么你的 API 要“RESTful”?因为这样你的 API 会变得: * **易于理解和使用:** 别人一眼就能看懂你的 API 是干嘛的。 * **易于扩展和维护:** 你的 API 结构清晰,未来添加新功能或修改旧功能会更简单。 * **松耦合:** 客户端和服务器可以独立发展,互不影响。 **RESTful API 的核心原则(这些是 RESTful API 的‘金科玉律’!):** 1. **资源 (Resource) — 一切皆资源!** * 在 RESTful API 的世界里,所有东西都被视为“资源”。比如用户、产品、订单、评论等等。 * 每个资源都应该有一个**唯一的 URI (Uniform Resource Identifier)** 来标识它。 * **最佳实践:** URI 应该使用**名词(通常是复数形式)**来表示资源,而不是动词。 * **示例:** * ✅ `GET /users` (获取所有用户) * ❌ `GET /getAllUsers` (包含动词,不推荐) * ✅ `GET /products/123` (获取 ID 为 123 的产品) * ❌ `GET /getProductById/123` * ✅ `POST /orders` (创建新订单) 2. **统一接口 (Uniform Interface) — 用标准的 HTTP 方法去‘操作’资源!** * 这是 RESTful 最重要的原则之一。它要求我们使用标准的 HTTP 方法(也就是那些动词:GET, POST, PUT, DELETE, PATCH)来对资源执行操作。这样,客户端就知道不同的 HTTP 方法代表什么操作,服务器也知道怎么响应。 * **GET:** 从服务器**获取**资源。它是“安全”的(不会对服务器上的数据做任何修改)和“幂等”的(你请求多少次,结果都一样)。 * **POST:** 在服务器上**创建**新资源,或者提交数据。它是“非幂等”的(你发两次 POST,可能就会创建两个资源)。 * **PUT:** **完全更新(替换)** 现有资源。它是“幂等”的(你发多少次 PUT,结果都是把资源更新成你提供的那个状态)。 * **PATCH:** **部分更新** 现有资源。它是“非幂等”的(只更新部分字段)。 * **DELETE:** 从服务器**删除**资源。它是“幂等”的(你删除多少次,资源最终都是被删除的状态)。 * **示例(结合 URI):** * `GET /users`:获取所有用户列表 * `GET /users/123`:获取 ID 为 123 的用户详情 * `POST /users`:创建一个新用户 * `PUT /users/123`:更新(替换)ID 为 123 的整个用户资源 * `PATCH /users/123`:部分更新 ID 为 123 的用户资源(比如只更新年龄) * `DELETE /users/123`:删除 ID 为 123 的用户 3. **无状态 (Stateless) — 服务器‘不记事儿’!** * 这是 RESTful API 的一个核心特性。服务器不应该存储任何关于客户端会话的“状态信息”。 * 这意味着:每一个请求都必须包含处理该请求所需的所有信息。服务器处理完这个请求,就“忘掉”它,不保留任何上下文。 * **优点:** 这使得 API 极具**可伸缩性**!因为任何服务器实例都可以处理任何请求,不需要担心会话粘滞,方便做负载均衡和集群部署。 4. **客户端-服务器分离 (Client-Server Separation):** * 客户端(比如你的前端应用、移动 App)和服务器(你的 Node.js 后端)应该独立发展。它们之间只通过 API 接口进行通信,互不关心对方内部的实现细节。客户端不关心数据在服务器端怎么存储,服务器不关心数据在客户端怎么渲染。 5. **分层系统 (Layered System):** * 客户端无法判断它是直接连接到最终的服务器,还是中间有代理服务器、负载均衡器、CDN 等中间层。这增加了系统的灵活性和可伸缩性。 6. **按需代码 (Code on Demand - 可选):** * 这是 REST 的一个可选原则。服务器可以通过发送可执行代码(如 JavaScript)来临时扩展客户端功能。这在现代 Web 应用中(比如 SPA 应用加载 JS bundle)很常见,但不是 REST 的强制要求。 **其他重要考虑:** * **HTTP 状态码:** **非常重要!** 使用正确的 HTTP 状态码来表示请求的结果,让客户端知道发生了什么。 * `200 OK`:请求成功,通用成功状态。 * `201 Created`:资源创建成功。 * `204 No Content`:请求成功,但没有响应体(比如 DELETE 请求成功)。 * `400 Bad Request`:客户端发送的请求无效,比如参数错误、数据验证失败。 * `401 Unauthorized`:未授权,客户端没有提供有效的认证凭据。 * `403 Forbidden`:禁止访问,客户端已认证,但没有权限访问该资源。 * `404 Not Found`:请求的资源不存在。 * `409 Conflict`:请求与目标资源的当前状态冲突(比如尝试创建已存在的唯一资源)。 * `500 Internal Server Error`:服务器内部发生了未知错误。 * **数据格式:** 通常使用 **JSON (JavaScript Object Notation)** 作为数据交换格式。因为它简洁、易读,而且 JavaScript 原生支持。 * **版本控制 (Versioning - 可选但推荐):** 当你的 API 发生重大、不兼容的变化时,为了不影响旧的客户端,你需要进行版本控制。常见的方式是在 URL 中添加版本号(如 `/v1/users`, `/v2/users`),或者通过请求头(`Accept-Version`)。 --- #### **15.2 使用 Express.js 和 Mongoose 实现 RESTful API 的 CRUD 接口 — 把‘原则’落地!** 在第 14 节中,咱们已经搭建了一个基于 Express.js 和 Mongoose 的用户管理 API,并且实现了基本的 CRUD 操作。那个 API 其实已经**基本遵循了 RESTful 原则**!咱们再来回顾一下它的结构,并强调其设计。 **文件结构(推荐这样组织你的项目!):** ``` my-api-app/ ├── app.js # Express 应用的入口文件,负责配置、引入路由等 ├── db.js # 数据库连接配置 └── models/ └── User.js # Mongoose User 模型定义,定义用户数据的结构和规则 ``` **`models/User.js` (与第 14 节完全相同,Schema 负责验证!):** ```javascript const mongoose = require('mongoose'); // 定义用户 Schema (数据的蓝图!) const UserSchema = new mongoose.Schema({ name: { // 用户名 type: String, required: [true, '用户名是必填项'], // 必填,如果没填会触发 ValidationError trim: true, minlength: [3, '用户名至少需要3个字符'] }, email: { // 邮箱 type: String, required: [true, '邮箱是必填项'], unique: true, // 邮箱必须唯一!如果重复插入会触发 MongoDB 唯一性错误 (code 11000) lowercase: true, match: [/.+@.+\..+/, '请输入有效的邮箱地址'] // 正则表达式验证 }, age: { // 年龄 type: Number, min: [0, '年龄不能为负数'], max: [120, '年龄不能超过120'] }, createdAt: { // 创建时间 type: Date, default: Date.now } }); // 创建并导出 User Model module.exports = mongoose.model('User', UserSchema); ``` **`app.js` (核心 API 路由,强调 RESTful 原则!):** ```javascript const express = require('express'); const connectDB = require('./db'); const User = require('./models/User'); // 引入 User 模型 const app = express(); const PORT = 3000; // 连接数据库 connectDB(); // 中间件:用于解析 JSON 和 URL-encoded 请求体 app.use(express.json()); app.use(express.urlencoded({ extended: true })); // --- RESTful 用户 API 路由 --- // GET /api/users - 获取所有用户 (资源集合) app.get('/api/users', async (req, res) => { try { const users = await User.find(); // 使用 find() 获取所有用户 res.status(200).json(users); // 返回 200 OK,JSON 格式的用户列表 } catch (err) { // 错误处理将在下一节详细说明,这里先简单返回 500 console.error('获取所有用户失败:', err.message); res.status(500).json({ message: '获取用户失败', error: err.message }); } }); // GET /api/users/:id - 获取单个用户 (特定资源) app.get('/api/users/:id', async (req, res) => { try { const user = await User.findById(req.params.id); // 使用 findById() 根据 ID 查找 if (!user) { // 如果没找到 return res.status(404).json({ message: '用户未找到' }); // 返回 404 Not Found } res.status(200).json(user); // 返回 200 OK,单个用户数据 } catch (err) { // 如果 ID 格式不正确,比如 ID 太短、不是有效ObjectId,Mongoose 会抛出 CastError if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); // 400 Bad Request } console.error('获取单个用户失败:', err.message); res.status(500).json({ message: '获取用户失败', error: err.message }); } }); // POST /api/users - 创建新用户 (在资源集合上创建) app.post('/api/users', async (req, res) => { const { name, email, age } = req.body; const newUser = new User({ name, email, age }); // 创建新的 Mongoose 文档实例 try { const savedUser = await newUser.save(); // 调用 save() 保存到数据库,Schema 验证会在这里触发 res.status(201).json(savedUser); // 返回 201 Created,并带上新创建的资源 } catch (err) { // 错误处理将在下一节详细说明 console.error('创建用户失败:', err.message); res.status(500).json({ message: '创建用户失败', error: err.message }); } }); // PUT /api/users/:id - 更新用户 (完全替换特定资源) app.put('/api/users/:id', async (req, res) => { try { // findByIdAndUpdate: 根据 ID 查找并更新文档。 // req.body 是客户端发送的完整新数据,会用来替换旧数据。 // { new: true }:告诉 Mongoose 返回更新后的文档(默认是返回更新前的)。 // { runValidators: true }:确保在更新时也运行 Schema 中定义的验证规则。 const updatedUser = await User.findByIdAndUpdate( req.params.id, req.body, { new: true, runValidators: true } ); if (!updatedUser) { return res.status(404).json({ message: '用户未找到' }); // 如果没找到要更新的资源 } res.status(200).json(updatedUser); // 返回 200 OK 和更新后的资源 } catch (err) { // 错误处理将在下一节详细说明 console.error('更新用户失败:', err.message); res.status(500).json({ message: '更新用户失败', error: err.message }); } }); // DELETE /api/users/:id - 删除用户 (删除特定资源) app.delete('/api/users/:id', async (req, res) => { try { const deletedUser = await User.findByIdAndDelete(req.params.id); // 根据 ID 查找并删除 if (!deletedUser) { return res.status(404).json({ message: '用户未找到' }); // 如果没找到要删除的资源 } res.status(204).send(); // 返回 204 No Content,表示成功删除但没有响应体 } catch (err) { if (err.name === 'CastError') { return res.status(400).json({ message: '无效的用户 ID 格式' }); } console.error('删除用户失败:', err.message); res.status(500).json({ message: '删除用户失败', error: err.message }); } }); // 启动服务器 app.listen(PORT, () => { console.log(`Express 服务器运行在 http://localhost:${PORT}`); console.log('请确保 MongoDB 服务正在运行在 localhost:27017,数据库名称为 mydatabase!'); }); // --- 全局错误处理中间件 (放在所有路由之后) --- // 它是 Express.js 最后一道防线,捕获所有未被路由处理的错误 app.use((err, req, res, next) => { console.error('未捕获的错误或内部错误:', err.stack); // 打印错误堆栈到服务器控制台 const statusCode = err.statusCode || 500; // 如果错误有自定义状态码,就用它,否则默认为 500 res.status(statusCode).json({ status: 'error', message: err.message || '服务器内部错误,请稍后再试。' // 在开发环境中,你可以考虑暴露更多错误信息,生产环境不要! // stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); }); ``` **运行步骤:** (如果你是接着上节课的环境,这些大部分都做过了) 1. **确保 MongoDB 服务正在运行!** (如果你用 Docker,执行 `docker start my-mongo`) 2. 在你项目的根目录,确保你已经执行了 `npm init -y`。 3. 安装必要的依赖:`npm install express mongoose` 4. 确保 `db.js` 文件存在并包含 MongoDB 连接代码。 5. 确保 `models/User.js` 文件存在并包含 Mongoose Schema 和 Model 定义代码。 6. 创建或更新 `app.js` 文件,并粘贴上面完整的 Express 应用代码。 7. 在终端中运行 `node app.js`。 8. 使用 Postman、Insomnia 或者 `curl` 命令来测试这些 API! --- #### **15.3 数据验证与错误处理 — API 的‘安检门’和‘急诊室’!** 一个健壮的 API 不仅要能处理请求,还要能**验证数据**(防止脏数据进入系统)和**优雅地处理错误**(给客户端清晰的反馈)。 **1. 数据验证 (Data Validation) — 确保数据是‘干净’的!** * **Mongoose Schema 验证:** * 这是最基本也是最推荐的验证方式!直接在 `Schema` 定义中进行。咱们在 `models/User.js` 里已经用了很多了: * `required: true`:字段必填。 * `unique: true`:字段值必须唯一。 * `minlength`, `maxlength`:字符串长度限制。 * `min`, `max`:数字范围限制。 * `match`:正则表达式验证(比如邮箱格式)。 * 你还可以定义自定义验证器,实现更复杂的逻辑。 * **优点:** 确保数据在写入数据库前是有效的,防止脏数据进入数据库,这是第一道防线! * **请求体验证 (Input Validation) / 业务逻辑验证:** * 在路由处理函数中,你可能需要对 `req.body` 中的数据进行更复杂的业务逻辑验证,或者在 Mongoose 验证之前进行初步的快速检查(比如检查一些基本参数是否存在)。 * 对于更复杂的验证场景,你通常会使用**第三方验证库**,比如 `Joi` (一个非常强大的 Schema 描述语言和数据验证器) 或 `express-validator` (一个基于 `validator.js` 的 Express 中间件)。 * **示例 (简单的初步检查,在 `POST /api/users` 路由中):** ```javascript app.post('/api/users', async (req, res) => { const { name, email, age } = req.body; if (!name || !email) { // 简单检查必填字段 return res.status(400).json({ message: '姓名和邮箱是必填项,请检查您的输入!' }); } // ... 剩下的交给 Mongoose Schema 验证 }); ``` **2. 错误处理 (Error Handling) — 给客户端清晰的‘反馈’!** 在 RESTful API 中,清晰、统一的错误响应至关重要。客户端收到错误,要知道是自己参数错了,还是服务器出 Bug 了,或者没有权限。 * **使用 `try...catch` 块:** 对于所有异步操作(尤其是数据库查询、外部 API 调用),务必使用 `async/await` 结合 `try...catch` 来捕获潜在的错误。 * **区分错误类型:** 不同的错误,应该返回不同的 HTTP 状态码和错误信息。 * **Mongoose `ValidationError`:** 当你尝试保存的数据不符合 Schema 定义的验证规则时发生。 * `err.name === 'ValidationError'` * `err.errors` 对象会包含详细的验证错误信息。 * **Mongoose `CastError`:** 当你尝试将一个不兼容的值转换为 Mongoose Schema 中定义的类型时发生(例如,你 `findById` 的时候,传入了一个格式错误的 MongoDB ObjectId)。 * `err.name === 'CastError'` * **MongoDB `duplicate key error` (错误码 11000):** 当你尝试插入或更新一个违反 `unique: true` 约束的文档时发生(比如你想注册一个已经被占用的邮箱)。 * `err.code === 11000` * **`JsonWebTokenError` / `TokenExpiredError`:** JWT 验证相关错误。 * **其他运行时错误:** 任何未被预料到的服务器端错误,通常是代码 Bug。 * **返回适当的 HTTP 状态码:** * `400 Bad Request`:客户端发送的请求有问题(比如验证失败、参数格式错误、缺少必填字段)。 * `401 Unauthorized`:未授权,客户端没有提供有效的认证凭据。 * `403 Forbidden`:已认证,但没有权限访问。 * `404 Not Found`:请求的资源不存在。 * `409 Conflict`:请求与目标资源的当前状态冲突(比如你尝试注册的邮箱已经被占用了)。 * `500 Internal Server Error`:服务器端发生了未知错误,这是你的代码 Bug,或者服务器问题。 * **提供有意义的错误信息:** 响应体中应该包含一个清晰的 `message` 字段,描述错误原因。对于 `4xx` 错误,可以提供更详细的 `errors` 数组或 `field` 信息,帮助客户端定位问题。对于 `5xx` 错误,则不应该暴露太多细节,只给一个通用错误信息。 **改进后的错误处理示例 (在 `app.js` 中,咱们把 `POST` 和 `PUT` 路由的 `catch` 块做得更精细化):** ```javascript // ... (app.js 的其他代码不变) // POST /api/users - 创建新用户 (改进错误处理) app.post('/api/users', async (req, res) => { const { name, email, age } = req.body; const newUser = new User({ name, email, age }); try { const savedUser = await newUser.save(); res.status(201).json(savedUser); } catch (err) { if (err.name === 'ValidationError') { // Mongoose 验证错误 const errors = Object.values(err.errors).map(el => el.message); // 提取所有验证错误消息 return res.status(400).json({ message: '数据验证失败', errors: errors }); // 返回 400 } if (err.code === 11000) { // MongoDB 唯一性约束错误 (例如邮箱重复插入) return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱', field: 'email' }); // 返回 409 Conflict } console.error('创建用户失败 (内部错误):', err.message, err.stack); // 记录内部错误 res.status(500).json({ message: '服务器内部错误,创建用户失败。' }); // 返回 500 } }); // PUT /api/users/:id - 更新用户 (改进错误处理) app.put('/api/users/:id', async (req, res) => { try { const updatedUser = await User.findByIdAndUpdate( req.params.id, req.body, { new: true, runValidators: true } // 返回更新后的文档,并运行验证器 ); if (!updatedUser) { return res.status(404).json({ message: '用户未找到,无法更新。' }); } res.status(200).json(updatedUser); } catch (err) { if (err.name === 'CastError') { // ID 格式错误 return res.status(400).json({ message: '无效的用户 ID 格式,请检查。' }); } if (err.name === 'ValidationError') { // 验证错误 const errors = Object.values(err.errors).map(el => el.message); return res.status(400).json({ message: '数据验证失败', errors: errors }); } if (err.code === 11000) { // 唯一性错误 return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱', field: 'email' }); } console.error('更新用户失败 (内部错误):', err.message, err.stack); // 记录内部错误 res.status(500).json({ message: '服务器内部错误,更新用户失败。' }); } }); // ... (其他路由的错误处理也应类似改进,特别是 GET 和 DELETE 的 CastError 捕获) // 全局错误处理中间件 (放在所有路由之后) // 它是 Express.js 的最后一道防线,捕获所有未被前面路由 try...catch 处理的错误 // 或者那些通过 next(err) 传递过来的错误 app.use((err, req, res, next) => { console.error('全局捕获到未处理的错误:', err.stack); // 打印详细堆栈到服务器控制台 // 默认错误状态码是 500,状态是 'error' const statusCode = err.statusCode || 500; const status = err.status || 'error'; // 生产环境和开发环境返回不同的错误信息 if (process.env.NODE_ENV === 'development') { // 开发环境:返回所有错误细节,方便调试 res.status(statusCode).json({ status: status, error: err, message: err.message, stack: err.stack // 暴露堆栈信息 }); } else { // 生产环境:只返回通用错误信息,不暴露敏感细节 // 如果是预期的“操作性错误”(比如 400、404、409),可以返回更具体的错误信息 // 如果是未预期的“编程错误”(比如代码逻辑 Bug),则只返回通用 500 错误 if (err.isOperational) { // 假设你定义了一个 AppError 类,有 isOperational 属性 res.status(statusCode).json({ status: status, message: err.message }); } else { res.status(500).json({ status: 'error', message: '出错了!请稍后再试。(服务器内部错误)' }); } } }); ``` **敲黑板!** * **区分 `4xx` 和 `5xx` 错误:** `4xx` 是客户端的错,要告诉客户端你哪里错了。`5xx` 是服务器的错,客户端通常不需要知道具体细节,只给一个通用错误就行。 * **生产环境不暴露细节:** 在生产环境中,千万不要把错误的详细堆栈信息(`err.stack`)返回给客户端!这会泄露你的服务器内部结构,非常不安全!只返回通用的、无敏感信息的错误消息即可。 * **自定义错误类:** 资深讲师推荐!为了更好地管理错误,通常会自定义一个错误类,比如 `AppError`,继承自 `Error`,并添加 `statusCode`、`isOperational` 等自定义属性,方便在全局错误处理中间件中统一判断和处理。这个咱们在后续的“高级错误处理”章节可能会更详细地讲。 --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * 深入理解了 **RESTful API 的设计原则**,知道如何优雅、标准化地设计你的 API 接口。 * 掌握了如何结合 **Mongoose Schema 验证**和**请求体验证**来确保数据的“干净”和有效性。 * 学会了如何**细致地处理各种错误**,并返回适当的 HTTP 状态码和有意义的错误信息,让你的 API 变得更加“彬彬有礼”和健壮! 你现在已经具备了构建一个功能强大、设计规范、并且能够妥善处理数据验证和错误的 Node.js 后端应用的能力了!这可是真正的“后端高手”必备技能! **下节预告:** 数据有了,API 也有了,但是,如何识别是哪个用户在操作?如何保护你的 API 不被“坏人”随便访问呢?下节课,咱们就要学习用户认证与授权的核心机制——**JWT (JSON Web Token)**!让你的 API 变得“聪明”又“安全”!准备好了吗?咱们下节课,不见不散! 好的,同学们,欢迎回来! 上节课咱们详细探讨了 RESTful API 的设计原则,并深入学习了如何进行数据验证和提供优雅的错误处理。现在你的 API 接口已经既规范又健壮了! 但是,一个能够“增删改查”的应用,如果不对用户进行身份验证和权限管理,那可是“裸奔”的状态!谁都能访问,谁都能操作,那还得了?!所以,今天这节课,咱们就要给你的 API 加上“安全门”和“通行证”!咱们要深入学习用户认证与授权的核心机制——**JWT (JSON Web Token)**!这可是现代 Web 应用安全的“压舱石”! --- ### **第 16 节:用户认证与授权 (JWT) — JWT 的魔力,让你的 API 变得‘聪明’又‘安全’!** **学习目标:** 本节课,你将理解会话(Session)认证和令牌(Token)认证(尤其是 JWT)这两种主流认证机制的区别和优劣。你将深入掌握 JWT 的结构、工作原理,并最终学会如何使用 `jsonwebtoken` 库在你的 Express.js 应用中实现用户注册、登录,以及保护你的 API 路由! --- #### **16.1 会话 (Session) 和令牌 (Token) 认证机制对比 — ‘你认识我’和‘你亮证件’!** **各位老铁,同学们好啊!** 咱们上网,登录一个网站或者 App,那肯定是需要认证的。认证就是为了证明“你是你”,授权就是为了判断“你能干啥”。在 Web 开发中,最常用的认证机制有两种:传统的**会话 (Session) 认证**和现代流行的**令牌 (Token) 认证**(其中 JWT 是最典型的代表)。 咱们来打个比方,就像你进了餐厅,有两种方式证明“你是你”: **1. 会话 (Session) 认证 — ‘你认识我’模式!** * **工作原理:** 1. 你登录餐厅(用户登录),餐厅服务员(服务器)核实你的身份(验证凭据)。 2. 身份核实通过后,服务员会在餐厅的“小本本”上(服务器端)给你开一个“包厢号”(会话记录),记录你的状态。 3. 服务员把这个“包厢号”(会话 ID)写在一张小纸条上,偷偷塞给你(通常通过 Cookie 发送给客户端)。 4. 你每次点菜或者叫服务员(后续请求),就把这张小纸条给他看(携带 Cookie)。 5. 服务员根据纸条上的“包厢号”,去查他的“小本本”(会话记录),就能认出“哦,这是 3 号包厢的客人!” * **优点:** * **安全性相对较高:** 因为你真正的身份信息(比如用户名、角色)都存在服务器端,客户端只知道一个“包厢号”,别人即使拿到“包厢号”,也无法直接获取你的详细信息。 * **易于撤销会话:** 如果你想把某个用户踢下线,直接把服务器上的“包厢号”记录删掉就行了,他手里的纸条就失效了。 * **缺点:** * **有状态 (Stateful):** **这是它最大的痛点!** 服务器必须维护每个用户的会话状态。这就好比服务员要一直记着所有包厢的客人信息。如果客人多了,服务员就累死了。 * **可伸缩性问题:** 在分布式系统(多台服务器一起提供服务)或负载均衡(请求会随机分发到不同服务器)的环境下,维护会话状态会变得非常复杂!你需要一个共享的存储(比如 Redis)来存放会话信息,所有服务器都能访问。这增加了系统的复杂度和运维成本。 * **跨域问题:** Cookie 默认受浏览器同源策略限制,如果你的前端和后端不在同一个域名下,跨域请求携带 Cookie 需要额外配置(比如 `withCredentials`)。 * **移动应用不友好:** 移动 App 通常不直接使用 Cookie 来管理会话,使用起来不太方便。 **2. 令牌 (Token) 认证 (以 JWT 为例) — ‘你亮证件’模式!** * **工作原理:** 1. 你登录餐厅(用户登录),餐厅服务员(服务器)核实你的身份。 2. 身份核实通过后,服务员不给你包厢号了,而是直接给你发一张“特殊定制的会员卡”(令牌 Token),这张卡上面直接写着你的身份信息(比如“你是 VIP 张三”、“你是普通顾客李四”),并且这张卡还带了“防伪标记”(签名)。 3. 服务员把这张卡给你,他自己不留副本,也不记任何信息(服务器无状态!)。 4. 你每次点菜或叫服务员(后续请求),都直接把这张“会员卡”(令牌)拿出来给服务员看(放在 HTTP 请求头里)。 5. 服务员拿到卡后,立刻验证这张卡的“防伪标记”(验证签名)和“有效期”(过期时间)。如果验证通过,他就能直接从卡上读出你的身份信息,无需去查任何“小本本”! * **优点:** * **无状态 (Stateless):** **这是它最大的优势!** 服务器不存储会话状态,每个请求都包含所有必要信息。这让服务器变得“轻装上阵”,极大地提高了**可伸缩性**!因为任何一台服务器都可以处理任何请求,不需要共享会话存储。 * **跨域友好:** 令牌通常通过 HTTP 头传递(比如 `Authorization: Bearer <token>`),不受浏览器同源策略限制,非常方便跨域 API 调用。 * **移动应用友好:** 令牌的传递方式对各种客户端(Web、iOS、Android)都非常友好。 * **性能:** 服务器无需每次都去查询数据库来验证会话,直接验证令牌即可,提高了认证效率。 * **缺点:** * **令牌无法撤销:** 一旦令牌被签发,在它过期之前,它都是有效的(除非服务器维护一个“黑名单”来强制失效,但这样又有点“有状态”了)。所以,如果令牌被泄露,风险较大。 * **安全性:** 令牌存储在客户端(通常是 `localStorage` 或 `sessionStorage`),容易受到 XSS (跨站脚本攻击) 攻击。需要配合 HTTPS 和适当的存储策略。 * **令牌大小:** 令牌中包含的信息越多,其大小越大,可能会增加请求负载。 **总结:** JWT 认证机制以其**无状态性**和**跨平台友好性**,更适合现代的、分布式的、前后端分离的 Web 应用和 API 开发。虽然它有自己的缺点,但只要正确使用和保护,它仍然是非常安全和高效的认证方案。 --- #### **16.2 JWT (JSON Web Token) 简介与工作原理 — 你的‘数字身份证’!** **JWT 是什么?** JWT (JSON Web Token) 是一个开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息,作为 JSON 对象。 **JWT 的“三大件”(Structure):`Header.Payload.Signature`** 一个 JWT 令牌通常由三部分组成,用点 `.` 分隔开来: 1. **Header (头部):** * 这部分通常包含两个信息: * `typ` (type): 令牌的类型,通常是 `JWT`。 * `alg` (algorithm): 所使用的签名算法,比如 `HMAC SHA256`(HS256)或 `RSA`(RS256)。 * **示例:** ```json { "alg": "HS256", "typ": "JWT" } ``` * 这个 JSON 对象会被转换成字符串,然后用 **Base64Url 编码**(一种变种的 Base64 编码,更适合 URL 传输)。 2. **Payload (载荷):** * 这是 JWT 的“核心”,里面包含了一系列“声明”(claims)。“声明”就是关于一个实体(通常是用户)和一些附加数据的信息。 * “声明”分为三类: * **Registered claims (注册声明):** 预定义的一些声明,不是强制使用的,但推荐使用,它们有特定的含义: * `iss` (issuer): 令牌的签发者。 * `exp` (expiration time): 令牌的过期时间(Unix 时间戳,秒)。**非常重要!** * `sub` (subject): 令牌的主题,通常是用户 ID。 * `aud` (audience): 令牌的接收者。 * `iat` (issued at): 令牌的签发时间(Unix 时间戳,秒)。 * **Public claims (公共声明):** 自定义声明,但为了避免冲突,应在 IANA JSON Web Token Registry 中注册,或定义为 URI。 * **Private claims (私有声明):** 这是最常用的自定义声明!你可以在这里放任何你想传递的非敏感信息,比如用户角色、权限列表等。只要签发者和接收者都同意使用这些声明就行。 * **示例:** ```json { "userId": "60d5ec49f8e7c20015f8e7c2", // 私有声明:用户 ID "username": "john.doe", // 私有声明:用户名 "role": "admin", // 私有声明:用户角色 "iat": 1678886400, // 注册声明:签发时间 (比如 2023-03-15 00:00:00 UTC) "exp": 1678890000 // 注册声明:过期时间 (比如 2023-03-15 01:00:00 UTC,1小时后过期) } ``` * 这个 JSON 对象同样会被 **Base64Url 编码**。 3. **Signature (签名):** * 这是 JWT 的“防伪标记”!用于验证令牌的发送者(确保是你的服务器签发的),并确保令牌在传输过程中没有被篡改。 * 签名的生成过程是这样的:它将 Base64Url 编码的 Header、Base64Url 编码的 Payload,用一个只有服务器知道的**密钥 (secret)**,以及 Header 中指定的**算法**(比如 HS256)进行哈希计算。 * **计算方式(伪代码):** `HMACSHA256( Base64Url(Header) + "." + Base64Url(Payload), secret_key)` * **安全性:** 如果有人篡改了 Header 或 Payload 的任何一个字节,那么重新计算出的签名就和原签名不一致,服务器验证就会失败,从而拒绝这个伪造的令牌。**注意:签名只验证数据是否被篡改,它不加密 Payload 内容!Payload 是 Base64Url 编码的,任何人都可以解码看到里面的信息,所以不要在 Payload 里放敏感信息!** **JWT 的工作原理(‘办证’和‘查验’流程):** 1. **用户登录(办证):** * 客户端(浏览器/App)向服务器发送用户名和密码。 * 服务器验证这些凭据是否正确。 * 如果凭据有效,服务器会使用一个**只有自己知道的秘密密钥**(`secret_key`)和选定的算法,生成一个包含用户身份信息和过期时间的 JWT。 * 服务器将生成的 JWT 发送回客户端。 2. **客户端存储(拿证):** * 客户端收到 JWT 后,通常会把它存储在本地(比如 `localStorage` 或 `sessionStorage`)。 3. **后续请求(亮证):** * 客户端在每次需要访问受保护的资源时(比如获取用户个人资料),都会在 HTTP 请求的 `Authorization` 头中,以 `Bearer <token>` 的格式将 JWT 发送给服务器。 * **示例:** `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2MzJmZTc2ZGY1ODdjYTIzYmY3YjIzMDQiLCJpYXQiOjE2NzgwODk2MDAsImV4cCI6MTY3ODA5MzIwMH0.some_signature_string` 4. **服务器验证 JWT(查验):** * 服务器接收到请求和 JWT 后,会做两件事: 1. **验证签名:** 用同一个秘密密钥,重新计算一遍签名,看看是否和 JWT 中的签名一致。如果不一致,说明令牌被篡改了,立即拒绝! 2. **验证过期时间:** 检查 `exp` 声明,看看令牌是否已经过期了。如果过期,立即拒绝! * 如果签名有效且未过期,服务器会从 Payload 中提取用户信息(比如用户 ID、角色),然后根据这些信息来判断用户是否有权限访问请求的资源,无需再次查询数据库! 5. **发送响应:** * 服务器处理请求并发送响应。 **总结:** JWT 是一个自包含的令牌,一旦签发,服务器就不需要再存储任何会话信息了。这种无状态性是其最大的优势!但记住,**Payload 内容是可解码的,不要放敏感信息!密钥要严格保密!** --- #### **16.3 使用 `jsonwebtoken` 库实现注册、登录和保护路由 — 你的 API‘门禁系统’!** 我们将使用两个 Node.js 库来搞定这一切: * **`jsonwebtoken`**: 用于生成和验证 JWT。 * **`bcryptjs`**: 用于安全地存储用户密码(因为密码不能明文存储,必须加密)。 **安装必要的库:** ```bash npm install jsonwebtoken bcryptjs ``` **1. 修改 `models/User.js` (添加密码字段和密码哈希方法):** 用户模型需要增加一个 `password` 字段来存储密码。同时,咱们不能直接存明文密码,必须进行**哈希加密**!`bcryptjs` 就是干这个的。而且,我们还要提供一个方法,用于比较用户输入的密码和数据库中存储的哈希密码。 ```javascript // models/User.js (修改这个文件) const mongoose = require('mongoose'); const bcrypt = require('bcryptjs'); // 引入 bcryptjs 库 const UserSchema = new mongoose.Schema({ name: { type: String, required: [true, '用户名是必填项'], trim: true, minlength: [3, '用户名至少需要3个字符'] }, email: { type: String, required: [true, '邮箱是必填项'], unique: true, // 邮箱必须唯一 lowercase: true, match: [/.+@.+\..+/, '请输入有效的邮箱地址'] }, password: { // <-- 新增的密码字段! type: String, required: [true, '密码是必填项'], minlength: [6, '密码至少需要6个字符'], select: false // <-- 敲黑板!默认情况下,当查询用户时,这个字段不会被返回!非常重要,避免敏感信息泄露。 }, age: { type: Number, min: [0, '年龄不能为负数'], max: [120, '年龄不能超过120'] }, createdAt: { type: Date, default: Date.now } }); // Mongoose “预保存钩子”(Pre-save Hook): 在用户保存到数据库之前执行! // 用途:在保存用户之前,对密码进行哈希处理 UserSchema.pre('save', async function(next) { // `this` 指向当前要保存的文档 (User 实例) // `this.isModified('password')` 检查 password 字段是否被修改过(只在创建用户或用户修改密码时执行哈希) if (!this.isModified('password')) { return next(); // 如果密码没改,就直接跳过哈希,继续下一步 } // 生成一个“盐”(salt),用于增加哈希的随机性,防止彩虹表攻击。强度为 10 const salt = await bcrypt.genSalt(10); // 使用 bcrypt.hash() 对密码进行哈希 this.password = await bcrypt.hash(this.password, salt); next(); // 继续保存操作 }); // 实例方法:为 User Model 添加一个方法,用于比较用户输入的密码和数据库中存储的哈希密码 UserSchema.methods.matchPassword = async function(enteredPassword) { // `this.password` 是数据库中存储的哈希密码 // `bcrypt.compare()` 会自动进行比较 return await bcrypt.compare(enteredPassword, this.password); }; module.exports = mongoose.model('User', UserSchema); ``` **2. `app.js` (集成认证和授权逻辑):** 咱们要在 `app.js` 里新增用户注册、登录路由,以及一个保护路由的中间件。 ```javascript const express = require('express'); const connectDB = require('./db'); const User = require('./models/User'); // 引入 User 模型 const jwt = require('jsonwebtoken'); // 引入 jsonwebtoken 库 const app = express(); const PORT = 3000; // 定义 JWT 密钥 (SECRET) 和过期时间 (EXPIRES_IN) // 敲黑板!在生产环境中,JWT_SECRET 绝对不能硬编码在这里! // 它必须从环境变量中获取,并且要足够复杂和随机! const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_you_must_change_this_in_production_environment_1234567890'; const JWT_EXPIRES_IN = '1h'; // 令牌过期时间:1 小时 (可以设置 '2d' 2天, '7d' 7天) // 连接数据库 connectDB(); // 中间件 app.use(express.json()); // 用于解析 JSON 请求体 app.use(express.urlencoded({ extended: true })); // --- 辅助函数:生成 JWT 令牌 --- // 传入用户 ID,生成一个签名的 JWT 令牌 const generateToken = (id) => { return jwt.sign({ id }, JWT_SECRET, { // jwt.sign(payload, secretOrPrivateKey, [options]) expiresIn: JWT_EXPIRES_IN, // 设置过期时间 }); }; // --- 认证路由 --- // POST /api/auth/register - 用户注册 app.post('/api/auth/register', async (req, res, next) => { // 添加 next 参数,方便错误传递给全局中间件 const { name, email, password, age } = req.body; try { const newUser = new User({ name, email, password, age }); // 创建用户实例 const savedUser = await newUser.save(); // 保存用户,此时密码会被 pre('save') 钩子哈希 // 为了安全,不要将哈希后的密码返回给客户端! const userResponse = savedUser.toObject(); // 将 Mongoose 文档转换为普通 JS 对象 delete userResponse.password; // 删除密码字段 // 生成 JWT 令牌 const token = generateToken(savedUser._id); res.status(201).json({ // 返回 201 Created message: '用户注册成功', user: userResponse, // 返回不含密码的用户信息 token, // 返回 JWT 令牌 }); } catch (err) { // 错误处理 (沿用之前的数据验证和错误处理逻辑) if (err.name === 'ValidationError') { const errors = Object.values(err.errors).map(el => el.message); return res.status(400).json({ message: '数据验证失败', errors: errors }); } if (err.code === 11000) { // MongoDB duplicate key error code return res.status(409).json({ message: '邮箱已被注册,请使用其他邮箱', field: 'email' }); } next(err); // 其他未知错误,传递给全局错误处理中间件 } }); // POST /api/auth/login - 用户登录 app.post('/api/auth/login', async (req, res, next) => { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: '请提供邮箱和密码' }); } try { // 查找用户,由于我们在 Schema 中设置了 password 字段 select: false // 所以这里需要显式地加上 .select('+password') 才能查询到密码字段 const user = await User.findOne({ email }).select('+password'); // 检查用户是否存在,以及密码是否匹配 // user.matchPassword 是我们在 User Schema 中定义的实例方法 if (!user || !(await user.matchPassword(password))) { return res.status(401).json({ message: '邮箱或密码不正确,请重新输入' }); // 401 Unauthorized } // 登录成功,生成 JWT 令牌 const token = generateToken(user._id); // 不返回密码 const userResponse = user.toObject(); delete userResponse.password; res.status(200).json({ message: '登录成功!欢迎回来!', user: userResponse, token, }); } catch (err) { next(err); // 传递给全局错误处理中间件 } }); // --- 保护路由中间件 (Authorization Middleware) --- // 这个中间件用于检查请求是否带有有效的 JWT 令牌 const protect = async (req, res, next) => { let token; // 1. 检查请求头中是否有 token (通常在 Authorization: Bearer <token> 格式中) if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { token = req.headers.authorization.split(' ')[1]; // 提取 Bearer 后面的 token 字符串 } if (!token) { return res.status(401).json({ message: '未授权: 您没有提供访问令牌,请先登录!' }); } try { // 2. 验证 token:使用 jwt.verify() 验证令牌的有效性(签名和过期时间) // 如果令牌无效或过期,这里会抛出错误 const decoded = jwt.verify(token, JWT_SECRET); // decoded 会包含 { id: userId, iat: ..., exp: ... } // 3. 查找用户并将其附加到请求对象 (req.user) // 这样,后续的路由处理函数就能直接通过 req.user 访问用户信息了 // 再次提醒:select('-password') 排除密码字段 req.user = await User.findById(decoded.id).select('-password'); if (!req.user) { // 理论上这不应该发生,除非数据库用户被删除了 return res.status(401).json({ message: '令牌无效: 令牌指向的用户不存在或已删除。' }); } next(); // 令牌有效且用户存在,放行请求,继续处理后续路由 } catch (err) { // 捕获 jwt.verify 抛出的错误 if (err.name === 'JsonWebTokenError') { return res.status(401).json({ message: '令牌无效: 您的令牌已损坏或不正确。' }); } if (err.name === 'TokenExpiredError') { return res.status(401).json({ message: '令牌已过期: 请重新登录以获取新的令牌。' }); } next(err); // 其他未知错误,传递给全局错误处理中间件 } }; // --- 受保护的 API 路由 (需要认证才能访问!) --- // 只需要在路由处理函数前面加上 protect 中间件即可! // 它的位置很重要,它会在实际的路由处理函数之前执行认证逻辑。 // GET /api/users/me - 获取当前登录用户的信息 app.get('/api/users/me', protect, (req, res) => { // 如果请求能够到达这里,说明 protect 中间件已经成功认证了用户 // req.user 就是当前登录用户的信息 (不含密码) res.status(200).json({ message: '成功获取当前用户信息!', user: req.user, }); }); // GET /api/protected-data - 示例受保护数据 app.get('/api/protected-data', protect, (req, res) => { res.status(200).json({ message: `欢迎 ${req.user.name}! 这是只有认证用户才能看到的数据。`, data: { secret_info: '这里存放着只有VIP才能看的高度机密信息!' }, }); }); // --- 其他 RESTful 用户 API 路由 (你可以根据业务需求,决定是否需要 protect 中间件) --- // 例如,只有认证用户才能获取所有用户列表 (或者只有管理员才能) app.get('/api/users', protect, async (req, res) => { try { const users = await User.find().select('-password'); // 确保不返回密码 res.status(200).json(users); } catch (err) { next(err); } }); // ... (其他 CRUD 路由,比如 PUT, DELETE 往往也需要 protect) // 启动服务器 app.listen(PORT, () => { console.log(`Express 服务器运行在 http://localhost:${PORT}`); console.log('请确保 MongoDB 服务正在运行在 localhost:27017,并且数据库名称为 mydatabase!'); console.log('\n--- 测试认证 API: ---'); console.log(' 1. POST /api/auth/register (Body: {"name": "Test User", "email": "test@example.com", "password": "password123", "age": 25})'); console.log(' 2. POST /api/auth/login (Body: {"email": "test@example.com", "password": "password123"})'); console.log('\n--- 测试受保护路由 (需要 Authorization: Bearer <token> 请求头): ---'); console.log(' 3. GET /api/users/me'); console.log(' 4. GET /api/protected-data'); }); // --- 全局错误处理中间件 (放在所有路由之后) --- app.use((err, req, res, next) => { console.error('全局捕获到未处理的错误:', err.stack); const statusCode = err.statusCode || 500; const status = err.status || 'error'; // 根据 NODE_ENV 决定是否暴露错误细节 if (process.env.NODE_ENV === 'development') { res.status(statusCode).json({ status: status, error: err, message: err.message, stack: err.stack }); } else { // 生产环境不暴露敏感信息 res.status(500).json({ status: 'error', message: '服务器内部错误,请稍后再试。' }); } }); ``` **运行步骤:** 1. **确保 MongoDB 服务正在运行!** 2. 在你项目的根目录,确保你已经执行了 `npm init -y`。 3. **安装必要的依赖:** `npm install express mongoose jsonwebtoken bcryptjs` 4. 确保 `db.js` 文件存在并包含 MongoDB 连接代码。 5. **修改 `models/User.js` 文件**,按照上面的讲义,添加 `password` 字段、`pre('save')` 钩子用于密码哈希,以及 `matchPassword` 实例方法。 6. 创建或更新 `app.js` 文件,并粘贴上面完整的 Express 应用代码。 7. 在终端中运行 `node app.js`。 8. **使用 Postman 或 Insomnia 进行测试(强烈推荐使用这些工具,非常方便):** * **1. 注册用户 (POST `http://localhost:3000/api/auth/register`)** * 设置 `Content-Type: application/json` 请求头。 * Body (raw, JSON): ```json { "name": "Test User", "email": "test@example.com", "password": "password123", "age": 25 } ``` * 发送请求。成功后,你会得到一个 201 Created 状态码的响应,里面包含一个 `token`。**记住这个 `token`!** * **2. 登录用户 (POST `http://localhost:3000/api/auth/login`)** * 设置 `Content-Type: application/json` 请求头。 * Body (raw, JSON): ```json { "email": "test@example.com", "password": "password123" } ``` * 发送请求。成功后,你会得到一个 200 OK 状态码的响应,里面包含一个新的 `token`。**再次记住这个 `token`!** * **3. 访问受保护路由 (GET `http://localhost:3000/api/users/me`)** * **关键步骤:** 在请求的 Headers 中,添加 `Authorization` 头部,值为 `Bearer <你从注册或登录获取到的token>`。 * 例如:`Authorization: Bearer eyJhbGciOiJIUzI1NiI...` * 发送请求。如果成功,你会得到当前登录用户的信息。 * **试试不带 token 或者带一个错误的 token:** 你会得到 401 Unauthorized 错误。 * **4. 访问另一个受保护路由 (GET `http://localhost:3000/api/protected-data`)** * 同样需要带上 `Authorization: Bearer <token>` 头部。 * 发送请求。你会得到一些“秘密数据”。 --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * **深入理解了 RESTful API 的设计原则**,知道如何优雅、标准化地设计你的 API 接口。 * 掌握了如何结合 **Mongoose Schema 验证**和**请求体验证**来确保数据的“干净”和有效性。 * 学会了如何**细致地处理各种错误**,并返回适当的 HTTP 状态码和有意义的错误信息,让你的 API 变得更加“彬彬有礼”和健壮! * **最重要的是,你掌握了现代 Web 应用中非常关键的安全知识——JWT!** 你理解了它与 Session 的区别、JWT 的结构和工作原理,并且能够使用 `jsonwebtoken` 和 `bcryptjs` 库,在你的 Express.js 应用中实现**用户注册、登录**功能,并使用**自定义中间件 (`protect`) 来保护你的 API 路由!** 你现在已经具备了构建一个功能强大、设计规范、能够存储管理数据、并且拥有**用户认证和基础授权**能力的 Node.js 后端应用了!这可是真正的“后端高手”必备技能! **本阶段总结:** 恭喜各位同学!你已经完成了 Node.js 学习的**第三阶段:Node.js Web 开发:Express.js**! 在这个阶段,我们: * **从原生 `http` 模块的“硬核”开发,过渡到了 Express.js 框架的“高效”开发**,感受到了框架带来的便利和效率提升。 * **深入理解了 Express.js 的“灵魂”——中间件机制**,包括应用级、路由级、错误处理中间件,以及如何编写自定义中间件。 * **掌握了 Express.js 的路由和请求处理**,能够灵活获取路由参数和查询字符串,并优雅地处理 POST 请求体。 * **将 Node.js 应用与 MongoDB 数据库无缝集成**,学会了使用 Mongoose 定义 Schema 和 Model,并实现了 CRUD 操作。 * **构建了符合 RESTful 原则的 API 接口**,并学会了进行数据验证和提供统一的错误响应。 * **最最重要的是,你学会了用户认证和授权的核心——JWT**,让你的 API 拥有了“身份识别”和“门禁管理”的能力! 你现在已经是一位合格的 Node.js Web API 开发者了!你已经可以独立搭建一个具备完整认证和数据管理功能的后端服务了! **下个阶段预告:** 接下来,我们将进入第四阶段的学习:**高级概念与实战应用**!咱们会聊聊更健壮的错误处理和日志系统、如何构建实时应用(聊天室!)、怎么用 Node.js 写命令行工具,以及最重要的——如何把你的 Node.js 应用**部署上线**并进行**性能优化**!这是从“大侠”到“宗师”的进阶之路,准备好了吗?咱们稍作休息,马上进入下一阶段的精彩内容! 好的,同学们,欢迎回来! 恭喜你!你已经顺利完成了 Node.js 学习的第三阶段,现在你已经能够构建一个功能强大、设计规范、具备认证和数据管理能力的 RESTful API 了!是不是感觉自己离“全栈”又近了一大步? 现在,咱们要进入**第四阶段:高级概念与实战应用**!这个阶段就像是给你的“武功”加上更多“ Buff”:让你的应用更健壮、更稳定、更快,还能玩转实时通信和命令行工具。最后,咱们还要把你的“作品”真正推向世界——部署上线并进行性能优化!这是从“大侠”到“宗师”的进阶之路,知识密度有点大,但含金量绝对高! --- ### **第四阶段:高级概念与实战应用 (约4节) — 从“大侠”到“宗师”的进阶之路!** **学习目标:** 在本阶段,你将学习如何构建更健壮、更可靠的 Node.js 应用程序,包括高级错误处理和日志记录。你将探索实时通信的世界(WebSockets & Socket.IO)。你还将了解 Node.js 在命令行工具开发中的应用。最后,你将掌握将 Node.js 应用部署到生产环境的基本策略和简单的性能优化技巧。 --- #### **第 17 节:错误处理与日志 — 让你的应用‘会说话’,‘不怕错’!** **各位老铁,同学们好啊!** 咱们前面在写 Express 路由的时候,已经初步做了错误处理和打印 `console.error`。但那只是“小打小闹”!在任何一个真正的、健壮的应用程序中,有效的**错误处理**和**日志记录**都是至关重要的!它们就像你应用的“侦察兵”和“急诊医生”:帮助你及时发现问题、诊断问题,并快速解决问题! --- #### **17.1 同步与异步错误的捕获 — 错误‘出没’,请注意!** 理解 Node.js 中错误是如何“发生”以及如何“捕获”的,是正确处理错误的基础。 1. **同步错误捕获 (`try...catch`):** * 这个咱们老熟人了!对于同步执行的代码,如果它可能抛出错误,你直接用标准的 `try...catch` 块就能把它“逮住”。 * **示例:** ```javascript function riskySyncOperation() { if (Math.random() > 0.5) { throw new Error('同步操作失败了!'); // 同步抛出错误 } return '同步操作成功!'; } try { const result = riskySyncOperation(); console.log(result); } catch (error) { console.error('捕获到同步错误:', error.message); } ``` 2. **异步错误捕获:** * **回调函数模式 (Callback Pattern):** * 传统的 Node.js 异步操作(比如 `fs.readFile` 的回调)通常把错误作为回调函数的第一个参数(`err, data`)。 * **敲黑板!划重点!`try...catch` 无法直接捕获回调函数内部的异步错误!** 因为当回调函数执行时,外层的 `try...catch` 块早已经执行完了,调用栈都清空了! * **错误的方式(千万别这么写!):** ```javascript // ❌ 这种写法是错的!外层的 try...catch 无法捕获异步回调里的错误! // try { // fs.readFile('/nonexistent-file', (err, data) => { // if (err) throw err; // 这个 throw 会导致程序崩溃,因为它不在 try...catch 里面! // console.log(data); // }); // } catch (error) { // console.error('这个 catch 不会执行到!'); // } ``` * **正确的方式(在回调函数内部处理错误):** ```javascript const fs = require('fs'); fs.readFile('nonexistent-file.txt', (err, data) => { if (err) { // 必须在回调函数内部判断错误 console.error('捕获到异步回调错误:', err.message); return; } console.log(data); }); ``` * **Promise 和 `async/await`:** * **这是现代 Node.js 处理异步错误的推荐方式!** 它们使得异步错误处理与同步错误处理非常相似。 * **Promise:** 使用 `.catch()` 方法。任何 Promise 链中发生的错误,只要链末尾有 `.catch()`,都能被捕获。 * **`async/await`:** 结合 `try...catch`。这是最推荐的方式! * **示例:** ```javascript function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.5; if (success) { resolve('数据获取成功'); } else { reject(new Error('数据获取失败了!')); } }, 500); }); } // Promise 方式 fetchData() .then(data => console.log(data)) .catch(error => console.error('捕获到 Promise 错误:', error.message)); // async/await 方式 (推荐在 Express 路由处理函数中使用) // 注意:下面的 app.get 只是一个示例,假设你已经配置了 Express // app.get('/async-test', async (req, res, next) => { // try { // const data = await fetchData(); // res.send(data); // } catch (error) { // // 如果这里捕获到错误,通常会用 next(error) 传递给 Express 的全局错误处理中间件 // next(error); // } // }); ``` * **Express.js 中的 `next(err)`:** * 在 Express.js 中,当你自定义的路由或中间件中捕获到错误时,你通常不会直接 `res.status().send()`,而是使用 `next(error)` 将错误传递给**下一个错误处理中间件**。 * 对于 `async` 路由处理函数,如果 `await` 的 Promise 被拒绝了,Express (从 Node.js 12+ 开始) 会自动捕获这个拒绝,并将其传递给错误处理中间件。所以,有时候你可以省略 `try...catch`,但显式使用 `try...catch` 并调用 `next(error)` 仍然是**良好的实践**,因为它允许你在传递错误之前进行一些预处理或包装。 --- #### **17.2 Express.js 错误处理中间件 — 你的应用‘急诊室’!** 咱们前面已经接触过 Express.js 的错误处理中间件了,这里再强调一下它的特点: * **函数签名特殊:** 它必须有四个参数:`(err, req, res, next)`。 * **位置特殊:** 它必须定义在**所有其他路由和常规中间件的后面**! * **工作机制:** 当任何路由或中间件中调用 `next(err)` 并传入一个错误对象时,Express 会非常智能地跳过所有常规中间件,直接把控制权传递给这个错误处理中间件。它就像你应用的“急诊室”,专门处理各种突发状况,保证你的应用程序不会因为一个未捕获的错误而“崩溃”。 **基本结构(再回忆一下):** ```javascript // app.js 或者专门的 error-handler.js 文件 app.use((err, req, res, next) => { // 1. 记录错误 (非常重要!生产环境不能只打印到控制台,要写入日志文件!) console.error('哎呀!捕获到错误了!', err.stack); // 打印错误堆栈到服务器控制台 // 2. 设置 HTTP 状态码 (从错误对象中获取,或默认 500) const statusCode = err.statusCode || 500; const status = err.status || 'error'; // 自定义错误状态 // 3. 发送错误响应给客户端 (根据环境决定是否暴露细节) res.status(statusCode).json({ status: status, message: err.message || '服务器内部错误,请稍后再试。' // 在开发环境可以加上堆栈信息,生产环境千万不能加! // stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); // 错误处理中间件通常是请求处理的终点,所以不需要调用 next() }); ``` --- #### **17.3 最佳实践:集中式错误处理 — 让你的错误处理‘有章可循’!** 为了使你的错误处理代码更具可维护性、可扩展性,并且在不同环境中返回统一且恰当的错误响应,强烈推荐采用**集中式错误处理**!这就像你给每个错误都贴上“标签”,然后把它们都送到一个“中央处理中心”去管理。 1. **自定义错误类:** * 这是集中式错误处理的基石!创建一个自定义的错误类,让它继承自 JavaScript 内置的 `Error` 类。在这个自定义错误类中,你可以添加一些额外的属性,比如 `statusCode` (HTTP 状态码) 和 `isOperational`。 * `isOperational` 是一个非常棒的概念!它用来区分两种错误: * **操作性错误 (Operational Errors)**:这些是我们可以预期的、可处理的、与程序操作相关的错误。比如:用户输入数据验证失败(`400 Bad Request`)、资源未找到(`404 Not Found`)、用户未授权(`401 Unauthorized`)等等。这些错误需要给客户端返回明确的、用户友好的信息。 * **编程错误 (Programming Errors)**:这些是未预期的、程序本身的 Bug!比如:代码里调用了未定义的变量、数据库连接断了(因为代码没处理好重连)、某个函数参数传错了等等。这些错误是程序内部的问题,通常不应该直接暴露给客户端,只需要在服务器日志中详细记录。 * **`utils/AppError.js` 示例:** ```javascript // utils/AppError.js (在你的项目根目录创建一个 utils 文件夹,然后在里面创建 AppError.js) class AppError extends Error { constructor(message, statusCode) { super(message); // 调用父类 Error 的构造函数,设置错误消息 this.statusCode = statusCode; // HTTP 状态码,比如 400, 404, 500 // 根据状态码判断是“失败”还是“错误”(通常 4xx 算 fail,5xx 算 error) this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; // 标记这是一个操作性错误 // 捕获堆栈信息,方便调试。第二个参数是当前构造函数,可以避免它出现在堆栈中 Error.captureStackTrace(this, this.constructor); } } module.exports = AppError; ``` 2. **统一的错误处理中间件:** * 咱们把这个复杂的错误处理逻辑,统一放在一个专门的中间件文件里。这个中间件会接收到所有 `next(err)` 传过来的错误。 * 它会根据错误类型(比如 `CastError`, `ValidationError`, 唯一性错误,JWT 错误)和 `NODE_ENV` 环境变量(`development` 开发环境 或 `production` 生产环境),返回不同的、友好的错误响应。 * **`middleware/errorHandler.js` 示例:** ```javascript // middleware/errorHandler.js (在你的项目根目录创建一个 middleware 文件夹,在里面创建 errorHandler.js) const AppError = require('../utils/AppError'); // 引入咱们自定义的 AppError // 处理 MongoDB 的 CastError (比如无效的 ID 格式) const handleCastErrorDB = err => { const message = `无效的 ${err.path}: ${err.value}。请检查您提供的 ID 或值。`; return new AppError(message, 400); // 包装成 400 Bad Request }; // 处理 MongoDB 的重复字段错误 (错误码 11000,比如邮箱唯一性约束冲突) const handleDuplicateFieldsDB = err => { // 正则表达式从错误信息中提取重复的值 const value = err.errmsg.match(/(["'])(\\?.)*?\1/)[0]; const message = `重复的字段值: ${value}。该值已被占用,请使用另一个值!`; return new AppError(message, 409); // 409 Conflict }; // 处理 Mongoose 的验证错误 (Schema 验证失败) const handleValidationErrorDB = err => { // 遍历 errors 对象,提取所有验证错误消息 const errors = Object.values(err.errors).map(el => el.message); const message = `无效的输入数据: ${errors.join('. ')}`; return new AppError(message, 400); // 400 Bad Request }; // 处理 JWT 令牌验证失败错误 const handleJWTError = () => new AppError('无效的令牌。请重新登录!', 401); // 401 Unauthorized // 处理 JWT 令牌过期错误 const handleJWTExpiredError = () => new AppError('您的令牌已过期!请重新登录。', 401); // 401 Unauthorized // 开发环境的错误响应:暴露所有错误细节,方便调试 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 }); // 编程或其他未知错误 (不可预期的 Bug) } else { // 1) 打印到控制台/日志文件 (非常重要!) console.error('ERROR 💥', err); // 2) 发送通用错误消息给客户端 res.status(500).json({ status: 'error', message: '出错了!服务器内部出现未知错误,请稍后再试。' }); } }; // 导出这个全局错误处理中间件 module.exports = (err, req, res, next) => { // 确保错误对象有状态码和状态属性 err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; // 根据 NODE_ENV 环境变量判断是开发环境还是生产环境 if (process.env.NODE_ENV === 'development') { sendErrorDev(err, res); } else if (process.env.NODE_ENV === 'production') { // 在生产环境中,我们需要对一些 Mongoose/MongoDB 错误进行“包装”, // 让它们变成我们自定义的 AppError,以便统一处理和返回用户友好的信息 let error = { ...err }; // 创建错误副本,避免直接修改原始错误对象 error.message = err.message; // 确保 message 属性被复制,因为展开运算符可能不会复制原型链上的属性 if (error.name === 'CastError') error = handleCastErrorDB(error); // MongoDB 唯一性错误在 Mongoose v6.x 以后可能不再直接返回 err.code === 11000 // 而是被 Mongoose 封装为 ValidationError 或其他特定错误。 // 假设我们这里仍然能通过 err.code 捕获 if (error.code === 11000) error = handleDuplicateFieldsDB(error); if (error.name === 'ValidationError') error = handleValidationErrorDB(error); if (error.name === 'JsonWebTokenError') error = handleJWTError(); if (error.name === 'TokenExpiredError') error = handleJWTExpiredError(); sendErrorProd(error, res); } }; ``` 3. **在路由中使用 `next(new AppError(...))`:** * 在你的 Express 路由处理函数中,当你遇到操作性错误时,直接创建 `AppError` 实例并通过 `next()` 传递。 * 对于那些你无法预料的编程错误(比如数据库连接突然断了),直接 `next(err)` 传递原始错误即可,全局错误处理中间件会帮你处理。 * **`app.js` 示例(修改之前的 API 路由):** ```javascript const AppError = require('./utils/AppError'); // 引入自定义 AppError const globalErrorHandler = require('./middleware/errorHandler'); // 引入全局错误处理中间件 // ... (app.js 的其他配置,连接数据库,中间件等) // GET /api/users/:id - 获取单个用户 (使用 AppError) app.get('/api/users/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); if (!user) { // 如果用户未找到,这是一个操作性错误,用 AppError 包装 return next(new AppError('用户未找到,请检查 ID 是否正确。', 404)); } res.status(200).json(user); } catch (err) { // 任何 Mongoose 抛出的编程错误(如 CastError),直接传递给全局错误处理中间件 next(err); } }); // POST /api/users - 创建新用户 (使用 AppError 和 next(err)) app.post('/api/users', async (req, res, next) => { const { name, email, password, age } = req.body; const newUser = new User({ name, email, password, age }); try { const savedUser = await newUser.save(); res.status(201).json(savedUser); } catch (err) { // 所有 Mongoose 相关的验证和唯一性错误,都被 handleValidationErrorDB 和 handleDuplicateFieldsDB 处理 // 所以这里直接 next(err) 即可 next(err); } }); // ... (其他路由和认证路由也要进行类似修改,将捕获到的错误通过 next(err) 传递) // IMPORTANT: 全局错误处理中间件必须放在所有路由和 app.use() 之后! app.use(globalErrorHandler); // 引入全局错误处理中间件 ``` 4. **捕获未处理的拒绝 (Unhandled Rejection) 和异常 (Uncaught Exception):** * 这是 Node.js 进程的**最后一道防线!** 有时候,异步 Promise 可能会被拒绝,但你没有 `.catch()` 它;或者有同步代码抛出了错误,但没有 `try...catch` 捕获。这些错误会冒泡到 Node.js 进程层面。 * 如果不处理它们,Node.js 进程会直接**崩溃!** * `process` 对象提供了一些事件来捕获这些“终极”错误。通常,当这些错误发生时,你记录日志,然后优雅地关闭服务器,并退出进程。 * **在 `app.js` 的最顶部(或者一个专门的启动文件)添加:** ```javascript // 在所有代码执行之前,监听未捕获的异常 // 这是同步代码的最后一道防线,任何没被 try...catch 捕获的同步错误都会在这里触发 process.on('uncaughtException', err => { console.error('UNCAUGHT EXCEPTION! 💥 程序即将关闭...'); console.error(err.name, err.message, err.stack); // 优雅地关闭服务器,然后退出进程 // 理论上,uncaughtException 发生后,进程处于不确定状态,不建议继续运行 process.exit(1); // 返回非零状态码表示异常退出 }); // ... app.js 的其余代码 const server = app.listen(PORT, () => { // 确保 app.listen 返回一个 server 实例 console.log(`Server running on port ${PORT}`); }); // 监听未处理的 Promise 拒绝 // 这是异步 Promise 的最后一道防线,任何没有 .catch() 的 Promise 拒绝都会在这里触发 process.on('unhandledRejection', err => { console.error('UNHANDLED REJECTION! 💥 程序即将关闭...'); console.error(err.name, err.message); // 优雅地关闭服务器,然后退出进程 server.close(() => { // 先关闭 Express 服务器,让所有正在处理的请求完成 process.exit(1); // 返回非零状态码表示异常退出 }); }); ``` **运行测试:** * 要测试 `uncaughtException`:在某个路由里直接写 `console.log(undefinedVar.prop);` (不加 `try...catch`)。 * 要测试 `unhandledRejection`:在一个路由里直接 `Promise.reject('我是未处理的拒绝!');` (不加 `.catch()`)。 --- #### **17.4 日志记录:使用 `console` 或第三方库(如 Winston)— 应用程序的‘黑匣子’!** 日志记录就像应用程序的“黑匣子”,它记录了应用程序运行时的重要事件、警告、错误等信息。在开发和生产环境中,日志都是排查问题、监控应用状态的不可或缺的工具。 **1. 使用 `console`:** * **优点:** 简单粗暴,开箱即用,你在开发时经常用 `console.log` 来调试。 * **缺点:** * **无日志级别:** 无法区分 `info` (信息), `warn` (警告), `error` (错误), `debug` (调试) 等不同重要性的日志。 * **无文件输出:** 默认只输出到控制台。生产环境里,你需要把日志写入文件,甚至发送到日志管理平台。 * **无日志轮转:** 如果日志一直写到一个文件里,文件会无限增长,最终撑爆硬盘。 * **无结构化日志:** 默认是纯文本,难以被日志分析工具(如 ELK Stack)解析和聚合。 * **性能:** 大量的 `console.log` 调用会阻塞 Node.js 的 I/O,影响应用程序性能! **2. 使用第三方库 (如 Winston):** * **Winston** 是 Node.js 中最流行和强大的日志库之一。它提供了超灵活的日志级别、多种传输方式(把日志发送到控制台、文件、数据库、远程服务等),以及丰富的格式化选项。 * **安装:** `npm install winston` * **基本使用示例:** ```javascript // logger.js (创建一个专门的日志模块) const winston = require('winston'); // 创建一个 logger 实例 const logger = winston.createLogger({ level: 'info', // 默认日志级别:只记录 info 级别及以上的日志 (warn, error) // 定义日志格式 format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // 添加时间戳 winston.format.errors({ stack: true }), // 如果是 Error 对象,自动包含错误堆栈 winston.format.json() // 输出 JSON 格式的日志,便于日志分析工具解析 ), // 定义日志的“传输器”(Transports),也就是日志要发到哪里去 transports: [ // 输出到控制台 (通常用于开发环境) new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), // 控制台输出带颜色,更醒目 winston.format.simple() // 简洁格式 ) }), // 将错误日志写入文件 (只记录 error 级别) new winston.transports.File({ filename: 'error.log', level: 'error', maxsize: 5 * 1024 * 1024, maxFiles: 5 }), // 5MB,最多5个文件,自动轮转 // 将所有级别日志写入文件 new winston.transports.File({ filename: 'combined.log', maxsize: 10 * 1024 * 1024, maxFiles: 3 }) // 10MB,最多3个文件,自动轮转 ], // 额外的:捕获未处理的异常和 Promise 拒绝 (让 Winston 来处理,而不是 process.on) exceptionHandlers: [ new winston.transports.File({ filename: 'exceptions.log' }) ], rejectionHandlers: [ new winston.transports.File({ filename: 'rejections.log' }) ] }); // 如果是开发环境,可以额外加一个更友好的控制台输出 if (process.env.NODE_ENV !== 'production') { // 避免重复添加 Console transport const consoleTransport = logger.transports.find(t => t instanceof winston.transports.Console); if (consoleTransport) { consoleTransport.format = winston.format.combine( winston.format.colorize(), winston.format.simple() ); } else { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } } module.exports = logger; ``` * **在 `app.js` 或其他模块中使用:** ```javascript const logger = require('./logger'); // 引入 logger 模块 // ... // 可以在任何地方使用 logger app.get('/', (req, res) => { logger.info('收到首页请求。'); // 记录 info 级别日志 res.send('Hello World!'); }); app.post('/data', (req, res) => { logger.warn('收到 POST 请求,但数据处理逻辑未实现。'); // 记录 warn 级别日志 res.send('Data received.'); }); // 在你的全局错误处理中间件中使用 logger 来记录错误 app.use((err, req, res, next) => { // 使用 logger.error 记录详细错误信息,并可以附带额外的元数据 logger.error(`请求错误: ${err.message}`, { method: req.method, url: req.originalUrl, ip: req.ip, stack: err.stack, // 确保记录堆栈 // otherMeta: 'any_other_relevant_data' }); // ... 其他错误处理逻辑 (如 sendErrorProd/Dev) }); // 如果你选择让 Winston 处理全局异常和拒绝,可以移除 app.js 中原有的 process.on 监听 // process.on('uncaughtException', (ex) => { logger.error('...'); process.exit(1); }); // process.on('unhandledRejection', (ex) => { logger.error('...'); server.close(() => process.exit(1)); }); ``` * **Winston 的优点:** * **日志级别:** 精细控制输出哪些日志,方便过滤。 * **传输器 (Transports):** 可以同时将日志发送到多个目的地(控制台、文件、数据库、甚至远程日志服务)。 * **格式化:** 支持 JSON、文本等多种格式,JSON 格式便于机器解析和日志分析工具处理。 * **错误堆栈:** 自动包含错误堆栈信息,方便定位问题。 * **日志轮转:** 自动管理日志文件大小和数量,防止硬盘被撑爆。 * **性能:** 异步写入,对应用程序性能影响小。 * **可扩展性:** 丰富的插件和活跃的社区支持。 --- ### **第 18 节:实时应用:WebSockets 与 Socket.IO — 让你的应用瞬间‘活’起来!** **好了,同学们,咱们的 API 已经很棒了!但是,它还是基于传统的 HTTP 请求-响应模型:** 客户端发请求,服务器响应,然后连接关闭。如果你想搞一个在线聊天室、多人游戏、实时股票报价或者实时通知系统,这种“一问一答”的模式就显得非常低效了!客户端不得不频繁地“轮询”(不断发请求问服务器有没有新消息),这会消耗大量资源,而且延迟高。 这时候,咱们就需要更高级的“沟通方式”——**WebSockets**,以及它的“增强版”——**Socket.IO**! --- #### **18.1 什么是 WebSockets?与 HTTP 的区别。** **1. HTTP (Hypertext Transfer Protocol) — 传统的‘一问一答’!** * **请求-响应模型:** 客户端发起请求,服务器给出响应。 * **无状态:** 服务器不保留客户端的会话信息(除非用 Cookie 或 Session),每次请求都是独立的。 * **短连接:** 每次请求通常会建立新的连接,或者在短时间内保持连接(HTTP/1.1 的 Keep-Alive),然后连接就关闭了。 * **单向通信:** 通信通常由客户端发起。 * **头部开销大:** 每次请求和响应都包含完整的 HTTP 头部信息,数据量小时开销相对较大。 * **适用场景:** 传统的网页浏览、RESTful API 调用、文件下载等。 **2. WebSockets — 真正的‘双向对话’!** * **全双工通信:** **这是它最核心的特点!** 客户端和服务器可以**同时发送和接收数据**,就像你和朋友打电话,可以同时说话,也可以同时听。 * **持久连接:** 一旦 WebSockets 连接建立成功,它会一直保持开放(只要客户端和服务器不主动关闭),直到一方关闭连接。 * **有状态:** 连接建立后,客户端和服务器都保留着这个连接的状态。 * **双向通信:** 客户端和服务器都可以**主动**发起数据传输,不再是“你问我答”模式了。 * **低延迟:** 握手成功后,数据传输不再需要每次都带上完整的 HTTP 头部,只需要传输轻量级的数据帧,所以延迟非常低! * **协议升级:** WebSockets 连接是通过 HTTP 握手开始的!客户端发送一个特殊的 HTTP 请求(包含 `Upgrade: websocket` 头),服务器如果同意,就会把这个 HTTP 连接“升级”到 WebSocket 协议。 * **适用场景:** 聊天应用、在线游戏、实时股票报价、协作文档、即时通知系统等所有需要低延迟、高频率双向通信的场景。 **核心区别总结(记住这张表!):** | 特性 | HTTP (请求-响应) | WebSockets (全双工) | | :--------- | :------------------------------- | :------------------------------- | | **通信模式** | 请求-响应 (客户端发起,单向) | 全双工 (客户端/服务器均可发起,双向) | | **连接** | 短连接 (每次请求或短时保持) | 持久连接 (一次握手,长期开放) | | **状态** | 无状态 | 有状态 | | **开销** | 每次请求头部开销大 | 握手后数据帧开销小 | | **延迟** | 相对较高 (每次请求都需要建立连接/重用连接) | 极低 (连接已建立) | | **适用** | 传统网页、REST API | 实时应用、聊天、游戏、通知 | --- #### **18.2 Socket.IO 简介与安装 — WebSocket 的‘增强版’!** **虽然原生 WebSockets 已经很强大了,但直接用它写代码还是有点麻烦。** 而且,它还有一个问题:有些老旧的浏览器或者在某些复杂的网络环境下(比如代理服务器),可能不支持 WebSockets。 **这时候,咱们的“超级英雄”——Socket.IO 就登场了!** **什么是 Socket.IO?** Socket.IO 是一个基于 WebSockets 的 JavaScript 库,它使得实时、双向、基于事件的通信在 Web 客户端和服务器之间变得非常简单和可靠! **为什么选择 Socket.IO 而不是原生 WebSockets?** * **自动回退 (Fallback):** 这是 Socket.IO 的杀手锏!如果客户端或服务器不支持 WebSockets,Socket.IO 会**自动**降级到其他实时通信技术(比如长轮询、Flash Socket 等),确保在各种浏览器和网络环境下都能工作,提供最大程度的兼容性!你不用操心兼容性问题,它帮你全搞定! * **自动重连:** 当网络连接不稳定导致连接断开时,Socket.IO 客户端会自动尝试重新连接服务器,并且在重连后恢复之前的状态。这个非常实用,增强了用户体验。 * **事件驱动:** 它提供了非常简单的 `emit` (发送事件) 和 `on` (监听事件) API,让你在客户端和服务器之间进行通信,就像使用 `EventEmitter` 一样简单和直观。 * **房间 (Rooms) 和命名空间 (Namespaces):** 方便地组织和管理连接。你可以轻松地实现群聊、私聊、特定主题的通知等功能。比如,你可以把所有在“A房间”的用户放到一个“房间”里,只给这个房间发消息。 * **广播 (Broadcasting):** 轻松向所有连接的客户端,或者特定房间里的所有客户端发送消息。 * **心跳机制:** 自动发送心跳包,检测连接是否存活,防止“假死”连接。 **安装 Socket.IO:** Socket.IO 包含两个部分:服务器端库(Node.js 用)和客户端库(浏览器用)。 1. **服务器端 (Node.js):** ```bash npm install socket.io ``` 2. **客户端 (浏览器):** * **最方便的方式:通过服务器提供。** 当 Socket.IO 服务器启动后,它会自动在你的 Express 应用的 `/socket.io/socket.io.js` 路径提供客户端库。你只需要在 HTML 文件里像这样引入就行: ```html <script src="/socket.io/socket.io.js"></script> ``` * 或者,如果你是前端项目,也可以通过 npm 安装并在你的前端打包工具(如 Webpack)中使用: ```bash npm install socket.io-client ``` --- #### **18.3 构建一个简单的聊天室应用:实时消息发送与接收 — 你的第一个‘实时’应用!** 咱们来动手搭建一个非常非常简单的实时聊天室!用户可以在网页上输入消息,所有连接到服务器的用户都能实时看到这些消息。 **文件结构(非常简洁!):** ``` chat-app/ ├── server.js # Node.js 服务器端代码 └── public/ └── index.html # 客户端 HTML 页面 ``` **1. 服务器端 (`server.js`):** ```javascript const express = require('express'); const http = require('http'); // Node.js 内置的 http 模块,Express 应用底层也用它 const { Server } = require('socket.io'); // 引入 Socket.IO 的 Server 类 const app = express(); // 创建一个 HTTP 服务器,并把 Express 应用作为其请求处理函数 const server = http.createServer(app); // 将 Socket.IO 绑定到这个 HTTP 服务器上,这样 Socket.IO 就能接管 WebSocket 握手了 const io = new Server(server); const PORT = process.env.PORT || 3000; // 提供静态文件服务:将 public 目录下的文件暴露给客户端访问 // 这样客户端就可以访问 public/index.html 了 app.use(express.static('public')); // 当有客户端通过 Socket.IO 连接到服务器时,会触发 'connection' 事件 io.on('connection', (socket) => { // socket 参数代表连接到服务器的这个具体客户端 console.log('一个用户连接了:', socket.id); // socket.id 是每个客户端连接的唯一 ID // 监听客户端发送的名为 'chat message' 的事件 socket.on('chat message', (msg) => { console.log(`收到消息来自 ${socket.id}: ${msg}`); // 将收到的消息广播给所有(包括发送者自己)连接的客户端 // io.emit() 会向所有连接的客户端发送事件 io.emit('chat message', msg); }); // 监听客户端断开连接事件 socket.on('disconnect', () => { console.log('一个用户断开连接了:', socket.id); }); }); // 启动 HTTP 服务器,监听指定端口 server.listen(PORT, () => { console.log(`聊天服务器运行在 http://localhost:${PORT}`); console.log('请在浏览器中打开 http://localhost:3000,多开几个窗口试试!'); }); ``` **2. 客户端 (`public/index.html`):** ```html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>简单的聊天室</title> <style> /* 一些简单的 CSS 样式,让聊天室看起来舒服点 */ body { margin: 0; padding-bottom: 3rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } #form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); } #input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; } #input:focus { outline: none; } #form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; } #messages { list-style-type: none; margin: 0; padding: 0; } #messages > li { padding: 0.5rem 1rem; } #messages > li:nth-child(odd) { background: #eee; } </style> </head> <body> <ul id="messages"></ul> <form id="form" action=""> <input id="input" autocomplete="off" /><button>发送</button> </form> <!-- 引入 Socket.IO 客户端库 --> <!-- 注意:这里的 /socket.io/socket.io.js 是由你的 Socket.IO 服务器自动提供的! --> <script src="/socket.io/socket.io.js"></script> <script> // 连接到 Socket.IO 服务器 // io() 会自动尝试连接到当前页面的服务器 const socket = io(); const form = document.getElementById('form'); const input = document.getElementById('input'); const messages = document.getElementById('messages'); // 监听表单提交事件 form.addEventListener('submit', (e) => { e.preventDefault(); // 阻止表单的默认提交行为(通常会导致页面刷新) if (input.value) { // 向服务器发送名为 'chat message' 的事件和消息内容 socket.emit('chat message', input.value); input.value = ''; // 清空输入框 } }); // 监听服务器发送的名为 'chat message' 的事件 socket.on('chat message', (msg) => { const item = document.createElement('li'); item.textContent = msg; // 将消息添加到列表中 messages.appendChild(item); window.scrollTo(0, document.body.scrollHeight); // 自动滚动到最新消息 }); // 监听连接事件 (可选,用于调试) socket.on('connect', () => { console.log('已连接到服务器,Socket ID:', socket.id); }); // 监听断开连接事件 (可选,用于调试) socket.on('disconnect', () => { console.log('已断开与服务器的连接'); }); </script> </body> </html> ``` **运行和测试:** 1. **创建项目目录和文件:** * 创建一个新文件夹,比如叫 `chat-app`。 * 在 `chat-app` 目录下运行 `npm init -y`。 * 安装依赖:`npm install express socket.io`。 * 创建 `server.js` 文件,并粘贴服务器端代码。 * 创建 `public` 目录,并在其中创建 `index.html` 文件,粘贴客户端代码。 2. **启动服务器:** * 在终端中进入 `chat-app` 目录,运行 `node server.js`。 3. **打开客户端:** * 打开你的浏览器,访问 `http://localhost:3000`。 * **关键步骤:多打开几个浏览器标签页或窗口**,访问同一个地址! 4. **开始聊天:** * 在任何一个窗口的输入框中输入消息,然后点击“发送”按钮。 * 你将看到:你的消息不仅会显示在你当前发送的窗口,还会**实时同步**到所有其他打开的聊天室窗口中! 是不是很酷?!这就是实时通信的魅力!你已经用 Node.js 搭建了一个最简单的实时聊天室! --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * 深入理解了 Node.js 中**错误处理的各种策略**,包括同步/异步错误的捕获,以及如何构建健壮的**集中式错误处理中间件**。 * 学会了如何使用强大的 **Winston 库进行日志记录**,让你的应用拥有专业的“黑匣子”。 * **最重要的是,你掌握了实时通信的核心——WebSockets 的概念及其与 HTTP 的区别**,并且使用**Socket.IO** 这个“超级工具”,成功构建了一个能够进行**实时消息发送与接收**的聊天室应用! 你现在已经具备了构建更健壮、更专业的 Node.js 应用的能力,并且迈出了实时通信应用开发的第一步! **下节预告:** 接下来,咱们继续探索 Node.js 的“十八般武艺”!下一节,咱们将学习如何利用 Node.js 强大的能力来开发**命令行工具 (CLI Tools)**,以及如何管理你的 Node.js 进程。这能让你用 JavaScript 搞定更多“幕后”的自动化任务!准备好了吗?咱们下节课,不见不散! 好的,同学们,欢迎回来! 恭喜你!你已经顺利完成了 Node.js 学习的第三阶段,现在你已经能够构建一个功能强大、设计规范、具备认证和数据管理能力的 RESTful API 了!是不是感觉自己离“全栈”又近了一大步? 现在,咱们要进入**第四阶段:高级概念与实战应用**!这个阶段就像是给你的“武功”加上更多“ Buff”:让你的应用更健壮、更稳定、更快,还能玩转实时通信和命令行工具。最后,咱们还要把你的“作品”真正推向世界——部署上线并进行性能优化!这是从“大侠”到“宗师”的进阶之路,知识密度有点大,但含金量绝对高! --- ### **第四阶段:高级概念与实战应用 (约4节) — 从“大侠”到“宗师”的进阶之路!** **学习目标:** 在本阶段,你将学习如何构建更健壮、更可靠的 Node.js 应用程序,包括高级错误处理和日志记录。你将探索实时通信的世界(WebSockets & Socket.IO)。你还将了解 Node.js 在命令行工具开发中的应用。最后,你将掌握将 Node.js 应用部署到生产环境的基本策略和简单的性能优化技巧。 --- #### **第 17 节:错误处理与日志 — 让你的应用‘会说话’,‘不怕错’!** **各位老铁,同学们好啊!** 咱们前面在写 Express 路由的时候,已经初步做了错误处理和打印 `console.error`。但那只是“小打小闹”!在任何一个真正的、健壮的应用程序中,有效的**错误处理**和**日志记录**都是至关重要的!它们就像你应用的“侦察兵”和“急诊医生”:帮助你及时发现问题、诊断问题,并快速解决问题! --- #### **17.1 同步与异步错误的捕获 — 错误‘出没’,请注意!** 理解 Node.js 中错误是如何“发生”以及如何“捕获”的,是正确处理错误的基础。 1. **同步错误捕获 (`try...catch`):** * 这个咱们老熟人了!对于同步执行的代码,如果它可能抛出错误,你直接用标准的 `try...catch` 块就能把它“逮住”。 * **示例:** ```javascript function riskySyncOperation() { if (Math.random() > 0.5) { throw new Error('同步操作失败了!'); // 同步抛出错误 } return '同步操作成功!'; } try { const result = riskySyncOperation(); console.log(result); } catch (error) { console.error('捕获到同步错误:', error.message); } ``` 2. **异步错误捕获:** * **回调函数模式 (Callback Pattern):** * 传统的 Node.js 异步操作(比如 `fs.readFile` 的回调)通常把错误作为回调函数的第一个参数(`err, data`)。 * **敲黑板!划重点!`try...catch` 无法直接捕获回调函数内部的异步错误!** 因为当回调函数执行时,外层的 `try...catch` 块早已经执行完了,调用栈都清空了! * **错误的方式(千万别这么写!):** ```javascript // ❌ 这种写法是错的!外层的 try...catch 无法捕获异步回调里的错误! // try { // fs.readFile('/nonexistent-file', (err, data) => { // if (err) throw err; // 这个 throw 会导致程序崩溃,因为它不在 try...catch 里面! // console.log(data); // }); // } catch (error) { // console.error('这个 catch 不会执行到!'); // } ``` * **正确的方式(在回调函数内部处理错误):** ```javascript const fs = require('fs'); fs.readFile('nonexistent-file.txt', (err, data) => { if (err) { // 必须在回调函数内部判断错误 console.error('捕获到异步回调错误:', err.message); return; } console.log(data); }); ``` * **Promise 和 `async/await`:** * **这是现代 Node.js 处理异步错误的推荐方式!** 它们使得异步错误处理与同步错误处理非常相似。 * **Promise:** 使用 `.catch()` 方法。任何 Promise 链中发生的错误,只要链末尾有 `.catch()`,都能被捕获。 * **`async/await`:** 结合 `try...catch`。这是最推荐的方式! * **示例:** ```javascript function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const success = Math.random() > 0.5; if (success) { resolve('数据获取成功'); } else { reject(new Error('数据获取失败了!')); } }, 500); }); } // Promise 方式 fetchData() .then(data => console.log(data)) .catch(error => console.error('捕获到 Promise 错误:', error.message)); // async/await 方式 (推荐在 Express 路由处理函数中使用) // 注意:下面的 app.get 只是一个示例,假设你已经配置了 Express // app.get('/async-test', async (req, res, next) => { // try { // const data = await fetchData(); // res.send(data); // } catch (error) { // // 如果这里捕获到错误,通常会用 next(error) 传递给 Express 的全局错误处理中间件 // next(error); // } // }); ``` * **Express.js 中的 `next(err)`:** * 在 Express.js 中,当你自定义的路由或中间件中捕获到错误时,你通常不会直接 `res.status().send()`,而是使用 `next(error)` 将错误传递给**下一个错误处理中间件**。 * 对于 `async` 路由处理函数,如果 `await` 的 Promise 被拒绝了,Express (从 Node.js 12+ 开始) 会自动捕获这个拒绝,并将其传递给错误处理中间件。所以,有时候你可以省略 `try...catch`,但显式使用 `try...catch` 并调用 `next(error)` 仍然是**良好的实践**,因为它允许你在传递错误之前进行一些预处理或包装。 --- #### **17.2 Express.js 错误处理中间件 — 你的应用‘急诊室’!** 咱们前面已经接触过 Express.js 的错误处理中间件了,这里再强调一下它的特点: * **函数签名特殊:** 它必须有四个参数:`(err, req, res, next)`。 * **位置特殊:** 它必须定义在**所有其他路由和常规中间件的后面**! * **工作机制:** 当任何路由或中间件中调用 `next(err)` 并传入一个错误对象时,Express 会非常智能地跳过所有常规中间件,直接把控制权传递给这个错误处理中间件。它就像你应用的“急诊室”,专门处理各种突发状况,保证你的应用程序不会因为一个未捕获的错误而“崩溃”。 **基本结构(再回忆一下):** ```javascript // app.js 或者专门的 error-handler.js 文件 app.use((err, req, res, next) => { // 1. 记录错误 (非常重要!生产环境不能只打印到控制台,要写入日志文件!) console.error('哎呀!捕获到错误了!', err.stack); // 打印错误堆栈到服务器控制台 // 2. 设置 HTTP 状态码 (从错误对象中获取,或默认 500) const statusCode = err.statusCode || 500; const status = err.status || 'error'; // 自定义错误状态 // 3. 发送错误响应给客户端 (根据环境决定是否暴露细节) res.status(statusCode).json({ status: status, message: err.message || '服务器内部错误,请稍后再试。' // 在开发环境可以加上堆栈信息,生产环境千万不能加! // stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); // 错误处理中间件通常是请求处理的终点,所以不需要调用 next() }); ``` --- #### **17.3 最佳实践:集中式错误处理 — 让你的错误处理‘有章可循’!** 为了使你的错误处理代码更具可维护性、可扩展性,并且在不同环境中返回统一且恰当的错误响应,强烈推荐采用**集中式错误处理**!这就像你给每个错误都贴上“标签”,然后把它们都送到一个“中央处理中心”去管理。 1. **自定义错误类:** * 这是集中式错误处理的基石!创建一个自定义的错误类,让它继承自 JavaScript 内置的 `Error` 类。在这个自定义错误类中,你可以添加一些额外的属性,比如 `statusCode` (HTTP 状态码) 和 `isOperational`。 * `isOperational` 是一个非常棒的概念!它用来区分两种错误: * **操作性错误 (Operational Errors)**:这些是我们可以预期的、可处理的、与程序操作相关的错误。比如:用户输入数据验证失败(`400 Bad Request`)、资源未找到(`404 Not Found`)、用户未授权(`401 Unauthorized`)等等。这些错误需要给客户端返回明确的、用户友好的信息。 * **编程错误 (Programming Errors)**:这些是未预期的、程序本身的 Bug!比如:代码里调用了未定义的变量、数据库连接断了(因为代码没处理好重连)、某个函数参数传错了等等。这些错误是程序内部的问题,通常不应该直接暴露给客户端,只需要在服务器日志中详细记录。 * **`utils/AppError.js` 示例:** ```javascript // utils/AppError.js (在你的项目根目录创建一个 utils 文件夹,然后在里面创建 AppError.js) class AppError extends Error { constructor(message, statusCode) { super(message); // 调用父类 Error 的构造函数,设置错误消息 this.statusCode = statusCode; // HTTP 状态码,比如 400, 404, 500 // 根据状态码判断是“失败”还是“错误”(通常 4xx 算 fail,5xx 算 error) this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; // 标记这是一个操作性错误 // 捕获堆栈信息,方便调试。第二个参数是当前构造函数,可以避免它出现在堆栈中 Error.captureStackTrace(this, this.constructor); } } module.exports = AppError; ``` 2. **统一的错误处理中间件:** * 咱们把这个复杂的错误处理逻辑,统一放在一个专门的中间件文件里。这个中间件会接收到所有 `next(err)` 传过来的错误。 * 它会根据错误类型(比如 `CastError`, `ValidationError`, 唯一性错误,JWT 错误)和 `NODE_ENV` 环境变量(`development` 开发环境 或 `production` 生产环境),返回不同的、友好的错误响应。 * **`middleware/errorHandler.js` 示例:** ```javascript // middleware/errorHandler.js (在你的项目根目录创建一个 middleware 文件夹,在里面创建 errorHandler.js) const AppError = require('../utils/AppError'); // 引入咱们自定义的 AppError // 处理 MongoDB 的 CastError (比如无效的 ID 格式) const handleCastErrorDB = err => { const message = `无效的 ${err.path}: ${err.value}。请检查您提供的 ID 或值。`; return new AppError(message, 400); // 包装成 400 Bad Request }; // 处理 MongoDB 的重复字段错误 (错误码 11000,比如邮箱唯一性约束冲突) const handleDuplicateFieldsDB = err => { // 正则表达式从错误信息中提取重复的值 const value = err.errmsg.match(/(["'])(\\?.)*?\1/)[0]; const message = `重复的字段值: ${value}。该值已被占用,请使用另一个值!`; return new AppError(message, 409); // 409 Conflict }; // 处理 Mongoose 的验证错误 (Schema 验证失败) const handleValidationErrorDB = err => { // 遍历 errors 对象,提取所有验证错误消息 const errors = Object.values(err.errors).map(el => el.message); const message = `无效的输入数据: ${errors.join('. ')}`; return new AppError(message, 400); // 400 Bad Request }; // 处理 JWT 令牌验证失败错误 const handleJWTError = () => new AppError('无效的令牌。请重新登录!', 401); // 401 Unauthorized // 处理 JWT 令牌过期错误 const handleJWTExpiredError = () => new AppError('您的令牌已过期!请重新登录。', 401); // 401 Unauthorized // 开发环境的错误响应:暴露所有错误细节,方便调试 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 }); // 编程或其他未知错误 (不可预期的 Bug) } else { // 1) 打印到控制台/日志文件 (非常重要!) console.error('ERROR 💥', err); // 2) 发送通用错误消息给客户端 res.status(500).json({ status: 'error', message: '出错了!服务器内部出现未知错误,请稍后再试。' }); } }; // 导出这个全局错误处理中间件 module.exports = (err, req, res, next) => { // 确保错误对象有状态码和状态属性 err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; // 根据 NODE_ENV 环境变量判断是开发环境还是生产环境 if (process.env.NODE_ENV === 'development') { sendErrorDev(err, res); } else if (process.env.NODE_ENV === 'production') { // 在生产环境中,我们需要对一些 Mongoose/MongoDB 错误进行“包装”, // 让它们变成我们自定义的 AppError,以便统一处理和返回用户友好的信息 let error = { ...err }; // 创建错误副本,避免直接修改原始错误对象 error.message = err.message; // 确保 message 属性被复制,因为展开运算符可能不会复制原型链上的属性 if (error.name === 'CastError') error = handleCastErrorDB(error); // MongoDB 唯一性错误在 Mongoose v6.x 以后可能不再直接返回 err.code === 11000 // 而是被 Mongoose 封装为 ValidationError 或其他特定错误。 // 假设我们这里仍然能通过 err.code 捕获 if (error.code === 11000) error = handleDuplicateFieldsDB(error); if (error.name === 'ValidationError') error = handleValidationErrorDB(error); if (error.name === 'JsonWebTokenError') error = handleJWTError(); if (error.name === 'TokenExpiredError') error = handleJWTExpiredError(); sendErrorProd(error, res); } }; ``` 3. **在路由中使用 `next(new AppError(...))`:** * 在你的 Express 路由处理函数中,当你遇到操作性错误时,直接创建 `AppError` 实例并通过 `next()` 传递。 * 对于那些你无法预料的编程错误(比如数据库连接突然断了),直接 `next(err)` 传递原始错误即可,全局错误处理中间件会帮你处理。 * **`app.js` 示例(修改之前的 API 路由):** ```javascript const AppError = require('./utils/AppError'); // 引入自定义 AppError const globalErrorHandler = require('./middleware/errorHandler'); // 引入全局错误处理中间件 // ... (app.js 的其他配置,连接数据库,中间件等) // GET /api/users/:id - 获取单个用户 (使用 AppError) app.get('/api/users/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); if (!user) { // 如果用户未找到,这是一个操作性错误,用 AppError 包装 return next(new AppError('用户未找到,请检查 ID 是否正确。', 404)); } res.status(200).json(user); } catch (err) { // 任何 Mongoose 抛出的编程错误(如 CastError),直接传递给全局错误处理中间件 next(err); } }); // POST /api/users - 创建新用户 (使用 AppError 和 next(err)) app.post('/api/users', async (req, res, next) => { const { name, email, password, age } = req.body; const newUser = new User({ name, email, password, age }); try { const savedUser = await newUser.save(); res.status(201).json(savedUser); } catch (err) { // 所有 Mongoose 相关的验证和唯一性错误,都被 handleValidationErrorDB 和 handleDuplicateFieldsDB 处理 // 所以这里直接 next(err) 即可 next(err); } }); // ... (其他路由和认证路由也要进行类似修改,将捕获到的错误通过 next(err) 传递) // IMPORTANT: 全局错误处理中间件必须放在所有路由和 app.use() 之后! app.use(globalErrorHandler); // 引入全局错误处理中间件 ``` 4. **捕获未处理的拒绝 (Unhandled Rejection) 和异常 (Uncaught Exception):** * 这是 Node.js 进程的**最后一道防线!** 有时候,异步 Promise 可能会被拒绝,但你没有 `.catch()` 它;或者有同步代码抛出了错误,但没有 `try...catch` 捕获。这些错误会冒泡到 Node.js 进程层面。 * 如果不处理它们,Node.js 进程会直接**崩溃!** * `process` 对象提供了一些事件来捕获这些“终极”错误。通常,当这些错误发生时,你记录日志,然后优雅地关闭服务器,并退出进程。 * **在 `app.js` 的最顶部(或者一个专门的启动文件)添加:** ```javascript // 在所有代码执行之前,监听未捕获的异常 // 这是同步代码的最后一道防线,任何没被 try...catch 捕获的同步错误都会在这里触发 process.on('uncaughtException', err => { console.error('UNCAUGHT EXCEPTION! 💥 程序即将关闭...'); console.error(err.name, err.message, err.stack); // 优雅地关闭服务器,然后退出进程 // 理论上,uncaughtException 发生后,进程处于不确定状态,不建议继续运行 process.exit(1); // 返回非零状态码表示异常退出 }); // ... app.js 的其余代码 const server = app.listen(PORT, () => { // 确保 app.listen 返回一个 server 实例 console.log(`Server running on port ${PORT}`); }); // 监听未处理的 Promise 拒绝 // 这是异步 Promise 的最后一道防线,任何没有 .catch() 的 Promise 拒绝都会在这里触发 process.on('unhandledRejection', err => { console.error('UNHANDLED REJECTION! 💥 程序即将关闭...'); console.error(err.name, err.message); // 优雅地关闭服务器,然后退出进程 server.close(() => { // 先关闭 Express 服务器,让所有正在处理的请求完成 process.exit(1); // 返回非零状态码表示异常退出 }); }); ``` **运行测试:** * 要测试 `uncaughtException`:在某个路由里直接写 `console.log(undefinedVar.prop);` (不加 `try...catch`)。 * 要测试 `unhandledRejection`:在一个路由里直接 `Promise.reject('我是未处理的拒绝!');` (不加 `.catch()`)。 --- #### **17.4 日志记录:使用 `console` 或第三方库(如 Winston)— 应用程序的‘黑匣子’!** 日志记录就像应用程序的“黑匣子”,它记录了应用程序运行时的重要事件、警告、错误等信息。在开发和生产环境中,日志都是排查问题、监控应用状态的不可或缺的工具。 **1. 使用 `console`:** * **优点:** 简单粗暴,开箱即用,你在开发时经常用 `console.log` 来调试。 * **缺点:** * **无日志级别:** 无法区分 `info` (信息), `warn` (警告), `error` (错误), `debug` (调试) 等不同重要性的日志。 * **无文件输出:** 默认只输出到控制台。生产环境里,你需要把日志写入文件,甚至发送到日志管理平台。 * **无日志轮转:** 如果日志一直写到一个文件里,文件会无限增长,最终撑爆硬盘。 * **无结构化日志:** 默认是纯文本,难以被日志分析工具(如 ELK Stack)解析和聚合。 * **性能:** 大量的 `console.log` 调用会阻塞 Node.js 的 I/O,影响应用程序性能! **2. 使用第三方库 (如 Winston):** * **Winston** 是 Node.js 中最流行和强大的日志库之一。它提供了超灵活的日志级别、多种传输方式(把日志发送到控制台、文件、数据库、远程服务等),以及丰富的格式化选项。 * **安装:** `npm install winston` * **基本使用示例:** ```javascript // logger.js (创建一个专门的日志模块) const winston = require('winston'); // 创建一个 logger 实例 const logger = winston.createLogger({ level: 'info', // 默认日志级别:只记录 info 级别及以上的日志 (warn, error) // 定义日志格式 format: winston.format.combine( winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // 添加时间戳 winston.format.errors({ stack: true }), // 如果是 Error 对象,自动包含错误堆栈 winston.format.json() // 输出 JSON 格式的日志,便于日志分析工具解析 ), // 定义日志的“传输器”(Transports),也就是日志要发到哪里去 transports: [ // 输出到控制台 (通常用于开发环境) new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), // 控制台输出带颜色,更醒目 winston.format.simple() // 简洁格式 ) }), // 将错误日志写入文件 (只记录 error 级别) new winston.transports.File({ filename: 'error.log', level: 'error', maxsize: 5 * 1024 * 1024, maxFiles: 5 }), // 5MB,最多5个文件,自动轮转 // 将所有级别日志写入文件 new winston.transports.File({ filename: 'combined.log', maxsize: 10 * 1024 * 1024, maxFiles: 3 }) // 10MB,最多3个文件,自动轮转 ], // 额外的:捕获未处理的异常和 Promise 拒绝 (让 Winston 来处理,而不是 process.on) exceptionHandlers: [ new winston.transports.File({ filename: 'exceptions.log' }) ], rejectionHandlers: [ new winston.transports.File({ filename: 'rejections.log' }) ] }); // 如果是开发环境,可以额外加一个更友好的控制台输出 if (process.env.NODE_ENV !== 'production') { // 避免重复添加 Console transport const consoleTransport = logger.transports.find(t => t instanceof winston.transports.Console); if (consoleTransport) { consoleTransport.format = winston.format.combine( winston.format.colorize(), winston.format.simple() ); } else { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } } module.exports = logger; ``` * **在 `app.js` 或其他模块中使用:** ```javascript const logger = require('./logger'); // 引入 logger 模块 // ... // 可以在任何地方使用 logger app.get('/', (req, res) => { logger.info('收到首页请求。'); // 记录 info 级别日志 res.send('Hello World!'); }); app.post('/data', (req, res) => { logger.warn('收到 POST 请求,但数据处理逻辑未实现。'); // 记录 warn 级别日志 res.send('Data received.'); }); // 在你的全局错误处理中间件中使用 logger 来记录错误 app.use((err, req, res, next) => { // 使用 logger.error 记录详细错误信息,并可以附带额外的元数据 logger.error(`请求错误: ${err.message}`, { method: req.method, url: req.originalUrl, ip: req.ip, stack: err.stack, // 确保记录堆栈 // otherMeta: 'any_other_relevant_data' }); // ... 其他错误处理逻辑 (如 sendErrorProd/Dev) }); // 如果你选择让 Winston 处理全局异常和拒绝,可以移除 app.js 中原有的 process.on 监听 // process.on('uncaughtException', (ex) => { logger.error('...'); process.exit(1); }); // process.on('unhandledRejection', (ex) => { logger.error('...'); server.close(() => process.exit(1)); }); ``` * **Winston 的优点:** * **日志级别:** 精细控制输出哪些日志,方便过滤。 * **传输器 (Transports):** 可以同时将日志发送到多个目的地(控制台、文件、数据库、甚至远程日志服务)。 * **格式化:** 支持 JSON、文本等多种格式,JSON 格式便于机器解析和日志分析工具处理。 * **错误堆栈:** 自动包含错误堆栈信息,方便定位问题。 * **日志轮转:** 自动管理日志文件大小和数量,防止硬盘被撑爆。 * **性能:** 异步写入,对应用程序性能影响小。 * **可扩展性:** 丰富的插件和活跃的社区支持。 --- ### **第 18 节:实时应用:WebSockets 与 Socket.IO — 让你的应用瞬间‘活’起来!** **好了,同学们,咱们的 API 已经很棒了!但是,它还是基于传统的 HTTP 请求-响应模型:** 客户端发请求,服务器响应,然后连接关闭。如果你想搞一个在线聊天室、多人游戏、实时股票报价或者实时通知系统,这种“一问一答”的模式就显得非常低效了!客户端不得不频繁地“轮询”(不断发请求问服务器有没有新消息),这会消耗大量资源,而且延迟高。 这时候,咱们就需要更高级的“沟通方式”——**WebSockets**,以及它的“增强版”——**Socket.IO**! --- #### **18.1 什么是 WebSockets?与 HTTP 的区别。** **1. HTTP (Hypertext Transfer Protocol) — 传统的‘一问一答’!** * **请求-响应模型:** 客户端发起请求,服务器给出响应。 * **无状态:** 服务器不保留客户端的会话信息(除非用 Cookie 或 Session),每次请求都是独立的。 * **短连接:** 每次请求通常会建立新的连接,或者在短时间内保持连接(HTTP/1.1 的 Keep-Alive),然后连接就关闭了。 * **单向通信:** 通信通常由客户端发起。 * **头部开销大:** 每次请求和响应都包含完整的 HTTP 头部信息,数据量小时开销相对较大。 * **适用场景:** 传统的网页浏览、RESTful API 调用、文件下载等。 **2. WebSockets — 真正的‘双向对话’!** * **全双工通信:** **这是它最核心的特点!** 客户端和服务器可以**同时发送和接收数据**,就像你和朋友打电话,可以同时说话,也可以同时听。 * **持久连接:** 一旦 WebSockets 连接建立成功,它会一直保持开放(只要客户端和服务器不主动关闭),直到一方关闭连接。 * **有状态:** 连接建立后,客户端和服务器都保留着这个连接的状态。 * **双向通信:** 客户端和服务器都可以**主动**发起数据传输,不再是“你问我答”模式了。 * **低延迟:** 握手成功后,数据传输不再需要每次都带上完整的 HTTP 头部,只需要传输轻量级的数据帧,所以延迟非常低! * **协议升级:** WebSockets 连接是通过 HTTP 握手开始的!客户端发送一个特殊的 HTTP 请求(包含 `Upgrade: websocket` 头),服务器如果同意,就会把这个 HTTP 连接“升级”到 WebSocket 协议。 * **适用场景:** 聊天应用、在线游戏、实时股票报价、协作文档、即时通知系统等所有需要低延迟、高频率双向通信的场景。 **核心区别总结(记住这张表!):** | 特性 | HTTP (请求-响应) | WebSockets (全双工) | | :--------- | :------------------------------- | :------------------------------- | | **通信模式** | 请求-响应 (客户端发起,单向) | 全双工 (客户端/服务器均可发起,双向) | | **连接** | 短连接 (每次请求或短时保持) | 持久连接 (一次握手,长期开放) | | **状态** | 无状态 | 有状态 | | **开销** | 每次请求头部开销大 | 握手后数据帧开销小 | | **延迟** | 相对较高 (每次请求都需要建立连接/重用连接) | 极低 (连接已建立) | | **适用** | 传统网页、REST API | 实时应用、聊天、游戏、通知 | --- #### **18.2 Socket.IO 简介与安装 — WebSocket 的‘增强版’!** **虽然原生 WebSockets 已经很强大了,但直接用它写代码还是有点麻烦。** 而且,它还有一个问题:有些老旧的浏览器或者在某些复杂的网络环境下(比如代理服务器),可能不支持 WebSockets。 **这时候,咱们的“超级英雄”——Socket.IO 就登场了!** **什么是 Socket.IO?** Socket.IO 是一个基于 WebSockets 的 JavaScript 库,它使得实时、双向、基于事件的通信在 Web 客户端和服务器之间变得非常简单和可靠! **为什么选择 Socket.IO 而不是原生 WebSockets?** * **自动回退 (Fallback):** 这是 Socket.IO 的杀手锏!如果客户端或服务器不支持 WebSockets,Socket.IO 会**自动**降级到其他实时通信技术(比如长轮询、Flash Socket 等),确保在各种浏览器和网络环境下都能工作,提供最大程度的兼容性!你不用操心兼容性问题,它帮你全搞定! * **自动重连:** 当网络连接不稳定导致连接断开时,Socket.IO 客户端会自动尝试重新连接服务器,并且在重连后恢复之前的状态。这个非常实用,增强了用户体验。 * **事件驱动:** 它提供了非常简单的 `emit` (发送事件) 和 `on` (监听事件) API,让你在客户端和服务器之间进行通信,就像使用 `EventEmitter` 一样简单和直观。 * **房间 (Rooms) 和命名空间 (Namespaces):** 方便地组织和管理连接。你可以轻松地实现群聊、私聊、特定主题的通知等功能。比如,你可以把所有在“A房间”的用户放到一个“房间”里,只给这个房间发消息。 * **广播 (Broadcasting):** 轻松向所有连接的客户端,或者特定房间里的所有客户端发送消息。 * **心跳机制:** 自动发送心跳包,检测连接是否存活,防止“假死”连接。 **安装 Socket.IO:** Socket.IO 包含两个部分:服务器端库(Node.js 用)和客户端库(浏览器用)。 1. **服务器端 (Node.js):** ```bash npm install socket.io ``` 2. **客户端 (浏览器):** * **最方便的方式:通过服务器提供。** 当 Socket.IO 服务器启动后,它会自动在你的 Express 应用的 `/socket.io/socket.io.js` 路径提供客户端库。你只需要在 HTML 文件里像这样引入就行: ```html <script src="/socket.io/socket.io.js"></script> ``` * 或者,如果你是前端项目,也可以通过 npm 安装并在你的前端打包工具(如 Webpack)中使用: ```bash npm install socket.io-client ``` --- #### **18.3 构建一个简单的聊天室应用:实时消息发送与接收 — 你的第一个‘实时’应用!** 咱们来动手搭建一个非常非常简单的实时聊天室!用户可以在网页上输入消息,所有连接到服务器的用户都能实时看到这些消息。 **文件结构(非常简洁!):** ``` chat-app/ ├── server.js # Node.js 服务器端代码 └── public/ └── index.html # 客户端 HTML 页面 ``` **1. 服务器端 (`server.js`):** ```javascript const express = require('express'); const http = require('http'); // Node.js 内置的 http 模块,Express 应用底层也用它 const { Server } = require('socket.io'); // 引入 Socket.IO 的 Server 类 const app = express(); // 创建一个 HTTP 服务器,并把 Express 应用作为其请求处理函数 const server = http.createServer(app); // 将 Socket.IO 绑定到这个 HTTP 服务器上,这样 Socket.IO 就能接管 WebSocket 握手了 const io = new Server(server); const PORT = process.env.PORT || 3000; // 提供静态文件服务:将 public 目录下的文件暴露给客户端访问 // 这样客户端就可以访问 public/index.html 了 app.use(express.static('public')); // 当有客户端通过 Socket.IO 连接到服务器时,会触发 'connection' 事件 io.on('connection', (socket) => { // socket 参数代表连接到服务器的这个具体客户端 console.log('一个用户连接了:', socket.id); // socket.id 是每个客户端连接的唯一 ID // 监听客户端发送的名为 'chat message' 的事件 socket.on('chat message', (msg) => { console.log(`收到消息来自 ${socket.id}: ${msg}`); // 将收到的消息广播给所有(包括发送者自己)连接的客户端 // io.emit() 会向所有连接的客户端发送事件 io.emit('chat message', msg); }); // 监听客户端断开连接事件 socket.on('disconnect', () => { console.log('一个用户断开连接了:', socket.id); }); }); // 启动 HTTP 服务器,监听指定端口 server.listen(PORT, () => { console.log(`聊天服务器运行在 http://localhost:${PORT}`); console.log('请在浏览器中打开 http://localhost:3000,多开几个窗口试试!'); }); ``` **2. 客户端 (`public/index.html`):** ```html <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>简单的聊天室</title> <style> /* 一些简单的 CSS 样式,让聊天室看起来舒服点 */ body { margin: 0; padding-bottom: 3rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } #form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); } #input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; } #input:focus { outline: none; } #form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; } #messages { list-style-type: none; margin: 0; padding: 0; } #messages > li { padding: 0.5rem 1rem; } #messages > li:nth-child(odd) { background: #eee; } </style> </head> <body> <ul id="messages"></ul> <form id="form" action=""> <input id="input" autocomplete="off" /><button>发送</button> </form> <!-- 引入 Socket.IO 客户端库 --> <!-- 注意:这里的 /socket.io/socket.io.js 是由你的 Socket.IO 服务器自动提供的! --> <script src="/socket.io/socket.io.js"></script> <script> // 连接到 Socket.IO 服务器 // io() 会自动尝试连接到当前页面的服务器 const socket = io(); const form = document.getElementById('form'); const input = document.getElementById('input'); const messages = document.getElementById('messages'); // 监听表单提交事件 form.addEventListener('submit', (e) => { e.preventDefault(); // 阻止表单的默认提交行为(通常会导致页面刷新) if (input.value) { // 向服务器发送名为 'chat message' 的事件和消息内容 socket.emit('chat message', input.value); input.value = ''; // 清空输入框 } }); // 监听服务器发送的名为 'chat message' 的事件 socket.on('chat message', (msg) => { const item = document.createElement('li'); item.textContent = msg; // 将消息添加到列表中 messages.appendChild(item); window.scrollTo(0, document.body.scrollHeight); // 自动滚动到最新消息 }); // 监听连接事件 (可选,用于调试) socket.on('connect', () => { console.log('已连接到服务器,Socket ID:', socket.id); }); // 监听断开连接事件 (可选,用于调试) socket.on('disconnect', () => { console.log('已断开与服务器的连接'); }); </script> </body> </html> ``` **运行和测试:** 1. **创建项目目录和文件:** * 创建一个新文件夹,比如叫 `chat-app`。 * 在 `chat-app` 目录下运行 `npm init -y`。 * 安装依赖:`npm install express socket.io`。 * 创建 `server.js` 文件,并粘贴服务器端代码。 * 创建 `public` 目录,并在其中创建 `index.html` 文件,粘贴客户端代码。 2. **启动服务器:** * 在终端中进入 `chat-app` 目录,运行 `node server.js`。 3. **打开客户端:** * 打开你的浏览器,访问 `http://localhost:3000`。 * **关键步骤:多打开几个浏览器标签页或窗口**,访问同一个地址! 4. **开始聊天:** * 在任何一个窗口的输入框中输入消息,然后点击“发送”按钮。 * 你将看到:你的消息不仅会显示在你当前发送的窗口,还会**实时同步**到所有其他打开的聊天室窗口中! 是不是很酷?!这就是实时通信的魅力!你已经用 Node.js 搭建了一个最简单的实时聊天室! --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * 深入理解了 Node.js 中**错误处理的各种策略**,包括同步/异步错误的捕获,以及如何构建健壮的**集中式错误处理中间件**。 * 学会了如何使用强大的 **Winston 库进行日志记录**,让你的应用拥有专业的“黑匣子”。 * **最重要的是,你掌握了实时通信的核心——WebSockets 的概念及其与 HTTP 的区别**,并且使用**Socket.IO** 这个“超级工具”,成功构建了一个能够进行**实时消息发送与接收**的聊天室应用! 你现在已经具备了构建更健壮、更专业的 Node.js 应用的能力,并且迈出了实时通信应用开发的第一步! **下节预告:** 接下来,咱们继续探索 Node.js 的“十八般武艺”!下一节,咱们将学习如何利用 Node.js 强大的能力来开发**命令行工具 (CLI Tools)**,以及如何管理你的 Node.js 进程。这能让你用 JavaScript 搞定更多“幕后”的自动化任务!准备好了吗?咱们下节课,不见不散! 好的,同学们,欢迎回来! 上节课咱们搞定了高级错误处理、日志记录,甚至还用 Socket.IO 搭建了一个简易的实时聊天室!是不是感觉 Node.js 不仅仅能搞 Web,还能玩出花来? 今天,咱们要继续深入 Node.js 的“多面手”属性!你可能以为 Node.js 只能写 Web 后端,但实际上,它在开发**命令行工具 (CLI Tools)** 和**进程管理**方面也表现出色!很多你天天用的前端构建工具和包管理工具,底层都是 Node.js 写的!所以,这节课,咱们就来揭秘 Node.js 在这些“幕后英雄”角色上的应用! --- ### **第 19 节:命令行工具开发与进程管理 — Node.js 不只搞 Web!** **学习目标:** 本节课,你将了解 Node.js 在命令行工具开发中的优势,学会如何使用 `commander.js` 或 `yargs` 等库来解析复杂的命令行参数。你还将掌握 Node.js 中创建和管理子进程的关键方法:`spawn`, `exec`, `fork`,从而让你的 Node.js 应用能够执行外部命令或运行其他独立的 Node.js 脚本,实现更强大的自动化能力! --- #### **19.1 Node.js 作为命令行工具的优势 — 你的 JavaScript 可以‘命令天下’了!** **各位老铁,同学们好啊!** 咱们平时开发,经常要用各种命令行工具,比如 `git`、`npm`、`webpack`、`vue create` 等等。你有没有想过,这些工具是用什么写的呢?答案五花八门,有 C++、Go、Python,当然,也有大量的**Node.js**! **Node.js 为什么适合开发命令行工具 (CLI)?** 1. **跨平台:** 最大的优势!你用 Node.js 写的 CLI 工具,可以在 Windows、macOS 和 Linux 上“横着走”,无需为每个系统单独编写代码。一次编写,到处运行,是不是很爽? 2. **JavaScript 熟悉度:** 如果你已经是 JavaScript 开发者(我相信你肯定是的!),那么用 Node.js 写 CLI 工具,就像“回家一样舒服”!你可以直接复用你已有的 JavaScript 技能栈,无需学习新的语言。 3. **NPM 生态系统:** 再次强调 NPM 的强大!你需要处理文件?`fs` 模块。需要发网络请求?`axios`。需要解析复杂的命令行参数?`commander.js`!NPM 上有海量的库可以拿来就用,极大地提高了 CLI 工具的开发效率。 4. **异步 I/O:** Node.js 的非阻塞 I/O 模型使其在处理大量文件操作、网络请求或者其他 I/O 密集型任务时表现出色。这对于那些需要快速处理数据、下载文件或者与外部系统交互的 CLI 工具来说,简直是“天作之合”! 5. **易于分发:** 你的 CLI 工具写好了,怎么让别人用?简单!通过 NPM,你可以把它打包发布到 NPM 注册表,然后全世界的开发者都能通过 `npm install -g your-cli-tool` 来安装和使用你的工具了! **如何让你的 Node.js 脚本变成可执行的 CLI 工具?** 1. **Shebang (哈希 bang)!** * 在你的 Node.js 脚本文件的**第一行**,添加一个特殊的注释:`#!/usr/bin/env node`。 * 这行代码叫做 Shebang(也叫 Hashbang 或 Hash-pling)。它告诉操作系统:这个脚本应该用 `/usr/bin/env` 程序找到的 `node` 解释器来执行。 * **示例:** ```javascript #!/usr/bin/env node // my-cli.js console.log("Hello, I am a Node.js CLI tool!"); ``` 2. **添加执行权限:** * 在 Linux 或 macOS 系统上,你需要给你的脚本文件添加执行权限: ```bash chmod +x your-cli-script.js ``` * 这样,你就可以直接在终端里运行它了:`./your-cli-script.js` 3. **通过 `package.json` 发布你的 CLI 工具:** * 如果你想把你的 CLI 工具发布到 NPM 上,或者让别人通过 `npm install` 来安装和使用,你需要在 `package.json` 文件中添加一个 `bin` 字段。 * `bin` 字段是一个对象,它的键是你希望用户在命令行中使用的命令名,值是你的 CLI 脚本文件的路径。 * **示例:** ```json { "name": "my-cool-cli", // 包名 "version": "1.0.0", "description": "一个我用 Node.js 写的新潮命令行工具", "main": "index.js", "bin": { "mycli": "./bin/mycli.js" // 告诉 NPM,当用户安装此包时,执行 `mycli` 命令会运行 `bin/mycli.js` }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["cli", "nodejs", "tool"], "author": "YourName", "license": "MIT" } ``` * **如何在本地测试 `bin` 配置:** 在你项目根目录运行 `npm link` 命令。这个命令会在全局 NPM 的 `node_modules` 目录下创建一个指向你本地 `bin` 脚本的软链接。这样你就可以在任何目录下直接使用 `mycli` 命令来测试了! --- #### **19.2 `commander.js` 或 `yargs` 库用于解析命令行参数 — 你的‘命令解析器’!** 原生 Node.js 虽然可以通过 `process.argv` 来访问命令行参数,但它只返回一个简单的字符串数组。如果你想定义复杂的命令(比如 `git commit`),带选项(比如 `--message`),或者有必填参数、可选参数,手动解析 `process.argv` 会让你吐血! 这时候,咱们就需要专业的“命令解析器”库了!`commander.js` 和 `yargs` 是两个最流行、最强大的库,它们提供了超级方便的方式来定义和解析命令行参数、子命令、选项,还能自动生成帮助信息! 这里我们以 **`commander.js`** 为例(它简洁易用): **安装:** `npm install commander` **示例 (`bin/mycli.js`):** ```javascript #!/usr/bin/env node // 别忘了这行! const { Command } = require('commander'); // 引入 Command 类 const program = new Command(); // 创建一个 Command 实例 // 配置你的 CLI 工具的基本信息 program .name('mycli') // 工具的名称 .description('一个我自己用 Node.js 写的新潮命令行工具!') // 工具的描述 .version('1.0.0'); // 工具的版本号,用户可以通过 `mycli --version` 查看 // --- 定义一个命令 'greet' (打招呼命令) --- // 例如:mycli greet <name> --message "早上好" program .command('greet') // 定义一个子命令:greet .description('向指定用户打招呼,可以自定义问候消息。') // 命令的描述 .argument('<name>', '要打招呼的用户名') // 定义一个必填参数,用尖括号 `< >` 包裹 .option('-m, --message <string>', '自定义问候消息', '你好') // 定义一个可选选项,用方括号 `[ ]` 表示可选参数 // -m 是短选项,--message 是长选项 // <string> 表示需要一个字符串值 // '你好' 是默认值 .action((name, options) => { // 当用户执行 `mycli greet ...` 时,这个 action 函数会被调用 console.log(`${options.message}, ${name}! 欢迎使用我的 CLI 工具!`); }); // --- 定义一个命令 'add' (加法命令) --- // 例如:mycli add 5 3 program .command('add') .description('将两个数字相加,并打印结果。') .argument('<num1>', '第一个数字', parseInt) // 必填参数,用 parseInt 转换成整数 .argument('<num2>', '第二个数字', parseInt) // 必填参数,用 parseInt 转换成整数 .action((num1, num2) => { // 简单的参数校验 if (isNaN(num1) || isNaN(num2)) { console.error('错误: 请输入有效的数字!例如:mycli add 10 20'); process.exit(1); // 错误退出 } console.log(`计算结果: ${num1} + ${num2} = ${num1 + num2}`); }); // --- 定义一个全局选项 --- // 例如:mycli --verbose greet Alice program .option('-v, --verbose', '启用详细输出模式,你会看到更多信息。') // 定义一个布尔类型的全局选项 .action((options) => { // 这个 action 会在任何命令之前执行,处理全局选项 if (options.verbose) { console.log('[调试信息] 详细模式已启用!'); } }); program.parse(process.argv); // <-- 敲黑板!这是最关键的一行!解析命令行参数并执行对应的命令和选项。 // process.argv 就是咱们之前学的,命令行参数的数组。 ``` **如何使用和测试你的 CLI 工具?** 1. **创建文件:** 把上述代码保存为 `bin/mycli.js`。 2. **添加执行权限:** 在 Linux/macOS 上,打开终端,进入 `chat-app` 目录(假设你的 `package.json` 和 `bin` 目录都在这里),运行 `chmod +x bin/mycli.js`。 3. **在本地进行链接测试:** 在 `chat-app` 目录(也就是 `package.json` 所在的目录)运行: ```bash npm link ``` 这个命令会在你全局的 Node.js 环境中创建一个指向你本地 `bin/mycli.js` 脚本的软链接。这样,你就可以在系统的任何目录下,直接像使用 `npm` 命令一样使用 `mycli` 命令了! * **小技巧:** 如果你不想全局链接,只想在当前项目里使用,可以在 `package.json` 的 `scripts` 里定义,比如 `"greet": "node ./bin/mycli.js greet"`,然后运行 `npm run greet ...`。但 CLI 工具通常还是全局链接方便。 4. **测试你的 CLI 工具:** * 查看版本:`mycli --version` * 查看帮助:`mycli --help` 或 `mycli greet --help` * 使用 greet 命令:`mycli greet World` * 使用 greet 命令带自定义消息:`mycli greet Alice --message "早上好" -v` (同时启用 verbose 模式) * 使用 add 命令:`mycli add 5 3` * 测试 add 命令错误:`mycli add hello world` (会提示错误) 是不是感觉开发命令行工具变得超级简单了?有了 `commander.js`,你也可以快速打造自己的自动化脚本和工具! --- #### **19.3 子进程 (Child Process):`spawn`, `exec`, `fork` — Node.js 的‘分身术’!** **同学们,一个 Node.js 进程通常是单线程的,对吧?** 那如果我有个任务,比如压缩一个大文件,或者进行复杂的 CPU 计算,这个任务很耗时,而且是同步的,那不是会把我的主线程“卡死”吗? 没错!这时候,Node.js 的 `child_process` 模块就派上用场了!它允许你的 Node.js 进程创建和管理**子进程**,从而可以在后台执行外部命令或者运行其他的 Node.js 脚本,而不会阻塞你的主事件循环!这就像你的 Node.js 进程施展了“分身术”,让“分身”去干那些又脏又累的活儿,本体继续处理高并发的请求! `child_process` 模块提供了几种创建子进程的方法,它们各有特点: 1. **`child_process.exec(command[, options][, callback])`:** * **特点:** * **在 shell 中执行命令!** 这意味着你可以直接输入像 `ls -lh /` 这种带有管道符(`|`)或者重定向符(`>`)的复杂 shell 命令。 * **缓冲所有输出:** 它会把子进程的**所有标准输出 (stdout)** 和**标准错误 (stderr)** 都**缓冲**起来,直到子进程执行完毕,然后一次性通过回调函数传递给你。 * **适合场景:** 执行**短时间运行**、**输出量较小**的命令。 * **示例:** ```javascript const { exec } = require('child_process'); console.log('\n--- exec 示例 ---'); // 执行一个简单的 shell 命令 exec('ls -lh /', (error, stdout, stderr) => { if (error) { // 如果命令执行出错 console.error(`exec 错误: ${error.message}`); return; } if (stderr) { // 如果有标准错误输出 console.error(`exec stderr: ${stderr}`); return; } console.log(`exec stdout:\n${stdout}`); }); // 执行一个会报错的命令(比如一个不存在的命令) exec('nonexistent-command', (error, stdout, stderr) => { if (error) { console.error(`exec 错误 (非存在命令): ${error.message}`); return; // 捕获到错误并返回 } console.log(`exec stdout: ${stdout}`); }); ``` * **缺点:** 因为它会缓冲所有输出,如果子进程输出量很大,可能会导致内存溢出! 2. **`child_process.spawn(command[, args][, options])`:** * **特点:** * **直接启动一个新进程,不通过 shell!** 这意味着你不能直接输入 `ls -lh /` 这种带 `|` 的命令,你需要把命令和参数分开传递,更安全也更底层。 * **流式处理输入/输出:** 这是它最大的优点!它不会缓冲所有输出,而是以**流 (Stream)** 的方式实时传递子进程的标准输出和标准错误。你可以监听 `data` 事件来逐块接收数据。 * **适合场景:** 处理**大量数据**或**长时间运行**的进程。 * **返回值:** 返回一个 `ChildProcess` 对象,你可以通过其 `stdout` (可读流), `stderr` (可读流), `stdin` (可写流) 属性来监听或写入数据。 * **示例:** ```javascript const { spawn } = require('child_process'); console.log('\n--- spawn 示例 ---'); // 执行一个简单的命令,参数分开传递 const ls = spawn('ls', ['-lh', '/']); // 命令是 'ls',参数是 ['-lh', '/'] // 监听标准输出流 ls.stdout.on('data', (data) => { console.log(`spawn stdout (数据块):\n${data}`); }); // 监听标准错误流 ls.stderr.on('data', (data) => { console.error(`spawn stderr (数据块): ${data}`); }); // 监听进程关闭事件 ls.on('close', (code) => { console.log(`spawn 子进程退出,退出码 ${code}`); }); // 监听进程启动失败事件 (比如命令不存在) ls.on('error', (err) => { console.error(`spawn 启动失败: ${err.message}`); }); // 示例:长时间运行的进程 (ping 命令) console.log('\n--- spawn 演示:长时间运行的 ping 命令 ---'); const ping = spawn('ping', ['-c', '4', 'google.com']); // -c 4 表示发送4个包 ping.stdout.on('data', (data) => { console.log(`ping stdout: ${data.toString()}`); // 数据是 Buffer,需要 toString() }); ping.on('close', (code) => { console.log(`ping 进程退出,退出码 ${code}`); }); ``` 3. **`child_process.fork(modulePath[, args][, options])`:** * **特点:** * `fork` 是 `spawn` 的一个**特殊版本**!它专门用于启动**另一个 Node.js 进程**。 * 在父子进程之间会自动建立一个特殊的 **IPC (Inter-Process Communication) 通道**。这个通道允许父子进程之间通过 `process.send()` (子进程发送给父进程) 和 `child.on('message')` (父进程接收子进程消息) 互相发送和接收**消息(通常是 JSON 对象)**。 * **适合场景:** 这是 Node.js 处理 **CPU 密集型任务**的“利器”!你可以把耗时、会阻塞主线程的计算任务,丢给 `fork` 出来的子进程去执行,而你的主事件循环(也就是你的 Express 服务器)则继续愉快地处理高并发的请求,不被阻塞! * **示例:** * **`parent.js` (主进程文件):** ```javascript // parent.js const { fork } = require('child_process'); const path = require('path'); console.log('父进程启动...'); // 启动一个子进程,执行 child.js 文件 const child = fork(path.join(__dirname, 'child.js')); // 监听子进程发送过来的消息 child.on('message', (message) => { console.log('父进程收到子进程消息:', message); }); // 父进程向子进程发送消息 child.send({ task: 'calculate_sum', start: 1, end: 1e7 }); child.on('close', (code) => { console.log(`子进程退出,退出码 ${code}`); }); console.log('父进程继续运行,等待子进程的消息...'); // 父进程可以继续处理其他请求,不会被子进程的计算阻塞 // 例如,可以模拟一个 Express 服务器 // setTimeout(() => console.log('主进程在处理其他任务...'), 100); ``` * **`child.js` (子进程文件):** ```javascript // child.js console.log('子进程启动...'); // 监听父进程发送过来的消息 process.on('message', (message) => { console.log('子进程收到父进程消息:', message); if (message.task === 'calculate_sum') { const { start, end } = message; let sum = 0; // 模拟一个耗时的 CPU 密集型计算 for (let i = start; i <= end; i++) { sum += i; } console.log('子进程完成计算:', sum); // 子进程将计算结果发送回父进程 process.send({ result: sum, from: 'child_process' }); } }); // 子进程也可以直接退出 // process.exit(0); // 只有当任务完成后才退出 ``` * **如何运行:** 1. 创建 `parent.js` 和 `child.js` 这两个文件。 2. 在终端中运行:`node parent.js` 3. 你会看到父子进程之间互相发送消息,并且子进程在进行大量计算时,父进程并没有被阻塞。 **选择哪个方法来创建子进程?** * **`exec`:** * 最简单,适合执行**短时间、输出量小、需要 shell 环境**(比如管道 `|`、重定向 `>`)的命令。 * **缺点:** 缓冲所有输出,可能导致内存溢出。 * **`spawn`:** * 最通用,适合执行**长时间运行、输出量大、不需要 shell 环境**的命令。 * **优点:** 流式处理 I/O,内存效率高。 * **`fork`:** * 专门用于启动**另一个 Node.js 脚本**,并且需要**父子进程之间进行消息通信**。 * **优点:** 可以在子进程中处理 CPU 密集型任务,而不会阻塞主进程的事件循环。这是 Node.js 中实现“多核利用”和“并发计算”的关键! --- **本节总结:** 好了,同学们,通过本节的学习,你已经: * 理解了 Node.js 在**命令行工具开发**中的巨大优势,并学会了如何配置 `package.json` 的 `bin` 字段,让你的脚本变成可执行命令。 * 掌握了 `commander.js` 这个“命令解析器”,能够轻松定义和解析复杂的命令行参数、选项和子命令。 * **最重要的是,你掌握了 Node.js 中创建和管理子进程的“分身术”!** 你了解了 `exec`, `spawn`, `fork` 这三个方法的区别和适用场景,特别是 `fork` 在处理 CPU 密集型任务中的重要作用,它让你的 Node.js 应用在单线程模式下也能发挥多核 CPU 的潜力! 你现在已经具备了开发强大命令行工具和进行进程管理的能力,Node.js 在你手里将不仅仅是一个 Web 服务器! **下节预告:** 激动人心的时刻来了!下一节,咱们要学习如何把你的 Node.js 应用**部署上线**,让全世界都能访问!同时,咱们还会探讨一些简单的**性能优化技巧**,让你的应用跑得更快、更稳!这是你走向“宗师”的最后一步!准备好了吗?咱们下节课,不见不散! 好的,同学们,欢迎回来! 恭喜你!你已经掌握了 Node.js 的各种“十八般武艺”,从 Web 开发到数据库集成,从认证授权到实时通信,甚至还能开发命令行工具和管理进程。你的“武功”已经非常高强了! 但是,你所有的“作品”目前都还在你的本地电脑里“跑着玩儿”呢!想要让全世界都能访问你的网站,使用你的 API,那还得把你的 Node.js 应用**部署上线**!而且,上线之后,如何保证它跑得又快又稳,不“卡顿”,不“掉链子”,那就是**性能优化**的学问了! 所以,今天这节课,咱们就来聊聊 Node.js 应用的**部署与性能优化**!这是你从“大侠”走向“宗师”的最后一步!学会了它,你的 Node.js 学习之旅就真正圆满了! --- ### **第 20 节:部署与性能优化基础 — 让你的应用‘上线’并‘飞起来’!** **学习目标:** 本节课,你将了解 Node.js 应用程序从开发环境到生产环境的常见部署方式,特别是掌握 PM2 进程管理器的基本使用和集群模式。你还将学习一些简单的性能优化技巧,如缓存和 Gzip 压缩,让你的应用在上线后能够跑得更快、更稳定。最后,我们将对整个 Node.js 学习大纲进行总结,并展望未来的学习方向! --- #### **20.1 常见部署方式简介 — 你的应用要‘搬家’了!** **各位老铁,同学们好啊!** 把你的 Node.js 应用程序从你的开发电脑,迁移到一台能让大家访问的服务器上,这个过程就叫**部署 (Deployment)**。部署可不是简单的“复制粘贴”,它涉及到很多东西,比如怎么让应用一直跑着、怎么负载均衡、怎么管理依赖等等。 咱们来看看几种常见的 Node.js 部署方式: 1. **PM2 (Process Manager 2) — 你的 Node.js 应用‘金牌保姆’!** * **是什么:** PM2 是一个非常流行的 Node.js **进程管理器**。它能帮你把 Node.js 应用“管”得服服帖帖,确保你的应用在服务器上**永远在线**,即使程序崩溃了也能自动重启。它还能提供负载均衡(把请求分发到多个应用实例)、日志管理、监控等一系列强大功能。 * **优势:** 简单易用,功能强大,对于**单服务器部署**(或者几台服务器的简单集群)来说,PM2 是一个非常棒的选择。很多公司在起步阶段都会用它。 2. **Docker — 你的应用‘集装箱’!** * **是什么:** Docker 是一种**容器化技术**。你可以把你的 Node.js 应用程序,连同它所有的依赖(包括 Node.js 运行时、操作系统环境、数据库驱动等等),全部打包到一个独立的、可移植的“集装箱”(也就是 Docker 容器)里。 * **优势:** * **环境一致性:** 最核心的优势!你打包的“集装箱”可以在任何支持 Docker 的机器上运行,而且运行效果一模一样!彻底解决了“在我的机器上能跑,在你机器上就报错”的经典难题! * **快速部署:** 打包好的镜像可以直接分发和运行,部署速度快。 * **易于扩展和管理:** 容器是轻量级、独立的,方便进行扩展、升级和版本控制。 * **配合使用:** 通常与 Docker Compose (用于管理多个相互关联的容器应用,比如 Node.js 应用 + MongoDB 数据库) 或 Kubernetes (用于大规模容器编排和管理) 结合使用。 3. **云服务 — 把你的应用‘托管’出去!** 现在,把应用部署到各种云服务上,已经成为主流。云服务商帮你搞定了服务器的底层维护、网络、安全等各种复杂的运维工作,你只管写代码,然后把代码扔上去就行! * **IaaS (Infrastructure as a Service - 基础设施即服务):** * **代表:** AWS EC2 (亚马逊云的弹性计算云), Google Compute Engine, Azure Virtual Machines。 * **特点:** 云服务商给你提供一台“裸机”(虚拟机),你需要自己手动在上面安装操作系统、Node.js 运行时、数据库、PM2 等所有东西,并自行管理它们。 * **优点:** 灵活性最高,你可以完全控制你的服务器环境。 * **缺点:** 管理成本也最高,你需要像管理自己的物理服务器一样去运维它。 * **PaaS (Platform as a Service - 平台即服务):** * **代表:** Heroku, AWS Elastic Beanstalk, Google App Engine, Azure App Service。 * **特点:** 云服务商给你提供一个“运行平台”。你只需要上传你的 Node.js 代码,平台会自动帮你处理运行环境、自动扩展、负载均衡、日志收集等等。 * **优点:** 极大地简化了部署和管理,你可以专注于写代码。 * **缺点:** 灵活性较低,你对底层环境的控制权有限。 * **FaaS (Function as a Service - 函数即服务) / Serverless (无服务器):** * **代表:** AWS Lambda, Google Cloud Functions, Azure Functions, Netlify Functions。 * **特点:** 你甚至都不用关心服务器了!你只编写单个的函数代码,上传到云端,这些函数按需执行(比如当一个 HTTP 请求过来时),然后按实际使用量计费。 * **优点:** 无需管理任何服务器,高度可伸缩,成本效益高(只为实际执行时间付费)。 * **缺点:** 适用于无状态、事件驱动的短时任务,不适合长时间运行或需要保持连接的应用(比如我们的聊天室)。 * **专门的 Node.js 托管平台:** * **Vercel, Netlify:** 这些平台主要用于托管前端应用(React, Vue, Next.js, Nuxt.js),但它们也提供方便的无服务器函数功能,可以托管你的 Node.js API。部署非常傻瓜化,适合快速上线。 **总结:** 对于初学者和中小项目,**PM2** 搭配一台云服务器(比如阿里云、腾讯云、DigitalOcean 的轻量服务器)是比较经济和方便的入门部署方案。如果项目规模扩大,或者需要更复杂的CI/CD流程,可以考虑深入学习 **Docker** 和相关的云服务。 --- #### **20.2 PM2 进程管理器:保持应用运行、负载均衡 — 你的应用‘永动机’!** **各位老铁,同学们,PM2 是你 Node.js 应用上线后的“守护神”!** 你的 Node.js 应用如果直接用 `node app.js` 启动,一旦进程崩溃(比如你代码出 Bug 了),或者终端关闭了,你的应用就“嗝屁”了!PM2 就是来解决这个问题的。它能确保你的应用**永远在线**,就像一个“永动机”! **安装 PM2:** PM2 是一个命令行工具,所以要全局安装: ```bash npm install -g pm2 ``` **PM2 基本命令(这些你得刻在脑子里!):** * **启动应用:** ```bash pm2 start app.js # 启动 app.js pm2 start app.js --name my-express-app # 启动并指定一个自定义名称 ``` 启动后,即使你关闭了终端,你的 Node.js 应用也会在后台持续运行! * **列出所有应用:** ```bash pm2 list # 或 pm2 ls,查看所有被 PM2 管理的应用列表 ``` 你会看到应用的 ID、名称、状态、CPU 占用、内存占用等信息。 * **停止应用:** ```bash pm2 stop <id|name> # 停止指定 ID 或名称的应用 pm2 stop all # 停止所有被 PM2 管理的应用 ``` * **重启应用:** ```bash pm2 restart <id|name> # 重启指定应用 pm2 restart all # 重启所有应用 (当你修改了代码,需要重启才能生效) ``` * **删除应用:** ```bash pm2 delete <id|name> # 从 PM2 列表中删除指定应用 (停止并移除) pm2 delete all # 删除所有应用 ``` * **查看日志:** ```bash pm2 logs <id|name> # 查看指定应用的实时日志 pm2 logs --lines 100 # 查看最近100行日志 pm2 logs --follow # 实时跟踪日志输出 (类似 tail -f) ``` * **监控应用:** ```bash pm2 monit # 实时显示所有应用的 CPU、内存使用情况,以及日志输出,非常方便! ``` * **开机自启(让你的应用‘死而复生’!):** ```bash pm2 startup # 生成一个启动脚本,让 PM2 在服务器重启后自动启动你的应用! # 按照提示复制粘贴生成的命令并执行。 pm2 save # 保存当前正在运行的应用列表,以便 startup 脚本在服务器重启时加载。 ``` **集群模式 (Cluster Mode) - 负载均衡:** 还记得我们说 Node.js 是单线程的吗?那怎么利用多核 CPU 的性能呢?PM2 的**集群模式**就是答案!它会利用 Node.js 的 `cluster` 模块,在你的服务器上启动多个(通常是与 CPU 核心数相同)应用实例,然后自动帮你进行负载均衡,把请求分发到不同的实例上,从而提高性能和可用性! ```bash pm2 start app.js -i max # 启动与 CPU 核心数相同数量的实例 (max 会自动检测) # 或者指定实例数量,例如,启动 4 个实例 pm2 start app.js -i 4 ``` 在集群模式下,PM2 会自动处理请求的分发,你不需要额外配置! **使用配置文件 (`ecosystem.config.js`):** 对于更复杂的部署,或者当你有很多应用需要管理时,直接敲命令有点麻烦。推荐使用 PM2 的配置文件! ```javascript // ecosystem.config.js (在你的项目根目录创建这个文件) module.exports = { apps : [{ name: 'my-express-app-prod', // 应用名称 script: 'app.js', // 启动脚本 instances: 'max', // 启动实例数量,'max' 会自动检测 CPU 核心数 exec_mode: 'cluster', // 启用集群模式 (非常重要!) watch: false, // 生产环境通常不开启 watch,避免代码改动自动重启 (开发环境可以开) max_memory_restart: '300M', // 当应用内存使用超过 300MB 时,自动重启(防止内存泄漏) log_file: 'logs/combined.log', // 所有日志输出到这个文件 error_file: 'logs/error.log', // 错误日志输出到这个文件 time: true, // 在日志中添加时间戳 // 环境变量配置 (可以根据不同环境设置不同的环境变量) env: { NODE_ENV: 'development', // 默认开发环境配置 PORT: 3000, DEBUG: 'my-app:*' }, env_production: { // 生产环境配置 NODE_ENV: 'production', PORT: 80, // 生产环境通常跑在 80 端口 (HTTP) 或 443 (HTTPS) JWT_SECRET: 'your_super_secret_production_jwt_key_here', // 生产环境的密钥,一定要保密! DB_URI: 'mongodb://user:password@prod-db-host:27017/prod_db' // 生产环境的数据库 URI } }] }; ``` **使用配置文件启动:** ```bash pm2 start ecosystem.config.js # 启动默认配置 (env) pm2 start ecosystem.config.js --env production # 启动生产环境配置 (env_production) ``` **生产环境注意事项:** * **不要在生产环境开启 `watch: true`!** 你的代码改动应该通过完整的部署流程(比如 Git pull, npm install, pm2 restart)来上线,而不是自动重启。 * **`JWT_SECRET` 和 `DB_URI` 等敏感信息一定要通过环境变量传入!** 绝不能写死在代码里!在 `ecosystem.config.js` 里,`env_production` 里面的变量会覆盖 `env` 里面的同名变量。 --- #### **20.3 简单的性能优化技巧:缓存、Gzip 压缩 — 让你的应用‘飞起来’!** **好了,同学们,应用上线了,跑着跑着,用户可能抱怨“好卡啊!”** 这时候,咱们就得考虑性能优化了。性能优化是一个复杂且持续的过程,需要长期关注。但有些基础技巧,可以显著提升 Node.js 应用的性能,而且实现起来相对简单。 1. **缓存 (Caching) — 减少‘重复劳动’!** * **目的:** 减少重复的计算或数据库查询,加快响应速度,降低服务器负载。就像你把经常用的文件放在桌面,而不是每次都去硬盘里找。 * **客户端缓存 (HTTP Caching):** * 通过设置 HTTP 响应头(如 `Cache-Control`, `ETag`, `Last-Modified`),指示浏览器或代理服务器缓存资源。 * 对于静态文件(图片、CSS、JavaScript 文件),这是最有效的优化之一。Express 的 `express.static` 中间件可以自动处理一些缓存头。 * **服务器端缓存 (Application-level Caching):** * **内存缓存:** 将频繁访问的、不经常变化的数据存储在应用程序的内存中。适用于数据量不大且不要求持久化的场景。 * **示例 (使用 `node-cache` 库,简单方便):** ```bash npm install node-cache ``` ```javascript const NodeCache = require('node-cache'); // 创建一个缓存实例,stdTTL: 默认过期时间(秒),checkperiod: 检查过期键的频率(秒) const myCache = new NodeCache({ stdTTL: 600, checkperiod: 120 }); // 缓存 10 分钟 (600秒) // 假设这是你的 Express 路由 app.get('/api/products', async (req, res, next) => { const cacheKey = 'all_products'; let products = myCache.get(cacheKey); // 1. 尝试从缓存获取数据 if (products) { console.log('--- 从缓存获取产品数据 ---'); return res.json(products); // 如果缓存命中,直接返回 } try { products = await Product.find(); // 2. 如果缓存未命中,从数据库获取 myCache.set(cacheKey, products); // 3. 将数据存入缓存 console.log('--- 从数据库获取产品数据并存入缓存 ---'); res.json(products); } catch (err) { next(err); // 错误传递给错误处理中间件 } }); ``` * **分布式缓存 (如 Redis):** 当你的应用部署在多台服务器实例上时,每台服务器有自己的内存缓存就不行了,因为它们之间数据不一致。这时候,就需要一个独立的、共享的缓存服务器,比如 Redis。所有应用实例都去 Redis 里读写缓存。 2. **Gzip 压缩 (Gzip Compression) — 给你的数据‘瘦身’!** * **目的:** 减小 HTTP 响应体的大小,从而减少网络传输时间,用户下载更快!对于文本类型的数据(HTML, CSS, JavaScript, JSON),Gzip 压缩效果非常显著。 * **实现:** 在 Express.js 中,你可以使用 `compression` 这个非常方便的第三方中间件。 * **安装:** `npm install compression` * **使用:** ```javascript const express = require('express'); const compression = require('compression'); // 引入 compression 模块 const app = express(); // 在所有路由和静态文件中间件之前使用 compression 中间件 // 它会检查客户端请求头是否支持 Gzip (Accept-Encoding: gzip) // 如果支持,它就会自动压缩你的响应体! app.use(compression()); app.get('/', (req, res) => { // 发送一个较大的响应体来测试压缩效果 const largeText = Array(5000).fill('Hello Node.js Performance! This is a long string to demonstrate gzip compression effect.').join('\n'); res.send(largeText); // 这个文本会被 compression 中间件自动压缩 }); // 也可以用于静态文件 // app.use(express.static('public')); // express.static 也会被 compression 影响 ``` * **测试:** 启动服务器后,用浏览器访问你的页面,打开开发者工具(F12),切换到“Network”标签页。点击你的请求,查看响应头,你会看到 `Content-Encoding: gzip`,这表示响应已经被压缩了!同时查看传输大小和实际资源大小。 3. **数据库索引:** * **目的:** 显著加快数据库查询速度。 * 确保你的数据库查询字段(尤其是 `_id`、`email`、`userId` 等经常用于查询的字段)有适当的索引。 * 在 Mongoose Schema 中,`unique: true` 会自动创建唯一索引。你也可以手动添加索引: ```javascript UserSchema.index({ email: 1 }); // 为 email 字段创建升序索引 UserSchema.index({ name: 1, age: -1 }); // 复合索引 ``` 4. **异步操作:** * 再次强调!Node.js 的核心优势在于其**非阻塞 I/O**。确保你的代码充分利用异步操作,避免使用同步方法(如 `fs.readFileSync`),除非在应用程序启动阶段或特殊工具脚本中。 5. **负载均衡:** * 当单个服务器无法处理所有请求时,通过在多个服务器实例之间分发请求来提高吞吐量和可用性。PM2 的集群模式就是一种简单的负载均衡实现。更高级的还有 Nginx 反向代理、云服务商的负载均衡器等。 --- #### **20.4 项目总结与下一步学习方向 — 你的‘宗师之路’才刚刚开始!** **各位老铁,恭喜你!** 你已经完成了 Node.js 学习的全部 20 节课程!这一路走来,你从对 Node.js 一无所知,到能够: * **深刻理解 Node.js 的核心原理**:V8 引擎、事件循环、异步编程。 * **熟练掌握 Node.js 的基础工具**:模块化、NPM 包管理、文件系统操作。 * **玩转 Express.js 框架**:路由、中间件、HTTP 方法、请求/响应处理、数据验证与错误处理。 * **搞定数据库集成**:MongoDB 和 Mongoose 的数据建模与 CRUD 操作。 * **实现用户认证与授权**:JWT 的工作原理与应用。 * **探索实时通信**:WebSockets 和 Socket.IO。 * **进军命令行工具开发**:参数解析与子进程管理。 * **学会部署与初步优化**:PM2、Docker 简介、缓存与 Gzip 压缩。 你现在已经具备了**构建一个功能完善、具备基本安全和性能考量,并能够部署上线**的 Node.js 后端应用的能力了!你已经从一个 Node.js 的“小白”蜕变成了能够独当一面的“大侠”! **但是,记住!“宗师之路”才刚刚开始!** 技术发展日新月异,Node.js 生态也无比活跃。你现在所学的,是成为一名优秀 Node.js 开发者的“内功心法”和“十八般武艺”。未来还有更广阔的天地等着你去探索! **下一步学习方向(给自己列个清单,继续升级!):** 1. **深入学习与进阶:** * **测试:** 学习如何编写健壮的单元测试 (Jest, Mocha, Vitest)、集成测试 (Supertest) 和端到端测试 (Cypress)。测试是保障代码质量的基石! * **TypeScript:** 将你的 JavaScript 代码升级到 TypeScript!它能提供静态类型检查,大大提高代码的可维护性、可读性和健壮性,减少运行时错误。 * **GraphQL:** 学习另一种流行的 API 设计风格,它允许客户端按需获取数据,提供更灵活的数据查询能力。 * **微服务架构:** 了解如何将一个庞大的应用程序拆分为更小、更独立的服务,提高可伸缩性、弹性和团队协作效率。 * **容器编排:** 深入学习 Kubernetes,它是管理和扩展 Docker 容器的“操作系统”。 * **CI/CD (持续集成/持续部署):** 自动化你的代码测试、构建和部署流程,实现快速、可靠的软件交付。 * **监控与日志分析:** 学习使用 Prometheus, Grafana, ELK Stack (Elasticsearch, Logstash, Kibana) 等工具来监控你的应用性能、收集和分析日志。 * **安全性:** 深入了解常见的 Web 安全漏洞(OWASP Top 10)及其防范措施,成为一名“安全卫士”! * **高级数据库概念:** 学习事务、聚合管道、数据库索引优化、NoSQL 数据库(Redis, Cassandra)或关系型数据库(PostgreSQL, MySQL)的更多高级用法。 2. **实践项目(通过实战来巩固和提升!):** * **独立构建一个完整的项目:** 比如一个博客系统 (带用户管理、文章发布、评论)、一个电商网站 (带商品、订单、购物车)、一个实时协作文档应用、一个简单的社交媒体平台。 * 在项目中**强制**自己应用你所学到的所有知识,并尝试引入新的技术。项目越大,你遇到的问题越多,学到的东西就越多! * 参与开源项目:向 GitHub 上的开源项目贡献代码,与社区高手交流,这是最好的学习方式! 3. **持续学习与社区交流:** * **阅读源码:** 尝试阅读一些流行的 Node.js 库和框架的源码,了解它们是如何设计和工作的。 * **关注技术博客和社区:** 保持对 Node.js 最新技术趋势和最佳实践的关注。 * **参与技术交流:** 参加技术大会、线上研讨会,多和同行交流经验。 **记住!学习是一个永无止境的过程!** 你现在已经站在了一个非常棒的起点上。继续保持好奇心,持续动手实践,不断挑战自己,你一定能在 Node.js 的世界里成为一名真正的“宗师”! **好了,各位 Node.js 的英雄们,本次 Node.js 学习大纲的课程就到这里!祝你们在未来的开发生涯中一帆风顺,写出更多精彩的应用!** **咱们江湖再见!**
配图 (可多选)
选择新图片文件或拖拽到此处
标签
更新文章
删除文章