Node.js开发实战手把手教你构建专业RESTful API应用包含路由设计数据库集成错误处理安全认证等核心模块
引言
RESTful API是现代Web应用开发的基石,它提供了一种标准化的方式来实现客户端和服务器之间的通信。Node.js作为一个基于Chrome V8引擎的JavaScript运行环境,以其非阻塞I/O和事件驱动的特性,成为了构建高性能RESTful API的理想选择。本文将手把手教你如何使用Node.js构建一个专业的RESTful API应用,涵盖路由设计、数据库集成、错误处理、安全认证等核心模块。
环境准备
在开始构建我们的RESTful API之前,需要确保你的开发环境已经准备就绪。
安装Node.js和npm
首先,你需要在你的系统上安装Node.js和npm(Node包管理器)。你可以从Node.js官网下载并安装最新的LTS版本。
安装完成后,可以通过以下命令验证安装是否成功:
node -v npm -v
选择代码编辑器
推荐使用Visual Studio Code作为开发环境,它提供了丰富的插件支持,特别是对于Node.js开发。
安装必要的工具
我们将使用Express.js作为Web框架,Mongoose作为MongoDB的ODM,以及一些其他有用的库:
npm install express mongoose body-parser cors helmet morgan jsonwebtoken bcryptjs dotenv express-rate-limit express-validator
同时,安装一些开发依赖:
npm install --save-dev nodemon jest supertest
项目初始化
创建项目结构
让我们创建一个清晰的项目结构:
project-root/ ├── src/ │ ├── controllers/ # 控制器 │ ├── models/ # 数据模型 │ ├── routes/ # 路由定义 │ ├── middlewares/ # 中间件 │ ├── services/ # 业务逻辑 │ ├── utils/ # 工具函数 │ ├── config/ # 配置文件 │ └── app.js # 应用入口 ├── tests/ # 测试文件 ├── .env # 环境变量 ├── .gitignore └── package.json
初始化npm项目
在项目根目录运行:
npm init -y
创建基本应用文件
创建src/app.js
文件,这是我们的应用入口点:
const express = require('express'); const bodyParser = require('body-parser'); const cors = require('cors'); const helmet = require('helmet'); const morgan = require('morgan'); const mongoose = require('mongoose'); require('dotenv').config(); const app = express(); // 中间件 app.use(helmet()); app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(morgan('combined')); // 数据库连接 mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, }) .then(() => console.log('MongoDB connected')) .catch(err => console.error('MongoDB connection error:', err)); // 路由 app.get('/', (req, res) => { res.json({ message: 'Welcome to our API!' }); }); // 错误处理中间件 app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ message: 'Something went wrong!', error: process.env.NODE_ENV === 'development' ? err : {} }); }); // 端口 const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); });
创建.env
文件用于存储环境变量:
NODE_ENV=development PORT=3000 MONGODB_URI=mongodb://localhost:27017/myapi JWT_SECRET=your_jwt_secret JWT_EXPIRES_IN=7d
路由设计
RESTful API的路由设计应当遵循REST(Representational State Transfer)原则,使用HTTP动词(GET, POST, PUT, DELETE等)来表示对资源的操作。
RESTful路由设计原则
- 使用名词而不是动词表示资源
- 使用HTTP动词表示操作
- 资源层级关系通过URL路径表示
- 使用查询参数进行过滤、排序和分页
实现用户路由
让我们以用户管理为例,实现一套完整的RESTful路由。
首先,创建src/routes/userRoutes.js
文件:
const express = require('express'); const router = express.Router(); const userController = require('../controllers/userController'); const authMiddleware = require('../middlewares/authMiddleware'); // 用户注册 router.post('/register', userController.register); // 用户登录 router.post('/login', userController.login); // 获取所有用户 (需要认证) router.get('/', authMiddleware, userController.getAllUsers); // 获取特定用户 (需要认证) router.get('/:id', authMiddleware, userController.getUserById); // 更新用户信息 (需要认证) router.put('/:id', authMiddleware, userController.updateUser); // 删除用户 (需要认证) router.delete('/:id', authMiddleware, userController.deleteUser); module.exports = router;
实现产品路由
同样,创建src/routes/productRoutes.js
文件:
const express = require('express'); const router = express.Router(); const productController = require('../controllers/productController'); const authMiddleware = require('../middlewares/authMiddleware'); // 获取所有产品 router.get('/', productController.getAllProducts); // 获取特定产品 router.get('/:id', productController.getProductById); // 创建新产品 (需要认证) router.post('/', authMiddleware, productController.createProduct); // 更新产品信息 (需要认证) router.put('/:id', authMiddleware, productController.updateProduct); // 删除产品 (需要认证) router.delete('/:id', authMiddleware, productController.deleteProduct); module.exports = router;
在主应用中注册路由
修改src/app.js
文件,添加路由注册:
const userRoutes = require('./routes/userRoutes'); const productRoutes = require('./routes/productRoutes'); // ... 其他代码 ... // 路由 app.get('/', (req, res) => { res.json({ message: 'Welcome to our API!' }); }); app.use('/api/users', userRoutes); app.use('/api/products', productRoutes); // ... 其他代码 ...
数据库集成
我们将使用MongoDB作为数据库,并通过Mongoose进行交互。MongoDB是一个NoSQL文档数据库,非常适合灵活的数据结构。
设计数据模型
首先,创建用户模型src/models/User.js
:
const mongoose = require('mongoose'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const UserSchema = new mongoose.Schema({ username: { type: String, required: [true, 'Please provide a username'], unique: true, trim: true, minlength: 3, maxlength: 50 }, email: { type: String, required: [true, 'Please provide an email'], unique: true, match: [ /^w+([.-]?w+)*@w+([.-]?w+)*(.w{2,3})+$/, 'Please provide a valid email' ] }, password: { type: String, required: [true, 'Please provide a password'], minlength: 6, select: false }, role: { type: String, enum: ['user', 'admin'], default: 'user' } }, { timestamps: true }); // 加密密码 UserSchema.pre('save', async function(next) { // 只有在密码被修改时才进行加密 if (!this.isModified('password')) { next(); } const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); next(); }); // 生成JWT令牌 UserSchema.methods.getSignedJwtToken = function() { return jwt.sign( { id: this._id, role: this.role }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN } ); }; // 验证密码 UserSchema.methods.matchPassword = async function(enteredPassword) { return await bcrypt.compare(enteredPassword, this.password); }; module.exports = mongoose.model('User', UserSchema);
然后,创建产品模型src/models/Product.js
:
const mongoose = require('mongoose'); const ProductSchema = new mongoose.Schema({ name: { type: String, required: [true, 'Please provide a product name'], trim: true, maxlength: [100, 'Name cannot be more than 100 characters'] }, description: { type: String, required: [true, 'Please provide a product description'], maxlength: [1000, 'Description cannot be more than 1000 characters'] }, price: { type: Number, required: [true, 'Please provide a product price'], min: [0, 'Price must be a positive number'] }, category: { type: String, required: [true, 'Please provide a product category'], enum: ['Electronics', 'Clothing', 'Food', 'Books', 'Other'] }, inStock: { type: Boolean, default: true }, createdBy: { type: mongoose.Schema.ObjectId, ref: 'User', required: true } }, { timestamps: true }); module.exports = mongoose.model('Product', ProductSchema);
实现控制器
现在,让我们实现用户控制器src/controllers/userController.js
:
const User = require('../models/User'); const ErrorResponse = require('../utils/ErrorResponse'); // @desc 注册用户 // @route POST /api/users/register // @access Public exports.register = async (req, res, next) => { try { const { username, email, password, role } = req.body; // 创建用户 const user = await User.create({ username, email, password, role }); // 生成令牌 const token = user.getSignedJwtToken(); res.status(201).json({ success: true, token, user: { id: user._id, username: user.username, email: user.email, role: user.role } }); } catch (err) { next(err); } }; // @desc 用户登录 // @route POST /api/users/login // @access Public exports.login = async (req, res, next) => { try { const { email, password } = req.body; // 验证请求 if (!email || !password) { return next(new ErrorResponse('Please provide an email and password', 400)); } // 查找用户并选择密码字段 const user = await User.findOne({ email }).select('+password'); if (!user) { return next(new ErrorResponse('Invalid credentials', 401)); } // 验证密码 const isMatch = await user.matchPassword(password); if (!isMatch) { return next(new ErrorResponse('Invalid credentials', 401)); } // 生成令牌 const token = user.getSignedJwtToken(); res.status(200).json({ success: true, token, user: { id: user._id, username: user.username, email: user.email, role: user.role } }); } catch (err) { next(err); } }; // @desc 获取所有用户 // @route GET /api/users // @access Private exports.getAllUsers = async (req, res, next) => { try { // 查询参数 const page = parseInt(req.query.page, 10) || 1; const limit = parseInt(req.query.limit, 10) || 10; const sort = req.query.sort || '-createdAt'; const startIndex = (page - 1) * limit; // 执行查询 const users = await User.find() .sort(sort) .skip(startIndex) .limit(limit); // 获取总记录数 const total = await User.countDocuments(); // 分页结果 const pagination = { page, limit, total, pages: Math.ceil(total / limit) }; res.status(200).json({ success: true, count: users.length, pagination, data: users }); } catch (err) { next(err); } }; // @desc 获取特定用户 // @route GET /api/users/:id // @access Private exports.getUserById = async (req, res, next) => { try { const user = await User.findById(req.params.id); if (!user) { return next(new ErrorResponse(`User not found with id of ${req.params.id}`, 404)); } res.status(200).json({ success: true, data: user }); } catch (err) { next(err); } }; // @desc 更新用户 // @route PUT /api/users/:id // @access Private exports.updateUser = async (req, res, next) => { try { // 确保用户只能更新自己的信息,除非是管理员 if (req.params.id !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse(`Not authorized to update this user`, 401)); } // 不允许通过此路由更新密码 if (req.body.password) { return next(new ErrorResponse('Password cannot be updated with this route', 400)); } const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); if (!user) { return next(new ErrorResponse(`User not found with id of ${req.params.id}`, 404)); } res.status(200).json({ success: true, data: user }); } catch (err) { next(err); } }; // @desc 删除用户 // @route DELETE /api/users/:id // @access Private exports.deleteUser = async (req, res, next) => { try { // 确保用户只能删除自己的账户,除非是管理员 if (req.params.id !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse(`Not authorized to delete this user`, 401)); } const user = await User.findByIdAndDelete(req.params.id); if (!user) { return next(new ErrorResponse(`User not found with id of ${req.params.id}`, 404)); } res.status(200).json({ success: true, data: {} }); } catch (err) { next(err); } };
接下来,实现产品控制器src/controllers/productController.js
:
const Product = require('../models/Product'); const ErrorResponse = require('../utils/ErrorResponse'); // @desc 获取所有产品 // @route GET /api/products // @access Public exports.getAllProducts = async (req, res, next) => { try { // 查询参数 const page = parseInt(req.query.page, 10) || 1; const limit = parseInt(req.query.limit, 10) || 10; const sort = req.query.sort || '-createdAt'; const category = req.query.category; const minPrice = parseFloat(req.query.minPrice); const maxPrice = parseFloat(req.query.maxPrice); // 构建查询 let query = {}; if (category) { query.category = category; } if (minPrice !== undefined && maxPrice !== undefined) { query.price = { $gte: minPrice, $lte: maxPrice }; } else if (minPrice !== undefined) { query.price = { $gte: minPrice }; } else if (maxPrice !== undefined) { query.price = { $lte: maxPrice }; } const startIndex = (page - 1) * limit; // 执行查询 const products = await Product.find(query) .sort(sort) .skip(startIndex) .limit(limit) .populate('createdBy', 'username'); // 获取总记录数 const total = await Product.countDocuments(query); // 分页结果 const pagination = { page, limit, total, pages: Math.ceil(total / limit) }; res.status(200).json({ success: true, count: products.length, pagination, data: products }); } catch (err) { next(err); } }; // @desc 获取特定产品 // @route GET /api/products/:id // @access Public exports.getProductById = async (req, res, next) => { try { const product = await Product.findById(req.params.id).populate('createdBy', 'username'); if (!product) { return next(new ErrorResponse(`Product not found with id of ${req.params.id}`, 404)); } res.status(200).json({ success: true, data: product }); } catch (err) { next(err); } }; // @desc 创建新产品 // @route POST /api/products // @access Private exports.createProduct = async (req, res, next) => { try { // 将创建者ID添加到请求体 req.body.createdBy = req.user.id; const product = await Product.create(req.body); res.status(201).json({ success: true, data: product }); } catch (err) { next(err); } }; // @desc 更新产品 // @route PUT /api/products/:id // @access Private exports.updateProduct = async (req, res, next) => { try { let product = await Product.findById(req.params.id); if (!product) { return next(new ErrorResponse(`Product not found with id of ${req.params.id}`, 404)); } // 确保用户是产品的创建者或者是管理员 if (product.createdBy.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse(`Not authorized to update this product`, 401)); } product = await Product.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); res.status(200).json({ success: true, data: product }); } catch (err) { next(err); } }; // @desc 删除产品 // @route DELETE /api/products/:id // @access Private exports.deleteProduct = async (req, res, next) => { try { const product = await Product.findById(req.params.id); if (!product) { return next(new ErrorResponse(`Product not found with id of ${req.params.id}`, 404)); } // 确保用户是产品的创建者或者是管理员 if (product.createdBy.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse(`Not authorized to delete this product`, 401)); } await product.remove(); res.status(200).json({ success: true, data: {} }); } catch (err) { next(err); } };
错误处理
良好的错误处理是构建专业API的关键部分。我们将创建自定义错误类和全局错误处理中间件。
创建自定义错误类
创建src/utils/ErrorResponse.js
文件:
class ErrorResponse extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; // 捕获堆栈跟踪 Error.captureStackTrace(this, this.constructor); } } module.exports = ErrorResponse;
实现错误处理中间件
创建src/middlewares/errorHandler.js
文件:
const ErrorResponse = require('../utils/ErrorResponse'); const errorHandler = (err, req, res, next) => { // 记录错误到控制台 console.error(err); let error = { ...err }; error.message = err.message; // Mongoose错误处理 // 错误的ObjectId if (err.name === 'CastError') { const message = `Resource not found`; error = new ErrorResponse(message, 404); } // Mongoose重复字段 if (err.code === 11000) { const message = 'Duplicate field value entered'; error = new ErrorResponse(message, 400); } // Mongoose验证错误 if (err.name === 'ValidationError') { const message = Object.values(err.errors).map(val => val.message); error = new ErrorResponse(message, 400); } res.status(error.statusCode || 500).json({ success: false, error: error.message || 'Server Error', ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); }; module.exports = errorHandler;
在应用中使用错误处理中间件
修改src/app.js
文件,添加错误处理中间件:
const errorHandler = require('./middlewares/errorHandler'); // ... 其他代码 ... // 路由 app.get('/', (req, res) => { res.json({ message: 'Welcome to our API!' }); }); app.use('/api/users', userRoutes); app.use('/api/products', productRoutes); // 错误处理中间件 app.use(errorHandler); // ... 其他代码 ...
安全认证
API安全是至关重要的,我们将实现基于JWT(JSON Web Token)的身份验证和授权。
实现认证中间件
创建src/middlewares/authMiddleware.js
文件:
const jwt = require('jsonwebtoken'); const User = require('../models/User'); const ErrorResponse = require('../utils/ErrorResponse'); // 保护路由 exports.protect = async (req, res, next) => { let token; // 从请求头获取令牌 if ( req.headers.authorization && req.headers.authorization.startsWith('Bearer') ) { token = req.headers.authorization.split(' ')[1]; } // 确保令牌存在 if (!token) { return next(new ErrorResponse('Not authorized to access this route', 401)); } try { // 验证令牌 const decoded = jwt.verify(token, process.env.JWT_SECRET); // 将用户添加到请求对象 req.user = await User.findById(decoded.id); next(); } catch (err) { return next(new ErrorResponse('Not authorized to access this route', 401)); } }; // 角色授权 exports.authorize = (...roles) => { return (req, res, next) => { if (!req.user) { return next(new ErrorResponse('Not authorized to access this route', 401)); } if (!roles.includes(req.user.role)) { return next( new ErrorResponse( `User role ${req.user.role} is not authorized to access this route`, 403 ) ); } next(); }; };
实现请求验证中间件
创建src/middlewares/validationMiddleware.js
文件:
const { validationResult } = require('express-validator'); const ErrorResponse = require('../utils/ErrorResponse'); // 处理验证错误 exports.handleValidationErrors = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { const errorMessages = errors.array().map(error => error.msg); return next(new ErrorResponse(errorMessages.join(', '), 400)); } next(); };
实现限流中间件
创建src/middlewares/rateLimitMiddleware.js
文件:
const rateLimit = require('express-rate-limit'); // API限流中间件 exports.apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15分钟 max: 100, // 每个IP在windowMs内最多100个请求 standardHeaders: true, // 返回标准的速率限制头信息 legacyHeaders: false, // 禁用旧的X-RateLimit-*头信息 message: { success: false, error: 'Too many requests, please try again later.' } }); // 登录限流中间件 exports.loginLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1小时 max: 5, // 每个IP在windowMs内最多5次登录尝试 message: { success: false, error: 'Too many login attempts, please try again after an hour.' } });
更新路由以使用中间件
修改src/routes/userRoutes.js
文件,添加中间件:
const express = require('express'); const router = express.Router(); const userController = require('../controllers/userController'); const { protect, authorize } = require('../middlewares/authMiddleware'); const { handleValidationErrors } = require('../middlewares/validationMiddleware'); const { loginLimiter } = require('../middlewares/rateLimitMiddleware'); const { body } = require('express-validator'); // 用户注册验证规则 const registerValidation = [ body('username') .notEmpty().withMessage('Username is required') .isLength({ min: 3, max: 50 }).withMessage('Username must be between 3 and 50 characters'), body('email') .isEmail().withMessage('Please provide a valid email'), body('password') .isLength({ min: 6 }).withMessage('Password must be at least 6 characters long') ]; // 用户登录验证规则 const loginValidation = [ body('email') .isEmail().withMessage('Please provide a valid email'), body('password') .notEmpty().withMessage('Password is required') ]; // 用户注册 router.post('/register', registerValidation, handleValidationErrors, userController.register); // 用户登录 router.post('/login', loginLimiter, loginValidation, handleValidationErrors, userController.login); // 获取所有用户 (需要认证和管理员权限) router.get('/', protect, authorize('admin'), userController.getAllUsers); // 获取特定用户 (需要认证) router.get('/:id', protect, userController.getUserById); // 更新用户信息 (需要认证) router.put('/:id', protect, userController.updateUser); // 删除用户 (需要认证) router.delete('/:id', protect, userController.deleteUser); module.exports = router;
同样,修改src/routes/productRoutes.js
文件:
const express = require('express'); const router = express.Router(); const productController = require('../controllers/productController'); const { protect } = require('../middlewares/authMiddleware'); const { handleValidationErrors } = require('../middlewares/validationMiddleware'); const { body } = require('express-validator'); // 产品创建验证规则 const createProductValidation = [ body('name') .notEmpty().withMessage('Product name is required') .isLength({ max: 100 }).withMessage('Name cannot be more than 100 characters'), body('description') .notEmpty().withMessage('Product description is required') .isLength({ max: 1000 }).withMessage('Description cannot be more than 1000 characters'), body('price') .isFloat({ min: 0 }).withMessage('Price must be a positive number'), body('category') .isIn(['Electronics', 'Clothing', 'Food', 'Books', 'Other']) .withMessage('Invalid category') ]; // 获取所有产品 router.get('/', productController.getAllProducts); // 获取特定产品 router.get('/:id', productController.getProductById); // 创建新产品 (需要认证) router.post('/', protect, createProductValidation, handleValidationErrors, productController.createProduct); // 更新产品信息 (需要认证) router.put('/:id', protect, productController.updateProduct); // 删除产品 (需要认证) router.delete('/:id', protect, productController.deleteProduct); module.exports = router;
在应用中使用限流中间件
修改src/app.js
文件,添加限流中间件:
const { apiLimiter } = require('./middlewares/rateLimitMiddleware'); // ... 其他代码 ... // 中间件 app.use(helmet()); app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(morgan('combined')); app.use(apiLimiter); // 应用API限流 // ... 其他代码 ...
测试API
测试是确保API质量的关键步骤。我们将使用Jest和Supertest进行单元测试和集成测试。
设置测试环境
首先,创建tests/setup.js
文件:
const mongoose = require('mongoose'); const request = require('supertest'); const app = require('../src/app'); const User = require('../src/models/User'); const Product = require('../src/models/Product'); // 连接到测试数据库 beforeAll(async () => { const testMongoUri = process.env.MONGODB_URI_TEST || 'mongodb://localhost:27017/myapi_test'; await mongoose.connect(testMongoUri, { useNewUrlParser: true, useUnifiedTopology: true, }); }); // 在每个测试之前清理数据库 beforeEach(async () => { await User.deleteMany(); await Product.deleteMany(); }); // 测试完成后断开数据库连接 afterAll(async () => { await mongoose.connection.close(); }); // 全局变量 global.app = app; global.request = request; global.User = User; global.Product = Product;
修改package.json
,添加测试脚本:
"scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js", "test": "jest --runInBand" }
编写用户测试
创建tests/user.test.js
文件:
const request = global.request; const User = global.User; describe('User API', () => { describe('POST /api/users/register', () => { it('should register a new user', async () => { const res = await request(global.app) .post('/api/users/register') .send({ username: 'testuser', email: 'test@example.com', password: 'password123' }); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('token'); expect(res.body).toHaveProperty('user'); expect(res.body.user).toHaveProperty('username', 'testuser'); expect(res.body.user).toHaveProperty('email', 'test@example.com'); expect(res.body.user).not.toHaveProperty('password'); }); it('should not register user with invalid email', async () => { const res = await request(global.app) .post('/api/users/register') .send({ username: 'testuser', email: 'invalid-email', password: 'password123' }); expect(res.statusCode).toEqual(400); expect(res.body).toHaveProperty('success', false); }); it('should not register user with duplicate email', async () => { // 创建第一个用户 await User.create({ username: 'testuser1', email: 'test@example.com', password: 'password123' }); // 尝试创建具有相同邮箱的第二个用户 const res = await request(global.app) .post('/api/users/register') .send({ username: 'testuser2', email: 'test@example.com', password: 'password123' }); expect(res.statusCode).toEqual(400); expect(res.body).toHaveProperty('success', false); }); }); describe('POST /api/users/login', () => { beforeEach(async () => { // 创建测试用户 await User.create({ username: 'testuser', email: 'test@example.com', password: 'password123' }); }); it('should login with valid credentials', async () => { const res = await request(global.app) .post('/api/users/login') .send({ email: 'test@example.com', password: 'password123' }); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('token'); expect(res.body).toHaveProperty('user'); expect(res.body.user).toHaveProperty('email', 'test@example.com'); }); it('should not login with invalid email', async () => { const res = await request(global.app) .post('/api/users/login') .send({ email: 'wrong@example.com', password: 'password123' }); expect(res.statusCode).toEqual(401); expect(res.body).toHaveProperty('success', false); }); it('should not login with invalid password', async () => { const res = await request(global.app) .post('/api/users/login') .send({ email: 'test@example.com', password: 'wrongpassword' }); expect(res.statusCode).toEqual(401); expect(res.body).toHaveProperty('success', false); }); }); describe('GET /api/users', () => { let token; beforeEach(async () => { // 创建管理员用户 const adminUser = await User.create({ username: 'admin', email: 'admin@example.com', password: 'password123', role: 'admin' }); // 获取令牌 const res = await request(global.app) .post('/api/users/login') .send({ email: 'admin@example.com', password: 'password123' }); token = res.body.token; }); it('should get all users with admin role', async () => { const res = await request(global.app) .get('/api/users') .set('Authorization', `Bearer ${token}`); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('data'); expect(Array.isArray(res.body.data)).toBeTruthy(); }); it('should not get users without authentication', async () => { const res = await request(global.app) .get('/api/users'); expect(res.statusCode).toEqual(401); expect(res.body).toHaveProperty('success', false); }); }); });
编写产品测试
创建tests/product.test.js
文件:
const request = global.request; const User = global.User; const Product = global.Product; describe('Product API', () => { let token; beforeEach(async () => { // 创建测试用户并获取令牌 const user = await User.create({ username: 'testuser', email: 'test@example.com', password: 'password123' }); const res = await request(global.app) .post('/api/users/login') .send({ email: 'test@example.com', password: 'password123' }); token = res.body.token; }); describe('GET /api/products', () => { it('should get all products', async () => { // 创建测试产品 await Product.create({ name: 'Test Product', description: 'This is a test product', price: 19.99, category: 'Electronics', createdBy: (await User.findOne())._id }); const res = await request(global.app) .get('/api/products'); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('data'); expect(Array.isArray(res.body.data)).toBeTruthy(); expect(res.body.data.length).toBe(1); expect(res.body.data[0]).toHaveProperty('name', 'Test Product'); }); it('should filter products by category', async () => { // 创建不同类别的产品 await Product.create({ name: 'Electronics Product', description: 'This is an electronics product', price: 29.99, category: 'Electronics', createdBy: (await User.findOne())._id }); await Product.create({ name: 'Clothing Product', description: 'This is a clothing product', price: 39.99, category: 'Clothing', createdBy: (await User.findOne())._id }); const res = await request(global.app) .get('/api/products?category=Electronics'); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('data'); expect(Array.isArray(res.body.data)).toBeTruthy(); expect(res.body.data.length).toBe(1); expect(res.body.data[0]).toHaveProperty('category', 'Electronics'); }); }); describe('POST /api/products', () => { it('should create a new product with authentication', async () => { const productData = { name: 'New Product', description: 'This is a new product', price: 49.99, category: 'Books' }; const res = await request(global.app) .post('/api/products') .set('Authorization', `Bearer ${token}`) .send(productData); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('data'); expect(res.body.data).toHaveProperty('name', 'New Product'); expect(res.body.data).toHaveProperty('createdBy'); }); it('should not create a product without authentication', async () => { const productData = { name: 'New Product', description: 'This is a new product', price: 49.99, category: 'Books' }; const res = await request(global.app) .post('/api/products') .send(productData); expect(res.statusCode).toEqual(401); expect(res.body).toHaveProperty('success', false); }); it('should not create a product with invalid data', async () => { const productData = { name: '', // 无效:名称为空 description: 'This is a new product', price: -10, // 无效:价格为负数 category: 'Invalid Category' // 无效:类别不在允许的列表中 }; const res = await request(global.app) .post('/api/products') .set('Authorization', `Bearer ${token}`) .send(productData); expect(res.statusCode).toEqual(400); expect(res.body).toHaveProperty('success', false); }); }); describe('PUT /api/products/:id', () => { let productId; beforeEach(async () => { // 创建测试产品 const product = await Product.create({ name: 'Original Product', description: 'This is the original product', price: 19.99, category: 'Electronics', createdBy: (await User.findOne())._id }); productId = product._id.toString(); }); it('should update a product with authentication', async () => { const updateData = { name: 'Updated Product', price: 29.99 }; const res = await request(global.app) .put(`/api/products/${productId}`) .set('Authorization', `Bearer ${token}`) .send(updateData); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('success', true); expect(res.body).toHaveProperty('data'); expect(res.body.data).toHaveProperty('name', 'Updated Product'); expect(res.body.data).toHaveProperty('price', 29.99); expect(res.body.data).toHaveProperty('description', 'This is the original product'); // 未更改的字段应保持不变 }); it('should not update a product without authentication', async () => { const updateData = { name: 'Updated Product' }; const res = await request(global.app) .put(`/api/products/${productId}`) .send(updateData); expect(res.statusCode).toEqual(401); expect(res.body).toHaveProperty('success', false); }); }); describe('DELETE /api/products/:id', () => { let productId; beforeEach(async () => { // 创建测试产品 const product = await Product.create({ name: 'Product to Delete', description: 'This product will be deleted', price: 19.99, category: 'Electronics', createdBy: (await User.findOne())._id }); productId = product._id.toString(); }); it('should delete a product with authentication', async () => { const res = await request(global.app) .delete(`/api/products/${productId}`) .set('Authorization', `Bearer ${token}`); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('success', true); // 验证产品已被删除 const getProductRes = await request(global.app) .get(`/api/products/${productId}`); expect(getProductRes.statusCode).toEqual(404); }); it('should not delete a product without authentication', async () => { const res = await request(global.app) .delete(`/api/products/${productId}`); expect(res.statusCode).toEqual(401); expect(res.body).toHaveProperty('success', false); }); }); });
部署与优化
使用PM2部署应用
PM2是一个强大的Node.js进程管理器,可以帮助我们保持应用在线并在崩溃时自动重启。
首先,安装PM2:
npm install -g pm2
创建ecosystem.config.js
文件:
module.exports = { apps: [{ name: 'myapi', script: 'src/app.js', instances: 'max', exec_mode: 'cluster', autorestart: true, watch: false, max_memory_restart: '1G', env: { NODE_ENV: 'development', PORT: 3000 }, env_production: { NODE_ENV: 'production', PORT: 80, MONGODB_URI: 'your_production_mongodb_uri', JWT_SECRET: 'your_production_jwt_secret' } }] };
使用PM2启动应用:
# 开发环境 pm2 start ecosystem.config.js --env development # 生产环境 pm2 start ecosystem.config.js --env production
性能优化
1. 实现缓存
我们可以使用Redis来缓存频繁访问的数据,减少数据库负载。
安装Redis客户端:
npm install redis
创建src/services/cacheService.js
文件:
const redis = require('redis'); const { promisify } = require('util'); // 创建Redis客户端 const redisClient = redis.createClient({ host: process.env.REDIS_HOST || '127.0.0.1', port: process.env.REDIS_PORT || 6379, password: process.env.REDIS_PASSWORD || null }); // 将Redis方法转换为Promise const getAsync = promisify(redisClient.get).bind(redisClient); const setAsync = promisify(redisClient.set).bind(redisClient); const delAsync = promisify(redisClient.del).bind(redisClient); // 缓存服务 const cacheService = { // 获取缓存数据 get: async (key) => { try { const data = await getAsync(key); return data ? JSON.parse(data) : null; } catch (err) { console.error('Redis GET error:', err); return null; } }, // 设置缓存数据 set: async (key, data, expireInSeconds = 3600) => { try { await setAsync(key, JSON.stringify(data), 'EX', expireInSeconds); return true; } catch (err) { console.error('Redis SET error:', err); return false; } }, // 删除缓存数据 del: async (key) => { try { await delAsync(key); return true; } catch (err) { console.error('Redis DEL error:', err); return false; } } }; module.exports = cacheService;
修改产品控制器以使用缓存:
const cacheService = require('../services/cacheService'); // ... 其他代码 ... // @desc 获取所有产品 // @route GET /api/products // @access Public exports.getAllProducts = async (req, res, next) => { try { // 创建缓存键 const cacheKey = `products:${JSON.stringify(req.query)}`; // 尝试从缓存获取数据 const cachedProducts = await cacheService.get(cacheKey); if (cachedProducts) { return res.status(200).json({ success: true, data: cachedProducts, cached: true }); } // 查询参数 const page = parseInt(req.query.page, 10) || 1; const limit = parseInt(req.query.limit, 10) || 10; const sort = req.query.sort || '-createdAt'; const category = req.query.category; const minPrice = parseFloat(req.query.minPrice); const maxPrice = parseFloat(req.query.maxPrice); // 构建查询 let query = {}; if (category) { query.category = category; } if (minPrice !== undefined && maxPrice !== undefined) { query.price = { $gte: minPrice, $lte: maxPrice }; } else if (minPrice !== undefined) { query.price = { $gte: minPrice }; } else if (maxPrice !== undefined) { query.price = { $lte: maxPrice }; } const startIndex = (page - 1) * limit; // 执行查询 const products = await Product.find(query) .sort(sort) .skip(startIndex) .limit(limit) .populate('createdBy', 'username'); // 获取总记录数 const total = await Product.countDocuments(query); // 分页结果 const pagination = { page, limit, total, pages: Math.ceil(total / limit) }; const result = { success: true, count: products.length, pagination, data: products }; // 缓存结果(1小时) await cacheService.set(cacheKey, result, 3600); res.status(200).json(result); } catch (err) { next(err); } }; // ... 其他代码 ... // @desc 更新产品 // @route PUT /api/products/:id // @access Private exports.updateProduct = async (req, res, next) => { try { let product = await Product.findById(req.params.id); if (!product) { return next(new ErrorResponse(`Product not found with id of ${req.params.id}`, 404)); } // 确保用户是产品的创建者或者是管理员 if (product.createdBy.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse(`Not authorized to update this product`, 401)); } product = await Product.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }); // 清除所有产品缓存 await cacheService.del('products:*'); res.status(200).json({ success: true, data: product }); } catch (err) { next(err); } }; // @desc 删除产品 // @route DELETE /api/products/:id // @access Private exports.deleteProduct = async (req, res, next) => { try { const product = await Product.findById(req.params.id); if (!product) { return next(new ErrorResponse(`Product not found with id of ${req.params.id}`, 404)); } // 确保用户是产品的创建者或者是管理员 if (product.createdBy.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse(`Not authorized to delete this product`, 401)); } await product.remove(); // 清除所有产品缓存 await cacheService.del('products:*'); res.status(200).json({ success: true, data: {} }); } catch (err) { next(err); } };
2. 实现压缩
使用compression中间件来压缩响应数据,减少传输大小。
安装compression:
npm install compression
修改src/app.js
文件,添加压缩中间件:
const compression = require('compression'); // ... 其他代码 ... // 中间件 app.use(helmet()); app.use(cors()); app.use(compression()); // 添加压缩中间件 app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(morgan('combined')); app.use(apiLimiter); // ... 其他代码 ...
3. 实现请求日志记录
使用morgan中间件我们已经实现了基本的请求日志记录,但对于生产环境,我们可能需要更详细的日志记录。
创建src/middlewares/requestLogger.js
文件:
const fs = require('fs'); const path = require('path'); // 确保日志目录存在 const logDir = 'logs'; if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir); } // 创建可写流 const accessLogStream = fs.createWriteStream( path.join(__dirname, '..', 'logs', 'access.log'), { flags: 'a' } ); // 自定义日志格式 const requestLogger = (req, res, next) => { const { method, originalUrl, ip, headers } = req; const userAgent = headers['user-agent']; const startTime = Date.now(); // 监听响应完成事件 res.on('finish', () => { const { statusCode } = res; const responseTime = Date.now() - startTime; const logData = `[${new Date().toISOString()}] ${method} ${originalUrl} ${statusCode} ${responseTime}ms - ${ip} - ${userAgent}n`; // 写入日志文件 accessLogStream.write(logData); // 在开发环境中也输出到控制台 if (process.env.NODE_ENV === 'development') { console.log(logData.trim()); } }); next(); }; module.exports = requestLogger;
修改src/app.js
文件,使用自定义日志中间件:
const requestLogger = require('./middlewares/requestLogger'); // ... 其他代码 ... // 中间件 app.use(helmet()); app.use(cors()); app.use(compression()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(requestLogger); // 使用自定义日志中间件 app.use(apiLimiter); // ... 其他代码 ...
总结与最佳实践
在本文中,我们手把手构建了一个专业的Node.js RESTful API应用,涵盖了从路由设计到数据库集成,从错误处理到安全认证的各个方面。下面是一些关键的最佳实践总结:
代码组织
- 分层架构:将代码分为控制器、模型、路由、中间件和服务层,使代码结构清晰,易于维护。
- 模块化:每个功能模块独立封装,减少耦合度。
- 配置分离:使用环境变量和配置文件,使应用在不同环境中灵活运行。
安全性
- 身份验证:使用JWT进行用户身份验证,确保只有授权用户才能访问受保护的资源。
- 输入验证:使用express-validator验证所有用户输入,防止恶意数据注入。
- 错误处理:实现全面的错误处理机制,避免泄露敏感信息。
- 限流:实施API限流,防止DDoS攻击和滥用。
- 安全头:使用helmet中间件设置安全相关的HTTP头。
性能优化
- 缓存:使用Redis缓存频繁访问的数据,减少数据库负载。
- 压缩:使用compression中间件压缩响应数据,减少传输大小。
- 集群模式:使用PM2的集群模式充分利用多核CPU。
- 数据库优化:合理使用索引和查询优化,提高数据库性能。
测试
- 单元测试:对每个功能模块进行单元测试,确保代码质量。
- 集成测试:测试API端点,验证各个组件之间的交互。
- 测试环境:使用独立的测试数据库,避免影响生产数据。
部署与监控
- 进程管理:使用PM2管理应用进程,实现自动重启和负载均衡。
- 日志记录:实现详细的请求日志记录,便于问题排查。
- 性能监控:实施应用性能监控,及时发现和解决性能问题。
通过遵循这些最佳实践,你可以构建出高质量、高性能、安全可靠的RESTful API应用。希望本文能对你的Node.js开发之旅有所帮助!