41

[译]像专家一样使用 panic

 4 years ago
source link: https://juejin.im/post/5eafe243e51d454dd4629639
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文假定你已经熟悉 go 语言及其 panic/recorer 函数、以及任何其他具有异常(try-catch)概念的编程语言。

介绍

你可能已经在 《The Little Go Book》 中看到诸如这样的句子:

Go 处理错误的首选方式是 return values,而不是抛出错误

也许你在 go wiki 上看到过 《CodeReviewComments》 页面,上面写着:

不要在平常的错误处理中使用 panic,而应使用 error 和多参数返回*

另外,你可能已经看过 《Effective Go》 的文章,上面说:

向调用者报告错误的通常方法,是将错误作为额外的返回值返回

此外,你可能已经在 Dave Cheney 的博客 《Why Go gets exceptions right》 上看到了:

当你在 Go 中使用 panic,小心被吓坏。出问题可别想甩锅了,完蛋的肯定是你

似乎 panic 最好在自己的项目中避免...

但这是否就意味着没人使用 panic 呢?

查查就知道了!我们对流行的 go 项目执行下面的指令,看是否真的没人使用 panic

grep "panic(" -r --include=*.go . | wc -l
复制代码

结果:

+-------------+-----------------+
| name        | count of panics |
+-------------+-----------------+
| go          |            4050 |
| kubernetes  |            4087 |
| gin         |              46 |
| prometheus  |             693 |
| terraform   |            1161 |
| echo        |              14 |
| dep         |             157 |
| gorilla mux |               9 |
| mysql       |               5 |
| pq          |              46 |
+-------------+-----------------+
复制代码

嗯哼...

应如何对待 panic

乍一看,文档、书本和文章都说不要使用 panic,但事实却正相反,到处都是 panic...

希望你能同意到:panic 不是简单的说“用或不用”就可以的。

因此,让我们试着深入探讨,分清用与不用的界限,为什么在 github 上有如此多的 panic,以及为什么所有的书和文档都不喜欢 panic。

什么是 panic

官方文档

内置函数 panic 停止当前 goroutine 的正常执行

PanicAndRecover wigi:

panic 和 recover 函数的表现与类似于一些其他语言中的异常和 try/catch

Go by Example :

panic 通常意味着出人意料的错误。大多数情况下,我们使用它来快速处理正常操作中不应该出现的错误。

好吧...现在感觉 panic 就像是其他语言中的异常,这也解释了前面提到的 github 的项目中有那么多的 panic 的原因。

但是,如果你看过 Dave Cheney 的博文 《Why Go gets exceptions right》 ,你可能会看到:

你可能会以为 panic 就是 throw,但你错了

这意味着 panic 与其他语言中的 throw exception 略有不同,并且有自己的优缺点。

优点

throw exception
if err != nil { // handle error }

缺点

  1. 当你没使用 recover 的话,程序将终止
  2. 当 go 执行释放堆栈时,它收集有关整个调用堆栈的信息,并且可能变慢
  3. recover 函数返回 interface{},因此你需要对获得的值做类型检查,这可能会变慢(特别是在 reflection 的情况下)。它不像其他语言直接 catch 到特定的异常
  4. recover 不会捕获到 goroutinue 中的 panic,这也不像其他语言中的 try-catch

什么时候使用 panic

现在很明显 panic 是把利器,你在使用它之前必须三思。前面介绍中提到的那些警告也就都可以理解了。

Effective Go 中提到:

一个可能的反例就是初始化: 若某个库真的不能让自己工作,且有足够理由产生 panic,那就由它去吧。

如果在某些情况下,程序无法继续执行,你可以使用 panic 来停止程序

还有一个使用 panic 的理由

假如你的应用程序有复杂的业务逻辑和分层架构(更甚者,使用领域驱动模型),你则应该使用 panic。

你可能会恨我,但我相信这是唯一使你不被错误处理淹没的方法,业务逻辑也会更清晰。

哪里都是 panic

首先,介绍部分提到的数字告诉我们必需始终处理 panic(即使我们并没有在我们的代码中显式地使用 panic),因为我们调用的下游可能会 panic,甚至语言本身也会 panic,为了防止程序中断,我们必需使用 panic 处理函数,也即 recover

这必须引起重视,由其当你的项目是面向用户的接口(从用户、其他服务中获取命令、请求,并提供结果/响应),即使在出现未处理的关键错误下,我们也必须保证能以确定的格式提供结果/响应。

因此,我们应该在 main.go 中如下处理:

func main() {
    defer func() {
        if r := recover(); r != nil {
            // handle panic
        }
    }()
    // ...
}
复制代码

这只是个简单的例子,你可以在 这里 了解更多的信息。

同样重要的是,当你开始新的 goroutine 时,你必须使用 defer-recover ,否则你将处理不了来自 goroutine 的 panic。

你可以在《Go in Pratice》一书中的《Handling errors and panics》一章了解更多信息,这里我截取了其中最有趣的图片:

iAvQB3B.png!webb2yai2r.png!web

语法糖

一旦开始更频繁地使用 panic,你还必须更频繁地执行 recover。为了更优雅地做到这点,你可以使用一些类似于 recover 的程序包。该程序包的主要思想是简化 panic 的恢复,并可以以下面的方式执行 recover:

// Performs recover in case of panic with error ErrorUsernameBlank
// otherwise panic won't be recovered and will be propagated.
defer recover.One(ErrorUsernameBlank, func(err interface{}) {
  fmt.Printf("got error: %s", err)
})

// Performs recover in case of panic with error ErrorUsernameBlank or ErrorUsernameAlreadyTaken
// otherwise panic won't be recovered and will be propagated.
defer recover.Any([]error{ErrorUsernameBlank, ErrorUsernameAlreadyTaken}, func(err interface{}) {
  fmt.Printf("got error: %s", err)
})

// Performs recover for all panics.
defer recover.All(func(err interface{}) {
  fmt.Printf("got error: %s", err)
})
复制代码

你可能会发现这种语法与其他语言中传统的异常捕获非常相似,但是其的主要目标是简单明了,并且容易阅读、理解和预测代码。

对照

我们来比较下两种方法:

  1. return error
  2. panic

为了进行比较,我们使用一个简单的示例,假设我们有:

1)facade:在 Facebook,Twitter 和 Pinterest 上创建用户的服务 2)controller:调用 facade 服务的控制器,检查错误并打印结果 序列图如下所示:

j6feYry.png!web

实现 1

// controller

func SignUp(username string) {
	msg := "ok"
	if err := service.SignUp(username); err != nil {
		msg = err.Error()
	}
	fmt.Printf("[error] SignUp: %s \n", msg)
}
复制代码
// service

func SignUp(username string) error {
	if err := validation(username); err != nil {
		return fmt.Errorf("validation failed, error: %s", err)
	}
	if err := signUpFacebook(username); err != nil {
		return fmt.Errorf("facebook sign up failed, error: %s", err)
	}
	if err := signUpTwitter(username); err != nil {
		return fmt.Errorf("twitter sign up failed, error: %s", err)
	}
	if err := signUpPinterest(username); err != nil {
		return fmt.Errorf("pinterest sign up failed, error: %s", err)
	}
	return nil
}

func validation(username string) error {
	if len(username) == 0 {
		return fmt.Errorf("username cannot be blank")
	}
	return nil
}

func signUpFacebook(username string) error {
	if username == "bond" {
		return fmt.Errorf("username already taken")
	}
	return nil
}

func signUpTwitter(username string) error {
	if username == "leiter" {
		return fmt.Errorf("username already taken")
	}
	return nil
}

func signUpPinterest(username string) error {
	if username == "q" {
		return fmt.Errorf("username already taken")
	}
	return nil
}
复制代码

(源码在 这里

在这里,你可以看到 controller 中的简单函数 SignUp,它调用 service.SignUp,然后检查服务中的错误,打印结果(清晰,简单明了)。

众所周知,此代码是通用的,可以处理 go 中的错误。 太好了!

但是当涉及到 service 时——你可以发现很多重复的代码,感觉没那么清爽…

实现 2

// controller

func SignUp(username string) {
	defer recover.All(func(err interface{}) {
		fmt.Printf("[pro_panic] SignUp: %s \n", err)
	})
	service.MustSignUp(username)
	fmt.Printf("[pro_panic] SignUp: %s \n", "ok")
}
复制代码
// service

func MustSignUp(username string) {
	mustValidation(username)
	mustSignUpFacebook(username)
	mustSignUpTwitter(username)
	mustSignUpPinterest(username)
}

func mustValidation(username string) {
	if len(username) == 0 {
		panic(c.ErrorUsernameBlank)
	}
}

func mustSignUpFacebook(username string) {
	if username == "bond" {
		panic(c.ErrorUsernameAlreadyTaken)
	}
}

func mustSignUpTwitter(username string) {
	if username == "leiter" {
		panic(c.ErrorUsernameAlreadyTaken)
	}
}

func mustSignUpPinterest(username string) {
	if username == "q" {
		panic(c.ErrorUsernameAlreadyTaken)
	}
}
复制代码

(源码在 这里

在这里,你可以在 controller 中看到相同的函数 SignUp,该函数调用 service.MustSignUp,然后执行 recover(通过 recover 包),并打印结果(相同流程)。

如果你查看一下 service,你可能会发现它现在看起来更短、更简单,而且更容易阅读和理解其中的业务逻辑。

真的很糟糕吗

从技术上讲,这两种实现都是相同的,并提供相同的功能、相同的错误和相同的结果(你可以在 这里 查看)。

但一对比代码量——很明显,第二个更简单,您可以在下一张图片中看到它:

AjmQbaM.png!web

另外,第一种实现没有 recover,但它应该要有,因为每个对用户友好的项目都必须有 recover,这意味着第一种实现将有更多的代码。

panic 慢不慢

在小例子上执行 benchmarking 可能看起来很愚蠢,但不管怎样,让我们看看它看起来如何,并找出是否有异常的数字:

+---------------------------------+----------+----------+
| case                            | imp. #1  | imp. #2  |
+---------------------------------+----------+----------+
| error: username cannot be blank | 53000 ns | 45000 ns |
| error: username already taken   | 51000 ns | 46000 ns |
| ok                              | 32000 ns | 34000 ns |
+---------------------------------+----------+----------+
复制代码

(你可以在 这里 找到与此 benchmarking 相关的源代码)。

看起来在出错的情况下——panic 更快,但在成功的情况下,recover 需要一些开销…

请注意,所有提供的数字都以纳秒表示时间,

这意味着:对于这种特殊的情况,两种方法之间并没有很大的区别…

Go 2 草案

你可能已经知道,在 go 2 中,错误处理将通过 check-handle 组合得到改进(如果不了解的话,可以 看一下 ),它将以非常优雅的方式简化所有事情!

但它是否有助于构建复杂的分层应用程序?

对于非常简单的应用程序,比如我们的案例(controller-service),答案是肯定的。但不幸的是,对于大型应用程序,特别是对于支持领域驱动设计的应用程序, check-handle 没有帮助,相信你还是要用 panic…

总结

这篇文章的重点,是要表明 panic 只是一个工具,你不必害怕这个工具,你必须知道什么时候和如何使用 panic…

一旦你知道这个工具的优点和缺点,你就可以利用它来决定是否使用它。

PS

你可以在 这里 找到具有分层架构(不是 DDD 而是许多层)的 demo 项目,它的构建思想是到处 panic,也许它是具有说明性的。

此外,你可以在 这里 找到更多使用这两种方法(errors vs panic)的例子。

如果你不喜欢 panic,您可能会找到 另一种方法 :如何以另一种方式简化错误处理。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK