Go 是 2007 年末由谷歌创立的一种程序设计语言,2009 年 11 月以开源形式发行。自那以后,Go 就作为一个公共项目运作,有成千上万的个人和几十家公司作出贡献。Go 已经成为一种很受欢迎的语言,用于构建云计算基础设施:Linux 容器管理器 Docker 和容器部署系统 Kubernetes 是由 Go 开发的一种核心云计算技术。现如今,Go 已经成为了各大云计算提供商的重要基础设施的基础,也是云原生计算基金会托管的大多数项目的实现语言。
有许多理由让早期使用者对 Go 感兴趣。一种用于构建系统的垃圾收集、静态编译的语言是不寻常的。Go 提供的并行性和并发性的原生支持,使其能够充分发挥当时正在成为主流的多核机器的优势。自带的二进制文件和简单的交叉编译使部署变得更加容易。当然,谷歌这个名称也是一大亮点。
但是为什么用户会留下来?为什么 Go 在很多其他语言项目还没有开发出来的时候,它就变得如此流行了呢?我们认为,语言本身只是答案的一小部分。完整的故事应该包括整个 Go 环境:库、工具、约定和软件工程的整体方法,这些都支持用该语言编程。所以,在语言设计方面,最关键的决策是让 Go 能够更好地适应大型软件工程,并且能够吸引有相同想法的开发人员。
在本文中,我们将会回顾那些我们认为对 Go 的成功负有最大责任的设计决策,并探讨这些设计决策如何不仅适用于语言,而且适用于更广泛的环境。很难将具体决策中的贡献分开,因此本文不应被视为一种科学的分析,而是一种对 Go 十多年来的经验和对用户反馈作出的最好的诠释。
起源
Go 的诞生源于谷歌构建了大规模分布式系统,在一个由成千上万的软件工程师共享的大型代码库中工作。我们希望为这种环境设计的语言和工具能够应对公司和整个行业所面临的挑战。随着开发工作的开展和生产系统的大量部署,这些都带来了一些挑战。
开发规模。在开发方面,谷歌在 2007 年有大约 4000 名活跃的用户在一个单一的、共享的、多语言(C++、Java、Python)的代码库中工作。单一的代码库使它很容易修复,例如,内存分配器中的问题会让主 Web 服务器变慢。但是在使用库的时候,由于很难找到一个包的所有依赖关系,所以很容易在不知不觉中破坏了一个以前未知的客户端。
另外,在我们使用的现有语言中,导入一个库可能会导致编译器递归加载所有导入的库。在 2007 年的一次 C++ 编译中,我们观察到,(在 #include
处理后)传递一组总共 4.2MB 的文件时,编译器读取了超过 8GB 的数据,在一个已经很大的程序上,扩展系数几乎达到 2000。如果为编译一个给定的源文件而读取的头文件的数量随着源树线性增长,那么整个源树的编译成本就会呈平方增长。
为了弥补速度的减慢,我们开始研究一个新的、大规模并行和可缓存的编译系统,它最终成为开源的 Bazel 编译系统。我们认为,光靠语言本身是远远不够的。
生产规模。在生产方面,谷歌运行的是规模非常庞大的系统。例如,在 2005 年 3 月,Sawzall 日志分析系统的一个拥有 1500 块 CPU 的集群处理了 2.8PB 的数据。2006 年 8 月,谷歌的 388 个 Big-table 服务集群由 24500 个独立的 Tablet 服务器组成,其中一组 8069 个服务器每秒处理 120 万个请求。
不过,像业界其他公司一样,谷歌也在致力于编写高效率的程序,以便充分发挥多核系统的优势。我们的很多系统都必须在一台机器上运行同一个二进制文件的多个副本,这是由于现有的多线程支持繁琐且性能低下。庞大的、固定大小的线程栈,重量级的栈开关,以及用于创建新线程和管理它们之间的交互的笨拙语法,都使得使用多核系统变得更加困难。但是显然,在服务器中,内核的数量只会越来越多。
我们还认为,语言自身能够提供易于使用的轻量级的并发性原语。我们也在这些额外的内核中看到了一个机会:垃圾收集器可以在一个专用的内核上与主程序并行地运行,这样可以减少它的延迟。
我们想知道,为应对这些挑战而设计的语言可能会是什么样子的,答案就是 Go。Go 的流行,一定程度上是因为所有的科技行业都要面对这样的挑战。云计算提供商使得最小型企业也可以将目标锁定在大规模的生产部署上。尽管大部分公司没有数千名雇员编写代码,但是如今几乎每个公司都依靠着数以千计的程序员完成的大量开源基础设施。
本文的其余部分将探讨具体的设计决定如何解决这些开发和生产的扩展目标。我们从核心语言本身开始,向外扩展到周围的环境。我们不打算全面地介绍这门语言。关于这一点,可以参阅 Go 语言规范或者《Go 编程语言》(The Go Programming Language)之类的书籍。
包
一个 Go 程序是由一个或多个可导入的包组成的,每个包都包含一个或多个文件。图 1 中的 Web 服务器展示很多有关 Go 的包系统设计的重要细节:
图 1:Go Web 服务器
该程序启动了一个本地的 Web 服务器(第 9 行),它通过调用 hello
函数来处理每个请求,hello 函数用消息“hello, world”(第 14 行)进行响应。
与许多语言相同,一个包使用明确的 import
语句导入另一个包(第 3-6 行),但与 C++ 的文本 #include
机制不同。不过,与大多数语言不同的是,Go 安排每个 import 只读取一个文件。例如,fmt
包的公共 API 引用了 io
包的类型:fmt.Fprintf
的第一个参数是 io.Writer
类型的接口值。在大多数语言中,处理 fmt
的 import 的编译器也会加载所有的 io
来理解 fmt
的定义,这可能又需要加载额外的包来理解所有 io
的定义。一条 import 语句可能最终要处理几十甚至几百个包。
Go 采用与 Modula-2 相似的方式,将编译后的 fmt
包的元数据包含了了解其自身依赖关系所需的一切,例如 io.Writer
的定义,从而避免了这种工作。因此,import "fmt"
的编译只读取一个完全描述 fmt
及其依赖关系的文件。此外,在编译 fmt
时,可以一次性实现这种扁平化,这样就可以避免每次导入时的多次加载。这种方式减少了编译器的工作量,加快了构建速度,为大规模的开发提供了便利。此外,包的导入循环是不允许的:由于 fmt
导入 io
,io
就不能导入 fmt
,也不能导入任何其他导入 fmt
的东西,即使是间接的。这也降低了编译器的工作量,确保了在单个单独编译的包的级别上对某个特定的构建进行拆分。这也使我们可以进行增量式的程序分析,即使在执行测试之前,我们也会执行这种分析来捕捉错误,如下所述。
导入 fmt
并不能使 io.Writer
这个名字对客户端可用。如果主包想使用 io.Writer
这个类型,那么它就必须为自己导入“io”。因此,一旦所有对 fmt 限定名称的引用被从源文件中删除——例如,如果 import "fmt"
调用被删除,import "fmt"
语句就可以安全地从源文件中删除,而无需进一步分析。这个属性使得自动管理源代码中的导入成为可能。事实上,Go 不允许未使用的导入,以避免将未使用的代码链接到程序中而造成的臃肿。
导入路径是带引号的字符串字面,这使其解释具有灵活性。斜线分隔的路径在导入时标识了 import
的包,但随后源代码会使用在包声明中声明的短标识符来引用该包。例如,import "net/http"
声明了顶层名称 http
,提供对其内容的访问。在标准库之外,包由以域名开头的类似 URL 的路径来识别,如 import "github.com/google/uuid"
。我们将在后面对这种包有更多的介绍。
作为最后一个细节,注意 fmt.Fprintf
和 io.Writer
这两个名字中的大写字母。Go 对 C++ 和 Java 的 public、private 和 protected 概念和关键字的模拟是一种命名惯例。带有大写字母的名字,如 Printf
和 Writer
,是“导出的”(公共的)。其他的则不是。基于大小写的、编译器强制执行的导出规则适用于常量、函数和类型的包级标识符;方法名称;以及结构域名称。我们采用这一规则是为了避免在公共 API 中涉及的每一个标识符旁边都写上一个像 export
这样的关键字的语法负担。随着时间的推移,我们已经开始重视查看标识符是否在包之外可用或在其每一次使用时纯粹是内部的能力。
类型
Go 提供了一套常见的基本类型。布尔,大小整数,如 uint8
和 int32
,非大小 int
和 uint
(32 或 64 位,取决于机器大小),以及大小浮点数和复数。它提供了指针、固定大小的数组和结构,其方式类似于 C 语言。它还提供了一个内置的字符串类型,一个称为 map 的哈希表,以及称为 slices 的动态大小的数组。大多数 Go 程序都依赖于这些,而没有其他特殊的容器类型。
Go 不定义类,但允许将方法绑定到任何类型,包括结构体、数组、切片、映射,甚至是基本类型,如整数。它没有类型层次结构;我们认为继承性往往会使程序在成长过程中更难适应。相反,Go 鼓励类型的组合。
Go 通过其接口类型提供面向对象的多态性。就像 Java 接口或 C++ 的抽象虚拟类一样,Go 接口包含一个方法名称和签名的列表。例如,前面提到的 io.Writer
接口被定义在 io
包中,如图 2 所示。
图 2:io 包的 Writer 接口
Write
接收一段字节,然后返回一个整数和可能的错误。与 Java 和 C++ 不同的是,任何 Go 类型如果拥有与某个接口相同的名称和签名的方法,都可以被视为实现了该接口,而无需显式声明它是这样做的。例如,os.File
类型有一个签名相同的 Write
方法,因此它实现了 io.Writer
,所以不需要像 Java 的“implements”注释那样的显式信号。
不要把这些接口当作一个复杂类型层次结构的基础块,而是要避免在接口和实现之间的显式关联,这样,Go 程序员就可以定义小型、灵活、通常是临时性的接口。它鼓励捕捉开发过程中出现的关系和操作,而不是需要提前计划和定义它们。这对大型程序尤其有帮助,因为在刚开始开发时,最终的结构是很难看清楚的。去除声明实现的簿记,鼓励使用精确的、只有一种或两种方法的接口,如 Writer
、Reader
、Stringer
(类似于 Java 的 toString
方法)等,这些接口普遍存在于标准库中。
初次学习 Go 的开发人员常常担心一个类型会意外地实现一个接口。虽然构建假设很容易,但在实践中,不太可能为两个不兼容的操作选择相同的名称和签名,而且我们从未在实际的 Go 程序中看到过这种情况发生。
并发性
当我们开始设计 Go 的时候,多核计算机已经开始广泛使用,但线程在所有流行的语言和操作系统中仍然是一个重量级的概念。创建、使用和管理线程的难度使其不受欢迎,限制了对多核 CPU 全部功能的使用。解决这一矛盾是创建 Go 的主要动机之一。
Go 语言本身包含了多个并发控制线程的概念,称为 goroutines,在一个共享地址空间中运行,并有效地复用到操作系统线程上。对阻塞操作的调用,如从文件或网络中读取,只阻塞进行该操作的 goroutine;该线程上的其他 goroutine 可能会被移到另一个线程,以便在调用者被阻塞时继续执行。goroutine 开始时只有几千字节的堆栈,它可以根据需要调整大小,无需程序员参与。开发人员将 Goroutines 作为一种丰富的、廉价的结构化程序的原语。对于一个服务器程序来说,拥有数千甚至数百万个 goroutines 是很平常的,因为它们的成本要远低于线程。
例如,net.Listener
是一个带有 Accept
方法的接口,可以监听并返回新进入的网络连接。图 3 显示了一个接受连接的函数 listen
,并为每个连接启动一个新的 goroutine 来运行服务函数。
图 3:一个 Go 的网络服务器。
listen
函数主体中的无限 for
循环(第 22-28 行)调用 listener.Accept
,它返回两个值:连接和一个可能的错误。假设没有错误,go
语句(第 27 行)在一个新的 goroutine 中启动其参数——函数调用 serve(conn)
,类似于 Unix shell 命令的后缀 &,但在同一个操作系统进程中。要调用的函数及其参数在原 goroutine 中被评估;这些值被复制以创建新 goroutine 的初始堆栈框架。因此,程序为每个进入的网络连接运行一个独立的 serve
函数实例。对 serve
的调用一次处理一个给定连接上的请求(第 37 行对 handle(req)
的调用没有以 go
为前缀);每次调用都可以阻塞而不影响对其他网络连接的处理。
在幕后,Go 的实现使用了高效的复用操作,比如 Linux 的 epoll,它可以处理并发的 I/O 操作,但用户是看不到的。Go 的运行库呈现的是阻塞式 I/O 的抽象,其中每个 goroutine 都是按顺序执行的,无需回调,这很容易推理。
在创建了多个 goroutine 之后,一个程序必须经常在它们之间进行协调。Go 提供了通道,允许 goroutine 之间进行通信和同步:通道是一个单向的、尺寸有限的管道,在 goroutine 之间传输类型化的信息。Go 还提供了一个多向 select
原语,可以根据通信的进行来控制执行。这些想法改编自 Hoare 的 "通信顺序过程 "19 和早期的语言实验,特别是 Newsqueak、Alef 和 Limbo。
图 4 展示了另一个版本的 listen
,它是为了限制任何时候的连接数量而写的。
图 4:一个 Go 网络服务器,限制为 10 个连接。
这个版本的 listen
首先创建了一个名为 ch
的通道(第 42 行),然后启动了一个由 10 个服务器 goroutines 组成的池(第 44-46 行),它们接收来自这个单一通道的连接。当新的连接被接收时,listen
使用 send 语句 ch < - conn
(第 53 行)在 ch
上发送每个连接。一个服务器执行接收表达式 < - ch
(第 59 行),完成通信。通道的创建没有空间来缓冲正在发送的值(Go 中的默认值),所以在 10 个服务器忙完前 10 个连接后,第 11 个 ch < - conn
将被阻塞,直到一个服务器完成对服务的调用并执行新的接收。被阻塞的通信操作对监听器产生了隐性的背压,阻止它接受一个新的连接,直到它放弃前一个连接。
请注意,在这些程序中缺乏互斥或其他传统的同步机制。在通道上进行的数据值通信可以作为同步的一部分;按照惯例,在通道上发送数据会将所有权从发送方传给接收方。Go 有提供互斥、条件变量、信号灯和原子值的库,供低级别的使用,但通道往往是更好的选择。根据我们的经验,人们对消息传递——利用通信在 goroutine 之间转移所有权——的推理比对互斥和条件变量的推理更容易、更正确。早期的口号是:“不要通过共享内存来交流,而是通过交流来共享内存”。
Go 的垃圾收集器大大简化了并发 API 的设计,消除了关于哪个 goroutine 负责释放共享数据的问题。与大多数语言一样(但与 Rust 不同),可变数据的所有权不由类型系统静态跟踪。相反,Go 集成了 TSAN,为测试和有限的生产使用提供了一个动态竞争检测器。
安全性
任何新语言的部分原因都是为了解决以前语言的缺陷,比如 Go,它涉及影响网络软件安全的安全问题。Go 删除了在 C 和 C++ 程序中造成许多安全问题的未定义行为。整数类型不会自动相互牵制。空指针取消引用和越界的数组和片索引会导致运行时异常。不存在指向栈框架的迷途指针。任何可能超出其栈框架的变量,例如在闭包中捕获的变量,将被移到堆中。在堆中也没有迷途指针;使用垃圾收集器而不是手动内存管理可以消除使用后的错误。当然,Go 并没有解决所有问题,有些东西被遗漏了,也许应该得到解决。例如,整数溢出可以被定义为运行时错误,而不是定义为绕过。
由于 Go 是一种用于编写系统的语言,它可能需要破坏类型安全的机器级操作,因此它能够将指针从一种类型胁迫到另一种类型,并执行地址运算,但只能通过使用 unsafe
包及其受限制的特殊类型 unsafe.Pointer
。必须注意保持对类型系统的违反与垃圾收集器兼容——例如,垃圾收集器必须始终能够识别一个特定的字是一个整数还是一个指针。在实践中,unsafe
包很少出现:安全 Go 是相当有效的。因此,看到 import "unsafe"
是一个信号,可以让我们更仔细地检查源文件是否存在安全问题。
与 C、C++ 之类的语言相比,Go 的安全性更好,更适合用于加密和其他重要的安全代码。在 C 和 C++ 中,一个微小的错误,比如一个越界的数据索引,就会造成敏感数据的泄漏或者被远程执行,但是在 Go 中,它会造成运行时的异常,从而使程序停止,极大地限制了潜在的影响。Go 提供了一个完整的加密库套件,其中包含了 SSL/TLS 的支持;标准库包含 HTTPS 客户端和服务器,可用于生产环境。实际上,Go 的安全性、性能和高品质库的结合使它成为了一个现代安全工作的热门试验场。比如,Let's Encrypt 是一家免费提供证书的机构,它依靠 Go 来提供生产服务,并在最近跨越了一个里程碑:签发了 10 亿份证书。
完整性
Go 在语言、库和工具层面提供了现代开发所需的核心部分。这就需要小心翼翼地平衡,既要增加足够多的“开箱即用”功能,又不能增加太多,以至于我们自己的开发过程因为要支持太多的功能而陷入困境。
该语言提供了字符串、哈希图和动态大小的数组作为内置的、易于使用的数据类型。如前所述,这些对于大多数 Go 程序来说已经足够了。其结果是 Go 程序之间有了更大的互操作性——例如,没有竞争性的字符串或哈希图的实现来分割包的生态系统。Go 包含的 goroutines 和 channel 是另一种形式的完整性。这些功能提供了现代网络程序中所需要的核心并发功能。直接在语言中提供这些功能,而不是在库中提供,这样可以更容易地调整语法、语义和实现,使其尽可能地轻量和易于使用,同时为所有用户提供统一的方法。
该标准库包括一个生产就绪的 HTTPS 客户端和服务器。对于在互联网上与其他机器互动的程序来说,这一点至关重要。直接满足这一需求可以避免额外的碎片化。我们已经看到了 io.Writer
接口;任何输出数据流都按惯例实现了这个接口,并与所有其他 I/O 适配器进行互操作。图 1 的 ListenAndServe
调用,作为另一个示例,期望有一个 http.Handler
类型的第二个参数,其定义如图 5 所示。参数 http.HandlerFunc(hello)
通过调用 hello
实现其 ServeHTTP
方法。该库创建了一个新的 goroutine 来处理每个连接,就像本文“并发性”部分中的监听器示例一样,所以处理程序可以用简单的阻塞风格来编写,服务器可以自动扩展以处理许多同步连接。
图 5:net/http 包的处理程序接口
http
包还提供了一个基本的调度器,它本身就是 Handler
的另一种实现,它允许为不同的 URL 路径注册不同的处理程序。将 Handler
确立为约定俗成的接口,使得许多不同类型的 HTTP 服务器中间件能够被创建并相互操作。我们不需要将所有这些实现添加到标准库中,但我们确实需要建立一个允许它们一起工作的接口。
标准 Go 发行版还提供了对交叉编译、测试、剖析、代码覆盖率、模糊处理等的集成支持。测试是另一个领域,在这个领域中,建立关于核心概念的协议——例如什么是测试用例以及如何运行——使得创建的自定义测试库和测试执行环境都能很好地互操作。
一致性
我们对 Go 的一个目标是让它在不同的实现、执行环境中,甚至在不同的时间内表现出相同的行为。这种“无聊”的一致性行为使开发人员能够专注于他们的日常工作,并使 Go 隐退到后台。
首先,语言尽可能地规定了一致的结果,即使是错误的行为,如空指针解除引用和越界数组索引,正如本文的“安全性”部分所讨论的。Go 需要不一致行为的一个例外是对哈希图的迭代。我们发现,程序员经常不经意地写下依赖于哈希函数的代码,导致在不同的架构或 Go 实现上出现不同的结果。
为了使程序在任何地方都有相同的表现,一种选择是强制规定一个特定的哈希函数。相反,Go 定义了映射迭代是非确定的。该实现为每个映射使用不同的随机种子,并从哈希表中的一个随机偏移量开始对映射进行每次迭代。其结果是,映射在不同的实现中都是不可预知的。代码不能意外地依赖于实现细节。与此类似,竞争检测器为调度决策增加了额外的随机性,创造了更多的机会来观察竞争。
一致性的另一个方面是在程序的生命周期内的性能。使用传统的编译器而不是 Java 和 Node.js 等语言使用的 JIT 来实现 Go 的决策,在启动时和短期程序中提供了一致的性能。不存在“慢速启动”来惩罚每个进程生命周期的前几秒。对于命令行工具和规模较大的网络服务器(如 Google App Engine)来说,这种快速的启动使 Go 成为一个有吸引力的目标。
一致性的性能包括垃圾收集的开销。最初的 Go 原型使用了一个基本的、即停即用的垃圾收集器,当然,它在网络服务器中引入了明显的尾部延时。今天,Go 使用了一个完全并发的垃圾收集器,暂停时间不到一毫秒,通常仅为几微秒,与堆的大小无关。最主要的延迟是操作系统向必须中断的线程传递信号所需的时间。
最后一种一致性是语言和库随着时间的推移而产生的一致性。在 Go 诞生的前几年,我们在每周的发布中都会对它进行修补和调整。用户在更新到新的 Go 版本时,往往不得不改变他们的程序。自动化的工具减轻了负担,但手动调整也是必要的。从 2012 年发布的 Go 版本开始,我们公开承诺只对语言和标准库进行向后兼容的修改,这样程序在编译到较新的 Go 版本时,可以在不改变的情况下继续运行。这一承诺吸引了业界,不仅鼓励了长期的工程项目,也鼓励了其他努力,如书籍、培训课程和第三方软件包的繁荣生态系统。
工具辅助开发
大规模的软件开发需要大量的自动化和工具化。从一开始,Go 的设计就是为了鼓励这种工具化,使其易于创建。
开发人员对 Go 的日常体验是通过 go
命令进行的。与只编译或运行代码的语言命令不同,go
命令为开发周期的所有关键部分提供了子命令:go build
和 go install
构建和安装可执行文件,go test
运行测试用例,go get
添加新的依赖。go
命令还提供了对构建细节的编程访问,例如软件包图,从而实现了新工具的创建。
其中一个工具是 go vet
,它可以执行增量的、每次打包的程序分析,可以像缓存编译的对象文件那样缓存,实现增量构建。go vet
工具的目的是高精度地识别常见的正确性问题,这样开发人员就有条件按照它的报告进行处理。简单的例子包括在调用 fmt.Printf
和相关函数时检查格式和参数是否匹配,或者诊断对变量或结构域的未使用的写入。这些不是编译器错误,因为我们不希望仅仅因为发现了一个新的可能的错误就停止编译旧代码。它们也不是编译器警告;用户要学会忽略这些。将这些检查放在一个单独的工具中,可以让它们在开发人员方便的时候运行,而不干扰普通的构建过程。这也使得所有的开发人员都可以使用同样的检查,即使是在使用 Go 编译器的另一种实现,如 Gccgo15 或 Gollvm17。增量方法使得这些静态检查足够有效,我们在 go test
期间自动运行它们,然后再运行测试本身。无论如何,测试是用户寻找错误的时候,而报告往往有助于解释实际的测试失败。这个增量框架也可以被其他工具重用。
分析程序的工具非常有用,但编辑程序的工具就更好了,特别是对于程序的维护方面,很多都是枯燥乏味的,并且已经是成熟的自动化。
Go 程序的标准布局是通过算法定义的。一个名为 gofmt
的工具将源文件解析为抽象的语法树,然后使用一致的布局规则将其格式化为源代码。在 Go 中,在将代码存储到源控制中之前将其格式化被认为是一种最佳做法。这种方法使数以千计的开发人员能够在一个共享的代码库中工作,而不需要经常为大括号样式和其他细节进行争论,这些都是伴随着这种大型工作的。更重要的是,工具可以通过对抽象语法形式的操作来修改 Go 程序,然后用 gofmt
的打印机写出结果。只有实际改变的部分才会被触及,产生的“差异”与人们的手写结果是一致的。人们和程序可以在同一个代码库中无缝协作。
为了实现这种方法,Go 的语法被设计为能够在没有类型信息或任何其他外部输入的情况下就可以对源文件进行解析,并且不需要预处理器或其他宏系统。Go 标准库提供了一些包,允许工具重新创建 gofmt
的输入和输出端,同时还有一个完整的类型检查器。
在发布 Go 第 1 版(第一个稳定的 Go 版本)之前,我们编写了一个叫做 gofix
的重构工具,它使用这些包来解析源代码,重写树,并写出格式良好的代码。例如,当从映射中删除一个条目的语法被改变时,我们就使用了 gofix
。每次用户更新到一个新版本时,他们可以在他们的源文件上运行 gofix
,自动应用更新到新版本所需的大部分变化。
这些技术也适用于 IDE 插件和其他支持 Go 程序员的工具——过滤器、调试器、分析器、构建自动程序、测试框架等等的构建。Go 的常规语法、既定的算法代码布局惯例以及对标准库的直接支持,使得这些工具的构建比其他方式要容易得多。因此,Go 世界拥有一个丰富的、不断扩展的、可互操作的工具包。
库
在语言和工具之后,用户如何体验 Go 的下一个关键方面是可用的库。作为一种分布式计算的语言,Go 中没有必须发布 Go 软件包的中央服务器。相反,每个以域名开始的导入路径都被解释为一个 URL(有一个隐含的前导 https://),提供远程源代码的位置。例如,import "github.com/google/uuid"
可以获取托管在相应的 GitHub 仓库的代码。
托管源代码最常见的方式是指向公共的 Git 或 Mercurial 服务器,但私人服务器也同样得到了很好的支持,作者可以选择发布一个静态的文件包,而不是开放对源控制系统的访问。这种灵活的设计和发布库的便利性创造了一个繁荣的可导入 Go 包的社区。依靠域名,避免了在扁平的包名称空间中急于声明有价值的条目。
仅仅下载软件包是不够的,我们还必须知道要使用哪些版本。Go 将包分组为称为模块的版本单位。一个模块可以为它的一个依赖关系指定一个最低要求的版本,但没有其他限制。当构建一个特定的程序时,Go 通过选择最大版本来解决竞争的依赖模块的所需版本。如果程序的一部分需要某个依赖模块的 1.2.0 版本,而另一部分需要 1.3.0 版本,Go 会选择 1.3.0 版本——也就是说,Go 要求使用语义版本划分,其中 1.3.0 版本必须是 1.2.0 的直接替换。另一方面,在这种情况下,即使 1.4.0 版本可用,Go 也不会选择它,因为程序中没有任何部分明确要求使用该较新的版本。这个规则保持了构建的可重复性,并最大限度地减少了因意外破坏新版本所引入的变化而造成的潜在风险。
在语义版本管理中,一个模块只能在一个新的主要版本中引入有意的破坏性变化,比如 2.0.0。在 Go 中,从 2.0.0 开始的每个主要版本在其导入路径中都有一个主要版本后缀,比如 /v2
。不同的主版本和其他不同名字的模块一样被分开。这种方法不允许出现钻石依赖性问题,而且在实践中,它可以适应不兼容的情况,也可以适应具有更精细约束的系统。
为了提高从互联网上下载软件包的构建的可靠性和可重现性,我们在 Go 工具链中运行了两个默认使用的服务:一个是可用 Go 软件包的公共镜像,一个是加密签名的预期内容的透明日志。即使如此,广泛使用从互联网上下载的软件包仍然存在安全和其他风险。我们正在努力使 Go 工具链能够主动识别并向用户报告脆弱的软件包。
总结
虽然大多数语言的设计都集中在语法、语义或类型的创新上,但 Go 的重点是软件开发过程本身。Go 语言高效、易学、免费,但我们相信,它的成功之处在于它所采取的编写程序的方式,特别是多个程序员在一个共享代码库上工作时。该语言本身的一个重要属性,即并发性,解决了 2010 年代随着多核 CPU 的大量使用而出现的问题。但更重要的是,早期的工作为打包、依赖关系、构建、测试、部署和软件开发领域的其他工作任务奠定了基础,这些方面通常在语言设计中并不重要。
这些想法吸引了志同道合的开发人员,他们重视的结果是:容易并发、明确的依赖关系、可扩展的开发和生产、安全的程序、简单的部署、自动代码格式化、工具辅助开发等等。这些早期的开发人员帮助普及了 Go,并播种了最初的 Go 包生态系统。他们还推动了该语言的早期发展,例如,将编译器和库移植到 Windows 和其他操作系统上(最初的版本只支持 Linux 和 MacOS X)。
并非所有的人都会喜欢——比如,有些人反对该语言省略了继承和泛型等常见功能。但是 Go 的开发导向的理念足够吸引人,也足够有效,以至于社区在保持最初推动 Go 存在的核心原则的同时,也得到了蓬勃发展。在很大程度上,多亏了这个社区和它所建立的技术,Go 如今已成为现代云计算环境的一个重要组成部分。
自 Go 第一版发布以来,该语言几乎被冻结。然而,工具已经大大扩展,有了更好的编译器,更强大的构建和测试工具,以及改进的依赖性管理,更不用说支持 Go 的大量开源工具了。然而,变化正在到来。2022 年 3 月发布的 Go 1.18,它包含了对语言的真正改变的第一个版本,一个被广泛要求的改变:首次实现了参数化多态。我们将任何形式的泛型排除在原始语言之外,因为我们敏锐地意识到,它很难设计好,而且在其他语言中,往往是复杂性而不是生产力的来源。在 Go 的第一个十年中,我们考虑了很多设计,但直到最近才找到一个我们认为很适合 Go 的设计。在坚持一致性、完整性和社区原则的前提下进行如此大的语言变革,对于这种方式来说,将是一个巨大的挑战。
作者介绍:
本文作者为:Russ Cox、Robert Griesemer、Rob Pike、Ian Lance Taylor、Ken Thompson,均为谷歌公司软件工程师,参与了 Go 项目。Rob Pike、Ken Thompson 已退休。
原文链接:
https://m-cacm.acm.org/magazines/2022/5/260357-the-go-programming-language-and-environment/fulltext
评论