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 或移动字段归属前,先检查所有依赖子图并制定回滚策略

返回技能库 更多技能入口