Skip to content

Feathers.js 钩子系统深度解析 - 业务逻辑的最佳实践

发布时间:2024-05-24
作者:一介布衣
标签:Feathers.js, Hooks, 业务逻辑, 中间件

前言

上一篇文章我们深入学习了各种 Service 的实现方式,今天咱们来重点学习 Feathers.js 的钩子(Hooks)系统。说实话,钩子系统是 Feathers.js 最精妙的设计之一,它让业务逻辑的组织变得非常优雅。

我记得刚开始用 Express 的时候,总是把各种逻辑混在一起:验证、权限检查、数据转换、日志记录等等,代码很快就变得一团糟。后来接触到 Feathers.js 的钩子系统,才发现原来可以把这些横切关注点分离得这么干净。

今天我就带大家深入学习钩子系统的各种用法,从基础概念到高级技巧,让你的业务逻辑组织得井井有条。

钩子系统的设计哲学

什么是钩子?

钩子(Hook)是在 Service 方法执行前后自动运行的函数,它们可以:

  • 修改请求参数
  • 验证数据
  • 检查权限
  • 转换数据格式
  • 记录日志
  • 处理错误

钩子的执行流程

javascript
// 钩子的执行顺序
Service Method Call

Before Hooks (all)

Before Hooks (method-specific)

Service Method Execution

After Hooks (method-specific)

After Hooks (all)

Return Result

// 如果出现错误
Error Occurs

Error Hooks (method-specific)

Error Hooks (all)

Throw Error

钩子的类型

javascript
app.service('users').hooks({
  before: {
    all: [],      // 所有方法执行前
    find: [],     // find 方法执行前
    get: [],      // get 方法执行前
    create: [],   // create 方法执行前
    update: [],   // update 方法执行前
    patch: [],    // patch 方法执行前
    remove: []    // remove 方法执行前
  },
  after: {
    all: [],      // 所有方法执行后
    find: [],     // find 方法执行后
    // ... 其他方法
  },
  error: {
    all: [],      // 所有方法出错时
    create: []    // create 方法出错时
    // ... 其他方法
  }
});

钩子 Context 对象详解

Context 对象结构

每个钩子都会接收一个 context 对象,包含了请求的所有信息:

javascript
const exampleHook = (context) => {
  console.log('钩子类型:', context.type);        // 'before', 'after', 'error'
  console.log('方法名:', context.method);         // 'find', 'get', 'create', etc.
  console.log('服务路径:', context.path);         // '/users'
  console.log('服务实例:', context.service);      // Service 实例
  console.log('应用实例:', context.app);          // Feathers 应用实例
  
  // 请求相关
  console.log('请求参数:', context.params);       // 包含 query, user, provider 等
  console.log('请求数据:', context.data);         // create/update/patch 的数据
  console.log('记录ID:', context.id);            // get/update/patch/remove 的 ID
  
  // 响应相关(after hooks)
  console.log('响应结果:', context.result);       // 方法执行结果
  
  // 错误相关(error hooks)
  console.log('错误信息:', context.error);        // 错误对象
  
  return context;
};

Context 对象的修改

javascript
// 修改请求参数
const addUserIdToQuery = (context) => {
  if (context.params.user) {
    context.params.query.userId = context.params.user.id;
  }
  return context;
};

// 修改请求数据
const addTimestamp = (context) => {
  if (context.data) {
    context.data.updatedAt = new Date();
  }
  return context;
};

// 修改响应结果
const hidePassword = (context) => {
  if (context.result) {
    if (Array.isArray(context.result.data)) {
      context.result.data.forEach(user => delete user.password);
    } else {
      delete context.result.password;
    }
  }
  return context;
};

常用钩子实现

1. 认证和授权钩子

javascript
// 基础认证钩子
const { authenticate } = require('@feathersjs/authentication').hooks;

// 自定义认证钩子
const requireAuth = () => {
  return async (context) => {
    if (!context.params.user) {
      throw new Error('用户未登录');
    }
    return context;
  };
};

// 角色权限检查
const requireRole = (roles) => {
  return async (context) => {
    const { user } = context.params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    const userRoles = Array.isArray(user.roles) ? user.roles : [user.role];
    const requiredRoles = Array.isArray(roles) ? roles : [roles];
    
    const hasPermission = requiredRoles.some(role => userRoles.includes(role));
    
    if (!hasPermission) {
      throw new Error(`需要以下角色之一: ${requiredRoles.join(', ')}`);
    }
    
    return context;
  };
};

// 资源所有者检查
const restrictToOwner = (ownerField = 'userId') => {
  return async (context) => {
    const { user } = context.params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    // 管理员可以访问所有资源
    if (user.role === 'admin') {
      return context;
    }
    
    if (context.method === 'find') {
      // 查询时限制为用户自己的数据
      context.params.query[ownerField] = user.id;
    } else if (context.id) {
      // 获取、更新、删除时检查所有权
      const resource = await context.service.get(context.id, {
        ...context.params,
        query: {}
      });
      
      if (resource[ownerField] !== user.id) {
        throw new Error('无权限访问此资源');
      }
    }
    
    return context;
  };
};

// 使用示例
app.service('posts').hooks({
  before: {
    all: [authenticate('jwt')],
    find: [restrictToOwner()],
    get: [restrictToOwner()],
    create: [requireRole(['user', 'admin'])],
    update: [restrictToOwner()],
    patch: [restrictToOwner()],
    remove: [requireRole('admin')]
  }
});

2. 数据验证钩子

javascript
// 基础验证钩子
const validateData = (schema) => {
  return async (context) => {
    const { data } = context;
    
    if (!data) {
      throw new Error('请求数据不能为空');
    }
    
    // 使用 Joi 进行验证
    const Joi = require('joi');
    const { error, value } = schema.validate(data);
    
    if (error) {
      throw new Error(`数据验证失败: ${error.details[0].message}`);
    }
    
    // 使用验证后的数据
    context.data = value;
    return context;
  };
};

// 用户数据验证 schema
const userSchema = require('joi').object({
  username: require('joi').string().min(3).max(30).required(),
  email: require('joi').string().email().required(),
  password: require('joi').string().min(6).required(),
  firstName: require('joi').string().max(50),
  lastName: require('joi').string().max(50)
});

// 自定义验证钩子
const validateUser = () => {
  return async (context) => {
    const { data } = context;
    
    // 检查用户名唯一性
    if (data.username) {
      const existingUser = await context.app.service('users').find({
        query: { 
          username: data.username,
          $limit: 1
        }
      });
      
      if (existingUser.total > 0 && existingUser.data[0].id !== context.id) {
        throw new Error('用户名已存在');
      }
    }
    
    // 检查邮箱唯一性
    if (data.email) {
      const existingUser = await context.app.service('users').find({
        query: { 
          email: data.email,
          $limit: 1
        }
      });
      
      if (existingUser.total > 0 && existingUser.data[0].id !== context.id) {
        throw new Error('邮箱已被注册');
      }
    }
    
    return context;
  };
};

// 条件验证
const validateIf = (condition, validator) => {
  return async (context) => {
    if (condition(context)) {
      return await validator(context);
    }
    return context;
  };
};

// 使用示例
app.service('users').hooks({
  before: {
    create: [
      validateData(userSchema),
      validateUser()
    ],
    update: [
      validateData(userSchema),
      validateUser()
    ],
    patch: [
      validateIf(
        (context) => context.data.username || context.data.email,
        validateUser()
      )
    ]
  }
});

3. 数据转换钩子

javascript
// 数据清理钩子
const sanitizeData = (fields) => {
  return (context) => {
    if (!context.data) return context;
    
    fields.forEach(field => {
      if (context.data[field] && typeof context.data[field] === 'string') {
        context.data[field] = context.data[field].trim();
      }
    });
    
    return context;
  };
};

// 数据格式化钩子
const formatData = () => {
  return (context) => {
    if (!context.data) return context;
    
    // 格式化邮箱为小写
    if (context.data.email) {
      context.data.email = context.data.email.toLowerCase();
    }
    
    // 格式化电话号码
    if (context.data.phone) {
      context.data.phone = context.data.phone.replace(/\D/g, '');
    }
    
    // 格式化标签
    if (context.data.tags) {
      context.data.tags = context.data.tags
        .map(tag => tag.trim().toLowerCase())
        .filter(tag => tag.length > 0);
    }
    
    return context;
  };
};

// 添加默认值
const addDefaults = (defaults) => {
  return (context) => {
    if (!context.data) return context;
    
    Object.keys(defaults).forEach(key => {
      if (context.data[key] === undefined) {
        context.data[key] = typeof defaults[key] === 'function' 
          ? defaults[key](context) 
          : defaults[key];
      }
    });
    
    return context;
  };
};

// 数据关联
const populateData = (associations) => {
  return async (context) => {
    if (!context.result) return context;
    
    const populate = async (item) => {
      for (const assoc of associations) {
        if (item[assoc.foreignKey]) {
          try {
            item[assoc.as] = await context.app.service(assoc.service).get(
              item[assoc.foreignKey],
              { query: assoc.select ? { $select: assoc.select } : {} }
            );
          } catch (error) {
            console.error(`关联数据加载失败: ${assoc.service}`, error);
            item[assoc.as] = null;
          }
        }
      }
    };
    
    if (Array.isArray(context.result.data)) {
      await Promise.all(context.result.data.map(populate));
    } else {
      await populate(context.result);
    }
    
    return context;
  };
};

// 使用示例
app.service('posts').hooks({
  before: {
    create: [
      sanitizeData(['title', 'content']),
      formatData(),
      addDefaults({
        status: 'draft',
        createdAt: () => new Date(),
        userId: (context) => context.params.user.id
      })
    ]
  },
  after: {
    find: [
      populateData([
        { service: 'users', foreignKey: 'userId', as: 'author', select: ['id', 'username', 'avatar'] },
        { service: 'categories', foreignKey: 'categoryId', as: 'category' }
      ])
    ],
    get: [
      populateData([
        { service: 'users', foreignKey: 'userId', as: 'author', select: ['id', 'username', 'avatar'] },
        { service: 'categories', foreignKey: 'categoryId', as: 'category' }
      ])
    ]
  }
});

4. 缓存钩子

javascript
// 缓存钩子
const cache = (options = {}) => {
  const {
    ttl = 300,  // 5分钟
    keyGenerator = (context) => `${context.path}:${JSON.stringify(context.params.query)}`,
    condition = () => true
  } = options;
  
  return {
    before: async (context) => {
      // 只缓存 find 和 get 方法
      if (!['find', 'get'].includes(context.method)) {
        return context;
      }
      
      if (!condition(context)) {
        return context;
      }
      
      const cacheKey = keyGenerator(context);
      
      try {
        const cached = await context.app.service('cache').get(cacheKey);
        if (cached && cached.value) {
          context.result = cached.value;
          context.params.fromCache = true;
        }
      } catch (error) {
        // 缓存未命中,继续执行
      }
      
      return context;
    },
    
    after: async (context) => {
      if (context.params.fromCache) {
        return context;
      }
      
      if (!['find', 'get'].includes(context.method)) {
        return context;
      }
      
      if (!condition(context)) {
        return context;
      }
      
      const cacheKey = keyGenerator(context);
      
      try {
        await context.app.service('cache').create({
          id: cacheKey,
          value: context.result,
          ttl
        });
      } catch (error) {
        console.error('缓存保存失败:', error);
      }
      
      return context;
    }
  };
};

// 缓存失效钩子
const invalidateCache = (patterns) => {
  return async (context) => {
    try {
      for (const pattern of patterns) {
        const keys = await context.app.service('cache').find({
          query: { pattern: typeof pattern === 'function' ? pattern(context) : pattern }
        });
        
        for (const key of keys.data) {
          await context.app.service('cache').remove(key.id);
        }
      }
    } catch (error) {
      console.error('缓存失效失败:', error);
    }
    
    return context;
  };
};

// 使用示例
const postCache = cache({
  ttl: 600,  // 10分钟
  keyGenerator: (context) => {
    if (context.method === 'find') {
      return `posts:list:${JSON.stringify(context.params.query)}`;
    } else {
      return `posts:item:${context.id}`;
    }
  },
  condition: (context) => {
    // 只缓存已发布的文章
    return !context.params.query || context.params.query.status !== 'draft';
  }
});

app.service('posts').hooks({
  before: {
    find: [postCache.before],
    get: [postCache.before]
  },
  after: {
    find: [postCache.after],
    get: [postCache.after],
    create: [invalidateCache(['posts:list:*'])],
    update: [invalidateCache(['posts:list:*', (context) => `posts:item:${context.id}`])],
    patch: [invalidateCache(['posts:list:*', (context) => `posts:item:${context.id}`])],
    remove: [invalidateCache(['posts:list:*', (context) => `posts:item:${context.id}`])]
  }
});

5. 日志和监控钩子

javascript
// 请求日志钩子
const logRequest = (options = {}) => {
  const { 
    level = 'info',
    includeData = false,
    includeResult = false,
    excludeMethods = []
  } = options;
  
  return {
    before: (context) => {
      if (excludeMethods.includes(context.method)) {
        return context;
      }
      
      const logData = {
        timestamp: new Date().toISOString(),
        method: context.method,
        path: context.path,
        user: context.params.user?.id,
        ip: context.params.ip,
        userAgent: context.params.headers?.['user-agent']
      };
      
      if (includeData && context.data) {
        logData.data = context.data;
      }
      
      if (context.params.query && Object.keys(context.params.query).length > 0) {
        logData.query = context.params.query;
      }
      
      console.log(`[${level.toUpperCase()}] Request:`, logData);
      
      // 记录开始时间
      context.params.startTime = Date.now();
      
      return context;
    },
    
    after: (context) => {
      if (excludeMethods.includes(context.method)) {
        return context;
      }
      
      const duration = Date.now() - context.params.startTime;
      
      const logData = {
        timestamp: new Date().toISOString(),
        method: context.method,
        path: context.path,
        duration: `${duration}ms`,
        user: context.params.user?.id
      };
      
      if (includeResult && context.result) {
        logData.result = context.result;
      }
      
      console.log(`[${level.toUpperCase()}] Response:`, logData);
      
      return context;
    },
    
    error: (context) => {
      if (excludeMethods.includes(context.method)) {
        return context;
      }
      
      const duration = Date.now() - (context.params.startTime || Date.now());
      
      const logData = {
        timestamp: new Date().toISOString(),
        method: context.method,
        path: context.path,
        duration: `${duration}ms`,
        user: context.params.user?.id,
        error: {
          message: context.error.message,
          stack: context.error.stack
        }
      };
      
      console.error(`[ERROR] Request failed:`, logData);
      
      return context;
    }
  };
};

// 性能监控钩子
const performanceMonitor = (thresholds = {}) => {
  const {
    slowQueryThreshold = 1000,  // 1秒
    memoryThreshold = 100 * 1024 * 1024  // 100MB
  } = thresholds;
  
  return {
    before: (context) => {
      context.params.startTime = Date.now();
      context.params.startMemory = process.memoryUsage();
      return context;
    },
    
    after: (context) => {
      const duration = Date.now() - context.params.startTime;
      const endMemory = process.memoryUsage();
      const memoryDiff = endMemory.heapUsed - context.params.startMemory.heapUsed;
      
      // 慢查询警告
      if (duration > slowQueryThreshold) {
        console.warn(`慢查询警告: ${context.method} ${context.path} 耗时 ${duration}ms`);
      }
      
      // 内存使用警告
      if (memoryDiff > memoryThreshold) {
        console.warn(`内存使用警告: ${context.method} ${context.path} 使用了 ${Math.round(memoryDiff / 1024 / 1024)}MB 内存`);
      }
      
      // 记录性能指标
      context.app.emit('performance', {
        method: context.method,
        path: context.path,
        duration,
        memoryUsage: memoryDiff,
        timestamp: new Date()
      });
      
      return context;
    }
  };
};

// 使用示例
const requestLogger = logRequest({
  level: 'info',
  includeData: process.env.NODE_ENV === 'development',
  excludeMethods: ['find']  // 排除频繁的查询操作
});

const perfMonitor = performanceMonitor({
  slowQueryThreshold: 500,
  memoryThreshold: 50 * 1024 * 1024
});

app.service('posts').hooks({
  before: {
    all: [requestLogger.before, perfMonitor.before]
  },
  after: {
    all: [requestLogger.after, perfMonitor.after]
  },
  error: {
    all: [requestLogger.error]
  }
});

钩子的组合和复用

1. 钩子工厂

javascript
// hooks/factories.js
const createOwnershipHook = (ownerField = 'userId', adminRoles = ['admin']) => {
  return (context) => {
    const { user } = context.params;
    
    if (!user) {
      throw new Error('用户未登录');
    }
    
    // 管理员可以访问所有资源
    if (adminRoles.includes(user.role)) {
      return context;
    }
    
    if (context.method === 'find') {
      context.params.query[ownerField] = user.id;
    } else if (context.id) {
      // 在 before hook 中添加所有权检查
      context.params.query[ownerField] = user.id;
    }
    
    return context;
  };
};

const createValidationHook = (schema, options = {}) => {
  const { 
    allowPartial = false,
    stripUnknown = true 
  } = options;
  
  return (context) => {
    if (!context.data) return context;
    
    const validationOptions = {
      stripUnknown,
      allowUnknown: !stripUnknown
    };
    
    if (allowPartial && context.method === 'patch') {
      validationOptions.presence = 'optional';
    }
    
    const { error, value } = schema.validate(context.data, validationOptions);
    
    if (error) {
      throw new Error(`数据验证失败: ${error.details[0].message}`);
    }
    
    context.data = value;
    return context;
  };
};

// 使用工厂创建钩子
const userOwnership = createOwnershipHook('userId', ['admin', 'moderator']);
const postValidation = createValidationHook(postSchema, { allowPartial: true });

2. 钩子组合

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

// 通用钩子组合
const requireAuthAndOwnership = (ownerField = 'userId') => [
  authenticate('jwt'),
  createOwnershipHook(ownerField)
];

const validateAndSanitize = (schema, sanitizeFields = []) => [
  sanitizeData(sanitizeFields),
  createValidationHook(schema)
];

const cacheAndLog = (cacheOptions = {}) => {
  const cacheHook = cache(cacheOptions);
  const logHook = logRequest();
  
  return {
    before: [logHook.before, cacheHook.before],
    after: [cacheHook.after, logHook.after],
    error: [logHook.error]
  };
};

// 在服务中使用组合钩子
app.service('posts').hooks({
  before: {
    all: requireAuthAndOwnership(),
    create: validateAndSanitize(postSchema, ['title', 'content']),
    update: validateAndSanitize(postSchema, ['title', 'content']),
    patch: validateAndSanitize(postSchema.fork(['title', 'content'], (schema) => schema.optional()), ['title', 'content'])
  },
  ...cacheAndLog({ ttl: 600 })
});

3. 条件钩子

javascript
// 条件执行钩子
const when = (condition, hook) => {
  return (context) => {
    if (condition(context)) {
      return hook(context);
    }
    return context;
  };
};

const unless = (condition, hook) => {
  return when((context) => !condition(context), hook);
};

// 使用示例
app.service('posts').hooks({
  before: {
    all: [
      when(
        (context) => context.params.provider,  // 只对外部请求执行
        authenticate('jwt')
      )
    ],
    create: [
      unless(
        (context) => context.params.user?.role === 'admin',  // 管理员跳过验证
        validateData(postSchema)
      )
    ]
  }
});

错误处理钩子

1. 统一错误处理

javascript
// 错误格式化钩子
const formatError = () => {
  return (context) => {
    const { error } = context;
    
    // 统一错误格式
    const formattedError = {
      name: error.name || 'Error',
      message: error.message,
      code: error.code || 500,
      timestamp: new Date().toISOString(),
      path: context.path,
      method: context.method
    };
    
    // 开发环境包含堆栈信息
    if (process.env.NODE_ENV === 'development') {
      formattedError.stack = error.stack;
    }
    
    // 记录错误日志
    console.error('Service Error:', formattedError);
    
    // 修改错误对象
    Object.assign(error, formattedError);
    
    return context;
  };
};

// 错误通知钩子
const notifyError = (options = {}) => {
  const { 
    notifyOn = ['create', 'update', 'patch', 'remove'],
    excludeErrors = ['ValidationError', 'NotAuthenticated']
  } = options;
  
  return async (context) => {
    const { error } = context;
    
    if (!notifyOn.includes(context.method)) {
      return context;
    }
    
    if (excludeErrors.includes(error.name)) {
      return context;
    }
    
    // 发送错误通知
    try {
      await context.app.service('notifications').create({
        type: 'error',
        title: `服务错误: ${context.path}`,
        message: error.message,
        metadata: {
          method: context.method,
          path: context.path,
          user: context.params.user?.id,
          timestamp: new Date()
        }
      });
    } catch (notifyError) {
      console.error('发送错误通知失败:', notifyError);
    }
    
    return context;
  };
};

// 使用错误处理钩子
app.service('posts').hooks({
  error: {
    all: [formatError(), notifyError()]
  }
});

总结

通过这篇文章,我们深入学习了 Feathers.js 钩子系统的各个方面:

钩子基础概念

  • 钩子的执行流程和类型
  • Context 对象的结构和使用
  • 钩子的设计哲学

常用钩子实现

  • 认证和授权钩子
  • 数据验证和转换钩子
  • 缓存和性能监控钩子
  • 日志记录钩子

高级钩子技巧

  • 钩子工厂和组合
  • 条件钩子执行
  • 错误处理钩子
  • 钩子的复用策略

钩子系统是 Feathers.js 的精髓,掌握了钩子系统,你就能够:

  • 优雅地组织业务逻辑
  • 实现横切关注点的分离
  • 构建可复用的功能模块
  • 保持代码的清洁和可维护性

下一篇文章,我们将学习 Feathers.js 的认证与授权系统,看看如何构建安全的 API。


相关文章推荐:

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