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。
相关文章推荐:
有问题欢迎留言讨论,我会及时回复大家!