当一段GraphQL查询文本到达服务器,它会经历四个阶段:解析、验证、执行、响应组装。理解这个流程对于调试性能问题、编写高效的解析器至关重要。
query { user(id: "1001") { name posts(limit: 3) { title author { name } } } }
阶段一:解析(Parsing)
服务器收到查询文本后,第一步是把它解析成抽象语法树(AST)。这一步只检查语法是否合法——括号是否匹配、字段名是否合法标识符——不关心字段是否存在于Schema中。
AST是一棵树状结构,根节点是 OperationDefinition,子节点对应每个字段,嵌套字段形成子树。上面的查询会被解析成类似这样的结构:
Document OperationDefinition (query) SelectionSet Field: user (args: {id: "1001"}) SelectionSet Field: name Field: posts (args: {limit: 3}) SelectionSet Field: title Field: author SelectionSet Field: name
阶段二:验证(Validation)
解析通过后,验证器对照Schema检查查询的语义合法性:
user 是否存在于 Query 类型id 的类型是否匹配(是 ID!)User 是否有 name 和 posts 字段posts 返回的 [Post] 是否有 title 和 author 字段验证失败时,服务器返回错误列表,不会执行查询。验证通过后进入执行阶段。
阶段三:执行(Execution)
执行引擎遍历AST,为每个字段调用对应的解析器函数。解析器是纯函数,接收四个参数:(parent, args, context, info)。
parent:父字段的解析器返回值。根字段的 parent 通常是 null 或 rootValueargs:查询中传递给该字段的参数context:跨解析器共享的上下文对象(通常包含数据库连接、认证信息、DataLoader实例等)info:包含当前字段元信息的对象(Schema信息、AST节点引用等)const resolvers = { Query: { user: async (parent, { id }, context) => { // 从数据库获取用户 return await context.db.users.findById(id); } }, User: { name: (parent) => parent.name, // 直接从parent取值 posts: async (parent, { limit }, context) => { // 用parent.id(从上一级解析器获得)查询帖子 return await context.db.posts.findByAuthor(parent.id, { limit }); }, }, Post: { title: (parent) => parent.title, author: async (parent, args, context) => { return await context.db.users.findById(parent.authorId); } } };
注意 parent 的传递链:user 解析器返回用户对象 → 这个对象成为 name 和 posts 解析器的 parent → posts 解析器返回帖子数组 → 每个帖子元素成为 title 和 author 解析器的 parent。
阶段四:响应组装
执行引擎把所有解析器的返回值按AST的结构组装成JSON对象。如果某个解析器返回 null,对应位置放 null;如果解析器抛出异常,该位置放 null 并把错误记录到响应的 errors 数组。
{ "data": { "user": { "name": "林远", "posts": [ { "title": "GraphQL深度剖析", "author": { "name": "林远" } } ] } }, "errors": [ { "message": "权限不足:无法访问部分帖子", "locations": [{ "line": 5, "column": 7 }], "path": ["user", "posts", 2, "title"] } ] }
关键点:GraphQL的默认错误处理是部分成功的。即使部分字段出错,其他字段的数据仍然会正常返回。这与REST"出错就返回500"的行为完全不同。
GraphQL引擎的一个特性是同级字段的解析器可以并发执行。在上面的例子中,name 和 posts 是 User 的同级字段,它们可以同时执行(互不依赖)。但 posts 内部的 title 和 author 是同级字段,也可以并发——前提是 author 不需要依赖 title 的结果。
如果 author 解析器需要读取数据库,title 解析器只是从 parent.title 取值,那么 title 几乎是即时完成的,不需要等 author。
如果你没有为一个字段定义解析器,GraphQL引擎会使用默认解析器:直接从 parent 对象上读取同名属性。比如上面的 name: (parent) => parent.name 实际上可以省略——引擎会自动做同样的事。
自定义解析器覆盖默认行为。常见的场景包括:
displayName vs user_name)fullName = firstName + lastName)posts 需要查数据库)
解析器的价值不仅在于它"怎么工作",而在于它"把什么分离了":
数据获取与数据形状分离。Schema定义数据形状("User有name和posts"),解析器定义数据获取方式("从MySQL取name,从Redis取posts")。前端只关心形状,不关心来源。
数据源无关性。一个GraphQL服务可以聚合来自MySQL、PostgreSQL、MongoDB、Redis、REST API、gRPC服务、甚至内存缓存的数据。每个解析器独立决定去哪取数据,引擎只负责编排调用。
关注点分离。每个解析器只负责一个字段的逻辑。user 解析器不关心 posts 怎么查,posts 解析器也不关心 author 怎么查。这种分离让代码更容易维护和测试。
可组合性。解析器可以组合——posts 解析器内部可以调用其他函数或服务,返回的数据自动成为子字段解析器的 parent。
这种"一个字段一个函数"的解析器模型,是GraphQL服务端架构中最核心的设计模式。理解它,就理解了GraphQL为什么能优雅地整合异构数据源,以及为什么它天然适合作为微服务架构的聚合层。
info 参数是四参数里最容易被忽略的一个,它在高级场景里很有用。info 里包含当前字段的 Schema 定义、AST 节点、返回类型、以及父类型信息。几个实际用途:
按需取列。解析器可以检查查询里选择了哪些子字段,只查询对应的数据库列。比如 users 解析器查看 info.fieldNodes[0].selectionSet 里有没有 email,没有就不查 email 列,减少不必要的 IO。
动态字段名。当多个 Schema 字段映射到同一个数据来源时,可以用 info.fieldName 判断当前正在解析哪个字段。
指令与权限元数据。info 里可以读取字段上的指令,实现基于指令的字段级授权(详见第五章)。
async function usersResolver(parent, args, ctx, info) { const selections = info.fieldNodes[0].selectionSet.selections .map(s => s.name.value); const columns = ['id', 'name']; if (selections.includes('email')) columns.push('email'); return ctx.db.query(`SELECT ${columns.join(',')} FROM users`); }
注意:按需取列优化了 IO,但牺牲了解析器与 Schema 的简单对应关系。团队需要约定"默认不依赖查询细节"的原则,只在确有性能压力的字段上用这个技巧,否则每个解析器都去解析 selectionSet,代码会迅速变得难以维护。
解析器可以返回三种形态的值:普通值(同步返回)、Promise(异步取数,执行引擎会 await)、以及 AsyncIterable(用于 Subscription 的实时流)。理解这一点有助于排查"为什么数据没出来"——如果你在解析器里返回了未 await 的 Promise,执行引擎仍然会正确等待;但如果你在同步解析器里抛错,错误的包装位置与 async 解析器不同。保持"解析器要么返回数据、要么抛业务错误"的简单约定,能让整个服务的错误处理可预测。