在前往 Go 2 的道路上

Russ Cox
1 August 2019

简介

这篇文章是我上周在 Go 开发者大会上的演讲记录.

视频链接地址, 戳我

我们都正在向 Go 2.0 演进的道路上, 但我们谁也无法确切的知道这条路通向何方, 有时甚至不确定这条路的最终方向. 这篇文章讨论了我们如何真正的找到并且通向 Go 2 的道路. 过程如下:

我们对 Go 现在的版本进行一些探索, 以便更好的理解它, 认识到哪些部分它表现的良好, 哪些部分不尽如人意. 然后, 我们探索一些它可能的改变,以便 更深入地理解 Go, 同样的, 了解它的优劣. 在这些探索的基础上, 继续简化它, 然后继续试验探索, 然后简化, 试验探索, ......

简化的四个步骤

在此过程中, 有四种主要的方法可以简化编写 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 处理这种情况是简单的.

因此,通过 Go 本身的特性,把我们程序中类似addToList这类的函数重塑到一个函数中. 对 Go 语言来说, 一方面, 添加了append函数,增加了它的一些复杂性, 但另一方面, 作为回报, 这无疑使编写 Go 程序整体体验上更加简单.

下面是另一个例子. 对于 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格式, 那么重写器不会进行任何格式方面的修改. 当你对比新老程序, 你看到的唯一的改变就是那些真实的变更. 这样限制简化了程序重写器, 并且催生了其它一些良好的体验工具, 例如goimports gorename等等.

Go 的开发流程

尝试和简化周期模型在过去 10 年的工作中经受了考验。但是并不意味着没有问题:它过于简单。我们不能仅仅止于尝试和简化。

我们必须交付可用的结果。当然,使用它可以带来更多的尝试,以及更多简化的可能,并让整个工作流程运转起来。

我们在 2019 年 10 月 10 日第一次将 Go 交付给公众。之后,在各位协力下,我们在 2012 年的 3 月一起交付了 Go 1。自那以后,我们已经交付了 12 个 Go 版本。它们都是重要的里程碑,带来了更多的尝试的同时帮助我们深入了解 Go,当然也让 Go 可以用于生产。

当我们交付 Go 1 的时候,我们明确地将目光转移到了 Go 的使用上,以便在尝试进行涉及语言更改的任何进一步简化之前,更好地理解该版本语言。我们需要花一些时间去尝试,以去伪存真。

当然,自 Go 1 之后我们已经有过 12 次发布,所以我们依旧在尝试,简化并交付。但是我们更专注于在无需进行重大的语言更改并且不会破坏现有的 Go 程序的前提下,简化 Go 开发。譬如,Go 1.5 交付了第一个并发垃圾回收器,在之后的发行版中持续地对其进行改善,通过优化暂停次数来简化 Go 的开发。

在 2017 年的 Gophercon 大会上,我们宣布经过 5 载的尝试,是时候去做出重大变革以简化 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

该代码片段在 2018 年 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 持续了七个月。

在获得更多使用接口的经验之后,翌年 4 月,我们决定通过将 os.Error 类型本身定义为接口,来进一步推广并允许用户定义错误实现。在简化过程中,我们删除了 Print 方法。

在 2 年后的 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 数值。在这个场景中,错误本质上是 syscall.Errno 附加上下文 os.SyscallError 再附加上下文 net.OpError

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 方法,或该方法返回 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 来调用这个可选方法,该函数会处理空错误或不含 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决定是否导出结构字段相同的方式。有时将细节暴露给调用代码是适当的,有时则不然。如果是这样,请实施“展开”。如果不是,请勿实施“展开”。

到目前为止,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(或始终实施< aaaa> Unwrap) "与绝对规则(如“从不导出结构字段”或“始终导出结构字段”)一样错误。相反,正确的决定取决于调用者是否应该能够检查并依赖于其他信息使用%w或实现Unwrap公开。

为了说明这一点,标准库中每个已经具有导出的Err字段的错误包装类型现在也具有返回该字段的Unwrap方法,但是实现未导出错误字段不使用,并且fmt.Errorf%v的现有用法仍然使用%v,而不是%w

错误值打印(放弃)

除了Unwrap的设计草案外,我们还发布了[用于更丰富的错误打印的可选方法的设计草案],其中包括堆栈框架信息和对本地化的支持。 ,翻译错误。

// 错误实现的可选方法
type Formatter interface {
    Format(p Printer) (next error)
}

// 接口传递给Format
type Printer interface {
    Print(args ...interface{})
    Printf(format string, args ...interface{})
    Detail() bool
}

这不像Unwrap那样简单,我在这里不做详细介绍。当我们在冬季与Go社区讨论设计时,我们了解到设计还不够简单。单个错误类型难以实施,并且对现有程序的帮助不足。总而言之,它并没有简化Go开发。

作为社区讨论的结果,我们放弃了该印刷设计。

错误语法

那是错误值。让我们简要看看错误语法,这是另一个废弃的实验。

这是来自compress / lzw / writer.go在标准库中:

// 如果有效,请写入saveCode。
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
    }
}

// 编写eof代码。
// EOF是一个计算机术语,为End Of File的缩写
// 通常在文本的最后存在此字符表示资料结束。
eof := uint32(1)<<e.litWidth + 1
if err := e.write(e, eof); err != nil {
    return err
}

乍一看,此代码大约是一半的错误检查。当我阅读它时,我的眼睛凝视。而且我们知道,编写繁琐且易于阅读的代码很容易被误读,从而使其成为难以发现的错误的好地方。例如,这三个错误检查之一与其他错误检查不同,在快速浏览中容易错过这一事实。如果您正在调试此代码,那么需要多长时间才能注意到?

去年在Gophercon上,我们提出了一个设计草案,用于使用关键字check标记的新控制流构造。 Check消耗函数调用或表达式的错误结果。如果错误不是nil,则check返回该错误。否则选中会评估该调用的其他结果。我们可以使用check来简化lzw代码:

// 如果有效,请写入saveCode。
if e.savedCode != invalidCode {
    check e.write(e, e.savedCode)
    if err := e.incHi(); err != errOutOfCodes {
        check err
    }
}

// 编写eof代码。
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)

此版本的相同代码使用check,删除了四行代码,更重要的是突出显示允许对e.incHi的调用返回errOutOfCodes

也许最重要的是,该设计还允许在以后的检查失败时运行定义错误处理程序块。这样一来,您只需编写一次共享上下文添加代码,如以下代码片段所示:

handle err {
    err = fmt.Errorf("closing writer: %w", err)
}

// 如果有效,请写入saveCode。
if e.savedCode != invalidCode {
    check e.write(e, e.savedCode)
    if err := e.incHi(); err != errOutOfCodes {
        check err
    }
}

// 编写eof代码。
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)

本质上,check是写if语句的一种简短方法,
句柄 就像 defer
但仅适用于错误返回路径。与其他语言的例外相反,此设计保留了Go的重要属性,即在代码中显式标记了每个潜在的失败调用,现在使用check关键字而不是if err!= nil

这种设计的最大问题是句柄defer重叠过多且以令人困惑的方式。

5月,我们发布了具有三个简化的新设计:为避免与defer混淆,设计删除了句柄赞成只使用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))

We spent most of June discussing this proposal publicly on GitHub.

我们在6月的大部分时间里在GitHub上公开讨论了该建议。

checktry的基本思想是缩短每次错误检查时重复的语法量,尤其是从视图中删除return语句,以保持错误检查是明确的,并且可以更好地突出有趣的变化。但是,在公众反馈讨论期间提出的一个有趣的观点是,没有明确的if声明和return,就没有地方可以打印调试信息,也没有地方可以设置断点,并且在代码覆盖率结果中,没有代码显示为未执行。我们追求的好处是以使这些情况变得更加复杂为代价的。总的来说,从上述以及其他方面的考虑来看,总体结果并不清楚是更简单的Go开发,因此我们放弃了该实验。

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

Generics 泛型

现在来讨论一些不太引起争议的东西:泛型。

我们为Go 2确定的第二个大主题是某种使用类型参数编写代码的方式。这将使编写通用数据结构以及编写适用于任何类型的切片,任何类型的通道或任何类型的映射的通用功能成为可能。例如,这是一个通用通道过滤器:

// 筛选器将值从c复制到返回的通道,
// 仅传递那些满足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开发的设计的方向前进。即使如此,我们仍可能会发现此设计也不起作用。我们可能不得不放弃该实验,并根据所学知识调整自己的道路。我们会找出答案的。

在Gophercon 2019上,Ian Lance Taylor谈到了我们为什么要向Go添加泛型的原因,并简要预览了最新的设计草案。有关详细信息,请参阅他的博客文章“ Why Generics?

依赖关系

我们为Go 2确定的第三个大主题是依赖性管理。

2010年,我们发布了一个名为goinstall的工具,我们称其为“ 软件包安装中的实验 ”。它下载了相关性并将其存储在GOROOT的Go分发树中。

当我们尝试使用goinstall时,我们了解到Go发行版和已安装的软件包应保持分开,以便可以更改为新的Go发行版而不会丢失所有Go软件包。因此在2011年,我们引入了GOPATH这个环境变量,该变量指定在哪里查找主Go发行版中找不到的软件包。

添加GOPATH为Go软件包创建了更多位置,但通过将Go发行版与Go库分开来简化了Go的总体开发。

兼容性

goinstall实验有意省略了软件包版本控制的明确概念。相反,goinstall始终下载最新副本。我们这样做是为了专注于软件包安装的其他设计问题。

作为Go 1的一部分,Goinstall成为了go get。当人们询问版本时,我们鼓励他们尝试通过创建其他工具来进行尝试。并且我们鼓励软件包AUTHORS为他们的用户提供与Go 1库相同的向后兼容性。引用Go常见问题解答

“面向公众的软件包在发展过程中应尽量保持向后兼容性。

如果需要其他功能,请添加一个新名称而不是更改旧名称。

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

通过限制作者可以做的事情,该约定简化了使用软件包的整体体验:避免破坏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的非常简单的规则(始终下载最新副本)的原因,即使在今天也运作良好。

版本和供应商

但是在生产环境中,您需要更加精确地了解依赖项版本,以使生成可复制。

许多人尝试了应有的外观,构建了满足其需求的工具,包括Keith Rarick的goven(2012)和godep(2013),Matt Butcher的glide <aaaa >(2014),和Dave Cheney的 gb `(2015)。所有这些工具都使用将依赖关系包复制到自己的源代码控制存储库中的模型。使这些软件包可用于导入的确切机制各不相同,但它们都比看起来应该的复杂得多。

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

go命令中运送供应商目录导致了对供应商本身的更多试验,我们意识到我们引入了一些问题。最严重的是我们失去了包装的独特性。之前,在任何给定的构建期间,导入路径可能会出现在许多不同的程序包中,并且所有导入均指向同一目标。现在使用供应商,不同软件包中的相同导入路径可能会引用该软件包的不同供应商副本,所有副本都将出现在最终生成的二进制文件中。

当时,我们没有此属性的名称:包的唯一性。这就是GOPATH模型的工作方式。直到它消失,我们才完全欣赏它。

这里与checktry错误语法建议相似。在那种情况下,我们依靠可见的return陈述如何以我们不喜欢的方式工作,直到我们考虑删除它为止。

当我们添加了供应商目录支持时,有许多用于管理依赖项的工具。我们认为,关于供应商目录和供应商元数据格式的明确协议将允许各种工具进行互操作,就像关于如何将Go程序存储在文本文件中的协议一样,可以使Go编译器,文本编辑器和诸如此类的工具之间实现互操作goimportsgorename

事实证明这是天真的乐观。供应商工具在语义上都有细微的差别。互操作将要求更改它们所有人以就语义达成一致,这可能会破坏其各自的用户。融合没有发生。

深度

在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命令集成在一起,这完全避免了在供应商目录之间进行复制的情况。

取代GOPATH

使用Go模块时,GOPATH作为全局名称空间的结尾。将现有的Go使用情况和工具转换为模块的几乎所有艰苦的工作都是由离开GOPATH的这一更改引起的。

GOPATH的基本思想是,GOPATH目录树是正在使用的版本的全球真实来源,并且在目录之间移动时,使用的版本不会更改。但是,全局GOPATH模式与按项目可复制的构建的生产要求直接冲突,它本身在许多重要方面简化了Go的开发和部署经验。

每个项目可重现的构建意味着,当您在项目A的结帐中工作时,您得到的依赖版本与项目A的其他开发人员在提交时所获得的依赖版本相同,这由` go.mod <aaaa定义>文件。现在,当您切换到对项目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](https://godoc.org/golang.org/x / tools / go / packages)稳定且可以使用。

Go模块代理

模块简化Go开发的方法之一是将一组软件包的概念与存储它们的基础源代码控制存储库分开。

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

Go模块的设计使您可以轻松地引入模块代理的概念,可以要求该模块提供特定的模块版本。

公司现在可以轻松地运行其自己的模块代理,并使用有关允许的内容以及缓存副本的存储位置的自定义规则。开源雅典项目就是建立了这样的代理,而亚伦·史莱辛格(Aaron Schlesinger)在Gophercon 2019上发表了关于它的演讲。(可用。)

对于个人开发人员和开源团队而言,Google的Go团队已经发布了代理所有开放源代码的Go程序包,在模块模式下,默认情况下,Go 1.13将使用该代理。凯蒂·霍克曼(Katie Hockman)在Gophercon 2019上就此系统进行了演讲。(当视频可用时,我们将在此处添加一个链接。)

转到模块状态

Go 1.11引入的模块作为实验性的,可选的预览版。我们一直在尝试并简化。 Go 1.12提供了改进,而Go 1.13提供了更多改进。

现在,我们相信模块可以为大多数用户提供服务,但是我们还没有准备好关闭GOPATH。我们将继续进行试验,简化和修订。

我们完全认识到,Go用户社区在GOPATH周围积累了近十年的经验,工具和工作流,将所有这些转换为Go模块将花费一些时间。

但是,再次,我们认为这些模块现在对于大多数用户来说都可以很好地工作,我鼓励您看看Go 1.13发行时的情况。

作为一个数据点,Kubernetes项目具有很多依赖性,它们已经迁移到使用Go模块进行管理。您可能也可以。如果无法解决,请通过提交错误报告告诉我们什么对您不起作用或太复杂了,我们将进行试验和简化。

工具

错误处理,泛型和依赖性管理至少要花几年的时间,现在我们将重点关注它们。错误处理已接近完成,之后将是模块,之后可能是泛型。

但是,假设我们已经完成了几年的实验和简化工作,并且已经交付了错误处理,模块和泛型,因此我们需要几年的时间。那呢预测未来非常困难,但是我认为一旦这三个交付,就可能标志着重大变革进入新的宁静时期的开始。在这一点上,我们的重点可能会转向使用改进的工具简化Go开发。

一些工具的工作已经在进行中,因此本文将着眼于此。

虽然我们帮助更新了Go社区的所有现有工具以了解Go模块,但我们注意到拥有大量的开发帮助工具,每个工具只能完成一项小工作,却不能很好地为用户服务。各个工具很难组合,调用起来太慢,使用起来也太不同了。

我们开始努力将最常用的开发助手整合到一个工具中,该工具现在称为gopls(发音为go,please)。 Gopls使用Language Server Protocol,LSP进行语音处理,并且可以与任何具有LSP支持的集成开发环境或文本编辑器一起使用,这实际上是所有内容。

Gopls标志着Go项目的重点扩展,从提供独立的类似于编译器的命令行工具(例如go vet或gorename)到还提供完整的IDE服务。 Rebecca Stambler在Gophercon 2019上发表了有关gopls和IDE的更多详细信息的演讲。(当视频可用时,我们将在此处添加链接。)

gopls之后,我们还提出了一些想法,以可扩展的方式恢复go fix,并使go vet更加有用。

Coda

因此,有了通往Go 2的道路。我们将进行实验和简化。并进行实验和简化。和船。并进行实验和简化。再做一遍。看起来甚至感觉到路径在绕圈转。但是每次我们进行实验和简化时,我们都会学到更多有关Go 2的外观的知识,并向它迈出又一步。即使像try或我们的前四个泛型设计或dep之类的废弃实验也不会浪费时间。他们帮助我们了解了在交付之前需要简化的内容,在某些情况下,它们还帮助我们更好地理解了我们认为理所当然的东西。

在某个时候,我们将意识到我们已经进行了足够的实验,足够的简化和足够的交付,并且我们将拥有Go 2。

感谢Go社区中的所有人,他们帮助我们进行了实验,简化,发布以及在这条路上找到了自己的道路。

本文章首发在 LearnKu.com 网站上。

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
上一篇 下一篇
贡献者:4
讨论数量: 0
发起讨论 只看当前版本


暂无话题~