日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程资源 > 编程问答 >内容正文

编程问答

Go 开发关键技术指南 | Go 面向失败编程 (内含超全知识大图)

發(fā)布時(shí)間:2025/3/20 编程问答 31 豆豆
生活随笔 收集整理的這篇文章主要介紹了 Go 开发关键技术指南 | Go 面向失败编程 (内含超全知识大图) 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

作者 |?楊成立(忘籬) 阿里巴巴高級(jí)技術(shù)專家

關(guān)注“阿里巴巴云原生”公眾號(hào),回復(fù) Go 即可查看清晰知識(shí)大圖!

導(dǎo)讀:從問(wèn)題本身出發(fā),不局限于 Go 語(yǔ)言,探討服務(wù)器中常常遇到的問(wèn)題,最后回到 Go 如何解決這些問(wèn)題,為大家提供 Go 開(kāi)發(fā)的關(guān)鍵技術(shù)指南。我們將以系列文章的形式推出《Go 開(kāi)發(fā)的關(guān)鍵技術(shù)指南》,共有 4 篇文章,本文為第 2 篇。

Could Not Recover

在 C/C 中,

  • 最苦惱的莫過(guò)于上線后發(fā)現(xiàn)有野指針或內(nèi)存越界,導(dǎo)致不可能崩潰的地方崩潰;
  • 最無(wú)語(yǔ)的是因?yàn)楹茉鐚?xiě)的日志打印,比如 %s 把整數(shù)當(dāng)字符串,突然某天執(zhí)行到了崩潰;
  • 最無(wú)奈的是無(wú)論因?yàn)槭裁幢罎⒍紝?dǎo)致服務(wù)的所有用戶受到影響。

如果能有一種方案,將指針和內(nèi)存都管理起來(lái),避免用戶錯(cuò)誤訪問(wèn)和釋放,這樣雖然浪費(fèi)了一部分的 CPU,但是可以在快速變化的業(yè)務(wù)中避免這些頭疼的問(wèn)題。在現(xiàn)代的高級(jí)語(yǔ)言中,比如 Java、Python 和 JS 的異常,以及 Go 的 panic-recover 都是這種機(jī)制。

畢竟,用一些 CPU 換得快速迭代中的不 Crash,怎么算都是劃得來(lái)的。

哪些可以 Recover

Go 有?Defer, Panic, and Recover。其中 defer 一般用在資源釋放或者捕獲 panic。而 panic 是中止正常的執(zhí)行流程,執(zhí)行所有的 defer,返回調(diào)用函數(shù)繼續(xù) panic;主動(dòng)調(diào)用 panic 函數(shù),還有些運(yùn)行時(shí)錯(cuò)誤都會(huì)進(jìn)入 panic 過(guò)程。最后 recover 是在 panic 時(shí)獲取控制權(quán),進(jìn)入正常的執(zhí)行邏輯。

注意 recover 只有在 defer 函數(shù)中才有用,在 defer 的函數(shù)調(diào)用的函數(shù)中 recover 不起作用,如下實(shí)例代碼不會(huì) recover:

go package mainimport "fmt"func main() {f := func() {if r := recover(); r != nil {fmt.Println(r)}}defer func() {f()} ()panic("ok") }

執(zhí)行時(shí)依舊會(huì) panic,結(jié)果如下:

$ go run t.go panic: okgoroutine 1 [running]: main.main()/Users/winlin/temp/t.go:16 0x6b exit status 2

有些情況是不可以被捕獲,程序會(huì)自動(dòng)退出,這種都是無(wú)法正常 recover。當(dāng)然,一般的 panic 都是能捕獲的,比如 Slice 越界、nil 指針、除零、寫(xiě)關(guān)閉的 chan。

下面是 Slice 越界的例子,recover 可以捕獲到:

go package mainimport ("fmt" )func main() {defer func() {if r := recover(); r != nil {fmt.Println(r)}}()b := []int{0, 1}fmt.Println("Hello, playground", b[2]) }

下面是 nil 指針被引用的例子,recover 可以捕獲到:

go package mainimport ("bytes""fmt" )func main() {defer func() {if r := recover(); r != nil {fmt.Println(r)}}()var b *bytes.Bufferfmt.Println("Hello, playground", b.Bytes()) }

下面是除零的例子,recover 可以捕獲到:

go package mainimport ("fmt" )func main() {defer func() {if r := recover(); r != nil {fmt.Println(r)}}()var v intfmt.Println("Hello, playground", 1/v) }

下面是寫(xiě)關(guān)閉的 chan 的例子,recover 可以捕獲到:

go package mainimport ("fmt" )func main() {defer func() {if r := recover(); r != nil {fmt.Println(r)}}()c := make(chan bool)close(c)c <- true }

Recover 最佳實(shí)踐

一般 recover 后會(huì)判斷是否 err,有可能需要處理特殊的 error,一般也需要打印日志或者告警,給一個(gè) recover 的例子:

package mainimport ("fmt" )type Handler interface {Filter(err error, r interface{}) error }type Logger interface {Ef(format string, a ...interface{}) }// Handle panic by hdr, which filter the error. // Finally log err with logger. func HandlePanic(hdr Handler, logger Logger) error {return handlePanic(recover(), hdr, logger) }type hdrFunc func(err error, r interface{}) errorfunc (v hdrFunc) Filter(err error, r interface{}) error {return v(err, r) }type loggerFunc func(format string, a ...interface{})func (v loggerFunc) Ef(format string, a ...interface{}) {v(format, a...) }// Handle panic by hdr, which filter the error. // Finally log err with logger. func HandlePanicFunc(hdr func(err error, r interface{}) error,logger func(format string, a ...interface{}), ) error {var f Handlerif hdr != nil {f = hdrFunc(hdr)}var l Loggerif logger != nil {l = loggerFunc(logger)}return handlePanic(recover(), f, l) }func handlePanic(r interface{}, hdr Handler, logger Logger) error {if r != nil {err, ok := r.(error)if !ok {err = fmt.Errorf("r is %v", r)}if hdr != nil {err = hdr.Filter(err, r)}if err != nil && logger != nil {logger.Ef("panic err % v", err)}return err}return nil }func main() {func() {defer HandlePanicFunc(nil, func(format string, a ...interface{}) {fmt.Println(fmt.Sprintf(format, a...))})panic("ok")}()logger := func(format string, a ...interface{}) {fmt.Println(fmt.Sprintf(format, a...))}func() {defer HandlePanicFunc(nil, logger)panic("ok")}() }

對(duì)于庫(kù)如果需要啟動(dòng) goroutine,如何 recover 呢?

  • 如果不可能出現(xiàn) panic,可以不用 recover,比如 tls.go 中的一個(gè) goroutine:errChannel <- conn.Handshake()?;
  • 如果可能出現(xiàn) panic,也比較明確的可以 recover,可以調(diào)用用戶回調(diào),或者讓用戶設(shè)置 logger,比如 http/server.go 處理請(qǐng)求的 goroutine:if err := recover(); err != nil && err != ErrAbortHandler {?;
  • 如果完全不知道如何處理 recover,比如一個(gè) cache 庫(kù),丟棄數(shù)據(jù)可能會(huì)造成問(wèn)題,那么就應(yīng)該由用戶來(lái)啟動(dòng) goroutine,返回異常數(shù)據(jù)和錯(cuò)誤,用戶決定如何 recover、如何重試;
  • 如果完全知道如何 recover,比如忽略 panic 繼續(xù)跑,或者能使用 logger 打印日志,那就按照正常的 panic-recover 邏輯處理。

哪些不能 Recover

下面看看一些情況是無(wú)法捕獲的,包括(不限于):

  • Thread Limit,超過(guò)了系統(tǒng)的線程限制,詳細(xì)參考下面的說(shuō)明;
  • Concurrent Map Writers,競(jìng)爭(zhēng)條件,同時(shí)寫(xiě) map,參考下面的例子。推薦使用標(biāo)準(zhǔn)庫(kù)的?sync.Map?解決這個(gè)問(wèn)題。

Map 競(jìng)爭(zhēng)寫(xiě)導(dǎo)致 panic 的實(shí)例代碼如下:

go package mainimport ("fmt""time" )func main() {m := map[string]int{}p := func() {defer func() {if r := recover(); r != nil {fmt.Println(r)}}()for {m["t"] = 0}}go p()go p()time.Sleep(1 * time.Second) }

注意:如果編譯時(shí)加了?-race,其他競(jìng)爭(zhēng)條件也會(huì)退出,一般用于死鎖檢測(cè),但這會(huì)導(dǎo)致嚴(yán)重的性能問(wèn)題,使用需要謹(jǐn)慎。

備注:一般標(biāo)準(zhǔn)庫(kù)中通過(guò)?throw?拋出的錯(cuò)誤都是無(wú)法 recover 的,搜索了下 Go1.11 一共有 690 個(gè)地方有調(diào)用 throw。

Go1.2 引入了能使用的最多線程數(shù)限制?ThreadLimit,如果超過(guò)了就 panic,這個(gè) panic 是無(wú)法 recover 的。

fatal error: thread exhaustionruntime stack: runtime.throw(0x10b60fd, 0x11)/usr/local/Cellar/go/1.8.3/libexec/src/runtime/panic.go:596 0x95 runtime.mstart()/usr/local/Cellar/go/1.8.3/libexec/src/runtime/proc.go:1132

默認(rèn)是 1 萬(wàn)個(gè)物理線程,我們可以調(diào)用?runtime?的?debug.SetMaxThreads?設(shè)置最大線程數(shù)。

SetMaxThreads sets the maximum number of operating system threads that the Go program can use. If it attempts to use more than this many, the program crashes. SetMaxThreads returns the previous setting. The initial setting is 10,000 threads.

用這個(gè)函數(shù)設(shè)置程序能使用的最大系統(tǒng)線程數(shù),如果超過(guò)了程序就 crash,返回的是之前設(shè)置的值,默認(rèn)是 1 萬(wàn)個(gè)線程。

The limit controls the number of operating system threads, not the number of goroutines. A Go program creates a new thread only when a goroutine is ready to run but all the existing threads are blocked in system calls, cgo calls, or are locked to other goroutines due to use of runtime.LockOSThread.

注意限制的并不是 goroutine 的數(shù)目,而是使用的系統(tǒng)線程的限制。goroutine 啟動(dòng)時(shí),并不總是新開(kāi)系統(tǒng)線程,只有當(dāng)目前所有的物理線程都阻塞在系統(tǒng)調(diào)用、cgo 調(diào)用,或者顯示有調(diào)用?runtime.LockOSThread?時(shí)。

SetMaxThreads is useful mainly for limiting the damage done by programs that create an unbounded number of threads. The idea is to take down the program before it takes down the operating system.

這個(gè)是最后的防御措施,可以在程序干死系統(tǒng)前把有問(wèn)題的程序干掉。

舉一個(gè)簡(jiǎn)單的例子,限制使用 10 個(gè)線程,然后用?runtime.LockOSThread?來(lái)綁定 goroutine 到系統(tǒng)線程,可以看到?jīng)]有創(chuàng)建 10 個(gè) goroutine 就退出了(runtime 也需要使用線程)。參考下面的例子 Playground: ThreadLimit:

go package mainimport ("fmt""runtime""runtime/debug""sync""time" )func main() {nv := 10ov := debug.SetMaxThreads(nv)fmt.Println(fmt.Sprintf("Change max threads %d=>%d", ov, nv))var wg sync.WaitGroupc := make(chan bool, 0)for i := 0; i < 10; i {fmt.Println(fmt.Sprintf("Start goroutine #%v", i))wg.Add(1)go func() {c <- truedefer wg.Done()runtime.LockOSThread()time.Sleep(10 * time.Second)fmt.Println("Goroutine quit")}()<- cfmt.Println(fmt.Sprintf("Start goroutine #%v ok", i))}fmt.Println("Wait for all goroutines about 10s...")wg.Wait()fmt.Println("All goroutines done") }

運(yùn)行結(jié)果如下:

bash Change max threads 10000=>10 Start goroutine #0 Start goroutine #0 ok ...... Start goroutine #6 Start goroutine #6 ok Start goroutine #7 runtime: program exceeds 10-thread limit fatal error: thread exhaustionruntime stack: runtime.throw(0xffdef, 0x11)/usr/local/go/src/runtime/panic.go:616 0x100 runtime.checkmcount()/usr/local/go/src/runtime/proc.go:542 0x100 ....../usr/local/go/src/runtime/proc.go:1830 0x40 runtime.startm(0x1040e000, 0x1040e000)/usr/local/go/src/runtime/proc.go:2002 0x180

從這次運(yùn)行可以看出,限制可用的物理線程為 10 個(gè),其中系統(tǒng)占用了 3 個(gè)物理線程,user-level 可運(yùn)行 7 個(gè)線程,開(kāi)啟第 8 個(gè)線程時(shí)就崩潰了。

注意這個(gè)運(yùn)行結(jié)果在不同的 go 版本是不同的,比如 Go1.8 有時(shí)候啟動(dòng) 4 到 5 個(gè) goroutine 就會(huì)崩潰。

而且加 recover 也無(wú)法恢復(fù),參考下面的實(shí)例代碼。

可見(jiàn)這個(gè)機(jī)制是最后的防御,不能突破的底線。我們?cè)诰€上服務(wù)時(shí),曾經(jīng)因?yàn)?block 的 goroutine 過(guò)多,導(dǎo)致觸發(fā)了這個(gè)機(jī)制。

go package mainimport ("fmt""runtime""runtime/debug""sync""time" )func main() {defer func() {if r := recover(); r != nil {fmt.Println("main recover is", r)}} ()nv := 10ov := debug.SetMaxThreads(nv)fmt.Println(fmt.Sprintf("Change max threads %d=>%d", ov, nv))var wg sync.WaitGroupc := make(chan bool, 0)for i := 0; i < 10; i {fmt.Println(fmt.Sprintf("Start goroutine #%v", i))wg.Add(1)go func() {c <- truedefer func() {if r := recover(); r != nil {fmt.Println("main recover is", r)}} ()defer wg.Done()runtime.LockOSThread()time.Sleep(10 * time.Second)fmt.Println("Goroutine quit")}()<- cfmt.Println(fmt.Sprintf("Start goroutine #%v ok", i))}fmt.Println("Wait for all goroutines about 10s...")wg.Wait()fmt.Println("All goroutines done") }

如何避免程序超過(guò)線程限制被干掉?一般可能阻塞在 system call,那么什么時(shí)候會(huì)阻塞?還有,GOMAXPROCS?又有什么作用呢?

The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package’s GOMAXPROCS function queries and changes the limit.

GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting. If n < 1, it does not change the current setting. The number of logical CPUs on the local machine can be queried with NumCPU. This call will go away when the scheduler improves.

可見(jiàn) GOMAXPROCS 只是設(shè)置 user-level 并行執(zhí)行的線程數(shù),也就是真正執(zhí)行的線程數(shù) 。實(shí)際上如果物理線程阻塞在 system calls,會(huì)開(kāi)啟更多的物理線程。關(guān)于這個(gè)參數(shù)的說(shuō)明,文章《Number of threads used by goroutine》解釋得很清楚:

There is no direct correlation. Threads used by your app may be less than, equal to or more than 10.

So if your application does not start any new goroutines, threads count will be less than 10.

If your app starts many goroutines (>10) where none is blocking (e.g. in system calls), 10 operating system threads will execute your goroutines simultaneously.

If your app starts many goroutines where many (>10) are blocked in system calls, more than 10 OS threads will be spawned (but only at most 10 will be executing user-level Go code).

設(shè)置 GOMAXPROCS 為 10:如果開(kāi)啟的 goroutine 小于 10 個(gè),那么物理線程也小于 10 個(gè)。如果有很多 goroutines,但是沒(méi)有阻塞在 system calls,那么只有 10 個(gè)線程會(huì)并行執(zhí)行。如果有很多 goroutines 同時(shí)超過(guò) 10 個(gè)阻塞在 system calls,那么超過(guò) 10 個(gè)物理線程會(huì)被創(chuàng)建,但是只有 10 個(gè)活躍的線程執(zhí)行 user-level 代碼。

那么什么時(shí)候會(huì)阻塞在 system blocking 呢?例子《Why does it not create many threads when many goroutines are blocked in writing》解釋很清楚,雖然設(shè)置了 GOMAXPROCS 為 1,但實(shí)際上還是開(kāi)啟了 12 個(gè)線程,每個(gè) goroutine 一個(gè)物理線程,具體執(zhí)行下面的代碼 Writing Large Block:

go package mainimport ("io/ioutil""os""runtime""strconv""sync" )func main() {runtime.GOMAXPROCS(1)data := make([]byte, 128*1024*1024)var wg sync.WaitGroupfor i := 0; i < 10; i {wg.Add(1)go func(n int) {defer wg.Done()for {ioutil.WriteFile("testxxx" strconv.Itoa(n), []byte(data), os.ModePerm)}}(i)}wg.Wait() }

運(yùn)行結(jié)果如下:

Mac chengli.ycl$ time go run t.go real 1m44.679s user 0m0.230s sys 0m53.474s

雖然 GOMAXPROCS 設(shè)置為 1,實(shí)際上創(chuàng)建了 12 個(gè)物理線程。

有大量的時(shí)間是在 sys 上面,也就是 system calls。

So I think the syscalls were exiting too quickly in your original test to show the effect you were expecting.

Effective Go?中的解釋:

Goroutines are multiplexed onto multiple OS threads so if one should block, such as while waiting for I/O, others continue to run. Their design hides many of the complexities of thread creation and management.

由此可見(jiàn),如果程序出現(xiàn)因?yàn)槌^(guò)線程限制而崩潰,那么可以在出現(xiàn)瓶頸時(shí),用 linux 工具查看系統(tǒng)調(diào)用的統(tǒng)計(jì),看哪些系統(tǒng)調(diào)用導(dǎo)致創(chuàng)建了過(guò)多的線程。

Errors

錯(cuò)誤處理是現(xiàn)實(shí)中經(jīng)常碰到的、難以處理好的問(wèn)題,下面會(huì)從下面幾個(gè)方面探討錯(cuò)誤處理:

  • 為什么 Go 沒(méi)有選擇異常,而是返回錯(cuò)誤碼(error)? 因?yàn)楫惓DP秃茈y看出有沒(méi)有寫(xiě)對(duì),錯(cuò)誤碼方式也不容易,相對(duì)會(huì)簡(jiǎn)單點(diǎn)。
  • Go 的 error 有什么問(wèn)題,為何 Go2 草案這么大篇幅說(shuō) error 改進(jìn)? 因?yàn)?Go 雖然是錯(cuò)誤碼但還不夠好,問(wèn)題在于啰嗦、繁雜、缺失關(guān)鍵信息。
  • 有哪些好用的 error 庫(kù),如何和日志配合使用? 推薦用庫(kù)?pkg/errors;另外,避免日志和錯(cuò)誤混淆。
  • Go 的錯(cuò)誤處理最佳實(shí)踐是什么? 配合日志使用錯(cuò)誤。錯(cuò)誤需要帶上上下文、堆棧等信息。

錯(cuò)誤和異常

我們總會(huì)遇到非預(yù)期的非正常情況,有一種是符合預(yù)期的,比如函數(shù)返回 error 并處理,這種叫做可以預(yù)見(jiàn)到的錯(cuò)誤,還有一種是預(yù)見(jiàn)不到的比如除零、空指針、數(shù)組越界等叫做 panic,panic 的處理主要參考?Defer, Panic, and Recover。

錯(cuò)誤處理的模型一般有兩種,一般是錯(cuò)誤碼模型比如 C/C 和 Go,還有異常模型比如 Java 和 C#。Go 沒(méi)有選擇異常模型,因?yàn)殄e(cuò)誤碼比異常更有優(yōu)勢(shì),參考文章《Cleaner, more elegant, and wrong》以及《Cleaner, more elegant, and harder to recognize》。

看下面的代碼:

try {AccessDatabase accessDb = new AccessDatabase();accessDb.GenerateDatabase(); } catch (Exception e) {// Inspect caught exception }public void GenerateDatabase() {CreatePhysicalDatabase();CreateTables();CreateIndexes(); }

這段代碼的錯(cuò)誤處理有很多問(wèn)題,比如如果?CreateIndexes?拋出異常,會(huì)導(dǎo)致數(shù)據(jù)庫(kù)和表不會(huì)刪除,造成臟數(shù)據(jù)。從代碼編寫(xiě)者和維護(hù)者的角度看這兩個(gè)模型,會(huì)比較清楚:

錯(cuò)誤處理不容易做好,要說(shuō)容易那說(shuō)明做錯(cuò)了;要把錯(cuò)誤處理寫(xiě)對(duì)了,基于錯(cuò)誤碼模型雖然很難,但比異常模型簡(jiǎn)單。

如果使用錯(cuò)誤碼模型,非常容易就能看出錯(cuò)誤處理沒(méi)有寫(xiě)對(duì),也能很容易知道做得好不好;要知道是否做得非常好,錯(cuò)誤碼模型也不太容易。

如果使用異常模型,無(wú)論做的好不好都很難知道,而且也很難知道怎么做好。

Errors in Go

Go 官方的 error 介紹,簡(jiǎn)單一句話就是返回錯(cuò)誤對(duì)象的方式,參考《Error handling and Go》,解釋了 error 是什么?如何判斷具體的錯(cuò)誤?以及顯式返回錯(cuò)誤的好處。

文中舉的例子就是打開(kāi)文件錯(cuò)誤:

func Open(name string) (file *File, err error)

Go 可以返回多個(gè)值,最后一個(gè)一般是 error,我們需要檢查和處理這個(gè)錯(cuò)誤,這就是 Go 的錯(cuò)誤處理的官方介紹:

if err := Open("src.txt"); err != nil {// Handle err }

看起來(lái)非常簡(jiǎn)單的錯(cuò)誤處理,有什么難的呢?稍等,在 Go2 的草案中,提到的三個(gè)點(diǎn)?[Error Handling](https://go.googlesource.com/proposal/ /master/design/go2draft-error-handling-overview.md)、[Error Values](https://go.googlesource.com/proposal/ /master/design/go2draft-error-values-overview.md)和?[Generics 泛型](https://go.googlesource.com/proposal/ /master/design/go2draft-generics-overview.md),兩個(gè)點(diǎn)都是錯(cuò)誤處理的,這說(shuō)明了 Go1 中對(duì)于錯(cuò)誤是有改進(jìn)的地方。

再詳細(xì)看下 Go2 的草案,[錯(cuò)誤處理:Error Handling](https://go.googlesource.com/proposal/ /master/design/go2draft-error-handling-overview.md)?中,主要描述了發(fā)生錯(cuò)誤時(shí)的重復(fù)代碼,以及不能便捷處理錯(cuò)誤的情況。比如草案中舉的這個(gè)例子 No Error Handling: CopyFile,沒(méi)有做任何錯(cuò)誤處理:

package mainimport ("fmt""io""os" )func CopyFile(src, dst string) error {r, _ := os.Open(src)defer r.Close()w, _ := os.Create(dst)io.Copy(w, r)w.Close()return nil }func main() {fmt.Println(CopyFile("src.txt", "dst.txt")) }

還有草案中這個(gè)例子 Not Nice and still Wrong: CopyFile,錯(cuò)誤處理是特別啰嗦,而且比較明顯有問(wèn)題:

package mainimport ("fmt""io""os" )func CopyFile(src, dst string) error {r, err := os.Open(src)if err != nil {return err}defer r.Close()w, err := os.Create(dst)if err != nil {return err}defer w.Close()if _, err := io.Copy(w, r); err != nil {return err}if err := w.Close(); err != nil {return err}return nil }func main() {fmt.Println(CopyFile("src.txt", "dst.txt")) }

當(dāng)?io.Copy?或?w.Close?出現(xiàn)錯(cuò)誤時(shí),目標(biāo)文件實(shí)際上是有問(wèn)題,那應(yīng)該需要?jiǎng)h除 dst 文件的。而且需要給出錯(cuò)誤時(shí)的信息,比如是哪個(gè)文件,不能直接返回 err。所以 Go 中正確的錯(cuò)誤處理,應(yīng)該是這個(gè)例子 Good: CopyFile,雖然啰嗦繁瑣不簡(jiǎn)潔:

package mainimport ("fmt""io""os" )func CopyFile(src, dst string) error {r, err := os.Open(src)if err != nil {return fmt.Errorf("copy %s %s: %v", src, dst, err)}defer r.Close()w, err := os.Create(dst)if err != nil {return fmt.Errorf("copy %s %s: %v", src, dst, err)}if _, err := io.Copy(w, r); err != nil {w.Close()os.Remove(dst)return fmt.Errorf("copy %s %s: %v", src, dst, err)}if err := w.Close(); err != nil {os.Remove(dst)return fmt.Errorf("copy %s %s: %v", src, dst, err)}return nil }func main() {fmt.Println(CopyFile("src.txt", "dst.txt")) }

具體應(yīng)該如何簡(jiǎn)潔的處理錯(cuò)誤,可以讀?[Error Handling](https://go.googlesource.com/proposal/ /master/design/go2draft-error-handling-overview.md),大致是引入關(guān)鍵字 handle 和 check,由于本文重點(diǎn)側(cè)重 Go1 如何錯(cuò)誤處理,就不展開(kāi)分享了。

明顯上面每次都返回的?fmt.Errorf?信息也是不夠的,所以 Go2 還對(duì)于錯(cuò)誤的值有提案,參考?[Error Values](https://go.googlesource.com/proposal/ /master/design/go2draft-error-values-overview.md)。大規(guī)模程序應(yīng)該面向錯(cuò)誤編程和測(cè)試,同時(shí)錯(cuò)誤應(yīng)該包含足夠的信息。

Go1 中判斷 error 具體是什么錯(cuò)誤,有以下幾種辦法:

  • 直接比較,比如返回的是?io.EOF?這個(gè)全局變量,那么可以直接比較是否是這個(gè)錯(cuò)誤;
  • 可以用類(lèi)型轉(zhuǎn)換 type 或 switch,嘗試來(lái)轉(zhuǎn)換成具體的錯(cuò)誤類(lèi)型,看是哪種錯(cuò)誤;
  • 提供某些函數(shù)來(lái)判斷是否是某個(gè)錯(cuò)誤,比如?os.IsNotExist?判斷是否是指定錯(cuò)誤;
  • 當(dāng)多個(gè)錯(cuò)誤被糅合到一起時(shí),只能用?error.Error()?返回的字符串匹配,看是否是某個(gè)錯(cuò)誤。

在復(fù)雜程序中,有用的錯(cuò)誤需要包含調(diào)用鏈的信息。例如,考慮一次數(shù)據(jù)庫(kù)寫(xiě),可能調(diào)用了 RPC,RPC 調(diào)用了域名解析,最終是沒(méi)有權(quán)限讀?/etc/resolve.conf?文件,那么給出下面的調(diào)用鏈會(huì)非常有用:

write users database: call myserver.Method: \dial myserver:3333: open /etc/resolv.conf: permission denied

Errors Solutions

由于 Go1 的錯(cuò)誤值沒(méi)有完整的解決這個(gè)問(wèn)題,才導(dǎo)致出現(xiàn)非常多的錯(cuò)誤處理的庫(kù),比如:

  • 2017 年 12 月, upspin.io/errors,帶邏輯調(diào)用堆棧的錯(cuò)誤庫(kù),而不是執(zhí)行的堆棧,引入了?errors.Is、errors.As?和?errors.Match;
  • 2015 年 12 月, github.com/pkg/errors,帶堆棧的錯(cuò)誤,引入了?% v?來(lái)格式化錯(cuò)誤的額外信息比如堆棧;
  • 2014 年 10 月, github.com/hashicorp/errwrap,可以 wrap 多個(gè)錯(cuò)誤,引入了錯(cuò)誤樹(shù),提供 Walk 函數(shù)遍歷所有的錯(cuò)誤;
  • 2014 年 2 月, github.com/juju/errgo,Wrap 時(shí)可以選擇是否隱藏底層錯(cuò)誤。和?pkg/errors?的 Cause 返回最底層的錯(cuò)誤不同,它只反饋錯(cuò)誤鏈的下一個(gè)錯(cuò)誤;
  • 2013 年 7 月, github.com/spacemonkeygo/errors,是來(lái)源于一個(gè)大型 Python 項(xiàng)目,有錯(cuò)誤的 hierarchies,自動(dòng)記錄日志和堆棧,還可以帶額外的信息。打印錯(cuò)誤的消息比較固定,不能自己定義;
  • 2019 年 9 月,Go1.13?標(biāo)準(zhǔn)庫(kù)擴(kuò)展了 error,支持了 Unwrap、As 和 Is,但沒(méi)有支持堆棧信息。

Go1.13 改進(jìn)了 errors,參考如下實(shí)例代碼:

package mainimport ("errors""fmt""io" )func foo() error {return fmt.Errorf("read err: %w", io.EOF) }func bar() error {if err := foo(); err != nil {return fmt.Errorf("foo err: %w", err)}return nil }func main() {if err := bar(); err != nil {fmt.Printf("err: % v\n", err)fmt.Printf("unwrap: % v\n", errors.Unwrap(err))fmt.Printf("unwrap of unwrap: % v\n", errors.Unwrap(errors.Unwrap(err)))fmt.Printf("err is io.EOF? %v\n", errors.Is(err, io.EOF))} }

運(yùn)行結(jié)果如下:

err: foo err: read err: EOF unwrap: read err: EOF unwrap of unwrap: EOF err is io.EOF? true

從上面的例子可以看出:

  • 沒(méi)有堆棧信息,主要是想通過(guò) Wrap 的日志來(lái)標(biāo)識(shí)堆棧,如果全部 Wrap 一層和堆棧差不多,不過(guò)對(duì)于沒(méi)有 Wrap 的錯(cuò)誤還是無(wú)法知道調(diào)用堆棧;
  • Unwrap 只會(huì)展開(kāi)第一個(gè)嵌套的 error,如果錯(cuò)誤有多層嵌套,取不到最里面的那個(gè) error,需要多次 Unwrap 才行;
  • 用?errors.Is?能判斷出是否是最里面的那個(gè) error。

另外,錯(cuò)誤處理往往和 log 是容易混為一談的,因?yàn)橛龅藉e(cuò)誤一般會(huì)打日志,特別是在 C/C 中返回錯(cuò)誤碼一般都會(huì)打日志記錄下,有時(shí)候還會(huì)記錄一個(gè)全局的錯(cuò)誤碼比如 linux 的 errno,而這種習(xí)慣,導(dǎo)致 error 和 log 混淆造成比較大的困擾。考慮以前寫(xiě)了一個(gè) C 的服務(wù)器,出現(xiàn)錯(cuò)誤時(shí)會(huì)在每一層打印日志,所以就會(huì)形成堆棧式的錯(cuò)誤日志,便于排查問(wèn)題,如果只有一個(gè)錯(cuò)誤,不知道調(diào)用上下文,排查會(huì)很困難:

avc decode avc_packet_type failed. ret=3001 Codec parse video failed, ret=3001 origin hub error, ret=3001

這種比只打印一條日志?origin hub error, ret=3001?要好,但是還不夠好:

  • 和 Go 的錯(cuò)誤一樣,比較啰嗦,有重復(fù)的信息。如果能提供堆棧信息,可以省去很多需要手動(dòng)寫(xiě)的信息;
  • 對(duì)于應(yīng)用程序可以打日志,但是對(duì)于庫(kù),信息都應(yīng)該包含在 error 中,不應(yīng)該直接打印日志。如果底層的庫(kù)都要打印日志,將會(huì)導(dǎo)致底層庫(kù)都要依賴日志庫(kù),這時(shí)很多庫(kù)都有日志打印函數(shù)供調(diào)用者重寫(xiě);
  • 對(duì)于多線程,看不到線程信息,或者看不到業(yè)務(wù)層 ID 的信息。對(duì)于服務(wù)器來(lái)說(shuō),有時(shí)候需要知道這個(gè)錯(cuò)誤是哪個(gè)連接的,從而查詢這個(gè)連接之前的上下文信息。

改進(jìn)后的錯(cuò)誤日志變成了在底層返回,而不在底層打印在調(diào)用層打印,有調(diào)用鏈和堆棧,有線程切換的 ID 信息,也有文件的行數(shù):

Error processing video, code=3001 : origin hub : codec parser : avc decoder [100] video_avc_demux() at [srs_kernel_codec.cpp:676] [100] on_video() at [srs_app_source.cpp:1076] [101] on_video_imp() at [srs_app_source:2357]

從 Go2 的描述來(lái)說(shuō),實(shí)際上這個(gè)錯(cuò)誤處理也還沒(méi)有考慮完備。從實(shí)際開(kāi)發(fā)來(lái)說(shuō),已經(jīng)比較實(shí)用了。

總結(jié)下 Go 的 error,錯(cuò)誤處理應(yīng)該注意以下幾點(diǎn):

  • 凡是有返回錯(cuò)誤碼的函數(shù),必須顯式的處理錯(cuò)誤,如果要忽略錯(cuò)誤,也應(yīng)該顯式的忽略和寫(xiě)注釋;
  • 錯(cuò)誤必須帶豐富的錯(cuò)誤信息,比如堆棧、發(fā)生錯(cuò)誤時(shí)的參數(shù)、調(diào)用鏈給的描述等等。特別要強(qiáng)調(diào)變量,我看過(guò)太多日志描述了一對(duì)常量,比如 “Verify the nonce, timestamp and token of specified appid failed”,而這個(gè)消息一般會(huì)提到工單中,然后就是再問(wèn)用戶,哪個(gè) session 或 request 甚至?xí)r間點(diǎn)?這么一大堆常量有啥用呢,關(guān)鍵是變量吶;
  • 盡量避免重復(fù)的信息,提高錯(cuò)誤處理的開(kāi)發(fā)體驗(yàn),糟糕的體驗(yàn)會(huì)導(dǎo)致無(wú)效的錯(cuò)誤處理代碼,比如拷貝和漏掉關(guān)鍵信息;
  • 分離錯(cuò)誤和日志,發(fā)生錯(cuò)誤時(shí)返回帶完整信息的錯(cuò)誤,在調(diào)用的頂層決定是將錯(cuò)誤用日志打印,還是發(fā)送到監(jiān)控系統(tǒng),還是轉(zhuǎn)換錯(cuò)誤,或者忽略。

Best Practice

推薦用?github.com/pkg/errors?這個(gè)錯(cuò)誤處理的庫(kù),基本上是夠用的,參考 Refine: CopyFile,可以看到 CopyFile 中低級(jí)重復(fù)的代碼已經(jīng)比較少了:

package mainimport ("fmt""github.com/pkg/errors""io""os" )func CopyFile(src, dst string) error {r, err := os.Open(src)if err != nil {return errors.Wrap(err, "open source")}defer r.Close()w, err := os.Create(dst)if err != nil {return errors.Wrap(err, "create dest")}nn, err := io.Copy(w, r)if err != nil {w.Close()os.Remove(dst)return errors.Wrap(err, "copy body")}if err := w.Close(); err != nil {os.Remove(dst)return errors.Wrapf(err, "close dest, nn=%v", nn)}return nil }func LoadSystem() error {src, dst := "src.txt", "dst.txt"if err := CopyFile(src, dst); err != nil {return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))}// Do other jobs.return nil }func main() {if err := LoadSystem(); err != nil {fmt.Printf("err % v\n", err)} }

改寫(xiě)的函數(shù)中,用?errors.Wrap?和?errors.Wrapf?代替了?fmt.Errorf,我們不記錄 src 和 dst 的值,因?yàn)樵谏蠈訒?huì)記錄這個(gè)值(參考下面的代碼),而只記錄我們這個(gè)函數(shù)產(chǎn)生的數(shù)據(jù),比如?nn。

import "github.com/pkg/errors"func LoadSystem() error {src, dst := "src.txt", "dst.txt"if err := CopyFile(src, dst); err != nil {return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))}// Do other jobs.return nil }

在這個(gè)上層函數(shù)中,我們用的是?errors.WithMessage?添加了這一層的錯(cuò)誤信息,包括?src?和?dst,所以?CopyFile?里面就不用重復(fù)記錄這兩個(gè)數(shù)據(jù)了。同時(shí)我們也沒(méi)有打印日志,只是返回了帶完整信息的錯(cuò)誤。

func main() {if err := LoadSystem(); err != nil {fmt.Printf("err % v\n", err)} }

在頂層調(diào)用時(shí),我們拿到錯(cuò)誤,可以決定是打印還是忽略還是送監(jiān)控系統(tǒng)。

比如我們?cè)谡{(diào)用層打印錯(cuò)誤,使用?% v?打印詳細(xì)的錯(cuò)誤,有完整的信息:

err open src.txt: no such file or directory open source main.CopyFile/Users/winlin/t.go:13 main.LoadSystem/Users/winlin/t.go:39 main.main/Users/winlin/t.go:49 runtime.main/usr/local/Cellar/go/1.8.3/libexec/src/runtime/proc.go:185 runtime.goexit/usr/local/Cellar/go/1.8.3/libexec/src/runtime/asm_amd64.s:2197 load src=src.txt, dst=dst.txt

但是這個(gè)庫(kù)也有些小毛病:

  • CopyFile?中還是有可能會(huì)有重復(fù)的信息,還是 Go2 的?handle?和?check?方案是最終解決;
  • 有時(shí)候需要用戶調(diào)用?Wrap,有時(shí)候是調(diào)用?WithMessage(不需要加堆棧時(shí)),這個(gè)真是非常不好用的地方(這個(gè)我們已經(jīng)修改了庫(kù),可以全部使用 Wrap 不用 WithMessage,會(huì)去掉重復(fù)的堆棧)。
  • Logger

    一直在碼代碼,對(duì)日志的理解總是不斷在變,大致分為幾個(gè)階段:

    • 日志是給人看的,是用來(lái)查問(wèn)題的。出現(xiàn)問(wèn)題后根據(jù)某些條件,去查不同進(jìn)程或服務(wù)的日志。日志的關(guān)鍵是不能漏掉信息,漏了關(guān)鍵日志,可能就斷了關(guān)鍵的線索;

    • 日志必須要被關(guān)聯(lián)起來(lái),上下文的日志比單個(gè)日志更重要。長(zhǎng)連接需要根據(jù)會(huì)話關(guān)聯(lián)日志;不同業(yè)務(wù)模型有不同的上下文,比如服務(wù)器管理把服務(wù)器作為關(guān)鍵信息,查詢這個(gè)服務(wù)器的相關(guān)日志;全鏈路跨機(jī)器和服務(wù)的日志跟蹤,需要定義可追蹤的邏輯 ID;

    • 海量日志是給機(jī)器看的,是結(jié)構(gòu)化的,能主動(dòng)報(bào)告問(wèn)題,能從日志中分析潛在的問(wèn)題。日志的關(guān)鍵是要被不同消費(fèi)者消費(fèi),要輸出不同主題的日志,不同的粒度的日志。日志可以用于排查問(wèn)題,可以用于告警,可以用于分析業(yè)務(wù)情況。

    Note: 推薦閱讀 Kafka 對(duì)于 Log 的定義,廣義日志是可以理解的消息,The Log: What every software engineer should know about real-time data’s unifying abstraction。

    完善信息查問(wèn)題

    考慮一個(gè)服務(wù),處理不同的連接請(qǐng)求:

    package mainimport ("context""fmt""log""math/rand""os""time" )type Connection struct {url stringlogger *log.Logger }func (v *Connection) Process(ctx context.Context) {go checkRequest(ctx, v.url)duration := time.Duration(rand.Int()00) * time.Millisecondtime.Sleep(duration)v.logger.Println("Process connection ok") }func checkRequest(ctx context.Context, url string) {duration := time.Duration(rand.Int()00) * time.Millisecondtime.Sleep(duration)logger.Println("Check request ok") }var logger *log.Loggerfunc main() {ctx := context.Background()rand.Seed(time.Now().UnixNano())logger = log.New(os.Stdout, "", log.LstdFlags)for i := 0; i < 5; i {go func(url string) {connecton := &Connection{}connecton.url = urlconnecton.logger = loggerconnecton.Process(ctx)}(fmt.Sprintf("url #%v", i))}time.Sleep(3 * time.Second) }

    這個(gè)日志的主要問(wèn)題,就是有了和沒(méi)有差不多,啥也看不出來(lái),常量太多變量太少,缺失了太多的信息。看起來(lái)這是個(gè)簡(jiǎn)單問(wèn)題,卻經(jīng)常容易犯這種問(wèn)題,需要我們?cè)诖蛴∶總€(gè)日志時(shí),需要思考這個(gè)日志比較完善的信息是什么。上面程序輸出的日志如下:

    2019/11/21 17:08:04 Check request ok 2019/11/21 17:08:04 Check request ok 2019/11/21 17:08:04 Check request ok 2019/11/21 17:08:04 Process connection ok 2019/11/21 17:08:05 Process connection ok 2019/11/21 17:08:05 Check request ok 2019/11/21 17:08:05 Process connection ok 2019/11/21 17:08:05 Check request ok 2019/11/21 17:08:05 Process connection ok 2019/11/21 17:08:05 Process connection ok

    如果完善下上下文信息,代碼可以改成這樣:

    type Connection struct {url stringlogger *log.Logger }func (v *Connection) Process(ctx context.Context) {go checkRequest(ctx, v.url)duration := time.Duration(rand.Int()00) * time.Millisecondtime.Sleep(duration)v.logger.Println(fmt.Sprintf("Process connection ok, url=%v, duration=%v", v.url, duration)) }func checkRequest(ctx context.Context, url string) {duration := time.Duration(rand.Int()00) * time.Millisecondtime.Sleep(duration)logger.Println(fmt.Sprintf("Check request ok, url=%v, duration=%v", url, duration)) }

    輸出的日志如下:

    2019/11/21 17:11:35 Check request ok, url=url #3, duration=32ms 2019/11/21 17:11:35 Check request ok, url=url #0, duration=226ms 2019/11/21 17:11:35 Process connection ok, url=url #0, duration=255ms 2019/11/21 17:11:35 Check request ok, url=url #4, duration=396ms 2019/11/21 17:11:35 Check request ok, url=url #2, duration=449ms 2019/11/21 17:11:35 Process connection ok, url=url #2, duration=780ms 2019/11/21 17:11:35 Check request ok, url=url #1, duration=1.01s 2019/11/21 17:11:36 Process connection ok, url=url #4, duration=1.099s 2019/11/21 17:11:36 Process connection ok, url=url #3, duration=1.207s 2019/11/21 17:11:36 Process connection ok, url=url #1, duration=1.257s

    上下文關(guān)聯(lián)

    完善日志信息后,對(duì)于服務(wù)器特有的一個(gè)問(wèn)題,就是如何關(guān)聯(lián)上下文,常見(jiàn)的上下文包括:

    • 如果是短連接,一條日志就能描述,那可能要將多個(gè)服務(wù)的日志關(guān)聯(lián)起來(lái),將全鏈路的日志作為上下文;

    • 如果是長(zhǎng)連接,一般長(zhǎng)連接一定會(huì)有定時(shí)信息,比如每隔 5 秒輸出這個(gè)鏈接的碼率和包數(shù),這樣每個(gè)鏈接就無(wú)法使用一條日志描述了,鏈接本身就是一個(gè)上下文;

    • 進(jìn)程內(nèi)的邏輯上下文,比如代理的上下游就是一個(gè)上下文,合并回源,故障上下文,客戶端重試等。

    以上面的代碼為例,可以用請(qǐng)求 URL 來(lái)作為上下文。

    package mainimport ("context""fmt""log""math/rand""os""time" )type Connection struct {url stringlogger *log.Logger }func (v *Connection) Process(ctx context.Context) {go checkRequest(ctx, v.url)duration := time.Duration(rand.Int()00) * time.Millisecondtime.Sleep(duration)v.logger.Println(fmt.Sprintf("Process connection ok, duration=%v", duration)) }func checkRequest(ctx context.Context, url string) {duration := time.Duration(rand.Int()00) * time.Millisecondtime.Sleep(duration)logger.Println(fmt.Sprintf("Check request ok, url=%v, duration=%v", url, duration)) }var logger *log.Loggerfunc main() {ctx := context.Background()rand.Seed(time.Now().UnixNano())logger = log.New(os.Stdout, "", log.LstdFlags)for i := 0; i < 5; i {go func(url string) {connecton := &Connection{}connecton.url = urlconnecton.logger = log.New(os.Stdout, fmt.Sprintf("[CONN %v] ", url), log.LstdFlags)connecton.Process(ctx)}(fmt.Sprintf("url #%v", i))}time.Sleep(3 * time.Second) }

    運(yùn)行結(jié)果如下所示:

    [CONN url #2] 2019/11/21 17:19:28 Process connection ok, duration=39ms 2019/11/21 17:19:28 Check request ok, url=url #0, duration=149ms 2019/11/21 17:19:28 Check request ok, url=url #1, duration=255ms [CONN url #3] 2019/11/21 17:19:28 Process connection ok, duration=409ms 2019/11/21 17:19:28 Check request ok, url=url #2, duration=408ms [CONN url #1] 2019/11/21 17:19:29 Process connection ok, duration=594ms 2019/11/21 17:19:29 Check request ok, url=url #4, duration=615ms [CONN url #0] 2019/11/21 17:19:29 Process connection ok, duration=727ms 2019/11/21 17:19:29 Check request ok, url=url #3, duration=1.105s [CONN url #4] 2019/11/21 17:19:29 Process connection ok, duration=1.289s

    如果需要查連接 2 的日志,可以 grep 這個(gè)?url #2?關(guān)鍵字:

    Mac:gogogo chengli.ycl$ grep 'url #2' t.log [CONN url #2] 2019/11/21 17:21:43 Process connection ok, duration=682ms 2019/11/21 17:21:43 Check request ok, url=url #2, duration=998ms

    然鵝,還是發(fā)現(xiàn)有不少問(wèn)題:

    • 如何實(shí)現(xiàn)隱式標(biāo)識(shí),調(diào)用時(shí)如何簡(jiǎn)單些,不用沒(méi)打一條日志都需要傳一堆參數(shù)?
    • 一般 logger 是公共函數(shù)(或者是每個(gè)類(lèi)一個(gè) logger),而上下文的生命周期會(huì)比 logger 長(zhǎng),比如 checkRequest 是個(gè)全局函數(shù),標(biāo)識(shí)信息必須依靠人打印,這往往是不可行的;
    • 如何實(shí)現(xiàn)日志的 logrotate(切割和輪轉(zhuǎn)),如何收集多個(gè)服務(wù)器日志。

    解決辦法包括:

    • 用?Context?的 WithValue 來(lái)將上下文相關(guān)的 ID 保存,在打印日志時(shí)將 ID 取出來(lái);
    • 如果有業(yè)務(wù)特征,比如可以取 SessionID 的 hash 的前 8 個(gè)字符形成 ID,雖然容易碰撞,但是在一定范圍內(nèi)不容易碰撞;
    • 可以變成 json 格式的日志,這樣可以將 level、id、tag、file、err 都變成可以程序分析的數(shù)據(jù),送到 SLS 中處理;
    • 對(duì)于切割和輪轉(zhuǎn),推薦使用?lumberjack?這個(gè)庫(kù),程序的 logger 只要提供?SetOutput(io.Writer)?將日志送給它處理就可以了。

    當(dāng)然,這要求函數(shù)傳參時(shí)需要帶 context.Context,一般在自己的應(yīng)用程序中可以要求這么做,凡是打日志的地方要帶 context。對(duì)于庫(kù),一般可以不打日志,而返回帶堆棧的復(fù)雜錯(cuò)誤的方式,參考?Errors?錯(cuò)誤處理部分。

    點(diǎn)擊下載《不一樣的 雙11 技術(shù):阿里巴巴經(jīng)濟(jì)體云原生實(shí)踐》

    本書(shū)亮點(diǎn)

    • 雙11 超大規(guī)模 K8s 集群實(shí)踐中,遇到的問(wèn)題及解決方法詳述
    • 云原生化最佳組合:Kubernetes 容器 神龍,實(shí)現(xiàn)核心系統(tǒng) 100% 上云的技術(shù)細(xì)節(jié)
    • 雙 11 Service Mesh 超大規(guī)模落地解決方案

    “阿里巴巴云原生關(guān)注微服務(wù)、Serverless、容器、Service Mesh 等技術(shù)領(lǐng)域、聚焦云原生流行技術(shù)趨勢(shì)、云原生大規(guī)模的落地實(shí)踐,做最懂云原生開(kāi)發(fā)者的技術(shù)圈。”

    總結(jié)

    以上是生活随笔為你收集整理的Go 开发关键技术指南 | Go 面向失败编程 (内含超全知识大图)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

    如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。