Resolver 函数签名
每个 GraphQL 字段都有一个对应的 Resolver 函数。Resolver 接受四个参数:
// (parent, args, context, info) => value | Promise<value>
type Resolver = (
parent: T, // 父字段解析的返回值
args: Args, // 字段的参数(如 user(id: "1") 中的 {id: "1"})
context: Context, // 跨 Resolver 共享的数据(DB连接、用户信息、DataLoader等)
info: GraphQLResolveInfo // 查询的元信息(字段名、路径、Schema等)
) => T | Promise<T>;
Resolver 链(Resolver Chain)
const resolvers = {
// 根查询 Resolver
Query: {
users: async (_, __, { db }) => {
return db.users.findMany();
// 返回 User 对象数组,parent = 每个 User 对象
},
},
// User 类型的字段 Resolver
User: {
// parent 是上面返回的 User 对象
fullName: (parent) => `${parent.firstName} ${parent.lastName}`,
// 关联字段:从 parent.id 加载帖子
posts: async (parent, { limit = 10 }, { db }) => {
return db.posts.findMany({
where: { authorId: parent.id },
take: limit,
});
},
},
};
N+1 问题根因
假设查询 10 个用户的帖子:
query {
users { # 查询1次:SELECT * FROM users (返回10条)
name
posts { # 查询10次:SELECT * FROM posts WHERE authorId = ?
title
}
}
}
这会触发 1 + 10 = 11 次数据库查询——这就是 N+1 问题。当用户数量增大时,数据库查询会线性增长。
DataLoader:批处理解决方案
DataLoader 工作原理
DataLoader 使用批处理(batching)和缓存(caching)两个机制:
- 批处理:在同一个事件循环 tick 内收集所有的 key 请求,然后用一次批量查询获取所有数据
- 缓存:在同一个请求中,相同 key 只查询一次,后续使用缓存结果
import DataLoader from 'dataloader';
// 创建 DataLoader:批处理函数接受 keys 数组,返回对应 values 数组
function createUserLoader(db: PrismaClient) {
return new DataLoader<string, User>(
async (userIds: ReadonlyArray) => {
// 批量查询:1次查询获取所有用户
const users = await db.users.findMany({
where: { id: { in: [...userIds] } },
});
// 按 key 顺序返回(DataLoader 要求结果顺序与 keys 顺序一致)
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
}
);
}
在 Context 中注入 DataLoader
// Context 创建(每个请求一个新的 DataLoader 实例)
const server = new ApolloServer({ typeDefs, resolvers });
await startStandaloneServer(server, {
context: async ({ req }) => ({
db,
user: await getUserFromToken(req.headers.authorization),
// 每个请求创建新的 DataLoader(避免请求间缓存泄漏)
loaders: {
user: createUserLoader(db),
posts: createPostsByAuthorLoader(db),
},
}),
});
使用 DataLoader
const resolvers = {
Post: {
// 不再直接查数据库,而是使用 DataLoader
author: (parent, _, { loaders }) => {
return loaders.user.load(parent.authorId);
// DataLoader 会在同一 tick 收集所有 authorId,批量查询
},
},
};
// 效果:原来 N+1 次查询,现在变成 2 次查询!
// 查询1:SELECT * FROM posts WHERE ...(获取帖子)
// 查询2:SELECT * FROM users WHERE id IN ('u1','u2','u3',...)(批量获取作者)
按关联字段批量加载
// 按作者 ID 批量加载帖子(一对多关系)
function createPostsByAuthorLoader(db: PrismaClient) {
return new DataLoader<string, Post[]>(
async (authorIds) => {
const posts = await db.posts.findMany({
where: { authorId: { in: [...authorIds] } },
});
// 按 authorId 分组
const grouped = new Map<string, Post[]>();
for (const post of posts) {
const arr = grouped.get(post.authorId) ?? [];
arr.push(post);
grouped.set(post.authorId, arr);
}
return authorIds.map(id => grouped.get(id) ?? []);
}
);
}
DataLoader 的缓存范围
DataLoader 的缓存是请求级别的——在同一个 GraphQL 请求中,相同 key 只查询一次。请求结束后,整个 DataLoader 实例(包括缓存)被丢弃。这就是为什么要在 context 函数中每次请求创建新的 DataLoader,而不是全局单例。
Resolver 执行机制详解
默认字段 Resolver
如果不为某个字段提供 Resolver,GraphQL 会使用默认 Resolver:直接返回
parent[fieldName]。例如 User 类型的 name 字段,如果父对象有 name 属性,则无需显式写 Resolver。Resolver 链执行顺序
GraphQL 引擎自顶向下执行:先执行根 Query Resolver 获取父对象,再并行执行所有子字段的 Resolver(同级字段并发,不同层级顺序执行)。这意味着同一层级的多个 Resolver 可能同时触发 DataLoader 批处理。
info 参数的用途
第四个参数
info: GraphQLResolveInfo 包含查询 AST(fieldNodes)、路径(path)、返回类型(returnType)等元信息。可用于字段级日志、按需加载(只有当客户端请求了某字段才触发查询)、复杂度计算等高级场景。异步 Resolver 与错误处理
Resolver 可以返回 Promise,GraphQL 会自动等待解析完成。若 Resolver 抛出错误,该字段在响应中值为 null,错误信息出现在顶层 errors 数组中——GraphQL 的部分成功设计让一个字段失败不影响整个请求。
按需查询优化(info 参数应用)
import { GraphQLResolveInfo } from 'graphql';
// 工具函数:检查客户端是否请求了某个字段
function hasField(info: GraphQLResolveInfo, fieldName: string): boolean {
return info.fieldNodes[0].selectionSet?.selections.some(
(s) => s.kind === 'Field' && s.name.value === fieldName
) ?? false;
}
const resolvers = {
Query: {
user: async (_, { id }, { db }, info) => {
// 只有当客户端请求了 posts 字段时,才 include posts
// 避免无谓的 JOIN 查询
const includePosts = hasField(info, 'posts');
return db.users.findUnique({
where: { id },
include: { posts: includePosts },
});
},
},
};
DataLoader 进阶:缓存失效与强制刷新
// DataLoader 支持手动清除缓存,适合 Mutation 后刷新数据
const resolvers = {
Mutation: {
updateUser: async (_, { id, data }, { db, loaders }) => {
const updated = await db.users.update({ where: { id }, data });
// 清除该用户的缓存,后续 load 会重新查询
loaders.user.clear(id);
// 或者主动填充新数据到缓存,避免额外查询
loaders.user.prime(id, updated);
return updated;
},
},
};
DataLoader 常见误区
- 不要全局共享 DataLoader:每个 HTTP 请求必须创建新的 DataLoader 实例,否则缓存会在用户之间泄漏(安全问题!)
- 批处理函数的返回顺序:DataLoader 要求批处理函数返回的数组长度和顺序必须与输入 keys 完全匹配,否则会造成数据错位 bug
- 错误处理:对于不存在的 key,应返回
new Error('Not found')而不是 null,这样 DataLoader 能正确地将错误传递给对应的load()调用
本章小结
本章核心要点
- Resolver 四参数:parent(父字段值)、args(查询参数)、context(请求共享数据:db/user/loaders)、info(查询元信息);大多数业务逻辑只用前三个。
- N+1 根因:GraphQL 字段 Resolver 独立执行,关联字段(如 Post.author)会为每个父对象单独查询,N 条记录触发 N 次查询。解决方案是 DataLoader。
- DataLoader 核心机制:收集同一 tick 内所有
load(key)调用,合并为一次批量查询(WHERE id IN (...));加上请求级缓存,相同 key 只查一次。 - DataLoader 实例必须每请求创建:在 context 函数中 new DataLoader(),而非全局单例——缓存跨请求共享会导致数据泄漏。
- 一对多批量加载:批处理函数中按关联字段分组(Map),返回与 keys 数组等长且顺序对应的结果数组,这是 DataLoader 协议要求。
- 部分成功设计:Resolver 抛出错误只影响对应字段(设为 null),不影响同级其他字段,错误信息聚合到顶层 errors 数组中返回。