去:好的,坏的和丑的

2021-08-10 00:10:12

这是“Go 不好”系列中的附加帖子。 Go 确实有一些不错的特性,因此在这篇文章中是“好的”部分,但总的来说,当我们超越 API 或网络服务器(这是它的设计目的)并将其用于业务时,我发现使用它既麻烦又痛苦域逻辑。但即使对于网络编程,它的设计和实现也有很多问题,这使得它在看似简单的情况下变得危险。这篇文章的动机是我最近重新开始使用 Go 进行辅助项目。我在之前的工作中广泛使用 Go 为 SaaS 服务编写网络代理(http 和原始 tcp)。网络部分相当愉快(我也发现了语言),但随之而来的会计和计费部分很痛苦。由于我的副项目是一个简单的 API,我认为使用 Go 将是快速完成工作的正确工具,但正如我们所知,许多项目超出了最初的范围,所以我不得不编写一些数据处理来计算统计数据和痛苦Go 回来了。所以这是我对围棋困境的看法。一些背景:我喜欢静态类型语言。我的第一个重要程序是用 Pascal 编写的。当我在 90 年代初开始工作时,我使用了 Ada 和 C/C++。后来我转向 Java,最后转向 Scala(中间有一些 Go),最近开始学习 Rust。我还编写了大量 JavaScript,因为直到最近它还是 Web 浏览器中唯一可用的语言。我对动态类型语言感到不安全,并试图将它们的使用限制为简单的脚本。我对命令式、函数式和面向对象的方法很满意。这是一个事实:如果你知道任何一种编程语言,你可以通过“Go 之旅”在几个小时内学习 Go 的大部分语法,并在几天内编写你的第一个真正的程序。阅读并消化 Effective Go,在标准库中闲逛,使用像 Gorilla 或 Go kit 这样的网络工具包,你将成为一个相当不错的 Go 开发人员。这是因为 Go 的首要目标是简单。当我开始学习 Go 时,它让我想起了我第一次发现 Java:一种简单的语言和一个丰富但不臃肿的标准库。学习 Go 是一种来自当今 Java 繁重环境的令人耳目一新的体验。由于 Go 的简单性,Go 程序非常易读,即使错误处理会增加一些噪音(更多内容见下文)。但这可能是错误的简单。引用 Rob Pike 的话,简单是复杂的,我们将在下面看到它背后有很多问题在等着我们,而简单和极简主义阻止了编写 DRY 代码。 Goroutines 可能是 Go 最好的特性。它们是与操作系统线程不同的轻量级计算线程。

当 Go 程序执行看起来像是阻塞 I/O 操作时,Go 运行时实际上会挂起 goroutine,并在事件指示某些结果可用时恢复它。与此同时,其他 goroutine 已被安排执行。因此,我们拥有异步编程与同步编程模型的可扩展性优势。 Goroutines 也是轻量级的:它们的堆栈按需增长和收缩,这意味着拥有 100 甚至 1000 个 goroutines 不是问题。我曾经在应用程序中遇到过 goroutine 泄漏:这些 goroutine 在结束之前等待关闭通道,而该通道从未关闭(一个常见问题)。该进程无缘无故地消耗了 90% 的 CPU,并且检查 expvars 显示 600k 空闲 goroutines!我猜 CPU 被 goroutine 调度程序使用了。当然,像 Akka 这样的 Actor 系统可以毫不费力地处理数百万个 Actor,部分原因是 Actor 没有堆栈,但它们远没有 goroutines 那样容易使用来编写大量并发的请求/响应应用程序(即http API)。通道是 goroutines 应该如何通信:它们提供了一个方便的编程模型来在 goroutines 之间发送和接收数据,而不必依赖脆弱的低级同步原语。频道有自己的一套使用模式。但是,必须仔细考虑通道,因为大小不正确的通道(默认情况下它们是无缓冲的)会导致死锁。他们也有大量的问题和不一致之处。我们还将在下面看到使用通道并不能防止竞争条件,因为 Go 缺乏不变性。 Go 标准库真的很棒,特别是对于与网络协议或 API 开发相关的一切:http 客户端和服务器、加密、存档格式、压缩、发送电子邮件等。甚至还有一个 html 解析器和一个相当强大的模板引擎来生成文本& html 自动转义以避免 XSS(例如由 Hugo 使用)。

各种 API 通常简单易懂。但它们有时看起来很简单:这部分是因为 goroutine 编程模型意味着我们只需要关心“看似同步”的操作。这也是因为我最近发现一些通用函数也可以代替许多专门的函数来计算时间。 Go 编译为本地可执行文件。许多 Go 用户来自 Python、Ruby 或 Node.js。对他们来说,这是一种令人兴奋的体验,因为他们看到服务器可以处理的并发请求数量大幅增加。当您来自没有并发(Node.js)或全局解释器锁的解释型语言时,这实际上很正常。结合语言的简单性,这解释了 Go 的部分兴奋。然而,与 Java 相比,原始性能基准测试中的情况并不那么清楚。 Go 击败 Java 的地方在于内存使用。除非您使用 Graal native-image 将它们放在同一范围内。 Go 的垃圾收集器旨在优先考虑延迟并避免停止世界暂停,这在服务器中尤为重要。这可能会带来更高的 CPU 成本,但在水平可扩展的架构中,这可以通过添加更多机器轻松解决。请记住,Go 是由 Google 设计的,而 Google 几乎资源匮乏!与 Java 相比,Go GC 要做的工作也更少:结构的切片是一个连续的结构数组,而不是像 Java 中的指针数组。类似地,Go 地图使用小数组作为桶用于相同的目的。这意味着 GC 的工作更少,而且 CPU 缓存位置更好。 Go 在命令行实用程序方面也优于 Java:作为本机可执行文件,Go 程序没有启动成本,而 Java 则首先必须加载和编译字节码。我职业生涯中一些最激烈的争论发生在团队代码格式的定义上。 Go 通过为 Go 代码定义规范格式来解决这个问题。 gofmt 工具重新格式化您的代码并且没有选项。

不管你喜不喜欢,gofmt 定义了 Go 代码应该如何格式化,因此这个问题一劳永逸地解决了! Go 在其标准库中附带了一个很棒的测试框架。它支持并行测试、基准测试,并包含许多实用程序来轻松测试网络客户端和服务器。与 Python、Ruby 或 Node.js 相比,必须安装单个可执行文件是运维工程师的梦想。随着 Docker 的使用越来越多,这越来越不是一个问题,但独立的可执行文件也意味着很小的 Docker 镜像。 Go 的 expvar 包还具有一些内置的可观察性功能,用于发布内部状态和指标,并且可以轻松添加新的状态和指标。不过要小心,因为它们会在默认的 http 请求处理程序上自动公开、不受保护。 Java 具有用于类似目的的 JMX,但它要复杂得多。 defer 语句的作用类似于 Java 中的 finally:在当前函数的末尾执行一些清理代码,无论该函数如何退出。 defer 的有趣之处在于它不链接到代码块,并且可以随时出现。这允许编写清理代码尽可能接近创建需要清理的代码: file , err := os 。 Open (fileName) if err != nil { return } defer file 。 Close() // 使用文件,我们不必再考虑关闭它 当然,Java 的 try-with-resource 不那么冗长,当资源的所有者被删除时 Rust 会自动声明资源,但是因为 Go 要求你明确资源清理,接近资源分配很好。

我喜欢类型,而让我恼火/害怕的事情是,例如,当我们将持久对象标识符作为 string 或 long 到处传递时。我们通常在参数名称中编码 id 的类型,但是当一个函数有多个标识符作为参数并且一些调用与参数顺序不匹配时,这会导致细微的错误。 Go 对新类型有一流的支持,即采用现有类型并赋予它与原始类型不同的单独标识的类型。与包装相反,新类型没有运行时开销。这允许编译器捕获这种错误: type UserId string // <-- new type type ProductId string func AddProduct ( userId UserId , productId ProductId ) {} func main () { userId := UserId ( "some-user- id" ) productId := ProductId ( "some-product-id" ) // 正确顺序:一切正常 AddProduct ( userId , productId ) // 错误顺序:将使用原始字符串编译 AddProduct ( productId , userId ) // 编译错误: // 不能在 AddProduct 的参数中使用 productId(类型 ProductId)作为类型 UserId // 不能在 AddProduct 的参数中使用 userId(类型 UserId)作为类型 ProductId } 不幸的是,缺乏泛型使得使用新类型变得很麻烦,因为要为他们需要将值转换为原始类型/从原始类型转换。在 Less 成倍增加中,Rob Pike 解释说 Go 旨在取代 Google 的 C 和 C++,它的前身是 Newsqueak,这是他在 80 年代编写的一种语言。 Go 也有很多对 Plan9 的引用,Plan9 是 Go 的作者在 80 年代在贝尔实验室开发的分布式操作系统。甚至还有一个直接受 Plan9 启发的 Go 程序集。为什么不使用可以提供各种开箱即用的目标架构的 LLVM?我也可能在这里遗漏了一些东西,但为什么需要这样做?如果您需要编写汇编以充分利用 CPU,您不应该直接使用目标 CPU 汇编语言吗? Go 的创造者应该受到很多尊重,但看起来 Go 的设计发生在平行宇宙(或他们的 Plan9 实验室?),在那里,90 年代和 2000 年代编译器和编程语言设计中发生的大部分事情从未发生过。或者 Go 是由能够编写编译器的系统程序员设计的。

函数式编程?没有提到它。泛型?你不需要它们,看看它们在 C++ 中产生的混乱!即使切片、贴图和通道是通用类型,我们将在下面看到。 Go 的目标是取代 C 和 C++,很明显,它的创建者并没有在其他地方寻找太多。但是他们没有达到目标,因为 Google 的 C 和 C++ 开发人员没有采用它。我的猜测是主要原因是垃圾收集器。低级 C 开发人员强烈拒绝托管内存,因为他们无法控制发生的事情和时间。他们喜欢这种控制,即使它带来了额外的复杂性并为内存泄漏和缓冲区溢出打开了大门。有趣的是,Rust 采取了一种完全不同的方法,在没有 GC 的情况下进行自动内存管理。 Go 反而在操作工具领域吸引了 Python 和 Ruby 等脚本语言的用户。他们在 Go 中发现了一种具有出色性能并减少内存/cpu/磁盘占用的方法。还有更多的静态类型,这对他们来说是新的。 Go 的杀手级应用是 Docker,这引发了它在 DevOps 领域的广泛采用。 Kubernetes 的兴起加强了这一趋势。 Go 接口类似于 Java 接口或 Scala & Rust 特征:它们定义了稍后由类型实现的行为(我不会在此处称其为“类”)。不过,与 Java 接口和 Scala & Rust 特征不同,类型不需要明确指定它实现了一个接口:它只需要实现接口中定义的所有函数。所以 Go 接口实际上是结构类型。我们可能认为这是为了允许其他包中的接口实现而不是它们适用的类型,例如存在于 Scala 或 Kotlin 中的类扩展,或 Rust 特征,但事实并非如此:与类型相关的所有方法都必须是在类型的包中定义。 Go 不是唯一使用结构类型的语言,但我发现它有几个缺点:

找到实现给定接口的类型很困难,因为它依赖于函数定义匹配。我经常通过搜索实现接口的类来发现 Java 或 Scala 中有趣的实现。在向接口添加方法时,您会发现只有在用作该接口类型的值时才需要更新哪些类型。这可能会在很长一段时间内被忽视。 Go 建议使用极少方法的小接口,这是一种防止这种情况的方法。一个类型可能在不知不觉中实现了一个接口,因为它有相应的方法。但偶然的是,实现的语义可能与接口契约的预期不同。在 Go 1.13 版本之后添加。这似乎没什么大不了的,但请继续阅读。 Go 1.13 引入了方法链,为错误添加了一个新的 Unwrap 方法。由于 Go 接口不支持其方法的默认实现,因此向现有接口添加方法会破坏大量现有代码。所以这个新方法是一个“约定”而不是错误接口的一部分。正因为如此,我们不能只调用 err.Unwrap() 来获取包装的错误。我们必须使用单独的函数 errors.Unwrap(err) ,它使用动态类型测试来检查 Unwrap 是否存在于其参数上。再见编译时检查,你好,本来可以是一个简单的方法调用的繁琐语法! Java 在 JDK8 中引入了 lambda 表达式时遇到了类似的问题,并添加了默认方法实现支持以允许接口以向后兼容的方式发展。有 iota 可以快速生成自动递增的值,但它看起来更像是一个 hack,而不是一个功能。实际上,这是一个危险的问题,因为在一系列 iota 生成的常量中插入一行会改变以下常量的值。由于生成的值是在整个代码中使用的值,这可能会导致有趣的(不是!)惊喜。

这也意味着 Go 无法让编译器检查 switch 语句是否详尽,也无法描述类型中允许的值。 Go 提供了两种方法来声明变量并为其赋值:var x = "foo" 和 x := "foo"。这是为什么?主要区别在于 var 允许在没有初始化的情况下声明(然后您必须声明类型),就像在 var x string 中一样,而 := 需要赋值并允许混合现有变量和新变量。我的猜测是 := 的发明是为了减少错误处理的痛苦: var x , err1 = SomeFunction () if ( err1 != nil ) { return nil } var y , err2 = SomeOtherFunction () if ( err2 != nil ) { return nil } x , err := SomeFunction () if ( err != nil ) { return nil } y , err := SomeOtherFunction () if ( err != nil ) { return nil } := 语法也很容易允许不小心遮蔽了一个变量。我不止一次被这个抓住了,因为 := (declare and assign) 太接近 = (assign),如下所示: foo := "bar" if someCondition { foo := "baz" doSomething ( foo ) } // foo == "bar" 即使 "someCondition" 为真

Go 没有构造函数。因此,它坚持“零值”应该易于使用的事实。这是一种有趣的方法,但在我看来,它带来的简化主要是针对语言实现者的。在实践中,许多类型没有适当的初始化就不能做有用的事情。我们来看一个在 Effective Go 中作为例子的 io.File 对象: type File struct { *file // os specific}func (f *File) Name() string { return f.name}func (f *File ) Read(b []byte) (n int, err error) { if err := f.checkValid("read"); err != nil { return 0, err } n, e := f.read(b) return n, f.wrapErr("read", e)}func (f *File) checkValid(op string) error { if f == nil { return ErrInvalid } return nil} Read 函数,以及几乎所有其他 File 方法,首先检查文件是否已初始化。所以基本上一个零值的文件不仅没有用,而且会导致恐慌。您必须使用其中一种构造函数,例如 Open 或 Create。检查正确的初始化是您在每次函数调用时都必须支付的开销。标准库中有无数这样的类型,有些甚至不尝试用它们的零值做一些有用的事情。在零值的 html.Template 上调用任何方法:它们都惊慌失措。还有一个严重的问题是 map 的值为零:你可以查询它,但在其中存储一些东西会导致恐慌:

var m1 = map [ string ] string {} // 空映射 var m0 map [ string ] string // 零映射 (nil) println ( len ( m1 )) // 输出 '0' println ( len ( m0 )) //输出 '0' println ( m1 [ "foo" ]) // 输出 '' println ( m0 [ "foo" ]) // 输出 '' m1 [ "foo" ] = "bar" // ok m0 [ "foo" ] = "bar" // 恐慌!这要求您在结构具有映射字段时要小心,因为必须在向其添加条目之前对其进行初始化。因此,作为开发人员,您必须不断检查要使用的结构是否需要调用构造函数,或者零值是否有用。由于语言的一些简化,这给代码编写者带来了很大的负担。博客文章“Why Go got exceptions right”详细解释了为什么异常是糟糕的,以及为什么 Go 方法要求返回错误更好。我同意这一点,当使用异步编程或像 Java 流这样的函数式风格时,异常很难处理(让我们抛开前者在 Go 中不是必需的,多亏了 goroutines,而后者根本不可能)。博客文章提到恐慌是“对你的程序来说总是致命的,游戏结束”,这很好。现在“延迟、恐慌和恢复”在它之前,解释了如何从恐慌中恢复(通过实际捕获它们),并说“有关恐慌和恢复的真实示例,请参阅 Go 标准库中的 json 包”。事实上,json 解码器有一个通用的错误处理函数,它只是恐慌,在顶级解组函数中恢复恐慌,该函数检查恐慌类型,如果是“本地恐慌”或重新恐慌,则将其作为错误返回否则会出错(在途中丢失原始恐慌的堆栈跟踪)。对于任何 Java 开发人员来说,这绝对看起来像一个 try / catch(DecodingException ex)。所以 Go 确实有例外,在内部使用它们但告诉你不要。

有趣的事实:几周前,一个非谷歌用户修复了 json 解码器,以使用冒泡的常规错误。更新:自从写这篇文章以来,Go 1.11 引入了基于语义版本控制的模块支持。这解决了下面解释的大部分问题,尽管选择最低版本的依赖项解析是有争议的(见......