Skip to content

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 数据库的优势,构建高性能、高可靠性的关系型数据库应用。

下一篇文章,我们将学习数据验证与模式设计。


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!