在2019年08月17日举办的 Gopher Meetup(深圳站)活动上,来自 Bindo Labs 公司的李雄飞进行了 《Go 编程陷阱》的演讲。李雄飞,Bindo Labs 后端技术负责人,全栈工程师。从事POS/支付业务架构以及通用Web系统建设工作,主要关注New SQL/ETL/Kubernetes等领域技术发展。以下为演讲实录。
前言
我大概是从 2015 年开始写下第一行 Go 代码,今天主要给大家分享我这几年以来所积累的一些让我非常难受地方以及感到崩溃的一些 BUG,我希望我的这些痛苦可以让大家快乐,当然我也希望我积累的这些经验以及案例可以拯救大家一些睡眠的时间,让我们有更多的时间去浪,去玩耍。为此,我们今天有一个副标题 ——— 为了早点下班。
1. Nil != Nil
我们先来讲我们第一个问题 Nil 不等于 Nil,这话听上去好像 1 不等于 1 一样, 骗小孩子的病句。其实在 Go 当中有的时候确实会发生这种问题,当我们认为我们的变量是 nil 的时候,甚至于我们很信誓旦旦说它是 nil 的时候,它其实并不是。
我为什么会第一个讲这个问题呢?因为这是让我最最刻骨铭心的问题,到现在我还仍能深深地记起那个下着雨的夜晚,那是一个情人节,月亮忽隐忽现,天空飘着茫茫细雨,我只能坐在办公室改 Bug,非常地绝望与愤怒。
为了表达我当时的愤怒,我做了一个特效来表达一下。
自定义错误类型
我们接下来看一下到底是什么样的问题会让我当时那么愤怒,即便到现在还是那么愤怒。
我们定义了自定义的错误类型,handle函数中判断了参数x的值是否等于1,如果不等于1就返回一个自定义的Error指针类型。否则就返回一个nil。在main函数中,我们分别用参数 0 和 1 调用了两次,然后判断了error是不是nil,不是nil 就打印一句话。好像就是一个很寻常的go函数。
那么我们的问题来了,它的输出是什么?
我们大家一起来稍微分析一下这个代码,先看第 24 行调用,这个很明显结果不是 nil, 所以我们第26行的打印应该是会成功的打印。我们再来看一下第28行的调用,我们用参数 1 来调用,1 等于 1 ,所以说它会return nil。应该不会去打印的。我觉得应该是这样,我觉得大家可能也觉得是这样。
给大家 10 秒钟时间大家可以判断一下到底是不是这样。
我们现在来看一下真正的表现是什么?
我们来看一下最终的输出。呃呃呃呃呃呃呃,好像和想的不太一样,什么鬼,一定是golang的bug。。我们看到第二个也已经打印了,并且我们注意一下这一部分,它打印的类型确实是 nil,但是 nil 为什么又不等于 nil 呢?
这是不是一个问题?明明是 nil,那这肯定是个 Bug,其实我也是这么想的,我还记得是凌晨两点钟左右,当时非常高兴,好像挖到一个大宝藏一样,我在github还提了一个issue
<https://github.com/golang/go/issues/16160>
我说你们这个有 Bug,赶紧修,然后我就被分分钟打脸。
接口准则
我们来看一下别人怎么来打我的脸?
第一点,其实Go在实现接口的时候,保存了两个东西,一个是这一个接口背后的类型 T,一个是这个类型背后的值 V,因此当我们在讨论接口这个东西的时候,实际上我们永远是在讨论类型以及类型的值。
第二点,只有类型与值同时是 nil 的时候这个接口才会是 nil,这点就是我们问题的来源。
第三点,我们都知道接口是隐式实现的,当我们用一个接口类型去接收一个 nil 结构体的时候,这个结果将不是 nil,因为此时的接口值是有类型(T)的, 只是它的值(V)是 nil,所以说我们刚才的 if 判断应该是成立的。
方案一
我们基于这个认识来看一下我们应该怎么来修这个问题,就很简单的。第一个修复的办法是,我们不再用 interface,我们直接用 struct,因为这个时候从头到尾都没有出现interface 的事情,自然不会出现与interface有关的问题。
方案二
我们还会有第二种方案,我们看一下,我不返回这一个 struct,我返回一个 error,这样再打印第 30 行的 err 就真的是 bug 了????。
2. 危险的 HTTP 请求
我们接下来看一下第二个问题 - 危险的 HTTP 请求。调用其他平台的HTTP API或者调用自己的内部服务是一件非常平常的事情,但是在Golang中,如果不仔细处理,则仍然容易造成一些泪流满面的事故发生。
简单的 Get 请求示例
我们通常会写这样的代码来请求第三方解耦。发起一个GET请求,然后读取返回的数据,进行下一步处理。这几乎是一个教科书式的代码。
当你的系统没什么人用的时候,这段代码会工作的非常良好,但是一旦有大量的流量,则会造成一个很严重的问题。
既然我们拿出来讲,肯定不是这么简单的事情,我们先来看一下 当我们在讨论HTTP请求的时候到底在讨论什么。
http.Client 请求细节分析
我们先来看一下在Go里面进行HTTP请求到底是怎么一回事。
上面是非常简化之后的HTTP调用流程图,左边是我们的用户级代码,右边是HTTP包内部的流程。httpClient.Get() 只是httpClient.Do()的一个语法而已,在Do方法中会做一系列的检查与判断,然后就会进入到 roundtrip 的流程。roundtrip流程负责了基础数据交互的逻辑,roundtrip首先会调用getConn方法获取一个原始连接(可能是拿出已经创建好的,也可能是全新建立一个),之后将开启两个协程,一个用来读取数据,一个用来发送数据。在读取的协程里面,会等待一个信号,阻塞等待协程变量信号的到来,当这个信号到达之后,它会尝试把连接又还回到连接池,结束整个 HTTP 的生命周期。也就是说当我们这个信号一直没到的时候,我们这个协程其实一直是卡住的,它没有被释放,虽然讲我们可以很轻松放着这个东西不管了,因为读完之后你可以愉快干其他事情,但是它还孤零零卡在这里。
这里面有个很重大的问题,这个信号什么时候到来?我们来看一下马赛克后面的内容,其实就是我们在调用这一个 Response.Body.Close 的时候,它就会发出WaitForBodyRead的信号,这也是我们在网上看到很多的文章都会提醒你做一个HTTP请求,不要忘记关闭 Body,这就是问题的本质,其实关闭 Body 这件事情没有做很多的事情,比较有意义的就是利用 Body这个信号需要去完成整个的生命周期。
HTTP 连接泄露解决方案
我们修复方法就很简单,我们只需要把这个请求关闭,把这个连接还回去,这样处理之后,我们这个代码就是一个真正的教科书级的代码。
网络编程准则
我们用 Golang 做网络编程的时候,其实会有一个很有意思的地方,它跟其他语言是不太一样的,就是我们大部分的组件背后会依赖于连接池的技术,比如说我们常用的MQ/Redis/MySQL 等,背后往往都是用的连接池的技术。
所以我们除了要学习基本的连接池相关的知识,比如说为什么需要连接池,以及连接池能够解决什么问题,我们还要去稍微深入地去学习一下,我们用的这些库的连接池设计我应该怎么样才能够正确管理好的我的TCP 连接,这是我们作为一个业务层是要掌握的一些网络方面的知识,我们一定要知道我们在用一个网络库的时候它背后的 TCP 的过程到底是怎么样,它会让我们提前去避免问题。因为这些问题往往是很难被提前发现的。
3. 可怕的协程
接下来再来看下一个问题,可怕的协程,其实协程是在整个 Golang 里面,可以这么来讲,Golang 其实就是为了协程而生的,因为 Golang 里面的 Go,就是够浪。
简易的 BeeGo 计数器
我们先来看一个有关协程小小的 demo,我们用Beego框架开发了一个很简单的 API,没做什么很有意义的事情,只是往map里面写入了一个数据,这个问题应该是很明显的,我们大家应该能够一目了然,很容易知道,因为之前的讲师也有提到过,Go 里面会有并发的读写问题,这里面就发生了并发的读写问题,我们也知道怎么去解决,只要给我们第 16行里面加一把锁这个问题就解决了,这个问题是一个很明显的问题,没有什么特别要提的。
跨协程资源共享
比较有意思的是下面这个问题,我们同时开启了两个协程,在每个协程里面分别循环了 100 次,然后做了一个累加,利用Sleep来模拟了一些网络操作时间,现在我们的问题是最后累加出来的值是多少,这里我可以给大家一分钟时间讨论一下,和身边的小伙伴一起讨论一下。
我们来看一下,它的值是多少呢?第一个是 100 吗?第二个是 200 吗?第三个是随机,只有天知道,我们不知道的。还有一个是以上答案都不对。现在我们来投票,A、B、C、D。
这个例子里面我们做数据读写的时候很聪明的加了锁,所以不会有并发写的问题。这个例子主要的点是这个Sleep,这个 Sleep 的时间是经过精妙设计的,它会精妙设计出具体的一个结果。
我觉得大家应该是猜不到的。最终我们算出的结果是 150,或者说它几乎是 150,因为我只测出 150,可能以上某种情况下面会有其他的一些值,但我测出来就是 150。至于说为什么是 150,我觉得是一个很有意思的问题,我不回答,我们在群里面在讨论。<编注:这个例子暴露出来的就是对原子性的理解不足>
跨协程资源共享 - 粗暴地修复 bug
我们来看一下怎么来修这个 Bug,看代码好像没有怎么变动,只是把 lock 这一个函数移过来,提前了一点去进行Lock动作,我整个代码逻辑就只变动了 lock 这一行,我们可以去对比一下,我们把 23 行的 lock 和 32 行的 lock 往前面提了。
协程使用准则
我们可以很简单地使用协程,但是Go 的协程其实远远不是这么简单。它有上面种种这些问题,这些问题跟协程有没有关系?有关系,也没有关系,因为它都是属于并发编程的问题。
这是我个人的感受。Golang提供了很方便的并发,但是它没有提供安全的并发,我们仍然需要非常小心地去处理这些资源竞争,以及要非常小心地知道我的资源什么时候开始去锁,什么时候开始解锁。
所以我们现在团队内部有这么一个共识,我们会小心在代码里面避免这种情况发生,避免在多协程里面去共享同一个对象,去给它同时做读写的情况。因为他们通常是我们整个系统疑难 Bug 的源泉,所以我们对于这种情况的应用就是尽量避免,除非你很自信。
4. 无处不在的 JSON
下面我们来看下一个话题,JSON。现在来讲 ,JSON 已经成为了HTTP数据交互的事实上的标准,基本上每个人可能从工作第一天开始就会接触到它。JSON 的问题是什么呢?
使用 Struct 接收 JSON
我们来看一下在 Golang 中怎么使用 JSON。
这个代码描述了我们怎么去用 struct 去接受一个JSON,我们定义了一个 struct,模拟了一个返回,把它们可以反序列化。我们看一下它的打印,跟我们的预期应该是一样。
使用 Map 接收 JSON
我们再来看一下,当我们用 map 来接收的时候好像不太一样了,为什么所有这种数字的类型已经变成了一个 float64,而不是一个 int。
这个问题本身来讲没有很奇怪的,它其实就是一个规则而已,但是它引发了一个很有意思的思考,我们来想一下我们在Golang 当中用哪一个基础类型来处理 JSON 当中的数字类型是最靠谱的、最合适的?
JSON 只有数字类型,没有 int ,long 这些东西,任何的正数、整数、负数都可以支持。那么我们在Go用什么来支持是最合适的呢?其实就是 Float64,我们抛开上下边界的问题,其实 Float64 是其他所有Golang 数字类型的一个并集。
类型映射规则
我们这里可以看到在 JSON 中处理这种类型映射关系的时候,当你没有去指明一个类型的时候,它大概会以这样的方式去给你做一个假设。比如说Object,就会用一个map[string]interface{} 来作为接收。这个映射关系是非常有意思的,在我们现在的业务系统开发当中,公司与公司之间的合作,不同编程语言之间的协作变得越来越多,如何去做不同系统之间的映射,技术的映射、类型的映射,甚至一些功能逻辑上的映射,是我们整个系统集成工作里面是一件最最重要的事情。<编注:这个问题的目的是抛出系统集成的话题,希望大家做系统集成工作的时候要非常在意兼容性>
5. 骄傲的指针
我们再来看今天的最后一个问题,骄傲的指针。指针其实我觉得是挺可怕的,它会让人非常不爽,会有很多意外的发生。
循环变量指针
我们定义了一个函数printNumber,接收一个int类型的指针形参,在函数内部仅仅是打印了一下数字。我现在问题是最后输出的是什么?
我们来看,最后输出的全部是 3。如果大家写过 C 代码可能对这种情况比较熟悉。我们都知道在 Golang 里面是可以去打印一个指针具体内存地址。我们来看一下这些值内存的类型到底是怎么样?可以发现它打印出来的内存地址都一样,所以说它的值一样,是因为它的指针,其实它是同一个指针,它同一个对象,所以说同一个对象肯定只能有一个值。
上面这个例子和range版本是等价的。这种写法让人一目了然就知道,我们定义了一个 变量V,先把这个V 去分别赋值,然后再循环,得出的结论跟我们上面是一模一样的。
为什么会一样呢?其实这只是一个设计而已。在 Golang 语言规范当中就指出了range 子句中的变量只会定义一次,就是我们刚才上面第 15 行,然后它会被重复使用,我们循环里面去给它们不停地赋值。第二点,当我们异步应用协程的方式去使用 range 变量这个指针的时候,其实它的结果是非预期的,不过这种情况一般是最后一次迭代的值。
上面这些问题是我这几年所积累的一些有关 Golang 本身特性引发的一些让人崩溃的瞬间。
学 Go 到底在学什么
接下来提一下我们学习 Golang,到底在学习什么样的东西?
第一, Golang 是一个最容易上手的编译型语言,它不像新型的Rust这样的语言有高级的特性,它没有。
第二,它其实带有 GC 和 Runtime 的语言。
第三,最重要的一点,它是语言级别支持协程。这是很重要的一个点,协程不是一个复杂的技术,很多年前就已经被提出来了。Golang 的伟大之处在于语言级别支持,官方的库都能支持协程的特性。因为语言支持协程,所以它是一个从基因当中用来适合做高性能后台开发的编程语言。很多人都说 Golang 是一个系统编程语言,很多人以为这个系统是操作系统,其实不是,这个系统指的是Web后台系统,因为里面所有的类似的包就会发现几乎大量的跟网络和数据交互有关的一些库,所以它从头到尾没有说自己是用来开发操作系统的。
有的时候在群里就会看,你刚刚说这是一个操作系统级别的语言,为什么没见你开发操作系统呢?这叫抬杠。
送给纯动态语言背景的几句话
作为一个纯动态语言背景的开发者,老是会害怕这些个变量啊,类型啊。其实我想跟大家说的是这些都不是高级的玩意,恰恰是低级的,它是我们对编程语言技术的一个妥协,因为我们要给编译器更多的信息量,它才可以帮我们自动做更多工作。然后我们这个时候因为 Golang 里面,脚本语言是托管语言,它是在另外一个程序上工作,所而 Golang 是直接运行在操作系统上面,所以一些操作系统的调度、网络编程支持在这个时候就会变得有效起来,可能我在写动态语言的时候好像没什么意义,但是我们一旦写这种语言深一点的就会变得有意义。
如果你之前没有更多的本地语言经验,并且有意愿去尝试类似Golang这种本地语言的时候,会有一些注意事项:
每一个静态语言都会有大量的类型,但是请不要害怕这些东西,本质上来说它们不是高级的玩意,恰恰相反,它们是低级且无无意义的。它是我们对目前编程语言技术的一种妥协,因为我们希望编译器去做更多工作的时候,编译器就一定要求有更多的信息量给它。编译器不是你的女朋友,它猜不到你想干嘛的。
动态语言都是托管在另外一个程序之上,所能接触到的功能、权限都是非常有限的。但是在使用本地语言的时候,网络编程、操作系统进程调度等知识将变得有用起来。
不要过分的神话协程,协程不是什么高级与新潮的技术。不要以为写Go代码的时候不go几下就是写的假go代码。之前我们在 Java 里面,我们去接收一个请求,我们会开启一个线程,这跟我们开始使用协程,我觉得从业务上来讲没有什么区别,我们就把协程等同于线程来用就可以了。如果我不用协程就是要浪费,就是吃亏,没有这个说法的。
还有一点,因为是一个常驻内存,它的生命周期也跟常规不太一样,我们要学会怎么管理我们的资源(包括内存、CPU、协程、网络连接等)。
Q&A
提问:有两个问题,刚才在说 JSON 转换问题的时候,我不知道大家有没有遇到过,平时跟别的系统对接的时候,同一个变量可能有的时候是int,有的时候又是string,这个时候你给它用 struct 来接收,如果类型不符就会产生 panic,针对情况有什么好的建议吗?
李雄飞:这是很好的一个问题,我也对接过 PHP 系统,其实这个问题是很好的,因为我们在动态里面是不会去管类型这些细节的。
我们要怎么处理呢?
第一个,修改源系统,让源系统的返回类型保持一致。<编注:几乎不可能完成>
第二个,不用官方的JSON库,我们自己实现一个支持动态类型的一个库。这里面还有另外一个问题,如果源系统返回null怎么办,这个时候也不可能在 Golang 里面把所有的变量都写成指针,这个时候你的代码还能看吗?全是 object.Name != nil 这种操作。
在编程语言里面有一个概念叫做Optional,我们目前的做法就是把所有基础类型都独立再次包装成一个Optional类型,具体的做法到时候我们可以在群里面讨论。这个类型可以接收各种乱七八糟的,你传一些乱七八糟的格式给我也 OK。对于时间类型我们也做了一些特别支持,什么乱七八糟的时间格式我们都支持,你说了算。话说过来,这是不是一件对的事情呢?不是,它是一个无赖的事情,目前我们是这样去解决的。<编注:https://github.com/imiskolee/optional>
本期嘉宾分享的 Go 编程陷阱是否解决了你遇到的问题
评论留言“最令你头痛的 bug”
点赞前五名的同学将获得
由比原链提供的
《Go 语言公链开发实战》一本
重磅活动预告
Gopher Meetup 广州站即将开启。来自小鹏汽车、腾讯、早安科技、PingCAP的大咖讲师带来 Go 开发领域的一线实践经验分享,尽在10月26日,小鹏汽车总部销售展厅!
报名请戳:阅读原文
Go中国
扫码关注
国内最大、最活跃的 Go 开发者社区