Skip to content

Feathers.js 核心概念详解 - Services、Hooks、Channels

发布时间:2024-05-10
作者:一介布衣
标签:Feathers.js, Services, Hooks, Channels, 核心概念

前言

上一篇文章我们快速搭建了第一个 Feathers.js 应用,体验了它的强大功能。但是要真正掌握 Feathers.js,我们必须深入理解它的三个核心概念:Services(服务)、Hooks(钩子)和 Channels(频道)。

说实话,刚开始学 Feathers.js 的时候,我对这些概念也是一知半解。特别是 Hooks,总觉得和其他框架的中间件差不多。后来深入使用才发现,Feathers.js 的设计真的很巧妙,这三个概念相互配合,形成了一个非常优雅的架构。

今天我就来详细解析这三个核心概念,让大家真正理解 Feathers.js 的设计哲学。

Services(服务)- 数据操作的统一抽象

什么是 Service?

Service 是 Feathers.js 的核心概念,它代表一个数据资源的抽象。无论数据存储在内存、文件、数据库,还是来自第三方 API,Service 都提供统一的接口。

Service 的标准方法

每个 Service 都实现以下标准方法:

javascript
// 标准的 Service 接口
class MyService {
  async find(params) {
    // 查询多条记录
    // 返回: { total: number, limit: number, skip: number, data: [] }
  }

  async get(id, params) {
    // 根据 ID 获取单条记录
    // 返回: object
  }

  async create(data, params) {
    // 创建新记录
    // 返回: object 或 array
  }

  async update(id, data, params) {
    // 完全替换记录
    // 返回: object
  }

  async patch(id, data, params) {
    // 部分更新记录
    // 返回: object
  }

  async remove(id, params) {
    // 删除记录
    // 返回: object
  }
}

内存 Service 示例

让我们创建一个简单的内存 Service:

javascript
// src/services/todos/todos.class.js
class TodosService {
  constructor() {
    this.todos = [];
    this.currentId = 0;
  }

  async find(params) {
    const { query = {} } = params;
    let result = [...this.todos];

    // 简单的过滤
    if (query.completed !== undefined) {
      result = result.filter(todo => todo.completed === query.completed);
    }

    // 简单的搜索
    if (query.text) {
      result = result.filter(todo => 
        todo.text.toLowerCase().includes(query.text.toLowerCase())
      );
    }

    return {
      total: result.length,
      limit: params.query.$limit || result.length,
      skip: params.query.$skip || 0,
      data: result
    };
  }

  async get(id, params) {
    const todo = this.todos.find(t => t.id === parseInt(id));
    if (!todo) {
      throw new Error(`Todo with id \${id} not found`);
    }
    return todo;
  }

  async create(data, params) {
    const todo = {
      id: ++this.currentId,
      text: data.text,
      completed: data.completed || false,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    this.todos.push(todo);
    return todo;
  }

  async patch(id, data, params) {
    const todo = await this.get(id, params);
    
    Object.assign(todo, data, {
      updatedAt: new Date()
    });

    return todo;
  }

  async remove(id, params) {
    const todo = await this.get(id, params);
    const index = this.todos.findIndex(t => t.id === parseInt(id));
    
    this.todos.splice(index, 1);
    return todo;
  }
}

module.exports = TodosService;

注册 Service

javascript
// src/services/todos/todos.service.js
const TodosService = require('./todos.class');
const hooks = require('./todos.hooks');

module.exports = function (app) {
  const options = {
    paginate: app.get('paginate')
  };

  // 注册服务
  app.use('/todos', new TodosService(options));

  // 获取服务实例并配置钩子
  const service = app.service('todos');
  service.hooks(hooks);
};

Service 的查询语法

Feathers.js 提供了强大的查询语法:

javascript
// 基础查询
await app.service('todos').find({
  query: {
    completed: true,
    text: 'Learn Feathers'
  }
});

// 比较操作符
await app.service('todos').find({
  query: {
    createdAt: {
      $gte: '2024-01-01',
      $lt: '2024-12-31'
    }
  }
});

// 逻辑操作符
await app.service('todos').find({
  query: {
    $or: [
      { completed: true },
      { priority: 'high' }
    ]
  }
});

// 分页和排序
await app.service('todos').find({
  query: {
    $limit: 10,
    $skip: 20,
    $sort: {
      createdAt: -1
    }
  }
});

// 字段选择
await app.service('todos').find({
  query: {
    $select: ['id', 'text', 'completed']
  }
});

Hooks(钩子)- 业务逻辑的组织方式

什么是 Hook?

Hook 是在 Service 方法执行前后运行的函数,用于处理验证、权限、数据转换、日志记录等横切关注点。

Hook 的类型和执行时机

javascript
// Hook 的执行顺序
app.service('todos').hooks({
  before: {
    all: [hook1, hook2],        // 所有方法执行前
    find: [hook3],              // find 方法执行前
    get: [hook4],               // get 方法执行前
    create: [hook5, hook6],     // create 方法执行前
    update: [hook7],            // update 方法执行前
    patch: [hook8],             // patch 方法执行前
    remove: [hook9]             // remove 方法执行前
  },
  after: {
    all: [hook10],              // 所有方法执行后
    find: [hook11],             // find 方法执行后
    // ... 其他方法
  },
  error: {
    all: [hook12],              // 所有方法出错时
    create: [hook13]            // create 方法出错时
  }
});

Hook Context 对象

每个 Hook 都会接收一个 context 对象:

javascript
const myHook = (context) => {
  console.log('Hook 类型:', context.type);        // 'before', 'after', 'error'
  console.log('方法名:', context.method);         // 'find', 'get', 'create', etc.
  console.log('服务路径:', context.path);         // '/todos'
  console.log('请求参数:', context.params);       // 包含 query, user 等
  console.log('请求数据:', context.data);         // create/update/patch 的数据
  console.log('响应结果:', context.result);       // after hook 中的结果
  console.log('错误信息:', context.error);        // error hook 中的错误

  return context;
};

常用 Hook 示例

1. 认证 Hook

javascript
const { authenticate } = require('@feathersjs/authentication').hooks;

// 要求用户登录
app.service('todos').hooks({
  before: {
    all: [authenticate('jwt')]
  }
});

2. 数据验证 Hook

javascript
const validateTodo = (context) => {
  const { data } = context;

  if (!data.text || data.text.trim().length === 0) {
    throw new Error('Todo 内容不能为空');
  }

  if (data.text.length > 200) {
    throw new Error('Todo 内容不能超过200个字符');
  }

  // 清理数据
  data.text = data.text.trim();

  return context;
};

app.service('todos').hooks({
  before: {
    create: [validateTodo],
    update: [validateTodo],
    patch: [validateTodo]
  }
});

3. 权限控制 Hook

javascript
const restrictToOwner = (context) => {
  const { params } = context;

  if (!params.user) {
    throw new Error('用户未登录');
  }

  // 只能操作自己的 Todo
  if (context.method === 'find') {
    context.params.query.userId = params.user.id;
  } else {
    // get, patch, remove 等方法
    context.params.query = {
      ...context.params.query,
      userId: params.user.id
    };
  }

  return context;
};

app.service('todos').hooks({
  before: {
    all: [authenticate('jwt'), restrictToOwner]
  }
});

4. 数据关联 Hook

javascript
const populateUser = (context) => {
  return context.app.service('users').get(context.result.userId)
    .then(user => {
      context.result.user = user;
      return context;
    });
};

app.service('todos').hooks({
  after: {
    get: [populateUser],
    find: [
      (context) => {
        // 批量关联用户信息
        const userIds = [...new Set(context.result.data.map(todo => todo.userId))];
        
        return context.app.service('users').find({
          query: { id: { $in: userIds } }
        }).then(users => {
          const userMap = new Map(users.data.map(user => [user.id, user]));
          
          context.result.data.forEach(todo => {
            todo.user = userMap.get(todo.userId);
          });
          
          return context;
        });
      }
    ]
  }
});

5. 日志记录 Hook

javascript
const logActivity = (context) => {
  const { method, path, params, data } = context;
  
  console.log(`[\${new Date().toISOString()}] \${method.toUpperCase()} \${path}`, {
    user: params.user?.id,
    data: data,
    query: params.query
  });

  return context;
};

app.service('todos').hooks({
  before: {
    all: [logActivity]
  }
});

Hook 的组合和复用

javascript
// hooks/common.js
const { authenticate } = require('@feathersjs/authentication').hooks;

const requireAuth = authenticate('jwt');

const addTimestamp = (field = 'createdAt') => (context) => {
  if (context.data) {
    context.data[field] = new Date();
  }
  return context;
};

const addUserId = (context) => {
  if (context.data && context.params.user) {
    context.data.userId = context.params.user.id;
  }
  return context;
};

module.exports = {
  requireAuth,
  addTimestamp,
  addUserId
};

// 在服务中使用
const { requireAuth, addTimestamp, addUserId } = require('../hooks/common');

app.service('todos').hooks({
  before: {
    all: [requireAuth],
    create: [addUserId, addTimestamp('createdAt')],
    update: [addTimestamp('updatedAt')],
    patch: [addTimestamp('updatedAt')]
  }
});

Channels(频道)- 实时事件的路由系统

什么是 Channel?

Channel 决定实时事件发送给哪些客户端。当 Service 的数据发生变化时,Feathers.js 会自动发送事件,Channel 负责路由这些事件。

基础 Channel 概念

javascript
// src/channels.js
module.exports = function(app) {
  if(typeof app.channel !== 'function') {
    // 如果没有实时功能,直接返回
    return;
  }

  app.on('connection', (connection) => {
    // 新连接建立时的处理
    console.log('新用户连接:', connection);
  });

  app.on('disconnect', (connection) => {
    // 连接断开时的处理
    console.log('用户断开连接:', connection);
  });

  // 发布事件到频道
  app.publish((data, hook) => {
    // 返回要发送事件的频道
    return app.channel('everybody');
  });
};

用户认证后的 Channel 管理

javascript
module.exports = function(app) {
  app.on('connection', (connection) => {
    // 未认证用户加入匿名频道
    app.channel('anonymous').join(connection);
  });

  app.on('login', (authResult, { connection }) => {
    const { user } = authResult;

    if (connection) {
      // 认证成功后,从匿名频道移除,加入认证频道
      app.channel('anonymous').leave(connection);
      app.channel('authenticated').join(connection);
      
      // 加入用户专属频道
      app.channel(`user-\${user.id}`).join(connection);
      
      // 根据用户角色加入不同频道
      if (user.role === 'admin') {
        app.channel('admins').join(connection);
      }
    }
  });

  app.on('disconnect', (connection) => {
    // 连接断开时自动从所有频道移除
  });
};

服务级别的事件发布

javascript
// 为不同服务配置不同的发布策略
module.exports = function(app) {
  // ... 连接管理代码

  // 全局发布策略
  app.publish((data, hook) => {
    const { service, method } = hook;

    // 根据服务和方法决定发布策略
    switch (service) {
      case 'todos':
        return publishTodoEvents(data, hook, app);
      case 'messages':
        return publishMessageEvents(data, hook, app);
      default:
        return app.channel('authenticated');
    }
  });

  // Todo 事件发布策略
  function publishTodoEvents(data, hook, app) {
    const { method, params } = hook;

    switch (method) {
      case 'created':
      case 'updated':
      case 'patched':
      case 'removed':
        // 只发送给 Todo 的所有者
        return app.channel(`user-\${data.userId}`);
      default:
        return [];
    }
  }

  // 消息事件发布策略
  function publishMessageEvents(data, hook, app) {
    const { method } = hook;

    switch (method) {
      case 'created':
        // 新消息发送给所有在线用户
        return app.channel('authenticated');
      case 'removed':
        // 删除消息只通知管理员
        return app.channel('admins');
      default:
        return app.channel('authenticated');
    }
  }
};

高级 Channel 功能

1. 房间系统

javascript
// 聊天室频道管理
app.on('login', (authResult, { connection }) => {
  const { user } = authResult;
  
  if (connection) {
    app.channel('authenticated').join(connection);
    
    // 用户可能在多个聊天室
    if (user.rooms) {
      user.rooms.forEach(roomId => {
        app.channel(`room-\${roomId}`).join(connection);
      });
    }
  }
});

// 加入/离开房间的服务方法
app.use('/room-actions', {
  async create(data, params) {
    const { action, roomId } = data;
    const { connection, user } = params;

    if (action === 'join') {
      app.channel(`room-\${roomId}`).join(connection);
      
      // 通知房间内其他用户
      app.channel(`room-\${roomId}`).send({
        type: 'user-joined',
        user: user,
        roomId: roomId
      });
    } else if (action === 'leave') {
      app.channel(`room-\${roomId}`).leave(connection);
      
      // 通知房间内其他用户
      app.channel(`room-\${roomId}`).send({
        type: 'user-left',
        user: user,
        roomId: roomId
      });
    }

    return { success: true };
  }
});

// 消息发布到特定房间
app.service('messages').publish('created', (data, hook) => {
  return app.channel(`room-\${data.roomId}`);
});

2. 条件发布

javascript
app.service('todos').publish('created', (data, hook) => {
  // 根据 Todo 的可见性决定发布范围
  if (data.isPublic) {
    return app.channel('authenticated');
  } else {
    return app.channel(`user-\${data.userId}`);
  }
});

app.service('notifications').publish('created', (data, hook) => {
  // 根据通知类型发布到不同频道
  switch (data.type) {
    case 'system':
      return app.channel('authenticated');
    case 'admin':
      return app.channel('admins');
    case 'personal':
      return app.channel(`user-\${data.userId}`);
    default:
      return [];
  }
});

3. 数据过滤

javascript
app.service('users').publish('patched', (data, hook) => {
  // 更新用户信息时,过滤敏感数据
  const safeData = {
    id: data.id,
    username: data.username,
    avatar: data.avatar,
    lastSeen: data.lastSeen
  };

  return app.channel('authenticated').send(safeData);
});

三大概念的协作

让我们看一个完整的例子,展示 Services、Hooks 和 Channels 如何协作:

javascript
// 聊天消息服务
class MessagesService {
  async create(data, params) {
    const message = {
      id: generateId(),
      text: data.text,
      userId: params.user.id,
      roomId: data.roomId,
      createdAt: new Date()
    };

    // 保存到数据库
    await this.Model.create(message);
    
    return message;
  }
}

// Hooks 配置
app.service('messages').hooks({
  before: {
    create: [
      authenticate('jwt'),
      // 验证用户是否在房间中
      async (context) => {
        const { roomId } = context.data;
        const { user } = context.params;
        
        const room = await context.app.service('rooms').get(roomId);
        if (!room.members.includes(user.id)) {
          throw new Error('您不在此房间中');
        }
        
        return context;
      },
      // 内容过滤
      (context) => {
        context.data.text = filterBadWords(context.data.text);
        return context;
      }
    ]
  },
  after: {
    create: [
      // 更新房间最后消息时间
      async (context) => {
        await context.app.service('rooms').patch(context.result.roomId, {
          lastMessageAt: new Date()
        });
        return context;
      }
    ]
  }
});

// Channels 配置
app.service('messages').publish('created', (data, hook) => {
  // 消息只发送给房间内的用户
  return app.channel(`room-\${data.roomId}`);
});

总结

通过深入学习 Services、Hooks 和 Channels,我们可以看到 Feathers.js 的设计哲学:

Services 提供了统一的数据操作接口,让我们可以:

  • 抽象不同的数据源
  • 保持 API 的一致性
  • 简化客户端开发

Hooks 提供了强大的业务逻辑组织方式,让我们可以:

  • 分离横切关注点
  • 复用通用逻辑
  • 保持代码的清洁

Channels 提供了灵活的实时事件路由,让我们可以:

  • 精确控制事件发送
  • 实现复杂的实时交互
  • 优化网络性能

这三个概念相互配合,形成了一个强大而优雅的架构,让我们能够快速构建复杂的实时应用。

下一篇文章,我们将通过构建一个待办事项应用,把这些概念应用到实际项目中。


相关文章推荐:

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