Feathers.js + SQL 数据库实战 - 关系型数据库的最佳实践
发布时间:2024-06-17
作者:一介布衣
标签:Feathers.js, SQL, Knex.js, 关系型数据库, PostgreSQL
前言
上一篇文章我们深入学习了 MongoDB 的使用,今天咱们来学习 Feathers.js 与 SQL 数据库的集成。说实话,虽然 NoSQL 很灵活,但在很多业务场景下,关系型数据库的 ACID 特性和成熟的生态系统还是不可替代的。
我记得刚开始做项目的时候,总觉得 SQL 数据库很复杂:要设计表结构、处理外键关系、写复杂的 JOIN 查询。后来用了 Feathers.js 的 Knex 适配器,发现原来可以用这么简洁的方式操作 SQL 数据库,而且还能享受到 TypeScript 的类型安全。
今天我就带大家深入学习如何用 Feathers.js 构建一个功能完整的 SQL 数据库应用。
环境搭建
1. 数据库选择和安装
bash
# PostgreSQL (推荐)
docker run -d \
--name postgres \
-p 5432:5432 \
-e POSTGRES_DB=feathers_blog \
-e POSTGRES_USER=admin \
-e POSTGRES_PASSWORD=password \
-v postgres_data:/var/lib/postgresql/data \
postgres:15
# MySQL
docker run -d \
--name mysql \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=password \
-e MYSQL_DATABASE=feathers_blog \
-v mysql_data:/var/lib/mysql \
mysql:8.0
# SQLite (开发环境)
# 无需安装,直接使用文件数据库
2. 安装依赖
bash
# 安装 Knex 适配器
npm install @feathersjs/knex knex
# 安装数据库驱动
npm install pg # PostgreSQL
npm install mysql2 # MySQL
npm install sqlite3 # SQLite
# 安装开发工具
npm install --save-dev @types/pg # TypeScript 类型定义
3. Knex 配置
javascript
// knexfile.js
module.exports = {
development: {
client: 'postgresql',
connection: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'feathers_blog',
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASSWORD || 'password'
},
pool: {
min: 2,
max: 10
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations'
},
seeds: {
directory: './seeds'
}
},
production: {
client: 'postgresql',
connection: process.env.DATABASE_URL,
pool: {
min: 2,
max: 20
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations'
}
}
};
// src/knex.js
const knex = require('knex');
const config = require('../knexfile');
const environment = process.env.NODE_ENV || 'development';
const db = knex(config[environment]);
module.exports = function (app) {
app.set('knexClient', db);
// 测试连接
db.raw('SELECT 1')
.then(() => {
console.log('数据库连接成功');
})
.catch(err => {
console.error('数据库连接失败:', err);
});
};
数据库迁移
1. 创建迁移文件
bash
# 创建用户表迁移
npx knex migrate:make create_users_table
# 创建文章表迁移
npx knex migrate:make create_posts_table
# 创建分类表迁移
npx knex migrate:make create_categories_table
# 创建标签表迁移
npx knex migrate:make create_tags_table
# 创建文章标签关联表迁移
npx knex migrate:make create_post_tags_table
2. 迁移文件实现
javascript
// migrations/001_create_users_table.js
exports.up = function(knex) {
return knex.schema.createTable('users', table => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('email').unique().notNullable();
table.string('username').unique().notNullable();
table.string('password').notNullable();
// 个人信息
table.string('first_name');
table.string('last_name');
table.text('bio');
table.string('avatar');
table.date('birth_date');
// 联系信息
table.string('phone');
table.string('website');
table.json('social_links'); // {twitter, github, linkedin}
// 地址信息
table.string('country');
table.string('city');
table.string('address');
table.string('postal_code');
// 系统字段
table.enum('role', ['user', 'moderator', 'admin']).defaultTo('user');
table.enum('status', ['active', 'inactive', 'suspended']).defaultTo('active');
table.boolean('email_verified').defaultTo(false);
table.string('email_verification_token');
// 偏好设置
table.json('preferences').defaultTo('{}');
// 统计信息
table.integer('posts_count').defaultTo(0);
table.integer('followers_count').defaultTo(0);
table.integer('following_count').defaultTo(0);
// 时间戳
table.timestamps(true, true);
table.timestamp('last_login_at');
// 索引
table.index(['email']);
table.index(['username']);
table.index(['status']);
table.index(['created_at']);
});
};
exports.down = function(knex) {
return knex.schema.dropTable('users');
};
// migrations/002_create_categories_table.js
exports.up = function(knex) {
return knex.schema.createTable('categories', table => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('name').notNullable();
table.string('slug').unique().notNullable();
table.text('description');
table.string('color').defaultTo('#007bff');
table.string('icon');
table.integer('sort_order').defaultTo(0);
table.boolean('is_active').defaultTo(true);
// 层级结构
table.uuid('parent_id').references('id').inTable('categories').onDelete('SET NULL');
table.integer('level').defaultTo(0);
table.string('path'); // 存储层级路径,如 "1/2/3"
// 统计信息
table.integer('posts_count').defaultTo(0);
table.timestamps(true, true);
// 索引
table.index(['slug']);
table.index(['parent_id']);
table.index(['is_active']);
table.index(['sort_order']);
});
};
exports.down = function(knex) {
return knex.schema.dropTable('categories');
};
// migrations/003_create_posts_table.js
exports.up = function(knex) {
return knex.schema.createTable('posts', table => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('title').notNullable();
table.string('slug').unique().notNullable();
table.text('excerpt');
table.text('content').notNullable();
// 关联
table.uuid('author_id').references('id').inTable('users').onDelete('CASCADE');
table.uuid('category_id').references('id').inTable('categories').onDelete('SET NULL');
// 状态
table.enum('status', ['draft', 'published', 'archived']).defaultTo('draft');
table.boolean('is_featured').defaultTo(false);
table.boolean('allow_comments').defaultTo(true);
// 媒体
table.string('featured_image');
table.json('gallery'); // 图片画廊
// SEO
table.string('meta_title');
table.text('meta_description');
table.json('meta_keywords');
// 统计
table.integer('view_count').defaultTo(0);
table.integer('like_count').defaultTo(0);
table.integer('comment_count').defaultTo(0);
table.integer('share_count').defaultTo(0);
// 时间
table.timestamps(true, true);
table.timestamp('published_at');
// 索引
table.index(['author_id']);
table.index(['category_id']);
table.index(['status']);
table.index(['published_at']);
table.index(['slug']);
// 全文搜索索引 (PostgreSQL)
table.index(knex.raw('to_tsvector(\'english\', title || \' \' || content)'), 'posts_search_idx', 'gin');
});
};
exports.down = function(knex) {
return knex.schema.dropTable('posts');
};
// migrations/004_create_tags_table.js
exports.up = function(knex) {
return knex.schema.createTable('tags', table => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('name').unique().notNullable();
table.string('slug').unique().notNullable();
table.text('description');
table.string('color').defaultTo('#6c757d');
table.integer('posts_count').defaultTo(0);
table.timestamps(true, true);
table.index(['slug']);
table.index(['name']);
});
};
exports.down = function(knex) {
return knex.schema.dropTable('tags');
};
// migrations/005_create_post_tags_table.js
exports.up = function(knex) {
return knex.schema.createTable('post_tags', table => {
table.uuid('post_id').references('id').inTable('posts').onDelete('CASCADE');
table.uuid('tag_id').references('id').inTable('tags').onDelete('CASCADE');
table.timestamps(true, true);
table.primary(['post_id', 'tag_id']);
table.index(['post_id']);
table.index(['tag_id']);
});
};
exports.down = function(knex) {
return knex.schema.dropTable('post_tags');
};
3. 运行迁移
bash
# 运行所有迁移
npx knex migrate:latest
# 回滚最后一次迁移
npx knex migrate:rollback
# 查看迁移状态
npx knex migrate:status
SQL 服务实现
1. 用户服务
javascript
// src/services/users/users.class.js
const { KnexService } = require('@feathersjs/knex');
class UsersService extends KnexService {
constructor(options, app) {
super({
...options,
name: 'users'
}, app);
this.app = app;
}
async find(params) {
const { query = {} } = params;
// 构建基础查询
let knexQuery = this.createQuery(params);
// 全文搜索
if (query.search) {
knexQuery = knexQuery.where(function() {
this.where('first_name', 'ilike', `%\${query.search}%`)
.orWhere('last_name', 'ilike', `%\${query.search}%`)
.orWhere('username', 'ilike', `%\${query.search}%`)
.orWhere('bio', 'ilike', `%\${query.search}%`);
});
}
// 角色筛选
if (query.role) {
knexQuery = knexQuery.where('role', query.role);
}
// 状态筛选
if (query.status) {
knexQuery = knexQuery.where('status', query.status);
}
// 关联查询
if (query.$populate) {
const populate = Array.isArray(query.$populate) ? query.$populate : [query.$populate];
if (populate.includes('posts')) {
knexQuery = knexQuery
.leftJoin('posts', 'users.id', 'posts.author_id')
.select('users.*')
.count('posts.id as posts_count')
.groupBy('users.id');
}
}
// 排序
if (!query.$sort) {
knexQuery = knexQuery.orderBy('created_at', 'desc');
}
return this.executeQuery(knexQuery, params);
}
async get(id, params) {
const { query = {} } = params;
let knexQuery = this.Model
.select('users.*')
.where('users.id', id)
.first();
// 关联查询
if (query.$populate) {
const populate = Array.isArray(query.$populate) ? query.$populate : [query.$populate];
if (populate.includes('posts')) {
// 获取用户的文章
const posts = await this.app.service('posts').find({
query: {
author_id: id,
status: 'published',
$limit: 10,
$sort: { published_at: -1 }
}
});
const user = await knexQuery;
if (user) {
user.posts = posts.data;
}
return user;
}
}
return knexQuery;
}
async create(data, params) {
// 数据预处理
const userData = {
...data,
created_at: new Date(),
updated_at: new Date()
};
// 生成用户名(如果没有提供)
if (!userData.username && userData.email) {
userData.username = await this.generateUsername(userData.email);
}
const result = await super.create(userData, params);
// 创建默认分类
await this.createDefaultCategory(result.id);
return result;
}
async patch(id, data, params) {
const updateData = {
...data,
updated_at: new Date()
};
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.Model
.where('username', username)
.first();
return !!result;
}
// 创建默认分类
async createDefaultCategory(userId) {
await this.app.service('categories').create({
name: '默认分类',
slug: `default-\${userId}`,
description: '系统默认分类',
user_id: userId
});
}
// 用户统计信息
async getUserStats(userId) {
const stats = await this.Model
.select(
this.Model.raw('COUNT(DISTINCT posts.id) as posts_count'),
this.Model.raw('COUNT(DISTINCT comments.id) as comments_count'),
this.Model.raw('SUM(posts.view_count) as total_views'),
this.Model.raw('SUM(posts.like_count) as total_likes')
)
.leftJoin('posts', 'users.id', 'posts.author_id')
.leftJoin('comments', 'users.id', 'comments.author_id')
.where('users.id', userId)
.groupBy('users.id')
.first();
return stats || {
posts_count: 0,
comments_count: 0,
total_views: 0,
total_likes: 0
};
}
// 活跃用户
async getActiveUsers(params = {}) {
const { limit = 10, days = 30 } = params;
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
return this.Model
.select(
'users.id',
'users.username',
'users.first_name',
'users.last_name',
'users.avatar',
this.Model.raw('COUNT(posts.id) as recent_posts_count'),
this.Model.raw('SUM(posts.view_count) as total_views')
)
.leftJoin('posts', function() {
this.on('users.id', 'posts.author_id')
.andOn('posts.created_at', '>=', startDate);
})
.where('users.status', 'active')
.groupBy('users.id')
.orderBy('recent_posts_count', 'desc')
.orderBy('total_views', 'desc')
.limit(limit);
}
}
module.exports = UsersService;
2. 文章服务
javascript
// src/services/posts/posts.class.js
const { KnexService } = require('@feathersjs/knex');
class PostsService extends KnexService {
constructor(options, app) {
super({
...options,
name: 'posts'
}, app);
this.app = app;
}
async find(params) {
const { query = {} } = params;
let knexQuery = this.createQuery(params);
// 默认关联作者和分类信息
knexQuery = knexQuery
.leftJoin('users', 'posts.author_id', 'users.id')
.leftJoin('categories', 'posts.category_id', 'categories.id')
.select(
'posts.*',
'users.username as author_username',
'users.first_name as author_first_name',
'users.last_name as author_last_name',
'users.avatar as author_avatar',
'categories.name as category_name',
'categories.slug as category_slug',
'categories.color as category_color'
);
// 全文搜索 (PostgreSQL)
if (query.search) {
knexQuery = knexQuery.whereRaw(
"to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ?)",
[query.search]
);
}
// 作者筛选
if (query.author) {
knexQuery = knexQuery.where('posts.author_id', query.author);
}
// 分类筛选
if (query.category) {
knexQuery = knexQuery.where('categories.slug', query.category);
}
// 标签筛选
if (query.tags) {
const tags = Array.isArray(query.tags) ? query.tags : [query.tags];
knexQuery = knexQuery
.join('post_tags', 'posts.id', 'post_tags.post_id')
.join('tags', 'post_tags.tag_id', 'tags.id')
.whereIn('tags.slug', tags)
.groupBy('posts.id')
.having(this.Model.raw('COUNT(DISTINCT tags.id) = ?'), [tags.length]);
}
// 状态筛选
if (query.status) {
knexQuery = knexQuery.where('posts.status', query.status);
} else if (!params.user || params.user.role !== 'admin') {
// 非管理员只能看到已发布的文章
knexQuery = knexQuery.where('posts.status', 'published');
}
// 日期范围
if (query.dateFrom || query.dateTo) {
if (query.dateFrom) {
knexQuery = knexQuery.where('posts.published_at', '>=', query.dateFrom);
}
if (query.dateTo) {
knexQuery = knexQuery.where('posts.published_at', '<=', query.dateTo);
}
}
// 特色文章
if (query.featured === 'true') {
knexQuery = knexQuery.where('posts.is_featured', true);
}
// 默认排序
if (!query.$sort) {
knexQuery = knexQuery.orderBy('posts.published_at', 'desc');
}
const result = await this.executeQuery(knexQuery, params);
// 获取标签信息
if (result.data && result.data.length > 0) {
await this.populateTags(result.data);
}
return result;
}
async get(id, params) {
const { query = {} } = params;
// 基础查询
let knexQuery = this.Model
.leftJoin('users', 'posts.author_id', 'users.id')
.leftJoin('categories', 'posts.category_id', 'categories.id')
.select(
'posts.*',
'users.username as author_username',
'users.first_name as author_first_name',
'users.last_name as author_last_name',
'users.avatar as author_avatar',
'users.bio as author_bio',
'categories.name as category_name',
'categories.slug as category_slug',
'categories.color as category_color'
)
.where('posts.id', id)
.first();
const post = await knexQuery;
if (!post) {
throw new Error(`文章 \${id} 不存在`);
}
// 获取标签
const tags = await this.Model
.select('tags.id', 'tags.name', 'tags.slug', 'tags.color')
.join('post_tags', 'posts.id', 'post_tags.post_id')
.join('tags', 'post_tags.tag_id', 'tags.id')
.where('posts.id', id);
post.tags = tags;
// 增加浏览量
this.incrementViewCount(id).catch(console.error);
return post;
}
async create(data, params) {
const { user } = params;
const postData = {
...data,
author_id: user.id,
slug: await this.generateSlug(data.title),
created_at: new Date(),
updated_at: new Date()
};
// 如果是发布状态,设置发布时间
if (postData.status === 'published') {
postData.published_at = new Date();
}
// 使用事务创建文章和标签关联
const result = await this.Model.transaction(async (trx) => {
// 创建文章
const [post] = await trx('posts').insert(postData).returning('*');
// 处理标签
if (data.tags && data.tags.length > 0) {
await this.createTagAssociations(post.id, data.tags, trx);
}
return post;
});
// 更新用户文章计数
await this.updateUserPostCount(user.id);
return result;
}
async patch(id, data, params) {
const updateData = {
...data,
updated_at: new Date()
};
// 如果状态变为发布,设置发布时间
if (data.status === 'published') {
const existing = await super.get(id, params);
if (existing.status !== 'published') {
updateData.published_at = new Date();
}
}
// 如果更新了标题,重新生成 slug
if (data.title) {
updateData.slug = await this.generateSlug(data.title, id);
}
// 使用事务更新
const result = await this.Model.transaction(async (trx) => {
// 更新文章
const [post] = await trx('posts')
.where('id', id)
.update(updateData)
.returning('*');
// 处理标签更新
if (data.tags !== undefined) {
// 删除现有标签关联
await trx('post_tags').where('post_id', id).del();
// 创建新的标签关联
if (data.tags.length > 0) {
await this.createTagAssociations(id, data.tags, trx);
}
}
return post;
});
return result;
}
// 生成唯一 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) {
let query = this.Model.where('slug', slug);
if (excludeId) {
query = query.whereNot('id', excludeId);
}
const result = await query.first();
return !!result;
}
// 创建标签关联
async createTagAssociations(postId, tagSlugs, trx = null) {
const knex = trx || this.Model;
// 获取或创建标签
const tags = [];
for (const tagSlug of tagSlugs) {
let tag = await knex('tags').where('slug', tagSlug).first();
if (!tag) {
// 创建新标签
[tag] = await knex('tags').insert({
name: tagSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
slug: tagSlug,
created_at: new Date(),
updated_at: new Date()
}).returning('*');
}
tags.push(tag);
}
// 创建关联
const associations = tags.map(tag => ({
post_id: postId,
tag_id: tag.id,
created_at: new Date(),
updated_at: new Date()
}));
await knex('post_tags').insert(associations);
// 更新标签文章计数
for (const tag of tags) {
await this.updateTagPostCount(tag.id, trx);
}
}
// 填充标签信息
async populateTags(posts) {
const postIds = posts.map(post => post.id);
const tagAssociations = await this.Model
.select(
'post_tags.post_id',
'tags.id',
'tags.name',
'tags.slug',
'tags.color'
)
.from('post_tags')
.join('tags', 'post_tags.tag_id', 'tags.id')
.whereIn('post_tags.post_id', postIds);
// 按文章ID分组标签
const tagsByPost = {};
tagAssociations.forEach(assoc => {
if (!tagsByPost[assoc.post_id]) {
tagsByPost[assoc.post_id] = [];
}
tagsByPost[assoc.post_id].push({
id: assoc.id,
name: assoc.name,
slug: assoc.slug,
color: assoc.color
});
});
// 添加标签到文章
posts.forEach(post => {
post.tags = tagsByPost[post.id] || [];
});
}
// 增加浏览量
async incrementViewCount(id) {
await this.Model
.where('id', id)
.increment('view_count', 1);
}
// 更新用户文章计数
async updateUserPostCount(userId) {
const count = await this.Model
.where('author_id', userId)
.where('status', 'published')
.count('id as count')
.first();
await this.app.service('users').patch(userId, {
posts_count: parseInt(count.count)
});
}
// 更新标签文章计数
async updateTagPostCount(tagId, trx = null) {
const knex = trx || this.Model;
const count = await knex('post_tags')
.join('posts', 'post_tags.post_id', 'posts.id')
.where('post_tags.tag_id', tagId)
.where('posts.status', 'published')
.count('posts.id as count')
.first();
await knex('tags')
.where('id', tagId)
.update({ posts_count: parseInt(count.count) });
}
// 热门文章
async getPopularPosts(params = {}) {
const { limit = 10, days = 7 } = params;
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
return this.Model
.select(
'posts.*',
'users.username as author_username',
'users.first_name as author_first_name',
'users.last_name as author_last_name',
'categories.name as category_name',
this.Model.raw('(view_count + like_count * 5 + comment_count * 10) as popularity_score')
)
.leftJoin('users', 'posts.author_id', 'users.id')
.leftJoin('categories', 'posts.category_id', 'categories.id')
.where('posts.status', 'published')
.where('posts.published_at', '>=', startDate)
.orderBy('popularity_score', 'desc')
.limit(limit);
}
// 相关文章
async getRelatedPosts(postId, params = {}) {
const { limit = 5 } = params;
// 获取当前文章的标签
const currentPostTags = await this.Model
.select('tags.id')
.join('post_tags', 'posts.id', 'post_tags.post_id')
.join('tags', 'post_tags.tag_id', 'tags.id')
.where('posts.id', postId);
if (currentPostTags.length === 0) {
return { total: 0, data: [] };
}
const tagIds = currentPostTags.map(tag => tag.id);
const relatedPosts = await this.Model
.select(
'posts.*',
'users.username as author_username',
this.Model.raw('COUNT(post_tags.tag_id) as matching_tags')
)
.leftJoin('users', 'posts.author_id', 'users.id')
.join('post_tags', 'posts.id', 'post_tags.post_id')
.whereIn('post_tags.tag_id', tagIds)
.whereNot('posts.id', postId)
.where('posts.status', 'published')
.groupBy('posts.id')
.orderBy('matching_tags', 'desc')
.orderBy('posts.published_at', 'desc')
.limit(limit);
return {
total: relatedPosts.length,
data: relatedPosts
};
}
}
module.exports = PostsService;
总结
通过这篇文章,我们深入学习了 Feathers.js 与 SQL 数据库的集成:
✅ 环境搭建和配置:
- 多种数据库支持
- Knex.js 配置优化
- 开发工具集成
✅ 数据库迁移系统:
- 表结构设计
- 索引优化策略
- 版本控制管理
✅ 高级查询功能:
- 复杂关联查询
- 全文搜索实现
- 事务处理
✅ 生产级特性:
- 性能优化技巧
- 数据一致性保证
- 错误处理机制
掌握了这些知识,你就能够充分发挥 SQL 数据库的优势,构建高性能、高可靠性的关系型数据库应用。
下一篇文章,我们将学习数据验证与模式设计。
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!