GraphQL 多态类型设计:Interface 与 Union 深度实践

GraphQL 多态类型设计实战:interface 与 union 的区别与选型、多态 schema 建模(共同字段抽象/鉴别字段)、inline fragment 与 __typename 的消费模式、多态查询的性能与 N+1、多态类型的 mutation 设计、接口演进与破坏性变更、union/interface 在真实业务(消息流/支付/内容类型)中的应用,以及多态与 Federation 的组合实践。

真实业务里,一个字段的返回值常常"是几种不同类型之一":消息流里有文本、图片、视频消息;支付方式有卡、支付宝、钱包;内容平台有文章、视频、播客。GraphQL 用 Interface 与 Union 表达这种多态,但在设计上它们经常被误用——要么全部塞进一个"万能类型",要么 interface 当 union 用、union 当 interface 用。设计对了,多态让 schema 表达力翻倍;设计错了,就是客户端噩梦。

本文讲清 interface 与 union 的本质区别、多态 schema 建模、客户端消费模式、性能陷阱与演进策略。

一、Interface 与 Union 的本质区别

1.1 两个概念的分工

维度InterfaceUnion
有无共同字段有(抽象公共字段)无(纯并集)
消费方式直接查公共字段必须 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」更多文章

  1. GraphQL 过滤、搜索与聚合:查询数据的工程化
  2. GraphQL 可观测性与链路追踪:从 resolver 指标到全链路
  3. GraphQL 文件上传与流式传输:multipart 到流式响应