大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
授权与缓存
这一课你将能做到
- 在 GraphQL 服务里实现「认证在入口、授权在字段」的两层模型
- 用 DataLoader 同时解决批处理与请求级缓存,并守住数据隔离红线
- 设计客户端缓存 + 持久化查询 + CDN 的多层缓存策略
为什么要学
REST 的权限是「一个端点一套规则」,中间件一把梭。GraphQL 只有一个端点,权限粒度天然落到字段上:Lesson.content 免费用户能看,Submission.judgeOutput 只有提交者能看。粒度变细意味着漏一处就是越权。同时,「一个端点」让 HTTP 层缓存几乎失效,缓存策略必须重新设计——这两件事是 GraphQL 从玩具走向生产的分水岭。
人话:REST 是大门设岗,GraphQL 是每个房间单独配钥匙。
先建立图像
两层安全模型:
请求 ──▶ ① 认证(Authentication):你是谁?
│ 网关/BFF 校验 JWT → 注入 context.currentUser
│
└──▶ ② 授权(Authorization):你能看这个字段吗?
每个敏感字段的 resolver 内判断:
- 对象级:这条 submission 是你的吗?
- 字段级:judgeOutput 只有 owner/admin 可见
缓存全景:
客户端归一化缓存(Apollo)
↑ miss
持久化查询 + CDN(GET 的匿名公开查询)
↑ miss
服务端:DataLoader 请求级缓存 / Redis 对象缓存 / HTTP 响应缓存
↑ miss
数据库
跟着做
步骤 1:认证——context 注入当前用户
// Yoga / Apollo 通用模式
const yoga = createYoga({
schema,
context: async ({ request }) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
const user = token ? await verifyJwt(token) : null // 验证失败 = null,不抛错
return { currentUser: user, loaders: createLoaders() }
}
})
关键决策:未认证不等于拒绝。公开数据(课时标题)匿名也能看;需要登录的字段在 resolver 里判断。整个操作级别的拒绝(401)只用于「该 Schema 完全不开放匿名」的场景。
步骤 2:对象级授权——这条数据是不是你的
const resolvers = {
Query: {
submission: async (_p, { id }, { currentUser, services }) => {
const sub = await services.submission.findById(id)
if (!sub) return null
if (sub.userId !== currentUser?.id && currentUser?.role !== 'ADMIN') {
throw new GraphQLError('无权查看', {
extensions: { code: 'FORBIDDEN' } // 机器可读码,见规范课
})
}
return sub
}
}
}
步骤 3:字段级授权——同一对象的不同字段不同权限
const resolvers = {
Submission: {
code: (sub, _a, { currentUser }) =>
sub.userId === currentUser?.id ? sub.code : null, // 他人代码 → null
judgeOutput: (sub, _a, { currentUser }) => {
if (currentUser?.role === 'ADMIN') return sub.judgeOutput
if (sub.userId === currentUser?.id) return sub.judgeOutput
return null // 可空设计在此兜底
}
}
}
注意与 Schema 设计的呼应:敏感字段必须可空,否则授权拒绝时 null 冒泡会炸掉整个父对象。这是「可空性 = 容错边界」在权限场景的应用。
步骤 4:DataLoader 兼做请求级缓存
function createLoaders(db, currentUser) {
return {
user: new DataLoader(async (ids) => {
const users = await db.user.findMany({ where: { id: { in: ids } } })
const byId = new Map(users.map(u => [u.id, u]))
return ids.map(id => byId.get(id) ?? null)
}),
// 带权限维度的 loader:key 里编码权限上下文,防串数据
submission: new DataLoader(async (ids) => {
const subs = await db.submission.findMany({ where: { id: { in: ids } } })
return ids.map(id => {
const s = subs.find(x => x.id === id)
if (!s) return null
return (s.userId === currentUser?.id || currentUser?.role === 'ADMIN') ? s : null
})
})
}
}
红线:loader 实例必须每请求新建,且闭包捕获当前用户——否则用户 A 的缓存条目会被用户 B 命中。
步骤 5:多层缓存组合拳
① 客户端:Apollo InMemoryCache(归一化,见客户端课)
② CDN:匿名 + 公开 + 可 GET 的查询(如课时列表头屏),配 APQ 让 URL 变短可缓存
③ 服务端 Redis:重型计算结果(如 track 的完成率排行),按 key 主动失效
④ DataLoader:单请求内批处理 + 去重(作用域最短,防串数据最安全)
服务端给响应加缓存提示(Apollo Server 的 cacheControl 或 @cacheControl 指令):
type Lesson @cacheControl(maxAge: 240) {
title: String!
myProgress: Progress @cacheControl(maxAge: 0, scope: PRIVATE) # 私有数据禁缓存
}
讲清楚要点
- 认证在 context、授权在 resolver:JWT 校验只做一次(建 context),权限判断分散到各敏感字段——这是单端点架构的必然分工。
- 未认证 ≠ 拒绝:公开字段匿名可见,登录才见的字段在 resolver 里分流;401 留给整体拒绝场景。
- 敏感字段必须可空:授权拒绝返回 null 而不是抛错时,页面其余部分还能渲染;抛错走 errors 通道适合「整个对象无权」。
- DataLoader 三原则:每请求新建、批处理函数按入参顺序返回、带权限的 loader 把权限上下文编入闭包或 key。
- 错误码走 extensions.code:
UNAUTHENTICATED(没登录)/FORBIDDEN(没权限)严格区分——前者引导登录,后者提示无权限,客户端分支完全不同。 - 缓存分层不重叠:客户端管 UI 一致性、CDN 管匿名公开、Redis 管跨请求计算结果、DataLoader 管单请求去重;层层 miss 才到数据库。
- PRIVATE scope 是底线:任何含用户维度的数据(myProgress)都必须标记私有,进不了共享缓存。
你可能会懵的地方
- 能继续用 Spring Security 的 @PreAuthorize 吗? 能。Spring for GraphQL 的 @QueryMapping/@SchemaMapping 方法上可直接加 @PreAuthorize,方法级安全和字段 resolver 天然契合。
- 指令式授权(@auth(role: ADMIN))好不好? 声明式很香,但复杂规则(「提交者本人或管理员」)指令表达不了。实践:简单角色判断用指令/拦截器,复杂对象级规则回 resolver。
- DataLoader 缓存和用户权限怎么共存? 两种安全做法:loader 闭包捕获 currentUser(每请求新建天然隔离);或权限过滤在 loader 之后做。绝不能用跨请求的全局 loader。
- GraphQL 能用 HTTP 缓存吗? POST 基本不能;GET 的匿名 query 可以(配合 APQ 缩短 URL)。所以公开重读数据(课时目录)尽量走 GET + CDN。
- 怎么发现哪个字段该加权限? 走查 Schema:凡是字段值因调用者不同而不同的(my*)、含敏感信息的(email、judgeOutput)、写操作(所有 Mutation),逐个标注权限规则,形成权限矩阵文档。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 只在网关校验 token 就算完事 | 字段级越权大开 | 对象级/字段级授权落到 resolver |
| 无权限时抛错且字段非空 | null 冒泡炸整页 | 敏感字段可空 + 返回 null,或明确抛 FORBIDDEN |
| 全局单例 DataLoader 缓存用户数据 | A 用户数据被 B 命中 | 每请求新建 loader |
| 401 和 403 语义混用 | 客户端无法决定跳登录还是提示 | 未认证 UNAUTHENTICATED、无权限 FORBIDDEN |
| 把 myProgress 响应交给 CDN | 用户数据互相泄露 | scope: PRIVATE 或干脆不走共享缓存 |
| 授权规则写在客户端 | 绕过轻而易举 | 客户端只做展示控制,判定永远服务端 |
卡点急救
- 登录了还一直 UNAUTHENTICATED:Authorization 头没带到(网关/代理剥头),或 token 校验失败被静默置 null——打日志看 context 构建。
- 普通用户能看到别人的提交记录:submission 查询只按 id 查库没校验归属,补对象级授权;检查是不是 DataLoader 全局缓存串了数据。
- 加了缓存后进度不更新:myProgress 被共享缓存命中,改 PRIVATE scope;或 Mutation 后没让客户端缓存失效。
面试常问
- GraphQL 的授权怎么做? 两层:认证在 HTTP/context 层(JWT 校验注入 currentUser);授权在 resolver 层,分对象级(这条数据归你吗)和字段级(这个角色能看这个字段吗)。错误用 extensions.code 区分 UNAUTHENTICATED/FORBIDDEN,敏感字段设计为可空以隔离失败。
- DataLoader 除了治 N+1 还有什么用?注意事项? 兼作请求级缓存(同 key 去重)。铁律:每请求新建实例,绝不做全局单例,否则跨用户/跨请求串数据;批处理函数返回值必须与入参 id 数组等长且同序。
- GraphQL 如何做缓存? 四层配合:客户端归一化缓存;公开匿名查询走 GET + APQ + CDN;服务端 @cacheControl/Redis 缓存重型结果,私有数据标 PRIVATE;DataLoader 做请求内去重。没有银弹,按数据公开性与变化频率选层。
自测三问
- UNAUTHENTICATED 和 FORBIDDEN 的语义差异及客户端处理差异?
- 为什么带权限过滤的 DataLoader 必须每请求新建?
- 「课时详情页的 myProgress 字段」为什么不能进 CDN 缓存?
小结一句话
授权与缓存是 GraphQL 的生产门槛:认证收口在 context、授权分散到字段、敏感字段可空兜底;缓存按「客户端 → CDN → Redis → DataLoader」分层,DataLoader 永远每请求新建。
延伸
- 官方文档:Authorization
- DataLoader 源码与设计
- 下一课:分页与综合——游标分页、Connection 规范与全栈收官练习。