GraphQL
GraphQL 是一种 API 查询语言和执行规范。服务端定义可查询的类型与字段,客户端在这些范围内声明需要的数据,执行器调用对应的解析逻辑,按选择的结构返回结果。它不要求使用图数据库,也不会自动把查询变成高效的 SQL。
对于同一份后端数据,不同页面往往需要不同字段。GraphQL 可以减少无用字段和客户端为关联数据发起的多次请求,但也把查询成本、权限和缓存策略更多地交给服务端设计。面试中解释它的优点时,需要同时说明这些收益在什么条件下成立。
1. 面试核心
先记住这几条,再读正文- GraphQL 通过 schema 定义允许访问的数据结构,客户端选择需要的字段,有助于减少过量获取和多次往返,但不是让客户端任意访问数据库。卡 · Schema 与字段选择
- 查询文档先解析、校验,再执行字段解析器。类型正确不代表业务合法,权限、分页上限等仍由服务端检查。卡 · 查询执行与解析器
- 字段解析器可以访问数据库、其他 API 或内存数据。客户端少发一次 HTTP 请求,不代表服务端只查询一次数据库。卡 · 请求数量与 N+1
- mutation 的顶层字段按顺序执行,不等于自动数据库事务;query 字段不应承担修改状态的副作用。卡 · 查询执行与解析器
- 执行错误可以与部分
data同时返回;非空字段失败时,空值会向上传播到允许为空的边界。卡 · 错误与空值传播 - 批量加载和请求内复用可以缓解 N+1,但不能跨用户无条件共享缓存,也不能代替权限检查。卡 · 请求数量与 N+1
- GraphQL 需要控制深度、宽度、列表规模和执行成本;客户端能少取字段,不代表它不会提交昂贵查询。卡 · Schema 与字段选择
2. Schema 与字段选择
Schema 是服务端公开的数据契约,描述有哪些类型、字段、参数和操作入口。下面用 GraphQL 的 schema 定义语言描述用户与文章:
type Query {
user(id: ID!): User
posts(first: Int = 10): [Post!]!
}
type User {
id: ID!
name: String!
bio: String
}
type Post {
id: ID!
title: String!
author: User
}
ID、String、Int 是标量类型。! 表示非空:name: String! 不允许成功结果中的 name 为 null;bio: String 则允许。[Post!]! 表示列表本身非空,列表元素也非空,但空数组 [] 仍然合法。
user(id: ID!): User 的参数 id 必须提供非空值,返回的用户却允许为空,例如没有找到对应用户。GraphQL 的 ID 用于标识对象,并不意味着数据库主键一定是整数,也不证明调用者有权读取这个对象。
客户端可以只选择用户页面所需的字段:
query UserCard($id: ID!) {
user(id: $id) {
id
name
bio
}
}
变量单独传入,例如 {"id":"u1"},不需要把用户输入拼接进查询语法。响应的 data 会遵循选择结构;如果查询只选择 id 和 name,就不会额外返回 bio。对象字段需要继续选择子字段,不能只写 user(id: $id) 而不说明要哪些内容。
服务端仍决定可以查询什么。如果 schema 没有 password,查询这个字段会在校验阶段失败。客户端的自由只存在于 schema 暴露的范围内,而这些字段是否可以被当前用户读取,还需要另外的权限规则。
同一个查询可以通过别名给字段结果换一个响应键,例如用 first: user(id: "u1") 和 second: user(id: "u2") 查询两个用户。片段可以复用重复的字段选择,但不会自动减少后端执行成本。返回数据的名字由查询决定,也不代表服务端允许客户端修改底层字段。
3. 查询执行与解析器
解析器(resolver)是执行器为某个字段取得值的逻辑。通常会收到父对象、字段参数、请求上下文和执行信息;具体注册方式由服务端库决定。
执行过程可以分成三步:
- 解析查询文档,检查语法是否正确。
- 按 schema 校验字段、参数和选择结构,处理变量的类型要求。
- 执行选中的字段,调用解析器取得值,再按返回类型完成结果。
父对象的值先确定,才能执行其子字段。普通 query 的独立字段可以并行解析,解析器不应依赖它们的执行先后,也不应偷偷修改业务状态。
下面使用 GraphQL.js 16.x 演示 user 的执行。把前面的 schema 字符串保存为 schemaText,查询字符串保存为 queryText。示例只实现 user,没有实现文章列表;bio 故意采用“只允许用户本人读取”的演示规则。
import { buildSchema, graphql } from "graphql";
const schema = buildSchema(schemaText);
const users = new Map([
["u1", { id: "u1", name: "Lin", bio: "学习 GraphQL" }],
]);
export function readBio(user, viewer) {
if (viewer?.id !== user.id) throw new Error("forbidden");
return user.bio;
}
export const rootValue = {
user({ id }) {
const user = users.get(id);
if (!user) return null;
return {
id: user.id,
name: user.name,
bio: (_args, context) => readBio(user, context.viewer),
};
},
};
const result = await graphql({
schema,
source: queryText,
rootValue,
variableValues: { id: "u1" },
contextValue: { viewer: { id: "u1" } },
});
console.log(JSON.stringify(result));
输出为 {"data":{"user":{"id":"u1","name":"Lin","bio":"学习 GraphQL"}}}。这里的 rootValue.user 根据参数找用户,bio 通过请求上下文检查读取权限。GraphQL.js 的默认字段解析器会读取同名属性;属性是函数时,会把参数、上下文和执行信息传给它。因此,本例返回的 bio 函数会在客户端选择该字段时被调用。
contextValue.viewer 在真实服务中应来自已经验证的会话或令牌,不能直接相信客户端 JSON 中自报的用户身份。权限逻辑也应放在可复用的业务层,避免 GraphQL 入口与其他入口执行不同规则。本例的 readBio() 展示了这一分工,但没有提供登录系统。
3.1 Query、mutation 与 subscription
Query 用于读取数据;mutation 用于执行修改;subscription 用于订阅后续产生的一系列结果。它们是 GraphQL 的操作类型,不是三种固定的 HTTP 方法。
同一次 mutation 操作的顶层字段按顺序执行,后一个在前一个完成后开始;它们内部的普通子字段仍按相应规则解析。这个顺序不意味着整个操作自动加入数据库事务:第一个字段修改成功、第二个字段失败时,框架不会替你回滚第一个修改。原子性需要业务层和数据库事务实现,跨请求的并发也需要独立控制。
Subscription 的结果流可以通过 WebSocket 或 SSE 等机制传输,具体协议由实现约定。GraphQL 规定怎样执行操作,不要求所有服务只能使用某个传输协议。
4. 错误与空值传播
GraphQL 要区分执行前的请求错误和执行中的字段错误。语法错误、未知字段或变量类型不合法,会阻止执行,此时响应包含 errors,不包含 data。执行器已经开始解析字段后,某个字段失败则可能返回部分数据。
把前面示例的访问者改成 u2,readBio() 会抛出异常。因为 bio 允许为空,结果仍保留 user.id 和 user.name,bio 为 null,同时 errors 中包含这次失败,路径是 user → bio。客户端应该根据字段缺失和业务规则展示降级状态,不能只看 HTTP 状态码就判定所有内容都成功。
如果把 bio 改成 String!,它就不能用 null 表示失败。错误会向上影响 user,由于 Query.user 允许为空,结果变成 data: { user: null }。如果 user 也声明为非空,空值还会继续传播,可能使整个 data 为 null。
列表同样受非空约束影响:[Post] 允许某个位置为 null;[Post!] 的一个元素失败,会使整个列表为 null;[Post!]! 还不允许列表本身为 null,因此继续向父级传播。空数组 [] 与 null 不同,仍然合法。设计非空字段时,应在稳定契约与局部失败的影响范围之间作判断,不能为了类型看起来严格就处处加 !。
null 本身也不一定是错误,例如用户不存在可以正常返回 user: null。需要结合 errors 以及其中的 path 判断是否发生字段失败;生产响应也不应直接暴露数据库信息、堆栈和内部凭据。
5. 请求数量与 N+1
N+1 问题是先取一组数据,再为其中每个对象分别请求关联数据,导致一次列表访问触发大量后端调用。它与客户端只发了一次 GraphQL 请求并不矛盾。
假设客户端查询 10 篇文章及其作者姓名。posts 解析器查一次文章列表,随后每篇文章的 author 解析器各查一次用户,就可能产生 11 次数据访问。即使 10 篇文章只有 2 位不同作者,朴素实现也可能重复查询同一个用户。
可以在请求内收集作者 ID,批量获取需要的用户,再按 ID 将结果映射回对应文章;重复 ID 可以在本次请求内复用。DataLoader 一类工具会帮助合并一批加载请求,但批处理函数仍要把结果与原始键正确对应,处理缺失记录与错误。
请求内缓存与跨请求缓存要分开。加载结果可能受当前用户权限、租户或语言影响,不能把带身份语义的加载器无条件做成全局单例。写入后也要清理或更新同一请求中的相关缓存,避免后续字段继续读到旧值。
批量查询不是唯一优化方式。合适的联表、预加载和后端服务的批量接口也能解决问题;是否减少调用,要看解析器执行计划和数据源记录。GraphQL 的字段选择可以缩小返回数据,但不保证 ORM 自动只查被选择的列,也不保证每种嵌套结构都高效。
6. 与 REST 的取舍
GraphQL 的直接收益是让不同客户端按 schema 选择字段和关联结构。列表页只需要标题,详情页需要作者介绍,客户端可以表达不同需求;schema 还支持校验、编辑器补全、文档和类型生成等工具能力。
REST 风格 API 同样可以提供聚合接口、字段选择和批量操作。因此,不能把 REST 描述成“必然多次请求、必然返回所有字段”,也不能把 GraphQL 描述成必然更快。
| 方面 | GraphQL 需要作出的判断 |
|---|---|
| 页面与客户端差异 | 数据需求变化多时,字段选择有价值;简单稳定接口未必值得增加执行层 |
| 服务端成本 | 灵活查询需要成本控制,解析器和数据源调用必须观测 |
| 缓存 | 可以使用客户端规范化缓存、服务端缓存和可缓存的 GET query;键与身份隔离需要明确 |
| 迭代兼容 | 增加字段通常较容易兼容;删除字段、收紧输入要求等变化可能破坏已有客户端 |
| 错误处理 | 客户端要理解部分数据、字段错误和空值边界 |
使用 HTTP 时,一个服务通常把操作提交到 /graphql,但请求身份、变量、操作文档不同,响应也可能不同。把所有结果按这个路径缓存会混用数据。GET 可用于只读 query,mutation 不应通过 GET 执行;POST 也可以承载 query,不是所有 GraphQL 请求都只能用 POST。具体状态码和响应媒体类型由使用的 HTTP 实现约定决定,不能背成“无论什么错误都返回 200”。
持久化查询是服务端保存查询文档,让客户端用标识或摘要引用它,可以减少传输文档的长度。只有服务端拒绝执行未批准的文档时,它才构成查询允许列表;自动接受并注册任意新查询,并不会自动消除昂贵查询风险。
即使一个查询完全符合 schema,也可能消耗大量资源。嵌套深度、多个别名造成的宽度、每层列表数量、批量操作数都需要限制;还要控制总执行时间和数据源调用。posts(first: Int = 10) 只提供类型和默认值,不自动限制 first 的上限,服务端必须检查范围。仅限制深度,也挡不住浅层同时查询很多对象。
7 · 面试题卡片
本页题目与参考回答
7.1 GraphQL 有哪些优点,什么时候值得使用?
考察重点:字段选择、关联查询、类型契约与实现代价。
相关内容:Schema 与字段选择、与 REST 的取舍。
参考回答
GraphQL 让客户端在 schema 范围内选择字段与关联结构,可以减少无用数据和客户端往返,并提供统一的类型契约。 多种页面或客户端的数据需求差异大时,这种能力更有价值;校验、补全和类型生成也有助于接口协作。
它不保证数据库查询更少或响应必然更快。服务端需要处理 N+1、权限、查询成本、缓存和兼容性,客户端还要处理部分结果。REST 也可以提供聚合和字段选择;需求简单稳定时,不必只为了使用新技术增加复杂度。
7.2 GraphQL 怎样执行查询,mutation 按顺序执行等于事务吗?
考察重点:解析、校验、字段解析器、请求上下文和执行边界。
参考回答
先解析和校验文档及变量,再调用选中字段的解析器,按 schema 完成结果。 解析器可以访问不同数据源,子字段依赖父对象;普通 query 的独立字段可以并行,不应承担修改状态的副作用。权限检查使用可信请求上下文,并由业务层执行。
Mutation 只有同一操作的顶层字段按顺序执行,不能据此推断自动提交或回滚数据库事务。第一个修改成功、后一个失败时,前一个可能已生效,原子性必须另行设计。
7.3 GraphQL 为什么可能同时返回 data 和 errors,非空字段失败会怎样?
考察重点:请求错误、字段错误、部分数据与非空传播。
相关内容:错误与空值传播。
参考回答
执行前的请求错误不产生 data;执行中的字段错误则可能保留其他成功字段,并同时返回 errors。 可空字段失败时变成 null,错误路径帮助客户端定位失败位置。
非空字段不能直接为 null,因此错误向上传播,直到遇到允许为空的父字段,必要时整个 data 为 null。列表还要区分列表和元素的非空约束。正常未找到对象也可以返回 null,应结合 errors 判断,而不是把所有 null 都视作故障。
7.4 一次 GraphQL 请求为什么仍会发生 N+1,怎样优化?
考察重点:客户端请求与数据源访问的区别、批量加载和权限范围。
相关内容:请求数量与 N+1。
参考回答
字段解析器可能为每个列表元素单独访问数据源,所以一次 HTTP 请求仍能触发一次列表查询加 N 次关联查询。 可以批量查询关联 ID、在请求内复用重复记录,或使用联表、预加载和批量服务接口,并用实际数据源调用数验证。
DataLoader 只能帮助组织加载,仍要正确对齐键与结果。权限和租户相关的结果不要跨请求无条件共享,写入后也要考虑清理加载缓存。还需限制列表规模和查询成本,避免把大量调用只变成一个同样昂贵的大查询。
8. 参考资料
- GraphQL Specification,October 2021
- GraphQL:Execution
- GraphQL:Authorization
- GraphQL:Performance
- GraphQL:Security
- GraphQL:Serving over HTTP
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明