像專家一樣使用 panic

本文假定你已經熟悉 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

<code>grep "panic(" -r --include=*.go . | wc -l/<code>

結果:

<code>+-------------+-----------------+
| 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 |
+-------------+-----------------+/<code>

嗯哼...

應如何對待 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 略有不同,並且有自己的優缺點。

優點

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

缺點

  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 中如下處理:

<code>func main() {
defer func() {
if r := recover(); r != nil {
// handle panic
}
}()
// ...
}/<code>

這只是個簡單的例子,你可以在

這裡 瞭解更多的信息。

同樣重要的是,當你開始新的 goroutine 時,你必須使用 defer-recover ,否則你將處理不了來自 goroutine 的 panic。

你可以在《Go in Pratice》一書中的《Handling errors and panics》一章瞭解更多信息,這裡我截取了其中最有趣的圖片:

像專家一樣使用 panic

像專家一樣使用 panic

語法糖

一旦開始更頻繁地使用 panic,你還必須更頻繁地執行 recover。為了更優雅地做到這點,你可以使用一些類似於 recover 的程序包。該程序包的主要思想是簡化 panic 的恢復,並可以以下面的方式執行 recover:

<code>// 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)
})/<code>

你可能會發現這種語法與其他語言中傳統的異常捕獲非常相似,但是其的主要目標是簡單明瞭,並且容易閱讀、理解和預測代碼。

對照

我們來比較下兩種方法:

  1. return error
  2. panic

為了進行比較,我們使用一個簡單的示例,假設我們有:

1)facade:在 Facebook,Twitter 和 Pinterest 上創建用戶的服務 2)controller:調用 facade 服務的控制器,檢查錯誤並打印結果 序列圖如下所示:

像專家一樣使用 panic

實現 1

<code>// controller

func SignUp(username string) {
\tmsg := "ok"
\tif err := service.SignUp(username); err != nil {
\t\tmsg = err.Error()
\t}

\tfmt.Printf("[error] SignUp: %s \\n", msg)
}/<code>
<code>// service

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

func validation(username string) error {
\tif len(username) == 0 {
\t\treturn fmt.Errorf("username cannot be blank")
\t}
\treturn nil
}

func signUpFacebook(username string) error {
\tif username == "bond" {
\t\treturn fmt.Errorf("username already taken")
\t}
\treturn nil
}

func signUpTwitter(username string) error {
\tif username == "leiter" {
\t\treturn fmt.Errorf("username already taken")
\t}
\treturn nil
}

func signUpPinterest(username string) error {
\tif username == "q" {
\t\treturn fmt.Errorf("username already taken")
\t}
\treturn nil
}/<code>

(源碼在

這裡

在這裡,你可以看到 controller 中的簡單函數 SignUp,它調用 service.SignUp,然後檢查服務中的錯誤,打印結果(清晰,簡單明瞭)。

眾所周知,此代碼是通用的,可以處理 go 中的錯誤。 太好了!

但是當涉及到 service 時——你可以發現很多重複的代碼,感覺沒那麼清爽…

實現 2

<code>// controller

func SignUp(username string) {
\tdefer recover.All(func(err interface{}) {
\t\tfmt.Printf("[pro_panic] SignUp: %s \\n", err)
\t})
\tservice.MustSignUp(username)
\tfmt.Printf("[pro_panic] SignUp: %s \\n", "ok")
}/<code>
<code>// service

func MustSignUp(username string) {
\tmustValidation(username)
\tmustSignUpFacebook(username)
\tmustSignUpTwitter(username)
\tmustSignUpPinterest(username)
}

func mustValidation(username string) {
\tif len(username) == 0 {
\t\tpanic(c.ErrorUsernameBlank)
\t}
}

func mustSignUpFacebook(username string) {
\tif username == "bond" {
\t\tpanic(c.ErrorUsernameAlreadyTaken)
\t}

}

func mustSignUpTwitter(username string) {
\tif username == "leiter" {
\t\tpanic(c.ErrorUsernameAlreadyTaken)
\t}
}

func mustSignUpPinterest(username string) {
\tif username == "q" {
\t\tpanic(c.ErrorUsernameAlreadyTaken)
\t}
}/<code>

(源碼在 這裡

在這裡,你可以在 controller 中看到相同的函數 SignUp,該函數調用 service.MustSignUp,然後執行 recover(通過 recover 包),並打印結果(相同流程)。

如果你查看一下 service,你可能會發現它現在看起來更短、更簡單,而且更容易閱讀和理解其中的業務邏輯。

真的很糟糕嗎

從技術上講,這兩種實現都是相同的,並提供相同的功能、相同的錯誤和相同的結果(你可以在 這裡 查看)。

但一對比代碼量——很明顯,第二個更簡單,您可以在下一張圖片中看到它:

像專家一樣使用 panic

另外,第一種實現沒有 recover,但它應該要有,因為每個對用戶友好的項目都必須有 recover,這意味著第一種實現將有更多的代碼。

panic 慢不慢

在小例子上執行 benchmarking 可能看起來很愚蠢,但不管怎樣,讓我們看看它看起來如何,並找出是否有異常的數字:

<code>+---------------------------------+----------+----------+
| 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 |
+---------------------------------+----------+----------+/<code>

(你可以在 這裡 找到與此 benchmarking 相關的源代碼)。

看起來在出錯的情況下——panic 更快,但在成功的情況下,recover 需要一些開銷…

請注意,所有提供的數字都以納秒錶示時間,

這意味著:對於這種特殊的情況,兩種方法之間並沒有很大的區別…

Go 2 草案

你可能已經知道,在 go 2 中,錯誤處理將通過 check-handle 組合得到改進(如果不瞭解的話,可以 看一下 ),它將以非常優雅的方式簡化所有事情!

但它是否有助於構建複雜的分層應用程序?

對於非常簡單的應用程序,比如我們的案例(controller-service),答案是肯定的。但不幸的是,對於大型應用程序,特別是對於支持領域驅動設計的應用程序, check-handle 沒有幫助,相信你還是要用 panic…

總結

這篇文章的重點,是要表明 panic 只是一個工具,你不必害怕這個工具,你必須知道什麼時候和如何使用 panic…

一旦你知道這個工具的優點和缺點,你就可以利用它來決定是否使用它。


分享到:


相關文章: