真实业务里,一个字段的返回值常常"是几种不同类型之一":消息流里有文本、图片、视频消息;支付方式有卡、支付宝、钱包;内容平台有文章、视频、播客。GraphQL 用 Interface 与 Union 表达这种多态,但在设计上它们经常被误用——要么全部塞进一个"万能类型",要么 interface 当 union 用、union 当 interface 用。设计对了,多态让 schema 表达力翻倍;设计错了,就是客户端噩梦。
本文讲清 interface 与 union 的本质区别、多态 schema 建模、客户端消费模式、性能陷阱与演进策略。
一、Interface 与 Union 的本质区别
1.1 两个概念的分工
| 维度 | Interface | Union |
|---|---|---|
| 有无共同字段 | 有(抽象公共字段) | 无(纯并集) |
| 消费方式 | 直接查公共字段 | 必须 inline fragment |
| 语义 | is-a(都是某种东西) | is-one-of(是其中一种) |
| 典型场景 | 节点/内容类型 | 搜索结果、消息负载 |
# Interface: 内容都有 id/url,但实现不同
interface Content {
id: ID!
title: String!
url: String!
}
type Article implements Content { id ID! title String! url String! body: String! }
type Video implements Content { id ID! title String! url String! duration: Int! }
# Union: 搜索结果"是其中一种",无公共字段
union SearchResult = Article | Video | Product
1.2 选型决策
# 用 Interface 当
# 1) 多个类型有"真正的公共字段"(id/名称/时间戳)
# 2) 客户端需要"只查公共字段就能渲染列表"
# 3) 希望面向抽象编程(父接口查公共,子类型下钻)
# 用 Union 当
# 1) 类型间几乎无公共字段(结果集/消息负载)
# 2) 强制客户端显式分支处理(避免隐性默认)
# 3) 不想定义"空接口"
# 反例: 用 interface 塞"空公共字段"硬抽象 → 客户端被迫全 fragment
二、多态 Schema 建模
2.1 公共字段的抽象粒度
# interface 公共字段的选择
# 1) 真正共同且稳定: id、createdAt、type
# 2) 别硬塞: 加一个只有少数实现有意义的字段到 interface
# 3) 用 __typename 做"显式类型标识"(客户端分支依据)
# 4) 接口字段非空: 所有实现都必须提供(约束即契约)
interface Node {
id: ID!
}
interface Content implements Node {
id: ID!
title: String!
type: ContentType! # 显式鉴别字段(可选,__typename 也可)
}
2.2 多态字段返回
# 消息流: 一个字段返回多种消息
type Query {
conversation(id: ID!): Conversation
}
type Conversation {
messages: [Message!]!
}
union Message = TextMessage | ImageMessage | VideoMessage
# 建模要点
# 1) union 成员尽量"行为一致"(都可渲染、都可回复)
# 2) 每个成员有标识字段(__typename 或自定义 type)
# 3) 客户端可按 __typename 决定渲染组件
# 4) 别把"所有字段塞进一个大 union 成员"(回到万能类型反模式)
2.3 鉴别字段 vs __typename
# __typename: 规范内置,每个对象都有(服务端自动给)
# 自定义 type 字段: 显式、可用于过滤/索引,但要多维护
# 实践
# 客户端分支优先用 __typename(零维护)
# schema 层若需"按类型过滤查询"(WHERE type=)才加显式字段
# 注意: __typename 是保留语义,别自己覆盖
三、客户端消费模式
3.1 Inline Fragment 分支
# 按 __typename 分支渲染
query Conversation($id: ID!) {
conversation(id: $id) {
messages {
__typename
... on TextMessage { text }
... on ImageMessage { imageUrl width height }
... on VideoMessage { videoUrl duration }
}
}
}
# 消费要点
# 1) 每个成员一个 inline fragment,只查需要的字段
# 2) 始终查 __typename(客户端分支依据)
# 3) 未知类型有兜底(未来新增类型客户端不崩)
# 4) 公共字段提出来(在 union 外层查不到,放 fragment 或接口)
3.2 多态的渲染组件模式
// 客户端按类型分发渲染组件
function MessageView({ msg }: { msg: MessageUnion }) {
switch (msg.__typename) {
case "TextMessage": return <TextMsg text={msg.text} />;
case "ImageMessage": return <ImageMsg url={msg.imageUrl} />;
case "VideoMessage": return <VideoMsg url={msg.videoUrl} />;
default: return <UnknownMsg />; // 兜底
}
}
3.3 客户端 type 守卫与 codegen
# codegen 生成判别联合(discriminated union)
# - 每个成员生成独立类型 + __typename 字面量
# - 类型守卫/switch 由生成类型驱动(编译期分支安全)
# 收益
# - 分支写错成员字段 → 编译报错
# - 新增成员 → 类型提示"未处理"(编译期提示)
# 这是 codegen + 多态的最佳组合
四、多态查询的性能与 N+1
4.1 多态的 resolver 性能
# 多态字段的解析路径
# messages → 逐个成员 resolver(按 __typename 分派)
# 陷阱
# 1) 逐成员查询 → N+1(消息列表每条查一次媒体元数据)
# 2) 公共字段重复解析(接口层 + 实现层各查一次)
# 优化
# 1) 批量: 一次查出所有消息的公共字段,成员字段用批量加载
# 2) DataLoader: 按类型分组的批量加载
# 3) 避免"先解析接口再逐类型二次查询"
4.2 解析分派的实现
// 按类型分派 resolver(避免低效逐条)
const Message = {
__resolveType(msg) {
return msg.kind === "text" ? "TextMessage"
: msg.kind === "image" ? "ImageMessage"
: "VideoMessage";
},
};
// 批量加载: 公共字段一次性查,媒体元数据按需批查
4.3 深度与复杂度的多态放大
# 多态类型易放大查询成本
# - union 成员多 → 客户端可查的字段组合爆炸
# - 每个成员独立字段 → 成本估算按"最重成员"或加权
# 治理
# 1) 成本估算: 多态字段成本 = max(成员成本) 或加权和
# 2) 深度限制: 多态嵌套容易深,注意 maxDepth
# 3) 白名单: 高成本多态查询持久化/降级
五、多态的 Mutation 设计
5.1 多态输入(Input Union)
# GraphQL 输入类型不支持 union(Input Object 是唯一输入结构)
# 处理多态输入的三方案
# 1) 扁平 + 可空字段: 每个可能字段可空,客户端填其一(笨重)
# 2) 拆分 mutation: 按类型拆成不同 mutation(sendTextMessage/sendImageMessage)
# 3) oneOf input(提案/部分支持): 声明"恰好一个字段"
# 工程建议: 按类型拆 mutation 最清晰(输入类型各自验证)
# 拆开的多态 mutation(清晰 + 各自校验)
type Mutation {
sendTextMessage(conversationId: ID!, text: String!): Message!
sendImageMessage(conversationId: ID!, image: Upload!): Message!
sendVideoMessage(conversationId: ID!, video: Upload!, duration: Int!): Message!
}
5.2 多态输出的 mutation
# mutation 返回多态没问题(输出支持 union/interface)
# 例: sendXxxMessage 都返回 Message union
# 客户端: 用返回值的 __typename 更新本地缓存(统一处理新消息)
# 注意: mutation 结果多态时,客户端更新缓存要覆盖所有成员类型
5.3 输入校验与多态
# 按类型校验
# 每个 mutation 的参数独立校验(文本长度、图片格式、视频时长)
# 错误码按类型区分: VALIDATION_TEXT / VALIDATION_IMAGE
# 避免: 一个万能 mutation 里"if type==text 校验文本 else..."
六、多态类型的演进
6.1 新增成员
# 新增 union/interface 成员的兼容性
# 1) 加新类型 + 加进 union/interface 实现 → 向后兼容
# 2) 客户端需处理未知 __typename(兜底分支,别崩)
# 3) 服务端 __resolveType 要能解析新类型(不改旧逻辑)
# 破坏性场景
# - 移除成员: 客户端还在用 → 破坏(需版本周期)
# - 给 interface 加非空字段: 所有实现必须补 → 破坏
6.2 接口演进的兼容规则
# interface 演进
# 1) 加可空字段: 兼容(实现可选择提供)
# 2) 加非空字段: 所有实现同步 → 一次性改动(视为破坏性,受控发布)
# 3) 移除字段: 破坏(客户端在用)
# 4) 新增 interface 实现: 兼容
# 治理: 用 breaking change 检测工具(graphql-inspector)跟踪多态变更
6.3 多态与 Federation
# Federation 中多态
# 1) interface 可作为跨子图契约(不同子图实现不同成员)
# 2) union/interface 的 __resolveType 可能跨子图
# 3) 子图共享 interface 定义(@shareable 或统一包)
# 注意: 跨子图多态的一致性——同一实体在不同子图类型一致
七、真实业务应用
7.1 内容信息流
# 场景: 首页 feed 混合文章/视频/问答
# 建模: union FeedItem = Article | Video | QA
# 公共字段(渲染必需): 抽到接口? 无公共 → union
# 实践: __typename 驱动卡片组件,新内容类型加成员即扩展
7.2 支付与订单
# 场景: 支付方式多态
# interface PaymentMethod { id, type, expiredAt }
# 实现: Card / Alipay / Wallet
# 好处: 前端"支付方式列表"只查公共字段即可渲染,点选再下钻
7.3 通知中心
# 场景: 通知有多种类型
# union Notification = SystemNotice | MentionNotice | OrderNotice
# 公共字段(时间/已读)放 interface,类型特有字段 fragment 下钻
# 混合: interface Notification 定义公共 + union 不必需(interface 够用)
Q1: 什么时候 interface、什么时候 union?
有"真正的公共字段 + 面向抽象编程"用 interface;类型间无公共字段、就是要客户端显式分支用 union。判断标准:客户端能否只查公共字段就完成主渲染——能则 interface,不能则 union。
Q2: 多态字段性能差怎么办?
逐成员解析容易 N+1。用批量加载(一次性取公共字段)+ DataLoader(按类型分组的批量)+ 避免接口层与实现层重复查询。trace 里"同 SQL 重复 N 次"就是信号。
Q3: 新增成员会破坏客户端吗?
向后兼容(旧客户端不识别新类型 → 用兜底分支渲染或忽略)。前提:客户端有"未知 __typename"兜底。没有兜底的客户端会崩——这是多态设计的硬要求。
Q4: 多态输入怎么办?
GraphQL 输入不支持 union。优先按类型拆 mutation(各自参数与校验);确实需要单一入口时用"扁平可空字段 + 服务端断言恰有一个",但维护差。别硬造 input union。
Q5: 什么时候该避免多态?
当"客户端其实不需要区分类型"(只是展示一个列表、字段完全同构)时,用单类型 + 可空字段更简单。多态是表达力,也是复杂度——没有类型差异就别多态。
一句话总结
GraphQL 多态设计的本质,是用 Interface 抽象共同契约、用 Union 表达异构结果集:interface 让客户端面向公共字段编程、union 让客户端显式分支消费,配合
__typename与 codegen 判别联合实现类型安全的多态渲染,再用批量加载与 DataLoader 化解 N+1、用兜底分支保障演进兼容——多态让 schema 的表达力翻倍,也让客户端与服务端的协作边界更清晰。
相关阅读
- GraphQL Schema 设计进阶 — 类型体系设计原则
- GraphQL Resolver 性能与 N+1 根治 — 多态批量加载
- GraphQL Schema 版本化 — 多态演进兼容
- GraphQL 工具链与代码生成 — codegen 判别联合
- GraphQL 服务端实现 — __resolveType 实现
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。