我们接下来要讲Go进阶训练营第二大课
Error
从第二课开始
我们就要关注一下Go语言本身的一些特性
我们回溯一下上一节课的内容主要是讲微服务
为什么会讲微服务
我们其实之前也介绍过
我们需要了解整个后端的一些服务化的编程
了解服务化的编程以后
我们再把Go语言的一些特性应用到
微服务整个框架或者整个体系里面去
Error这块为什么要去重点讲
我相信所有刚开始接触Go语言的人
都会关注if
就要吐槽
就是if err !=nil
要写大量类似的代码
因为Go它整个的错误处理异常处理
和其他语言还是有些区别
对比来说
像Java的Exception
整个代码块写的方式会区别很大
第二大课的内容其实就是围绕Error
到底应该怎么去使用
它的最佳实践是什么样的
以及我们如何通过一些编码的技巧
减少类似if err !=nil的这种代码
包括我们其实也很关注Error
怎么能够把整个报错的路径给记录下来
完整记录下来
我见过很多代码同学都是
在每个地方到处去打错误的日志
是否是最佳的呢
我们就回到我们第二课关于Error的部分
我们刚开始讲的话
会重点去介绍Error本身
它在Go语言层面它是怎么去实现的
第一块要讲一下Go的Error
和其他语言的Exception
为什么会有这种对比
我相信你接触了Go语言的Error处理之后
你肯定会去搜百度搜谷歌
为什么
Error vs Exception
哪个更好
为什么Go的选型会使用Error这种处理方式
其实也是非常深思熟虑的一些思考之后
做的选择
Error未必是最好的
但是是目前Go的几个设计者认为是最适合当下
尤其是适合Go语言的这种处理方式
Go的error它本身就是一个普通的接口
一个普通的值
看然后runtime代码的时候
其实可以看到error的标准定义
它就是定义了一个type error的interface
它里面有个返回string的Error方法
就是这个地方返回了string
我可以知道错误的详细信息
我们经常在代码里面
大家都会使用errors.New()
我们标准库里面有个errors的包
它里面New方法其实就是构建一个新的error对象
我们看一下runtime pkg errors在这个代码里面
go root/src/pkg/errors这个目录下的
errors.go这个文件
它内部的实现其实就是定义了一个
就是一个私有的小写的errorString的struct
内部有一个s的字段
它是一个字符串
我们其实在errors.New()的时候
它其实就是返回了errorString的对象
因为你要实现Error方法
所以它其实直接return了e.s
errorString内部的一个s字段
我们看一下
我们再看看这个地方
errors.New()它实际上返回的是
内部的errorString对象的指针
也就说我们在New方法里面
我们看到它是取了一个新的errorString对象的指针
为什么要用指针呢
其实也是避免两个人万一创建的两个error
他们字符串的内容一模一样
这种情况下我们在有一些意外的场景里面
你去判定两个对象是否相等的时候
结果会相等
所以说它这个地方每次返回的都是一个新的对象
然后取它的地址
它是这样的
我们基础库里面
比方说我们看到bufio里面
它其实有大量自定义的一些Error
比方说我们打开bufio.go
就有类似像ErrBufferFull
我们看errors.New()的话
它其实就是一个包级别的全局变量
这里有一个小的技巧
一个规范
我建议大家在所有errors.New()返回的时候
最好是先带上它的包名
然后冒号、空格
再带上它具体错误的一些信息
所以我在我们自己内部的一些错误的时候
我也会用类似的方式
就是把包名冒号空格
再加上详细的信息
这样的话至少我知道我这个Error
大概是从哪个包抛出来的
这样的话相对来说会清晰一点
所以我们看到因为error的interface
就是一个简单的内置对象
它就是一个普普通通的值
我记得Rob Pike有一句话叫Error are values
它就是一个很普通的值
通过这一段定义Errors are value
它其实有很多很花哨的玩法
我在后面的话会逐渐地告诉大家
它有一些什么样的小技巧
能够让我们Error的处理变得更简单一些
回到我们刚刚说的
它为什么要去返回一个errorString指针
如果我们代码这样写
我们看到先定义了一个type
我们定义了一个string
其实就是用类型的别名
我们定义了一个叫errorString的对象
它实际上内部就是一个string
我去实现了error interface的接口
也就是返回string的Error方法
我这个地方的话
其实就是我直接做一个类型的强制转换
return一个string(e)
为什么我们的errors包
标准库的errors包不是这样的方式去使用呢
好我们先看一下下面的代码
我们当然要提供一个New方法
让别人来使用
传参的话error的text
它需要传具体的错误内容
我们这个时候定义了一个叫ErrNamedType
它返回的是EOF
EOF是不是大家很眼熟
我们在标准库的io包里面有个叫io.EOF
其实也是一样的大家如果去看它源码的话
它就是返回一个简单的EOF
第二个有个叫ErrStructType
它这个地方是用的标准库的EOF
注意这两个是有区别的
第一个New是自己构建的一种errorString的方法
第二个ErrStructType
它是一种用的标准库的errors.New()返回的
所以说我们变量的名字有所区分
一个是我们叫NamedType
其实就是对string做了一个别名
那么ErrStructType
因为在标准库的errors里面
它返回的是指针
变量名字也有区分
我们就看一下main函数
它执行的时候有一些什么样的区别
if ErrNamedType
也就是说我们自定义的
我们自己实现的Error对象==New("EOF")
我们看一下这个地方应该是什么
是不是应该是True
所以说我们看实际上它会输出
Named Type Error
也就是说两种New返回的Error
你去做判定的时候
它认为是相等的
有可能会导致一些意外的情况
这种姿势不是特别好
我们看一下标准库
用ErrStructType变量
我们再去重新用errors.New()
这种方式去申请返回的一个新的Error对象
它去判定的时候会返回什么呢
它并不会输出
因为这个条件是不满足的
在栈上你有了两个对象
你去比较它们的指针
指向的内容肯定是不一样的
其实两个人的内存地址是不一样的
所以它这个地方是不会返回的
这种就是从我们设计上
去避免一些意外的判定相等的情况
其实也是鼓励大家
不要用Error的text这种模型
把底层的两个字符串去匹配
去相等、去判定
这个肯定是不合适的
必须是我返回的
我定义的对象只能拿到唯一的这份
你去做判定才能成立
而不是说我随便定义两个字符串相等就能匹配上
这肯定不合适
所以这个也是前面为什么要返回指针
在这个地方为什么是要返回指针的
它是稍微有一些小区别
我们再看一段代码
我们现在用一个wrap struct 的方式
abstract是什么意思
就是定义一个errorString的struct
s字段跟标准库errors包一模一样
但是这个地方唯一的区别是什么呢
就是把它的指针给去掉了
这样为什么不行
我们先看
我们继续看
我们现在又定义ErrType=NewError("EOF")
main函数把自己定义的ErrType
和重新New的一个新EOF去做判定
这个时候会不会相等
它会相等
它会返回Error: EOF
也就是把这一行
fmt.Println这一行给打印出来了
说明它还是会导致意外申请的
分配的两个对象会相等
因为你不取地址的话
它会去展开里面的字段
展开里面的s字段去做一个等值的对比
所以这样也是不行的
为什么要去取地址
这个是要注意一下
我们再来看一下
我们知道Go的Error
它是一个很普通的概念
一个很朴素的概念
我们就看一下为什么它会选择这种模型
去应对Go的异常处理
我们先简单了解一下各个语言演进的历史
我们看一下这个代码是随便找的一个Nginx的函数
看一下叫ngx_create_path
它的第一个参数叫
我们知道创建路径
所以这个对象有可能是
是不是有可能像是一个创建路径成功返回的对象
我们一般创建路径会返回一个file的对象
第二个就是它具体需要创建的路径叫path
有个path
它的成功和失败在哪里
在前面
它返回了一个int
所以在C语言
因为C语言是单返回值
一般的它是通过传递指针作为入参
返回值为int表示成功还是失败
就是返回值是表示成功还是失败
为什么不返回bool
返回一个bool类型的话
它就是成功、失败
但是如果返回int的话
它可以表述更多的状态
它可能有更详细的一些错误信息
更详细的一些错误码
所以我们其实俗称约定的
C语言基本上就是
单返回值返回的是它的成功还是失败
甚至是它更详细的状态
它的第一个参数一般是它需要被返回的对象
第二个参数或者第三个参数就是它的入参
一般是使用这样的模型
我觉得某种意义上和Go是非常像的
我们再来看一下C++
虽然我没有直接写过C++
但是C++我知道它引入一个exception
就是引入了异常
无法知道被调用方会抛出什么异常
如果你调了别人的代码
别人不告诉你它会返回什么异常
只能看文档
感觉不是特别好处理
感觉很容易出锅
不知道什么情况下
应该带上一个try-catch去把它包住
也是不是特别爽的地方
我们再看看Java
相对现代一些的语言
因为刚才说了对于一个方法的提供者
如果它不告诉你返回什么异常
不知道该怎么去处理
或者说它会抛什么错
你应该怎么去应对
所以Java那个时候它引入了checked exception
中文的话叫需要检查的异常
那么方法的所有者就是这个函数的所有者必须声明
调用者必须处理
也就是说如果我在方法签名里面
声明了这个方法会throw一个什么样的exception
那么我的调用者就必须去处理
也就是catch的block块里面
必须catch住exception
然后做一些代码处理
这样的话相比C++来说
比不知道抛出什么异常
更明确得多
我发现有一个现象
在Java程序启动的时候
经常一片片的异常抛出来
基本上司空见惯的事情
我不知道大家有没有印象
经常给我感觉运行一个Java的进程
老是一片片的异常抛出来
但是好像也没有什么影响
而且因为Java的异常它的堆栈信息是非常详细的
所以一般一片片的这种日志输出
都是会打到StdOut里面看得到
最终就会变成什么呢
Java的异常它不再是异常了
就变得很常见
因为我刚刚说的你刚启动的时候抛一大片错
它还可以很正常地运行
然后我问我的同事这个没问题吗
他说没问题这个错是正常的
其实就是变成异常就不再是异常了
变得很常见了
从良性的错误到灾难的情况都会使用Exception
异常的严重性
最终是由这个函数的调用者
也就是谁使用谁来处理
它来区分
但是这个就很尴尬了
Java里面还有一个
我后来也问同学
我说你们checked exception
你们一般都会去处理吗
很严谨的程序员
或者说很资深的程序员
他还是会认真处理每一种异常
但是其实大部分人会直接catch一个根对象
就是Exception这个对象
然后在代码里面忽略掉
也就是说它不会去处理
或者它抛出的是一个runtime的exception
或者是用Java.error类似的方式
他不再处理了
他不再明细地去处理
马虎了事绕开了
实际上语言的设计者从这个角度
其实是提供了一些好的机制让大家去处理
但是我们总能想到一些偷懒的办法去绕开它
Go它的整个核心逻辑就是
它处理异常的逻辑不引入exception
相反它支持多参数返回
我们刚刚知道C是单参数返回
Go的话多参数返回
我们再看看这个地方
有个handle函数返回int和error
它是多参数返回的
第一个值
我们通常第一个值表示的是你需要处理的结果
但是第二个参数通常就是error
我们通常都是这样去使用
第二个是error
我们调用者拿到一个函数的时候
使用的时候其实也是一样的
比方我们看到main函数
我们用i和err调完这个handle
然后我们立马会去做err的处理
也就是说这种模型怎么理解呢
实际上需要你对error立即处理
如果一个函数它返回了
如果一个函数返回了value和error
你不能再对value做任何假设
必须先判定error
就像我们这个代码
我们是先判定的error有异常了
我们就return掉了
唯一可以忽略error的是
如果你连value都不关心
如果你连这个value也不关心
那么你就这个地方可能是下划线逗号下划线
调完这个handle你完全不管
你完全不管
你直接调用它
所以说我们基本上写Go代码都是要调完一个函数
如果它返回了error你需要先判定error
然后再去使用它的值
否则你不能对这个值做任何假设
这个是要注意的
因为Go支持是多参数返回
所以说既不像C一样需要再传一个对象指针去
我觉得这个其实对我来说是比较舒服的点
当然我知道有很多人也不太喜欢要到处去
代码里面就到处都是if err!=nil这种处理
但是我其实非常喜欢立即处理错误这种模型
而不是说一个超大的代码块
然后try-catch
然后在catch里面再去做处理
一旦有一个地方报错
我期望的一种模式就是能够立即去处理它
立即处理它
然后你知道是要降级还是容错还是怎么着
这个是一个
我们再回溯一下
我们想一想
Java里面提倡的一种是什么
最终就是一些良性的错误和一些致命的错误
它无法区分了
大家都是这样很随意地抛
但是Go它引入了一个panic的机制
如果你认为panic和其他语言的exception一样
肯定你就错了
当我们抛出异常的时候就像Java、C++里面
相当于要把这个exception扔给上面的人
扔给上游去处理
扔给调用者来处理
你把一个string转成一个int
如果转换失败会抛出异常
或者你在Java中转化String为一个Date
当失败的时候也会抛出异常
这个时候Exception是不是一个良性的
同时如果你抛出的Exception
你相当于是要抛给调用者来处理
但是在Go里面的panic
它其实意味着是fatal error
就是它彻底挂掉
如果使用的panic机制不能假设调用者来解决panic
也就是说throw一个exception
肯定是等待调用者来解决
但是我们throw假设是一个panic
那么你不能假设有任何人来帮你兜底
做任何的逻辑处理
意味着给game over就直接结束了
代码不再继续运行
我觉得这个是有本质区别的
一个是彻底结束
一个是把Exception扔给调用者来处理
它来决定
就是使用多返回值
加上这么一个简单的约定
这个约定是什么
就是如果抛错
如果有一个error出现
你要立即处理
两个事情
Go解决的让程序员知道什么时候出了问题
或者是一种良性错误
或者它就是个错误
以及真正出现异常的时候
fatal error出现异常的时候
你保留了panic这个关键字
panic关键字和error
我觉得这两种机制结合起来的话
其实是让我们的代码处理逻辑变得更简单
但是我们知道panic
就像在net/http这个代码标准库里面
如果我们处理了一个request请求
处理request请求
如果这个请求挂掉了
我们一般会有个recovery
recovery其实它不代表exception抛给我
我继续去把request处理
不是的
其实是说我要兜底
把这一次request放弃掉
让我整个Go的runtime不会退出
然后可以继续执行其他的
相当于牺牲了单个request
让其他的request可以继续执行
它其实通过panic加recover还可以兜个底
其实说牺牲了这次请求
这个不应该再做recover继续恢复执行
其实这个风险非常大的
这个要注意一下
panic意味着game over
就什么都不做了
你顶多是做个兜底打个日志之类的
我们演示一下
这种error的一个
我们使用普通的这种单参数返回
以及后面我们使用error的代码展示
首先我们有个函数叫Positive
返回的number是正数
它是一个正数还是个负数
就这么简单
它返回的是什么
是返回一个bool类型
因为你只要知道它是正数还是负数
它不就是个bool类型吗
我这个时候
我们看一下有个Check函数
Check函数如果它是负数的话就会打印负数
如果它是正数的话它会打印正数就这么简单
大于-1的话是什么
就是个正数
main函数Check(1/0/-1)
这里面有个什么问题呢
大家知道吗
这个地方有个什么问题呢
请问0是正数还是负数
它都不是对吧
所以这个地方用bool
布尔类型它只有True和False
它无法表示第三种类型
怎么办这个代码
我们做些小改动
我们做些小改动以后
我们再来看一下
这个时候我就利用了Go的多参数返回
如果n==0
我就返回一个false. false
如果n不为0
我就返回它是正数还是负数
然后第二个参数表示它是不是0
第二个返回值标示它是0还是非0
这个其实我觉得是很不爽的
所以我们看一下这个代码怎么写的
这个Check函数怎么样
先判定!ok就是等于0
两者都不是
如果它是正数就打印正数
负数打印负数
这个代码是这样的
看上去好像不是特别爽
我们再看一下再下一个演进版本
我们使用的是error
这个时候很简单
如果n==0
return false
同时我返回了一个errors.New("undefined")
就是未定义
Check函数就简单了
我们知道所有刚请求完这个函数
如果它返回error
不能假定value的返回值
所以说先要判定err我就知道它等于0了
如果这个地方我处理完之后
我再去处理这个pos的话
我其实就知道它是正数负数
我们看通过一个简单的约定
加上一个多参数返回
我就能处理好很多场景
我觉得这个是非常重要的一个点
我们同学可能就问了
还有一种写法可能同学觉得就有点炫技
也小改了一下
我不想使用多参数返回我使用参数
我加上指针
我返回一个bool的指针
返回一个bool指针怎么办
如果n==0我return nil
不为0我取它的地址
注意这个地方r是true or false
我取它地址
我在用的时候先要判定指针是不是等于nil
等于nil的话表示等于0
我们在取它
这个地方注意星号
如果取它值为true就是它是正值
如果不是就是负值
非常不建议这种方式
因为相当于它返回值是有二义性的
如果它为nil的话它表示的是0
如果再取它值表示的是其他意思
这个其实非常不好
我们的Data代码所谓的DAO层就是访问数据层
我到底是返回nil
表示的是找不到对象
还是返回一个空数组找不到对象
大家可以想一想
我们回头再讨论
就到底是返回一个nil好
还是返回一个空数组
我们可以把这个问题留下来大家可以想一想
然后我们再看一下这个代码
我真的见过很多代码
用panic加recover
模拟类似try-catch的这种写法
我直接给结论
强烈不建议
Go的panic、recover绝对不是干这个事情的
回到我们刚刚讲的
panic它就搞定一个事情
出现了fatal error
什么是fatal error
数组越界、除以零值
这种情况下你可以用panic
其他情况下不建议使用
我们这个地方是什么
如果等于0
panic
我的Check函数就要做个defer func
要把panic recover住
然后如果recover之后
它不等于1那么表示它==0
这肯定是不好的
这个写法是非常不建议
我们总结一下对于真正意外的情况
那些表示不可恢复的程序错误
索引越界
不可恢复的环境问题
比方说配置文件没提供
启动的服务配置文件没提供
然后你的程序又没有什么默认配置之类的
栈溢出
这种情况下才使用panic
对于其他的错误情况
我认为我们应该使用error来进行判定
也就是说异常和错误它是两码事
异常是指不可恢复的
Error是指交给调用者来处理的
你应该怎么做
我们看一下 Dave(Dave Cheney) 说的这段话
you only need to check the error value
if you care about the result
如果你对返回值感兴趣
你一定要处理error
做个error的判定
关于error和exception的争论
网上去搜的话
包括V2EX这种论坛有大量的帖子
大家如果感兴趣是可以去看一下他们在吵什么
各个语言其实每一种处理方法不一样
可以去看一下
但是有一篇blog其实是很有意思的
我念一下这段话叫
This blog post
from Microsoft's engineering blog in 2005
still holds true today. namely
微软的engineering的blog写了一篇文章
这个blog在PPT里面大家可以去看一下
关于exception和error两种处理到底好与坏
作者的观点是什么
就是他觉得exception并没有太多问题
本质上没有太多问题
而是说exception这种模型会比较难
以至于作者说他自己不够聪明能够处理它
其实他就比较自谦
其实指的是exception它实际上也是有一些复杂情况
我们再看一下这一段代码
其实大家可能不感觉
我们看看这段代码
我们用exception它有一个讲法是什么
就是说我们任何一行代码随时都可能会出错
我们假设这一段代码
就是我们把某个人加入到一个联盟
好像打棒球一样
我把它加入到一个联盟
把它加到一个Team里面去
我们看这个代码
我先new了一个guy
new了个guy创建了对象
然后把它加到联盟
然后再把这个人的Team
我让他随机选一个队伍
然后在return这个guy
假设有这样的一个函数
注意我们在函数任何地方都会抛一个异常
假设这个时候是第二个
我们假设它是在加入联盟
这一行执行完以后的中间某个地方
抛了一个异常出去
其实这个代码的语义就不完整了
如果这个地方抛了一个异常出去
意味着有一个人加入了联盟
但是我从联盟列表里面能找到这个人
但是我去双击他的Team的时候
发现抛了一个NullPointerException
空指针异常
为什么
因为你返回的对象抛了异常以后
有一些原因它可能继续使用了
即便guy没使用
突然有个人去看了一下联盟列表找到这个人
这个人怎么很奇怪点了一下它
查看Team的时候崩掉了
抛了一个NonePointerException
就是破坏了语义
使用exception它会出现这种情况
那么如果用error的话
的的确确用error也是有可能会出现这种情况
但是它鼓励你立马处理
尽可能让代码的语义原子性
当下立即处理
不能说error绝对能规避这种情况
但是exception比我们想象的都要复杂
它可能是有很多边界或者一些意外的情况的
所以这个文章大家可以去看一下
简单来说就是Go为什么去选择error的这种方式
我觉得就几个点考虑
第一简单
第二要考虑失败而不是成功
因为每一行代码都会报错
所以叫plan for failure not success
为失败去考虑
第三没有隐藏的控制流
因为try-catch它这像所谓的setjump、longjump
这种跳
有点像go to这种跳到一个地方去
error的处理没有任何的控制流
没有go to这种跳到一个地方去
需要你立即处理
完全交给你来控制error
因为整个error代码都是你来处理的
你不会跳一个地方
最后一句话非常重要
叫Error are values
其实也是Go官方的一个blog
Rob Pike写的就是关于error的一些优化
一些代码的处理
Error在Go里面就是个普普通通的interface
你可以自定义一个error
你可以做很多事情
我们下一课就是要接触一下Error Type
我们使用内置的interface的error能做哪些事情