查看原文
其他

保持向后兼容,Go 2永不会给GO 1带去任何破坏性

CSDN 2023-09-04

【CSDN 编者按】这篇文章详细讨论了 Go 语言的向后兼容性问题。文章强调了 Go 1.21 中的新特性,以改善兼容性,并解释了为什么兼容性对于 Go 语言如此重要。文章还提到了 Go 2 的最新情况,强调了兼容性的价值,并承诺将在未来几年内以谨慎、兼容的方式进行新的、令人兴奋的工作。

原文链接:https://go.dev/blog/compat

未经允许,禁止转载!


作者 | Russ Cox       译者 | 明明如月
责编 | 夏萌
出品 | CSDN(ID:CSDNnews)

Go 1.21 引入了许多增强兼容性的新功能。我知道这听起来可能有些枯燥,但实际上,这种枯燥有时正是我们追求的。Go 1 在早期阶段曾让人感到兴奋,充满了惊喜。我们每周都会发布新的快照版本,用户可以体验我们的新功能,他们的程序也可能因此出现问题。Go 1 的发布以及随后的兼容性承诺使得新版本的 Go 变得稳定、可预测,甚至让人感觉有些枯燥。枯燥是件好事,它意味着稳定,让你能够更专注于你的工作,而不是 Go 本身的各种新变化。

本文将详细介绍我们为了保持 Go 的这种稳定状态在 Go 1.21 中所做的主要工作。

Go 1 的兼容性

我们致力于兼容性已有十多年的时间。2012 年,我们发布了一篇名为“Go 1 与 Go 程序的未来”的文章,其中明确了一个清晰的目标:

我们将确保基于 Go 1 规范编写的程序在规范的整个生命周期内都能正确编译和运行,且功能保持不变。即使发布了 Go 1 的后续版本,今天能够运行的 Go 程序也应该继续运行。

这一目标自然有其局限性。首先,兼容性主要是源代码的兼容性。当你升级到 Go 的新版本时,你还需要重新编译你的代码。其次,我们需要保证增加新的 API,但不能破坏现有代码。

文档最后部分提醒道:“[我们] 不能保证未来的更改不会影响任何程序。”并列出了一些可能仍然会造成程序无法运行的情况。

举例来说,如果你的程序依赖于某个错误的行为,当我们修复该错误后,你的程序可能会出现问题。然而,我们一直在努力尽量减少这种情况来保持 Go 的稳定性。到目前为止,我们主要采用了两种方法:API 检查和测试

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.txtpkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685pkg bytes, method (*Buffer) Available() int #53685pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488pkg cmp, type Ordered interface {} #59488pkg context, func AfterFunc(Context, func()) func() bool #57928pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661pkg context, func WithoutCancel(Context) Context #40221pkg 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类型定义为具有两个字段IPPort的结构体。由于这些字段与复合字面量中的字段匹配,程序能够成功编译。

然而,在 Go 1.1 版本中,程序无法编译,编译器报错:“too few initializers in struct literal.”。出现这个问题的原因是我们在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](https://go.dev/pkg/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,然后通过saveload函数进行了时间的保存和加载,并期望重新加载后的时间与原始时间相同。如果saveload函数使用的是微秒精度,那么在 Go 1 版本中,这段代码将正常工作。然而,在 Go 1.1 版本中,由于精度的提升,该测试将无法通过。

为了解决这类测试中的问题,我们引入了[Round](https://go.dev/pkg/time/#Time.Round)[Truncate](https://go.dev/pkg/time/#Time.Truncate)方法来舍弃不必要的精度,并在发布说明中详细记录了可能的问题及解决方法。

这些例子凸显了测试的重要性,特别是在发现与 API 检查不同的兼容性问题方面。尽管测试不能完全保证兼容性,但它在辨识问题方面比仅进行 API 检查更为全面。在测试过程中,我们确实发现了许多问题,它们违反了兼容性规则,因此我们在发布之前对其进行了回滚。时间精度的变化是一个启示性的例子:虽然这一改变确实破坏了一些程序,但我们还是选择了发布。我们做出这一决策是因为增加的精度是一项改进,并且仍在函数所记录的行为范围内。

这个案例说明,尽管付出了极大的努力和关注,改变 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 和 Go 1.6 的返回顺序有所不同。

虽然这个改动使排序速度提高了 10%,但如果程序期望特定的输出,这一改动可能会导致程序出错。这反映了兼容性问题的挑战性。我们不仅要避免破坏程序,还要避免被固化到未明确记录的实现细节中。

Compress/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 RFC 中的样例,该样例经常展示带有前导零的十进制 IP 地址,例如将 IP 地址 18.032.4.011 读作 18.32.4.11。我们后来发现,与 BSD 派生的 C 库不同,它把 IP 地址中的前导零视为八进制数字的开头。因此,18.032.4.011 被解释为了 18.26.4.9。

这一点造成了 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,而该协议在此情景下无法运行。

Go 的目标是默认支持现代协议,但此示例表明,激活 HTTP/2 可能会破坏程序,而程序本身或 Go 并没有任何错误。由此受影响的开发人员可以选择回退到 Go 1.5 版本,但这并不是理想的解决方案。因此,Go 1.6 在发布说明中详细记录了这个更改,并提供了方便地禁用 HTTP/2 的方式。

实际上,Go 1.6 提供了两种禁用 HTTP/2 的方法:通过设置包 API 的 TLSNextProto 字段,或设置 GODEBUG 环境变量:

GODEBUG=http2client=0 ./myprogGODEBUG=http2server=0 ./myprogGODEBUG=http2client=0,http2server=0 ./myprog

值得注意的是,Go 1.21 将此 GODEBUG 机制普及为所有可能的破坏性更改的标准。

SHA1。以下是协议更改的一个微妙情况。现今,基于 SHA1 的 HTTPS 证书不应再使用。自 2015 年证书颁发机构停发、主要浏览器自 2017 年停用以后,Go 1.18 在 2020 年初默认禁用了它们的支持,但可以通过 GODEBUG 设置进行修改。并且宣布在 Go 1.19 中完全移除 GODEBUG 设置。

Kubernetes 团队向我们反映,一些系统仍在使用私有 SHA1 证书。而强迫这些企业升级证书基础设施并非 Kubernetes 的职责,且保持对 SHA1 支持的分叉 crypto/tlsnet/http 将非常复杂,这里并没有深入讨论安全性问题。因此,我们同意比最初计划的更长时间保留覆盖设置,以便为有序过渡提供更多时间。毕竟,我们的目标是尽量减少对程序的干扰。

Go 1.21 对 GODEBUG 的增强支持

Go 1.21 在微妙的兼容性问题研究方面有所改进,并扩展并规范了 GODEBUG 的使用。

首先,对于任何 Go 1 兼容性所允许但可能对现有程序造成影响的更改,我们会全面评估潜在兼容性问题,并精心改进以确保现有程序尽可能正常运行。针对剩余的程序,新方法如下:

  1. 定义新的 GODEBUG 设置,允许单个程序选择退出新行为。如果有些现有程序与新变化不兼容,可以通过 GODEBUG 设置来回到旧行为。

  2. 为了保持兼容性,新加入的 GODEBUG 设置至少会保留两年(四个 Go 版本)。其中一些设置,例如 http2client http2server,将长期保留。

  3. 若可行,每个 GODEBUG 设置都将拥有一个名为 /godebug/non-default-behavior/<name>:events[runtime/metrics](https://go.dev/pkg/runtime/metrics/) 计数器,记录特定程序基于该设置的非默认值更改次数。

  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) 现在会导致一个(非空)的运行时 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 2 规范会打破旧的 Go 1 程序吗?

答案是永远不会。Go 2 不会停止对旧程序的编译支持。Go 2 已经以 Go 1 的主要修订版本形式发布。旧 Go 1 程序不会被 Go 2 打破。相反,我们将进一步关注兼容性,因为这比任何可能的过去的决裂更有价值。事实上,优先考虑兼容性是我们为 Go 1 做出的最重要的设计决策。

因此,你将在未来几年中看到大量新的、激动人心的工作,但将以谨慎、兼容的方式进行,使你从一个工具链升级到下一个工具链的过程尽可能平滑。

广大网友讨论

本文引发了广大网友的激烈讨论。有网友提到 Perl、Haskell 等其他编程语言中存在的类似功能。例如,Perl 允许通过在文件开头使用特定版本来模拟该版本的行为。Haskell 则允许单个文件打开和关闭语言特性。很多网友对 Go 语言向后兼容的特性表示支持,他们认为 Go 语言的向后兼容性是其成功的关键因素。有些网友持怀疑态度,尽管这些特性听起来很有吸引力,但一些用户担心它们可能导致复杂性和问题。特别是在大型代码库中,这种灵活性可能会导致混乱和不可预测的问题。还有些网友则保持中立,他们认为虽然改进是必要的,但需要权衡利弊,仔细考虑对现有开发者和项目的影响,同时也关注社区的反馈和需求。

你对 Go 向后兼容的设计怎么看?你有没有更好的建议?

参考链接

  1. Go 1 与 Go 程序的未来:https://go.dev/doc/go1compat

  2. 兼容性文档:https://go.dev/doc/go1compat

  3. Go 1.6 提供了两种禁用 HTTP/2 的方法:https://go.dev/doc/go1.6#http2

  4. 统一、中心列表:https://go.dev/doc/godebug#history

  5. Go、向后兼容性和 GODEBUG:https://go.dev/doc/godebug

  6. Go 1 和 Go 程序的未来:https://go.dev/doc/go1compat

推荐阅读:

钉钉个人版开放内测:没有打卡已读功能;印度用本土操作系统”玛雅“取代Windows;Kubernetes 1.28发布|极客头条

三层软件架构导致程序员负担翻倍?

非技术岗的 AI 产品经理年薪近百万美元,美国公司开启“抢人大战”!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存