这是一篇实践者对 Go 语言的微吐槽

阅读数:2 2020 年 4 月 24 日 17:17

这是一篇实践者对Go语言的微吐槽

本文作者最近开始在工作中将 Go 作为主力编程语言来使用,这是一种有趣的语言,带有丰富的标准库,但在标准库中交付一个生产就绪的 HTTP 服务器并非易事。因此,作者写下了这篇文章,提到了 Go 语言的一些问题。

本文最初发布于 sbstp 博客,经原作者授权由 InfoQ 中文站翻译并分享,未经许可禁止一切形式的转载

在这篇文章中,我将讨论在使用 Go 语言的过程中遇到的一些问题和怪癖。我会有意略过那些经常被提到的问题,例如缺少泛型和 err != nil 错误处理模式等,因为关于它们的讨论已经够多了,并且 Go 团队准备在 Go 2 中解决它们。

问题目录

  • 零初始化
  • 过度 linting
  • 返回错误
  • nil 切片和 JSON
  • Go 模块和 Gitlab
  • 日期格式 API
  • 非类型化常量
  • 总结

零初始化

Go 允许变量和 struct 字段不使用一个值也能显式初始化。在这种情况下,它将为变量或字段赋予一个零值,我认为这可能成为错误和意外行为的潜在源头。

我第一次遇到这方面的问题,是一个微服务开始用尽文件描述符,并因此出现虚假错误的时候。以下是导致问题出现的代码:

复制代码
client := &http.Client {
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}

乍一看代码没什么问题,但实际上它包含一个导致 TCP 套接字泄漏的错误。这里发生的事情是,我们创建了一个新的 http.Client,其中所有传输超时都未指定。由于它们未被指定,因此 Go 会将它们初始化为零值,这就是问题所在。

复制代码
// IdleConnTimeout is the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing
// itself.
// Zero means no limit.
IdleConnTimeout time.Duration // Go 1.7

在上面的 http.Transport 文档中,可以看到零值表示超时是无限的,因此连接永远不会关闭。

随着时间的流逝,套接字不断积累,最终导致文件描述符用尽。这一过程所需的时间取决于你的服务获得多少活动,以及文件描述符的 ulimit 设置。

解决这个问题的方法很简单:初始化 http.Transport 时提供非零超时。Stack Overflow 网站上有这个问题的答案,演示了如何从 http 库复制默认值。

但这仍然是一个容易掉进去的陷阱,据我所知目前没有 lint 可以帮助解决这类问题。

这还会带来其他副作用。例如,未导出字段将始终被初始化为零值,因为字段无法从包外部进行初始化。

下面是一个示例包:

复制代码
package utils
type Collection struct {
items map[string]string
}
func (c *Collection) Set(key, val string) {
c.items[key] = val
}

下面是这个包的用法示例:

复制代码
package main
func main() {
col := utils.Collection{}
col.Set("name", "val") // panic: assignment to nil map
}

解决这个问题的方法没那么优雅。这是防御性编程,在访问映射前,包作者必须检查它是否已经被初始化:

复制代码
func (c *Collection) Set(key, val string) {
if c.items == nil {
c.items = make(map[string]string)
}
c.items[key] = val
}

如果 struct 具有多个字段,代码很快会变得臃肿。一种解决方案是为类型提供构造函数,例如 utils.NewCollection(),其会始终初始化字段,即便有了这个构造函数,也无法阻止用户使用 utils.Collections{}初始化其结构,结果就是带来一堆问题。

过度 linting

我认为编译器对未使用的变量过于严格。我经常遇到的麻烦是,注释了一个函数调用后还得在调用上方修改多行代码。

我有一个 API 客户端,可以在其上发送请求和接收响应,如下:

复制代码
client, err := NewClient()
if err != nil {
return err
}
defer client.Close()
resp, err := client.GetSomething()
if err != nil {
return err
}
process(resp)

假如我想调试代码,并注释掉对 process 函数的调用:

复制代码
client, err := NewClient()
if err != nil {
return err
}
defer client.Close()
resp, err := client.GetSomething()
if err != nil {
return err
}
//process(resp)

现在,编译器会出现:resp declared and not used(resp 已声明但未使用)。好的,我使用 _ 代替 resp:

复制代码
client, err := NewClient()
if err != nil {
return err
}
defer client.Close()
_, err := client.GetSomething()
// process(resp)

现在编译器将提示:no new variables on left side of :=(:= 左侧没有新变量)。!之前已声明了 err,我将使用 = 代替:=

复制代码
client, err := NewClient()
if err != nil {
return err
}
defer client.Close()
_, err = client.GetSomething()
// process(resp)

终于通过编译,但是为了注释掉一行代码还得更改代码两次才行。我经常要做更多的编辑工作才能让程序通过编译。

我希望编译器有一种开发模式,其中未使用的变量只会给出警告,而不会阻止编译,这样编辑 - 编译 - 调试的周期不会像现在这样麻烦。

返回错误

在 Go 语言社区中有很多关于错误管理的讨论。我个人不介意 if err != nil { return err }这种模式。它可以再做改进,并且有人已经在 Go 2 中提出了对其改进的提案。

最让我感到困扰的是元组样式返回。当一个函数可能产生错误时,你仍然必须在发生错误时提供有效伪值。比如,函数返回 (int, error),那么必须 return 0, err,也就是说就算一切正常,也还是要为返回的 int 提供一个值。

我觉得这从根本上就是错的。首先,当出现错误时,我用不着找出一些伪值也应该能返回才是。这导致了指针的过度使用,因为 return nil, err 比返回具有零值的空结构,和诸如 return User{}, err 之类的错误要容易得多,也更干净。

其次,提供有效伪值后,我们很容易假设伪值就是正确的,然后在调用侧略过错误而继续下去。

复制代码
// The fact that err is declared and used here makes it so
// there's no warnings about it being unused below.
err := hello()
if err != nil {
return err
}
x, err := strconv.ParseInt("not a number", 10, 32)
// Forget to check err, no warning
doSomething(x)

相比起简单返回 nil 来说,这种错误更难找到。因为如果我们返回了 nil,我们应该会在后面代码行的某处出现 nil 指针 panic。

我认为支持求和类型的语言(例如 Rust、Haskell 或 OCaml)可以更优雅地解决这个问题。发生错误时,它们无需为非错误返回值提供一个值。

复制代码
enum Result<T, E> {
Ok(T),
Err(E),
}

结果要么是 Ok(T),要么是 Err(E),而不会两者都是。

复制代码
fn connect(port u32) -> Result<Socket, Error> {
if port > 65536 {
// note that I don't have to provide a value for Socket
return Err(Error::InvalidPort);
}
// ...
}

nil 切片和 JSON

在 Go 中创建切片的推荐方法是使用 var 声明,例如 var vals []int。这个语句会创建一个 nil 切片,这意味着没有数组支持此切片:它只是一个 nil 指针。append 函数支持附加到一个 nil 切片,这就是为什么可以使用模式 vals = append(vals, x) 的原因所在。len 函数也支持 nil 切片,当切片为 nil 时返回 0。在实践中,大多数情况下这用起来挺不错的,但它也会导致奇怪的行为。

例如,假设正在构建一个 JSON API。我们从一个数据库查询事务并将它们转换为对象,以便可以将它们序列化为 JSON。服务层如下所示:

复制代码
package models
import "sql"
type Customer struct {
Name string `json:"name"`
Email string `json:"email"`
}
func GetCustomers(db *sql.DB) ([]*Customer, error) {
rows, err := db.Query("SELECT name, email FROM customers")
if err != nil {
return nil, err
}
var customers []*Customer
for _, row := range rows {
customers = append(customers, &User {
Name: row[0].(string)
Email: row[1].(string)
})
}
return customers, nil
}

这是相当简单的,使用这个服务的 HTTP 控制器如下所示:

复制代码
package controllers
import "http"
import "encoding/json"
import "github.com/me/myapp/models"
func GetCustomers(req *http.Request, resp http.ResponseWriter) {
...
customers, err := models.GetCustomers(db)
if err != nil {
...
}
resp.WriteHeader(200)
if err := json.NewEncoder(resp).Encode(customers); err != nil {
...
}
}

这些都是基础,但这里实际上有问题,它可能会在这个 API 的消费者中触发错误。当数据库中没有客户时,SQL 查询将不返回任何行。因此,附加到 customers 切片的循环将永远不会处理任何项目。于是,custormers 切片将作为 nil 返回。

当 JSON 编码器看到一个 nil 切片时,它将对响应写入 null,而不是写入 [],可是没有结果的情况下本来应该写入的是后者,这势必会给 API 消费者带来一些问题,因为在没有项目的情况下它们本来预期的是一个空列表。

解决方案很简单,要么使用一个切片字面量 customers := []*Customer{},要么使用 customers := make([]*Customer, 0) 这样的调用。请注意,某些 Go linters 会警告你不要使用空切片字面量,并建议使用 var customers []*Customer 来代替,但后者的语义是不一样的。

在其他地方也可能出现麻烦。对于 len 函数,一个空映射和一个 nil 映射是相同的。他们有 0 个元素。但是对于其他函数,例如 reflect.DeepEqual 来说,这些映射并不相同。我认为考虑到 len 的行为方式,如果一个函数会检查这两个映射是否相同,那么可以预期检查的结果是一样的。但是 reflect.DeepEqual 表示不同意,这可能因为它使用了反射来对比两个对象,这种比法不是很好的办法,但却是 Go 目前唯一可用的选项。

Go 模块和 Gitlab

一开始,依靠 Git 存储库下载模块可能是一个好主意,但是一旦出现更复杂的用例,Go 模块就会彻底瓦解。我的团队在搭配使用 Go 模块和私有 Gitlab 实例时遇到了很多问题。其中有两大问题最为突出。

第一个问题是 Gitlab 允许用户拥有递归项目组。例如,你可以在 gitlab.whatever.com/group/tools/tool-1 上拥有一个 git 存储库。Go 模块并没有对此提供开箱即用的支持。Go 模块将尝试下载 gitlab.whatever.com/group/tools.git,因为它假定该网站使用类似于 GitHub 的模式,也就是说里面只有两个级别的嵌套。我们必须在 go.mod 文件中使用一个 replace 来将 Go 模块指向正确的位置。

还有一种解决问题的方法是使用 HTML标签,让它指向正确的 git 存储库,但这需要 Git 平台来支持它。要求 Git 平台为 Go 模块添加这种特殊用例的支持并不是一个好的设计决策。它不仅需要在 Git 平台中进行上游更改,而且还需要将已部署的软件升级到最新版本,后者在企业部署流程中并不会一直那么迅速。

第二个问题是,由于我们的 Gitlab 实例是私有的,并且 Go 尝试通过 https 下载 git 存储库,因此当我们尝试下载未经任何身份验证的 Go 模块时,会收到 401 错误。使用我们的 Gitlab 密码进行身份验证是不切实际的选择,尤其是在涉及 CI/CD 的情况下。我们找到的解决方案是在使用这个.gitconfig 发出 https 请求时,强制 git 使用 ssh。

复制代码
[url "git@gitlab.whatever.com:"]
insteadOf = https://gitlab.whatever.com

这个方案在实践中效果很好,但是在初次遇到这个问题时要修复它没那么容易。它还假定 SSH 公钥已在 Gitlab 中注册,并且私钥未使用密码加密。如果你在 GNOME Keyring 或 KDE Wallet 之类的 keyring 代理中注册了密码,并且 git 集成了它,那么倒可能使用一个加密的私钥,但是我没有尝试过这种办法,所以也不知道是否真的可行。

日期格式 API

Go 的日期格式实在让人摸不着头脑。Go 没有使用常用的 strftime %Y-%m-%d 格式或 yyyy-mm-dd 格式,而是使用了占位符数字和具有特殊含义的单词。如果要在 Go 中使用 yyyy-mm-dd 格式设置日期,则必须使用“2006-01-02”格式的字符串。2006 是年份的占位符,01 是月份的占位符,而 02 是日期的占位符。Jan 一词代表月份,各个月份以三个字母的缩写表示,如 Jan、Feb、Mar……以此类推。

我觉得这毫无必要。不查看文档是很难记住它的,实在太乱了,并且没有充分理由就抛弃了已经有半个世纪历史的 strftime 标准格式。

我还发现 time 包的官方文档在解释这部分内容时一团糟。它基本没讲明白工作机制,结果是你必须去找那些以清晰易懂的方式解释清楚这个问题的第三方资源才行。

非类型化的常量

看下这段代码:

复制代码
sess, err := mongo.Connect("mongodb://...")
if err != nil {
return err
}
defer mongo.Disconnect(sess)
ctx, cancel := context.WithTimeout(context.Background(), 15)
defer cancel()
if err := sess.Ping(ctx, nil) {
return err
}

看起来人畜无害。我们连接到 MongoDB 数据库,在函数退出时 defer 断开连接,然后创建一个具有 15 秒超时的上下文,并使用此上下文运行一个 ping 命令,对数据库运行状况检查。这应该能顺利运行,但可惜不行,每次运行都会返回一个 context deadline exceeded 错误。

因为我们创建的上下文没有 15 秒的超时,它的超时时间是 15 纳秒。这叫超时吗?这是瞬间失败。

context.WithTimeout 函数接受一个 context.Context 和一个 time.Duration。time.Duration 是一个新类型,定义为 type Duration int64。由于 Go 的非类型化常量的缘故,我们能够将一个 int 传递给这个函数。也就是说,在常量被赋予类型之前是没有类型的。因此,15 不是一个整数字面量或整数常数。当我们将其作为 time.Duration 传递时,它将被类型化为 time.Duration。

所有这一切意味着,没有类型错误或 lint 告诉我们,我们没有给这个函数一个适当的 time.Duration。正常来说你要把这个函数 time.Second * x 传递给 timeout,单位是 x 秒。time.Second 的类型是 time.Duration,它与 x 相乘后会进行类型化,让这里的类型保持安全。但现在并不是这回事,一个没有类型的常量与真实的 time.Duration 一样有效,于是就搞出来上面那摊子麻烦。

总结

Go 是一种有趣且非常有用的语言。简洁是它的宗旨,而且它在大部分时候都做到了这一点。但是,简单性不应该高于正确性。如果你选择简单性而不是正确性,那么到头来你会偷工减料,并交付有问题的解决方案。

我认为 Go 模块与 Gitlab 的交互就是一个很好的例子。Go 决定采用一种“简单”的解决方案,不像其他那些语言那样做一个包存储中心,而是从 git 服务器中获取内容。结果不仅在对 git 服务器进行身份验证时会出现严重错误。当 git 服务器的命名 / 分组约定与 GitHub 不同时,它也会出错。最后,你浪费了一整天的时间来研究 stackoverflow,试图解决这个“简单”的软件包系统的问题。

我一直在关注 Go 2 的提案,并且很高兴看到 Go 团队在这方面投入了很大努力。他们正在收集很多社区反馈,这是很好的做法。用户经常会提供非常有趣的反馈。Go 2 是修复本文中提到的某些问题的绝好机会,或者至少可以允许用户创建自己的数据结构和类型集,从而解决其中的一些问题。

我可以断定,当 Go 2 到来时,我将编写大量实用程序和数据结构来让程序更安全,更加人性化。

原文链接: https://blog.sbstp.ca/go-quirks/

评论

发布