这是关于 「」 系列的另一篇文章。Go 确实有一些很棒的特性,所以我在这篇文章中展示了它的优点。但是总体而言,当超过 API 或者网络服务器(这也是它的设计所在)的范畴,用 Go 处理商业领域的逻辑时,我感觉它用起来麻烦而且痛苦。就算在网络编程方面,Go 的设计和实现也存在诸多问题,这使它看上去简单实际则暗藏危险。

写这篇文章的动机是因为我最近重新开始用 Go 写一个业余项目。在以前的工作中我广泛的使用了 Go 为 SaaS 服务编写网络代理(包括 http 和原始的 tcp)。网络编程的部分是相当令人愉快的(我也正在探索这门语言),但随之而来的会计和账单部分则苦不堪言。因为我的业余项目只是一个简单的 API,我认为 Go 非常适合快速的完成这个任务。但是我们都知道,很多项目的增长会超过了预期的范围,所以我不得不写一些数据处理来计算统计数据,Go 的痛苦之处也随着而来。下面就是我对 Go 的困扰。

一些背景情况:我喜欢静态类型的语言。我第一个标志性的项目是用 写的。当90年代初我开始工作之后,开始使用 和 C/C++。后来我转移到 Java 阵地,最后到了 Scala(中间夹杂着 Go),最近开始学习 。我也写了大量的 JavaScript,因为直到现在它依旧是浏览器端唯一可用的语言。我感觉动态类型的语言并不安全,并尽力将它们的使用限制在脚本级别。我习惯了命令式,函数式和面向对象的方法。

这是一篇很长的文章,所以我列出了菜单来“激发你的食欲”:

优点

Go 很容易学习

这是事实:如果你了解任何一种编程语言,那么通过在「」学习几个小时就能够掌握 Go 的大部分语法,并在几天后写出你的第一个真正的程序。阅读并理解 ,浏览一下「」,玩一玩 或者 这样的网络工具包,然后你将成为一个相当不错的 Go 开发者。

这是因为 Go 的首要目标是简单。当我开始学习 Go,它让我想起我第一次 :一个简单的语言和一个丰富但不臃肿的标准库。对比当前 Java 沉重的环境,学习 Go 是一个耳目一新的体验。因为 Go 的简易性,Go 程序可读性非常高,虽然错误处理添加了一些麻烦(更多的内容在下面)。

Go 语言的简单可能是错误的。引用 Rob Pike 的话,,我们会看到简单背后有很多的陷阱等着我们去踩,极简主义会让我们违背 DRY(Don't Repeat Yourself) 原则。

基于 goroutines 和 channels 的简单并发编程

Goroutines 可能是 Go 的最佳特性了。它们是轻量级的计算线程,与操作系统线程截然不同。

当 Go 程序执行看似阻塞 I/O 的操作时,实际上 Go 运行时挂起了 goroutine ,当一个事件指示某个结果可用时恢复它。与此同时,其他的 goroutines 已被安排执行。因此在同步编程模型下,我们具有了异步编程的可伸缩性优势。

Goroutines 也是轻量级的:它们的堆栈 ,这意味着有 100 个甚至 1000 个 goroutines 都不是问题。

我以前的应用程序中有一个 goroutine 漏洞:这些 goroutines 结束之前正在等待一个 channel 关闭,而这个 channel 永远不会关闭(一个常见的死锁问题)。这个进程毫无任何理由吃掉了 90 % 的 CPU ,而检查 显示有 600 k 空闲的 goroutines! 我猜测 goroutine 调度程序占用了 CPU。

当然,像 Akka 这样的 Actor 系统可以轻松 ,部分原因是 actors 没有堆栈,但是他们远没有像 goroutines 那样简单地编写大量并发的请求/响应应用程序(即 http APIs)。

channel 是 goroutines 的通信方式:它们提供了一个便利的编程模型,可以在 goroutines 之间发送和接收数据,而不必依赖脆弱的低级别同步基本体。channels 有它们自己的一套 。

但是,channels 必须仔细考虑,因为错误大小的 channels (默认情况下没有缓冲) 。下面我们还将看到,使用通道并不能阻止竞争情况,因为它缺乏不可变性。

丰富的标准库

Go 的 非常丰富,特别是对于所有与网络协议或 API 开发相关的: http 客户端和服务器,加密,档案格式,压缩,发送电子邮件等等。甚至还有一个html解析器和相当强大的模板引擎去生成 text & html,它会自动过滤 XSS 攻击(例如在 中的使用)。

各种 APIs 一般都简单易懂。它们有时看起来过于简单:这个某种程度上是因为 goroutine 编程模型意味着我们只需要关心“看似同步”的操作。这也是因为一些通用的函数也可以替换许多专门的函数,就像 。

Go 性能优越

Go 编译为本地可执行文件。许多 Go 的用户来自 Python、Ruby 或 Node.js。对他们来说,这是一种令人兴奋的体验,因为他们看到服务器可以处理的并发请求数量大幅增加。当您使用非并发(Node.js)或全局解释器锁定的解释型语言时,这实际上是相当正常的。结合语言的简易性,这解释了 Go 令人兴奋的原因。

然而与 Java 相比,在 中,情况并不是那么清晰。Go 打败 Java 地方是内存使用和垃圾回收。

Go 的垃圾回收器的设计目的是 ,并避免停机,这在服务器中尤其重要。这可能会带来更高的 CPU 成本,但是在水平可伸缩的体系结构中,这很容易通过添加更多的机器来解决。请记住,Go 是由谷歌设计的,他们从不会在资源上面短缺。

与 Java 相比,Go 的垃圾回收器(GC)需要做的更少:切片是一个连续的数组结构,而不是像 Java 那样的指针数组。类似地,Go maps 也使用,以实现相同的目的。这意味着垃圾回收器的工作量减少,并且 CPU 缓存本地化也更好。

Go 同样在命令行实用程序中优于 Java :作为本地可执行文件,Go 程序没有启动消耗,反之 Java 首先需要加载和编译的字节码。

语言层面定义源代码的格式化

gofmt
gofmt

标准化的测试框架

Go 在其标准库中提供了一个很好的 。它支持并行测试、基准测试,并包含许多实用程序,可以轻松测试网络客户端和服务器。

Go 程序方便操作

与 Python,Ruby 或 Node.js 相比,必须安装单个可执行文件对于运维工程师来说是一个梦想。 随着越来越多的 Docker 的使用,这个问题越来越少,但独立的可执行文件也意味着小型的 Docker 镜像。

Go还具有一些内置的观察性功能,可以使用 包发布内部状态和指标,并易于添加新内容。但要小心,因为它们在默认的 http 请求处理程序中 ,不受保护。Java 有类似的 JMX ,但它要复杂得多。

Defer 声明,防止忘记清理

finallydefer

当然,Java的 没那么冗长,而且 Rust 在其所有者被删除时会 ,但是由于 Go 要求您清楚地了解资源清理情况,因此让它接近资源分配很不错。

新类型

stringlong

Go 对新类型有一等支持,即类型为现有类型并赋予其独立身份,与原有类型不同。 与包装相反,新类型没有运行时间开销。 这允许编译器捕捉这种错误:

不幸的是,缺乏泛型使得使用新类型变得麻烦,因为为它们编写可重用代码需要从原始类型转换值。

缺点

Go 忽略了现代语言设计的进步

在中,Rob Pike 解释说 Go 是为了在谷歌取代 C 和 C++,它的前身是 ,这是他在80年代写的一种语言。Go 也有很多关于 的参考,Plan9 是一个分布式操作系统,在贝尔实验室的80年代开发的。

甚至有一个直接从 Plan9 获得灵感的。为什么不使用 来提供目标范围广泛且开箱即用的体系结构?我此处可能也遗漏了某些东西,但是为什么需要汇编?如果你需要编写汇编以充分利用 CPU ,那么不应该直接使用目标 CPU 汇编语言吗?

Go 的创造者应该得到尊重,但是看起来 Go 的设计发生在平行宇宙(或者他们的 Plan9 lab?)中发生的,这些编译器和编程语言的设计在 90 年代和 2000 年中从未发生过。也可能 Go 是由一个会写编译器的系统程序员设计的。

函数式编程吗?不要提它。泛型?你不需要,看看他们用 C++ 编写的烂摊子!尽管 slice、map 和 channel 都是泛型类型,我们将在下面看到。

Go 的目标是替换 C 和 C++,很明显它的创建者也没有关注其他地方。但他们没有达到目标,因为在谷歌的 C 和 C++ 开发人员没有采用它。我的猜测是主要原因是垃圾回收器。低级别 C 开发人员强烈拒绝托管内存,因为他们无法控制什么时间发生什么情况。他们喜欢这种控制,即使它带来了额外的复杂性,并且打开了内存泄漏和缓冲溢出的大门。有趣的是,Rust 在没有 GC 的情况下采用了完全不同的自动内存管理方法。

Go 反而在操作工具的领域吸引了 Python 和 Ruby 等脚本语言的用户。他们在 Go 中找到了一种方法,可以提高性能,减少 内存/cpu/磁盘 占用。还有更多的静态类型,这对他们来说是全新的。Go 的杀手级应用是 Docker ,它在 devops 世界中引起了广泛的应用。Kubernetes 的崛起加强了这一趋势。

接口是结构类型

Go 接口就像 Java 接口或 Scala 和 Rust 特性(traits):它们定义了后来由类型实现的行为(我不称之为“类”)。

与 Java 接口和 Scala 和 Rust 特性不同,类型不需要显式地指定接口实现:它只需要实现接口中定义的所有函数。所以 Go 的接口实际上是结构化的。

我们可能认为,这是为了允许其他包中的接口实现,而不是它们适用的类型,比如 Scala 或 Kotlin 中的类扩展,或 Rust 特性,但事实并非如此:所有与类型相关的方法都必须在类型的包中定义。

Go 并不是唯一使用结构化类型的语言,但我发现它有几个缺点:

  • 找到实现给定接口的类型很难,因为它依赖于函数定义匹配。我通过搜索实现接口的类,经常发现 Java 或 Scala 中有趣的实现。
  • 当向接口添加方法时,只有当它们用作此接口类型的值时,才会发现哪些类型需要更新。 相当一段时间这可能被忽视。 Go 建议使用非常少的方法构建小型的接口,这是防止这种情况的一种方式。
  • 类型可能在不知不觉中实现了一个接口,因为它作为相应的方法。但是偶然的,实现的语义可能与接口契约所期望的不同。

更新 : 对于接口的一些丑陋问题,请参阅下面的 。

没有枚举

Go 没有枚举,在我看来,这是一个错失的机会。

iota
switch
:=var
var x = "foo"x:= "foo"
var x string:=:=
var
:=
:=:==

零值 panic

Go 没有构造函数。正因为如此,它坚持认为“零值”应该是易于使用的。这是一个有趣的方法,但在我看来,它所带来的简化主要是针对语言实现者的。

io.File

我们能发现什么?

FileName()fileReadFile
FilepanicsOpenCreate
html.Templatepanic
mappanic
map

因此,作为一个开发人员,您必须经常检查您想要使用的结构是否需要调用构造函数,或者零值是否可用。这是语言简化对编程带来的沉重负担。

Go 没有异常。哦,等一下……它有!

错误panic
panic
unmarshal
trycatch (DecodingException ex)

有趣的事实:几个星期前,一个非谷歌的人,以使用常规的错误冒泡处理。

令人厌恶的点

依赖管理噩梦

首先引用一个在谷歌著名的 Go 语言使用者 Jaana Dogan (aka JBD) 的话,最近在推特上发泄她的不满:

如果依赖管理再过一年还没有解决,我将会考虑退出 Go 并且永远不会回来。 依赖性管理问题经常颠覆我从语言中获得的所有乐趣。
— JBD (@rakyll) 简单点说,Go 中没有依赖管理。当前所有的解决方案都只是一些技巧和变通方法。

这要追溯到它的起源 -- 谷歌,以使用了一个 管理所有源代码而闻名。不需要模块的版本控制,也不需要第三方模块的仓库,你可以从当前分支构建任何项目。不幸的是,这在开放的互联网上是行不通。

在 Go 中添加依赖意味着将依赖的源代码仓库克隆到你的 GOPATH 下。版本是什么?克隆当前的主分支就行了,管它写的是什么。但是如果不同的项目需要不同的版本依赖呢?他们做不到。因为「版本」的概念根本不存在。

GOPATHGOPATH
vendoring
vendorvendoring
depGOPATH
depvgo
GOPATH

现在让我们再次回到代码的问题上。

易变性是用语言硬编码的。

conststruct
struct

下面的例子说明了这个问题:

所以你必须非常小心,如果你通过值传递参数,不要认定它就是不变的。

有一些 试图使用(慢)反射来解决这个问题,但是它们有不足之处,因为私有字段不能通过反射访问。因此,为了避免竞争条件而进行防御性复制将会很困难,需要大量的重复代码。Go甚至没有一个可以标准化这个的克隆接口。

切片(slice)陷阱

copy()
appendcopy()append

在下面的代码中,我们看到为子切片追加值的影响取决于原始切片的容量:

易变性和 channels: 竞争条件更容易发生。

Go 并发性是 上的,它使用 channel 使得协调 goroutines 比在共享数据上同步更简单和安全。老话说的是「」。这是一厢情愿的想法,在实践中是不能安全实现的。

正如我们在上面看到的那样,Go 没办法获得不可变的数据结构。这意味着一旦我们在 channel 上发送一个指针,游戏就结束了:我们在并发进程之间共享了可变的数据。当然,一个 channel 的结构是赋值 channel 传送的值(而不是指针),但是正如我们在上面看到的,这些没有深度复制引用,包括 slices 和 maps 本质上都是可变的。与接口类型的 struct 字段相同:它们是指针,接口定义的任何可变方法都是对竞争条件的开放。

因此,尽管 channels 表面上使并发编程变得容易,但它们并不能阻止共享数据上的竞争条件。而 slices 和 maps 本身的可变性使这种情况更有可能发生。

谈到竞争条件时,Go 包含一个 ,该模式检测代码以找到不同步的共享访问。它只能在事件发生的时候检测到竞争问题,所以大多数情况下是在集成或负载测试期间,希望这些能够运行比赛条件。由于它的高运行时成本(除了临时的调试会话),它不能实际应用于生产环境。

嘈杂的错误管理

你可以很快学会 Go 的错误处理模式,重复到令人作呕:

因为 Go 声称不支持异常(),每个能够以错误结尾的函数都必须把错误作为其最后一个结果。这特别适用于执行某些 I/O 的每个函数,因此这种啰嗦的模式在网络应用程序中非常普遍,这是 Go 的主要领域。

您很快就会忽视这种模式,并将其识别为「好,错误处理了」,但是仍然很杂乱,有时很难在错误处理中找到实际的代码。

这里有几个问题,因为一个错误的结果可能有名无实,例如当从无所不在的 io.Reader读取时:

在“Error has values”中,Rob Pike 提出了一些减少错误处理冗余的策略。我发现它们实际上是危险的创可贴:

基本来说,一直检查错误是很痛苦的,所以这里提供了直到结束之前都会忽略错误的方法。任何写入操作一旦出错它还是会执行,即使我们知道不该再执行了。如果这样做资源消耗更高呢?我们刚刚浪费了资源,因为 Go 的错误处理是一种痛苦。

Resulttry!

由于 Go 没有泛型和宏,所以很不幸地,更换为 Rust 的方法是不可能的。

Nil 接口值

这是在看到 展示了 nil 和接口的怪异表现后的更新,这绝对称得上是丑陋的。我稍微扩展了一下:

explodesBoomBangprintlnbomb0x0nilexplodes(0x10a7060,0x0)
ExplodesBombExplodesnil
BangBombBoom
var explodes Explodes = nil!= nil

那么,我们应该如何安全地编写测试呢?我们必须检查接口值,如果是非 nil,检查接口对象指向的值…使用反射!

Go语言之旅

尽管如此,这仍然是丑陋的,并且会导致非常细微的错误。在我看来,这是语言设计中的一个很大的缺陷,只是为了使它的实现更加容易。

Struct 字段标记:字符串中的运行时DSL。

如果您在 Go 中使用了 JSON,您肯定遇到过类似的情况:

这些是 ,语言规范说这是一个字符串「通过反射接口可见,并参与结构的类型标识,但是却被忽略了」。所以,基本上,把你想要的东西放到这个字符串中,并在运行时使用反射来解析它。如果语法不对,运行时就会出现 panic。

这个字符串实际上是字段元数据,在许多语言中已经存在了几十年,称为「」或「属性」。通过语言支持,它们的语法在编译时被正式定义和检查,同时仍然是可扩展的。

为什么要决定使用一个原始字符串,任何库都可以决定使用它想要的任何 DSL ,在运行时解析?

当您使用多个库时,情况会变得很糟糕:这里有一个从协议缓冲区的 中取出的示例:

附注:为什么这些标签在使用 JSON 时如此常见?因为在 Go 公共字段中,必须使用大写字母,或者至少以大写字母开头,而在 JSON 中命名字段的常见约定是小写的 camelcase 或 snake_case。因此需要进行冗长的标记。

标准的 JSON 编码器 / 解码器不允许提供自动转换的命名策略,就像 。这可能解释了为什么 Docker APIs 中的所有字段都是大写的:这避免了它的开发人员为他们的大型 API 编写这些笨拙的标签。

没有泛型…至少不是为了你。

很难想象一种没有泛型的现代静态类型化语言,但这就是你在 Go 中看到的:它没有泛型...或者更精确地说,几乎没有泛型,我们会看到它比没有泛型更糟糕。

map[string]MyStruct
interface{}

在「」中,Rob Pike 意外地将泛型和继承放在同一个「类型编程」包中,并说他喜欢组合而不是继承。不喜欢继承很好(实际上我写了很多没有继承的Scala),但是泛型回答了另一个问题:可重用性,同时保护类型安全。

正如下面我们将看到的,在用泛型做内部构建和用户无法定义泛型之间的区别会对开发人员「舒适」和编译时类型安全产生更多的影响:它会影响整个 Go 生态系统。

Go 在 slice 和 map 之外几乎没有什么数据结构。

interface{}

让我们来看一个 的例子,它是一个具有较低线程争用的并发映射,而不是使用互斥锁来保护常规映射::

这就是为什么在 Go 生态系统中没有很多数据结构的一个很好的例子:与内置的切片和映射相比,它们是一种痛苦。原因很简单:数据结构分为两类:

range

因此,库定义的数据结构真的需要为我们的开发人员提供切实的利益,才愿意为松散类型的安全性和额外的代码冗长付出代价。

当我们想要编写可重用的算法时,内置结构和 Go 代码之间的二元性在细节方面是痛苦的。这是标准库的 中的一个例子:

ByAge

对我们开发人员来说唯一重要的是,用较少的函数比较两个对象,并且是域依赖的。其他的东西都是噪音和重复,简单的事实是,Go 没有泛型。我们需要对每一种我们想排序的类型重复它,每个比较器也是。

更新: 指出我忘记了 。看起来好多了,尽管它在 hood (eek!)下使用反射,并要求比较器函数在切片上做一个闭包去排序,这仍然很难看。

interface{}

好了。为了缓解疼痛,如果 Go 有可以生成这个无意义的样板的宏就好了,对吗?

go generate,还说得过去,但是...

//go:generate//go:generate

这实际上涵盖了两种应用场景:

String()
makefiles

对于第二个用例,许多语言,比如 Scala 和 Rust,有宏(在 中提到的)在编译过程中都可以访问源代码的 AST。Stringer 实际上 来遍历 AST。Java 没有宏,但注释处理器扮演同样的角色。

许多语言也不支持宏,所以在这里没有什么根本的错误,除了这个脆弱的由逗号驱动的语法,它看起来像一个快速的技巧,以某种方式完成工作,而不是作为清晰的语言设计被慎重考虑。

哦,你知道 Go 编译器实际上有 和 使用这个脆弱的注释语法吗?

结论

就像你猜到的,我对 Go 爱恨交加。Go 有点像这样的朋友,你喜欢和他一起出去玩,因为他很有趣和他喝啤酒聊天很棒,但是当你想进行更深入的交流时,你会觉得无聊和痛苦,然后你不想和他一起去度假。

我喜欢 Go 在写高效的 APIs 或网络方面时的简单,goroutines 使这些 很容易解释。当我必须实现业务逻辑时,我讨厌它有限的表现力和所有的等着打击你的语言怪癖和陷阱。

直到最近,在 Go 占据的领域中并没有出现真正的替代选择,它高效地开发本地可执行文件,而不会导致 C 或 C++ 的痛苦。 在飞速进步,我用得越多,越能发现它的有趣之处和优秀设计。我有一种感觉,Rust 是那些需要时间相处的朋友,你最终会想要和他们建立长期的关系。

回归技术层面,你会发现一些文章说 Rust 和 Go 不是一个领域的,Rust 是一种系统语言,因为它没有内存回收机制 等等。我认为这越来越不真实了。在 和优秀的 s 中 Rust 正在爬得更高。它也给你一种温暖的感觉,“如果它编译,错误将来自我写的逻辑,而不是我忘记注意的语言怪癖”。

我们还在容器/服务网格区域看到一些有趣的行动, Buoyant(的开发商)正在开发它们的新 Kubernetes 服务网格 作为一个组合,来自控制层面(我猜可能是因为可用的 )的 Go 和数据层面拥有良好效率和鲁棒性的 Rust ,以及 。

Swift 也是这个家庭的一份子,或者是 C 和 C++ 的最新替代品。它的生态系统仍然过于以苹果为中心,即使它现在可以在 Linux 上使用,并且已经有了新的 和 。

这里当然没有万能药和通用之法。但是知道你所用工具的问题至关重要。我希望这篇博文教会了你关于 Go 你以前没有意识到的问题,这样你就可以避开陷阱!

几天后: Hacker News 第三名!

更新,发布3天后:这篇文章反响惊人。它已经成为了 的头版(我看到的最好排名是#3)和(我看到的最好排名是#5),并且在 。

这些评论通常都是正面的(甚至是在),或者至少承认这篇文章是公平的,并且力求公正。的人们当然喜欢我对 Rust 的兴趣。我从未听说过的人甚至给我发邮件说:“我只是想让你知道,我认为你写的文章是最好的。感谢您为此付出的所有努力”。

这是写作时最困难的部分:尽量做到客观公正。这当然不是完全可能的,因为每个人都有自己的偏好,为什么我关注意外的惊喜和语言工程学:语言对你有多大帮助,而不是妨碍你,或者至少是我的方式

我还在标准库或 上搜索了代码样本,并引用了Go团队的人员,以我对权威材料的分析为基础,避免了“meh,你引用了一个错误的人”的反应。

写这篇文章用了我两个星期的晚上时间,但是这真的很有趣。当你做严肃而诚实的工作时,你会得到这样的结果:来自技术网络的许多好的共鸣(如果你忽略掉少数捣乱的和一直脾气暴躁的人)。极大调用了我写更多的深度内容的积极性!


译者:

校对:

本文由 原创编译, 荣誉推出