Express.js 零基础入门指南
Express.js 零基础入门指南
本指南将帮助您从零开始,逐步掌握 Express.js 框架,学会使用它构建强大的 Web 应用。
🗺️ 学习路径图
学习建议:
- ⏰ 总时间:4-8 周(根据基础不同)
- 📅 每天学习:1-2 小时
- 🎯 学习方式:理论 + 实践相结合
- 🔄 循环学习:先快速浏览,再深入细节
目录
- 第一步:了解什么是 Express.js
- 第二步:前置知识准备
- 第三步:5分钟快速入门
- 第四步:创建第一个 Express 应用(详细版)
- 第五步:路由基础
- 第六步:中间件
- 第七步:静态文件服务
- 第八步:请求处理
- 第九步:响应处理
- 第十步:模板引擎
- 第十一步:错误处理
- 第十二步:调试技巧
- 第十三步:RESTful API 设计
- 第十四步:数据库集成
- 第十五步:测试基础
- 第十六步:实战练习
- 第十七步:项目结构最佳实践
- 第十八步:部署上线
- 第十九步:学习资源推荐
- 第二十步:常见问题
- 第二十一步:性能优化
- 第二十二步:安全最佳实践
- 第二十三步:实时通信
- 第二十四步:GraphQL 入门
- 第二十五步:微服务架构
- 第二十六步:下一步
第一步:了解什么是 Express.js
Express.js(简称 Express)是一个基于 Node.js 的轻量级 Web 应用框架,它提供了丰富的功能来帮助开发人员快速构建 Web 应用和 API。
Express.js 的特点
- ✅ 简洁灵活:最小化核心,提供强大的扩展能力
- ✅ 丰富的中间件:通过中间件实现各种功能
- ✅ 强大的路由系统:支持灵活的路由定义
- ✅ 易于学习:对新手友好,文档完善
- ✅ 生态系统庞大:有大量的第三方包和工具
Express.js vs 原生 Node.js
| 特性 | 原生 Node.js | Express.js |
|---|---|---|
| 代码量 | 多 | 少 |
| 路由处理 | 手动实现 | 内置支持 |
| 中间件 | 需要自己实现 | 丰富的中间件生态 |
| 学习曲线 | 陡峭 | 平缓 |
| 适用场景 | 简单应用 | 中大型 Web 应用/API |
第二步:前置知识准备
在开始学习 Express.js 之前,您需要掌握以下基础知识:
必备知识
- JavaScript 基础:变量、函数、对象、数组等
- Node.js 基础:模块系统、npm 包管理
- HTTP 基础:请求方法(GET、POST 等)、状态码
- 命令行操作:基本的终端命令
WARNING⚠️ 重要提醒:如果您还没有掌握以上基础知识,强烈建议先学习这些内容,否则学习 Express.js 会非常吃力。学习编程要循序渐进,打好基础是成功的关键!
推荐学习资源
如果您还没有掌握这些知识,建议先学习以下资源:
- Node.js 入门指南:针对 Node.js 的零基础编程入门指南
- JavaScript 教程:MDN JavaScript 教程
第三步:5分钟快速入门
目标:在 5 分钟内创建并运行你的第一个 Express.js 应用,先跑起来,再深入理解!
🚀 快速开始(3步搞定)
第 1 步:创建项目(1分钟)
打开终端,依次执行以下命令:
# 创建项目目录mkdir my-express-appcd my-express-app
# 初始化项目npm init -y
# 安装 Expressnpm install express第 2 步:创建服务器(2分钟)
创建一个名为 app.js 的文件,复制以下代码:
// 引入 Expressconst express = require('express');
// 创建应用const app = express();
// 定义路由:访问首页时返回"Hello, Express!"app.get('/', (req, res) => { res.send('Hello, Express! 🎉');});
// 启动服务器app.listen(3000, () => { console.log('✅ 服务器已启动!访问 http://localhost:3000');});第 3 步:运行应用(1分钟)
# 运行应用node app.js打开浏览器,访问 http://localhost:3000,你会看到:
Hello, Express! 🎉恭喜! 你已经成功创建了第一个 Express.js 应用!🎊
💡 现在发生了什么?
让我们用简单的语言解释一下刚才的代码:
const express = require('express');这行代码的意思是:
- 从
express这个”工具箱”里取出 Express - 就像从厨房拿出”菜刀”准备做饭
const app = express();这行代码的意思是:
- 创建一个 Express 应用
- 就像准备好了一个”厨房”
app.get('/', (req, res) => { res.send('Hello, Express! 🎉');});这行代码的意思是:
- 当有人访问首页(
/)时 - 返回”Hello, Express! 🎉”
- 就像客人来了,你给他端上一杯茶
app.listen(3000, () => { console.log('✅ 服务器已启动!访问 http://localhost:3000');});这行代码的意思是:
- 在 3000 端口启动服务器
- 就像打开餐厅的大门,准备迎接客人
🎯 试试修改代码
让我们修改一下代码,让它更有趣:
const express = require('express');const app = express();
// 添加更多路由app.get('/', (req, res) => { res.send('🏠 欢迎来到我的网站!');});
app.get('/about', (req, res) => { res.send('👋 这是关于页面');});
app.get('/contact', (req, res) => { res.send('📧 联系我们:contact@example.com');});
// 启动服务器app.listen(3000, () => { console.log('✅ 服务器已启动!'); console.log('🏠 首页:http://localhost:3000'); console.log('👋 关于:http://localhost:3000/about'); console.log('📧 联系:http://localhost:3000/contact');});现在你可以访问:
http://localhost:3000- 首页http://localhost:3000/about- 关于页面http://localhost:3000/contact- 联系页面
🎉 常见问题
Q: 我看到 “command not found” 错误
- A: 说明你还没有安装 Node.js,请先访问 https://nodejs.org 下载并安装
Q: 端口 3000 被占用了怎么办?
- A: 把
3000改成其他数字,比如3001:
app.listen(3001, () => { console.log('✅ 服务器已启动!访问 http://localhost:3001');});Q: 如何停止服务器?
- A: 在终端按
Ctrl + C
🚀 下一步
现在你已经成功运行了第一个 Express.js 应用!接下来:
- 继续学习:阅读后面的章节,深入了解 Express.js
- 动手实践:尝试修改代码,添加更多功能
- 查看文档:遇到问题时,查看 Express.js 官方文档
TIP如果你还不太理解上面的代码,没关系!继续往下看,后面的章节会详细解释每一个概念。现在最重要的是先跑起来,建立信心!
第四步:创建第一个 Express 应用(详细版)
4.1 初始化项目
首先,创建一个新的项目目录并初始化:
# 创建项目目录mkdir my-express-appcd my-express-app
# 初始化 npm 项目npm init -y
# 安装 Expressnpm install express4.2 创建基本的服务器
创建一个名为 app.js 的文件:
// 引入 Express 模块const express = require('express');
// 创建 Express 应用const app = express();
// 定义端口const PORT = 3000;
// 定义路由app.get('/', (req, res) => { res.send('Hello, Express!');});
// 启动服务器app.listen(PORT, () => { console.log(`服务器运行在 http://localhost:${PORT}`);});4.3 运行应用
# 运行应用node app.js打开浏览器访问 http://localhost:3000,您将看到 “Hello, Express!”。
TIP在开发过程中,建议使用
nodemon工具,它可以在代码修改后自动重启服务器:Terminal window npm install -D nodemonnodemon app.js
第五步:路由基础
路由是 Express.js 的核心功能之一,它决定了应用如何响应客户端请求。
5.1 基本路由
// GET 请求app.get('/', (req, res) => { res.send('首页');});
// POST 请求app.post('/users', (req, res) => { res.send('创建用户');});
// PUT 请求app.put('/users/:id', (req, res) => { res.send(`更新用户 ${req.params.id}`);});
// DELETE 请求app.delete('/users/:id', (req, res) => { res.send(`删除用户 ${req.params.id}`);});NOTE💡 路由定义顺序很重要:Express.js 会按照路由定义的顺序进行匹配。具体路由应该放在通配路由之前,否则通配路由会先匹配,导致具体路由永远不会执行。
5.2 路由参数
// 路由参数app.get('/users/:userId', (req, res) => { const userId = req.params.userId; res.send(`用户 ID: ${userId}`);});
// 多个参数app.get('/posts/:year/:month', (req, res) => { const { year, month } = req.params; res.send(`文章归档: ${year}年${month}月`);});5.3 查询参数
app.get('/search', (req, res) => { const { q, page } = req.query; res.send(`搜索: ${q}, 页码: ${page}`);});
// 访问: /search?q=nodejs&page=1// 输出: 搜索: nodejs, 页码: 15.4 路由模块化
为了保持代码整洁,建议将路由单独放在一个文件中:
const express = require('express');const router = express.Router();
router.get('/', (req, res) => { res.send('用户列表');});
router.get('/:id', (req, res) => { res.send(`用户详情: ${req.params.id}`);});
module.exports = router;在主应用中使用路由:
const usersRouter = require('./routes/users');app.use('/users', usersRouter);第六步:中间件
中间件是 Express.js 的一个重要概念,它可以访问请求对象(req)、响应对象(res)和下一个中间件函数。
🗺️ 中间件执行流程
中间件执行顺序:
- 请求进入应用
- 按照注册顺序依次执行中间件
- 每个中间件可以修改请求/响应对象
- 调用
next()将控制权传递给下一个中间件 - 路由处理器执行业务逻辑
- 响应返回(可能经过响应中间件)
- 错误处理中间件捕获并处理错误
💡 什么是中间件?(通俗解释)
中间件就像工厂的流水线,每个工位(中间件)都有特定的任务:
🏭 生活比喻:流水线
想象一个汽车制造流水线:
原材料 → [工位1:检查材料] → [工位2:组装零件] → [工位3:喷漆] → [工位4:质检] → 成品在 Express.js 中:
用户请求 → [中间件1:记录日志] → [中间件2:解析数据] → [中间件3:验证身份] → [路由处理器] → 响应每个中间件就像流水线上的一个工位:
- 工位1(日志中间件):记录”来了一个请求”
- 工位2(解析中间件):把用户提交的数据整理好
- 工位3(验证中间件):检查用户是否有权限
- 质检(错误处理中间件):如果前面的工位出了问题,这里会处理
🧽 另一个比喻:过滤网
中间件也像多层过滤网:
水 → [粗网:过滤大颗粒] → [细网:过滤小颗粒] → [活性炭:吸附杂质] → [消毒:杀菌] → 干净水在 Express.js 中:
请求 → [日志中间件] → [解析中间件] → [验证中间件] → [路由处理器] → 响应每一层过滤网(中间件)都会检查或处理请求,然后传递给下一层。
🎯 中间件的核心特点
- 顺序执行:像流水线一样,按照注册的顺序依次执行
- 可以修改:每个中间件可以查看和修改请求/响应数据
- 可以终止:某个中间件可以决定不继续传递(比如验证失败)
- 可以跳过:某些中间件只对特定路由生效
📝 生活中的例子
餐厅点餐流程:
- 门口服务员(中间件1):欢迎顾客,记录人数
- 领位员(中间件2):引导顾客到座位
- 服务员(中间件3):介绍菜单,记录点餐
- 厨师(路由处理器):烹饪食物
- 传菜员(中间件4):检查菜品质量
- 服务员(中间件5):上菜,结账
每个环节都是”中间件”,负责处理一部分任务,然后传递给下一个环节。
6.1 应用级中间件
// 应用级中间件app.use((req, res, next) => { console.log(`${new Date().toLocaleString()} - ${req.method} ${req.url}`); next();});
// 路由级中间件app.get('/admin', (req, res, next) => { // 验证逻辑 const isAuthenticated = true; if (!isAuthenticated) { return res.status(401).send('未授权'); } next();}, (req, res) => { res.send('管理员页面');});6.2 内置中间件
// 解析 JSON 请求体app.use(express.json());
// 解析 URL 编码的请求体app.use(express.urlencoded({ extended: true }));
// 静态文件服务app.use(express.static('public'));6.3 错误处理中间件
// 错误处理中间件(必须放在最后)app.use((err, req, res, next) => { console.error(err.stack); res.status(500).send('服务器错误!');});WARNING⚠️ 重要:错误处理中间件必须放在所有路由和其他中间件之后,因为它需要捕获前面的所有错误。如果放在前面,后面的错误就无法被捕获了!
6.4 第三方中间件
# 安装常用中间件npm install morgan cors helmetconst morgan = require('morgan');const cors = require('cors');const helmet = require('helmet');
// 日志记录app.use(morgan('dev'));
// 跨域支持app.use(cors());
// 安全头设置app.use(helmet());第七步:静态文件服务
Express.js 可以轻松地提供静态文件服务,如 HTML、CSS、JavaScript、图片等。
7.1 基本用法
// 设置静态文件目录app.use(express.static('public'));
// 访问 public/index.html// URL: http://localhost:3000/index.html
// 访问 public/images/logo.png// URL: http://localhost:3000/images/logo.png7.2 多个静态目录
// 主静态文件目录app.use(express.static('public'));
// 上传文件目录app.use('/uploads', express.static('uploads'));7.3 虚拟路径
// 使用虚拟路径app.use('/static', express.static('public'));
// 实际文件: public/index.html第八步:请求处理
🗺️ 请求-响应生命周期
请求处理关键点:
- 请求体解析(JSON、URL 编码)
- 路由参数提取
- 查询参数解析
- 请求头信息获取
- 文件上传处理
8.1 获取请求数据
app.use(express.json());app.use(express.urlencoded({ extended: true }));
app.post('/api/users', (req, res) => { // 获取 JSON 数据 const { name, email } = req.body;
// 获取查询参数 const { page, limit } = req.query;
// 获取路由参数 const userId = req.params.id;
// 获取请求头 const userAgent = req.headers['user-agent'];
res.json({ name, email, page, limit, userId, userAgent });});8.2 文件上传
npm install multerconst multer = require('multer');
// 配置存储const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { cb(null, Date.now() + '-' + file.originalname); }});
const upload = multer({ storage });
// 单文件上传app.post('/upload', upload.single('file'), (req, res) => { if (!req.file) { return res.status(400).send('没有上传文件'); } res.send('文件上传成功!');});
// 多文件上传app.post('/uploads', upload.array('files', 5), (req, res) => { res.send('文件上传成功!');});第九步:响应处理
9.1 发送响应
// 发送文本app.get('/', (req, res) => { res.send('Hello, World!');});
// 发送 JSONapp.get('/api/data', (req, res) => { res.json({ message: 'Hello, JSON!' });});
// 发送状态码app.get('/not-found', (req, res) => { res.status(404).send('页面未找到');});
// 发送文件app.get('/download', (req, res) => { res.download('/path/to/file.pdf');});
// 重定向app.get('/redirect', (req, res) => { res.redirect('/new-location');});9.2 设置响应头
app.get('/api/data', (req, res) => { res.set('Content-Type', 'application/json'); res.set('X-Custom-Header', 'Custom Value'); res.json({ message: 'Hello!' });});第十步:模板引擎
模板引擎让您可以使用模板文件生成动态 HTML。
10.1 使用 EJS
npm install ejs// 设置模板引擎app.set('view engine', 'ejs');app.set('views', './views');
// 渲染模板app.get('/', (req, res) => { res.render('index', { title: '首页', user: '张三' });});创建模板文件 views/index.ejs:
<!DOCTYPE html><html><head> <title><%= title %></title></head><body> <h1>欢迎, <%= user %>!</h1></body></html>10.2 使用 Pug
npm install pugapp.set('view engine', 'pug');app.set('views', './views');
app.get('/', (req, res) => { res.render('index', { title: '首页', user: '张三' });});创建模板文件 views/index.pug:
doctype htmlhtml head title= title body h1 欢迎, #{user}!第十一步:错误处理
TIP💡 错误处理是生产应用的必备技能:良好的错误处理可以防止应用崩溃,提供友好的错误信息,帮助开发者快速定位问题。不要忽视错误处理,它会让你的应用更加健壮!
11.1 同步错误处理
app.get('/error', (req, res) => { throw new Error('这是一个错误');});11.2 异步错误处理
// 使用 next 传递错误app.get('/async-error', (req, res, next) => { setTimeout(() => { next(new Error('异步错误')); }, 1000);});
// 使用 async/awaitapp.get('/async-await', async (req, res, next) => { try { const data = await someAsyncFunction(); res.json(data); } catch (error) { next(error); }});11.3 自定义错误处理
// 404 错误处理app.use((req, res, next) => { res.status(404).render('404', { url: req.url });});
// 全局错误处理app.use((err, req, res, next) => { console.error(err.stack); res.status(err.status || 500); res.json({ error: { message: err.message, status: err.status || 500 } });});第十二步:调试技巧
调试是开发过程中不可或缺的技能,掌握调试技巧可以大大提高开发效率。
💡 为什么需要调试?
在开发 Express.js 应用时,你可能会遇到各种问题:
- 🐛 代码逻辑错误
- 🐛 中间件顺序错误
- 🐛 路由不匹配
- 🐛 数据库连接失败
- 🐛 异步操作错误
🗺️ 调试流程
12.1 使用 console.log 调试
最简单的调试方法就是使用 console.log 输出信息:
app.get('/api/users/:id', async (req, res) => { console.log('收到请求:', req.url); console.log('请求参数:', req.params); console.log('查询参数:', req.query);
const userId = req.params.id; console.log('用户 ID:', userId);
try { const user = await User.findById(userId); console.log('查询结果:', user);
if (!user) { console.log('用户不存在'); return res.status(404).json({ error: '用户不存在' }); }
res.json(user); } catch (error) { console.error('查询错误:', error); res.status(500).json({ error: '服务器错误' }); }});💡 console.log 的最佳实践
✅ 推荐做法:
// 使用有意义的标签console.log('[用户服务] 查询用户 ID:', userId);console.log('[数据库] 连接成功');
// 使用对象展开console.log('请求数据:', { ...req.body });
// 使用时间戳console.log(`[${new Date().toISOString()}] 处理请求: ${req.method} ${req.url}`);❌ 不推荐做法:
// 没有标签console.log(userId);
// 输出过多信息console.log(req); // 会输出整个请求对象,非常混乱
// 忘记删除调试代码console.log('调试信息'); // 生产环境不应该有12.2 使用 VS Code 调试
VS Code 提供了强大的调试功能,可以设置断点、单步执行、查看变量等。
📝 配置 launch.json
在项目根目录创建 .vscode/launch.json 文件:
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "启动 Express 应用", "program": "${workspaceFolder}/app.js", "skipFiles": [ "<node_internals>/**" ] } ]}🎯 使用断点调试
- 设置断点:在代码行号左侧点击,出现红点
- 启动调试:按
F5或点击调试按钮 - 单步执行:
F10:单步跳过(Step Over)F11:单步进入(Step Into)Shift + F11:单步跳出(Step Out)
- 查看变量:在调试面板中查看变量的值
- 查看调用栈:了解代码执行路径
📊 调试面板功能
| 功能 | 快捷键 | 说明 |
|---|---|---|
| 继续执行 | F5 | 继续运行到下一个断点 |
| 单步跳过 | F10 | 执行当前行,不进入函数 |
| 单步进入 | F11 | 进入当前行的函数内部 |
| 单步跳出 | Shift+F11 | 跳出当前函数 |
| 重启调试 | Ctrl+Shift+F5 | 重新启动调试 |
| 停止调试 | Shift+F5 | 停止调试 |
12.3 使用调试中间件
Express.js 有一些专门的调试中间件可以帮助你排查问题。
morgan(HTTP 请求日志)
npm install morganconst morgan = require('morgan');
// 开发环境:详细日志if (process.env.NODE_ENV === 'development') { app.use(morgan('dev'));}
// 生产环境:简洁日志if (process.env.NODE_ENV === 'production') { app.use(morgan('combined'));}
// 自定义日志格式app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));morgan 预设格式:
| 格式 | 说明 | 示例 |
|---|---|---|
dev | 开发环境,彩色输出 | GET /api/users 200 15 ms |
combined | Apache 组合格式 | 完整的访问日志 |
common | Apache 通用格式 | 简化的访问日志 |
short | 短格式 | GET /api/users 200 |
tiny | 最简格式 | GET /api/users 200 |
debug(调试信息)
npm install debugconst debug = require('debug')('myapp:server');const debugUser = require('debug')('myapp:user');
app.get('/api/users/:id', async (req, res) => { debug('收到用户查询请求');
const userId = req.params.id; debugUser('查询用户 ID: %s', userId);
try { const user = await User.findById(userId); debugUser('查询结果: %O', user);
if (!user) { debug('用户不存在'); return res.status(404).json({ error: '用户不存在' }); }
res.json(user); } catch (error) { debug('查询错误: %O', error); res.status(500).json({ error: '服务器错误' }); }});使用 debug:
# 启用所有调试信息DEBUG=* node app.js
# 只启用特定命名空间的调试DEBUG=myapp:* node app.js
# 只启用用户相关的调试DEBUG=myapp:user node app.js12.4 常见错误及解决方法
错误 1:端口被占用
错误信息:
Error: listen EADDRINUSE: address already in use :::3000原因: 端口 3000 已经被其他程序占用
解决方案:
方法 1:找到并关闭占用端口的程序
# 查找占用端口的进程(Linux/Mac)lsof -i :3000
# 查找占用端口的进程(Windows)netstat -ano | findstr :3000
# 杀死进程kill -9 <PID> # Linux/Mactaskkill /PID <PID> /F # Windows方法 2:使用其他端口
const PORT = process.env.PORT || 3001; // 改用 3001方法 3:自动查找可用端口
npm install detect-portconst detectPort = require('detect-port');
detectPort(3000).then(port => { if (port === 3000) { console.log('端口 3000 可用'); } else { console.log(`端口 3000 被占用,使用端口 ${port}`); }
app.listen(port, () => { console.log(`服务器运行在端口 ${port}`); });});错误 2:路由不匹配
问题: 定义了路由,但访问时返回 404
可能原因:
- 路由顺序错误
// ❌ 错误:通配路由放前面app.get('/api/users/*', (req, res) => { res.send('所有用户');});
app.get('/api/users/admin', (req, res) => { res.send('管理员'); // 永远不会执行});
// ✅ 正确:具体路由放前面app.get('/api/users/admin', (req, res) => { res.send('管理员');});
app.get('/api/users/*', (req, res) => { res.send('所有用户');});- 中间件顺序错误
// ❌ 错误:路由在静态文件中间件之前app.get('/index.html', (req, res) => { res.send('自定义首页');});
app.use(express.static('public')); // 会先匹配静态文件
// ✅ 正确:静态文件中间件在前app.use(express.static('public'));
app.get('/index.html', (req, res) => { res.send('自定义首页'); // 只有找不到静态文件时才会执行});- 路径大小写问题
// 路由定义app.get('/api/Users', (req, res) => { res.send('用户列表');});
// ❌ 错误:大小写不匹配// 访问:/api/users (返回 404)
// ✅ 正确:大小写匹配// 访问:/api/Users错误 3:请求体解析失败
问题: req.body 是 undefined
原因: 没有使用解析中间件
解决方案:
// ✅ 添加解析中间件app.use(express.json()); // 解析 JSONapp.use(express.urlencoded({ extended: true })); // 解析 URL 编码
app.post('/api/users', (req, res) => { console.log(req.body); // 现在可以正确获取请求体 res.json(req.body);});错误 4:CORS 跨域问题
错误信息:
Access to XMLHttpRequest at 'http://localhost:3000/api/users'from origin 'http://localhost:8080' has been blocked by CORS policy原因: 浏览器的同源策略限制
解决方案:
npm install corsconst cors = require('cors');
// 允许所有来源(开发环境)app.use(cors());
// 只允许特定来源(生产环境)app.use(cors({ origin: 'https://yourdomain.com'}));
// 允许多个来源app.use(cors({ origin: ['https://example1.com', 'https://example2.com']}));
// 自定义 CORS 配置app.use(cors({ origin: function (origin, callback) { // 允许没有 origin 的请求(如移动应用) if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) === -1) { const msg = 'CORS policy for this site does not allow access from the specified Origin.'; return callback(new Error(msg), false); } return callback(null, true); }}));错误 5:异步错误未捕获
问题: 异步操作中的错误没有被捕获,导致服务器崩溃
解决方案:
方法 1:使用 try-catch
app.get('/api/users/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); res.json(user); } catch (error) { next(error); // 传递给错误处理中间件 }});方法 2:使用全局错误处理
// 捕获未处理的 Promise 拒绝process.on('unhandledRejection', (reason, promise) => { console.error('未处理的 Promise 拒绝:', reason);});
// 捕获未捕获的异常process.on('uncaughtException', (error) => { console.error('未捕获的异常:', error); process.exit(1); // 退出进程});方法 3:使用 express-async-errors
npm install express-async-errorsrequire('express-async-errors'); // 必须在引入 express 之后
// 现在可以直接使用 async/await,不需要 try-catchapp.get('/api/users/:id', async (req, res) => { const user = await User.findById(req.params.id); res.json(user); // 如果出错,会自动传递给错误处理中间件});错误 6:数据库连接失败
错误信息:
MongoNetworkError: failed to connect to server [localhost:27017]解决方案:
- 检查数据库服务是否运行
# MongoDB# Linux/Macsudo systemctl status mongod
# Windows# 检查 MongoDB 服务是否运行
# 启动 MongoDB# Linux/Macsudo systemctl start mongod
# Windows# 在服务管理器中启动 MongoDB 服务- 检查连接字符串
// ❌ 错误:可能拼写错误mongoose.connect('mongodb://localhost:27017/myapp');
// ✅ 正确:添加错误处理mongoose.connect('mongodb://localhost:27017/myapp', { useNewUrlParser: true, useUnifiedTopology: true}).then(() => console.log('数据库连接成功')).catch(error => { console.error('数据库连接失败:', error); process.exit(1);});- 检查网络连接
// 测试数据库连接const mongoose = require('mongoose');
mongoose.connection.on('connected', () => { console.log('MongoDB 连接成功');});
mongoose.connection.on('error', (error) => { console.error('MongoDB 连接错误:', error);});
mongoose.connection.on('disconnected', () => { console.log('MongoDB 连接断开');});错误 7:内存泄漏
症状: 应用运行一段时间后变慢,最终崩溃
常见原因:
- 全局变量累积
// ❌ 错误:全局变量无限增长const cache = {};
app.get('/api/data', (req, res) => { cache[Date.now()] = req.query.data; // 缓存无限增长 res.json({ success: true });});
// ✅ 正确:限制缓存大小const cache = new Map();const MAX_CACHE_SIZE = 1000;
app.get('/api/data', (req, res) => { if (cache.size >= MAX_CACHE_SIZE) { // 删除最旧的条目 const oldestKey = cache.keys().next().value; cache.delete(oldestKey); }
cache.set(Date.now(), req.query.data); res.json({ success: true });});- 事件监听器未移除
// ❌ 错误:事件监听器累积app.get('/api/data', (req, res) => { someEmitter.on('data', handler); // 每次请求都添加监听器 res.json({ success: true });});
// ✅ 正确:使用 once 或移除监听器app.get('/api/data', (req, res) => { someEmitter.once('data', handler); // 只执行一次 res.json({ success: true });});- 定时器未清理
// ❌ 错误:定时器未清理app.post('/api/schedule', (req, res) => { setInterval(() => { // 定时任务 }, 1000); // 每次请求都创建定时器 res.json({ success: true });});
// ✅ 正确:清理定时器const timers = new Set();
app.post('/api/schedule', (req, res) => { const timer = setInterval(() => { // 定时任务 }, 1000);
timers.add(timer);
// 在某个时机清理 // clearInterval(timer); // timers.delete(timer);
res.json({ success: true });});12.5 性能分析
使用 Node.js 性能分析器
# 启动应用时启用性能分析node --prof app.js
# 生成性能报告node --prof-process isolate-*.log > profile.txt使用 clinic.js
npm install -g clinic
# 启动应用并分析clinic doctor -- node app.js使用 Chrome DevTools
// 在 app.js 中添加const inspector = require('inspector');const fs = require('fs');
if (process.env.NODE_ENV === 'development') { inspector.open(9229, '0.0.0.0'); console.log('调试器运行在 http://localhost:9229');}然后在 Chrome 浏览器中打开 chrome://inspect,点击 “Inspect” 进行调试。
12.6 日志管理
使用 winston(专业日志库)
npm install winstonconst winston = require('winston');
const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports: [ // 错误日志 new winston.transports.File({ filename: 'error.log', level: 'error' }), // 所有日志 new winston.transports.File({ filename: 'combined.log' }) ]});
// 开发环境同时输出到控制台if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple() }));}
// 使用日志logger.info('服务器启动');logger.error('数据库连接失败', { error: err });logger.warn('内存使用率过高', { usage: '90%' });12.7 调试最佳实践
- 开发环境使用详细日志
if (process.env.NODE_ENV === 'development') { app.use(morgan('dev'));}- 生产环境使用简洁日志
if (process.env.NODE_ENV === 'production') { app.use(morgan('combined', { stream: fs.createWriteStream('./access.log', { flags: 'a' }) }));}- 使用环境变量控制调试
const DEBUG = process.env.DEBUG === 'true';
if (DEBUG) { app.use((req, res, next) => { console.log(`${req.method} ${req.url}`); console.log('Headers:', req.headers); console.log('Body:', req.body); next(); });}- 定期清理调试代码
// ❌ 不要在生产环境留下调试代码console.log('调试信息');
// ✅ 使用条件判断if (process.env.NODE_ENV === 'development') { console.log('调试信息');}第十三步:RESTful API 设计
RESTful API 是一种设计风格,使用 HTTP 方法和资源路径来构建 API。
🗺️ RESTful API 设计流程
RESTful API 设计原则:
- 资源导向:使用名词而非动词
- 统一接口:使用标准 HTTP 方法
- 无状态:每个请求包含所有必要信息
- 可缓存:响应应该明确是否可缓存
- 分层系统:客户端不需要知道是否直接连接服务器
13.1 RESTful 原则
| HTTP 方法 | 操作 | 示例路径 |
|---|---|---|
| GET | 获取资源 | /api/users |
| POST | 创建资源 | /api/users |
| PUT | 更新资源 | /api/users/ |
| PATCH | 部分更新 | /api/users/ |
| DELETE | 删除资源 | /api/users/ |
💡 RESTful API 设计原则详解
📚 什么是 RESTful API?
RESTful API 是一种基于 REST(Representational State Transfer,表述性状态转移) 架构风格的 API 设计方式。它使用 HTTP 协议的标准方法来操作资源,使 API 更加简洁、统一和易于理解。
🏢 生活比喻:图书馆管理系统
想象一个图书馆的图书管理系统:
| 操作 | 传统方式 | RESTful 方式 |
|---|---|---|
| 查看所有书 | getAllBooks() | GET /api/books |
| 查看一本书 | getBookById(123) | GET /api/books/123 |
| 借一本书 | borrowBook(123) | POST /api/books/123/borrow |
| 归还一本书 | returnBook(123) | POST /api/books/123/return |
| 添加新书 | addBook(bookInfo) | POST /api/books |
| 更新书信息 | updateBook(123, newInfo) | PUT /api/books/123 |
| 删除一本书 | deleteBook(123) | DELETE /api/books/123 |
RESTful 方式的优点:
- ✅ 统一:所有操作都使用标准 HTTP 方法
- ✅ 直观:URL 清晰地表达了资源和操作
- ✅ 无状态:每个请求都包含所有必要信息
- ✅ 可缓存:GET 请求可以被缓存
🎯 RESTful 设计的核心原则
1. 资源导向(Resource-Oriented)
使用名词而非动词来命名资源:
❌ 错误示例:
GET /getUsersPOST /createUserPUT /updateUser/1DELETE /deleteUser/1✅ 正确示例:
GET /api/usersPOST /api/usersPUT /api/users/1DELETE /api/users/1原则:URL 应该代表资源,而不是动作。
2. 统一接口(Uniform Interface)
使用标准 HTTP 方法:
| HTTP 方法 | 操作 | 幂等性 | 安全性 |
|---|---|---|---|
| GET | 获取资源 | ✅ 是 | ✅ 是 |
| POST | 创建资源 | ❌ 否 | ❌ 否 |
| PUT | 完整更新 | ✅ 是 | ❌ 否 |
| PATCH | 部分更新 | ❌ 否 | ❌ 否 |
| DELETE | 删除资源 | ✅ 是 | ❌ 否 |
幂等性:多次执行相同操作,结果不变 安全性:操作不会改变服务器状态
3. 无状态(Stateless)
每个请求都包含所有必要信息,服务器不保存客户端状态:
❌ 错误示例(有状态):
# 第一次请求POST /api/login{ username: "alice", password: "123456" }
# 第二次请求(依赖第一次请求的状态)GET /api/profile # 服务器需要知道当前用户是谁✅ 正确示例(无状态):
# 第一次请求POST /api/login{ username: "alice", password: "123456" }# 返回:{ token: "abc123" }
# 第二次请求(携带 token)GET /api/profileAuthorization: Bearer abc1234. 可缓存(Cacheable)
GET 请求应该可以被缓存:
// 设置缓存头app.get('/api/posts', (req, res) => { res.set('Cache-Control', 'public, max-age=3600'); // 缓存 1 小时 res.json(posts);});5. 分层系统(Layered System)
客户端不需要知道是否直接连接服务器:
客户端 → 负载均衡器 → API 网关 → 应用服务器 → 数据库📋 RESTful API 设计最佳实践
1. 使用复数形式命名资源
✅ 正确:
GET /api/usersGET /api/postsGET /api/comments❌ 错误:
GET /api/userGET /api/postGET /api/comment2. 使用小写字母和连字符
✅ 正确:
GET /api/user-profilesGET /api/blog-posts❌ 错误:
GET /api/userProfiles # 驼峰命名GET /api/user_profiles # 下划线GET /api/UserProfiles # 大写3. 使用嵌套资源表示关系
✅ 正确:
GET /api/users/123/posts # 获取用户的文章GET /api/posts/456/comments # 获取文章的评论POST /api/users/123/posts # 为用户创建文章❌ 错误:
GET /api/posts?userId=123 # 使用查询参数GET /api/user/123/post # 使用单数4. 合理使用查询参数
✅ 正确:
GET /api/posts?page=2&limit=10 # 分页GET /api/posts?category=tech # 筛选GET /api/posts?sort=date&order=desc # 排序GET /api/posts?q=nodejs # 搜索5. 使用标准状态码
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 | OK | 请求成功 |
| 201 | Created | 资源创建成功 |
| 204 | No Content | 删除成功(无返回内容) |
| 400 | Bad Request | 请求参数错误 |
| 401 | Unauthorized | 未认证 |
| 403 | Forbidden | 无权限 |
| 404 | Not Found | 资源不存在 |
| 409 | Conflict | 资源冲突(如重复创建) |
| 500 | Internal Server Error | 服务器错误 |
6. 统一的响应格式
[{ "success": true, "data": { "id": 1, "name": "张三" }},{ "success": false, "error": { "code": "USER_NOT_FOUND", "message": "用户不存在", "details": {} }}]7. 版本控制
✅ 正确:
GET /api/v1/usersGET /api/v2/users❌ 错误:
GET /api/users/v1GET /api/users?version=1🚫 常见错误示例
错误 1:在 URL 中使用动词
❌ 错误:
GET /api/getUsersPOST /api/createUserPUT /api/updateUser/1✅ 正确:
GET /api/usersPOST /api/usersPUT /api/users/1错误 2:返回不一致的数据结构
❌ 错误:
# 成功时{ "id": 1, "name": "张三" }
# 失败时{ "error": "用户不存在" }✅ 正确:
# 成功时{ "success": true, "data": { "id": 1, "name": "张三" }}
# 失败时{ "success": false, "error": { "code": "USER_NOT_FOUND", "message": "用户不存在" }}错误 3:不使用正确的 HTTP 状态码
❌ 错误:
# 资源不存在GET /api/users/999# 返回 200 OK{ "error": "用户不存在" }✅ 正确:
# 资源不存在GET /api/users/999# 返回 404 Not Found{ "success": false, "error": { "code": "USER_NOT_FOUND", "message": "用户不存在" }}13.2 实现用户 API
// 模拟数据库let users = [ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' }];
// 获取所有用户app.get('/api/users', (req, res) => { res.json(users);});
// 获取单个用户app.get('/api/users/:id', (req, res) => { const user = users.find(u => u.id === parseInt(req.params.id)); if (!user) { return res.status(404).json({ error: '用户不存在' }); } res.json(user);});
// 创建用户app.post('/api/users', (req, res) => { const { name, email } = req.body; const newUser = { id: users.length + 1, name, email }; users.push(newUser); res.status(201).json(newUser);});
// 更新用户app.put('/api/users/:id', (req, res) => { const user = users.find(u => u.id === parseInt(req.params.id)); if (!user) { return res.status(404).json({ error: '用户不存在' }); }
user.name = req.body.name || user.name; user.email = req.body.email || user.email;
res.json(user);});
// 删除用户app.delete('/api/users/:id', (req, res) => { const userIndex = users.findIndex(u => u.id === parseInt(req.params.id)); if (userIndex === -1) { return res.status(404).json({ error: '用户不存在' }); }
users.splice(userIndex, 1); res.status(204).send();});第十四步:数据库集成
💡 数据库设计思路与注意事项
🏗️ 数据库设计的重要性
数据库设计就像建筑的地基,设计得好,后续开发会事半功倍;设计得不好,后期维护会非常痛苦。
📋 数据库设计的基本原则
1. 理解业务需求
在开始设计之前,先问自己这些问题:
- 🤔 需要存储什么数据?(用户、文章、评论、订单…)
- 🤔 数据之间有什么关系?(用户-文章、文章-评论…)
- 🤔 数据的访问模式是什么?(频繁查询、偶尔更新…)
- 🤔 数据量有多大?(几千条、几百万条…)
2. 选择合适的数据库
| 数据库类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| MongoDB | 文档型数据、快速迭代 | 灵活、易扩展、无模式 | 事务支持较弱 |
| MySQL | 关系型数据、事务要求高 | 成熟、稳定、事务支持强 | 扩展性相对较差 |
| PostgreSQL | 复杂查询、数据分析 | 功能强大、支持 JSON | 配置相对复杂 |
| Redis | 缓存、会话、计数器 | 极快、支持多种数据结构 | 数据不能太大 |
生活比喻:
- MongoDB 就像文件夹,可以放任何类型的文件
- MySQL 就像Excel 表格,结构固定,关系明确
- Redis 就像白板,快速读写,但容量有限
3. 数据库设计原则
🎯 MongoDB 设计原则
原则 1:嵌入 vs 引用
嵌入(Embedding):把相关数据放在同一个文档中
✅ 适用场景:
- 数据量小(通常 < 1000 条)
- 数据经常一起查询
- 数据很少单独更新
{ "_id": "...", "name": "张三", "email": "zhangsan@example.com", "addresses": [ { "type": "home", "street": "长安街 1 号", "city": "北京" }, { "type": "work", "street": "科技路 2 号", "city": "上海" } ]}引用(Referencing):使用 ID 引用其他文档
✅ 适用场景:
- 数据量大
- 数据需要单独查询和更新
- 数据之间关系复杂
[{ "_id": "...", "name": "张三", "email": "zhangsan@example.com"},{ "_id": "...", "title": "我的第一篇文章", "content": "...", "author": "..."}]生活比喻:
- 嵌入:就像把照片直接贴在相册里
- 引用:就像在相册里写”见第 3 页”
原则 2:避免过深的嵌套
❌ 错误示例:
{ "title": "文章标题", "comments": [ { "text": "评论1", "replies": [ { "text": "回复1", "replies": [ { "text": "回复的回复", "replies": [] } ] } ] } ]}✅ 正确示例:
[{ "_id": "...", "title": "文章标题", "comments": ["...", "..."]},{ "_id": "...", "text": "评论1", "articleId": "...", "parentId": null},{ "_id": "...", "text": "回复1", "articleId": "...", "parentId": "..."}]🎯 MySQL 设计原则
原则 1:规范化(Normalization)
规范化是为了减少数据冗余,避免数据不一致。
第一范式(1NF):每个字段都是不可分割的
❌ 错误示例:
-- 用户字段包含多个值CREATE TABLE users ( id INT, name VARCHAR(100), phones VARCHAR(200) -- "13800000001,13800000002");✅ 正确示例:
-- 分开存储CREATE TABLE users ( id INT, name VARCHAR(100));
CREATE TABLE user_phones ( id INT, user_id INT, phone VARCHAR(20));第二范式(2NF):非主键字段完全依赖于主键
❌ 错误示例:
-- 订单表中包含商品信息CREATE TABLE orders ( id INT, user_id INT, product_name VARCHAR(100), -- 依赖于商品,不依赖于订单 product_price DECIMAL(10,2));✅ 正确示例:
-- 订单表CREATE TABLE orders ( id INT, user_id INT, created_at DATETIME);
-- 订单详情表CREATE TABLE order_items ( id INT, order_id INT, product_id INT, quantity INT, price DECIMAL(10,2));
-- 商品表CREATE TABLE products ( id INT, name VARCHAR(100), price DECIMAL(10,2));原则 2:合理使用索引
索引就像书的目录,可以快速找到内容。
✅ 应该创建索引的字段:
- 经常用于查询的字段(WHERE)
- 经常用于排序的字段(ORDER BY)
- 经常用于连接的字段(JOIN)
- 唯一性字段(UNIQUE)
-- 为常用查询字段创建索引CREATE INDEX idx_user_email ON users(email);CREATE INDEX idx_post_author ON posts(author_id);CREATE INDEX idx_post_created ON posts(created_at DESC);❌ 不应该创建索引的字段:
- 很少查询的字段
- 数据量很小的表
- 频繁更新的字段(索引会降低更新速度)
原则 3:使用外键约束
外键可以保证数据的一致性。
-- 创建外键约束CREATE TABLE posts ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200), author_id INT, FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE -- 删除用户时,同时删除其文章 ON UPDATE CASCADE -- 更新用户 ID 时,同时更新文章);⚠️ 常见错误和注意事项
错误 1:过度设计
❌ 错误: 为了”完美”,创建了太多的表和关系
✅ 正确: 根据实际需求设计,够用就好
错误 2:缺少索引
❌ 错误: 所有查询都是全表扫描
✅ 正确: 为常用查询字段创建索引
错误 3:不使用事务
❌ 错误: 转账操作没有事务保护
// 危险!如果第二步失败,钱已经扣了但没到账// 假设这是在 async 函数中await db.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1');await db.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2');✅ 正确: 使用事务确保数据一致性
// 安全!要么全部成功,要么全部失败// 假设这是在 async 函数中await db.beginTransaction();try { await db.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1'); await db.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2'); await db.commit();} catch (error) { await db.rollback(); throw error;}错误 4:不处理数据库错误
❌ 错误: 数据库错误直接抛出,暴露敏感信息
// 假设这是在 async 函数中const users = await db.query('SELECT * FROM users');// 如果数据库连接失败,会暴露数据库配置信息✅ 正确: 捕获并处理错误,返回友好的错误信息
// 假设这是在 async 函数中try { const users = await db.query('SELECT * FROM users'); res.json(users);} catch (error) { console.error('数据库错误:', error); res.status(500).json({ error: '获取用户列表失败' });}错误 5:N+1 查询问题
❌ 错误: 在循环中查询数据库
// 假设这是在 async 函数中// 获取所有文章const posts = await Post.find();
// 为每篇文章获取作者信息(N+1 查询)for (const post of posts) { post.author = await User.findById(post.authorId);}✅ 正确: 使用关联查询或批量查询
// 假设这是在 async 函数中// 使用 populate(MongoDB)const posts = await Post.find().populate('author');
// 或者批量查询const posts = await Post.find();const authorIds = posts.map(p => p.authorId);const authors = await User.find({ _id: { $in: authorIds } });📊 数据库性能优化建议
- 使用连接池:避免频繁创建和销毁连接
- 合理使用缓存:将热点数据缓存到 Redis
- 分页查询:避免一次性加载大量数据
- 定期维护:清理无用数据,优化索引
- 监控性能:使用工具监控慢查询
14.1 使用 MongoDB
npm install mongooseconst mongoose = require('mongoose');
// 连接数据库mongoose.connect('mongodb://localhost:27017/myapp', { useNewUrlParser: true, useUnifiedTopology: true});
// 定义模型const User = mongoose.model('User', { name: String, email: String});
// 创建用户app.post('/api/users', async (req, res) => { try { const user = await User.create(req.body); res.status(201).json(user); } catch (error) { res.status(400).json({ error: error.message }); }});
// 获取所有用户app.get('/api/users', async (req, res) => { try { const users = await User.find(); res.json(users); } catch (error) { res.status(500).json({ error: error.message }); }});WARNING⚠️ 数据库连接错误处理:在实际应用中,数据库连接可能会失败(如数据库服务未启动、网络问题等)。务必添加数据库连接的错误处理,否则应用会在启动时就崩溃。建议在
connect()后添加.catch()处理连接错误。
14.2 使用 MySQL
npm install mysql2const mysql = require('mysql2/promise');
// 创建连接池const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'password', database: 'myapp', waitForConnections: true, connectionLimit: 10, queueLimit: 0});
// 查询用户app.get('/api/users', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM users'); res.json(rows); } catch (error) { res.status(500).json({ error: error.message }); }});第十五步:测试基础
测试是保证代码质量的重要手段,可以帮助你发现和修复错误,提高代码的可靠性。
💡 为什么需要测试?
生活比喻:
- 不测试代码:就像盖房子不检查地基,随时可能倒塌
- 测试代码:就像开车前检查刹车,确保安全
测试的好处:
- ✅ 发现错误:在开发阶段发现错误,而不是在生产环境
- ✅ 保证质量:确保代码按预期工作
- ✅ 重构信心:修改代码时不用担心破坏现有功能
- ✅ 文档作用:测试代码本身就是最好的文档
- ✅ 提高效率:快速验证功能,减少手动测试时间
🗺️ 测试类型
15.1 单元测试
单元测试是测试代码中最小的可测试部分(通常是函数或方法)。
📦 安装 Jest
Jest 是一个流行的 JavaScript 测试框架。
npm install --save-dev jest在 package.json 中添加测试脚本:
{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }}🎯 编写第一个单元测试
创建一个简单的工具函数 utils.js:
/** * 计算两个数的和 * @param {number} a - 第一个数 * @param {number} b - 第二个数 * @returns {number} 两数之和 */function add(a, b) { return a + b;}
/** * 格式化日期 * @param {Date} date - 日期对象 * @returns {string} 格式化后的日期字符串 */function formatDate(date) { return date.toISOString().split('T')[0];}
/** * 验证邮箱格式 * @param {string} email - 邮箱地址 * @returns {boolean} 是否有效 */function isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email);}
module.exports = { add, formatDate, isValidEmail};创建测试文件 utils.test.js:
const { add, formatDate, isValidEmail } = require('./utils');
describe('add 函数测试', () => { test('应该正确计算两个正数的和', () => { expect(add(1, 2)).toBe(3); });
test('应该正确计算负数的和', () => { expect(add(-1, -2)).toBe(-3); });
test('应该正确处理零', () => { expect(add(0, 5)).toBe(5); expect(add(5, 0)).toBe(5); });
test('应该正确处理小数', () => { expect(add(0.1, 0.2)).toBeCloseTo(0.3); });});
describe('formatDate 函数测试', () => { test('应该正确格式化日期', () => { const date = new Date('2024-01-15'); expect(formatDate(date)).toBe('2024-01-15'); });
test('应该处理不同时区的日期', () => { const date = new Date('2024-01-15T00:00:00Z'); const result = formatDate(date); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); });});
describe('isValidEmail 函数测试', () => { test('应该验证有效的邮箱', () => { expect(isValidEmail('test@example.com')).toBe(true); expect(isValidEmail('user.name+tag@domain.co.uk')).toBe(true); });
test('应该拒绝无效的邮箱', () => { expect(isValidEmail('invalid')).toBe(false); expect(isValidEmail('invalid@')).toBe(false); expect(isValidEmail('@example.com')).toBe(false); expect(isValidEmail('test@example')).toBe(false); });});运行测试:
npm test📚 Jest 常用断言
| 断言 | 说明 | 示例 |
|---|---|---|
toBe() | 严格相等 | expect(1 + 1).toBe(2) |
toEqual() | 深度相等 | expect({a: 1}).toEqual({a: 1}) |
toBeCloseTo() | 浮点数近似 | expect(0.1 + 0.2).toBeCloseTo(0.3) |
toMatch() | 正则匹配 | expect('hello').toMatch(/ell/) |
toContain() | 包含元素 | expect([1, 2, 3]).toContain(2) |
toThrow() | 抛出异常 | expect(() => fn()).toThrow() |
toBeTruthy() | 真值 | expect(true).toBeTruthy() |
toBeFalsy() | 假值 | expect(false).toBeFalsy() |
toBeNull() | null | expect(null).toBeNull() |
toBeUndefined() | undefined | expect(undefined).toBeUndefined() |
15.2 集成测试
集成测试是测试多个模块或组件之间的交互。
📦 安装 Supertest
Supertest 是一个专门用于测试 HTTP 服务器的库。
npm install --save-dev supertest🎯 测试 Express.js 路由
创建一个简单的 Express 应用 app.js:
const express = require('express');const app = express();
app.use(express.json());
// 模拟数据库let users = [ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' }];
// 获取所有用户app.get('/api/users', (req, res) => { res.json(users);});
// 获取单个用户app.get('/api/users/:id', (req, res) => { const user = users.find(u => u.id === parseInt(req.params.id)); if (!user) { return res.status(404).json({ error: '用户不存在' }); } res.json(user);});
// 创建用户app.post('/api/users', (req, res) => { const { name, email } = req.body;
if (!name || !email) { return res.status(400).json({ error: '姓名和邮箱不能为空' }); }
const newUser = { id: users.length + 1, name, email };
users.push(newUser); res.status(201).json(newUser);});
// 更新用户app.put('/api/users/:id', (req, res) => { const user = users.find(u => u.id === parseInt(req.params.id)); if (!user) { return res.status(404).json({ error: '用户不存在' }); }
user.name = req.body.name || user.name; user.email = req.body.email || user.email;
res.json(user);});
// 删除用户app.delete('/api/users/:id', (req, res) => { const index = users.findIndex(u => u.id === parseInt(req.params.id)); if (index === -1) { return res.status(404).json({ error: '用户不存在' }); }
users.splice(index, 1); res.status(204).send();});
module.exports = app;创建测试文件 app.test.js:
const request = require('supertest');const app = require('./app');
describe('用户 API 测试', () => { describe('GET /api/users', () => { test('应该返回所有用户', async () => { const response = await request(app) .get('/api/users') .expect('Content-Type', /json/) .expect(200);
expect(response.body).toBeInstanceOf(Array); expect(response.body.length).toBeGreaterThan(0); expect(response.body[0]).toHaveProperty('id'); expect(response.body[0]).toHaveProperty('name'); expect(response.body[0]).toHaveProperty('email'); }); });
describe('GET /api/users/:id', () => { test('应该返回指定用户', async () => { const response = await request(app) .get('/api/users/1') .expect(200);
expect(response.body).toHaveProperty('id', 1); expect(response.body).toHaveProperty('name', '张三'); });
test('用户不存在时应该返回 404', async () => { const response = await request(app) .get('/api/users/999') .expect(404);
expect(response.body).toHaveProperty('error', '用户不存在'); }); });
describe('POST /api/users', () => { test('应该创建新用户', async () => { const newUser = { name: '王五', email: 'wangwu@example.com' };
const response = await request(app) .post('/api/users') .send(newUser) .expect('Content-Type', /json/) .expect(201);
expect(response.body).toHaveProperty('id'); expect(response.body.name).toBe(newUser.name); expect(response.body.email).toBe(newUser.email); });
test('缺少必要字段时应该返回 400', async () => { const invalidUser = { name: '赵六' // 缺少 email };
const response = await request(app) .post('/api/users') .send(invalidUser) .expect(400);
expect(response.body).toHaveProperty('error', '姓名和邮箱不能为空'); });
test('邮箱格式无效时应该返回 400', async () => { const invalidUser = { name: '赵六', email: 'invalid-email' };
const response = await request(app) .post('/api/users') .send(invalidUser) .expect(400); }); });
describe('PUT /api/users/:id', () => { test('应该更新用户信息', async () => { const updatedData = { name: '张三(更新)' };
const response = await request(app) .put('/api/users/1') .send(updatedData) .expect(200);
expect(response.body.name).toBe(updatedData.name); });
test('用户不存在时应该返回 404', async () => { const response = await request(app) .put('/api/users/999') .send({ name: '测试' }) .expect(404); }); });
describe('DELETE /api/users/:id', () => { test('应该删除用户', async () => { await request(app) .delete('/api/users/2') .expect(204);
// 验证用户已被删除 await request(app) .get('/api/users/2') .expect(404); });
test('用户不存在时应该返回 404', async () => { await request(app) .delete('/api/users/999') .expect(404); }); });});15.3 测试数据库操作
🎯 使用测试数据库
在生产环境中,应该使用独立的测试数据库,避免影响生产数据。
const mongoose = require('mongoose');const User = require('./models/User');
describe('用户模型测试', () => { // 在所有测试之前连接数据库 beforeAll(async () => { await mongoose.connect('mongodb://localhost:27017/test_db', { useNewUrlParser: true, useUnifiedTopology: true }); });
// 在所有测试之后断开连接 afterAll(async () => { await mongoose.connection.close(); });
// 在每个测试之前清空数据库 beforeEach(async () => { await User.deleteMany({}); });
test('应该创建用户', async () => { const userData = { name: '张三', email: 'zhangsan@example.com' };
const user = await User.create(userData);
expect(user).toHaveProperty('_id'); expect(user.name).toBe(userData.name); expect(user.email).toBe(userData.email); });
test('应该查找用户', async () => { const userData = { name: '李四', email: 'lisi@example.com' };
await User.create(userData);
const user = await User.findOne({ email: userData.email });
expect(user).not.toBeNull(); expect(user.name).toBe(userData.name); });
test('应该更新用户', async () => { const user = await User.create({ name: '王五', email: 'wangwu@example.com' });
user.name = '王五(更新)'; await user.save();
const updatedUser = await User.findById(user._id);
expect(updatedUser.name).toBe('王五(更新)'); });
test('应该删除用户', async () => { const user = await User.create({ name: '赵六', email: 'zhaoliu@example.com' });
await User.findByIdAndDelete(user._id);
const deletedUser = await User.findById(user._id);
expect(deletedUser).toBeNull(); });});15.4 测试异步代码
🎯 测试异步函数
/** * 模拟异步获取用户数据 * @param {number} userId - 用户 ID * @returns {Promise<Object>} 用户数据 */async function fetchUser(userId) { // 模拟 API 调用 return new Promise((resolve, reject) => { setTimeout(() => { if (userId > 0) { resolve({ id: userId, name: `用户${userId}`, email: `user${userId}@example.com` }); } else { reject(new Error('无效的用户 ID')); } }, 100); });}
/** * 模拟异步保存用户数据 * @param {Object} userData - 用户数据 * @returns {Promise<Object>} 保存后的用户数据 */async function saveUser(userData) { return new Promise((resolve) => { setTimeout(() => { resolve({ ...userData, id: Date.now(), createdAt: new Date() }); }, 100); });}
module.exports = { fetchUser, saveUser};测试文件:
const { fetchUser, saveUser } = require('./asyncFunctions');
describe('fetchUser 函数测试', () => { test('应该成功获取用户数据', async () => { const user = await fetchUser(1);
expect(user).toHaveProperty('id', 1); expect(user).toHaveProperty('name'); expect(user).toHaveProperty('email'); });
test('无效 ID 时应该抛出错误', async () => { await expect(fetchUser(-1)).rejects.toThrow('无效的用户 ID'); });
test('应该处理超时情况', async () => { // 设置超时时间 await expect(fetchUser(1)).resolves.toBeDefined(); }, 1000); // 1 秒超时});
describe('saveUser 函数测试', () => { test('应该成功保存用户数据', async () => { const userData = { name: '张三', email: 'zhangsan@example.com' };
const savedUser = await saveUser(userData);
expect(savedUser).toHaveProperty('id'); expect(savedUser).toHaveProperty('createdAt'); expect(savedUser.name).toBe(userData.name); });});15.5 测试覆盖率
测试覆盖率衡量代码被测试的程度。
📊 生成覆盖率报告
npm run test:coverageJest 会生成一个覆盖率报告,通常包含:
- 语句覆盖率(Statements):有多少语句被执行
- 分支覆盖率(Branches):有多少分支被测试
- 函数覆盖率(Functions):有多少函数被调用
- 行覆盖率(Lines):有多少行代码被执行
🎯 配置覆盖率阈值
在 package.json 中配置:
{ "jest": { "collectCoverage": true, "coverageThreshold": { "global": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 } } }}15.6 测试最佳实践
✅ DO(应该做的)
- 测试应该独立
// ✅ 每个测试独立运行test('应该创建用户', async () => { const user = await User.create({ name: '张三' }); expect(user.name).toBe('张三');});
test('应该删除用户', async () => { // 不依赖前面的测试 const user = await User.create({ name: '李四' }); await User.findByIdAndDelete(user._id); const deleted = await User.findById(user._id); expect(deleted).toBeNull();});- 使用描述性的测试名称
// ✅ 清晰的测试名称test('当用户不存在时,应该返回 404', async () => { // ...});
// ❌ 不清晰的测试名称test('测试 1', async () => { // ...});- 遵循 AAA 模式(Arrange-Act-Assert)
test('应该更新用户信息', async () => { // Arrange(准备) const user = await User.create({ name: '张三' }); const updatedData = { name: '张三(更新)' };
// Act(执行) user.name = updatedData.name; await user.save();
// Assert(断言) const updatedUser = await User.findById(user._id); expect(updatedUser.name).toBe(updatedData.name);});- 使用 beforeEach 和 afterEach
describe('用户 API 测试', () => { beforeEach(async () => { // 每个测试前执行 await User.deleteMany({}); });
afterEach(async () => { // 每个测试后执行 await User.deleteMany({}); });
test('应该创建用户', async () => { // 测试代码 });});❌ DON’T(不应该做的)
- 不要测试第三方库
// ❌ 不要测试 Express.js 本身test('Express 应该能处理 GET 请求', async () => { // 这是 Express.js 的责任,不是你的});
// ✅ 测试你的业务逻辑test('应该返回用户列表', async () => { const response = await request(app).get('/api/users'); expect(response.body).toBeInstanceOf(Array);});- 不要在测试中硬编码数据
// ❌ 硬编码test('应该返回用户', async () => { const user = await User.findById(1); expect(user.name).toBe('张三');});
// ✅ 动态创建test('应该返回用户', async () => { const user = await User.create({ name: '张三' }); const found = await User.findById(user._id); expect(found.name).toBe('张三');});- 不要忽略异步操作
// ❌ 忘记 awaittest('应该创建用户', async () => { User.create({ name: '张三' }); expect(User.count()).toBe(1); // 可能失败});
// ✅ 正确处理异步test('应该创建用户', async () => { await User.create({ name: '张三' }); expect(await User.count()).toBe(1);});15.7 常见测试场景
场景 1:测试身份验证
describe('身份验证测试', () => { let token;
beforeEach(async () => { // 登录获取 token const response = await request(app) .post('/api/login') .send({ username: 'admin', password: 'password' });
token = response.body.token; });
test('应该拒绝未认证的请求', async () => { await request(app) .get('/api/protected') .expect(401); });
test('应该接受有效的 token', async () => { await request(app) .get('/api/protected') .set('Authorization', `Bearer ${token}`) .expect(200); });
test('应该拒绝无效的 token', async () => { await request(app) .get('/api/protected') .set('Authorization', 'Bearer invalid-token') .expect(401); });});场景 2:测试文件上传
describe('文件上传测试', () => { test('应该成功上传文件', async () => { const response = await request(app) .post('/api/upload') .attach('file', './test/fixtures/test.png') .expect(200);
expect(response.body).toHaveProperty('filename'); expect(response.body).toHaveProperty('path'); });
test('应该拒绝不支持的文件类型', async () => { await request(app) .post('/api/upload') .attach('file', './test/fixtures/test.exe') .expect(400); });});场景 3:测试错误处理
describe('错误处理测试', () => { test('应该捕获数据库错误', async () => { // 模拟数据库错误 jest.spyOn(User, 'create').mockRejectedValue(new Error('数据库错误'));
await request(app) .post('/api/users') .send({ name: '张三', email: 'zhangsan@example.com' }) .expect(500); });
test('应该返回友好的错误消息', async () => { const response = await request(app) .post('/api/users') .send({ name: '张三' }) // 缺少 email .expect(400);
expect(response.body).toHaveProperty('error'); expect(response.body.error).toContain('邮箱'); });});15.8 持续集成中的测试
在 CI/CD 流程中自动运行测试。
📝 GitHub Actions 示例
创建 .github/workflows/test.yml:
name: 运行测试
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest
strategy: matrix: node-version: [14.x, 16.x, 18.x]
steps: - uses: actions/checkout@v2
- name: 使用 Node.js ${{ matrix.node-version }} uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }}
- name: 安装依赖 run: npm ci
- name: 运行测试 run: npm test
- name: 生成覆盖率报告 run: npm run test:coverage
- name: 上传覆盖率报告 uses: codecov/codecov-action@v215.9 测试工具推荐
| 工具 | 用途 | 特点 |
|---|---|---|
| Jest | 单元测试、集成测试 | 零配置、断言丰富、覆盖率报告 |
| Supertest | HTTP 测试 | 专门测试 Express.js 应用 |
| Mocha | 单元测试 | 灵活、可扩展 |
| Chai | 断言库 | 可读性强 |
| Sinon | Mock/Stub | 模拟函数和对象 |
| Cypress | 端到端测试 | 真实浏览器环境 |
| Istanbul | 覆盖率工具 | 详细的覆盖率报告 |
第十六步:实战练习
TIP💡 实践是最好的学习方式:阅读文档只能让你”知道”,动手实践才能让你”真正掌握”。请务必完成这些练习,不要只是看代码!遇到问题时,尝试自己解决,这是提升编程能力的最佳途径。
16.1 练习 1:创建待办事项 API
let todos = [];
// 获取所有待办事项app.get('/api/todos', (req, res) => { res.json(todos);});
// 创建待办事项app.post('/api/todos', (req, res) => { const { text } = req.body; const todo = { id: Date.now(), text, completed: false }; todos.push(todo); res.status(201).json(todo);});
// 更新待办事项app.put('/api/todos/:id', (req, res) => { const todo = todos.find(t => t.id === parseInt(req.params.id)); if (!todo) { return res.status(404).json({ error: '待办事项不存在' }); }
todo.text = req.body.text || todo.text; todo.completed = req.body.completed !== undefined ? req.body.completed : todo.completed;
res.json(todo);});
// 删除待办事项app.delete('/api/todos/:id', (req, res) => { const index = todos.findIndex(t => t.id === parseInt(req.params.id)); if (index === -1) { return res.status(404).json({ error: '待办事项不存在' }); }
todos.splice(index, 1); res.status(204).send();});16.2 练习 2:创建博客 API
let posts = [];
// 获取所有文章app.get('/api/posts', (req, res) => { const { category } = req.query; let filteredPosts = posts;
if (category) { filteredPosts = posts.filter(p => p.category === category); }
res.json(filteredPosts);});
// 获取单篇文章app.get('/api/posts/:id', (req, res) => { const post = posts.find(p => p.id === parseInt(req.params.id)); if (!post) { return res.status(404).json({ error: '文章不存在' }); } res.json(post);});
// 创建文章app.post('/api/posts', (req, res) => { const { title, content, category } = req.body; const post = { id: Date.now(), title, content, category, createdAt: new Date(), updatedAt: new Date() }; posts.push(post); res.status(201).json(post);});
// 更新文章app.put('/api/posts/:id', (req, res) => { const post = posts.find(p => p.id === parseInt(req.params.id)); if (!post) { return res.status(404).json({ error: '文章不存在' }); }
post.title = req.body.title || post.title; post.content = req.body.content || post.content; post.category = req.body.category || post.category; post.updatedAt = new Date();
res.json(post);});
// 删除文章app.delete('/api/posts/:id', (req, res) => { const index = posts.findIndex(p => p.id === parseInt(req.params.id)); if (index === -1) { return res.status(404).json({ error: '文章不存在' }); }
posts.splice(index, 1); res.status(204).send();});第十七步:项目结构最佳实践
🗺️ Express.js 应用架构
分层架构说明:
- 路由层:定义 API 端点,处理请求路由
- 中间件层:处理身份验证、错误处理、日志等
- 控制器层:处理业务逻辑,协调服务层
- 服务层:封装业务逻辑,处理复杂操作
- 数据访问层:定义数据模型,与数据库交互
17.1 推荐的项目结构
my-express-app/├── bin/│ └── www # 应用启动脚本├── public/ # 静态文件│ ├── images/│ ├── javascripts/│ └── stylesheets/├── routes/ # 路由文件│ ├── index.js│ ├── users.js│ └── posts.js├── views/ # 模板文件│ ├── index.ejs│ └── error.ejs├── models/ # 数据模型│ ├── User.js│ └── Post.js├── middleware/ # 自定义中间件│ ├── auth.js│ └── error.js├── config/ # 配置文件│ ├── database.js│ └── config.js├── app.js # 应用主文件└── package.json17.2 环境变量
npm install dotenv创建 .env 文件:
PORT=3000NODE_ENV=developmentDB_URI=mongodb://localhost:27017/myapp在应用中使用:
require('dotenv').config();
const PORT = process.env.PORT || 3000;const DB_URI = process.env.DB_URI;
app.listen(PORT, () => { console.log(`服务器运行在端口 ${PORT}`);});第十八步:部署上线
TIP💡 部署前的准备工作:在部署应用之前,请确保:
- ✅ 已完成所有功能测试
- ✅ 已修复所有已知 bug
- ✅ 已配置环境变量(数据库连接、密钥等)
- ✅ 已设置错误日志和监控
- ✅ 已配置 HTTPS(生产环境)
- ✅ 已准备好回滚方案
18.1 准备部署
# 安装生产依赖npm install --production
# 设置环境变量export NODE_ENV=productionexport PORT=8018.2 使用 PM2 管理进程
# 全局安装 PM2npm install -g pm2
# 启动应用pm2 start app.js --name my-app
# 查看状态pm2 status
# 查看日志pm2 logs
# 重启应用pm2 restart my-app
# 停止应用pm2 stop my-app
# 设置开机自启动pm2 startuppm2 save18.3 使用 Docker
创建 Dockerfile:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]构建和运行:
# 构建镜像docker build -t my-express-app .
# 运行容器docker run -p 3000:3000 my-express-app18.4 部署到云平台
Vercel
npm install -g vercelvercelHeroku
# 安装 Heroku CLInpm install -g heroku
# 登录heroku login
# 创建应用heroku create
# 部署git push heroku main第十九步:学习资源推荐
官方资源
-
Express.js 官方文档
-
Node.js 官方文档
教程推荐
-
Express.js 教程(菜鸟教程)
-
Express.js 视频教程
- B站搜索”Express.js 入门”
实践项目
- 简单的博客系统
- 待办事项应用
- RESTful API 服务
- 实时聊天应用(使用 Socket.io)
第二十步:常见问题
Q1: Express.js 和 Koa.js 有什么区别?
A:
- Express.js:更成熟,中间件生态丰富,适合快速开发
- Koa.js:更轻量,使用 async/await,更灵活,适合有经验的开发者
Q2: 如何处理跨域问题?
A: 使用 cors 中间件:
const cors = require('cors');app.use(cors());Q3: 如何实现用户认证?
A: 使用 jsonwebtoken 和 bcrypt:
npm install jsonwebtoken bcryptQ4: 如何优化 Express.js 应用性能?
A:
- 使用 gzip 压缩
- 启用缓存
- 使用集群模式
- 优化数据库查询
- 使用 CDN
Q5: Express.js 适合大型项目吗?
A: Express.js 本身是轻量级的,但通过合理的项目结构和中间件组合,完全可以用于大型项目。对于复杂项目,可以考虑使用基于 Express 的框架,如 NestJS。
第二十一步:性能优化
NOTE📝 性能优化的时机:不要过早优化!在应用功能完善之前,不要花太多时间优化性能。先让应用跑起来,然后通过性能分析工具找出真正的瓶颈,再有针对性地进行优化。过早优化往往是浪费时间。
性能优化是构建高性能 Express.js 应用的关键。通过合理的优化策略,可以显著提升应用的响应速度和并发处理能力。
11.1 响应时间优化
使用 gzip 压缩
npm install compressionconst compression = require('compression');
// 启用 gzip 压缩app.use(compression());
// 配置压缩选项app.use(compression({ filter: (req, res) => { if (req.headers['x-no-compression']) { // 不压缩请求头包含 x-no-compression 的请求 return false; } return compression.filter(req, res); }, threshold: 1024 // 只压缩大于 1KB 的响应}));减少响应数据量
// ❌ 不好的做法:返回所有字段app.get('/api/users', async (req, res) => { const users = await User.find(); res.json(users);});
// ✅ 好的做法:只返回需要的字段app.get('/api/users', async (req, res) => { const users = await User.find({}, { name: 1, email: 1 }); res.json(users);});
// ✅ 更好的做法:使用分页app.get('/api/users', async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit;
const [users, total] = await Promise.all([ User.find().skip(skip).limit(limit), User.countDocuments() ]);
res.json({ data: users, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } });});11.2 内存优化
使用流式处理大文件
const fs = require('fs');
// ❌ 不好的做法:一次性读取整个文件到内存app.get('/download', (req, res) => { const data = fs.readFileSync('large-file.pdf'); res.send(data);});
// ✅ 好的做法:使用流式处理app.get('/download', (req, res) => { const fileStream = fs.createReadStream('large-file.pdf'); fileStream.pipe(res);});及时释放资源
// ❌ 不好的做法:缓存所有数据const cache = {};
app.get('/api/data', async (req, res) => { const key = req.params.id; if (!cache[key]) { cache[key] = await fetchData(key); } res.json(cache[key]);});
// ✅ 好的做法:使用 LRU 缓存,限制缓存大小const LRU = require('lru-cache');const cache = new LRU({ max: 500, // 最多缓存 500 个项目 maxAge: 1000 * 60 * 5 // 5 分钟后过期});
app.get('/api/data', async (req, res) => { const key = req.params.id; if (cache.has(key)) { return res.json(cache.get(key)); } const data = await fetchData(key); cache.set(key, data); res.json(data);});11.3 并发处理
使用 Node.js 集群模式
const cluster = require('cluster');const numCPUs = require('os').cpus().length;
if (cluster.isMaster) { console.log(`主进程 ${process.pid} 正在运行`);
// 衍生工作进程 for (let i = 0; i < numCPUs; i++) { cluster.fork(); }
cluster.on('exit', (worker, code, signal) => { console.log(`工作进程 ${worker.process.pid} 已退出`); console.log('正在重启工作进程...'); cluster.fork(); });} else { // 工作进程可以共享同一个端口 const app = require('./app'); app.listen(3000, () => { console.log(`工作进程 ${process.pid} 已启动`); });}使用 PM2 集群模式
# 使用 PM2 启动集群模式pm2 start app.js -i max
# 查看集群状态pm2 status11.4 缓存策略
使用 Redis 缓存
npm install redisconst redis = require('redis');const client = redis.createClient();
// 缓存中间件const cache = (duration) => { return async (req, res, next) => { const key = `cache:${req.originalUrl || req.url}`;
try { // 尝试从缓存获取 const cached = await client.get(key); if (cached) { return res.json(JSON.parse(cached)); }
// 缓存未命中,继续处理请求 res.sendResponse = res.json; res.json = (body) => { // 将响应存入缓存 client.setex(key, duration, JSON.stringify(body)); res.sendResponse(body); }; next(); } catch (error) { next(); } };};
// 使用缓存中间件app.get('/api/users', cache(60), async (req, res) => { const users = await User.find(); res.json(users);});HTTP 缓存头
// 设置缓存头app.get('/api/data', (req, res) => { const data = fetchData();
// 设置缓存头(1 小时) res.set('Cache-Control', 'public, max-age=3600'); res.set('ETag', generateETag(data));
// 检查 ETag if (req.headers['if-none-match'] === res.get('ETag')) { return res.status(304).end(); }
res.json(data);});
function generateETag(data) { return require('crypto') .createHash('md5') .update(JSON.stringify(data)) .digest('hex');}内存缓存
// 使用 Node.js 内置 Map 实现简单缓存const memoryCache = new Map();
// 缓存中间件const memoryCacheMiddleware = (duration = 60) => { return (req, res, next) => { const key = req.originalUrl || req.url;
// 检查缓存 if (memoryCache.has(key)) { const cached = memoryCache.get(key); if (Date.now() < cached.expiresAt) { return res.json(cached.data); } // 缓存过期,删除 memoryCache.delete(key); }
// 缓存响应 res.sendResponse = res.json; res.json = (data) => { memoryCache.set(key, { data, expiresAt: Date.now() + duration * 1000 }); res.sendResponse(data); };
next(); };};
// 使用内存缓存app.get('/api/users', memoryCacheMiddleware(60), async (req, res) => { const users = await User.find(); res.json(users);});
// 清除缓存app.delete('/api/cache', (req, res) => { memoryCache.clear(); res.json({ message: '缓存已清除' });});缓存失效策略
// 缓存失效策略const CacheStrategy = { // 基于时间的失效(TTL) TTL: 'ttl', // 基于事件的失效(数据更新时) EVENT: 'event', // 基于计数的失效(LRU) LRU: 'lru'};
// 事件驱动的缓存失效class EventDrivenCache { constructor() { this.cache = new Map(); this.subscribers = new Map(); }
// 订阅数据变化 subscribe(key, callback) { if (!this.subscribers.has(key)) { this.subscribers.set(key, []); } this.subscribers.get(key).push(callback); }
// 发布数据变化 publish(key, data) { // 清除缓存 this.cache.delete(key);
// 通知订阅者 if (this.subscribers.has(key)) { this.subscribers.get(key).forEach(callback => callback(data)); } }
// 获取缓存 get(key) { return this.cache.get(key); }
// 设置缓存 set(key, value) { this.cache.set(key, value); }}
const eventCache = new EventDrivenCache();
// 订阅用户数据变化eventCache.subscribe('user:123', (userData) => { console.log('用户数据已更新:', userData);});
// 更新用户数据时发布事件app.put('/api/users/:id', async (req, res) => { const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
// 发布数据变化事件 eventCache.publish(`user:${user._id}`, user);
res.json(user);});
// 缓存预热(应用启动时加载热点数据)async function warmUpCache() { console.log('开始缓存预热...');
const popularPosts = await Post.find().sort({ views: -1 }).limit(100); popularPosts.forEach(post => { eventCache.set(`post:${post._id}`, post); });
console.log('缓存预热完成');}
// 应用启动时执行缓存预热warmUpCache();11.5 数据库查询优化
使用索引
// MongoDB 索引const userSchema = new mongoose.Schema({ email: { type: String, unique: true }, name: String, age: Number});
// 为常用查询字段添加索引userSchema.index({ email: 1 });userSchema.index({ age: 1 });userSchema.index({ name: 1, age: 1 }); // 复合索引
const User = mongoose.model('User', userSchema);优化查询
// ❌ 不好的做法:N+1 查询问题app.get('/api/posts', async (req, res) => { const posts = await Post.find(); for (const post of posts) { post.author = await User.findById(post.authorId); // N+1 查询 } res.json(posts);});
// ✅ 好的做法:使用 populateapp.get('/api/posts', async (req, res) => { const posts = await Post.find().populate('authorId', 'name email'); res.json(posts);});
// ✅ 更好的做法:只查询需要的字段app.get('/api/posts', async (req, res) => { const posts = await Post.find() .select('title content createdAt') .populate('authorId', 'name') .sort({ createdAt: -1 }) .limit(20); res.json(posts);});使用连接池
// MySQL 连接池配置const mysql = require('mysql2/promise');
const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'password', database: 'myapp', waitForConnections: true, connectionLimit: 10, // 最大连接数 queueLimit: 0});
// 使用连接池查询app.get('/api/users', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM users LIMIT 10'); res.json(rows); } catch (error) { res.status(500).json({ error: error.message }); }});11.6 静态资源优化
使用 CDN
// 开发环境使用本地静态文件if (process.env.NODE_ENV === 'development') { app.use(express.static('public'));} else { // 生产环境使用 CDN app.use((req, res, next) => { if (req.url.startsWith('/static/')) { return res.redirect(`https://cdn.example.com${req.url}`); } next(); });}压缩静态资源
npm install shrink-ray-currentconst shrinkRay = require('shrink-ray-current');
// 压缩 HTML、CSS、JavaScriptapp.use(shrinkRay());11.7 日志与监控
日志管理
npm install winston morganconst winston = require('winston');const morgan = require('morgan');
// 配置 Winston 日志const logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), defaultMeta: { service: 'express-app' }, transports: [ // 写入所有日志到 combined.log new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'logs/combined.log' }) ]});
// 生产环境不输出到控制台if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple() }));}
// 使用 Morgan 记录 HTTP 请求app.use(morgan('combined', { stream: { write: (message) => logger.info(message.trim()) }}));
// 在应用中使用日志app.get('/api/test', (req, res) => { logger.info('测试接口被调用', { ip: req.ip, userAgent: req.headers['user-agent'] });
try { const data = fetchData(); logger.info('数据获取成功', { count: data.length }); res.json(data); } catch (error) { logger.error('数据获取失败', { error: error.message, stack: error.stack }); res.status(500).json({ error: '服务器错误' }); }});性能监控
npm install express-status-monitor prom-clientconst statusMonitor = require('express-status-monitor');const promClient = require('prom-client');
// 添加状态监控页面app.use(statusMonitor({ title: 'Express Status Monitor', path: '/status', spans: [ { interval: 1, retention: 60 }, { interval: 5, retention: 60 }, { interval: 15, retention: 60 } ]}));
// Prometheus 指标收集const httpRequestDurationMicroseconds = new promClient.Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'code'], buckets: [0.1, 0.5, 1, 1.5, 2, 5]});
// 请求监控中间件app.use((req, res, next) => { const start = Date.now();
res.on('finish', () => { const duration = Date.now() - start; httpRequestDurationMicroseconds.observe( { method: req.method, route: req.route ? req.route.path : req.path, code: res.statusCode }, duration / 1000 ); });
next();});
// 暴露 Prometheus 指标app.get('/metrics', (req, res) => { res.set('Content-Type', promClient.register.contentType); res.end(promClient.register.metrics());});
// 访问 http://localhost:3000/status 查看性能监控// 访问 http://localhost:3000/metrics 查看 Prometheus 指标错误追踪
npm install @sentry/nodeconst Sentry = require('@sentry/node');
// 配置 SentrySentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV, tracesSampleRate: 1.0,});
// Sentry 错误处理中间件app.use(Sentry.Handlers.requestHandler());app.use(Sentry.Handlers.tracingHandler());
// 在路由中使用 Sentryapp.get('/api/test', async (req, res) => { try { const data = await fetchData(); res.json(data); } catch (error) { // 发送错误到 Sentry Sentry.captureException(error); res.status(500).json({ error: '服务器错误' }); }});
// Sentry 错误处理(必须在所有路由之后)app.use(Sentry.Handlers.errorHandler());
// 自定义错误处理app.use((err, req, res, next) => { // 记录错误 logger.error('未捕获的错误', { error: err.message, stack: err.stack, url: req.url, method: req.method, ip: req.ip });
// 发送到 Sentry Sentry.captureException(err);
res.status(500).json({ error: '服务器错误' });});告警系统
const nodemailer = require('nodemailer');
// 配置邮件发送器const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: process.env.SMTP_PORT, secure: false, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }});
// 告警规则const alertRules = { // 错误率告警 errorRate: { threshold: 0.1, // 10% 错误率 window: 60000, // 1 分钟窗口 count: 0, total: 0 }, // 响应时间告警 responseTime: { threshold: 3000, // 3 秒 violations: 0, maxViolations: 5 }};
// 发送告警邮件async function sendAlert(type, message) { const mailOptions = { from: process.env.ALERT_FROM, to: process.env.ALERT_TO, subject: `[告警] ${type}`, text: message };
try { await transporter.sendMail(mailOptions); logger.info('告警邮件已发送', { type }); } catch (error) { logger.error('发送告警邮件失败', { error: error.message }); }}
// 监控错误率app.use((req, res, next) => { const originalSend = res.send;
res.send = function (data) { alertRules.errorRate.total++;
if (res.statusCode >= 400) { alertRules.errorRate.count++; }
// 检查错误率 const errorRate = alertRules.errorRate.count / alertRules.errorRate.total; if (errorRate > alertRules.errorRate.threshold) { sendAlert('错误率过高', `错误率: ${(errorRate * 100).toFixed(2)}%`); }
originalSend.call(this, data); };
next();});
// 定期重置计数器setInterval(() => { alertRules.errorRate.count = 0; alertRules.errorRate.total = 0;}, alertRules.errorRate.window);
// 健康检查端点app.get('/health', (req, res) => { const health = { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), memory: process.memoryUsage(), errorRate: alertRules.errorRate.count / alertRules.errorRate.total };
res.json(health);});使用 PM2 监控
# 实时监控pm2 monit
# 查看日志pm2 logs
# 查看详细信息pm2 show app-name
# 设置告警pm2 install pm2-logrotatepm2 set pm2-logrotate:max_size 10Mpm2 set pm2-logrotate:retain 711.8 性能优化总结
| 优化策略 | 工具/技术 | 预期效果 |
|---|---|---|
| 响应时间优化 | gzip 压缩、减少数据量 | 减少 50-70% 响应时间 |
| 内存优化 | 流式处理、LRU 缓存 | 减少 30-50% 内存使用 |
| 并发处理 | 集群模式、PM2 | 提高 2-4 倍并发能力 |
| 缓存策略 | Redis、内存缓存、HTTP 缓存 | 减少 80-90% 数据库查询 |
| 数据库优化 | 索引、连接池、查询优化 | 提高 3-10 倍查询速度 |
| 静态资源优化 | CDN、压缩 | 减少 40-60% 加载时间 |
| 日志与监控 | Winston、Sentry、Prometheus | 实时监控,快速定位问题 |
npm install express-status-monitorconst statusMonitor = require('express-status-monitor');
// 添加状态监控页面app.use(statusMonitor({ title: 'Express Status Monitor', path: '/status', spans: [ { interval: 1, retention: 60 }, // 1 分钟间隔,保留 60 个数据点 { interval: 5, retention: 60 }, { interval: 15, retention: 60 } ]}));
// 访问 http://localhost:3000/status 查看性能监控使用 PM2 监控
# 实时监控pm2 monit
# 查看日志pm2 logs
# 查看详细信息pm2 show app-name第二十二步:安全最佳实践
🚨 安全是 Web 应用的生命线:一旦应用上线,就会面临各种安全威胁。不要等到被攻击后才重视安全!在开发阶段就应该遵循安全最佳实践,定期进行安全审计,保护用户数据和系统安全。
安全是 Web 应用开发中不可忽视的重要环节。本节将介绍常见的安全威胁及其防护措施。
12.1 常见安全威胁
| 威胁类型 | 描述 | 影响 |
|---|---|---|
| SQL 注入 | 在查询中注入恶意 SQL 代码 | 数据泄露、数据篡改 |
| XSS(跨站脚本) | 在网页中注入恶意脚本 | 用户信息泄露、会话劫持 |
| CSRF(跨站请求伪造) | 伪造用户请求 | 未授权操作 |
| DDoS 攻击 | 大量请求导致服务瘫痪 | 服务不可用 |
| 暴力破解 | 尝试大量密码组合 | 账户被盗 |
12.2 SQL 注入防护
使用参数化查询
// ❌ 不好的做法:直接拼接 SQLapp.get('/api/users/:id', async (req, res) => { const userId = req.params.id; const query = `SELECT * FROM users WHERE id = ${userId}`; // 危险! const [rows] = await pool.query(query); res.json(rows);});
// ✅ 好的做法:使用参数化查询app.get('/api/users/:id', async (req, res) => { const userId = req.params.id; const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [userId]); res.json(rows);});
// ✅ 更好的做法:使用 ORMapp.get('/api/users/:id', async (req, res) => { const user = await User.findById(req.params.id); res.json(user);});🚨 永远不要信任用户输入:用户输入可能包含恶意代码,永远不要直接拼接 SQL 语句。使用参数化查询或 ORM 可以有效防止 SQL 注入攻击。这是一个必须遵守的安全原则!
使用 Mongoose 的验证
const userSchema = new mongoose.Schema({ name: { type: String, required: true, trim: true, minlength: 2, maxlength: 50 }, email: { type: String, required: true, unique: true, lowercase: true, trim: true, validate: { validator: function(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }, message: '请输入有效的邮箱地址' } }});12.3 XSS 防护
使用 helmet
npm install helmetconst helmet = require('helmet');
// 启用 helmet 中间件app.use(helmet());
// 配置 Content Security Policyapp.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], imgSrc: ["'self'", "data:", "https:"], }}));转义用户输入
const xss = require('xss');
// 转义用户输入app.post('/api/comments', async (req, res) => { const { content } = req.body;
// 转义 HTML 标签 const sanitizedContent = xss(content);
const comment = await Comment.create({ content: sanitizedContent }); res.json(comment);});12.4 CSRF 防护
使用 csurf
npm install csurf cookie-parserconst cookieParser = require('cookie-parser');const csrf = require('csurf');
app.use(cookieParser());app.use(csrf({ cookie: true }));
// 提供 CSRF Tokenapp.get('/api/csrf-token', (req, res) => { res.json({ csrfToken: req.csrfToken() });});
// 验证 CSRF Tokenapp.post('/api/posts', (req, res) => { res.json({ message: 'CSRF 验证通过' });});前端使用 CSRF Token
// 获取 CSRF Tokenfetch('/api/csrf-token') .then(res => res.json()) .then(data => { const csrfToken = data.csrfToken;
// 在请求头中发送 CSRF Token fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ title: '新文章' }) }); });12.5 速率限制
使用 express-rate-limit
npm install express-rate-limitconst rateLimit = require('express-rate-limit');
// 基本速率限制const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 分钟 max: 100 // 最多 100 次请求});
app.use('/api/', limiter);
// 登录接口更严格的限制const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 分钟 max: 5, // 最多 5 次尝试 message: '登录尝试次数过多,请稍后再试'});
app.post('/api/login', loginLimiter, (req, res) => { // 登录逻辑});IP 黑名单
const ipBlacklist = ['192.168.1.100', '10.0.0.1'];
app.use((req, res, next) => { const clientIp = req.ip; if (ipBlacklist.includes(clientIp)) { return res.status(403).json({ error: 'IP 被封禁' }); } next();});12.6 安全头设置
使用 helmet 设置安全头
app.use(helmet());
// 自定义安全头app.use(helmet({ frameguard: { action: 'deny' // 防止点击劫持 }, hsts: { maxAge: 31536000, // 1 年 includeSubDomains: true, preload: true }, noSniff: true, // 防止 MIME 类型嗅探 xssFilter: true // 启用 XSS 过滤器}));自定义安全头
app.use((req, res, next) => { // X-Content-Type-Options res.set('X-Content-Type-Options', 'nosniff');
// X-Frame-Options res.set('X-Frame-Options', 'DENY');
// X-XSS-Protection res.set('X-XSS-Protection', '1; mode=block');
// Strict-Transport-Security (HTTPS) if (process.env.NODE_ENV === 'production') { res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); }
next();});12.7 敏感数据保护
🚨 敏感信息泄露是严重的安全漏洞:永远不要在代码中硬编码数据库密码、API 密钥、JWT 密钥等敏感信息!这些信息一旦泄露,攻击者就可以完全控制你的应用。务必使用环境变量或密钥管理服务来存储敏感信息。
使用环境变量
npm install dotenv创建 .env 文件(不提交到 Git):
DB_URI=mongodb://localhost:27017/myappJWT_SECRET=your-secret-keyAPI_KEY=your-api-key在应用中使用:
require('dotenv').config();
const DB_URI = process.env.DB_URI;const JWT_SECRET = process.env.JWT_SECRET;
// 不要在代码中硬编码敏感信息console.log(DB_URI); // ✅ 从环境变量读取// console.log('mongodb://localhost:27017/myapp'); // ❌ 不好的做法密码加密
npm install bcryptconst bcrypt = require('bcrypt');
// 注册时加密密码app.post('/api/register', async (req, res) => { const { username, password } = req.body;
// 加密密码 const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({ username, password: hashedPassword });
res.json({ message: '注册成功' });});
// 登录时验证密码app.post('/api/login', async (req, res) => { const { username, password } = req.body;
const user = await User.findOne({ username }); if (!user) { return res.status(401).json({ error: '用户名或密码错误' }); }
// 验证密码 const isValid = await bcrypt.compare(password, user.password); if (!isValid) { return res.status(401).json({ error: '用户名或密码错误' }); }
res.json({ message: '登录成功' });});使用 JWT 认证
npm install jsonwebtokenconst jwt = require('jsonwebtoken');
// 生成 JWT Tokenapp.post('/api/login', async (req, res) => { const { username, password } = req.body;
// 验证用户...
// 生成 Token const token = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, { expiresIn: '1h' } );
res.json({ token });});
// 验证 JWT Token 中间件const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1];
if (!token) { return res.status(401).json({ error: '未提供认证令牌' }); }
jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) { return res.status(403).json({ error: '无效的认证令牌' }); } req.user = user; next(); });};
// 使用认证中间件app.get('/api/profile', authenticateToken, (req, res) => { res.json({ user: req.user });});Session 管理
npm install express-sessionconst session = require('express-session');
// 配置 Session 中间件app.use(session({ secret: process.env.SESSION_SECRET || 'your-secret-key', resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', // 生产环境使用 HTTPS httpOnly: true, // 防止 XSS 攻击 maxAge: 24 * 60 * 60 * 1000 // 24 小时 }, store: new (require('connect-mongo')(session))({ url: process.env.DB_URI, ttl: 24 * 60 * 60 // Session 过期时间(秒) })}));
// 设置 Sessionapp.post('/api/login', (req, res) => { const { username, password } = req.body;
// 验证用户...
req.session.userId = user._id; req.session.username = user.username;
res.json({ message: '登录成功' });});
// 检查 Sessionapp.get('/api/profile', (req, res) => { if (!req.session.userId) { return res.status(401).json({ error: '未登录' }); } res.json({ userId: req.session.userId, username: req.session.username });});
// 销毁 Sessionapp.post('/api/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: '登出失败' }); } res.json({ message: '登出成功' }); });});OAuth 2.0 集成
npm install passport passport-google-oauth20const passport = require('passport');const GoogleStrategy = require('passport-google-oauth20').Strategy;
// 配置 Google OAuth 2.0passport.use(new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: '/auth/google/callback'}, async (accessToken, refreshToken, profile, done) => { // 查找或创建用户 let user = await User.findOne({ googleId: profile.id });
if (!user) { user = await User.create({ googleId: profile.id, name: profile.displayName, email: profile.emails[0].value }); }
return done(null, user);}));
// 序列化和反序列化用户passport.serializeUser((user, done) => done(null, user._id));passport.deserializeUser(async (id, done) => { const user = await User.findById(id); done(null, user);});
// Google 登录路由app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/login' }), (req, res) => { // 登录成功,重定向到首页 res.redirect('/'); });
// 检查认证状态app.get('/api/auth/status', (req, res) => { if (req.isAuthenticated()) { res.json({ authenticated: true, user: req.user }); } else { res.json({ authenticated: false }); }});权限控制
// 角色枚举const Role = { ADMIN: 'admin', USER: 'user', GUEST: 'guest'};
// 权限检查中间件const checkRole = (...allowedRoles) => { return (req, res, next) => { if (!req.user) { return res.status(401).json({ error: '未登录' }); }
if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ error: '权限不足' }); }
next(); };};
// 资源所有权检查const checkOwnership = (Model) => { return async (req, res, next) => { try { const resource = await Model.findById(req.params.id);
if (!resource) { return res.status(404).json({ error: '资源不存在' }); }
// 管理员可以访问所有资源 if (req.user.role === Role.ADMIN) { return next(); }
// 检查是否是资源所有者 if (resource.userId.toString() !== req.user.userId) { return res.status(403).json({ error: '无权访问此资源' }); }
req.resource = resource; next(); } catch (error) { res.status(500).json({ error: error.message }); } };};
// 使用权限中间件app.get('/api/admin/users', authenticateToken, checkRole(Role.ADMIN), async (req, res) => { const users = await User.find(); res.json(users);});
// 更新自己的文章app.put('/api/posts/:id', authenticateToken, checkOwnership(Post), async (req, res) => { const post = req.resource; Object.assign(post, req.body); await post.save(); res.json(post);});12.8 安全检查清单
在部署应用之前,请检查以下安全措施:
基础安全
- 使用 helmet 设置安全头
- 启用 HTTPS(生产环境)
- 配置 CORS 策略
- 设置速率限制
- 使用环境变量存储敏感信息
认证和授权
- 密码加密存储(bcrypt)
- 使用 JWT 或 Session 认证
- 实现 CSRF 防护
- 设置合理的会话过期时间
数据安全
- 使用参数化查询防止 SQL 注入
- 转义用户输入防止 XSS
- 验证和清理用户输入
- 使用 HTTPS 传输敏感数据
日志和监控
- 记录安全事件
- 监控异常请求
- 定期审计日志
- 设置安全告警
定期维护
- 定期更新依赖包
- 定期备份数据
- 定期进行安全审计
- 制定应急响应计划
第二十三步:实时通信
实时通信是现代 Web 应用的重要特性,让服务器能够主动向客户端推送数据,实现即时更新和双向通信。
23.1 WebSocket 基础
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,它允许服务器和客户端之间进行实时数据交换。
WebSocket vs HTTP
| 特性 | HTTP | WebSocket |
|---|---|---|
| 通信模式 | 请求-响应 | 双向通信 |
| 连接 | 每次请求新建连接 | 持久连接 |
| 延迟 | 较高 | 极低 |
| 服务器推送 | 不支持 | 原生支持 |
| 开销 | 较大 | 较小 |
使用原生 WebSocket
const WebSocket = require('ws');
// 创建 WebSocket 服务器const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => { console.log('客户端已连接');
// 向客户端发送消息 ws.send('欢迎连接到 WebSocket 服务器!');
// 接收客户端消息 ws.on('message', (message) => { console.log('收到消息:', message.toString());
// 广播消息给所有客户端 wss.clients.forEach((client) => { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(message); } }); });
// 客户端断开连接 ws.on('close', () => { console.log('客户端已断开连接'); });
// 处理错误 ws.on('error', (error) => { console.error('WebSocket 错误:', error); });});
console.log('WebSocket 服务器运行在 ws://localhost:8080');23.2 Socket.io
NOTE📝 为什么选择 Socket.io?:虽然可以使用原生 WebSocket,但 Socket.io 提供了以下优势:
- ✅ 自动降级:在不支持 WebSocket 的浏览器中自动降级到长轮询
- ✅ 房间功能:轻松实现分组通信
- ✅ 自动重连:连接断开时自动重连
- ✅ 心跳检测:自动检测连接状态
- ✅ 更简单的 API:比原生 WebSocket 更易用
Socket.io 是一个基于 WebSocket 的库,提供了更简单易用的 API 和自动降级功能。
安装 Socket.io
npm install socket.io服务器端实现
const express = require('express');const http = require('http');const socketIo = require('socket.io');
const app = express();const server = http.createServer(app);const io = socketIo(server);
// Socket.io 连接处理io.on('connection', (socket) => { console.log('用户已连接:', socket.id);
// 加入房间 socket.on('join', (room) => { socket.join(room); socket.to(room).emit('message', `用户 ${socket.id} 加入了房间 ${room}`); });
// 接收消息 socket.on('message', (data) => { console.log('收到消息:', data);
// 广播消息给所有客户端 io.emit('message', { id: socket.id, text: data.text, timestamp: new Date() }); });
// 私聊 socket.on('private', (data) => { const { targetId, message } = data; io.to(targetId).emit('private', { from: socket.id, message }); });
// 断开连接 socket.on('disconnect', () => { console.log('用户已断开连接:', socket.id); io.emit('message', `用户 ${socket.id} 已离开`); });});
server.listen(3000, () => { console.log('服务器运行在 http://localhost:3000');});客户端实现
<!DOCTYPE html><html><head> <title>Socket.io 聊天室</title></head><body> <div id="messages"></div> <input type="text" id="messageInput" placeholder="输入消息..."> <button onclick="sendMessage()">发送</button>
<script src="/socket.io/socket.io.js"></script> <script> const socket = io();
// 接收消息 socket.on('message', (data) => { const messagesDiv = document.getElementById('messages'); const messageElement = document.createElement('div'); messageElement.textContent = data.text || data; messagesDiv.appendChild(messageElement); });
// 发送消息 function sendMessage() { const input = document.getElementById('messageInput'); const message = input.value; if (message) { socket.emit('message', { text: message }); input.value = ''; } }
// 加入房间 socket.emit('join', 'chat-room'); </script></body></html>23.3 实时聊天应用
完整的聊天应用示例
const express = require('express');const http = require('http');const socketIo = require('socket.io');const mongoose = require('mongoose');
const app = express();const server = http.createServer(app);const io = socketIo(server);
// 连接数据库mongoose.connect('mongodb://localhost:27017/chat-app');
// 消息模型const Message = mongoose.model('Message', { username: String, text: String, room: String, timestamp: { type: Date, default: Date.now }});
// 获取历史消息app.get('/api/messages/:room', async (req, res) => { const messages = await Message.find({ room: req.params.room }) .sort({ timestamp: -1 }) .limit(50); res.json(messages.reverse());});
// Socket.io 连接io.on('connection', (socket) => { console.log('用户已连接:', socket.id);
// 用户加入 socket.on('join', ({ username, room }) => { socket.join(room); socket.username = username; socket.room = room;
// 通知其他用户 socket.to(room).emit('notification', { message: `${username} 加入了聊天室`, type: 'join' }); });
// 发送消息 socket.on('sendMessage', async (message) => { const newMessage = new Message({ username: socket.username, text: message, room: socket.room });
await newMessage.save();
// 广播消息到房间 io.to(socket.room).emit('message', { username: socket.username, text: message, timestamp: newMessage.timestamp }); });
// 用户离开 socket.on('disconnect', () => { if (socket.username && socket.room) { socket.to(socket.room).emit('notification', { message: `${socket.username} 离开了聊天室`, type: 'leave' }); } });});
server.listen(3000, () => { console.log('聊天应用运行在 http://localhost:3000');});第二十四步:GraphQL 入门
NOTE📝 GraphQL vs REST:GraphQL 和 REST 各有优劣:
- REST:简单、成熟、缓存友好,适合大多数场景
- GraphQL:灵活、高效、减少请求次数,适合复杂的前端需求
建议:如果你是初学者,建议先掌握 RESTful API。在遇到 REST 难以解决的问题时,再考虑使用 GraphQL。
GraphQL 是一种用于 API 的查询语言,它提供了更灵活、高效的数据获取方式。
24.1 GraphQL 基础
GraphQL vs REST
| 特性 | REST | GraphQL |
|---|---|---|
| 数据获取 | 多个端点 | 单个端点 |
| 过度获取 | 常见 | 避免 |
| 不足获取 | 需要多次请求 | 一次请求 |
| 类型系统 | 无 | 强类型 |
| 自描述 | 需要 OpenAPI | 内置 |
GraphQL 基本概念
# 查询(Query)query { user(id: "1") { name email posts { title content } }}
# 变更(Mutation)mutation { createPost(input: { title: "新文章", content: "内容" }) { id title author { name } }}
# 订阅(Subscription)subscription { messageAdded { id text author { name } }}24.2 Apollo Server
安装 Apollo Server
npm install @apollo/server graphql创建 Apollo Server
const { ApolloServer, gql } = require('@apollo/server');const { startStandaloneServer } = require('@apollo/server/standalone');
// 定义 GraphQL Schemaconst typeDefs = gql` type User { id: ID! name: String! email: String! posts: [Post!]! }
type Post { id: ID! title: String! content: String! author: User! }
type Query { user(id: ID!): User users: [User!]! post(id: ID!): Post posts: [Post!]! }
type Mutation { createUser(name: String!, email: String!): User! createPost(title: String!, content: String!, authorId: ID!): Post! }`;
// 模拟数据const users = [ { id: '1', name: '张三', email: 'zhangsan@example.com' }, { id: '2', name: '李四', email: 'lisi@example.com' }];
const posts = [ { id: '1', title: '第一篇文章', content: '内容...', authorId: '1' }, { id: '2', title: '第二篇文章', content: '内容...', authorId: '2' }];
// 定义 Resolversconst resolvers = { Query: { user: (_, { id }) => users.find(user => user.id === id), users: () => users, post: (_, { id }) => posts.find(post => post.id === id), posts: () => posts },
Mutation: { createUser: (_, { name, email }) => { const newUser = { id: String(users.length + 1), name, email }; users.push(newUser); return newUser; },
createPost: (_, { title, content, authorId }) => { const newPost = { id: String(posts.length + 1), title, content, authorId }; posts.push(newPost); return newPost; } },
User: { posts: (user) => posts.filter(post => post.authorId === user.id) },
Post: { author: (post) => users.find(user => user.id === post.authorId) }};
// 创建 Apollo Serverconst server = new ApolloServer({ typeDefs, resolvers});
// 启动服务器startStandaloneServer(server, { listen: { port: 4000 }}).then(({ url }) => { console.log(`🚀 Server ready at ${url}`);});24.3 与 Express 集成
安装依赖
npm install @apollo/server express body-parser集成 Apollo Server 到 Express
const express = require('express');const { ApolloServer } = require('@apollo/server');const { expressMiddleware } = require('@apollo/server/express4');const bodyParser = require('body-parser');
const app = express();
// GraphQL Schema 和 Resolversconst typeDefs = gql` type Query { hello: String! }`;
const resolvers = { Query: { hello: () => 'Hello, World!' }};
// 创建 Apollo Serverconst server = new ApolloServer({ typeDefs, resolvers});
// 启动 Apollo Serverawait server.start();
// 集成到 Expressapp.use('/graphql', bodyParser.json(), expressMiddleware(server));
// 添加 REST APIapp.get('/api/hello', (req, res) => { res.json({ message: 'Hello from REST API!' });});
app.listen(3000, () => { console.log('服务器运行在 http://localhost:3000'); console.log('GraphQL Playground: http://localhost:3000/graphql');});使用数据库
const mongoose = require('mongoose');const { ApolloServer, gql } = require('@apollo/server');
// 连接数据库mongoose.connect('mongodb://localhost:27017/myapp');
// 定义 Mongoose 模型const User = mongoose.model('User', { name: String, email: String});
const Post = mongoose.model('Post', { title: String, content: String, author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }});
// GraphQL Schemaconst typeDefs = gql` type User { id: ID! name: String! email: String! posts: [Post!]! }
type Post { id: ID! title: String! content: String! author: User! }
type Query { users: [User!]! posts: [Post!]! }
type Mutation { createUser(name: String!, email: String!): User! createPost(title: String!, content: String!, authorId: ID!): Post! }`;
// Resolversconst resolvers = { Query: { users: async () => await User.find(), posts: async () => await Post.find().populate('author') },
Mutation: { createUser: async (_, { name, email }) => { const user = new User({ name, email }); await user.save(); return user; },
createPost: async (_, { title, content, authorId }) => { const post = new Post({ title, content, author: authorId }); await post.save(); return post.populate('author'); } },
User: { posts: async (user) => await Post.find({ author: user._id }) }};
// 创建 Apollo Serverconst server = new ApolloServer({ typeDefs, resolvers });第二十五步:微服务架构
WARNING⚠️ 微服务不是银弹:微服务架构虽然有很多优点,但也带来了额外的复杂度:
- 📡 服务间通信:需要处理网络延迟、故障等问题
- 🔧 运维复杂度:需要部署和管理多个服务
- 📊 分布式追踪:调试和监控变得更加困难
- 💾 数据一致性:需要处理分布式事务
建议:对于小型应用,单体架构更合适。只有当应用变得复杂、团队规模扩大时,才考虑微服务架构。
微服务架构是一种将应用拆分为多个小型、独立服务的设计模式,每个服务专注于特定的业务功能。
25.1 微服务概念
微服务 vs 单体应用
| 特性 | 单体应用 | 微服务 |
|---|---|---|
| 部署 | 整体部署 | 独立部署 |
| 扩展 | 整体扩展 | 按需扩展 |
| 技术栈 | 统一 | 多样化 |
| 故障隔离 | 影响整体 | 隔离故障 |
| 复杂度 | 较低 | 较高 |
微服务架构图
25.2 服务间通信
HTTP 通信
// 服务 A 调用服务 Bconst axios = require('axios');
class UserService { async getUserPosts(userId) { try { const response = await axios.get(`http://localhost:3002/api/posts/${userId}`); return response.data; } catch (error) { console.error('调用文章服务失败:', error.message); throw error; } }}
module.exports = new UserService();使用服务发现
npm install consulconst Consul = require('consul');
const consul = new Consul();
// 注册服务async function registerService() { await consul.agent.service.register({ name: 'user-service', port: 3001, check: { http: 'http://localhost:3001/health', interval: '10s' } }); console.log('服务已注册到 Consul');}
// 发现服务async function discoverService(serviceName) { const services = await consul.agent.service.list(); const service = services[serviceName];
if (!service) { throw new Error(`服务 ${serviceName} 未找到`); }
return service;}
// 调用服务async function callService(serviceName, endpoint) { const service = await discoverService(serviceName); const url = `http://${service.Address}:${service.Port}${endpoint}`; const response = await axios.get(url); return response.data;}25.3 API 网关
使用 Express 作为 API 网关
const express = require('express');const httpProxy = require('http-proxy-middleware');const axios = require('axios');
const app = express();
// 服务配置const services = { users: 'http://localhost:3001', posts: 'http://localhost:3002', comments: 'http://localhost:3003'};
// 代理中间件const createProxy = (target) => { return httpProxy.createProxyMiddleware({ target, changeOrigin: true, pathRewrite: { [`^/api/${target.split(':')[2].replace('//', '')}`]: '' } });};
// 路由到用户服务app.use('/api/users', createProxy(services.users));
// 路由到文章服务app.use('/api/posts', createProxy(services.posts));
// 路由到评论服务app.use('/api/comments', createProxy(services.comments));
// 聚合接口app.get('/api/dashboard/:userId', async (req, res) => { try { const { userId } = req.params;
// 并行调用多个服务 const [user, posts, comments] = await Promise.all([ axios.get(`${services.users}/api/users/${userId}`), axios.get(`${services.posts}/api/posts?userId=${userId}`), axios.get(`${services.comments}/api/comments?userId=${userId}`) ]);
res.json({ user: user.data, posts: posts.data, comments: comments.data }); } catch (error) { res.status(500).json({ error: error.message }); }});
// 健康检查app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date() });});
app.listen(3000, () => { console.log('API 网关运行在 http://localhost:3000');});使用 Nginx 作为 API 网关
upstream user_service { server localhost:3001;}
upstream post_service { server localhost:3002;}
upstream comment_service { server localhost:3003;}
server { listen 80; server_name api.example.com;
# 用户服务 location /api/users { proxy_pass http://user_service; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
# 文章服务 location /api/posts { proxy_pass http://post_service; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
# 评论服务 location /api/comments { proxy_pass http://comment_service; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}完成本指南的学习后,您可以:
🎯 实践项目
- 完成教程中的实战练习,巩固所学知识
- 尝试开发自己的第一个 Express.js 应用
- 开发一个完整的博客系统,包含用户认证、文章管理、评论功能
- 开发一个实时聊天应用,使用 WebSocket 或 Socket.io
- 开发一个电商平台,包含商品管理、购物车、订单系统
📚 深入学习
- 继续深入学习 Express.js 的高级特性
- 学习数据库集成(MongoDB、MySQL、PostgreSQL)
- 学习用户认证和授权(JWT、Session、OAuth 2.0)
- 学习 WebSocket 实时通信
- 学习 GraphQL,构建灵活的 API
- 学习微服务架构,构建可扩展的应用
🔧 工具和框架
- 学习测试(单元测试、集成测试、E2E 测试)
- 学习 CI/CD(GitHub Actions、GitLab CI、Jenkins)
- 学习容器化(Docker、Kubernetes)
- 学习云服务(AWS、Azure、Google Cloud)
- 学习监控和日志(Prometheus、Grafana、ELK Stack)
🌐 进阶主题
- 性能优化:缓存策略、负载均衡、CDN
- 安全最佳实践:HTTPS、安全头、漏洞扫描
- API 设计:RESTful API、GraphQL、gRPC
- 实时应用:WebSocket、Server-Sent Events、WebRTC
- 微服务:服务发现、API 网关、分布式追踪
📖 推荐学习路径
🎓 学习资源
官方资源
推荐书籍
- 《Node.js 实战(第2版)》
- 《Node.js 设计模式》
- 《深入浅出 GraphQL》
- 《微服务架构设计模式》
在线课程
- Udemy: Node.js - The Complete Guide
- Coursera: Server-side Development with Node.js
- Pluralsight: Building Web Apps with Node.js and Express
- Egghead.io: GraphQL Fundamentals
社区资源
💡 学习建议
- 循序渐进:不要急于求成,按照文档的顺序学习
- 多动手实践:理论知识要结合实际项目
- 阅读源码:学习优秀开源项目的代码
- 参与社区:加入技术社区,与其他开发者交流
- 持续学习:技术更新快,保持学习的热情
🚀 职业发展
掌握 Express.js 后,您可以:
- 成为全栈开发工程师
- 成为后端开发工程师
- 成为 API 开发专家
- 成为系统架构师
- 创业开发自己的产品
📚 附录:完整知识体系
Express.js 知识图谱
Express.js├── 基础│ ├── 安装和配置│ ├── 路由│ ├── 中间件│ ├── 请求处理│ └── 响应处理├── 进阶│ ├── 错误处理│ ├── 模板引擎│ ├── 静态文件│ ├── RESTful API│ └── 调试技巧├── 数据库│ ├── MongoDB│ ├── MySQL│ ├── PostgreSQL│ └── Redis├── 认证授权│ ├── JWT│ ├── Session│ ├── OAuth 2.0│ └── 权限控制├── 性能优化│ ├── 缓存策略│ ├── 数据库优化│ ├── 并发处理│ └── 监控告警├── 安全实践│ ├── SQL 注入防护│ ├── XSS 防护│ ├── CSRF 防护│ └── 速率限制├── 实时通信│ ├── WebSocket│ └── Socket.io├── GraphQL│ ├── Schema 定义│ ├── Resolvers│ └── Apollo Server├── 微服务│ ├── 服务拆分│ ├── 服务通信│ └── API 网关└── 部署运维 ├── Docker ├── CI/CD ├── 监控 └── 日志技能树
初级(1-3 个月)├── Express.js 基础├── RESTful API├── MongoDB/MySQL└── 基础认证
中级(3-6 个月)├── 性能优化├── 安全实践├── WebSocket├── 测试└── Docker
高级(6-12 个月)├── GraphQL├── 微服务├── TypeScript├── CI/CD└── 系统架构
专家(1 年以上)├── 分布式系统├── 高并发处理├── 云原生└── 技术领导祝您学习愉快!🎉
如果您在学习过程中遇到问题,欢迎查阅官方文档、搜索技术社区或参与开源项目。记住,编程是一个持续学习的过程,保持好奇心和学习的热情,您一定能成为一名优秀的 Express.js 开发者!
最后更新:2026-01-15
祝您学习愉快!🎉
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
MZ-Blog
提供网站内容分发