Go 博客
向后兼容性、Go 1.21 和 Go 2
Go 1.21 包含了改进兼容性的新功能。在你停止阅读之前,我知道这听起来很无聊。但无聊可能是好事。在 Go 1 的早期,Go 非常令人兴奋,充满了惊喜。每周我们都会发布一个新的快照版本,每个人都得碰运气,看看我们做了哪些更改,以及他们的程序会如何崩溃。我们发布了 Go 1 及其兼容性承诺,以消除这种兴奋,以便 Go 的新版本可以变得无聊。
无聊是好事。无聊是稳定的。无聊意味着能够专注于自己的工作,而不是 Go 的不同之处。这篇文章是关于我们在 Go 1.21 中发布的重要工作,以保持 Go 的无聊。
Go 1 兼容性
十多年来,我们一直专注于兼容性。对于 2012 年发布的 Go 1,我们发布了一篇名为“Go 1 和 Go 程序的未来”的文档,其中明确阐述了我们的意图
我们的目标是,针对 Go 1 规范编写的程序将继续在该规范的生命周期内正确编译和运行,无需更改。… 今天工作的 Go 程序应该在 Go 1 的未来版本出现时继续工作。
对此有一些限制。首先,兼容性意味着源代码兼容性。当您更新到 Go 的新版本时,您确实需要重新编译代码。其次,我们可以添加新的 API,但不能以破坏现有代码的方式添加。
该文档的最后警告说,“[它] 无法保证未来不会发生任何会破坏任何程序的更改。”然后它列出了程序仍然可能会崩溃的一些原因。
例如,如果您的程序依赖于一个错误的行为,而我们修复了该错误,您的程序就会崩溃,这是有道理的。但我们非常努力地尽量减少破坏,并保持 Go 的无聊。到目前为止,我们使用了两种主要方法:API 检查和测试。
API 检查
关于兼容性最清晰的事实可能是,我们不能删除 API,否则使用它的程序就会崩溃。
例如,以下是一个我们无法破坏的程序,它是某人编写的
package main
import "os"
func main() {
os.Stdout.WriteString("hello, world\n")
}
我们不能删除 os
包;我们不能删除全局变量 os.Stdout
,它是一个 *os.File
;我们也不能删除 os.File
方法 WriteString
。很明显,删除这些中的任何一个都会破坏这个程序。
也许不那么明显的是,我们不能更改 os.Stdout
的类型。假设我们想把它变成一个具有相同方法的接口。我们刚刚看到的程序不会崩溃,但这个程序就会崩溃
package main
import "os"
func main() {
greet(os.Stdout)
}
func greet(f *os.File) {
f.WriteString(“hello, world\n”)
}
这个程序将 os.Stdout
传递给名为 greet
的函数,该函数需要一个类型为 *os.File
的参数。因此,将 os.Stdout
更改为接口将破坏这个程序。
为了帮助我们在开发 Go 时,我们使用了一个工具,该工具将每个包的导出 API 保存在与实际包分开的文件中
% cat go/api/go1.21.txt
pkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386
pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685
pkg bytes, method (*Buffer) Available() int #53685
pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488
pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488
pkg cmp, type Ordered interface {} #59488
pkg context, func AfterFunc(Context, func()) func() bool #57928
pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661
pkg context, func WithoutCancel(Context) Context #40221
pkg context, func WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc) #56661
我们的一个标准测试检查实际包 API 是否与这些文件匹配。如果我们向某个包添加了新的 API,测试就会崩溃,除非我们也将其添加到 API 文件中。如果我们更改或删除了 API,测试也会崩溃。这有助于我们避免错误。但是,这种工具只能找到一类特定的问题,即 API 更改和删除。还有其他方法可以对 Go 进行不兼容的更改。
这让我们转向了我们用来保持 Go 无聊的第二种方法:测试。
测试
查找意外不兼容性的最有效方法是在下一个 Go 版本的开发版本上运行现有测试。我们定期将 Go 的开发版本与 Google 内部所有 Go 代码进行测试。当测试通过时,我们将该提交作为 Google 的生产 Go 工具链安装。
如果更改破坏了 Google 内部测试,我们假设它也会破坏 Google 外部的测试,并且我们会寻找减少影响的方法。大多数情况下,我们会完全回滚更改,或者找到一种方法重新编写它,使其不会破坏任何程序。然而,有时我们会得出结论,即使更改确实破坏了一些程序,它也是重要的更改,并且“兼容”。在这种情况下,我们仍然会尽最大努力减少影响,然后在发布说明中记录潜在问题。
以下是在 Google 内部测试 Go 时发现的两种这种微妙的兼容性问题示例,但仍包含在 Go 1.1 中。
结构体字面量和新字段
以下是一些在 Go 1 中可以正常运行的代码
package main
import "net"
var myAddr = &net.TCPAddr{
net.IPv4(18, 26, 4, 9),
80,
}
包 main
声明了一个全局变量 myAddr
,它是一个类型为 net.TCPAddr
的复合字面量。在 Go 1 中,包 net
将类型 TCPAddr
定义为一个具有两个字段的结构体,分别是 IP
和 Port
。这些与复合字面量中的字段匹配,因此程序可以编译。
在 Go 1.1 中,程序停止编译,并出现编译器错误,提示“结构体字面量中的初始化程序太少”。问题在于,我们向 net.TCPAddr
添加了第三个字段 Zone
,而这个程序缺少该第三个字段的值。解决方法是使用带标记的字面量重写程序,这样它就可以在两个版本的 Go 中构建
var myAddr = &net.TCPAddr{
IP: net.IPv4(18, 26, 4, 9),
Port: 80,
}
由于这个字面量没有为 Zone
指定值,它将使用零值(在本例中为空字符串)。
在 兼容性文档 中明确指出,此要求适用于标准库结构体的复合字面量,go vet
会报告需要标记才能确保与 Go 以后版本兼容的字面量。这个问题在 Go 1.1 中是比较新的,因此在发布说明中只简短地提到了它。现在我们只提到新字段。
时间精度
我们在测试 Go 1.1 时发现的第二个问题与 API 完全无关。它与时间有关。
Go 1 发布后不久,有人指出 time.Now
返回具有微秒精度的时间,但通过一些额外的代码,它可以改为返回具有纳秒精度的时间。这听起来不错,对吧?更高的精度更好。因此,我们做了更改。
这破坏了 Google 内部的一些测试,这些测试类似于以下代码
func TestSaveTime(t *testing.T) {
t1 := time.Now()
save(t1)
if t2 := load(); t2 != t1 {
t.Fatalf("load() = %v, want %v", t1, t2)
}
}
此代码调用 time.Now
,然后通过 save
和 load
对结果进行往返操作,并期望得到相同的时间。如果 save
和 load
使用仅存储微秒精度的表示形式,这在 Go 1 中可以正常工作,但在 Go 1.1 中会失败。
为了帮助修复此类测试,我们添加了 Round
和 Truncate
方法,以丢弃不需要的精度,并在发布说明中记录了可能出现的问题以及用来帮助修复该问题的新方法。
这些示例表明,测试能够发现 API 检查无法发现的不同类型的兼容性问题。当然,测试也不能完全保证兼容性,但它比仅仅进行 API 检查更加全面。我们通过测试发现了许多问题,最终决定这些问题违反了兼容性规则,并在发布之前回滚。时间精度更改是一个有趣的例子,它会破坏程序,但我们仍然发布了它。我们进行了更改,因为更高的精度更好,并且在该函数的已记录行为范围内是允许的。
这个例子表明,有时,尽管付出了巨大的努力和关注,但更改 Go 意味着会破坏 Go 程序。严格地说,这些更改是“兼容”的,因为它们符合 Go 1 文档,但它们仍然会破坏程序。大多数这些兼容性问题可以归类为三类:输出更改、输入更改和协议更改。
输出更改
当某个函数的输出与以前不同时,就会发生输出更改,但新的输出与旧输出一样正确,甚至比旧输出更正确。如果现有代码被编写为仅期望旧输出,它就会崩溃。我们刚刚看到了一个例子,time.Now
添加了纳秒精度。
Sort. 另一个例子发生在 Go 1.6 中,当时我们改变了 sort 的实现,使其速度提高了大约 10%。以下是一个按名称长度对颜色列表进行排序的示例程序
colors := strings.Fields(
`black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)
Go 1.5: [red blue green white black yellow orange indigo violet]
Go 1.6: [red blue white green black orange yellow indigo violet]
更改排序算法通常会改变相等元素的排序方式,而这里也发生了这种情况。Go 1.5 返回了绿色、白色、黑色,按此顺序排列。Go 1.6 返回了白色、绿色、黑色。
Sort 明显允许以任何它喜欢的顺序返回相等的结果,而更改使它快了 10%,这很好。但期望特定输出的程序将会崩溃。这是一个很好的例子,说明了为什么兼容性如此困难。我们不想破坏程序,但我们也不想被锁定在未记录的实现细节中。
Compress/flate. 另一个例子是在 Go 1.8 中,我们改进了 compress/flate
,使其能够生成更小的输出,同时 CPU 和内存开销大致相同。这听起来像是双赢,但它破坏了 Google 内部的一个项目,该项目需要可重现的归档构建:现在他们无法重现旧的归档文件。他们对 compress/flate
和 compress/gzip
进行了分叉,以保留旧算法的副本。
我们对 Go 编译器也做了类似的事情,使用了 `sort` 包的分支版本(以及其他),以便编译器即使使用早期版本的 Go 构建也能产生相同的结果。
对于这类输出更改不兼容问题,最好的答案是编写能够接受任何有效输出的程序和测试,并利用这种中断作为机会改变您的测试策略,而不仅仅是更新预期答案。如果您需要真正可重复的输出,下一个最好的答案是将代码分叉以隔离自己免受更改的影响,但请记住,您也在隔离自己免受错误修复的影响。
输入更改
当函数更改其接受的输入或处理输入的方式时,就会发生输入更改。
ParseInt. 例如,Go 1.13 添加了对大数字中下划线的支持以提高可读性。随着语言的更改,我们让 `strconv.ParseInt` 接受新的语法。此更改在 Google 内部没有造成任何破坏,但很久以后我们从外部用户那里听说他们的代码确实被破坏了。他们的程序使用用下划线分隔的数字作为数据格式。它首先尝试 `ParseInt`,只有在 `ParseInt` 失败时才会回退到检查下划线。当 `ParseInt` 停止失败时,下划线处理代码也停止运行。
ParseIP. 另一个例子是,Go 的 `net.ParseIP` 遵循早期 IP RFC 中的示例,这些示例通常显示带有前导零的十进制 IP 地址。它将 IP 地址 18.032.4.011 视为 18.32.4.11,只是多了一些额外的零。我们后来发现,源自 BSD 的 C 库将 IP 地址中的前导零解释为以八进制数字开头:在这些库中,18.032.4.011 表示 18.26.4.9!
这在 Go 和世界其他地区之间存在严重的不匹配,但从一个 Go 版本更改前导零的含义到下一个版本也是一个严重的不匹配。这将是一个巨大的不兼容问题。最后,我们决定在 Go 1.17 中更改 `net.ParseIP`,完全拒绝前导零。这种更严格的解析确保当 Go 和 C 都成功解析 IP 地址时,或者当旧版和新版 Go 版本解析 IP 地址时,它们都对地址的含义达成一致。
此更改在 Google 内部没有造成任何破坏,但 Kubernetes 团队担心可能已被解析但会在 Go 1.17 中停止解析的已保存配置。带有前导零的地址可能应该从这些配置中删除,因为 Go 对它们的解释与几乎所有其他语言都不同,但这应该在 Kubernetes 的时间线内发生,而不是 Go 的时间线内。为了避免语义变化,Kubernetes 开始使用它自己分叉的原始 `net.ParseIP` 的副本。
对输入更改的最佳响应是在解析值之前先验证要接受的语法,来处理用户输入,但有时您需要分叉代码。
协议更改
最后一种常见的不兼容性是协议更改。协议更改是对包的更改,最终在程序用于与外部世界通信的协议中外部可见。几乎任何更改都可以在某些程序中变得外部可见,正如我们从 `ParseInt` 和 `ParseIP` 中看到的那样,但协议更改在几乎所有程序中都是外部可见的。
HTTP/2. 当 Go 1.6 添加了对 HTTP/2 的自动支持时,协议更改的一个明显例子。假设一个 Go 1.5 客户端正在通过一个网络与一个支持 HTTP/2 的服务器连接,而该网络存在可能破坏 HTTP/2 的中间盒。由于 Go 1.5 只使用 HTTP/1.1,因此程序运行良好。但是,更新到 Go 1.6 会破坏程序,因为 Go 1.6 开始使用 HTTP/2,而在此上下文中,HTTP/2 不起作用。
Go 旨在默认情况下支持现代协议,但此示例表明,启用 HTTP/2 会因程序自身(或 Go 自身)的错误而破坏程序。这种情况下的开发人员可以返回使用 Go 1.5,但这并不令人满意。相反,Go 1.6 在发布说明中记录了更改,并使禁用 HTTP/2 变得非常简单。
事实上,Go 1.6 记录了两种禁用 HTTP/2 的方法:使用包 API 显式配置 `TLSNextProto` 字段,或设置 GODEBUG 环境变量。
GODEBUG=http2client=0 ./myprog
GODEBUG=http2server=0 ./myprog
GODEBUG=http2client=0,http2server=0 ./myprog
正如我们稍后将看到的,Go 1.21 将此 GODEBUG 机制推广,使其成为所有潜在破坏性更改的标准。
SHA1. 这是一个关于协议更改的更微妙的例子。现在没有人应该使用基于 SHA1 的 HTTPS 证书。证书颁发机构在 2015 年停止签发它们,并且所有主要浏览器在 2017 年停止接受它们。在 2020 年初,Go 1.18 默认禁用了对它们的 supports,并提供了一个 GODEBUG 设置来覆盖此更改。我们还宣布了在 Go 1.19 中删除 GODEBUG 设置的意图。
Kubernetes 团队让我们知道,一些安装仍然使用私有 SHA1 证书。抛开安全问题不谈,Kubernetes 无权强迫这些企业升级其证书基础设施,而且分叉 `crypto/tls` 和 `net/http` 以保持 SHA1 支持将非常痛苦。相反,我们同意将覆盖保留更长的时间,以便为有序过渡创造更多时间。毕竟,我们希望尽可能少地破坏程序。
Go 1.21 中扩展的 GODEBUG 支持
为了即使在这些我们一直在检查的微妙情况下也能提高向后兼容性,Go 1.21 扩展和规范了 GODEBUG 的使用。
首先,对于任何被 Go 1 兼容性允许但仍可能破坏现有程序的更改,我们会做所有我们刚刚看到的工作来了解潜在的兼容性问题,并且我们会对更改进行设计,以尽可能多地保持现有程序的正常运行。对于剩余的程序,新的方法是
-
我们将定义一个新的 GODEBUG 设置,允许单个程序选择不使用新行为。如果这样做不可行,则可能不会添加 GODEBUG 设置,但这应该非常罕见。
-
为兼容性添加的 GODEBUG 设置将至少维护两年(四个 Go 版本)。有些,例如 `http2client` 和 `http2server`,将维护更长时间,甚至无限期维护。
-
如果可能,每个 GODEBUG 设置都与一个关联的 `runtime/metrics` 计数器,名为 `/godebug/non-default-behavior/<name>:events`,它计算由于设置的非默认值而导致特定程序的行为更改的次数。例如,当设置 `GODEBUG=http2client=0` 时,`/godebug/non-default-behavior/http2client:events` 将计算程序已配置的不支持 HTTP/2 的 HTTP 传输的数量。
-
程序的 GODEBUG 设置配置为匹配主包的 `go.mod` 文件中列出的 Go 版本。如果您的程序的 `go.mod` 文件说 `go 1.20`,并且您更新到 Go 1.21 工具链,那么在您将 `go.mod` 更改为 `go 1.21` 之前,Go 1.21 中更改的任何 GODEBUG 控制的行为将保留其旧的 Go 1.20 行为。
-
程序可以通过在包 `main` 中使用 `//go:debug` 行来更改单个 GODEBUG 设置。
-
所有 GODEBUG 设置都记录在 一个单一的、中央列表中,以便轻松参考。
这种方法意味着 Go 的每个新版本都应该是 Go 的旧版本的最佳实现,即使在编译旧代码时保留在以后的版本中以兼容但破坏的方式更改的行为。
例如,在 Go 1.21 中,`panic(nil)` 现在会导致一个(非 nil)运行时 panic,因此 `recover` 的结果现在可靠地报告当前 goroutine 是否正在 panic。此新行为由 GODEBUG 设置控制,因此取决于主包的 `go.mod` 的 `go` 行:如果它说 `go 1.20` 或更早,则仍然允许 `panic(nil)`。如果它说 `go 1.21` 或更高,则 `panic(nil)` 会变成一个带有 `runtime.PanicNilError` 的 panic。版本相关的默认值可以通过在包 main 中添加类似这样的行来显式覆盖
//go:debug panicnil=1
这些功能的组合意味着程序可以更新到更新的工具链,同时保留其使用的早期工具链的行为,可以根据需要对特定设置应用更精细的控制,并且可以使用生产监控来了解哪些作业实际上使用了这些非默认行为。综合起来,这些应该使新工具链的推出比过去更加顺畅。
有关更多详细信息,请参见“Go、向后兼容性和 GODEBUG”。
关于 Go 2 的更新
在本文开头引用的“Go 1 和 Go 程序的未来”中的引文中,省略号隐藏了以下限定词
在某个不确定的时间点,可能会出现 Go 2 规范,但在那时之前,[… 所有兼容性细节 …]。
这提出了一个显而易见的问题:我们何时应该期待会破坏旧 Go 1 程序的 Go 2 规范?
答案是永远不会。从破坏过去和不再编译旧程序的意义上说,Go 2 永远不会发生。从我们 2017 年开始着手进行的 Go 1 主要修订版的意义上说,Go 2 已经发生了。
不会出现一个会破坏 Go 1 程序的 Go 2。相反,我们将加倍努力地提高兼容性,这比任何可能的与过去的断裂都要有价值得多。事实上,我们相信,优先考虑兼容性是我们对 Go 1 做出的最重要的设计决策。
因此,在未来几年中,您将看到大量新颖、令人兴奋的工作,但这些工作都以谨慎、兼容的方式完成,以便我们可以使您从一个工具链到另一个工具链的升级尽可能无聊。
下一篇文章: Go 1.21 中的向前兼容性和工具链管理
前一篇文章: Go 1.21 发布!
博客索引