通过字段版本控制演进 Schema
随着应用程序需求的演进,为其提供数据的 GraphQL API 也需要不断演进,并对其 schema 引入变更。当变更是非破坏性的(例如添加新类型或字段)时,可以直接应用,无需担心副作用。但当变更是破坏性的时,我们需要确保不会在应用程序中引入 bug 或意外行为。
破坏性变更是指删除类型、字段或指令,或修改已有字段(或指令)签名的变更,例如:
- 重命名字段
- 更改现有字段参数的类型,或将其设为必填
- 向字段添加新的必填参数
- 将字段响应类型设为 non-nullable
为了处理破坏性变更,主要有两种策略:版本控制和演进,分别由 REST 和 GraphQL 实现。
REST API 通过端点 URL(如 https://api.mycompany.com/v1 或 https://api-v1.mycompany.com)或某些请求头(如 Accept-version: v1)来指示要使用的 API 版本。通过版本控制,破坏性变更被添加到 API 的新版本中,由于客户端需要明确指向新版本的 API,因此它们能够感知到这些变更。
GraphQL 并不反对使用版本控制,但鼓励使用演进的方式。正如 GraphQL best practices 页面所述:
While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.
演进与版本控制的行为方式不同——它并非像版本控制那样每隔几个月进行一次,而是一个持续的过程,甚至可以每天进行,这使其更适合快速迭代。这种方式由 Principled GraphQL 提出并阐述,Principled GraphQL 是指导 GraphQL 服务开发的一套最佳实践,在其第五条原则中有所体现:
5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time
演进 Schema
通过演进方式,存在破坏性变更的字段必须经历以下过程:
- 使用不同的名称重新实现该字段。
- 将该字段标记为已废弃(deprecated),并提示客户端改用新字段。
- 当该字段不再被任何人使用时,将其从 schema 中移除。
来看一个例子。假设我们有一个 Account 类型,通过以下 schema(使用 GraphQL 的 SDL——Schema Definition Language)将账户建模为具有名字和姓氏的人:
type Account {
id: Int
name: String!
surname: String!
}在此 schema 中,name 和 surname 两个字段均为必填(即类型 String 后面添加的 ! 符号),因为我们预期所有人都有名字和姓氏。
后来,我们还允许组织开立账户。但组织没有姓氏,因此我们必须更改 surname 字段的签名,使其变为非必填:
type Account {
id: Int
name: String!
surname: String # 这里已更改
}这是一个破坏性变更,因为应用程序并不期望 surname 字段返回 null,因此可能未对此情况进行检查,就像执行以下 JavaScript 代码时一样:
// 当 account.surname 为 null 时,此处将会失败
const upperCaseSurname = account.surname.toUpperCase();通过演进 schema,可以避免破坏性变更所带来的潜在 bug:
- 不修改
surname字段的签名;而是将其标记为已废弃,并添加一条有用的消息,指明替代字段的名称 - 向 schema 引入新字段名
personSurname(或accountSurname)
此时 Account 类型如下所示:
type Account {
id: Int
name: String!
surname: String! @deprecated(reason: "Use `personSurname`")
personSurname: String
}最后,通过收集客户端 Query 的日志,我们可以分析他们是否已切换到新字段。一旦发现 surname 字段不再被任何人使用,即可将其从 schema 中移除:
type Account {
id: Int
name: String!
personSurname: String
}演进的问题
上述示例非常简单,但已经展示了演进 schema 时的几个潜在问题:
| 问题 | 说明 |
|---|---|
| 字段名称变得不够简洁 | 第一次命名字段时,我们可能会找到最优的名称,例如 surname。但当需要替换它时,由于最优名称已被占用,我们不得不创建一个次优的名称。上述示例中所有可能的替代方案都存在问题:- personName 明确表示账户属于某个人,因此如果以后需要为有姓氏的非人实体(比如说……外星人?)开立账户,则需要再次演进 schema 以保持名称一致性- accountName 中的"account"部分完全是冗余的,因为类型本身已经是 Account- 除此之外,还能用什么名称呢? surname1?surnameNew?或者更糟糕的 surnameV2?结果,更新后的 schema 将变得难以理解且更加冗长。 |
| Schema 可能积累大量已废弃字段 | 将字段标记为废弃最适合作为临时措施;最终,我们真的希望在这些字段开始堆积之前将其从 schema 中清除。 然而,可能存在一些客户端没有修改其 Query,仍然从已废弃字段获取信息。在这种情况下,schema 会逐渐变成一种"字段墓地",为同一功能积累多个不同的字段。 |
让我们来看看如何解决这些问题。
字段版本控制
我们可以为字段创建一个名为 version 的参数,通过该参数指定要使用的字段版本。
在这种方案中,我们仍然必须保留已废弃字段的实现,因此在这方面并没有改善。然而,其契约变得隐藏起来:新字段现在可以保留其原始名称(不需要从 surname 重命名为 personSurname),从而防止 schema 变得过于冗长。
请注意,这里的版本控制概念与 REST 中的不同:
- REST 建立了一种全有或全无的情况,由于版本是端点的一部分,整个被查询的 API 具有相同的版本
- 在这种方案中,每个字段独立进行版本控制
因此,我们可以为不同字段访问不同版本,如下所示:
query GetPosts {
posts(version: "1.0.0") {
id
title(version: "2.1.1")
url
author {
id
name(version: "1.5.3")
}
}
}此外,通过依赖语义化版本控制,我们可以使用版本约束来选择版本,遵循 Composer 用于声明包依赖关系的相同规则。然后,将字段参数 version 重命名为 versionConstraint 并更新 Query:
query GetPosts {
posts(versionConstraint: "^1.0") {
id
title(versionConstraint: ">=2.1")
url
author {
id
name(versionConstraint: "~1.5.3")
}
}
}将此策略应用于已废弃字段 surname,现在可以将已废弃的实现标记为版本 "1.0.0",将新实现标记为版本 "2.0.0",并在同一 Query 中同时访问两者:
query GetSurname {
account(id: 1) {
oldVersion: surname(versionConstraint: "^1.0")
newVersion: surname(versionConstraint: "^2.0")
}
}此功能在 Gato GraphQL 中可用:

指令版本控制
由于指令也接收参数,我们可以应用完全相同的方法来对指令进行版本控制!
例如,运行以下 Query 时:
query {
post(by: { id: 1 }) {
oldVersion: title @strTitleCase(versionConstraint: "^0.1")
newVersion: title @strTitleCase(versionConstraint: "^0.2")
}
}它可以为每个版本的指令生成不同的响应:
