Feathers.js + MongoDB 实战 - 构建灵活的文档数据库应用
发布时间:2024-06-12
作者:一介布衣
标签:Feathers.js, MongoDB, 文档数据库, NoSQL
前言
上一篇文章我们学习了 Feathers.js 的适配器系统,今天咱们来深入实战 MongoDB 适配器。说实话,MongoDB 和 Feathers.js 真的是天作之合,一个提供灵活的文档存储,一个提供优雅的 API 抽象,两者结合起来开发效率特别高。
我记得第一次用 MongoDB 的时候,被它的灵活性震撼了:不需要预定义表结构,可以存储复杂的嵌套对象,查询功能强大。但是直接用 MongoDB 的原生驱动写代码还是挺繁琐的。后来用了 Feathers.js 的 MongoDB 适配器,发现原来可以这么简单优雅地操作 MongoDB。
今天我就带大家深入学习如何用 Feathers.js 构建一个功能完整的 MongoDB 应用。
MongoDB 环境搭建
1. 安装 MongoDB
bash
# 使用 Docker 快速启动 MongoDB
docker run -d \
--name mongodb \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=admin \
-e MONGO_INITDB_ROOT_PASSWORD=password \
-v mongodb_data:/data/db \
mongo:latest
# 或者使用 Docker Compose
# docker-compose.yml
version: '3.8'
services:
mongodb:
image: mongo:latest
container_name: mongodb
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password
volumes:
- mongodb_data:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
volumes:
mongodb_data:
2. 安装依赖
bash
# 安装 Feathers.js MongoDB 适配器
npm install @feathersjs/mongodb mongodb
# 安装其他相关依赖
npm install mongoose # 如果需要使用 Mongoose
npm install @faker-js/faker # 生成测试数据
3. 连接配置
javascript
// config/default.json
{
"mongodb": "mongodb://admin:password@localhost:27017/feathers_blog?authSource=admin",
"paginate": {
"default": 10,
"max": 50
}
}
// src/mongodb.js
const { MongoClient } = require('mongodb');
module.exports = function (app) {
const connection = app.get('mongodb');
const database = connection.substr(connection.lastIndexOf('/') + 1).split('?')[0];
const mongoClient = MongoClient.connect(connection, {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
}).then(client => {
console.log('MongoDB 连接成功');
return client.db(database);
}).catch(error => {
console.error('MongoDB 连接失败:', error);
throw error;
});
app.set('mongoClient', mongoClient);
};
文档模型设计
1. 用户模型
javascript
// src/models/users.model.js
module.exports = function (app) {
const mongoClient = app.get('mongoClient');
// 创建索引
mongoClient.then(db => {
const collection = db.collection('users');
// 创建索引
collection.createIndex({ email: 1 }, { unique: true });
collection.createIndex({ username: 1 }, { unique: true });
collection.createIndex({ 'profile.location': '2dsphere' }); // 地理位置索引
collection.createIndex({ tags: 1 }); // 数组字段索引
collection.createIndex({
'profile.firstName': 'text',
'profile.lastName': 'text',
'profile.bio': 'text'
}); // 全文搜索索引
});
return mongoClient;
};
// 用户文档结构示例
const userDocument = {
_id: ObjectId("..."),
email: "john@example.com",
username: "john_doe",
password: "hashed_password",
// 嵌套的个人资料
profile: {
firstName: "John",
lastName: "Doe",
avatar: "https://example.com/avatar.jpg",
bio: "Full-stack developer passionate about JavaScript",
birthDate: new Date("1990-01-01"),
// 地理位置
location: {
type: "Point",
coordinates: [-73.97, 40.77] // [longitude, latitude]
},
// 社交媒体链接
social: {
twitter: "@johndoe",
github: "johndoe",
linkedin: "john-doe"
}
},
// 用户偏好设置
preferences: {
theme: "dark",
language: "en",
timezone: "America/New_York",
notifications: {
email: true,
push: true,
sms: false
}
},
// 标签数组
tags: ["javascript", "nodejs", "react", "mongodb"],
// 角色和权限
roles: ["user"],
permissions: ["read:posts", "write:own:posts"],
// 统计信息
stats: {
postsCount: 0,
followersCount: 0,
followingCount: 0,
lastLoginAt: new Date()
},
// 时间戳
createdAt: new Date(),
updatedAt: new Date()
};
2. 博客文章模型
javascript
// src/models/posts.model.js
module.exports = function (app) {
const mongoClient = app.get('mongoClient');
mongoClient.then(db => {
const collection = db.collection('posts');
// 创建索引
collection.createIndex({ authorId: 1 });
collection.createIndex({ status: 1 });
collection.createIndex({ publishedAt: -1 });
collection.createIndex({ 'category.slug': 1 });
collection.createIndex({ tags: 1 });
collection.createIndex({
title: 'text',
content: 'text',
excerpt: 'text'
}); // 全文搜索
});
return mongoClient;
};
// 文章文档结构
const postDocument = {
_id: ObjectId("..."),
title: "深入理解 MongoDB 聚合管道",
slug: "understanding-mongodb-aggregation-pipeline",
excerpt: "MongoDB 聚合管道是一个强大的数据处理工具...",
content: "完整的文章内容...",
// 作者信息(引用)
authorId: ObjectId("..."),
// 嵌套的分类信息
category: {
_id: ObjectId("..."),
name: "数据库技术",
slug: "database"
},
// 标签数组
tags: ["mongodb", "database", "aggregation", "nosql"],
// 文章状态
status: "published", // draft, published, archived
// 特色图片
featuredImage: {
url: "https://example.com/image.jpg",
alt: "MongoDB 聚合管道示意图",
caption: "聚合管道的工作流程"
},
// SEO 信息
seo: {
metaTitle: "深入理解 MongoDB 聚合管道 - 完整指南",
metaDescription: "学习 MongoDB 聚合管道的核心概念和实战应用",
keywords: ["mongodb", "aggregation", "pipeline", "database"]
},
// 统计信息
stats: {
viewCount: 1250,
likeCount: 89,
commentCount: 23,
shareCount: 15
},
// 评论(嵌套文档数组)
comments: [
{
_id: ObjectId("..."),
authorId: ObjectId("..."),
content: "很棒的文章!",
createdAt: new Date(),
replies: [
{
_id: ObjectId("..."),
authorId: ObjectId("..."),
content: "同意!",
createdAt: new Date()
}
]
}
],
// 版本控制
version: 1,
revisions: [
{
version: 1,
content: "初始版本内容",
updatedAt: new Date(),
updatedBy: ObjectId("...")
}
],
// 时间戳
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: new Date()
};
MongoDB 服务实现
1. 用户服务
javascript
// src/services/users/users.class.js
const { MongoDBService } = require('@feathersjs/mongodb');
const { ObjectId } = require('mongodb');
class UsersService extends MongoDBService {
constructor(options, app) {
super(options, app);
this.app = app;
}
async find(params) {
const { query = {} } = params;
// 构建聚合管道
const pipeline = [];
// 匹配阶段
const matchStage = {};
// 处理基础查询
Object.keys(query).forEach(key => {
if (!key.startsWith('$') && key !== 'search' && key !== 'near') {
matchStage[key] = query[key];
}
});
if (Object.keys(matchStage).length > 0) {
pipeline.push({ $match: matchStage });
}
// 全文搜索
if (query.search) {
pipeline.unshift({
$match: {
$text: { $search: query.search }
}
});
// 添加搜索分数
pipeline.push({
$addFields: {
searchScore: { $meta: 'textScore' }
}
});
}
// 地理位置查询
if (query.near) {
const { coordinates, maxDistance = 10000 } = query.near;
pipeline.unshift({
$geoNear: {
near: {
type: 'Point',
coordinates: coordinates
},
distanceField: 'distance',
maxDistance: maxDistance,
spherical: true
}
});
}
// 添加计算字段
pipeline.push({
$addFields: {
fullName: {
$concat: ['$profile.firstName', ' ', '$profile.lastName']
},
isActive: {
$gt: ['$stats.lastLoginAt', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)]
}
}
});
// 排序
if (query.$sort) {
pipeline.push({ $sort: query.$sort });
} else if (query.search) {
pipeline.push({ $sort: { searchScore: { $meta: 'textScore' } } });
} else {
pipeline.push({ $sort: { createdAt: -1 } });
}
// 分页
if (query.$skip) {
pipeline.push({ $skip: parseInt(query.$skip) });
}
if (query.$limit) {
pipeline.push({ $limit: parseInt(query.$limit) });
}
// 字段选择
if (query.$select) {
const projection = {};
query.$select.forEach(field => {
projection[field] = 1;
});
pipeline.push({ $project: projection });
} else {
// 默认排除敏感字段
pipeline.push({
$project: {
password: 0,
'preferences.notifications': 0
}
});
}
// 执行聚合查询
const [data, totalResult] = await Promise.all([
this.Model.aggregate(pipeline).toArray(),
this.getAggregationCount(pipeline.slice(0, -3)) // 排除分页和投影阶段
]);
const total = totalResult[0]?.count || 0;
return {
total,
limit: query.$limit || total,
skip: query.$skip || 0,
data
};
}
async getAggregationCount(pipeline) {
const countPipeline = [
...pipeline,
{ $count: 'count' }
];
return this.Model.aggregate(countPipeline).toArray();
}
async create(data, params) {
// 数据预处理
const userData = {
...data,
createdAt: new Date(),
updatedAt: new Date(),
stats: {
postsCount: 0,
followersCount: 0,
followingCount: 0,
lastLoginAt: null,
...data.stats
}
};
// 生成用户名(如果没有提供)
if (!userData.username && userData.email) {
userData.username = await this.generateUsername(userData.email);
}
const result = await super.create(userData, params);
// 创建用户后的后续操作
await this.initializeUserData(result._id);
return result;
}
async patch(id, data, params) {
const updateData = {
...data,
updatedAt: new Date()
};
// 如果更新了个人资料,重新计算全名
if (data.profile) {
updateData.$set = updateData.$set || {};
if (data.profile.firstName || data.profile.lastName) {
const user = await this.get(id, params);
const firstName = data.profile.firstName || user.profile?.firstName || '';
const lastName = data.profile.lastName || user.profile?.lastName || '';
updateData.fullName = `${firstName} ${lastName}`.trim();
}
}
return super.patch(id, updateData, params);
}
// 生成唯一用户名
async generateUsername(email) {
const baseUsername = email.split('@')[0].toLowerCase();
let username = baseUsername;
let counter = 1;
while (await this.isUsernameExists(username)) {
username = `${baseUsername}${counter}`;
counter++;
}
return username;
}
async isUsernameExists(username) {
const result = await this.find({
query: { username, $limit: 1 }
});
return result.total > 0;
}
// 初始化用户数据
async initializeUserData(userId) {
// 创建默认分类
await this.app.service('categories').create({
name: '默认分类',
slug: 'default',
userId: userId,
isDefault: true
});
// 发送欢迎邮件
await this.app.service('notifications').create({
type: 'welcome',
userId: userId,
title: '欢迎加入我们!',
content: '感谢您注册我们的平台...'
});
}
// 用户统计信息
async getUserStats(userId) {
const pipeline = [
{ $match: { _id: new ObjectId(userId) } },
{
$lookup: {
from: 'posts',
localField: '_id',
foreignField: 'authorId',
as: 'posts'
}
},
{
$lookup: {
from: 'comments',
localField: '_id',
foreignField: 'authorId',
as: 'comments'
}
},
{
$addFields: {
'stats.postsCount': { $size: '$posts' },
'stats.commentsCount': { $size: '$comments' },
'stats.publishedPostsCount': {
$size: {
$filter: {
input: '$posts',
cond: { $eq: ['$$this.status', 'published'] }
}
}
}
}
},
{
$project: {
posts: 0,
comments: 0
}
}
];
const result = await this.Model.aggregate(pipeline).toArray();
return result[0] || null;
}
// 附近的用户
async findNearbyUsers(coordinates, maxDistance = 10000, params = {}) {
const pipeline = [
{
$geoNear: {
near: {
type: 'Point',
coordinates: coordinates
},
distanceField: 'distance',
maxDistance: maxDistance,
spherical: true,
query: { 'profile.location': { $exists: true } }
}
},
{
$project: {
username: 1,
'profile.firstName': 1,
'profile.lastName': 1,
'profile.avatar': 1,
'profile.bio': 1,
distance: 1
}
},
{ $limit: params.limit || 20 }
];
const data = await this.Model.aggregate(pipeline).toArray();
return {
total: data.length,
data
};
}
// 用户活动时间线
async getUserTimeline(userId, params = {}) {
const { limit = 20, skip = 0 } = params;
const pipeline = [
{
$facet: {
posts: [
{ $match: { authorId: new ObjectId(userId), status: 'published' } },
{ $addFields: { type: 'post', date: '$publishedAt' } },
{ $project: { title: 1, slug: 1, type: 1, date: 1 } }
],
comments: [
{ $match: { authorId: new ObjectId(userId) } },
{ $addFields: { type: 'comment', date: '$createdAt' } },
{ $project: { content: 1, postId: 1, type: 1, date: 1 } }
]
}
},
{
$project: {
timeline: { $concatArrays: ['$posts', '$comments'] }
}
},
{ $unwind: '$timeline' },
{ $replaceRoot: { newRoot: '$timeline' } },
{ $sort: { date: -1 } },
{ $skip: skip },
{ $limit: limit }
];
const data = await this.app.get('mongoClient')
.then(db => db.collection('posts'))
.then(collection => collection.aggregate(pipeline).toArray());
return {
total: data.length,
data
};
}
}
module.exports = UsersService;
2. 文章服务
javascript
// src/services/posts/posts.class.js
const { MongoDBService } = require('@feathersjs/mongodb');
const { ObjectId } = require('mongodb');
class PostsService extends MongoDBService {
constructor(options, app) {
super(options, app);
this.app = app;
}
async find(params) {
const { query = {} } = params;
const pipeline = [];
const matchStage = {};
// 基础查询条件
Object.keys(query).forEach(key => {
if (!key.startsWith('$') && !['search', 'author', 'category', 'dateRange'].includes(key)) {
matchStage[key] = query[key];
}
});
// 默认只显示已发布的文章(除非明确查询其他状态)
if (!matchStage.status && !params.user?.role === 'admin') {
matchStage.status = 'published';
}
if (Object.keys(matchStage).length > 0) {
pipeline.push({ $match: matchStage });
}
// 全文搜索
if (query.search) {
pipeline.unshift({
$match: {
$text: { $search: query.search }
}
});
}
// 作者筛选
if (query.author) {
pipeline.push({
$match: {
authorId: new ObjectId(query.author)
}
});
}
// 分类筛选
if (query.category) {
pipeline.push({
$match: {
'category.slug': query.category
}
});
}
// 日期范围筛选
if (query.dateRange) {
const { start, end } = query.dateRange;
const dateMatch = {};
if (start) dateMatch.$gte = new Date(start);
if (end) dateMatch.$lte = new Date(end);
if (Object.keys(dateMatch).length > 0) {
pipeline.push({
$match: {
publishedAt: dateMatch
}
});
}
}
// 关联作者信息
pipeline.push({
$lookup: {
from: 'users',
localField: 'authorId',
foreignField: '_id',
as: 'author',
pipeline: [
{
$project: {
username: 1,
'profile.firstName': 1,
'profile.lastName': 1,
'profile.avatar': 1
}
}
]
}
});
// 展开作者信息
pipeline.push({
$addFields: {
author: { $arrayElemAt: ['$author', 0] }
}
});
// 添加计算字段
pipeline.push({
$addFields: {
readingTime: {
$ceil: {
$divide: [
{ $size: { $split: ['$content', ' '] } },
200 // 假设每分钟阅读200个单词
]
}
},
isPopular: {
$gt: ['$stats.viewCount', 1000]
}
}
});
// 排序
if (query.$sort) {
pipeline.push({ $sort: query.$sort });
} else if (query.search) {
pipeline.push({ $sort: { score: { $meta: 'textScore' } } });
} else {
pipeline.push({ $sort: { publishedAt: -1 } });
}
// 分页
const skip = parseInt(query.$skip) || 0;
const limit = parseInt(query.$limit) || 10;
// 获取总数
const countPipeline = [...pipeline, { $count: 'total' }];
// 添加分页
pipeline.push({ $skip: skip });
pipeline.push({ $limit: limit });
// 字段投影
if (query.$select) {
const projection = {};
query.$select.forEach(field => {
projection[field] = 1;
});
pipeline.push({ $project: projection });
}
// 执行查询
const [data, totalResult] = await Promise.all([
this.Model.aggregate(pipeline).toArray(),
this.Model.aggregate(countPipeline).toArray()
]);
const total = totalResult[0]?.total || 0;
return {
total,
limit,
skip,
data
};
}
async get(id, params) {
const pipeline = [
{ $match: { _id: new ObjectId(id) } },
// 关联作者信息
{
$lookup: {
from: 'users',
localField: 'authorId',
foreignField: '_id',
as: 'author',
pipeline: [
{
$project: {
username: 1,
'profile.firstName': 1,
'profile.lastName': 1,
'profile.avatar': 1,
'profile.bio': 1
}
}
]
}
},
// 关联评论信息
{
$lookup: {
from: 'users',
localField: 'comments.authorId',
foreignField: '_id',
as: 'commentAuthors'
}
},
// 处理评论作者信息
{
$addFields: {
author: { $arrayElemAt: ['$author', 0] },
comments: {
$map: {
input: '$comments',
as: 'comment',
in: {
$mergeObjects: [
'$$comment',
{
author: {
$arrayElemAt: [
{
$filter: {
input: '$commentAuthors',
cond: { $eq: ['$$this._id', '$$comment.authorId'] }
}
},
0
]
}
}
]
}
}
}
}
},
// 清理临时字段
{
$project: {
commentAuthors: 0
}
}
];
const result = await this.Model.aggregate(pipeline).toArray();
if (result.length === 0) {
throw new Error(`文章 ${id} 不存在`);
}
const post = result[0];
// 增加浏览量(异步执行,不影响响应速度)
this.incrementViewCount(id).catch(console.error);
return post;
}
async create(data, params) {
const { user } = params;
const postData = {
...data,
authorId: new ObjectId(user._id),
slug: await this.generateSlug(data.title),
stats: {
viewCount: 0,
likeCount: 0,
commentCount: 0,
shareCount: 0
},
createdAt: new Date(),
updatedAt: new Date()
};
// 如果是发布状态,设置发布时间
if (postData.status === 'published') {
postData.publishedAt = new Date();
}
const result = await super.create(postData, params);
// 更新用户文章计数
await this.updateUserPostCount(user._id);
return result;
}
async patch(id, data, params) {
const updateData = {
...data,
updatedAt: new Date()
};
// 如果状态变为发布,设置发布时间
if (data.status === 'published') {
const existing = await super.get(id, params);
if (existing.status !== 'published') {
updateData.publishedAt = new Date();
}
}
// 如果更新了标题,重新生成 slug
if (data.title) {
updateData.slug = await this.generateSlug(data.title, id);
}
return super.patch(id, updateData, params);
}
// 生成唯一 slug
async generateSlug(title, excludeId = null) {
const baseSlug = title
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
let slug = baseSlug;
let counter = 1;
while (await this.isSlugExists(slug, excludeId)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
return slug;
}
async isSlugExists(slug, excludeId = null) {
const query = { slug };
if (excludeId) {
query._id = { $ne: new ObjectId(excludeId) };
}
const result = await this.find({ query: { ...query, $limit: 1 } });
return result.total > 0;
}
// 增加浏览量
async incrementViewCount(id) {
await this.Model.updateOne(
{ _id: new ObjectId(id) },
{ $inc: { 'stats.viewCount': 1 } }
);
}
// 更新用户文章计数
async updateUserPostCount(userId) {
const postCount = await this.Model.countDocuments({
authorId: new ObjectId(userId),
status: 'published'
});
await this.app.service('users').patch(userId, {
'stats.postsCount': postCount
});
}
// 热门文章
async getPopularPosts(params = {}) {
const { limit = 10, days = 7 } = params;
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const pipeline = [
{
$match: {
status: 'published',
publishedAt: { $gte: startDate }
}
},
{
$addFields: {
popularityScore: {
$add: [
{ $multiply: ['$stats.viewCount', 1] },
{ $multiply: ['$stats.likeCount', 5] },
{ $multiply: ['$stats.commentCount', 10] },
{ $multiply: ['$stats.shareCount', 15] }
]
}
}
},
{ $sort: { popularityScore: -1 } },
{ $limit: limit },
{
$lookup: {
from: 'users',
localField: 'authorId',
foreignField: '_id',
as: 'author',
pipeline: [
{
$project: {
username: 1,
'profile.firstName': 1,
'profile.lastName': 1,
'profile.avatar': 1
}
}
]
}
},
{
$addFields: {
author: { $arrayElemAt: ['$author', 0] }
}
},
{
$project: {
title: 1,
slug: 1,
excerpt: 1,
featuredImage: 1,
author: 1,
stats: 1,
publishedAt: 1,
popularityScore: 1
}
}
];
const data = await this.Model.aggregate(pipeline).toArray();
return {
total: data.length,
data
};
}
// 相关文章推荐
async getRelatedPosts(postId, params = {}) {
const { limit = 5 } = params;
// 获取当前文章的标签
const currentPost = await super.get(postId, {});
const tags = currentPost.tags || [];
if (tags.length === 0) {
return { total: 0, data: [] };
}
const pipeline = [
{
$match: {
_id: { $ne: new ObjectId(postId) },
status: 'published',
tags: { $in: tags }
}
},
{
$addFields: {
matchingTags: {
$size: {
$setIntersection: ['$tags', tags]
}
}
}
},
{ $sort: { matchingTags: -1, publishedAt: -1 } },
{ $limit: limit },
{
$lookup: {
from: 'users',
localField: 'authorId',
foreignField: '_id',
as: 'author',
pipeline: [
{
$project: {
username: 1,
'profile.firstName': 1,
'profile.lastName': 1,
'profile.avatar': 1
}
}
]
}
},
{
$addFields: {
author: { $arrayElemAt: ['$author', 0] }
}
},
{
$project: {
title: 1,
slug: 1,
excerpt: 1,
featuredImage: 1,
author: 1,
publishedAt: 1,
matchingTags: 1
}
}
];
const data = await this.Model.aggregate(pipeline).toArray();
return {
total: data.length,
data
};
}
}
module.exports = PostsService;
总结
通过这篇文章,我们深入学习了 Feathers.js 与 MongoDB 的结合使用:
✅ MongoDB 环境搭建:
- Docker 快速部署
- 连接配置和优化
- 索引策略设计
✅ 文档模型设计:
- 灵活的嵌套结构
- 地理位置数据
- 全文搜索索引
✅ 高级查询功能:
- 聚合管道应用
- 复杂关联查询
- 地理位置查询
✅ 实战服务实现:
- 用户服务完整实现
- 文章服务高级功能
- 性能优化技巧
掌握了这些知识,你就能够充分发挥 MongoDB 的优势,构建高性能的文档数据库应用。
下一篇文章,我们将学习 SQL 数据库的集成和使用。
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!