名称: graphql-resolvers 用户可调用: false 描述: 在实现GraphQL解析器时使用,包括解析器函数、上下文管理、DataLoader批处理、错误处理、认证和测试策略。 允许工具: []
GraphQL解析器
应用解析器实现模式来创建高效、可维护的GraphQL服务器。此技能涵盖解析器函数签名、执行链、上下文管理、DataLoader模式、异步处理、认证和测试策略。
解析器函数签名
每个解析器函数接收四个参数:父级、参数、上下文和信息。理解这些参数是编写有效解析器的基础。
type ResolverFn = (
parent: any,
args: any,
context: any,
info: GraphQLResolveInfo
) => any;
const resolvers = {
Query: {
// parent: 根值(通常对查询是未定义)
// args: 传递给查询的参数
// context: 共享上下文对象
// info: 执行信息
user: async (parent, args, context, info) => {
const { id } = args;
const { dataSources, user } = context;
// 使用上下文访问数据源和认证信息
return dataSources.userAPI.getUserById(id);
},
posts: async (parent, args, context, info) => {
const { limit, offset } = args;
// 从info中访问请求的字段
const fields = info.fieldNodes[0].selectionSet.selections
.map(s => s.name.value);
return context.dataSources.postAPI.getPosts({
limit,
offset,
fields
});
}
}
};
字段解析器
字段解析器定义如何解析类型上的单个字段。父级参数包含解析后的父对象。
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUserById(id);
}
},
User: {
// 计算字段的字段解析器
fullName: (parent) => {
return `${parent.firstName} ${parent.lastName}`;
},
// 相关数据的字段解析器
posts: async (parent, args, { dataSources }) => {
// parent.id 来自父User对象
return dataSources.postAPI.getPostsByAuthor(parent.id);
},
// 带参数的字段解析器
friends: async (parent, { limit }, { dataSources }) => {
return dataSources.userAPI.getFriends(parent.id, limit);
},
// 异步计算字段
postCount: async (parent, _, { dataSources }) => {
return dataSources.postAPI.countByAuthor(parent.id);
}
},
Post: {
author: async (parent, _, { dataSources }) => {
// parent.authorId 来自父Post对象
return dataSources.userAPI.getUserById(parent.authorId);
},
comments: async (parent, _, { dataSources }) => {
return dataSources.commentAPI.getByPostId(parent.id);
}
}
};
上下文对象模式
上下文对象在单个请求中所有解析器之间共享。用于认证、数据源和请求范围的数据。
interface Context {
user: User | null;
dataSources: DataSources;
db: Database;
req: Request;
loaders: Loaders;
}
// 上下文创建函数
const createContext = async ({ req }): Promise<Context> => {
// 提取并验证认证令牌
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
// 初始化数据源
const dataSources = {
userAPI: new UserAPI(),
postAPI: new PostAPI(),
commentAPI: new CommentAPI()
};
// 初始化DataLoaders
const loaders = {
userLoader: new DataLoader(ids => batchGetUsers(ids)),
postLoader: new DataLoader(ids => batchGetPosts(ids))
};
return {
user,
dataSources,
db: database,
req,
loaders
};
};
// 在解析器中使用上下文
const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) {
throw new Error('未认证');
}
return user;
},
post: async (_, { id }, { loaders }) => {
return loaders.postLoader.load(id);
}
}
};
解析器链和执行
解析器以链式执行,父解析器在子解析器开始前完成。理解执行顺序对于优化至关重要。
const resolvers = {
Query: {
// 步骤1: 根解析器执行
user: async (_, { id }, { db }) => {
console.log('1. 获取用户');
return db.users.findById(id);
}
},
User: {
// 步骤2: 字段解析器使用父数据执行
posts: async (parent, _, { db }) => {
console.log('2. 为用户获取帖子', parent.id);
return db.posts.findByAuthor(parent.id);
},
profile: async (parent, _, { db }) => {
console.log('2. 为用户获取个人资料', parent.id);
return db.profiles.findByUserId(parent.id);
}
},
Post: {
// 步骤3: 嵌套字段解析器执行
comments: async (parent, _, { db }) => {
console.log('3. 为帖子获取评论', parent.id);
return db.comments.findByPostId(parent.id);
}
}
};
// 查询执行顺序:
// query {
// user(id: "1") { # 1. 用户解析器
// posts { # 2. 帖子解析器
// comments { # 3. 评论解析器
// text
// }
// }
// profile { # 2. 个人资料解析器(并行)
// bio
// }
// }
// }
DataLoader模式用于批处理
DataLoader通过将多个单独加载合并为单个批处理请求并缓存结果来解决N+1问题。
import DataLoader from 'dataloader';
// 批处理函数接收键数组
// 必须以相同顺序返回结果数组
const batchGetUsers = async (userIds: string[]) => {
console.log('批处理加载用户:', userIds);
// 为所有ID进行单次数据库查询
const users = await db.users.findByIds(userIds);
// 为O(1)查找创建映射
const userMap = new Map(users.map(u => [u.id, u]));
// 以与输入ID相同的顺序返回用户
return userIds.map(id => userMap.get(id) || null);
};
// 在上下文中创建加载器
const userLoader = new DataLoader(batchGetUsers, {
// 可选配置
cache: true, // 缓存结果(默认: true)
maxBatchSize: 100, // 最大批处理大小
batchScheduleFn: cb => setTimeout(cb, 10) // 自定义调度
});
const resolvers = {
Post: {
author: async (parent, _, { loaders }) => {
// 这些调用会自动批处理
return loaders.userLoader.load(parent.authorId);
}
},
Comment: {
author: async (parent, _, { loaders }) => {
// 添加到与Post.author相同的批处理中
return loaders.userLoader.load(parent.authorId);
}
}
};
// 示例: 无DataLoader(N+1问题)
// 查询10个帖子 = 1个帖子查询 + 10个作者查询
//
// 有DataLoader:
// 查询10个帖子 = 1个帖子查询 + 1个批处理查询所有作者
高级DataLoader模式
// 复合键加载器
interface CompositeKey {
userId: string;
type: string;
}
const batchGetUserData = async (keys: CompositeKey[]) => {
// 按类型分组以高效查询
const byType = keys.reduce((acc, key) => {
acc[key.type] = acc[key.type] || [];
acc[key.type].push(key.userId);
return acc;
}, {});
// 按类型获取数据
const results = await Promise.all(
Object.entries(byType).map(([type, userIds]) =>
fetchDataByType(type, userIds)
)
);
// 映射回原始键顺序
return keys.map(key =>
results.find(r => r.userId === key.userId && r.type === key.type)
);
};
const dataLoader = new DataLoader(
batchGetUserData,
{
cacheKeyFn: (key: CompositeKey) => `${key.userId}:${key.type}`
}
);
// 预加载缓存
await dataLoader.prime({ userId: '1', type: 'profile' }, userData);
// 清除特定键
dataLoader.clear({ userId: '1', type: 'profile' });
// 清除所有缓存
dataLoader.clearAll();
异步错误处理
解析器中的适当错误处理确保有意义的错误传递到客户端,同时保护敏感信息。
import { GraphQLError } from 'graphql';
import { ApolloServerErrorCode } from '@apollo/server/errors';
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
try {
const user = await dataSources.userAPI.getUserById(id);
if (!user) {
throw new GraphQLError('用户未找到', {
extensions: {
code: 'USER_NOT_FOUND',
http: { status: 404 }
}
});
}
return user;
} catch (error) {
// 记录完整错误以调试
console.error('获取用户时出错:', error);
// 抛出安全错误给客户端
if (error instanceof GraphQLError) {
throw error;
}
throw new GraphQLError('获取用户失败', {
extensions: {
code: 'INTERNAL_SERVER_ERROR'
}
});
}
}
},
Mutation: {
createPost: async (_, { input }, { user, dataSources }) => {
// 验证错误
if (!input.title || input.title.length < 3) {
throw new GraphQLError('标题必须至少3个字符', {
extensions: {
code: 'BAD_USER_INPUT',
argumentName: 'title'
}
});
}
// 认证错误
if (!user) {
throw new GraphQLError('必须认证', {
extensions: {
code: ApolloServerErrorCode.UNAUTHENTICATED
}
});
}
try {
return await dataSources.postAPI.create(input);
} catch (error) {
throw new GraphQLError('创建帖子失败', {
extensions: {
code: 'INTERNAL_SERVER_ERROR'
},
originalError: error
});
}
}
}
};
认证和授权
在解析器和上下文中实现认证和授权模式。
// 认证中间件
const requireAuth = (resolver) => {
return (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('未认证', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
return resolver(parent, args, context, info);
};
};
// 授权中间件
const requireRole = (role: string) => (resolver) => {
return (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('未认证', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
if (!context.user.roles.includes(role)) {
throw new GraphQLError('权限不足', {
extensions: { code: 'FORBIDDEN' }
});
}
return resolver(parent, args, context, info);
};
};
const resolvers = {
Query: {
me: requireAuth((_, __, { user }) => user),
adminPanel: requireRole('ADMIN')(
async (_, __, { dataSources }) => {
return dataSources.adminAPI.getDashboard();
}
),
// 基于资源的授权
post: async (_, { id }, { user, dataSources }) => {
const post = await dataSources.postAPI.getById(id);
if (!post) {
throw new GraphQLError('帖子未找到');
}
// 检查用户是否可以查看此帖子
if (post.status === 'DRAFT' && post.authorId !== user?.id) {
throw new GraphQLError('无法查看草稿帖子', {
extensions: { code: 'FORBIDDEN' }
});
}
return post;
}
},
Mutation: {
updatePost: requireAuth(
async (_, { id, input }, { user, dataSources }) => {
const post = await dataSources.postAPI.getById(id);
// 检查所有权
if (post.authorId !== user.id && !user.roles.includes('ADMIN')) {
throw new GraphQLError('未授权更新此帖子', {
extensions: { code: 'FORBIDDEN' }
});
}
return dataSources.postAPI.update(id, input);
}
)
}
};
缓存策略
在解析器级别实现缓存以提高性能。
import { createHash } from 'crypto';
// 内存缓存
const cache = new Map<string, { data: any; expiry: number }>();
const getCacheKey = (prefix: string, args: any): string => {
const hash = createHash('md5')
.update(JSON.stringify(args))
.digest('hex');
return `${prefix}:${hash}`;
};
const cacheResolver = (
resolver,
{ ttl = 300, prefix = 'cache' } = {}
) => {
return async (parent, args, context, info) => {
const key = getCacheKey(prefix, args);
const cached = cache.get(key);
if (cached && cached.expiry > Date.now()) {
console.log('缓存命中:', key);
return cached.data;
}
const result = await resolver(parent, args, context, info);
cache.set(key, {
data: result,
expiry: Date.now() + (ttl * 1000)
});
return result;
};
};
const resolvers = {
Query: {
// 缓存5分钟
popularPosts: cacheResolver(
async (_, { limit }, { dataSources }) => {
return dataSources.postAPI.getPopular(limit);
},
{ ttl: 300, prefix: 'popular-posts' }
),
// Redis缓存
user: async (_, { id }, { redis, dataSources }) => {
const cacheKey = `user:${id}`;
// 先尝试缓存
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 获取并缓存
const user = await dataSources.userAPI.getUserById(id);
await redis.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
}
};
解析器中间件和插件
创建可重用的中间件模式以处理横切关注点。
// 日志中间件
const logResolver = (resolver) => {
return async (parent, args, context, info) => {
const start = Date.now();
const fieldName = info.fieldName;
try {
const result = await resolver(parent, args, context, info);
const duration = Date.now() - start;
console.log(`${fieldName} 解析在 ${duration}毫秒内`);
return result;
} catch (error) {
console.error(`${fieldName} 失败:`, error);
throw error;
}
};
};
// 计时中间件
const timeResolver = (resolver) => {
return async (parent, args, context, info) => {
const start = performance.now();
const result = await resolver(parent, args, context, info);
const duration = performance.now() - start;
// 添加计时到扩展
info.operation.extensions = info.operation.extensions || {};
info.operation.extensions.timing =
info.operation.extensions.timing || {};
info.operation.extensions.timing[info.fieldName] = duration;
return result;
};
};
// 组合中间件
const compose = (...middlewares) => (resolver) => {
return middlewares.reduceRight(
(acc, middleware) => middleware(acc),
resolver
);
};
const resolvers = {
Query: {
user: compose(
logResolver,
timeResolver,
requireAuth
)(async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUserById(id);
})
}
};
测试解析器
使用模拟上下文和数据源编写解析器的全面测试。
import { describe, it, expect, vi } from 'vitest';
describe('用户解析器', () => {
it('应通过ID获取用户', async () => {
const mockUser = { id: '1', username: 'test' };
const mockContext = {
dataSources: {
userAPI: {
getUserById: vi.fn().mockResolvedValue(mockUser)
}
}
};
const result = await resolvers.Query.user(
null,
{ id: '1' },
mockContext,
{} as any
);
expect(result).toEqual(mockUser);
expect(mockContext.dataSources.userAPI.getUserById)
.toHaveBeenCalledWith('1');
});
it('用户未找到时应抛出错误', async () => {
const mockContext = {
dataSources: {
userAPI: {
getUserById: vi.fn().mockResolvedValue(null)
}
}
};
await expect(
resolvers.Query.user(null, { id: '999' }, mockContext, {} as any)
).rejects.toThrow('用户未找到');
});
it('应要求认证', async () => {
const mockContext = {
user: null,
dataSources: {}
};
await expect(
resolvers.Query.me(null, {}, mockContext, {} as any)
).rejects.toThrow('未认证');
});
it('应使用DataLoader进行批处理', async () => {
const mockUsers = [
{ id: '1', username: 'user1' },
{ id: '2', username: 'user2' }
];
const batchFn = vi.fn().mockResolvedValue(mockUsers);
const loader = new DataLoader(batchFn);
const mockContext = {
loaders: { userLoader: loader }
};
// 进行多次调用
const [user1, user2] = await Promise.all([
resolvers.Post.author(
{ authorId: '1' },
{},
mockContext,
{} as any
),
resolvers.Post.author(
{ authorId: '2' },
{},
mockContext,
{} as any
)
]);
expect(user1).toEqual(mockUsers[0]);
expect(user2).toEqual(mockUsers[1]);
expect(batchFn).toHaveBeenCalledTimes(1);
expect(batchFn).toHaveBeenCalledWith(['1', '2']);
});
});
最佳实践
- 保持解析器薄: 将业务逻辑委托给服务层,仅使用解析器进行数据获取和转换
- 使用DataLoader: 为任何获取相关数据的解析器实现DataLoader以避免N+1查询
- 利用上下文: 将所有解析器的共享资源(数据库、认证、数据源)存储在上下文中
- 优雅处理错误: 捕获错误并抛出有意义的GraphQLError实例,带有适当的代码
- 实现适当认证: 在解析器或中间件中一致检查认证和授权
- 策略性缓存: 使用内存或分布式缓存在解析器级别缓存昂贵操作
- 使用类型化解析器: 为解析器函数定义TypeScript类型以在编译时捕获错误
- 全面测试: 为解析器编写单元测试,带有模拟依赖和边缘情况
- 避免阻塞操作: 尽可能使用async/await和并行执行以防止阻塞
- 监控性能: 记录解析器执行时间并识别慢速解析器进行优化
常见陷阱
- N+1查询: 在没有批处理的情况下循环中获取相关数据,导致过多数据库查询
- 阻塞操作: 在解析器中使用同步操作阻塞事件循环
- 内存泄漏: 在闭包或模块范围中存储无限制增长的数据
- 不一致错误处理: 抛出原始错误而没有适当的GraphQLError包装和代码
- 解析器中过度获取: 当只需要特定字段时获取整个对象
- 上下文突变: 在解析器执行期间修改上下文对象,导致副作用
- 缺失认证检查: 在敏感解析器中忘记验证认证
- 不当DataLoader使用: 为每个解析器创建新DataLoader实例而不是每个请求
- 循环解析器链: 创建导致无限循环的解析器依赖
- 未使用info参数: 忽略包含优化所需请求字段的info参数
何时使用此技能
在以下情况下使用GraphQL解析器技能:
- 实现新的GraphQL服务器
- 优化现有解析器性能
- 调试N+1查询问题
- 添加认证和授权
- 实现数据批处理和缓存
- 编写解析器单元测试
- 为更好维护性重构解析器
- 向解析器添加日志和监控
- 实现自定义中间件或插件
- 从REST API迁移到GraphQL
资源
- GraphQL解析器文档 - 官方执行和解析器指南
- DataLoader GitHub - 官方DataLoader库和文档
- Apollo Server解析器 - 解析器模式和示例
- GraphQL错误处理 - 错误处理最佳实践
- 测试GraphQL解析器 - 测试策略