本文作者最近开始在工作中将Go作为主力编程语言来使用,这是一种有趣的语言,带有丰富的标准库,但在标准库中交付一个生产就绪的HTTP服务器并非易事。因此,作者写下了这篇文章,提到了Go语言的一些问题。
本文最初发布于sbstp博客,经原作者授权由InfoQ中文站翻译并分享,未经许可禁止一切形式的转载
在这篇文章中,我将讨论在使用Go语言的过程中遇到的一些问题和怪癖。我会有意略过那些经常被提到的问题,例如缺少泛型和err != nil错误处理模式等,因为关于它们的讨论已经够多了,并且Go团队准备在Go 2中解决它们。
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{}初始化其结构,结果就是带来一堆问题。
我认为编译器对未使用的变量过于严格。我经常遇到的麻烦是,注释了一个函数调用后还得在调用上方修改多行代码。
我有一个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);
}
// ...
}
在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目前唯一可用的选项。
一开始,依靠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集成了它,那么倒可能使用一个加密的私钥,但是我没有尝试过这种办法,所以也不知道是否真的可行。
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到来时,我将编写大量实用程序和数据结构来让程序更安全,更加人性化。
领取专属 10元无门槛券
私享最新 技术干货