第10章 10.3 终极项目:仿 V2EX 论坛 API
🎯 开场 3 分钟:为什么要学这个?
上一章我们学会了用 commander + chalk 写 CLI 工具,现在你已经有了一把趁手的瑞士军刀。但你有没有想过,那些每天刷的论坛、用的 API,背后是怎么跑起来的?
想象一下:你打开 V2EX,看到帖子列表、点进去看详情、登录后能发帖回帖——这整套流程,背后全靠 API(接口) 在运转。API 就像是餐厅的菜单,告诉你能点什么菜(接口列表),厨房接到单子后做菜返回给你(响应数据)。
这一章,我们要用 Node.js 从零搭一个「仿 V2EX 论坛 API」,涉及:
- Express 框架搭服务器
- MySQL 存用户和帖子数据
- Redis 做缓存(让热门帖子秒回)
- JWT 做登录验证
学完你能:自己写一个能跑论坛 API 的后端服务。
🧱 基础 25 分钟:核心概念
什么是 API?为什么需要它?
类比:你走进一家快餐店,点了一份薯条套餐。
- 你 → 浏览器 / 前端应用
- 服务员 → API 接口
- 厨房 → 后端服务器 + 数据库
你不需要知道厨房怎么炸薯条,只需要跟服务员说「我要薯条套餐」,服务员就给你端来。对接 API 也是这个道理——前端只管调接口拿数据,后端怎么实现它不 care。
Express 是什么?
Express 是 Node.js 最流行的 Web 框架,好比「自动挡汽车」——你不用自己造引擎,挂挡就走。
// 1. 引入 Express
const express = require('express');
// 2. 创建应用
const app = express();
// 3. 定义一个接口:GET /hello 返回 "Hello V2EX"
app.get('/hello', (req, res) => {
res.send('Hello V2EX');
});
// 4. 监听 3000 端口
app.listen(3000, () => {
console.log('🚀 服务器跑在 http://localhost:3000');
});
运行后访问 http://localhost:3000/hello,浏览器就显示 Hello V2EX。
路由是什么?
路由 = 接口地址 + 请求方法。类比:路由就像快递单上的「收件人 + 地址」,决定你的请求发到哪儿去。
// GET 请求 - 查帖子列表
app.get('/api/topics', (req, res) => { ... });
// GET 请求 - 查单个帖子详情
app.get('/api/topics/:id', (req, res) => { ... });
// POST 请求 - 发新帖子
app.post('/api/topics', (req, res) => { ... });
:id 是动态参数,req.params.id 能拿到 URL 里的 ID 值。
中间件是什么?
中间件 = 处理请求的「流水线工人」。一个请求进来,要经过多个中间件处理:验证登录 → 查询数据库 → 格式化返回。
// 这是一个中间件函数
const logger = (req, res, next) => {
console.log(`收到请求: ${req.method} ${req.url}`);
next(); // 必须调用 next() 交给下一个中间件
};
app.use(logger); // 所有请求都会先经过 logger

MySQL 存数据
MySQL 是一个结构化数据库,类似 Excel 表格,但能用代码操作。
-- 创建 users 表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE,
password VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 创建 topics 表
CREATE TABLE topics (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200),
content TEXT,
user_id INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
Node.js 里用 mysql2 包操作数据库:
const mysql = require('mysql2/promise');
// 创建连接池(复用连接,性能更好)
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'your_password',
database: 'v2ex_clone',
waitForConnections: true,
connectionLimit: 10
});
// 查询所有帖子
const [rows] = await pool.query('SELECT * FROM topics ORDER BY created_at DESC');
res.json(rows);
Redis 做缓存
Redis 是内存数据库,读写速度极快。热门帖子数据放 Redis 里,用户请求时直接读缓存,不用每次都查 MySQL。
const redis = require('redis');
const client = redis.createClient();
client.on('error', err => console.log('Redis 连接错误', err));
// 查询帖子列表 - 先看缓存有没有
async function getTopics() {
const cacheKey = 'topics:list';
const cached = await client.get(cacheKey);
if (cached) {
console.log('📦 从缓存读取');
return JSON.parse(cached);
}
// 缓存没有,查数据库
const [rows] = await pool.query('SELECT * FROM topics ORDER BY created_at DESC');
// 存入缓存,过期时间 60 秒
await client.setEx(cacheKey, 60, JSON.stringify(rows));
console.log('💾 写入缓存');
return rows;
}
JWT 做登录验证
JWT(JSON Web Token) = 电子身份证。用户登录成功后,服务器发给他一张令牌(token),之后请求需要带这张令牌,服务器验证后才让他操作。
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'v2ex_secret_key_2024';
// 登录接口,验证成功后生成 token
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// 验证用户名密码(实际项目要加密比对,这里省略)
const [users] = await pool.query('SELECT * FROM users WHERE username = ?', [username]);
if (users.length === 0) {
return res.status(401).json({ error: '用户不存在' });
}
// 生成 token,有效期 7 天
const token = jwt.sign(
{ userId: users[0].id, username: users[0].username },
SECRET_KEY,
{ expiresIn: '7d' }
);
res.json({ token });
});
// 需要登录才能访问的接口,用中间件验证
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: '请先登录' });
}
try {
const decoded = jwt.verify(token, SECRET_KEY);
req.user = decoded; // 把用户信息挂到 req 上
next();
} catch (err) {
res.status(403).json({ error: 'token 无效或已过期' });
}
};
// 发帖接口需要登录
app.post('/api/topics', authMiddleware, async (req, res) => {
const { title, content } = req.body;
await pool.query(
'INSERT INTO topics (title, content, user_id) VALUES (?, ?, ?)',
[title, content, req.user.userId]
);
res.json({ success: true, message: '发帖成功' });
});

🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):Hello V2EX API
目标:用 Express 搭一个最简单的服务器,返回欢迎信息和当前时间。
// app.js
const express = require('express');
const app = express();
const PORT = 3000;
// 解析 JSON 请求体
app.use(express.json());
// 欢迎接口
app.get('/api/welcome', (req, res) => {
res.json({
message: '欢迎来到仿 V2EX API',
version: '1.0.0',
time: new Date().toISOString()
});
});
// 跑起来
app.listen(PORT, () => {
console.log(`🎉 V2EX API 已启动: http://localhost:${PORT}`);
});
运行:
node app.js
预期输出:
🎉 V2EX API 已启动: http://localhost:3000
curl 测试:
curl http://localhost:3000/api/welcome
返回:
{"message":"欢迎来到仿 V2EX API","version":"1.0.0","time":"2024-01-15T10:30:00.000Z"}
项目 2(15 分钟):带 MySQL 存储的帖子 API
目标:把项目 1 升级,支持从 MySQL 查帖子数据。假设你已经在本地建好 v2ex_clone 数据库和 topics 表。
// app_with_db.js
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
app.use(express.json());
const PORT = 3000;
// 数据库连接池
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'your_password', // 改成你的密码
database: 'v2ex_clone',
waitForConnections: true,
connectionLimit: 10
});
// 先插入几条测试数据(如果表是空的)
async function initData() {
const [rows] = await pool.query('SELECT COUNT(*) as count FROM topics');
if (rows[0].count === 0) {
await pool.query(`
INSERT INTO topics (title, content, user_id) VALUES
('Node.js 入门求助', '刚学 Node,感觉回调地狱好难理解,求大佬带', 1),
('V2EX 是最良心的程序员社区', '没有广告,干货满满,强烈推荐', 2),
('求推荐 Node.js 好书', '看过《深入浅出 Node.js》,还想再买几本', 1)
`);
console.log('📝 测试数据已初始化');
}
}
// GET /api/topics - 获取所有帖子
app.get('/api/topics', async (req, res) => {
try {
const [topics] = await pool.query(
'SELECT t.*, u.username FROM topics t JOIN users u ON t.user_id = u.id ORDER BY t.created_at DESC'
);
res.json({ success: true, data: topics });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// GET /api/topics/:id - 获取单个帖子
app.get('/api/topics/:id', async (req, res) => {
try {
const [topics] = await pool.query(
'SELECT t.*, u.username FROM topics t JOIN users u ON t.user_id = u.id WHERE t.id = ?',
[req.params.id]
);
if (topics.length === 0) {
return res.status(404).json({ success: false, error: '帖子不存在' });
}
res.json({ success: true, data: topics[0] });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// POST /api/topics - 发新帖子(简化版,不做登录验证)
app.post('/api/topics', async (req, res) => {
try {
const { title, content, user_id } = req.body;
if (!title || !content) {
return res.status(400).json({ success: false, error: '标题和内容不能为空' });
}
const [result] = await pool.query(
'INSERT INTO topics (title, content, user_id) VALUES (?, ?, ?)',
[title, content, user_id || 1]
);
res.json({ success: true, message: '发帖成功', topicId: result.insertId });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// 启动
initData().then(() => {
app.listen(PORT, () => {
console.log(`🔥 帖子 API 已启动: http://localhost:${PORT}`);
});
});
运行:
node app_with_db.js
curl 测试:
# 获取所有帖子
curl http://localhost:3000/api/topics
# 获取 ID 为 1 的帖子
curl http://localhost:3000/api/topics/1
# 发新帖子
curl -X POST http://localhost:3000/api/topics \
-H "Content-Type: application/json" \
-d '{"title":"我的第一个帖子","content":"Hello V2EX!","user_id":1}'
预期输出(获取帖子列表):
{
"success": true,
"data": [
{"id":1,"title":"Node.js 入门求助","content":"刚学 Node,感觉回调地狱好难理解,求大佬带","user_id":1,"username":"xiaoming","created_at":"..."},
{"id":2,"title":"V2EX 是最良心的程序员社区","content":"没有广告,干货满满,强烈推荐","user_id":2,"username":"路人甲","created_at":"..."}
]
}
项目 3(15 分钟):仿 V2EX 完整 API(带 Redis 缓存 + JWT 认证)
目标:组合前面学的所有知识,做一个有点真实用的论坛 API,支持登录、发帖、点赞(用 Redis 计数)。
// v2ex_api.js - 仿 V2EX 论坛完整 API
const express = require('express');
const mysql = require('mysql2/promise');
const redis = require('redis');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const PORT = 3000;
const SECRET_KEY = 'v2ex_super_secret_2024';
// MySQL 连接池
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'your_password',
database: 'v2ex_clone',
waitForConnections: true,
connectionLimit: 10
});
// Redis 客户端
const redisClient = redis.createClient();
redisClient.on('error', err => console.log('Redis 错误:', err));
// 初始化数据
async function initData() {
await redisClient.connect();
const [rows] = await pool.query('SELECT COUNT(*) as count FROM users');
if (rows[0].count === 0) {
await pool.query(`
INSERT INTO users (username, password) VALUES
('xiaoming', '123456'),
('路人甲', 'password')
`);
await pool.query(`
INSERT INTO topics (title, content, user_id) VALUES
('Node.js 入门求助', '刚学 Node,感觉回调地狱好难理解', 1),
('V2EX 是最良心的程序员社区', '没有广告,干货满满', 2)
`);
console.log('📦 初始化数据完成');
}
}
// ============ 中间件 ============
// JWT 验证中间件
const auth = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: '请先登录' });
try {
const decoded = jwt.verify(token, SECRET_KEY);
req.user = decoded;
next();
} catch (err) {
res.status(403).json({ error: 'token 无效或已过期' });
}
};
// 请求日志中间件
const logger = (req, res, next) => {
console.log(`📨 ${new Date().toLocaleTimeString()} | ${req.method} ${req.url}`);
next();
};
app.use(logger);
// ============ API 接口 ============
// POST /api/register - 注册
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码必填' });
}
try {
const [result] = await pool.query(
'INSERT INTO users (username, password) VALUES (?, ?)',
[username, password] // 实际项目要加密!
);
res.json({ success: true, message: '注册成功', userId: result.insertId });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
res.status(400).json({ error: '用户名已存在' });
} else {
res.status(500).json({ error: err.message });
}
}
});
// POST /api/login - 登录
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const [users] = await pool.query(
'SELECT * FROM users WHERE username = ? AND password = ?',
[username, password]
);
if (users.length === 0) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = jwt.sign(
{ userId: users[0].id, username: users[0].username },
SECRET_KEY,
{ expiresIn: '7d' }
);
res.json({ success: true, token, username: users[0].username });
});
// GET /api/topics - 获取帖子列表(带 Redis 缓存)
app.get('/api/topics', async (req, res) => {
const cacheKey = 'topics:list';
// 先查缓存
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log('📦 命中缓存');
return res.json({ success: true, data: JSON.parse(cached), fromCache: true });
}
// 缓存没有,查数据库
const [topics] = await pool.query(
'SELECT t.*, u.username FROM topics t JOIN users u ON t.user_id = u.id ORDER BY t.created_at DESC'
);
// 写入缓存,60 秒过期
await redisClient.setEx(cacheKey, 60, JSON.stringify(topics));
console.log('💾 写入缓存');
res.json({ success: true, data: topics, fromCache: false });
});
// POST /api/topics - 发帖(需登录)
app.post('/api/topics', auth, async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: '标题和内容不能为空' });
}
await pool.query(
'INSERT INTO topics (title, content, user_id) VALUES (?, ?, ?)',
[title, content, req.user.userId]
);
// 发新帖子后清除缓存
await redisClient.del('topics:list');
res.json({ success: true, message: '发帖成功' });
});
// POST /api/topics/:id/like - 点赞(用 Redis 计数)
app.post('/api/topics/:id/like', async (req, res) => {
const topicId = req.params.id;
const likeKey = `topic:${topicId}:likes`;
const likes = await redisClient.incr(likeKey);
res.json({ success: true, topicId, likes });
});
// GET /api/topics/:id/like - 获取点赞数
app.get('/api/topics/:id/like', async (req, res) => {
const topicId = req.params.id;
const likeKey = `topic:${topicId}:likes`;
const likes = await redisClient.get(likeKey);
res.json({ topicId, likes: parseInt(likes) || 0 });
});
// 启动
initData().then(() => {
app.listen(PORT, () => {
console.log(`🎉 仿 V2EX API 完整版已启动: http://localhost:${PORT}`);
console.log('📚 可用接口:');
console.log(' POST /api/register - 注册');
console.log(' POST /api/login - 登录');
console.log(' GET /api/topics - 获取帖子列表');
console.log(' POST /api/topics - 发帖(需登录)');
console.log(' POST /api/topics/:id/like - 点赞');
});
});
运行:
node v2ex_api.js
测试流程:
# 1. 注册新用户
curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"test123"}'
# 2. 登录获取 token
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"test123"}'
# 返回: {"success":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","username":"testuser"}
# 3. 查看帖子(第一次查数据库,第二次命中缓存)
curl http://localhost:3000/api/topics
curl http://localhost:3000/api/topics # 会看到 "fromCache": true
# 4. 发帖(带上 token)
curl -X POST http://localhost:3000/api/topics \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 你的token" \
-d '{"title":"学习 Node.js 真的很好玩","content":"从 CLI 到 API,终于有点感觉了!"}'
# 5. 点赞帖子
curl -X POST http://localhost:3000/api/topics/1/like
curl http://localhost:3000/api/topics/1/like
💪 进阶 20 分钟:常见坑 + 性能小贴士
❌ 坑 1:忘记 await 导致数据没存进去
// ❌ 错误:没写 await,插入操作可能没完成就继续执行了
app.post('/api/topics', (req, res) => {
pool.query('INSERT INTO topics ...'); // 没 await
res.json({ success: true }); // 直接返回了
});
// ✅ 正确:async/await 配合
app.post('/api/topics', async (req, res) => {
try {
await pool.query('INSERT INTO topics ...');
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
❌ 坑 2:密码明文存储
// ❌ 危险:密码直接存数据库,万一数据库泄露就完了
await pool.query('INSERT INTO users VALUES (?, ?)', [username, password]);
// ✅ 正确:用 bcrypt 加密
const bcrypt = require('bcrypt');
const hashedPassword = await bcrypt.hash(password, 10);
await pool.query('INSERT INTO users VALUES (?, ?)', [username, hashedPassword]);
// 登录时比对
const match = await bcrypt.compare(inputPassword, storedHash);
❌ 坑 3:Redis 连接没等待就使用
// ❌ 错误:Redis 还没连接完成就执行 get/set
const client = redis.createClient();
client.get('key'); // 很可能报错 Connection is not ready
// ✅ 正确:等连接完成
const client = redis.createClient();
await client.connect();
// 或者用 Promise 包装
new Promise((resolve) => {
client.on('ready', resolve);
});
❌ 坑 4:SQL 注入漏洞
// ❌ 危险:直接把用户输入拼进 SQL
const sql = `SELECT * FROM users WHERE username = '${username}'`;
// ✅ 安全:用参数化查询
await pool.query('SELECT * FROM users WHERE username = ?', [username]);
❌ 坑 5:忘记处理跨域(CORS)
如果前端页面(localhost:5500)调用 API(localhost:3000),浏览器会报 CORS 错误:
// ✅ 安装并使用 cors 中间件
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:5500', // 允许的前端地址
credentials: true
}));
⚡ 性能优化:批量操作用事务
// ❌ 慢:多次单独查询
await pool.query('INSERT INTO topics ...');
await pool.query('UPDATE users SET post_count = post_count + 1 ...');
// ✅ 快:一次事务搞定
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
await connection.query('INSERT INTO topics ...');
await connection.query('UPDATE users SET post_count = post_count + 1 ...');
await connection.commit();
} catch (err) {
await connection.rollback();
} finally {
connection.release();
}
🔍 调试技巧:用 console.log 打日志
// 关键位置打印变量,快速定位问题
app.get('/api/topics', async (req, res) => {
console.log('🔥 收到请求, params:', req.params);
console.log('🔥 查询参数:', req.query);
try {
const [topics] = await pool.query('SELECT * FROM topics');
console.log('✅ 查询结果数量:', topics.length);
res.json(topics);
} catch (err) {
console.error('❌ 数据库错误:', err);
res.status(500).json({ error: '服务器错误' });
}
});
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):改端口
- 输入:把项目 1 的端口从 3000 改成 8080
- 预期输出:服务器监听在 8080
- 提示:PORT 变量改一下就行
练习 2(2 分钟):加条件判断
- 输入:在项目 2 的发帖接口,加一个检查:内容长度不能超过 1000 字
- 预期输出:超过 1000 字返回错误 {"error":"内容太长了"}
- 提示:在 INSERT 之前加 if 判断
练习 3(2 分钟):用新数据
- 输入:创建一张 comments 表(id, topic_id, content, user_id, created_at),用项目 2 的方法写一个查询某帖子所有评论的接口
- 预期输出:GET /api/topics/:id/comments 返回该帖子的评论列表
- 提示:SQL 用 WHERE topic_id = ?
练习 4(2 分钟):串起来
- 输入:在项目 3 基础上,给帖子加「阅读数」字段,每次调用 GET /api/topics/:id 时阅读数 +1(用 Redis)
- 预期输出:多次请求同一帖子,返回的阅读数递增
- 提示:类似点赞的逻辑,用 INCR 命令
练习 5(2 分钟):分析报错
- 输入:用户运行 curl -X POST http://localhost:3000/api/topics -d "title=test" 返回 {"error":"Unexpected end of JSON input"}
- 预期输出:说出原因并修复
- 提示:检查请求头和数据格式
作业题(30 分钟 - 2 小时)
作业:给仿 V2EX API 加评论功能
- 需求描述:让用户能对帖子进行评论,形成完整的讨论功能
- 功能点:
1.POST /api/topics/:id/comments- 添加评论(需登录)
2.GET /api/topics/:id/comments- 获取某帖子的所有评论
3. 评论列表按时间正序(最早的在前)
4. 评论需要显示评论者用户名 - 加分项:
1. 评论支持 Redis 缓存,3 分钟过期
2. 加一个DELETE /api/comments/:id删除自己的评论(需登录验证) - 验收标准:
- 能跑起来不报错
- curl 测试有正确返回
- 代码有适当注释
- 提交方式:评论区贴完整代码或 GitHub 链接
📚 总结 + 资源
一句话总结
本文学了 3 个核心点:用 Express 搭 API、用 MySQL 存数据、用 Redis 做缓存、JWT 做登录验证,终于能把一个完整的后端服务跑起来了。
延伸学习资源
-
Express 官方文档 https://expressjs.com/
官方文档永远是最好的学习资料,边写代码边查 -
《Node.js 实战》(人民邮电出版社)
进阶读物,讲得很系统,从基础到项目 -
V2EX 社区 https://www.v2ex.com/
本身就是程序员社区,技术氛围浓厚,多逛逛找灵感
🎯 互动钩子
你在学习 API 开发时踩过什么坑? 是 SQL 注入、还是 CORS 跨域?评论区聊聊你的血泪史,老粉优先回复!
📢 下一章预告:Node.js 系列到这里就告一段落了。你已经从前端的 npm 脚本、CLI 工具,一路走到了后端的 API 开发、数据库操作。下一阶段建议:
- 尝试用 React 或 Vue 写一个前端页面,对接你写的 API,真正「前后端联调」
- 学习 TypeScript,给你的 Node.js 代码加上类型检查,写出更健壮的代码
- 研究 Docker 部署,把你的 API 打包成容器,一键部署到云服务器
路还长,但基础你已经打好了。继续加油! 🚀

评论(0)