GraphQL Schema 设计
让 Agent 在定义类型、联邦子图与分页字段时遵循一致命名与可演进规则;Connection 与游标策略写进 SKILL,避免 N+1、跨服务重复实体与破坏性变更进入生产。
本页提供 DataLoader 解决 N+1 问题的完整实现、游标分页 Connection schema 定义、查询深度限制中间件、resolver 级鉴权,以及 complexity analysis 防护恶意查询的可运行代码。
多子图时:实体由单一子图「拥有」字段集,其余子图用联邦指令扩展;避免在两个子图上对同一实体定义冲突的标量字段或非 key 解析路径。
- 命名:类型 PascalCase,字段 camelCase,枚举全大写下划线。
- 可空与默认值:区分「未设置」与「显式 null」,避免客户端歧义。
- 与 REST 并存时:在 SKILL 中固定「BFF 聚合 vs 直连解析器」的职责划分。
Schema 设计主流程(skill-flow-block)
[ 领域模型 / 用例 ]
│
▼
[ 划分子图或单体 Schema:Query 入口、Mutation、Subscription 边界 ]
│
▼
[ 类型:Object / Input / Enum;列表 → Connection 或明确 limit+offset+上限 ]
│
┌──────┴──────┐
▼ ▼
[ 联邦:@key / @external / 解析器归属 ] [ 解析器:DataLoader、分页游标稳定、max first ]
│ │
└──────┬──────┘
▼
[ @deprecated + reason;Schema Registry / 契约测试在 CI 拦截 breaking ]
│
▼
[ 深度 / 复杂度 / introspection 策略;生产与预发一致 ]
Agent 输出 SDL 时按此顺序自检:入口字段是否过宽、列表是否缺上限、联邦实体是否只有一个「主」子图负责 key 字段、分页是否在解析器层强制 first ≤ max。
DataLoader 解决 N+1 问题
// DataLoader 完整实现:批量加载解决 N+1 问题
import DataLoader from 'dataloader';
// 批量加载函数:将 N 次单独查询合并为 1 次 IN 查询
const userLoader = new DataLoader<string, User>(async (userIds) => {
// userIds 是同一 tick 内所有请求的 id 集合(自动去重)
const users = await db.users.findMany({
where: { id: { in: [...userIds] } }
});
// 必须按 userIds 顺序返回,缺失项用 null 占位
return userIds.map(id => users.find(u => u.id === id) ?? null);
}, {
cache: true, // 请求内缓存(每次请求新建 loader 实例)
maxBatchSize: 100, // 单批最大 100 条,防止超大 IN 查询
});
// Resolver 中使用:每次调用 .load(),DataLoader 自动批量
const resolvers = {
Post: {
// 查询 100 篇文章,不会触发 100 次 user 查询
author: (post, _, { loaders }) => loaders.user.load(post.authorId),
},
};
// 每次 HTTP 请求创建新的 DataLoader 实例(避免跨请求缓存污染)
function createContext(req) {
return {
loaders: {
user: new DataLoader(async (ids) => batchLoadUsers(ids)),
post: new DataLoader(async (ids) => batchLoadPosts(ids)),
}
};
}
-
实体与 @key:可跨服务解析的类型使用
@key声明主键字段;扩展子图用@external引用他图字段,仅在本地添加增量字段或解析逻辑。 - 变更协调:改 key 或移动字段归属是 breaking 级操作;SKILL 应要求同步更新子图清单、兼容性检查与回滚策略。
游标分页 Schema 与查询防护
# 游标分页 Connection/Edge/PageInfo 标准模式(Relay 规范)
"""分页信息(全图唯一定义,其他 Connection 复用)"""
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String # 不透明游标,客户端不解析内容
}
type UserEdge {
cursor: String!
node: User!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int # 高成本,按需启用;避免默认触发 COUNT(*)
}
extend type Query {
"""列出用户(first 最大 100,超限返回 BAD_USER_INPUT)"""
users(
first: Int! # 必填,解析器强制上限
after: String
filter: UserFilter
): UserConnection!
}
// 查询深度限制(depth-limit 中间件)+ 复杂度分析
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
validationRules: [
depthLimit(5), // 最大嵌套深度 5 层,防止深度嵌套攻击
createComplexityRule({
maximumComplexity: 1000, // 最大复杂度分值
variables: {},
onComplete: (complexity) => console.log(`Query complexity: ${complexity}`),
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
}),
],
plugins: [
{
requestDidStart: () => ({
didResolveOperation({ request, document }) {
// 生产环境禁用 introspection(防止 schema 泄露)
if (process.env.NODE_ENV === 'production' &&
request.operationName === 'IntrospectionQuery') {
throw new ForbiddenError('Introspection disabled in production');
}
}
})
}
],
});
// Resolver 级别鉴权:在 resolver 内检查权限,不依赖调用方自觉
const resolvers = {
Query: {
adminUsers: (_, __, { user }) => {
if (!user || user.role !== 'admin')
throw new ForbiddenError('Admin access required'); // 403
return db.users.findAll();
}
},
Mutation: {
deletePost: async (_, { id }, { user, loaders }) => {
const post = await loaders.post.load(id);
if (!post) throw new UserInputError('Post not found');
// ABAC:检查资源属性,不仅仅检查角色
if (post.authorId !== user.id && user.role !== 'admin')
throw new ForbiddenError('Can only delete your own posts');
return db.posts.delete({ where: { id } });
}
}
};
弃用、Registry 与安全成本
对即将删除的字段使用 @deprecated 并注明替代字段;重大变更通过 Schema Registry 或契约测试在 CI 中拦截。多子图场景下 composition 失败或版本漂移须在流水线中显式报错。
安全与成本:在技能描述中要求校验查询深度、复杂度与 introspection 策略,防止开放端点被滥用拖垮解析层。
Connection SDL 片段实验室(gql-page)
填写节点类型名与列表字段名,生成可粘贴进子图的 Relay 风格 Connection 骨架(含注释提醒在解析器内强制 first 上限)。生成仅在浏览器本地完成。
解析器中须校验 first:若调用方传入大于 100,应抛业务错误或截断为上限(团队择一并在 SKILL 中统一)。
---
name: graphql-schema-design
description: 生成可演进的 GraphQL SDL,含安全防护与 N+1 解决方案
---
# Schema 设计
1. 类型命名:Object/Input/Enum PascalCase,字段 camelCase,枚举 ALL_CAPS
2. 列表字段:使用 Connection 模式(edges/node/pageInfo),first 必填并限制最大值
3. 游标不透明:客户端不应解析游标内容,服务端自由改变编码策略
4. totalCount 高成本:单独字段或可选参数,默认不触发 COUNT 查询
# N+1 与性能
5. 每个 Resolver 使用 DataLoader 批量加载关联数据
6. 每次 HTTP 请求创建新 DataLoader 实例,避免跨请求缓存污染
7. 批量函数按输入 ids 顺序返回结果,缺失项用 null 占位
# 查询防护
8. 深度限制:graphql-depth-limit 设置最大嵌套深度(推荐 5-10)
9. 复杂度分析:设置 maximumComplexity,记录每次查询分值
10. 生产环境禁用 introspection(防止 schema 结构泄露)
11. Resolver 级鉴权:每个敏感 resolver 内检查权限,不依赖调用方自觉
# 演进与弃用
12. 弃用字段:@deprecated(reason: "Use newField instead")
13. Schema Registry 在 CI 拦截 breaking change
14. 联邦:实体由单一子图拥有 @key 字段,其他子图 @external 引用
15. 改 key 或移动字段归属前,先检查所有依赖子图并制定回滚策略