在GraphQL的世界里,Schema的地位类似于编程语言中的类型系统——它定义了所有合法的数据形状和操作边界。没有Schema,查询就是一段无意义的文本;有了Schema,查询才能被验证、被执行、被文档化。
Schema使用SDL(Schema Definition Language)编写。SDL是一种声明式语言,语法简洁,接近于TypeScript的类型定义。一个Schema文件通常包含三类定义:类型定义、操作入口定义和自定义指令。
以一个电商场景为例:
# 标量定义 scalar DateTime scalar Currency # 枚举定义 enum OrderStatus { PENDING PROCESSING SHIPPED DELIVERED CANCELLED } # 输入类型(用于Mutation参数) input CreateProductInput { name: String! description: String price: Currency! categoryId: ID! } # 接口定义(共享字段) interface Node { id: ID! } interface Timestamped { createdAt: DateTime! updatedAt: DateTime! } # 对象类型 type User implements Node & Timestamped { id: ID! name: String! email: String! avatarUrl: String orders(status: OrderStatus): [Order!]! createdAt: DateTime! updatedAt: DateTime! } type Product implements Node & Timestamped { id: ID! name: String! description: String price: Currency! category: Category! reviews(limit: Int = 10): [Review!]! createdAt: DateTime! updatedAt: DateTime! } type Category implements Node { id: ID! name: String! products(limit: Int = 20): [Product!]! } type Order implements Node & Timestamped { id: ID! status: OrderStatus! items: [OrderItem!]! total: Currency! createdAt: DateTime! updatedAt: DateTime! } type OrderItem { product: Product! quantity: Int! subtotal: Currency! } type Review implements Node & Timestamped { id: ID! rating: Int! body: String! author: User! product: Product! createdAt: DateTime! updatedAt: DateTime! } # 操作入口 type Query { node(id: ID!): Node user(id: ID!): User products(categoryId: ID, limit: Int = 20): [Product!]! search(query: String!, limit: Int = 10): [SearchResult!]! } type Mutation { createProduct(input: CreateProductInput!): Product! createReview(input: CreateReviewInput!): Review! cancelOrder(orderId: ID!): Order! } type Subscription { orderStatusChanged(userId: ID!): Order! newReview(productId: ID!): Review! } # 联合类型 union SearchResult = Product | User | Category type Subscription { orderUpdated(userId: ID!): Order! } type Schema { query: Query mutation: Mutation subscription: Subscription }
这个Schema展示了几种核心构造:
String、Int、Boolean、ID,以及自定义标量如 DateTime、CurrencyOrderStatusUser、ProductNode、TimestampedCreateProductInputSearchResultQuery、Mutation、Subscription 作为所有操作的入口
SDL的语法规则很直接,掌握这几个模式就够用了:
定义对象类型:type TypeName { fieldName: FieldType! }
实现接口:type User implements Node { id: ID! }
定义枚举:enum Status { ACTIVE INACTIVE }
定义输入类型:input CreateUserInput { name: String! email: String! }
定义联合类型:union Media = Photo | Video | Audio
定义标量:scalar DateTime(实现逻辑在服务端代码中)
字段参数:products(limit: Int = 20, sortBy: String = "NEWEST")
非空与列表:! 表示非空,[Type] 表示列表,[Type!]! 表示非空的列表,且每个元素非空。
Schema入口:type Query { ... } type Mutation { ... } type Subscription { ... }
这些语法元素组合起来,就构成了一个完整的Schema。Schema文件通常按业务领域组织——用户相关的类型放一个文件,商品相关的放另一个——然后在构建时合并成一个完整的Schema。
在实际项目中,Schema的组织方式取决于团队规模和项目复杂度:
单文件Schema:适合小型项目或原型阶段,所有类型定义在一个 .graphql 文件中。
按领域拆分:中型项目的常见做法。每个业务领域一个Schema文件(user.graphql、product.graphql、order.graphql),构建时用工具合并。
联邦子图:大型项目在Apollo Federation下,每个微服务维护自己的子图Schema(Subgraph),通过网关组合成超级图(Supergraph)。
无论哪种组织方式,最终合并后的Schema必须是合法的——同一个类型不能有两个冲突的定义,所有引用的类型必须存在,操作入口类型(Query、Mutation、Subscription)必须唯一。
SDL的设计理念是"声明意图,不关心实现"。Schema只描述数据的形状和关系,不包含任何逻辑代码(比如SQL查询、HTTP调用)。这种分离是GraphQL架构的基石:Schema负责声明数据形状,解析器负责按形状取数。
! 和 [] 的组合产生几种截然不同的契约,很多人在这里踩坑:
| 写法 | 含义 | 典型误区 |
|---|---|---|
String |
可空单值 | 允许返回 null |
String! |
非空单值 | 返回 null 会触发错误传播 |
[String] |
列表可空、元素可空 | 元素里可能混入 null |
[String!] |
列表可空、元素非空 | 单元素不可为 null |
[String]! |
列表非空、元素可空 | 列表本身不可为 null |
[String!]! |
列表非空、元素非空 | 最严格,生产环境最常用 |
易错点一:[String] 里混入 null。如果数据库里某一行邮箱为空,[String] 允许返回 ["a", null, "b"],客户端必须逐元素做 null 判断。多数情况下你应该用 [String!] 甚至 [String!]!,让契约更严格、客户端更省心。
易错点二:字段返回 null 与字段不存在是两回事。email: String 返回 null 表示"该用户没有邮箱",这是合法的业务状态;但如果 Schema 里声明的是 email: String! 而解析器返回了 null,执行引擎会把这个字段标记为错误并向上传播——父字段可能因此整体变成 null,整个响应结构被破坏。设计 Schema 时,非空标记要保守:确定"永远有值"才用 !,否则宁可先放空,等数据质量稳定后再收紧。这也是为什么很多团队给数据库里"理论上非空"的字段仍然标可空——因为历史脏数据可能违反理论。
scalar DateTime 只是声明,真正的序列化与反序列化逻辑要在解析器之外实现。以 Node 生态为例:
import { GraphQLScalarType, Kind } from 'graphql'; const DateTime = new GraphQLScalarType({ name: 'DateTime', description: 'ISO 8601 日期时间字符串', // 客户端传入的值 → 内部使用的值 parseValue(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new TypeError('无效的日期时间'); } return date; }, // 内部值 → 返回给客户端 serialize(value) { return value instanceof Date ? value.toISOString() : String(value); }, // 查询文本里内联的字面量 parseLiteral(ast) { if (ast.kind !== Kind.STRING) throw new TypeError('DateTime 必须是字符串'); return this.parseValue(ast.value); }, });
自定义标量把"格式校验"从业务代码里抽出来,集中在一处维护。常见的自定义标量还有 Email、URL、JSON、BigInt、UUID。注意边界:标量只负责值的转换,不负责业务规则——比如"日期必须晚于今天"属于业务校验,应该放在输入类型或解析器里,而不是塞进标量,否则同一个标量在不同场景下的语义会被迫一致,反而失去灵活性。
Schema 不只是一份给机器执行的契约,它本身就是可以查询的文档。GraphQL 内置 introspection 能力:客户端可以查询 __schema 元字段,拿到所有类型、字段、参数、枚举值的完整描述。GraphiQL 的自动补全、代码生成工具拉取类型、API 文档站点,全都依赖 introspection。
{ __schema { queryType { name } types { name kind fields { name type { name kind ofType { name } } } } } }
生产环境通常要关闭 introspection 或加权限保护——它对外暴露了完整的数据模型,可能被攻击者用来测绘攻击面(详见第五章安全防护)。但在内部环境和开发工具里,introspection 是 GraphQL 体验远超 REST 的重要原因:文档永远不会和实现脱节,因为文档就是从实现里实时生成的。