Go 博客
实验、简化、发布
引言
这是我上周在 GopherCon 2019 上演讲的博客文章版本。
我们都在通往 Go 2 的道路上,但我们中的任何人都不知道这条路到底通向何方,有时甚至不知道路的方向。本文将讨论我们是如何找到并遵循通往 Go 2 的道路的。这个过程是这样的。

我们对现有的 Go 进行实验,以更好地理解它,学习什么有效,什么无效。然后我们对可能的更改进行实验,以更好地理解它们,再次学习什么有效,什么无效。根据我们在这些实验中学到的东西,我们进行简化。然后我们再次实验。然后我们再次简化。如此往复。如此往复。
简化的四大“R”
在这个过程中,我们可以通过四种主要方式来简化编写 Go 程序时的整体体验:重塑(reshaping)、重新定义(redefining)、移除(removing)和限制(restricting)。
通过重塑来简化
我们简化的第一种方式是将现有事物重塑成新的形式,从而使整体更简单。
我们编写的每一个 Go 程序都服务于测试 Go 本身的实验。在 Go 的早期,我们很快就发现,编写像 addToList
这样的函数很常见。
func addToList(list []int, x int) []int {
n := len(list)
if n+1 > cap(list) {
big := make([]int, n, (n+5)*2)
copy(big, list)
list = big
}
list = list[:n+1]
list[n] = x
return list
}
我们会为字节切片、字符串切片等编写相同的代码。我们的程序过于复杂,因为 Go 太简单了。
因此,我们将程序中许多像 addToList
这样的函数重塑为 Go 本身提供的一个函数。添加 append
使 Go 语言稍微复杂了一些,但总体而言,它使编写 Go 程序的整体体验更加简单,即使算上学习 append
的成本。
这里是另一个例子。对于 Go 1,我们查看了 Go 分发版中大量的开发工具,并将它们重塑为一个新命令。
5a 8g
5g 8l
5l cgo
6a gobuild
6cov gofix → go
6g goinstall
6l gomake
6nm gopack
8a govet
go
命令现在如此重要,以至于很容易忘记我们曾经在没有它并且付出了多少额外工作的情况下走过了多远。
我们为 Go 分发版添加了代码和复杂性,但总体而言,我们简化了编写 Go 程序的体验。新的结构也为其他有趣的实验创造了空间,我们稍后会看到。
通过重新定义来简化
我们简化的第二种方式是通过重新定义已有的功能,使其能够做更多的事情。就像通过重塑来简化一样,通过重新定义来简化也使程序编写起来更简单,但现在无需学习新东西。
例如,append
最初被定义为只能从切片读取。将一个字节切片附加到另一个字节切片时,您可以附加另一个字节切片中的字节,但不能附加字符串中的字节。我们重新定义了 append,允许从字符串附加,而无需向语言添加任何新内容。
var b []byte
var more []byte
b = append(b, more...) // ok
var b []byte
var more string
b = append(b, more...) // ok later
通过移除来简化
我们简化的第三种方式是移除功能,当它不像我们预期的那样有用或不那么重要时。移除功能意味着少学一样东西,少修复一个 bug,少一些分心或被错误使用的东西。当然,移除也会迫使用户更新现有程序,可能使它们变得更复杂,以弥补移除。但总体结果仍然是编写 Go 程序的流程可以变得更简单。
一个例子是当我们从语言中移除了非阻塞通道操作的布尔形式时。
ok := c <- x // before Go 1, was non-blocking send
x, ok := <-c // before Go 1, was non-blocking receive
使用 select
也可以实现这些操作,这使得选择使用哪种形式变得令人困惑。移除它们在不降低语言功能的前提下简化了语言。
通过限制来简化
我们也可以通过限制允许的范围来简化。从第一天起,Go 就限制了 Go 源文件的编码:它们必须是 UTF-8。这一限制使尝试读取 Go 源文件的每个程序都更简单。这些程序不必担心以 Latin-1、UTF-16、UTF-7 或其他任何编码方式编码的 Go 源文件。
另一个重要的限制是 gofmt
用于程序格式化。任何不使用 gofmt
格式化的 Go 代码都会被拒绝,但我们已经建立了一个约定,即重写 Go 程序的工具应将其保留为 gofmt
格式。如果您也将程序保留为 gofmt
格式,那么这些重写工具将不会进行任何格式化更改。当您比较前后差异时,您看到的唯一差异就是实际更改。这一限制简化了程序重写工具,并促成了 goimports
、gorename
等许多成功的实验。
Go 开发流程
这种实验和简化的循环是我们过去十年所做的事情的一个好模型,但它有一个问题:它太简单了。我们不能只进行实验和简化。
我们必须发布结果。我们必须使其可用。当然,使用它会带来更多的实验,以及更多的简化,过程会不断循环。

我们第一次向大家发布 Go 是在 2009 年 11 月 10 日。然后,在你们的帮助下,我们在 2012 年 3 月一起发布了 Go 1。从那时起,我们已经发布了十二个 Go 版本。所有这些都是重要的里程碑,旨在实现更多实验,帮助我们更多地了解 Go,当然,也使 Go 可用于生产环境。
当我们发布 Go 1 时,我们明确地将重点转移到使用 Go 上,以便在尝试任何涉及语言更改的进一步简化之前,更好地理解这个版本的语言。我们需要花时间进行实验,真正了解什么有效,什么无效。
当然,自 Go 1 以来我们已经发布了十二个版本,所以我们仍在进行实验、简化和发布。但是,我们专注于如何简化 Go 开发,而无需重大的语言更改,也无需破坏现有的 Go 程序。例如,Go 1.5 发布了第一个并发垃圾收集器,随后的版本对其进行了改进,通过消除暂停时间作为持续的担忧来简化 Go 开发。
在 2017 年的 Gophercon 上,我们宣布经过五年的实验,是时候重新考虑对简化 Go 开发进行重大更改了。我们通往 Go 2 的道路与通往 Go 1 的道路相同:实验、简化和发布,朝着简化 Go 开发的总体目标前进。
对于 Go 2,我们认为最重要要解决的具体主题是错误处理、泛型和依赖项。此后,我们意识到另一个重要主题是开发工具。
本文的其余部分将讨论我们在这些领域的工作如何遵循这条道路。沿途,我们将绕道而行,停下来详细检查即将随 Go 1.13 一起发布的错误处理的详细技术内容。
Errors
当所有输入都有效且正确,并且程序所依赖的一切都没有失败时,编写一个在所有情况下都能正确运行的程序就已经足够困难了。当引入错误时,编写一个无论发生什么错误都能正确运行的程序会更加困难。
作为思考 Go 2 的一部分,我们想更好地了解 Go 是否能帮助简化这项工作。
有两个方面可能被简化:错误值和错误语法。我们将逐一讨论,并以我承诺的 Go 1.13 错误值更改的技术细节为重点。
错误值
错误值必须从某个地方开始。这是 os
包第一个版本中的 Read
函数。
export func Read(fd int64, b *[]byte) (ret int64, errno int64) {
r, e := syscall.read(fd, &b[0], int64(len(b)));
return r, e
}
那时还没有 File
类型,也没有错误类型。Read
和该包中的其他函数直接返回底层 Unix 系统调用的 errno int64
。
这段代码是在 2008 年 9 月 10 日下午 12:14 提交的。和当时所有代码一样,这是一个实验,代码更改很快。两个小时零五分钟后,API 发生了变化。
export type Error struct { s string }
func (e *Error) Print() { … } // to standard error!
func (e *Error) String() string { … }
export func Read(fd int64, b *[]byte) (ret int64, err *Error) {
r, e := syscall.read(fd, &b[0], int64(len(b)));
return r, ErrnoToError(e)
}
这个新 API 引入了第一个 Error
类型。一个错误包含一个字符串,并可以返回该字符串并将其打印到标准错误。
这里的意图是推广到整数代码之外。我们从过去的经验知道,操作系统错误号的表示方法太有限了,不将关于错误的全部细节塞进 64 位整数将使程序更简单。使用错误字符串在过去对我们来说效果不错,所以我们在这里也做了同样的事情。这个新 API 持续了七个月。
第二年四月,在更多使用接口的经验之后,我们决定进一步推广,并通过将 os.Error
类型本身变成一个接口,允许用户定义的错误实现。我们通过移除 Print
方法来简化。
两年后的 Go 1,根据 Roger Peppe 的建议,os.Error
成为了内置的 error
类型,并将 String
方法重命名为 Error
。从那时起就没有改变过。但我们编写了许多 Go 程序,因此我们在如何最好地实现和使用错误方面进行了大量实验。
错误即值
将 error
作为一个简单的接口,并允许许多不同的实现,意味着我们拥有整个 Go 语言来定义和检查错误。我们喜欢说 错误就是值,与其他任何 Go 值一样。
这里有一个例子。在 Unix 上,尝试拨打网络连接最终会使用 connect
系统调用。该系统调用返回 syscall.Errno
,这是一个命名整数类型,代表系统调用错误号并实现了 error
接口。
package syscall
type Errno int64
func (e Errno) Error() string { ... }
const ECONNREFUSED = Errno(61)
... err == ECONNREFUSED ...
syscall
包还为主机操作系统定义的错误号定义了命名常量。在这种情况下,在此系统上,ECONNREFUSED
是数字 61。从函数获取错误的 कोड 可以通过普通的 值相等性 来测试错误是否为 ECONNREFUSED
。
往上一层,在 os
包中,任何系统调用失败都使用一个更大的错误结构来报告,该结构记录了尝试的操作以及错误。这些结构很少。这个,SyscallError
,描述了一个调用特定系统调用但没有记录附加信息的错误。
package os
type SyscallError struct {
Syscall string
Err error
}
func (e *SyscallError) Error() string {
return e.Syscall + ": " + e.Err.Error()
}
再往上一层,在 net
包中,任何网络故障都使用一个更大的错误结构来报告,该结构记录了周围网络操作的详细信息,例如拨号或监听,以及涉及的网络和地址。
package net
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
func (e *OpError) Error() string { ... }
将这些组合起来,net.Dial
等操作返回的错误可以格式化为字符串,但它们也是结构化的 Go 数据值。在这种情况下,错误是 net.OpError
,它为 os.SyscallError
添加了上下文,而 os.SyscallError
又为 syscall.Errno
添加了上下文。
c, err := net.Dial("tcp", "localhost:50001")
// "dial tcp [::1]:50001: connect: connection refused"
err is &net.OpError{
Op: "dial",
Net: "tcp",
Addr: &net.TCPAddr{IP: ParseIP("::1"), Port: 50001},
Err: &os.SyscallError{
Syscall: "connect",
Err: syscall.Errno(61), // == ECONNREFUSED
},
}
当我们说错误是值时,我们既指整个 Go 语言都可以用来定义它们,也指整个 Go 语言都可以用来检查它们。
这里有一个来自 net 包的例子。事实证明,当您尝试连接套接字时,大多数时候您会连接成功或收到连接被拒绝的错误,但有时您可能会收到一个虚假的 EADDRNOTAVAIL
,没有好的原因。Go 通过重试来保护用户程序免受这种失败模式的影响。为此,它必须检查错误结构以找出内部的 syscall.Errno
是否为 EADDRNOTAVAIL
。
这是代码。
func spuriousENOTAVAIL(err error) bool {
if op, ok := err.(*OpError); ok {
err = op.Err
}
if sys, ok := err.(*os.SyscallError); ok {
err = sys.Err
}
return err == syscall.EADDRNOTAVAIL
}
一个 类型断言 去除了任何 net.OpError
的包装。然后第二个类型断言去除了任何 os.SyscallError
的包装。然后函数检查解包后的错误是否等于 EADDRNOTAVAIL
。
通过多年经验,通过对 Go 错误的这些实验,我们学到的是,能够定义任意的 error
接口实现,同时拥有完整的 Go 语言来构造和解构错误,并且不需要使用任何单一的实现,这是非常强大的。
这些属性——即错误是值,并且没有一个强制的错误实现——都很重要,值得保留。
不强制要求单一的错误实现,使得每个人都可以实验错误可能提供的附加功能,从而产生了许多包,例如 github.com/pkg/errors、gopkg.in/errgo.v2、github.com/hashicorp/errwrap、upspin.io/errors、github.com/spacemonkeygo/errors 等。
然而,不受限制的实验有一个问题是,作为客户端,您必须针对您可能遇到的所有可能实现的并集进行编程。对于 Go 2,一个看似值得探索的简化是定义一个标准版本,包含常用添加的功能,以可选接口的形式,以便不同的实现能够互操作。
Unwrap (解包)
在这些包中最常添加的功能是某种可以调用的方法,用于移除错误的上下文,返回内部的错误。包使用不同的名称和含义来表示此操作,有时它移除一个级别的上下文,有时它移除尽可能多的级别。
对于 Go 1.13,我们引入了一个约定,即一个添加可移除上下文到内部错误的实现,应该实现一个 Unwrap
方法,该方法返回内部错误,解包上下文。如果没有适合暴露给调用者的内部错误,要么错误不应该有 Unwrap
方法,要么 Unwrap
方法应该返回 nil。
// Go 1.13 optional method for error implementations.
interface {
// Unwrap removes one layer of context,
// returning the inner error if any, or else nil.
Unwrap() error
}
调用此可选方法的途径是调用辅助函数 errors.Unwrap
,该函数处理错误本身为 nil 或根本没有 Unwrap
方法的情况。
package errors
// Unwrap returns the result of calling
// the Unwrap method on err,
// if err’s type defines an Unwrap method.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error
我们可以使用 Unwrap
方法来编写一个更简单、更通用的 spuriousENOTAVAIL
版本。而不是查找特定的错误包装实现,如 net.OpError
或 os.SyscallError
,通用版本可以循环调用 Unwrap
来移除上下文,直到它达到 EADDRNOTAVAIL
或没有错误为止。
func spuriousENOTAVAIL(err error) bool {
for err != nil {
if err == syscall.EADDRNOTAVAIL {
return true
}
err = errors.Unwrap(err)
}
return false
}
然而,这个循环非常普遍,以至于 Go 1.13 定义了第二个函数 errors.Is
,它会重复解包错误以查找特定目标。因此,我们可以用对 errors.Is
的单个调用来替换整个循环。
func spuriousENOTAVAIL(err error) bool {
return errors.Is(err, syscall.EADDRNOTAVAIL)
}
在这一点上,我们可能甚至不需要定义这个函数;直接在调用点调用 errors.Is
会同样清晰,并且更简单。
Go 1.13 还引入了一个函数 errors.As
,它会解包直到找到特定的实现类型。
如果您想编写可以处理任意包装错误的 बढ़त,errors.Is
是错误相等性检查的包装感知版本。
err == target
→
errors.Is(err, target)
而 errors.As
是错误类型断言的包装感知版本。
target, ok := err.(*Type)
if ok {
...
}
→
var target *Type
if errors.As(err, &target) {
...
}
是否解包?
是否使错误能够被解包是一个 API 决策,就像是否导出结构字段是一个 API 决策一样。有时适合将该详细信息暴露给调用代码,有时则不适合。当适合时,实现 Unwrap。当不适合时,不要实现 Unwrap。
到目前为止,fmt.Errorf
一直没有将格式化为 %v
的底层错误暴露给调用方检查。也就是说,fmt.Errorf
的结果无法被解包。考虑这个例子。
// errors.Unwrap(err2) == nil
// err1 is not available (same as earlier Go versions)
err2 := fmt.Errorf("connect: %v", err1)
如果 err2
返回给调用方,那么调用方将永远无法打开 err2
并访问 err1
。我们在 Go 1.13 中保留了这一特性。
对于您确实希望允许解包 fmt.Errorf
结果的情况,我们还添加了一个新的打印动词 %w
,它像 %v
一样格式化,需要一个错误值参数,并使结果错误的 Unwrap
方法返回该参数。在我们的例子中,假设我们将 %v
替换为 %w
。
// errors.Unwrap(err4) == err3
// (%w is new in Go 1.13)
err4 := fmt.Errorf("connect: %w", err3)
现在,如果 err4
返回给调用方,调用方可以使用 Unwrap
来检索 err3
。
重要的是要注意,像“总是使用 %v
(或从不实现 Unwrap
)”或“总是使用 %w
(或总是实现 Unwrap
)”这样的绝对规则与“从不导出结构字段”或“总是导出结构字段”这样的绝对规则一样错误。相反,正确的决定取决于调用者是否应该能够检查和依赖使用 %w
或实现 Unwrap
所暴露的附加信息。
作为对这一点的一个说明,标准库中所有已经有一个导出 Err
字段的错误包装类型现在也有一个返回该字段的 Unwrap
方法,但具有未导出错误字段的实现则没有,并且对 fmt.Errorf
使用 %v
的现有用法仍然使用 %v
,而不是 %w
。
错误值打印(已放弃)
与 Unwrap 的设计草案一起,我们还发布了一个 用于更丰富的错误打印的可选方法的设计草案,包括堆栈帧信息和对本地化、翻译错误的支援。
// Optional method for error implementations
type Formatter interface {
Format(p Printer) (next error)
}
// Interface passed to Format
type Printer interface {
Print(args ...interface{})
Printf(format string, args ...interface{})
Detail() bool
}
这个比 Unwrap
简单,我在这里不详述。在我们与 Go 社区在冬季讨论设计时,我们了解到该设计不够简单。它对单个错误类型的实现来说太难了,而且对现有程序的帮助不够。总的来说,它并没有简化 Go 开发。
因此,在这次社区讨论之后,我们放弃了这个打印设计。
错误语法
以上是错误值。让我们简要看看错误语法,这是另一个被放弃的实验。
这是标准库中 compress/lzw/writer.go
的一些代码。
// Write the savedCode if valid.
if e.savedCode != invalidCode {
if err := e.write(e, e.savedCode); err != nil {
return err
}
if err := e.incHi(); err != nil && err != errOutOfCodes {
return err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
if err := e.write(e, eof); err != nil {
return err
}
一眼看去,这段代码大约有一半是错误检查。我读它时眼睛都会发花。我们知道,写起来冗长、读起来冗长的代码很容易被误读,这使得它很容易成为隐藏 bug 的温床。例如,这三个错误检查中的一个与其他不同,这是一个很容易在快速浏览时忽略的事实。如果您在调试这段代码,需要多长时间才能注意到这一点?
在去年的 Gophercon 上,我们 提出了一个新控制流结构的草案设计,该结构由关键字 check
标记。Check
消耗函数调用或表达式的错误结果。如果错误非 nil,check
会返回该错误。否则,check
的计算结果为调用中的其他结果。我们可以使用 check
来简化 lzw 代码。
// Write the savedCode if valid.
if e.savedCode != invalidCode {
check e.write(e, e.savedCode)
if err := e.incHi(); err != errOutOfCodes {
check err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)
同一代码的这个版本使用了 check
,它删除了四行代码,更重要的是突出了 e.incHi
的调用允许返回 errOutOfCodes
。
也许最重要的是,该设计还允许定义错误处理块,以便在后续检查失败时运行。这将允许您只编写一次共享的上下文添加代码,就像在这个片段中一样。
handle err {
err = fmt.Errorf("closing writer: %w", err)
}
// Write the savedCode if valid.
if e.savedCode != invalidCode {
check e.write(e, e.savedCode)
if err := e.incHi(); err != errOutOfCodes {
check err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)
本质上,check
是编写 if
语句的简写方式,而 handle
类似于 defer
,但仅用于错误返回路径。与 C++ 中的异常不同,此设计保留了 Go 的一个重要特性,即每个潜在的失败调用都在代码中显式标记,现在使用 check
关键字而不是 if err != nil
。
此设计的主要问题是 handle
与 defer
的重叠太多,并且以令人困惑的方式重叠。
在五月份,我们发布了 一个包含三个简化功能的新设计:为了避免与 defer
的混淆,该设计放弃了 handle
,而是使用 defer
;为了匹配 Rust 和 Swift 中的类似想法,该设计将 check
重命名为 try
;为了允许以现有解析器(如 gofmt
)能够识别的方式进行实验,它将 check
(现在是 try
)从关键字更改为内置函数。
现在相同的代码看起来会像这样。
defer errd.Wrapf(&err, "closing writer")
// Write the savedCode if valid.
if e.savedCode != invalidCode {
try(e.write(e, e.savedCode))
if err := e.incHi(); err != errOutOfCodes {
try(err)
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
try(e.write(e, eof))
我们花费了整个六月在 GitHub 上公开讨论这个提议。
check
或 try
的基本思想是缩短每个错误检查处重复的语法量,特别是从视图中移除 return
语句,保持错误检查的显式性,并更好地突出有趣的变体。然而,在公开反馈讨论中提出的一个有趣观点是,没有显式的 if
语句和 return
,就没有地方放置调试打印,没有地方放置断点,也没有代码可以在代码覆盖率结果中显示为未执行。我们追求的好处是以使这些情况更加复杂为代价的。总体而言,基于这一点以及其他考虑因素,不确定整体结果是否会简化 Go 开发,因此我们放弃了这个实验。
以上就是关于错误处理的全部内容,这是今年的一项主要重点。
泛型
现在来看看一些不太有争议的内容:泛型。
我们为 Go 2 确定的第二个重大主题是某种编写类型参数代码的方法。这将能够编写泛型数据结构,以及编写与任何类型的切片、任何类型的通道或任何类型的映射一起工作的泛型函数。例如,这是一个泛型通道过滤器。
// Filter copies values from c to the returned channel,
// passing along only those values satisfying f.
func Filter(type value)(f func(value) bool, c <-chan value) <-chan value {
out := make(chan value)
go func() {
for v := range c {
if f(v) {
out <- v
}
}
close(out)
}()
return out
}
我们从 Go 开始工作起就一直在考虑泛型,并且在 2010 年写了并否决了我们的第一个具体设计。到 2013 年底,我们又写了三个设计并被否决。四个被放弃的实验,但不是失败的实验。我们从它们中学到了东西,就像我们从 check
和 try
中学到的一样。每一次,我们都学到通往 Go 2 的道路不在那个确切的方向,并注意到其他可能值得探索的方向。但到 2013 年,我们决定需要专注于其他问题,所以我们将整个主题搁置了几年。
去年,我们开始重新探索和实验,并在去年的 Gophercon 上提出了一个基于契约思想的 新设计。我们继续进行实验和简化,并与编程语言理论专家合作,以更好地理解该设计。
总的来说,我希望我们正朝着一个好的方向前进,朝着一个将简化 Go 开发的设计。即便如此,我们也可能发现这个设计不起作用。我们可能不得不放弃这个实验,并根据我们学到的东西调整我们的道路。我们将拭目以待。
在 Gophercon 2019 上,Ian Lance Taylor 谈论了为什么我们可能想为 Go 添加泛型,并简要预览了最新的设计草案。详情请参阅他的博客文章《为什么需要泛型?》。
依赖项
我们为 Go 2 确定的第三个重大主题是依赖项管理。
2010 年,我们发布了一个名为 goinstall
的工具,我们称之为“包安装的实验”。它下载依赖项并将它们存储在您的 Go 分发树中的 GOROOT 里。
在对 goinstall
进行实验的过程中,我们了解到 Go 分发版和已安装的包应该分开存放,这样就可以在不丢失所有 Go 包的情况下更改到新的 Go 分发版。因此,在 2011 年,我们引入了 GOPATH
,一个环境变量,用于指定在 Go 主要分发版中找不到的包的查找位置。
添加 GOPATH 创造了更多存放 Go 包的地方,但总体上简化了 Go 开发,因为它将 Go 分发版与 Go 库分开了。
兼容性
goinstall
实验有意省略了明确的包版本概念。相反,goinstall
始终下载最新副本。我们这样做是为了能够专注于包安装的其他设计问题。
Goinstall
在 Go 1 中成为了 go get
。当人们询问版本时,我们鼓励他们通过创建额外的工具进行实验,他们也这样做了。我们鼓励包作者为用户提供与 Go 1 库相同的向后兼容性。引用 Go FAQ
“旨在公开使用的包应在演变过程中努力保持向后兼容性。
如果需要不同的功能,请添加新名称而不是更改旧名称。
如果需要完全中断,请创建一个具有新导入路径的新包。”
这个约定通过限制作者可以做什么来简化使用包的整体体验:避免对 API 进行破坏性更改;为新功能提供新名称;并为整个新包设计提供新导入路径。
当然,人们一直在实验。最有趣的实验之一是由 Gustavo Niemeyer 发起的。他创建了一个名为 gopkg.in
的 Git 重定向器,它为不同的 API 版本提供了不同的导入路径,以帮助包作者遵循为新包设计提供新导入路径的约定。
例如,GitHub 存储库 go-yaml/yaml 中的 Go 源代码在 v1 和 v2 语义版本标签中具有不同的 API。gopkg.in
服务器提供了这些不同的导入路径 gopkg.in/yaml.v1 和 gopkg.in/yaml.v2。
提供向后兼容性的约定,以便可以使用新版本的包来替代旧版本,这使得 go get
的非常简单的规则——“始终下载最新副本”——即使在今天也能很好地工作。
版本控制和打包
但在生产环境中,您需要更精确地确定依赖项版本,以使构建可重现。
许多人实验过它应该是什么样子,构建了满足他们需求的工具,包括 Keith Rarick 的 goven
(2012)和 godep
(2013),Matt Butcher 的 glide
(2014),以及 Dave Cheney 的 gb
(2015)。所有这些工具都使用将依赖项包复制到您自己的源代码控制存储库的模型。用于使这些包可导入的确切机制各不相同,但它们都比看起来应该的要复杂。
在社区广泛讨论之后,我们采纳了 Keith Rarick 的一项提议,即添加对引用复制的依赖项的显式支持,而无需 GOPATH 技巧。这是通过重塑来简化的:就像 addToList
和 append
一样,这些工具已经在实现这个概念,但比它需要的更笨拙。添加对 vendor 目录的显式支持总体上简化了这些用法。
在 go
命令中发布 vendor 目录导致了对 vendoring 本身的更多实验,我们意识到我们引入了一些问题。最严重的是我们丢失了包的唯一性。以前,在任何给定的构建过程中,一个导入路径可能出现在许多不同的包中,并且所有导入都指向同一个目标。现在随着 vendoring,不同包中的相同导入路径可能指向该包的不同 vendor 副本,所有这些都将出现在最终生成的二进制文件中。
那时,我们还没有为这个属性命名:包的唯一性。这只是 GOPATH 模型的工作方式。直到它消失,我们才完全理解它。
这里与 check
和 try
错误语法提案有一个相似之处。在这种情况下,我们依赖于可见的 return
语句的工作方式,直到我们考虑移除它之前,我们并没有完全理解它。
当我们添加 vendor 目录支持时,有许多不同的工具来管理依赖项。我们认为,关于 vendor 目录的格式和 vendoring 元数据的清晰约定将允许各种工具互操作,就像关于如何在文本文件中存储 Go 程序一样,这使得 Go 编译器、文本编辑器和 goimports
、gorename
等工具之间能够互操作。
事实证明,这是天真的乐观。vendoring 工具在语义上都有细微差别。互操作将需要更改所有这些工具以就语义达成一致,这可能会破坏它们各自的用户。融合没有发生。
Dep
在 2016 年的 Gophercon 上,我们开始着手定义一个单一的工具来管理依赖项。作为该工作的一部分,我们对不同类型的用户进行了调查,以了解他们在依赖项管理方面的需求,然后一个团队开始开发一个新工具,这个工具后来成为了 dep
。
Dep
旨在取代所有现有的依赖项管理工具。目标是通过将现有的不同工具重塑为一个工具来简化。它部分实现了这一点。Dep
还通过在项目树的顶部只有一个 vendor 目录,为其用户恢复了包的唯一性。
但是 dep
也引入了一个我们花了很长时间才完全理解的严重问题。问题在于 dep
采纳了 glide
的设计选择,即支持并鼓励对给定包进行不兼容的更改而不改变导入路径。
这是一个例子。假设您正在构建自己的程序,并且需要一个配置文件,因此您使用了流行的 Go YAML 包的第 2 版。

现在假设您的程序导入了 Kubernetes 客户端。事实证明,Kubernetes 大量使用 YAML,并且它使用了同一个流行包的第 1 版。

第 1 版和第 2 版具有不兼容的 API,但它们也有不同的导入路径,因此对于给定导入指的是哪个版本没有歧义。Kubernetes 获取第 1 版,您的配置解析器获取第 2 版,一切正常。
Dep
放弃了这种模型。yaml 包的第 1 版和第 2 版现在将具有相同的导入路径,从而产生冲突。使用相同的导入路径来表示两个不兼容的版本,再加上包的唯一性,使得您无法构建以前可以构建的这个程序。

我们花了很长时间才理解这个问题,因为我们长期以来一直应用“新 API 意味着新导入路径”的约定,以至于我们把它当作理所当然。dep 实验帮助我们更好地理解了这个约定,并给它起了一个名字:导入兼容性规则。
“如果一个旧包和一个新包具有相同的导入路径,那么新包必须向后兼容旧包。”
Go Modules
我们借鉴了 dep 实验中的优点以及我们学到的缺点,并进行了一个名为 vgo
的新设计实验。在 vgo
中,包遵循导入兼容性规则,以便我们提供包的唯一性,但仍然不会破坏我们刚刚看到的那个构建。这使我们能够简化设计的其他部分。
除了恢复导入兼容性规则之外,vgo
设计的另一个重要部分是为一组包的概念命名,并允许将该分组与源代码存储库边界分离。一组 Go 包的名称是模块,因此我们现在将该系统称为 Go modules。
Go modules 现在已与 go
命令集成,完全无需复制 vendor 目录。
替换 GOPATH
随着 Go modules 的出现,GOPATH 作为全局命名空间的时代宣告结束。将现有 Go 用法和工具转换为 modules 所需的大部分繁重工作都源于这一变化,即从 GOPATH 迁移。
GOPATH 的基本思想是,GOPATH 目录树是正在使用的版本全局真相的来源,并且在使用版本时,您在不同目录之间移动时版本不会改变。但全局 GOPATH 模式与生产环境中对每个项目进行可重现构建的要求直接冲突,而后者在许多重要方面简化了 Go 的开发和部署体验。
每个项目可重现构建意味着,当您在项目 A 的检出中工作时,您将获得与其他项目 A 开发人员在该提交时获得的依赖项版本相同的集合,由 go.mod
文件定义。当您切换到项目 B 的检出时,您将获得该项目的选定依赖项版本,与项目 B 的其他开发人员获得的相同。但这些可能与项目 A 不同。当您从项目 A 切换到项目 B 时,依赖项版本集合的变化对于保持您的开发与 A 和 B 上的其他开发人员同步是必要的。不能再存在一个全局 GOPATH 了。
采用 modules 的复杂性大部分直接源于一个全局 GOPATH 的丢失。包的源代码在哪里?以前,答案仅取决于您的 GOPATH 环境变量,而大多数人很少更改它。现在,答案取决于您正在处理的项目,这可能会经常改变。所有内容都需要为这一新约定进行更新。
大多数开发工具使用 go/build
包来查找和加载 Go 源代码。我们一直保持该包工作,但 API 没有考虑到 modules,我们添加的用于避免 API 更改的变通方法比我们期望的要慢。我们发布了一个替代品 golang.org/x/tools/go/packages
。开发工具现在应该使用它。它同时支持 GOPATH 和 Go modules,并且速度更快,使用也更方便。在一两个版本中,我们可能会将其移入标准库,但目前 golang.org/x/tools/go/packages
是稳定且可用的。
Go Module 代理
modules 简化 Go 开发的一种方式是将一组包的概念与它们存储的底层源代码存储库分离开来。
当我们与 Go 用户谈论依赖项时,几乎所有在公司使用 Go 的人都询问如何通过自己的服务器路由 go get
包的获取,以更好地控制可以使用哪些代码。即使是开源开发者也担心依赖项消失或意外更改,导致他们的构建中断。在 modules 之前,用户尝试了复杂的解决方案来解决这些问题,包括拦截 go
命令运行的版本控制命令。
Go modules 的设计使得引入一个模块代理的概念变得容易,该模块代理可以被请求特定的模块版本。
公司现在可以轻松运行自己的模块代理,其中包含关于允许内容和缓存副本存储位置的自定义规则。开源的 Athens 项目构建了一个这样的代理, Aaron Schlesinger 在 2019 年的 Gophercon 上就此发表了演讲。(一旦视频可用,我们会在此处添加链接。)
对于个人开发者和开源团队,Google 的 Go 团队 启动了一个代理,作为所有开源 Go 包的公共镜像,Go 1.13 在模块模式下将默认使用该代理。Katie Hockman 在 2019 年的 Gophercon 上就该系统发表了 演讲。
Go Modules 状态
Go 1.11 将 modules 作为实验性的、可选的预览引入。我们继续进行实验和简化。Go 1.12 进行了改进,Go 1.13 将带来更多改进。
modules 现在已经达到了我们认为它们将服务于大多数用户的程度,但我们还没有准备好关闭 GOPATH。我们将继续进行实验、简化和修订。
我们充分认识到,Go 用户社区围绕 GOPATH 积累了近十年的经验、工具和工作流程,将所有这些转换为 Go modules 需要一段时间。
但再次强调,我们认为 modules 现在对大多数用户来说都会非常好,我鼓励您在 Go 1.13 发布时进行了解。
作为一个数据点,Kubernetes 项目有很多依赖项,他们已经迁移到使用 Go modules 来管理它们。您也可能可以。如果您不行,请通过 提交 bug 报告 告诉我们什么对您不起作用或太复杂,我们将进行实验和简化。
工具
错误处理、泛型和依赖项管理至少还需要几年时间,我们现在将专注于它们。错误处理已接近完成,modules 将是下一个,然后可能是泛型。
但如果我们展望未来几年,当我们完成实验、简化和发布了错误处理、modules 和泛型之后。然后呢?预测未来非常困难,但我认为一旦这三个都发布了,这可能标志着一个主要变化的新平静期的开始。届时我们的重点可能会转移到通过改进的工具来简化 Go 开发。
一些工具工作已经开始,所以这篇文章最后将介绍这些。
虽然我们帮助更新了 Go 社区所有现有的工具来理解 Go modules,但我们注意到拥有大量执行一项小任务的开发辅助工具并没有很好地服务于用户。单个工具太难组合,调用起来太慢,而且太不统一,难以使用。
我们开始着手将最常用的开发助手统一到一个名为 gopls
(发音为“go, please”)的工具中。Gopls
使用 语言服务器协议 (LSP),并与任何支持 LSP 的集成开发环境或文本编辑器配合使用,目前几乎所有编辑器都支持 LSP。
Gopls
标志着 Go 项目关注点的扩展,从提供独立的编译器类命令行工具(如 go vet 或 gorename)扩展到提供完整的 IDE 服务。Rebecca Stambler 在 2019 年的 Gophercon 上就 gopls
和 IDE 进行了更详细的演讲。(一旦视频可用,我们会在此处添加链接。)
在 gopls
之后,我们还有想法可以以可扩展的方式复兴 go fix
,并使 go vet
更加有用。
结尾

这就是通往 Go 2 的道路。我们将实验并简化。然后实验并简化。然后发布。然后实验并简化。然后重复这一切。它可能看起来甚至感觉像是在原地打转。但每一次实验和简化,我们都会学到更多关于 Go 2 应该是什么样子,并朝着它迈出一步。即使是像 try
、我们最初的四个泛型设计或 dep
这样的被放弃的实验也并非浪费时间。它们帮助我们了解在发布之前需要简化什么,在某些情况下,它们帮助我们更好地理解了我们理所当然的一些东西。
总有一天,我们会意识到我们已经进行了足够的实验,进行了足够的简化,进行了足够的发布,然后我们就拥有了 Go 2。
感谢 Go 社区中的所有您帮助我们进行实验、简化、发布,并在这条道路上找到方向。
下一篇文章: Contributors Summit 2019
上一篇文章: 为什么需要泛型?
博客索引