Go 博客
向后兼容性、Go 1.21 和 Go 2
Go 1.21 包含了一些改进兼容性的新功能。在您停止阅读之前,我知道这听起来很无聊。但无聊有时是好事。在 Go 1 的早期,Go 充满活力和惊喜。每周我们都会发布一个新的快照版本,每个人都可以尝试一下,看看我们改变了什么,以及他们的程序会如何中断。我们发布了 Go 1 及其兼容性承诺,就是为了消除这种惊喜,让新的 Go 版本变得无聊。
无聊是好的。无聊意味着稳定。无聊意味着您可以专注于您的工作,而不是 Go 的不同之处。这篇博文将介绍我们在 Go 1.21 中为保持 Go 的无聊所做的重要工作。
Go 1 兼容性
十多年来,我们一直专注于兼容性。对于 Go 1,早在 2012 年,我们就发布了一份题为“Go 1 和 Go 项目的未来”的文件,其中明确阐述了一个非常清晰的意图:
按照 Go 1 规范编写的程序,在其生命周期内都应能够正确编译和运行,无需更改。… 即使 Go 1 的未来版本出现,今天能工作的 Go 程序也应该继续工作。
这里有几点说明。首先,兼容性意味着源代码兼容性。当您更新到新版本的 Go 时,确实需要重新编译您的代码。其次,我们可以添加新的 API,但不能以破坏现有代码的方式添加。
文档末尾警告说,“[我们]不可能保证未来不会有任何更改会破坏任何程序。”然后列举了程序可能仍然中断的几个原因。
例如,如果您的程序依赖于一个有 bug 的行为,而我们修复了这个 bug,那么您的程序就会中断,这是可以理解的。但我们非常努力地将中断降到最低,并保持 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 版本开发的版本运行现有测试。我们滚动测试 Google 所有内部 Go 代码的 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 中会失败。
为了帮助修复此类测试,我们向 `time.Time` 添加了 `Round` 和 `Truncate` 方法来丢弃不必要的精度,并在发布说明中记录了潜在问题和用于修复它的新方法。
这些例子表明,测试发现的兼容性问题类型与 API 检查不同。当然,测试也不能完全保证兼容性,但它比仅进行 API 检查更全面。我们在测试中发现了很多决定破坏兼容性规则并在此次发布前回滚的问题。时间精度更改是一个有趣的例子,它破坏了程序但我们仍然发布了它。我们进行了更改,因为提高精度更好,并且在函数记录的行为允许范围内。
这个例子表明,尽管付出了巨大的努力并引起了极大的关注,有时更改 Go 意味着破坏 Go 程序。严格来说,这些更改在 Go 1 文档的意义上是“兼容”的,但它们仍然会破坏程序。这些兼容性问题大多可以归为三类:输出更改、输入更改和协议更改。
输出更改
当一个函数给出与以前不同的输出时,就会发生输出更改,但新的输出与旧的输出一样正确,甚至更正确。如果现有代码编写为只期望旧输出,那么它就会中断。我们刚才看到了一个例子,即 `time.Now` 添加了纳秒精度。
排序。 另一个例子发生在 Go 1.6 中,当时我们将排序的实现速度提高了约 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 按顺序返回 green, white, black。Go 1.6 返回 white, green, black。
排序显然可以按喜欢的任何顺序返回相等的结果,并且这次更改使其速度提高了 10%,这非常好。但期望特定输出的程序会中断。这很好地说明了兼容性为何如此困难。我们不想破坏程序,但也不想被锁定在未记录的实现细节中。
Compress/flate。 另一个例子是,在 Go 1.8 中,我们改进了 `compress/flate`,使其产生更小的输出,而 CPU 和内存开销大致相同。这听起来像是双赢,但它破坏了一个需要可重复存档构建的项目:现在他们无法重现旧的存档。他们分叉了 `compress/flate` 和 `compress/gzip` 来保留旧算法的副本。
我们对 Go 编译器也做了类似的事情,使用 `sort` 包的一个分叉(以及其他),这样编译器在构建时就能产生与早期 Go 版本相同的results。
对于此类输出更改不兼容性,最佳答案是编写接受任何有效输出的程序和测试,并将这些中断作为更改测试策略的机会,而不仅仅是更新预期答案。如果您需要真正可重现的输出,次佳答案是分叉代码以使自己免受更改的影响,但请记住,您也使自己免受了错误修复的影响。
输入更改
当一个函数改变它接受的输入或处理输入的方式时,就会发生输入更改。
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 地址时,它们都对 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 默认禁用了对它们的广泛支持,并提供了一个 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/
: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` 之前,所有由 GODEBUG 控制的在 Go 1.21 中更改的行为都将保留其旧的 Go 1.20 行为。
-
程序可以通过在 `main` 包中使用 `//go:debug` 行来更改单个 GODEBUG 设置。
-
所有 GODEBUG 设置都记录在一个 单一的中央列表 中,方便参考。
这种方法意味着每个新版本的 Go 都应该是旧版本 Go 的最佳实现,即使在编译旧代码时保留了在后续版本中以兼容但破坏性方式更改的行为。
例如,在 Go 1.21 中,`panic(nil)` 现在会导致一个(非零值)运行时 panic,这样 `recover` 的结果现在可以可靠地报告当前 goroutine 是否在 panic。这种新行为由 GODEBUG 设置控制,因此取决于主包 `go.mod` 的 `go` 行:如果它显示 `go 1.20` 或更早版本,则仍然允许 `panic(nil)`。如果它显示 `go 1.21` 或更高版本,则 `panic(nil)` 会变成一个带有 `runtime.PanicNilError` 的 panic。并且版本化的默认值可以通过在主包中添加类似这样的行来显式覆盖:
//go:debug panicnil=1
这种功能组合意味着程序可以在更新到更新的工具链的同时保留它们所使用的早期工具链的行为,可以根据需要对特定设置进行更细粒度的控制,并且可以使用生产环境监控来了解哪些作业实际上使用了这些非默认行为。总而言之,这些应该能使推出新工具链比以往更加顺利。
有关更多详细信息,请参阅“Go、向后兼容性与 GODEBUG”。
关于 Go 2 的更新
在本文顶部引用的“Go 1 和 Go 项目的未来”的文本中,省略号隐藏了以下限定条件:
在某个不确定的时间点,可能会出现 Go 2 规范,但在此之前,[…所有兼容性细节… ]。
这引发了一个显而易见的问题:我们何时可以预期会有一个破坏旧 Go 1 程序的 Go 2 规范?
答案是永远不会。Go 2,从打破过去、不再编译旧程序的意义上讲,将永远不会发生。Go 2,从 2017 年我们开始的 Go 1 主要修订版的意义上讲,已经发生。
不会有破坏 Go 1 程序的 Go 2。相反,我们将加倍努力实现兼容性,这比任何可能的打破过去的举动都更有价值。事实上,我们认为优先考虑兼容性是我们为 Go 1 做出的最重要的设计决策。
因此,在接下来的几年里,您将看到大量令人兴奋的新工作,但这些工作将以一种谨慎、兼容的方式完成,以便我们可以让您从一个工具链升级到下一个工具链的过程尽可能地无聊。
下一篇文章: Go 1.21 中的向前兼容性和工具链管理
上一篇文章: Go 1.21 发布!
博客索引