Go 博客

实验、简化、交付

Russ Cox
2019 年 8 月 1 日

介绍

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

我们都在通往 Go 2 的道路上,携手前行,但我们没有人确切地知道这条道路通向何方,有时甚至不知道道路的方向。这篇文章讨论了我们如何实际找到并遵循通往 Go 2 的道路。以下是流程。

我们对现有的 Go 进行实验,以更好地理解它,了解哪些有效,哪些无效。然后我们对可能的更改进行实验,以更好地理解它们,再次了解哪些有效,哪些无效。根据我们在这些实验中学到的知识,我们进行简化。然后我们再次进行实验。然后我们再次简化。等等。等等。

简化的四个 R

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

通过重塑简化

我们简化的第一种方式是将现有内容重塑成新的形式,最终使其整体更简单。

我们编写的每个 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 形式保留程序,那么这些重写器不会进行任何格式更改。当您比较前后时,您看到的唯一差异是真正的更改。此限制简化了程序重写器,并导致了 goimportsgorename 等许多成功的实验。

Go 开发流程

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

我们必须交付结果。我们必须使其可用。当然,使用它可以进行更多实验,并可能进行更多简化,并且流程循环往复。

我们于 2009 年 11 月 10 日首次向大家交付了 Go。然后,在你们的帮助下,我们于 2012 年 3 月共同交付了 Go 1。从那时起,我们已经交付了 12 个 Go 版本。所有这些都是重要的里程碑,可以进行更多实验,帮助我们更多地了解 Go,当然还有使 Go 可用于生产环境。

当我们交付 Go 1 时,我们明确地将重点转移到使用 Go 上,以便在尝试任何更多涉及语言更改的简化之前,更好地理解这个版本的语言。我们需要花时间进行实验,真正了解哪些有效,哪些无效。

当然,自从 Go 1 以来,我们已经发布了 12 个版本,因此我们一直在进行实验、简化和交付。但我们专注于无需进行重大语言更改且不会破坏现有 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 语言都可以用来定义它们,而且整个 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 来说,似乎值得探索的一个简化是,以商定的可选接口的形式定义常用功能的标准版本,以便不同的实现可以互操作。

解包

这些包中最常添加的功能是某些可以调用的方法,以从错误中删除上下文,返回内部的错误。包使用不同的名称和含义来表示此操作,有时它会删除一层上下文,而有时它会删除尽可能多的层。

对于 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

到目前为止,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
}

乍一看,这段代码大约有一半是错误检查。当我阅读它时,我的眼睛会变得模糊。而且我们知道,编写起来很乏味且阅读起来很乏味的代码很容易读错,使其成为难以发现的错误的温床。例如,这三个错误检查之一与其他两个不同,这是一个在快速浏览时很容易忽略的事实。如果你正在调试这段代码,需要多长时间才能注意到这一点?

在去年的 Gophercon 上,我们提出了一个新的控制流构造草案设计,该构造由关键字 check 标记。Check 使用来自函数调用或表达式的错误结果。如果错误非空,则 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 确定的第二个重要主题是某种编写带类型参数的代码的方式。这将能够编写泛型数据结构,以及编写适用于任何类型的切片、任何类型的通道或任何类型的映射的泛型函数。例如,这是一个泛型通道过滤器

// 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 上提出了一个 新的基于契约理念的设计。我们继续进行实验和简化,并且一直在与编程语言理论专家合作,以更好地理解该设计。

总的来说,我希望能朝着正确的方向前进,朝着一个将简化 Go 开发的设计方向发展。即便如此,我们也可能会发现这个设计也不起作用。我们可能不得不放弃这个实验,并根据我们学到的东西调整我们的路径。我们会发现的。

在 2019 年的 Gophercon 上,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 常见问题解答

“用于公共用途的包应在发展过程中尝试保持向后兼容性。

如果需要不同的功能,请添加新名称而不是更改旧名称。

如果需要完全中断,请使用新的导入路径创建一个新的包。”

此约定通过限制作者可以执行的操作来简化使用包的整体体验:避免对 API 进行重大更改;为新功能提供新名称;并为全新的包设计提供新的导入路径。

当然,人们一直在不断尝试。其中最有趣的实验是由 Gustavo Niemeyer 启动的。他创建了一个名为 gopkg.in 的 Git 重定向器,它为不同的 API 版本提供了不同的导入路径,以帮助包作者遵循为全新的包设计提供新的导入路径的约定。

例如,GitHub 存储库 go-yaml/yaml 中的 Go 源代码在 v1 和 v2 语义版本标签中具有不同的 API。gopkg.in 服务器使用不同的导入路径提供这些 API,例如 gopkg.in/yaml.v1gopkg.in/yaml.v2

提供向后兼容性的约定(以便可以使用较新版本的包代替较旧版本的包)正是使 go get 的非常简单的规则“始终下载最新副本”即使在今天也能很好地发挥作用的原因。

版本控制和供应商

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

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

在社区范围内的讨论之后,我们采纳了 Keith Rarick 的一个提案,即添加对引用已复制的依赖项(无需 GOPATH 技巧)的明确支持。这通过重塑来简化:就像 addToListappend 一样,这些工具已经实现了这个概念,但它比需要的更笨拙。添加对供应商目录的明确支持使这些用法总体上更简单。

go 命令中交付供应商目录导致了更多关于供应商本身的实验,我们意识到我们引入了一些问题。最严重的问题是我们失去了包唯一性。以前,在任何给定的构建过程中,导入路径可能出现在许多不同的包中,并且所有导入都引用相同的目标。现在使用供应商,不同包中的相同导入路径可能引用包的不同供应商副本,所有这些都将出现在最终的生成二进制文件中。

当时,我们没有为这个属性命名:包唯一性。它只是 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 实验帮助我们更好地理解了这个约定,并且我们为它起了一个名字:导入兼容性规则

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

Go 模块

我们借鉴了 dep 实验中行之有效的部分,以及我们对哪些部分效果不佳的经验教训,并尝试了一种新的设计,称为 vgo。在 vgo 中,包遵循导入兼容性规则,这样我们就可以提供包的唯一性,但又不会像我们刚才看到的那个示例那样破坏构建。这让我们能够简化设计的其他部分。

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

Go 模块现在已集成到 go 命令中,这样就完全避免了四处复制 vendor 目录的需要。

替换 GOPATH

Go 模块的出现标志着 GOPATH 作为全局命名空间的终结。从摆脱 GOPATH 开始,将现有的 Go 使用方式和工具转换为模块所需要做的几乎所有艰苦工作都是由此变化引起的。

GOPATH 的基本思想是,GOPATH 目录树是所使用版本的全局真相来源,并且在不同目录之间移动时,所使用的版本不会发生变化。但是,全局 GOPATH 模式与每个项目的可重复构建的生产需求直接冲突,而每个项目的可重复构建本身又以多种重要方式简化了 Go 开发和部署体验。

每个项目的可重复构建意味着,当您在项目 A 的检出副本中工作时,您会获得与项目 A 的其他开发人员在该提交时获得的相同的一组依赖项版本,如 go.mod 文件中定义的那样。当您切换到项目 B 的检出副本中工作时,现在您会获得该项目的选定依赖项版本,与项目 B 的其他开发人员获得的相同的一组版本。但这些版本很可能与项目 A 的不同。在从项目 A 移动到项目 B 时,依赖项版本集发生变化是必要的,以使您的开发与 A 和 B 上其他开发人员的开发保持同步。不再存在单个全局 GOPATH。

采用模块的大部分复杂性都直接源于全局 GOPATH 的消失。包的源代码在哪里?以前,答案仅取决于您的 GOPATH 环境变量,大多数人很少更改该变量。现在,答案取决于您正在处理的项目,这可能会经常更改。所有内容都需要更新以适应这种新的约定。

大多数开发工具都使用 go/build 包来查找和加载 Go 源代码。我们一直保持该包的工作状态,但 API 没有预料到模块,并且我们添加的避免 API 更改的解决方法比我们希望的要慢。我们发布了一个替代方案,golang.org/x/tools/go/packages。开发人员工具现在应该使用它来代替。它支持 GOPATH 和 Go 模块,并且使用起来更快、更容易。在一两个版本中,我们可能会将其移动到标准库中,但目前 golang.org/x/tools/go/packages 稳定且可以使用。

Go 模块代理

模块简化 Go 开发的方式之一是将一组包的概念与存储它们的底层源代码控制存储库分离。

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

Go 模块设计使引入模块代理的概念变得很容易,该代理可以被请求提供特定模块版本。

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

对于个人开发人员和开源团队,Google 的 Go 团队已 启动了一个代理,该代理充当所有开源 Go 包的公共镜像,并且 Go 1.13 在模块模式下默认情况下将使用该代理。Katie Hockman 在 2019 年的 Gophercon 大会上发表了关于此系统的演讲

Go 模块状态

Go 1.11 引入了模块作为实验性的、可选的预览版。我们一直在进行实验和简化。Go 1.12 发布了改进,Go 1.13 将发布更多改进。

模块现在已经发展到我们认为它们将为大多数用户提供服务的程度,但我们还没有准备好立即关闭 GOPATH。我们将继续进行实验、简化和修改。

我们完全认识到,Go 用户社区积累了近 10 年围绕 GOPATH 的经验、工具和工作流程,将所有这些转换为 Go 模块需要一段时间。

但同样,我们认为模块现在对于大多数用户来说都能很好地工作,我鼓励您在 Go 1.13 发布时尝试一下。

举一个数据点,Kubernetes 项目有很多依赖项,他们已经迁移到使用 Go 模块来管理它们。您可能也可以。如果您不能,请告诉我们哪些方面对您不起作用或哪些方面过于复杂,方法是 提交错误报告,我们将进行实验和简化。

工具

错误处理、泛型和依赖项管理至少还需要几年时间,我们将暂时专注于这些方面。错误处理即将完成,模块将紧随其后,之后可能是泛型。

但假设我们展望几年后,当我们完成实验和简化并发布了错误处理、模块和泛型之后。然后呢?预测未来非常困难,但我认为一旦这三者发布,这可能标志着重大更改的新平静期的开始。届时我们的重点可能会转向使用改进的工具简化 Go 开发。

一些工具工作已经开始,因此本文最后将介绍这些工作。

虽然我们帮助更新了所有 Go 社区的现有工具以了解 Go 模块,但我们注意到,拥有大量每个都执行一项小任务的开发辅助工具并不能很好地服务于用户。各个工具太难组合、调用速度太慢且使用方式太不同。

我们开始努力将最常用的开发辅助工具统一到一个工具中,现在称为 gopls(发音为“go, please”)。Gopls 使用 语言服务器协议 (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 社区中的所有成员帮助我们进行实验、简化、发布并在这条道路上找到方向。

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