大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Schema 设计
这一课你将能做到
- 从业务域(课程/用户/提交)出发,独立设计出一份可演进、可治理的 Schema
- 说清 schema 块、根类型、extend 的组织方式及多文件 Schema 的合并规则
- 运用命名、空值、演进三组纪律,避免教科书 Schema 在生产环境翻车
为什么要学
类型系统教你认砖块,这一课教你盖房子。真实项目的 Schema 不是一次写成的:新需求每周加字段、老字段要体面退役、十几个类型散在七八个文件里。设计决策一旦做错——比如把 Schema 当数据库 ER 图照抄——后面每次加需求都要付利息。
人话:Schema 是产品的 API 门面,门面的装修决定了以后十年顾客(客户端)进店的体验。
先建立图像
Schema 的组织结构:
schema { ← 显式声明三个根(可省略走默认命名)
query: Query
mutation: Mutation
subscription: Subscription
}
type Query { ... } ← 所有读入口
type Mutation { ... } ← 所有写入口
type Subscription { ... } ← 所有订阅入口
# 领域类型按业务分组,不在根类型里堆字段
type Lesson / type Track / type Progress / type Submission ...
多文件开发时用 extend 扩展根类型,构建期合并:
# lesson.graphqls
extend type Query {
lesson(slug: String!): Lesson
lessons(first: Int, after: String): LessonConnection!
}
跟着做
步骤 1:显式声明 schema 块
schema {
query: QueryRoot
mutation: MutationRoot
}
如果根类型就叫 Query/Mutation/Subscription,schema 块可省略。但团队规约建议显式声明——可读性和「根类型重命名自由」兼得。
步骤 2:根字段按「读什么」而不是「表是什么」组织
type Query {
"按 slug 查课时"
lesson(slug: String!): Lesson
"课时分页列表(游标分页见最后一课)"
lessons(filter: LessonFilter, first: Int, after: String): LessonConnection!
"当前登录用户"
viewer: User
"全局节点查询(Relay 风格,可选)"
node(id: ID!): Node
}
type Mutation {
markLessonDone(input: MarkLessonDoneInput!): MarkLessonDonePayload!
submitCode(input: SubmitCodeInput!): SubmitCodePayload!
enrollTrack(input: EnrollTrackInput!): EnrollTrackPayload!
}
注意三个细节:viewer 而不是 me(命名从「图」的视角);读返回可空(查无此课时返回 null 而非报错);写统一 input/payload 范式。
步骤 3:领域类型按图遍历设计
问自己「用户在这个对象上还想顺路看什么」:
type Lesson {
id: ID!
slug: String!
title: String!
content: String
durationMinutes: Int!
difficulty: Difficulty!
# 图遍历出口:从课时能走到哪
track: Track
author: User!
submissions(first: Int, after: String): SubmissionConnection!
}
type Track {
id: ID!
slug: String!
title: String!
lessons(first: Int, after: String): LessonConnection!
lessonCount: Int! # 聚合字段直接放类型上,别建 LessonStats 类型
}
「lessonCount 放 Track 上」这个决定值得体会:REST 思维会让你建 /tracks/:id/stats 端点,图思维是「统计就是节点的字段」。
步骤 4:多文件拆分与合并
按域拆文件,构建工具(Spring for GraphQL、graphql-tools、Maven 插件)自动合并:
src/main/resources/graphql/
├── common.graphqls # scalar DateTime、interface Node、PageInfo
├── lesson.graphqls # extend type Query / type Lesson / enum Difficulty
├── track.graphqls
├── user.graphqls
└── submission.graphqls
规则:base 定义一次,extend 扩展多次。合并器要求恰好一个 type Query 定义(或全部用 extend),重复字段直接构建失败。
步骤 5:演进实战——给课时加「所属章节」
安全演进的标准动作:
type Lesson {
chapter: Chapter # ① 新增可空字段(安全,老客户端无感)
section: String @deprecated(reason: "Use chapter.title, since 2026-09")
# ② 旧字段标记弃用,仍正常返回
}
# ③ 观察 1-2 个发布周期,确认无客户端查询 section 后再物理删除
破坏性变更清单(背下来):删字段、改字段类型、给字段加 !、删枚举值、改参数类型、给参数加 !。对应安全操作都是反向的:加字段、放宽非空、加枚举值、加可空参数。
讲清楚要点
- Schema 是图不是表:从「客户端要遍历什么」出发设计,不做 ER 图的一一映射;冗余字段(lessonCount)在图里是正当的。
- 根类型只做入口:Query 的字段回答「从哪进图」,业务字段长在领域类型上。根字段超过 20 个就该审视边界。
- 可空优先、非空慎重:新增字段默认可空;加
!前先确认所有数据源都能保证产出。 - 命名一致性:类型名词(Lesson)、Mutation 动词(submitCode)、字段驼峰(durationMinutes)、枚举大写蛇形(IN_PROGRESS)、布尔字段不带 is 前缀(
published而非isPublished,因为 GraphQL 响应里 is 前缀易混淆)。 - 分页字段统一签名:凡是列表一律
(filter: XxxFilter, first: Int, after: String): XxxConnection!,全站一种分页语言(细节在最后一课)。 - schema 块显式声明:换来根类型命名自由和文档自明性,成本为零。
- 演进纪律:加字段 + @deprecated 是常态操作,破坏性变更必须走「双写/双读过渡」。
你可能会懵的地方
- Query 里要不要做权限过滤?
viewer返回当前用户是入口级鉴权;对象级授权放在字段 resolver(如Lesson.myProgress内部校验)。Schema 层面看不出权限是对的——权限是运行时行为。 - 为啥不建议
getLesson这种动词开头? 图里字段是「属性」不是「方法」。lesson(slug:)读作「课时这个节点,按 slug 定位」。动词感由参数承担。 - 嵌套多深算合理? 业务上 4-6 层是常态(Track→Lesson→Submission→User→Progress)。深度限制是防护网不是设计目标。
- Schema-first 还是 Code-first? 先写 SDL(Schema-first)契约清晰、适合前后端并行;从类/装饰器生成(Code-first)少一份维护但容易泄露实现细节。本站服务端若是 Spring,注解式 Code-first 也能产出等价 SDL,选团队顺手的。
- Connection/Edge 那套分页必须有吗?
不是必须。小列表
items: [X!]!+totalCount足够;无限滚动/强一致分页才上 Connection(最后一课专讲)。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| Schema 照抄数据库表结构 | 暴露实现、关联生硬 | 按图遍历路径建模 |
| Query 里堆 50 个根字段 | 无边界、难治理 | 根字段只做入口,收敛到域对象 |
所有字段一律 ! | 单点故障清空整页 | 默认可空,必有才加 ! |
| 删除字段完成「重构」 | 老客户端集体崩 | @deprecated → 观察 → 删除 |
| 每个列表一种分页参数 | 客户端心智负担 | 统一 first/after + Connection |
布尔字段 isVip: Boolean | 响应 JSON 里歧义 | vip: Boolean 或 role: Role |
卡点急救
- 启动报 "Query root type must be provided":有
extend type Query但没有任何基础type Query定义,或 schema 块指向的根类型名写错。 - 多文件合并报重复字段:两个 .graphqls 都定义了
type Query { lesson },保留一处,其余改 extend。 - 加字段后老客户端报校验错:不可能——加字段是安全变更;真报错多半是你顺手改了类型或加了
!。
面试常问
- GraphQL Schema 的演进策略? 只加不删:新需求加字段/类型,旧字段 @deprecated 标记并保留,经监控确认零调用后删除。破坏性变更(改类型、删枚举值、加非空)必须走双版本过渡。这就是为什么 GraphQL API 没有 /v2。
- Schema 设计和数据库设计的关系? 正交。Schema 面向消费者的查询路径建模,数据库面向存储一致性建模;中间靠 resolver 翻译。同一张表可以在图里呈现为多个类型,反之亦然。
- Query 根字段的设计原则? 入口点收敛(lesson/lessons/viewer/node 级别)、按「定位方式」参数化(id/slug/filter)、查无结果返回 null 而非抛错、列表统一分页签名。
自测三问
- 列举三种破坏性 Schema 变更和它们对应的安全替代操作。
- 为什么推荐显式写
schema { query: QueryRoot }块? Track.lessonCount这种「冗余」字段为什么符合图的设计哲学?
小结一句话
Schema 设计的核心是「以图遍历为中心」:根类型收敛入口、领域类型承载业务、默认可空留出演进余地、@deprecated 替代删除、全站统一分页与命名语言。
延伸
- GraphQL Schema 设计最佳实践(Shopify)
- 官方文档:Schema
- 下一课:服务端执行——resolver、校验、执行阶段与 N+1 问题的真面目。