Node.js+MongoDB搭建RESTful?API實(shí)戰(zhàn)示例
第一章:引言與概述
1.1 為什么選擇 Node.js 和 MongoDB
在當(dāng)今的 Web 開發(fā)領(lǐng)域,Node.js 和 MongoDB 已經(jīng)成為構(gòu)建現(xiàn)代應(yīng)用程序的首選技術(shù)組合。Node.js 是一個(gè)基于 Chrome V8 引擎的 JavaScript 運(yùn)行時(shí)環(huán)境,它采用事件驅(qū)動(dòng)、非阻塞 I/O 模型,使其輕量且高效。MongoDB 是一個(gè)基于分布式文件存儲(chǔ)的 NoSQL 數(shù)據(jù)庫,由 C++ 語言編寫,旨在為 Web 應(yīng)用提供可擴(kuò)展的高性能數(shù)據(jù)存儲(chǔ)解決方案。
這個(gè)技術(shù)棧的優(yōu)勢(shì)體現(xiàn)在多個(gè)方面。首先,JavaScript 全棧開發(fā)使得前后端開發(fā)人員能夠使用同一種語言,降低了學(xué)習(xí)成本和上下文切換的開銷。其次,JSON 數(shù)據(jù)格式在兩者之間的無縫流轉(zhuǎn)——Node.js 使用 JSON 作為數(shù)據(jù)交換格式,MongoDB 使用 BSON(Binary JSON)存儲(chǔ)數(shù)據(jù)——這種一致性大大簡(jiǎn)化了開發(fā)流程。第三,非阻塞異步特性讓 Node.js 特別適合處理高并發(fā)的 I/O 密集型應(yīng)用,而 MongoDB 的橫向擴(kuò)展能力能夠很好地支持這種應(yīng)用場(chǎng)景。
1.2 RESTful API 設(shè)計(jì)原則
REST(Representational State Transfer)是一種軟件架構(gòu)風(fēng)格,而不是標(biāo)準(zhǔn)或協(xié)議。它由 Roy Fielding 在 2000 年的博士論文中提出,定義了一組約束和原則,用于創(chuàng)建可擴(kuò)展、可靠和高效的 Web 服務(wù)。
RESTful API 的核心原則包括:
- 無狀態(tài)性(Stateless):每個(gè)請(qǐng)求都包含處理該請(qǐng)求所需的所有信息,服務(wù)器不存儲(chǔ)客戶端的狀態(tài)信息
- 統(tǒng)一接口(Uniform Interface):使用標(biāo)準(zhǔn)的 HTTP 方法(GET、POST、PUT、DELETE 等)和狀態(tài)碼
- 資源導(dǎo)向(Resource-Based):所有內(nèi)容都被抽象為資源,每個(gè)資源有唯一的標(biāo)識(shí)符(URI)
- 表述性(Representation):客戶端與服務(wù)器交換的是資源的表述,而不是資源本身
- 可緩存性(Cacheable):響應(yīng)應(yīng)該被標(biāo)記為可緩存或不可緩存,以提高性能
- 分層系統(tǒng)(Layered System):客戶端不需要知道是否直接連接到最后端的服務(wù)器
1.3 教程目標(biāo)與內(nèi)容概述
本教程將帶領(lǐng)您從零開始構(gòu)建一個(gè)完整的博客平臺(tái) API,實(shí)現(xiàn)文章的增刪改查、用戶認(rèn)證、文件上傳、分頁查詢等核心功能。通過這個(gè)實(shí)踐項(xiàng)目,您將掌握:
- Node.js 和 Express 框架的核心概念和用法
- MongoDB 數(shù)據(jù)庫的設(shè)計(jì)和操作
- Mongoose ODM 庫的高級(jí)用法
- RESTful API 的設(shè)計(jì)原則和最佳實(shí)踐
- JWT 身份認(rèn)證和授權(quán)機(jī)制
- 錯(cuò)誤處理、日志記錄和性能優(yōu)化
- API 測(cè)試和文檔編寫
- 項(xiàng)目部署和運(yùn)維考慮
第二章:環(huán)境搭建與項(xiàng)目初始化
2.1 開發(fā)環(huán)境要求
在開始之前,請(qǐng)確保您的系統(tǒng)滿足以下要求:
操作系統(tǒng)要求:
- Windows 7 或更高版本
- macOS 10.10 或更高版本
- Ubuntu 16.04 或更高版本(推薦 LTS 版本)
軟件依賴: - Node.js 版本 14.0.0 或更高版本(推薦 LTS 版本)
- MongoDB 版本 4.0 或更高版本
- npm 版本 6.0.0 或更高版本
- Git 版本控制工具
開發(fā)工具推薦: - 代碼編輯器:Visual Studio Code(推薦)、WebStorm、Sublime Text
- API 測(cè)試工具:Postman、Insomnia、Thunder Client(VSCode 擴(kuò)展)
- 數(shù)據(jù)庫管理工具:MongoDB Compass、Studio 3T
- 命令行工具:Windows Terminal、iTerm2(macOS)、Git Bash
2.2 安裝和配置 Node.js
Windows 系統(tǒng)安裝:
訪問 Node.js 官網(wǎng)(https://nodejs.org/)
下載 LTS 版本的安裝程序
運(yùn)行安裝程序,按照向?qū)瓿砂惭b
安裝完成后,打開命令提示符或 PowerShell,驗(yàn)證安裝:
node --version npm --version
macOS 系統(tǒng)安裝:
推薦使用 Homebrew 包管理器:
# 安裝 Homebrew(如果尚未安裝) /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # 使用 Homebrew 安裝 Node.js brew install node
Linux(Ubuntu)系統(tǒng)安裝:
# 使用 NodeSource 安裝腳本 curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt-get install -y nodejs
2.3 安裝和配置 MongoDB
本地安裝 MongoDB:
- 訪問 MongoDB 官網(wǎng)(https://www.mongodb.com/try/download/community)
- 選擇適合您操作系統(tǒng)的版本下載
- 按照官方文檔完成安裝和配置
使用 MongoDB Atlas(云數(shù)據(jù)庫): - 訪問 https://www.mongodb.com/atlas/database
- 注冊(cè)賬號(hào)并創(chuàng)建免費(fèi)集群
- 配置網(wǎng)絡(luò)訪問和白名單
- 獲取連接字符串
使用 Docker 運(yùn)行 MongoDB:
# 拉取 MongoDB 鏡像 docker pull mongo:latest # 運(yùn)行 MongoDB 容器 docker run --name mongodb -d -p 27017:27017 -v ~/mongo/data:/data/db mongo:latest # 帶認(rèn)證的啟動(dòng)方式 docker run --name mongodb -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=admin -e MONGO_INITDB_ROOT_PASSWORD=password -v ~/mongo/data:/data/db mongo:latest
2.4 項(xiàng)目初始化與結(jié)構(gòu)設(shè)計(jì)
創(chuàng)建項(xiàng)目目錄并初始化:
# 創(chuàng)建項(xiàng)目目錄 mkdir blog-api cd blog-api # 初始化 npm 項(xiàng)目 npm init -y # 創(chuàng)建項(xiàng)目目錄結(jié)構(gòu) mkdir -p src/ mkdir -p src/controllers mkdir -p src/models mkdir -p src/routes mkdir -p src/middleware mkdir -p src/utils mkdir -p src/config mkdir -p tests mkdir -p docs # 創(chuàng)建基礎(chǔ)文件 touch src/app.js touch src/server.js touch .env touch .gitignore touch README.md
安裝項(xiàng)目依賴:
# 生產(chǎn)依賴 npm install express mongoose dotenv bcryptjs jsonwebtoken cors helmet morgan multer express-rate-limit express-validator # 開發(fā)依賴 npm install --save-dev nodemon eslint prettier eslint-config-prettier eslint-plugin-prettier jest supertest mongodb-memory-server
配置 package.json 腳本:
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src/**/*.js",
"lint:fix": "eslint src/**/*.js --fix",
"format": "prettier --write src/**/*.js"
}
}
配置 .gitignore 文件:
# 依賴目錄 node_modules/ # 環(huán)境變量文件 .env .env.local .env.development.local .env.test.local .env.production.local # 日志文件 logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # 運(yùn)行時(shí)數(shù)據(jù) pids/ *.pid *.seed *.pid.lock # 覆蓋率目錄 coverage/ .nyc_output # 系統(tǒng)文件 .DS_Store Thumbs.db # IDE文件 .vscode/ .idea/ *.swp *.swo # 操作系統(tǒng)文件 *.DS_Store Thumbs.db
第三章:Express 服務(wù)器基礎(chǔ)搭建
3.1 創(chuàng)建基本的 Express 服務(wù)器
首先創(chuàng)建主要的應(yīng)用文件 src/app.js:
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
// 導(dǎo)入路由
const postRoutes = require('./routes/posts');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
// 導(dǎo)入中間件
const errorHandler = require('./middleware/errorHandler');
const notFound = require('./middleware/notFound');
const app = express();
// 安全中間件
app.use(helmet());
// CORS 配置
app.use(cors({
origin: process.env.NODE_ENV === 'production'
? process.env.FRONTEND_URL
: 'http://localhost:3000',
credentials: true
}));
// 速率限制
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分鐘
max: 100, // 限制每個(gè)IP每15分鐘最多100個(gè)請(qǐng)求
message: {
error: '請(qǐng)求過于頻繁,請(qǐng)稍后再試。',
status: 429
}
});
app.use(limiter);
// 日志記錄
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// 解析請(qǐng)求體
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// 靜態(tài)文件服務(wù)
app.use('/uploads', express.static('uploads'));
// 路由配置
app.use('/api/posts', postRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// 健康檢查端點(diǎn)
app.get('/api/health', (req, res) => {
res.status(200).json({
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development'
});
});
// 404處理
app.use(notFound);
// 錯(cuò)誤處理
app.use(errorHandler);
module.exports = app;
創(chuàng)建服務(wù)器啟動(dòng)文件 src/server.js:
const app = require('./app');
const connectDB = require('./config/database');
// 環(huán)境變量配置
const PORT = process.env.PORT || 5000;
const NODE_ENV = process.env.NODE_ENV || 'development';
// 優(yōu)雅關(guān)閉處理
const gracefulShutdown = (signal) => {
console.log(`收到 ${signal},開始優(yōu)雅關(guān)閉服務(wù)器...`);
process.exit(0);
};
// 啟動(dòng)服務(wù)器
const startServer = async () => {
try {
// 連接數(shù)據(jù)庫
await connectDB();
// 啟動(dòng)Express服務(wù)器
const server = app.listen(PORT, () => {
console.log(`
?? 服務(wù)器已啟動(dòng)!
?? 環(huán)境: ${NODE_ENV}
?? 端口: ${PORT}
?? 時(shí)間: ${new Date().toLocaleString()}
?? 健康檢查: http://localhost:${PORT}/api/health
`);
});
// 優(yōu)雅關(guān)閉處理
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// 處理未捕獲的異常
process.on('uncaughtException', (error) => {
console.error('未捕獲的異常:', error);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason, promise) => {
console.error('未處理的Promise拒絕:', reason);
gracefulShutdown('unhandledRejection');
});
} catch (error) {
console.error('服務(wù)器啟動(dòng)失敗:', error);
process.exit(1);
}
};
// 啟動(dòng)應(yīng)用
startServer();
3.2 環(huán)境變量配置
創(chuàng)建 .env 文件:
# 服務(wù)器配置 NODE_ENV=development PORT=5000 FRONTEND_URL=http://localhost:3000 # 數(shù)據(jù)庫配置 MONGODB_URI=mongodb://localhost:27017/blog_api MONGODB_URI_TEST=mongodb://localhost:27017/blog_api_test # JWT配置 JWT_SECRET=your_super_secret_jwt_key_here_change_in_production JWT_EXPIRE=7d JWT_COOKIE_EXPIRE=7 # 文件上傳配置 MAX_FILE_UPLOAD=5 FILE_UPLOAD_PATH=./uploads # 速率限制配置 RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX=100 # 郵件配置(可選) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_EMAIL=your_email@gmail.com SMTP_PASSWORD=your_app_password FROM_EMAIL=noreply@blogapi.com FROM_NAME=Blog API
創(chuàng)建環(huán)境配置工具 src/config/env.js:
const Joi = require('joi');
// 環(huán)境變量驗(yàn)證規(guī)則
const envVarsSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().default(5000),
MONGODB_URI: Joi.string().required().description('MongoDB連接字符串'),
JWT_SECRET: Joi.string().required().description('JWT密鑰'),
JWT_EXPIRE: Joi.string().default('7d').description('JWT過期時(shí)間'),
}).unknown().required();
// 驗(yàn)證環(huán)境變量
const { value: envVars, error } = envVarsSchema.validate(process.env);
if (error) {
throw new Error(`環(huán)境變量配置錯(cuò)誤: ${error.message}`);
}
// 導(dǎo)出配置對(duì)象
module.exports = {
env: envVars.NODE_ENV,
port: envVars.PORT,
mongoose: {
url: envVars.MONGODB_URI + (envVars.NODE_ENV === 'test' ? '_test' : ''),
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
},
},
jwt: {
secret: envVars.JWT_SECRET,
expire: envVars.JWT_EXPIRE,
},
};
第四章:MongoDB 數(shù)據(jù)庫連接與配置
4.1 數(shù)據(jù)庫連接配置
創(chuàng)建數(shù)據(jù)庫連接文件 src/config/database.js:
const mongoose = require('mongoose');
const config = require('./env');
const connectDB = async () => {
try {
const conn = await mongoose.connect(config.mongoose.url, config.mongoose.options);
console.log(`
? MongoDB連接成功!
?? 主機(jī): ${conn.connection.host}
?? 數(shù)據(jù)庫: ${conn.connection.name}
?? 狀態(tài): ${conn.connection.readyState === 1 ? '已連接' : '斷開'}
?? 時(shí)間: ${new Date().toLocaleString()}
`);
// 監(jiān)聽連接事件
mongoose.connection.on('connected', () => {
console.log('Mongoose已連接到數(shù)據(jù)庫');
});
mongoose.connection.on('error', (err) => {
console.error('Mongoose連接錯(cuò)誤:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('Mongoose已斷開數(shù)據(jù)庫連接');
});
// 進(jìn)程關(guān)閉時(shí)關(guān)閉數(shù)據(jù)庫連接
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('Mongoose連接已通過應(yīng)用終止關(guān)閉');
process.exit(0);
});
} catch (error) {
console.error('? MongoDB連接失敗:', error.message);
process.exit(1);
}
};
module.exports = connectDB;
4.2 數(shù)據(jù)庫連接優(yōu)化
創(chuàng)建高級(jí)數(shù)據(jù)庫配置 src/config/databaseAdvanced.js:
const mongoose = require('mongoose');
const config = require('./env');
class DatabaseManager {
constructor() {
this.isConnected = false;
this.connection = null;
this.retryAttempts = 0;
this.maxRetryAttempts = 5;
this.retryDelay = 5000; // 5秒
}
async connect() {
try {
// 連接選項(xiàng)配置
const options = {
...config.mongoose.options,
poolSize: 10, // 連接池大小
bufferMaxEntries: 0, // 禁用緩沖
connectTimeoutMS: 10000, // 10秒連接超時(shí)
socketTimeoutMS: 45000, // 45秒套接字超時(shí)
family: 4, // 使用IPv4
useCreateIndex: true,
useFindAndModify: false
};
this.connection = await mongoose.connect(config.mongoose.url, options);
this.isConnected = true;
this.retryAttempts = 0;
this.setupEventListeners();
return this.connection;
} catch (error) {
console.error('數(shù)據(jù)庫連接失敗:', error.message);
if (this.retryAttempts < this.maxRetryAttempts) {
this.retryAttempts++;
console.log(`嘗試重新連接 (${this.retryAttempts}/${this.maxRetryAttempts})...`);
await new Promise(resolve => setTimeout(resolve, this.retryDelay));
return this.connect();
} else {
throw new Error(`數(shù)據(jù)庫連接失敗,已達(dá)到最大重試次數(shù): ${this.maxRetryAttempts}`);
}
}
}
setupEventListeners() {
mongoose.connection.on('connected', () => {
console.log('Mongoose已連接到數(shù)據(jù)庫');
this.isConnected = true;
});
mongoose.connection.on('error', (error) => {
console.error('Mongoose連接錯(cuò)誤:', error);
this.isConnected = false;
});
mongoose.connection.on('disconnected', () => {
console.log('Mongoose已斷開數(shù)據(jù)庫連接');
this.isConnected = false;
});
mongoose.connection.on('reconnected', () => {
console.log('Mongoose已重新連接到數(shù)據(jù)庫');
this.isConnected = true;
});
}
async disconnect() {
if (this.isConnected) {
await mongoose.disconnect();
this.isConnected = false;
console.log('Mongoose連接已關(guān)閉');
}
}
getConnectionStatus() {
return {
isConnected: this.isConnected,
readyState: mongoose.connection.readyState,
host: mongoose.connection.host,
name: mongoose.connection.name,
retryAttempts: this.retryAttempts
};
}
}
// 創(chuàng)建單例實(shí)例
const databaseManager = new DatabaseManager();
module.exports = databaseManager;
4.3 數(shù)據(jù)庫健康檢查中間件
創(chuàng)建數(shù)據(jù)庫健康檢查中間件 src/middleware/dbHealthCheck.js:
const mongoose = require('mongoose');
const dbHealthCheck = async (req, res, next) => {
try {
const dbState = mongoose.connection.readyState;
// readyState 值說明:
// 0 = disconnected
// 1 = connected
// 2 = connecting
// 3 = disconnecting
if (dbState !== 1) {
return res.status(503).json({
success: false,
error: '數(shù)據(jù)庫連接異常',
details: {
status: dbState,
statusText: ['斷開連接', '已連接', '連接中', '斷開中'][dbState],
timestamp: new Date().toISOString()
}
});
}
// 執(zhí)行簡(jiǎn)單的查詢來驗(yàn)證數(shù)據(jù)庫響應(yīng)
await mongoose.connection.db.admin().ping();
next();
} catch (error) {
res.status(503).json({
success: false,
error: '數(shù)據(jù)庫健康檢查失敗',
details: {
message: error.message,
timestamp: new Date().toISOString()
}
});
}
};
module.exports = dbHealthCheck;
第五章:數(shù)據(jù)模型設(shè)計(jì)與 Mongoose 進(jìn)階
5.1 用戶模型設(shè)計(jì)
創(chuàng)建用戶模型 src/models/User.js:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const config = require('../config/env');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, '用戶名不能為空'],
unique: true,
trim: true,
minlength: [3, '用戶名至少3個(gè)字符'],
maxlength: [20, '用戶名不能超過20個(gè)字符'],
match: [/^[a-zA-Z0-9_]+$/, '用戶名只能包含字母、數(shù)字和下劃線']
},
email: {
type: String,
required: [true, '郵箱不能為空'],
unique: true,
lowercase: true,
trim: true,
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, '請(qǐng)輸入有效的郵箱地址']
},
password: {
type: String,
required: [true, '密碼不能為空'],
minlength: [6, '密碼至少6個(gè)字符'],
select: false // 默認(rèn)不返回密碼字段
},
role: {
type: String,
enum: ['user', 'author', 'admin'],
default: 'user'
},
avatar: {
type: String,
default: 'default-avatar.png'
},
bio: {
type: String,
maxlength: [500, '個(gè)人簡(jiǎn)介不能超過500個(gè)字符'],
default: ''
},
website: {
type: String,
match: [/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, '請(qǐng)輸入有效的網(wǎng)址']
},
isVerified: {
type: Boolean,
default: false
},
isActive: {
type: Boolean,
default: true
},
lastLogin: {
type: Date,
default: Date.now
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// 虛擬字段:用戶的文章
userSchema.virtual('posts', {
ref: 'Post',
localField: '_id',
foreignField: 'author',
justOne: false
});
// 索引優(yōu)化
userSchema.index({ email: 1 });
userSchema.index({ username: 1 });
userSchema.index({ createdAt: 1 });
// 密碼加密中間件
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// 比較密碼方法
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// 生成JWT令牌方法
userSchema.methods.generateAuthToken = function() {
return jwt.sign(
{
userId: this._id,
role: this.role
},
config.jwt.secret,
{
expiresIn: config.jwt.expire,
issuer: 'blog-api',
audience: 'blog-api-users'
}
);
};
// 獲取用戶基本信息(不包含敏感信息)
userSchema.methods.getPublicProfile = function() {
const userObject = this.toObject();
delete userObject.password;
delete userObject.__v;
return userObject;
};
// 靜態(tài)方法:通過郵箱查找用戶
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email: email.toLowerCase() });
};
// 靜態(tài)方法:通過用戶名查找用戶
userSchema.statics.findByUsername = function(username) {
return this.findOne({ username: new RegExp('^' + username + '$', 'i') });
};
// 查詢中間件:自動(dòng)過濾已刪除的用戶
userSchema.pre(/^find/, function(next) {
this.find({ isActive: { $ne: false } });
next();
});
module.exports = mongoose.model('User', userSchema);
5.2 文章模型設(shè)計(jì)
創(chuàng)建文章模型 src/models/Post.js:
const mongoose = require('mongoose');
const slugify = require('slugify');
const postSchema = new mongoose.Schema({
title: {
type: String,
required: [true, '文章標(biāo)題不能為空'],
trim: true,
minlength: [5, '文章標(biāo)題至少5個(gè)字符'],
maxlength: [200, '文章標(biāo)題不能超過200個(gè)字符']
},
slug: {
type: String,
unique: true,
lowercase: true
},
content: {
type: String,
required: [true, '文章內(nèi)容不能為空'],
minlength: [50, '文章內(nèi)容至少50個(gè)字符'],
maxlength: [20000, '文章內(nèi)容不能超過20000個(gè)字符']
},
excerpt: {
type: String,
maxlength: [300, '文章摘要不能超過300個(gè)字符']
},
coverImage: {
type: String,
default: 'default-cover.jpg'
},
author: {
type: mongoose.Schema.ObjectId,
ref: 'User',
required: true
},
tags: [{
type: String,
trim: true,
lowercase: true
}],
category: {
type: String,
required: [true, '文章分類不能為空'],
trim: true,
enum: [
'technology', 'programming', 'design', 'business',
'lifestyle', 'travel', 'food', 'health', 'education'
]
},
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft'
},
isFeatured: {
type: Boolean,
default: false
},
viewCount: {
type: Number,
default: 0
},
likeCount: {
type: Number,
default: 0
},
commentCount: {
type: Number,
default: 0
},
readingTime: {
type: Number, // 閱讀時(shí)間(分鐘)
default: 0
},
meta: {
title: String,
description: String,
keywords: [String]
},
publishedAt: Date
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// 虛擬字段:評(píng)論
postSchema.virtual('comments', {
ref: 'Comment',
localField: '_id',
foreignField: 'post',
justOne: false
});
// 虛擬字段:點(diǎn)贊用戶
postSchema.virtual('likes', {
ref: 'Like',
localField: '_id',
foreignField: 'post',
justOne: false
});
// 索引優(yōu)化
postSchema.index({ title: 'text', content: 'text' });
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ category: 1, status: 1 });
postSchema.index({ tags: 1 });
postSchema.index({ status: 1, publishedAt: -1 });
postSchema.index({ slug: 1 });
// 生成slug中間件
postSchema.pre('save', function(next) {
if (this.isModified('title') && this.title) {
this.slug = slugify(this.title, {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g
});
}
next();
});
// 計(jì)算閱讀時(shí)間和摘要中間件
postSchema.pre('save', function(next) {
if (this.isModified('content')) {
// 計(jì)算閱讀時(shí)間(按每分鐘200字計(jì)算)
const wordCount = this.content.trim().split(/\s+/).length;
this.readingTime = Math.ceil(wordCount / 200);
// 自動(dòng)生成摘要
if (!this.excerpt) {
this.excerpt = this.content
.replace(/[#*`~>]/g, '') // 移除Markdown標(biāo)記
.substring(0, 200)
.trim() + '...';
}
}
next();
});
// 發(fā)布文章時(shí)設(shè)置發(fā)布時(shí)間
postSchema.pre('save', function(next) {
if (this.isModified('status') && this.status === 'published' && !this.publishedAt) {
this.publishedAt = new Date();
}
next();
});
// 靜態(tài)方法:獲取已發(fā)布文章
postSchema.statics.getPublishedPosts = function() {
return this.find({ status: 'published' });
};
// 靜態(tài)方法:按分類獲取文章
postSchema.statics.getPostsByCategory = function(category) {
return this.find({
category: category.toLowerCase(),
status: 'published'
});
};
// 靜態(tài)方法:搜索文章
postSchema.statics.searchPosts = function(query) {
return this.find({
status: 'published',
$text: { $search: query }
}, { score: { $meta: 'textScore' } })
.sort({ score: { $meta: 'textScore' } });
};
// 實(shí)例方法:增加瀏覽量
postSchema.methods.incrementViews = function() {
this.viewCount += 1;
return this.save();
};
// 查詢中間件:自動(dòng)填充作者信息
postSchema.pre(/^find/, function(next) {
this.populate({
path: 'author',
select: 'username avatar bio'
});
next();
});
module.exports = mongoose.model('Post', postSchema);
5.3 評(píng)論和點(diǎn)贊模型
創(chuàng)建評(píng)論模型 src/models/Comment.js:
const mongoose = require('mongoose');
const commentSchema = new mongoose.Schema({
content: {
type: String,
required: [true, '評(píng)論內(nèi)容不能為空'],
trim: true,
minlength: [1, '評(píng)論內(nèi)容至少1個(gè)字符'],
maxlength: [1000, '評(píng)論內(nèi)容不能超過1000個(gè)字符']
},
author: {
type: mongoose.Schema.ObjectId,
ref: 'User',
required: true
},
post: {
type: mongoose.Schema.ObjectId,
ref: 'Post',
required: true
},
parentComment: {
type: mongoose.Schema.ObjectId,
ref: 'Comment',
default: null
},
likes: {
type: Number,
default: 0
},
isEdited: {
type: Boolean,
default: false
},
isApproved: {
type: Boolean,
default: true
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// 虛擬字段:回復(fù)評(píng)論
commentSchema.virtual('replies', {
ref: 'Comment',
localField: '_id',
foreignField: 'parentComment',
justOne: false
});
// 索引優(yōu)化
commentSchema.index({ post: 1, createdAt: -1 });
commentSchema.index({ author: 1 });
commentSchema.index({ parentComment: 1 });
// 保存后更新文章的評(píng)論計(jì)數(shù)
commentSchema.post('save', async function() {
const Post = mongoose.model('Post');
await Post.findByIdAndUpdate(this.post, {
$inc: { commentCount: 1 }
});
});
// 刪除后更新文章的評(píng)論計(jì)數(shù)
commentSchema.post('findOneAndDelete', async function(doc) {
if (doc) {
const Post = mongoose.model('Post');
await Post.findByIdAndUpdate(doc.post, {
$inc: { commentCount: -1 }
});
}
});
// 查詢中間件:自動(dòng)填充作者信息
commentSchema.pre(/^find/, function(next) {
this.populate({
path: 'author',
select: 'username avatar'
}).populate({
path: 'replies',
populate: {
path: 'author',
select: 'username avatar'
}
});
next();
});
module.exports = mongoose.model('Comment', commentSchema);
創(chuàng)建點(diǎn)贊模型 src/models/Like.js:
const mongoose = require('mongoose');
const likeSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.ObjectId,
ref: 'User',
required: true
},
post: {
type: mongoose.Schema.ObjectId,
ref: 'Post',
required: true
},
type: {
type: String,
enum: ['like', 'love', 'laugh', 'wow', 'sad', 'angry'],
default: 'like'
}
}, {
timestamps: true
});
// 復(fù)合唯一索引,確保一個(gè)用戶只能對(duì)一篇文章點(diǎn)一次贊
likeSchema.index({ user: 1, post: 1 }, { unique: true });
// 索引優(yōu)化
likeSchema.index({ post: 1 });
likeSchema.index({ user: 1 });
// 保存后更新文章的點(diǎn)贊計(jì)數(shù)
likeSchema.post('save', async function() {
const Post = mongoose.model('Post');
await Post.findByIdAndUpdate(this.post, {
$inc: { likeCount: 1 }
});
});
// 刪除后更新文章的點(diǎn)贊計(jì)數(shù)
likeSchema.post('findOneAndDelete', async function(doc) {
if (doc) {
const Post = mongoose.model('Post');
await Post.findByIdAndUpdate(doc.post, {
$inc: { likeCount: -1 }
});
}
});
module.exports = mongoose.model('Like', likeSchema);
到此這篇關(guān)于Node.js+MongoDB搭建RESTful API實(shí)戰(zhàn)示例的文章就介紹到這了,更多相關(guān)Node.js搭建RESTful API內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
js實(shí)現(xiàn)簡(jiǎn)單進(jìn)度條效果
這篇文章主要為大家詳細(xì)介紹了js實(shí)現(xiàn)簡(jiǎn)單進(jìn)度條效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-03-03
js學(xué)習(xí)總結(jié)_基于數(shù)據(jù)類型檢測(cè)的四種方式(必看)
下面小編就為大家?guī)硪黄猨s學(xué)習(xí)總結(jié)_基于數(shù)據(jù)類型檢測(cè)的四種方式(必看)。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-07-07
js實(shí)現(xiàn)兩個(gè)值相加alert出來精確到指定位
兩個(gè)值相加精確指定位數(shù)在alert出來,下面有個(gè)不錯(cuò)的示例,感興趣的朋友可以參考下2013-09-09
詳解JavaScript中的構(gòu)造器Constructor模式
構(gòu)造器Constructor不能被繼承,因此不能重寫Overriding,但可以被重載Overloading。通過本文給大家分享JavaScript中的構(gòu)造器Constructor模式,對(duì)構(gòu)造器constructor相關(guān)知識(shí)感興趣的朋友一起學(xué)習(xí)吧2016-01-01
JS使用正則表達(dá)式實(shí)現(xiàn)關(guān)鍵字替換加粗功能示例
這篇文章主要介紹了JS使用正則表達(dá)式實(shí)現(xiàn)關(guān)鍵字替換加粗功能,涉及javascript基本正則匹配與替換操作相關(guān)技巧,需要的朋友可以參考下2016-08-08
javascript文件加載管理簡(jiǎn)單實(shí)現(xiàn)方法
這篇文章主要介紹了javascript文件加載管理簡(jiǎn)單實(shí)現(xiàn)方法,可實(shí)現(xiàn)順序加載所有js文件的功能,非常簡(jiǎn)單實(shí)用,需要的朋友可以參考下2015-07-07
使用JSON格式提交數(shù)據(jù)到服務(wù)端的實(shí)例代碼
這篇文章主要介紹了使用JSON格式提交數(shù)據(jù)到服務(wù)端的實(shí)例代碼,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友參考下2018-04-04

