Go 博客

实验、简化、发布

Russ Cox
2019 年 8 月 1 日

引言

这是我在上周的 GopherCon 2019 大会上演讲的博客文章版本。

我们都在通往 Go 2 的道路上,携手同行,但我们谁也不知道这条路具体通往哪里,甚至有时连方向都不确定。本文讨论了我们如何真正找到并遵循通往 Go 2 的道路。过程如下所示。

我们对现有的 Go 进行实验,以便更好地理解它,学习哪些地方做得好,哪些地方做得不好。然后,我们对可能的变化进行实验,以便更好地理解它们,再次学习哪些地方做得好,哪些地方做得不好。根据从这些实验中学到的东西,我们进行简化。然后再次实验。然后再次简化。如此循环,周而复始。

简化的四个 R

在这个过程中,我们可以通过四种主要方式简化编写 Go 程序的整体体验:重塑 (reshaping)、重新定义 (redefining)、移除 (removing) 和限制 (restricting)。

通过重塑进行简化

我们简化的第一种方式是将现有事物重塑成一种新的形式,从而最终达到整体更简单的效果。

我们编写的每一个 Go 程序都 serve as 一个测试 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 源文件的程序都变得更简单。这些程序无需担心 Go 源文件使用 Latin-1、UTF-16、UTF-7 或其他任何编码。

另一个重要的限制是用于程序格式化的 gofmt。没有什么会拒绝不使用 gofmt 格式化的 Go 代码,但我们建立了一个约定:重写 Go 程序的工具会将它们保留在 gofmt 格式。如果您也保持程序使用 gofmt 格式,那么这些重写工具就不会进行任何格式化更改。当您比较前后版本时,唯一看到的差异是实际的改动。这一限制简化了程序重写工具,并带来了像 goimportsgorename 等许多成功的实验。

Go 开发过程

这个实验和简化的循环是我们过去十年一直在做的事情的良好模型。但它有一个问题:它太简单了。我们不能只进行实验和简化。

我们必须发布结果。我们必须使其可用。当然,使用它会带来更多实验,并可能带来更多简化,这个过程就这样循环往复。

我们于 2009 年 11 月 10 日首次向大家发布了 Go。然后,在你们的帮助下,我们于 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 中即将发布的错误处理的技术细节。

错误

当所有输入都有效且正确,并且程序依赖的一切都没有问题时,编写一个在所有情况下都能正常工作的程序已经够难了。当你将错误也考虑进去时,无论出现什么问题都能正常工作的程序就更难编写了。

作为考虑 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 语言都可以用来定义它们,也可以用来检查它们。

这里有一个来自 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/errorsgopkg.in/errgo.v2github.com/hashicorp/errwrapupspin.io/errorsgithub.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.OpErroros.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。

到目前为止,使用 %v 格式化的 fmt.Errorf 返回的结果,其底层错误并未暴露给调用者检查。也就是说,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 方法,但带有未导出错误字段的实现则没有,并且现有使用 %vfmt.Errorf 仍然使用 %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,但仅用于错误返回路径。与其它语言的异常不同,这个设计保留了 Go 的重要特性,即每一个潜在的失败调用都在代码中明确标记,现在使用 check 关键字而不是 if err != nil

这个设计的最大问题在于 handledefer 的重叠太多,并且是以令人困惑的方式重叠。

五月份,我们发布了一个包含三项简化的新设计:为了避免与 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 上公开讨论这个提案。

checktry 的基本思想是缩短每个错误检查处重复的语法量,特别是将 return 语句从视线中移除,同时保持错误检查的明确性,并更好地突出有趣的变体。然而,在公众反馈讨论中提出的一个有趣的观点是,如果没有明确的 if 语句和 return,就无法放置调试打印语句,无法设置断点,代码覆盖率结果中也没有代码可以显示为未执行。我们追求的益处是以使这些情况更复杂为代价的。综合考虑这一点以及其他因素,总体结果是否能简化 Go 开发根本不清楚,因此我们放弃了这个实验。

这就是关于错误处理的所有内容,这是今年主要的关注点之一。

泛型

现在谈谈一个争议小一点的话题:泛型。

我们为 Go 2 确定的第二个重要议题是某种使用类型参数编写代码的方式。这将使得编写泛型数据结构以及编写可处理任何类型的切片、任何类型的通道或任何类型的 map 的泛型函数成为可能。例如,这是一个泛型通道过滤器

// 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 年底,我们又编写并否定了三个设计。四次被放弃的实验,但并非失败的实验。我们从中学习到了,就像我们从 checktry 中学习到的一样。每一次,我们都学到了通往 Go 2 的道路并非确切是那个方向,并且我们注意到了其他可能值得探索的方向。但到了 2013 年,我们决定需要专注于其他问题,所以我们将整个话题搁置了几年。

去年我们再次开始探索和实验,并在去年夏天的 GopherCon 大会上,基于契约(contract)的概念,提出了一个新设计。我们一直在继续实验和简化,并且与编程语言理论专家合作,以便更好地理解这个设计。

总的来说,我希望我们正朝着一个好的方向前进,朝向一个能够简化 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.v1gopkg.in/yaml.v2 来提供这些版本。

保持向后兼容性的约定,即可以使用新版本的包来替代旧版本,这使得 go get 非常简单的规则——“始终下载最新副本”——即使在今天也能很好地工作。

版本控制与供应商机制(Vendoring)

但在生产环境中,您需要更精确地指定依赖版本,以使构建可重现。

许多人尝试了这应该是什么样子,构建了满足他们需求的工具,包括 Keith Rarick 的 goven (2012) 和 godep (2013),Matt Butcher 的 glide (2014),以及 Dave Cheney 的 gb (2015)。所有这些工具都采用将依赖包复制到您自己的源代码控制仓库中的模型。用于使这些包可导入的精确机制各不相同,但都比看起来应有的更加复杂。

经过社区范围的讨论,我们采纳了 Keith Rarick 的一项提案,以增加对引用复制的依赖项的显式支持,而无需使用 GOPATH 技巧。这是通过重塑来简化的:就像 addToListappend 一样,这些工具已经实现了这个概念,但比原本需要的更笨拙。添加对供应商目录的显式支持使这些用法整体上更加简单。

go 命令中支持供应商目录导致了更多关于供应商机制本身的实验,我们意识到我们引入了一些问题。最严重的问题是我们失去了包唯一性(package uniqueness)。以前,在任何给定的构建期间,一个导入路径可能出现在许多不同的包中,并且所有的导入都指向同一个目标。现在有了供应商机制,不同包中的同一个导入路径可能指向包的不同供应商副本,所有这些都会出现在最终生成的二进制文件中。

当时,我们没有给这个属性命名:包唯一性。它只是 GOPATH 模型的工作方式。直到它消失,我们才完全理解它的价值。

这与 checktry 错误语法提案有相似之处。在那种情况下,我们依赖于可见的 return 语句的工作方式,直到我们考虑移除它时才理解其价值。

当我们添加供应商目录支持时,有许多不同的依赖管理工具。我们认为,如果对供应商目录的格式和供应商元数据达成明确协议,将使各种工具能够互操作,就像对 Go 程序如何存储在文本文件中的协议使得 Go 编译器、文本编辑器以及 goimportsgorename 等工具能够互操作一样。

事实证明,这过于天真乐观了。这些供应商工具在细微的语义方面都有所不同。互操作需要改变所有工具以在语义上达成一致,这很可能会破坏它们各自的用户。趋同并未发生。

Dep

在 2016 年的 Gophercon 大会上,我们开始努力定义一个单一的工具来管理依赖项。作为这项工作的一部分,我们对许多不同类型的用户进行了调查,以了解他们在依赖管理方面的需求,并且一个团队开始着手开发一个新工具,最终成为了 dep

Dep 的目标是能够取代所有现有的依赖管理工具。目标是通过将现有不同的工具重塑为一个单一工具来实现简化。它部分地实现了这一点。Dep 还通过在项目树顶部仅设一个供应商目录,为其用户恢复了包唯一性。

dep 也引入了一个严重的问题,我们花了一段时间才完全理解。问题在于 dep 采纳了 glide 的一个设计选择,即支持和鼓励对给定包进行不兼容的更改而无需更改导入路径。

这是一个例子。假设您正在构建自己的程序,需要一个配置文件,因此您使用了流行的 Go YAML 包的第 2 版。

现在假设您的程序导入了 Kubernetes 客户端。结果发现 Kubernetes 广泛使用了 YAML,并且它使用了同一个流行包的第 1 版。

第 1 版和第 2 版的 API 是不兼容的,但它们的导入路径也不同,因此对于给定导入究竟指代哪个版本没有歧义。Kubernetes 使用第 1 版,您的配置解析器使用第 2 版,一切正常工作。

Dep 放弃了这个模型。yaml 包的第 1 版和第 2 版现在将拥有相同的导入路径,从而产生冲突。对两个不兼容的版本使用相同的导入路径,再加上包唯一性,使得无法构建之前可以构建的这个程序。

我们花了一段时间才理解这个问题,因为我们应用“新 API 意味着新导入路径”的约定已经太久了,以至于我们将其视为理所当然。dep 的实验帮助我们更好地理解了这个约定,并给它起了个名字:导入兼容性规则(import compatibility rule)

“如果旧包和新包具有相同的导入路径,则新包必须向后兼容旧包。”

Go Modules

我们吸取了 dep 实验中运行良好的部分以及我们了解到的不足之处,并试验了一个新的设计,称为 vgo。在 vgo 中,包遵循导入兼容性规则,这样我们就可以提供包唯一性,同时仍然不会像我们刚才看到的例子那样破坏构建。这也使我们能够简化设计的其他部分。

除了恢复导入兼容性规则之外,vgo 设计的另一个重要部分是给一组包的概念一个名称,并允许将这个分组与源代码仓库边界分开。一组 Go 包的名称称为模块(module),因此我们现在将该系统称为 Go modules。

Go modules 现已集成到 go 命令中,这样就完全避免了需要复制供应商目录。

取代 GOPATH

随着 Go modules 的到来,GOPATH 作为全局命名空间的作用即将终结。将现有 Go 用法和工具转换为 modules 的几乎所有艰苦工作都是由这一变化引起的,即摆脱 GOPATH。

GOPATH 的基本思想是,GOPATH 目录树是正在使用的版本的全局真实来源,并且无论您在目录之间如何移动,正在使用的版本都不会改变。但是全局 GOPATH 模式与生产环境中每项目可重现构建的需求直接冲突,而可重现构建本身在许多重要方面简化了 Go 的开发和部署体验。

每项目可重现构建意味着当您在项目 A 的检出副本中工作时,您会获得与项目 A 的其他开发人员在那个提交(commit)上相同的依赖版本集合,这由 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 Proxy

Modules 简化 Go 开发的一种方式是,它将一组包的概念与存储它们的底层源代码控制仓库分开。

当我们与 Go 用户讨论依赖项时,几乎所有在其公司使用 Go 的人都会问如何通过自己的服务器路由 go get 包获取请求,以便更好地控制可以使用哪些代码。即使是开源开发者也担心依赖项意外消失或更改,从而破坏他们的构建。在 modules 之前,用户尝试了复杂的解决方案来解决这些问题,包括拦截 go 命令运行的版本控制命令。

Go modules 的设计使得引入一个可以查询特定 module 版本的 module proxy 变得容易。

公司现在可以轻松运行自己的 module proxy,并设置关于允许哪些内容以及缓存副本存储位置的自定义规则。开源的 Athens 项目 就构建了这样一个 proxy,Aaron Schlesinger 在 2019 年的 Gophercon 大会上就此发表了演讲。(视频可用时,我们将在此添加链接。)

对于个人开发者和开源团队,Google 的 Go 团队启动了一个 proxy,作为所有开源 Go 包的公共镜像,并且 Go 1.13 在 module 模式下将默认使用该 proxy。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 遵循 语言服务器协议(Language Server Protocol, LSP),并与任何支持 LSP 的集成开发环境或文本编辑器配合使用,基本上现在所有主流的编辑器都支持 LSP。

Gopls 标志着 Go 项目焦点的扩展,从提供像 go vet 或 gorename 这样的独立编译器式命令行工具,扩展到提供一个完整的 IDE 服务。Rebecca Stambler 在 2019 年的 Gophercon 大会上就 gopls 和 IDEs 发表了一次演讲,提供了更多细节。(视频可用时,我们将在此添加链接。)

gopls 之后,我们还有关于以可扩展方式重振 go fix 和使 go vet 更加有用的想法。

尾声

这就是通往 Go 2 的道路。我们将实验并简化。然后实验并简化。然后发布。然后实验并简化。如此循环往复。这条路可能看起来甚至感觉像是在绕圈子。但每一次实验和简化,我们都会对 Go 2 应该是什么样子学到更多,并向它迈进一步。即使是像 try 或我们最初的四个泛型设计或 dep 这样被放弃的实验,时间也没有浪费。它们帮助我们了解在发布之前需要简化什么,在某些情况下,它们帮助我们更好地理解我们曾视为理所当然的事物。

在某个时刻,我们将意识到我们已经进行了足够的实验,足够的简化,并发布了足够多的东西,然后我们就将拥有 Go 2。

感谢 Go 社区中的各位帮助我们实验、简化、发布,并在这条道路上找到方向。

下一篇文章:2019 贡献者峰会
上一篇文章:为什么需要泛型?
博客索引