原文在这里

由 Russ Cox 发布于 2023年8月14日

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,但不能以破坏现有代码的方式添加。

文件的结尾警告说,“[它]无法保证没有任何未来的变化会破坏任何程序。”然后它列出了一些程序可能仍然会破坏的原因。

例如,如果你的程序依赖于一个错误的行为,我们修复了这个错误,你的程序就会崩溃。但我们尽可能地减少破坏,保持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,
}

主包声明了一个全局变量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增加了纳秒精度。

排序。另一个例子发生在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%,这是很好的。但是,期望特定输出的程序将会破坏。这是一个很好的例子,说明为什么兼容性如此困难。我们不想破坏程序,但我们也不想被锁定在未记录的实现细节中。

压缩/flate。作为另一个例子,在Go 1.8中,我们改进了compress/flate,使其产生更小的输出,大致上CPU和内存开销相同。这听起来像是双赢,但它破坏了一个在Google内部需要可重现的存档构建的项目:现在他们无法重现他们的旧存档。他们分叉了compress/flatecompress/gzip,以保留旧算法的副本。

我们对Go编译器也做了类似的事情,使用sort包(和其他包)的一个分叉,以便编译器即使使用早期版本的Go构建,也能产生相同的结果。

对于这样的输出变化不兼容性,最好的答案是编写接受任何有效输出的程序和测试,并使用这些破坏作为改变你的测试策略的机会,而不仅仅是更新预期的答案。如果你需要真正可重现的输出,下一个最好的答案是分叉代码以使自己免受变化的影响,但记住你也在使自己免受错误修复的影响。

输入变化

当一个函数改变它接受的输入或者如何处理它们时,就会发生输入变化。

ParseInt。例如,Go 1.13增加了对大数字中的下划线的支持,以提高可读性。与语言变化一起,我们使strconv.ParseInt接受新的语法。这个改变没有在Google内部破坏任何东西,但很久以后我们听到了一个外部用户的代码确实破坏了。他们的程序使用下划线分隔的数字作为数据格式。它首先尝试ParseInt,只有在ParseInt失败时才回退到检查下划线。当ParseInt停止失败时,处理下划线的代码停止运行。

ParseIP。作为另一个例子,Go的net.ParseIP,遵循了早期IP RFCs中的例子,这些例子经常显示带有前导零的十进制IP地址。它将IP地址18.032.4.011读取为18.32.4.11,只是多了几个零。我们后来发现,BSD派生的C库将IP地址中的前导零解释为开始一个八进制数:在这些库中,18.032.4.011表示18.26.4.9!

这是Go和世界其他地方之间的严重不匹配,但是从一个Go版本到下一个Go版本改变前导零的含义也是一个严重的不匹配。这将是一个巨大的不兼容性。最后,我们决定在Go 1.17中改变net.ParseIP,完全拒绝前导零。这种更严格的解析确保了当Go和C都成功解析一个IP地址,或者当旧的和新的Go版本都这样做时,它们都同意它的含义。

这个改变没有在Google内部破坏任何东西,但Kubernetes团队担心保存的配置可能在以前解析过,但在Go 1.17中会停止解析。应该从这些配置中删除带有前导零的地址,因为Go与基本上所有其他语言的解释不同,但这应该在Kubernetes的时间线上发生,而不是Go的。为了避免语义变化,Kubernetes开始使用其自己的分叉副本的原始net.ParseIP。

对于输入变化的最好的回应是首先验证你想要接受的语法,然后再解析值,但有时你需要分叉代码。

协议变化

最常见的不兼容性类型是协议变化。协议变化是对一个包进行的改变,这个改变在程序用来与外部世界通信的协议中变得外部可见。几乎任何改变都可以在某些程序中变得外部可见,就像我们看到的ParseIntParseIP一样,但协议变化在基本上所有程序中都是外部可见的。

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兼容性允许但仍可能破坏现有程序的改变,我们做了所有我们刚刚看到的工作,以理解潜在的兼容性问题,并设计改变以保持尽可能多的现有程序的工作。对于剩下的程序,新的方法是:

  1. 我们将定义一个新的GODEBUG设置,允许单个程序选择退出新的行为。如果这样做是不可行的,可能不会添加GODEBUG设置,但这应该极其罕见。
  2. 为了兼容性添加的GODEBUG设置将被维护至少两年(四个Go版本)。一些,如http2clienthttp2server,将被维护得更长,甚至无限期。
  3. 在可能的情况下,每个GODEBUG设置都有一个关联的runtime/metrics计数器,名为/godebug/non-default-behavior/:events,它计算了一个特定程序的行为基于该设置的非默认值改变了多少次。例如,当设置`GODEBUG=http2client=0`时,`/godebug/non-default-behavior/http2client:events`计算了程序配置了多少个没有HTTP/2支持的HTTP传输。
  4. 一个程序的GODEBUG设置被配置为匹配主包的go.mod文件中列出的Go版本。如果你的程序的go.mod文件说go 1.20,你更新到Go 1.21工具链,任何在Go 1.21中改变的GODEBUG控制的行为将保持他们旧的Go 1.20行为,直到你改变go.mod说go 1.21。
  5. 一个程序可以通过在主包中使用//go:debug行来改变单个GODEBUG设置。
  6. 所有的GODEBUG设置都在一个单一的,中心的列表中记录,以便于参考。

这种方法意味着每个新版本的Go应该是旧版本Go的最好的可能的实现,即使在以后的版本中以兼容但破坏的方式改变行为时也能编译旧代码。

例如,在Go 1.21中,panic(nil)现在会导致一个(非nil)的运行时恐慌,所以recover的结果现在可以可靠地报告当前的goroutine是否在恐慌。这个新的行为由一个GODEBUG设置控制,因此依赖于主包的go.mod的go行:如果它说go 1.20或更早,panic(nil)仍然被允许。如果它说go 1.21或更晚,panic(nil)变成了一个带有runtime.PanicNilError的恐慌。并且版本基础的默认值可以通过在主包中添加这样的行来显式覆盖:

//go:debug panicnil=1

这种特性的组合意味着程序可以更新到新的工具链,同时保留他们使用的早期工具链的行为,可以根据需要对特定设置应用更细粒度的控制,并可以使用生产监控来理解实践中哪些工作使用了这些非默认行为。结合起来,这些应该使得推出新的工具链比过去更加顺畅。

参见[Go,向后兼容性,和GODEBUG(https://go.dev/doc/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做出的最重要的设计决策。

所以你在接下来的几年里看到的将是大量的新的,令人兴奋的工作,但是以一种谨慎的,兼容的方式完成,这样我们就可以保持你从一个工具链升级到下一个工具链尽可能地无聊。


孟斯特

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。
Author: mengbin
blog: mengbin
Github: mengbin92
cnblogs: 恋水无意