Go编程基础-5. 函数

64

函数

函数是包含连续执行语句的代码块,可以通过在代码中调用函数来执行它们。函数可以将一个复杂的工作划分成多个更小的模块,使得多人协作变得更加容易。另外,函数对它的使用者隐藏了实现细节。这几方面的特性使得函数成为大多数编程语言的重要特性之一。

1. 函数声明

每个函数声明都包含一个名字、一个形参列表、一个返回列表以及函数体:

func name(parameter-list) result-list {
    body
}

其中,形参和返回值都可以命名。命名后的形参和返回值会被声明为局部变量,并初始化为相应类型的零值。注意:只要有返回值就需要显示return, 只对返回值变量进行赋值是无效的,返回值变量本身只是一个局部变量。

// 函数声明(参数列表)的简写方式
func f(i, j, k int, s, t string) {}

在Go语言的官方package实现中,经常能看到一些函数声明没有函数体 - 这是因为使用了第三方语言实现(例如C++),因此只定义了该函数的签名。例如:

package math

func Sin(x float64) float64 // 使用汇编语言实现

2. 递归

函数可以递归调用=直接或者间接地调用自己,来处理一些带有递归特性的数据结构,例如链表or树。一个简单的例子,通过递归处理htmlDom树,从而快速找到目标节点:

package main

import (
	"fmt"
	"os"

	"golang.org/x/net/html"
)

func main() {
	doc, err := html.Parse(os.Stdin)
	if err != nil {
		fmt.Fprintf(os.Stderr, "findlinks: %v\n", err)
		os.Exit(1)
	}
	fmt.Println("Result:")
	link := make([]string, 0, 10)
	for _, link := range visit(link, doc) {
		fmt.Println(link)
	}
}

func visit(links []string, n *html.Node) []string {
	// 1. operation for current node (树形dp递归,如果当前节点是元素节点<div>\<p>\<a>\<img>等进行处理)
	if n.Type == html.ElementNode && n.Data == "a" {
		for _, a := range n.Attr {
			if a.Key == "href" {
				links = append(links, a.Val)
			}
		}
	}

	// 2. iteration (如果是其余节点,向下进行递归;由于是多叉树结构,先向下找到firstChild,再水平遍历其兄弟节点NextSiBling)
	if n.FirstChild != nil {
		links = visit(links, n.FirstChild)
	}
	if n.NextSibling != nil {
		links = visit(links, n.NextSibling)
	}
	// 循环方式:
	// for c := n.FirstChild; c != nil; c = c.NextSibling {
	// 	links = visit(links, c)
	// }
	return links
}

使用管道,将之前已经编译好的fetch程序输出定向到findlinks,然后对爬取到的页面内容进行查找和索引,执行命令如下:

./fetch https://www.baidu.com | ./findlinks

3. 多返回值

一个函数能返回不止一个结果,例如标准包的许多函数都会返回两个值,一个期望得到的执行结果与一个错误值or布尔值。下为一个简单的示例:

package main

import (
	"fmt"
	"net/http"
	"os"

	"golang.org/x/net/html"
)

func main() {
	for _, url := range os.Args[1:] {
		links, err := findLinks(url)
		if err != nil {
			fmt.Fprintf(os.Stderr, "findlinks2: %v\n", err)
		}
		for _, link := range links {
			fmt.Println(link)
		}
	}
}

func findLinks(url string) ([]string, error) {
	r, err := http.Get(url)
	// 1. 是否程序执行异常
	if err != nil {
		return nil, err
	}
	// 2. 是否成功获取内容 -> http.StatusOK
	if r.StatusCode != http.StatusOK {
		r.Body.Close()
		return nil, fmt.Errorf("getting %s: %s", url, r.Status)
	}
	// 3. 获取成功,进行解析
	doc, err := html.Parse(r.Body)
	r.Body.Close()
	if err != nil {
		return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
	}
	return visit(nil, doc), nil
}

func visit(links []string, n *html.Node) []string {
	// 1. operation for current node (树形dp递归,如果当前节点是元素节点<div>\<p>\<a>\<img>等进行处理)
	if n.Type == html.ElementNode && n.Data == "a" {
		for _, a := range n.Attr {
			if a.Key == "href" {
				links = append(links, a.Val)
			}
		}
	}

	// 2. iteration (如果是其余节点,向下进行递归;由于是多叉树结构,先向下找到firstChild,再水平遍历其兄弟节点NextSiBling)
	if n.FirstChild != nil {
		links = visit(links, n.FirstChild)
	}
	if n.NextSibling != nil {
		links = visit(links, n.NextSibling)
	}
	// 循环方式:
	// for c := n.FirstChild; c != nil; c = c.NextSibling {
	// 	links = visit(links, c)
	// }
	return links
}

良好的命名可以使得多返回值更加有意义,尤其是多个结果类型相同时,名字的选择更加重要,例如:

func Size(rect image.Rectangle) (width, height int) {}
func Split(path string) (dir, file string) {}

为返回值命名,相当于创建了一个对应的临时变量。我们可以在函数中对其进行操作和赋值,然后将其返回。注意:如果想要返回临时变量中的内容,直接return(裸返回,后面不接参数)即可,其直接等价于按顺序返回对应临时变量中的内容,示例如下:

package main

import "fmt"

func NamedReturn() (length int, width int) {
	length = 10
	width = 5
    // 等价于 return width, height
	return
}

func main() {
	fmt.Println(NamedReturn())
}

但是,如果我们在return后添加了参数,那么还是优先返回对应指定内容,这点需要小心。

最佳实践:

  1. 当返回值变量数量较少,且类型各不相同时,我们可以直接写类型
  2. 当返回值变量数量较多,且类型存在相同时,推荐为返回值变量命名,这样代码可读性更强
    注意:尽管Go语言提供了多参数+多返回值的机制,但是函数的输入输出仍然应该足够简洁。层与层之间的参数类型传递要做好封装和隔离,避免因为接口的实现发生变化,而导致原有功能的异常。通常会将所有返回内容封装到一个result对象中,再根据需要从中提取部分信息,这样即使新增或减少字段也不会影响接口的定义和原有功能的可用性。

4. 错误

在程序设计的过程中,有些因素并不受编程者的掌控,例如任何I/O操作都一定会面对可能的错误,即使只是一个简单的读或写操作。事实上,这些地方正是我们最需要关注的,**很多可靠的操作都可能会毫无征兆地发生错误。**因此错误处理是API设计的重要部分

  1. 当函数调用发生异常时,习惯上会返回一个附加的结果作为错误值(即:最后一个返回值)。如果错误只有一种情况,那么通常设置为布尔类型,例如查询;如果存在多种错误原因,需要一些更加详细的信息进行区分,那么为error类型。
value, ok := cache.Lookup(key)
if != ok {
    // ... cache[key]不存在
}
  1. 与其它语言不同,Go语言使用普通的值类型error而非与Java类似的Exception机制来报告错误。尽管Go语言有异常机制,但是这种异常只是针对程序bug导致的预料外错误所使用的,而不能作为常规的错误处理方法出现在程序中。因为这类异常往往会带有大量难以理解的栈跟踪信息,并需要额外的错误消息控制流进行处理,增加程序的复杂度。
  2. 相较之下,Go程序使用通常的控制流机制(比如if和return语句)来应对可预料的错误,将错误处理作为程序设计的一种常态,是代码逻辑、程序功能中一部分。因此,在实现错误处理逻辑时要更加小心谨慎,但设计出的程序的鲁棒性会更强。

PS: Java的Exception机制产生了两种编程风格:

  1. 在每一个可能报错的地方try catch, 然后增加控制流进行处理,与Go类似但是不够简洁;
  2. 将Exception层层上抛,在顶层统一进行捕获和处理;这种情况下,程序需要能够做到严格幂等,保证多次重试依然能够达到唯一的目标状态;(在阿里实习的项目中用的就是这种方式,代码更加简洁,但是在程序的控制流特别多且特别长时,复杂度会大幅增加,需要在每一个环节加唯一性id和判断避免重复执行)
    Go语言的错误处理类似于第一种,但是做的更加彻底,直接将error作为一种需要处理的常规类型,融入到正常程序的控制流中。编程者必须设想程序存在哪些异常情况,并增加控制流进行处理,保证了设计出来的程序的鲁棒性。

4.1 错误处理机制

1. 将错误向上抛出,告知调用者:

// 1. 无需处理
resp, err := http.Get(url)
if err != nil {
    return nil, err
}

// 2. 部分处理
doc, err := html.Parse(resp.Body)
resp.Body.Close() // 必须处理的部分,将File关闭
if err != nil {
    return nil, fmt.Errorf("parsing %s as HTML: %v", url, err) // 可选部分,增加额外的错误提示信息,用来标记错误原因和错误位置
}

PS:fmt.Errorf 使用 fmt.Sprintf函数格式化一条错误信息,并返回一个性的错误值。一般通过不断添加上下文信息,来建立一个可读的错误描述,帮助错误分析和快速定位。

注意:设计错误消息时,需要慎重,确保每一个描述都是有意义的,包含充足的相关信息 - 例如 错误函数名、唯一性id、错误原因、错误时间等等;同时需要保证一个包下的错误消息保持统一的形式和错误处理方式

2. 重试,超出一定次数或时间后再报错退出

func WaitForServer(url string) error {
    const timeout = 1 * time.Minute
    deadline := time.Now().Add(timeout)
    for tries := 0; time.Now().Before(deadline); tries++ {
        _, err := http.Head(url)
        if err == nil {
            return nil // 成功
        }
        log.Printf("server not responding (%s); retrying...", err)
        time.Sleep(time.Second << unit(tries)) // 指数退避策略
    }
    return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}

3. 输出错误并终止程序,这种操作一般只在主程序进行,库函数应当将错误传递给调用者 - 除非库内部存在bug

// (In function main.)
if err := WaitForServer(url); err != nil {
    fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
    os.Exit(1)
}

一个更加方便的方法是通过调用log.Fatalf实现相同的效果,同时还能够自动添加时间和日期前缀,有助于长期运行的服务器:

if err := WaitForServer(url); err != nil {
    log.Fatalf("Site is down: %v\n", err)
}

部分情况下,我们可能会自定义例子的前缀,同时将日期和时间略去:

log.SetPrefix("wait: ")
log.SetFlags(0)

4. 只记录错误信息,然后程序继续运行

if err := Ping(); err != nil {
    log.Printf("ping failed: %v; networking disabled", err)
}

5. 极少数情况下,可以直接忽略错误,例如对linux临时目录的操作,因为会定时清空,就可能存在错误,如果这些错误无关紧要,就可以忽略

dir, err := ioutil.TempDir("", "scratch")
if err != nil {
    return fmt.Errorf(failed to create temp dir: %v", err)
}
// ...使用临时目录...
os.RemoveAll(dir) // 忽略错误, 临时目录会被定时删除

PS:要习惯性地考虑每一个函数调用可能发生地出错情况,如果有意地忽略了错误,需要注明意图!!!

4.2 文件结束标识

通常情况下,我们需要针对错误的情况做不同的处理,有些错误可能被赋予了特殊的含义。例如:当我们从文件中读取数据时,可能存在两种错误情况,1.操作失败,2.文件结束。调用者必须把读取到文件尾的情况,区别于其它错误的操作。io包针对这一问题引入了一个特殊的错误类型:io.EOF

// 1. 定义
package iio

import "errors"

var EOF = errors.New("EOF")
// 2. 使用
in := bufio.NewReader(os.Stdin)
for {
	r, _, err := in.ReadRune()
	if err == io.EOF {
		break  // 文件结束,正常退出
	}
	if err != nil {
		return fmt.Errorf("read rune failed: %v", err) // 读取失败,异常退出 
	}
}

5. 函数变量

函数变量是Go语言中一个重要的概念,函数变量可以像变量一样被赋值,并且可以作为函数的参数传递。函数变量可以用于实现一些高级功能,例如函数式编程、闭包等。例如:

func square(n int) int {return n * n}
func negative(n int) int {return -n}
func product(m, n int) int {return m * n}

f := squre               // f是一个函数变量,指向函数square
fmt.Println(f(3))        // 1. 可以正常通过函数变量执行计算操作
fmt.Printf("T\n", f)     // 2. 函数变量的类型由其参数+返回值组成 func(int) int

f = negative             // 3. 对于同类型的函数,可以通过函数变量进行赋值、传递
f = product              // 4. 编译报错:不同类型的函数,无法赋值  ------- product -> func(int, int) int 和变量类型并不匹配

var g func(int) int      // 5. 函数变量的零值为 nil
g(3) 					 // 6. 调用零值函数,会宕机(运行时panic: runtime error: invalid memory address or nil pointer dereference)
if g != nil {            // -- 需要格外小心,因为编译器是无法检测出这种错误的
    g(3)                 // 7. 可以将函数变量和nil(空值)进行对比判断,然后再进行调用或者异常处理
}

### 5.1 函数变量的应用
函数变量的出现使得将函数行为当作参数进行传递成为了可能,我们可以动态决定对数据的操作行为,实现更加灵活的代码逻辑。例如:

// 1. 标准库中的strings.Map函数,可以将字符串中的每个字符都进行映射处理
func add(r rune) run {
	return r + 1
}

func main() {
	fmt.Println(strings.Map(add, "Hello, world!"))
}

此外,例如我们在对树进行遍历时(例如一个HTML DOM树),我们可以通过函数变量的形式,将遍历逻辑和操作逻辑进行分离。这样就可以实现对遍历函数的复用,极大简化代码的编写。例如:

func forEachNode(n *html.Node, pre, post func(n *html.Node)) {
	// 预处理
	if pre != nil {
		pre(n)
	}
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		forEachNode(c, pre, post)
	}
	// 后处理
	if post != nil {
		post(n)
	}
}

6. 匿名函数

命名函数只能在包级别的作用域中声明,但是我们能够使用**函数字面量(匿名函数)**在任何表达式内为函数变量赋值。函数字面量是一个函数定义,它没有名称,并且可以在任何需要函数的地方定义和使用。例如:

strings.Map(func(r rune) rune { return r + 1}, "Hello, world!")

更重要的是,匿名函数可以获取到整个词法环境。匿名函数内部可以访问外部函数中的变量,示例如下:

package main

import "fmt"

func squares() func() int {
	var x int
	return func() int {
		x++
		return x * x
	}
}

func main() {
	f := squares()
	fmt.Println(f())
	fmt.Println(f())
	fmt.Println(f()) // 因为f一直保持着对x的引用,所以x的作用域、生命周期被延长了
}

使用函数变量+匿名函数结合的方式进行拓扑排序(注意,这只是一个简单的示例,真正的图中往往不止一个起点或者终点,因此会有多条不同的路径,需要进行判断):

package main

import (
	"fmt"
	"sort"
)

var prereqs = map[string][]string{
	"algorithms":            {"data structures"},
	"calculus":              {"linear algebra"},
	"compilers":             {"data structures", "formal languages", "computer organization"},
	"data structures":       {"discrete math"},
	"databases":             {"data structures"},
	"discrete math":         {"intro to programming"},
	"formal languages":      {"discrete math"},
	"networks":              {"operating systems"},
	"operating systems":     {"data structures", "computer organization"},
	"programming languages": {"data structures", "computer organization"},
}

func topoSort(m map[string][]string) []string {

	var order []string

	// 1. 对map进行拓扑遍历
	seen := make(map[string]bool)
	var visit func(items []string)
	visit = func(items []string) {
		for _, item := range items {
			if !seen[item] {
				seen[item] = true
				visit(m[item])
				order = append(order, item)
				fmt.Println(order)
			}
		}
	}

	// 2. 构建起始集合
	var keys []string
	for key := range m {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	visit(keys)
	return order

}

func main() {
	for i, course := range topoSort(prereqs) {
		fmt.Printf("%d:\t%s\n", i+1, course)
	}
}

6.1 捕获迭代变量

当我们在匿名函数中使用外部变量时,需要格外小心。因为外部变量随时可能被修改,尤其是在迭代中,示例如下:

func main() {
	// 1. 定义一组匿名函数,在所有操作执行完成后自动清理临时目录
	var rmdirs []func()
	for _, dir := range tempDirs() {
		tempdir := dir // ** 捕获迭代变量
		os.MkdirAll(tempdir, 0755)
		rmdirs = append(rmdirs, func(){
			os.RemoveAll(tempdir)
		})
	}

	// 2. 对文件进行处理

	// 3. 对文件进行清理
	for _, rmdir := range rmdirs {
		rmdir()
	}
}

可以看到,我们在迭代过程中使用匿名函数(对迭代变量进行操作)时,额外增加了一步赋值操作,创建了一个临时变量来暂存迭代变量的值。这是因为迭代变量在外部函数中是一直变化的,如果我们直接在匿名函数中用迭代变量来记录要清理的文件夹,那么所有rmdir函数最后保存的都是同一个变量,清理的都是同一个文件夹。这提醒我们:所有涉及推迟函数执行时机的方法(defer | 匿名函数),都需要注意迭代变量的捕获。

7. 变长函数

变长函数调用时可以有可变的参数个数,例如:fmt.Printf()。通常我们在参数列表的类型名称之前使用省略号“…”来声明一个变长函数,调用这个函数时可以传递任意个该类型的参数,示例如下:

func sum(vals ...int) int {
	total := 0
	for _, val := range vals {
		total += val
	}
	return total
}

在函数体内,变长参数相当于一个对应类型的slice。这个语法糖省略了我们定义、赋值、传递slice的过程。

8. 延迟函数调用

在语法上,一个defer语句就是一个普通的函数或方法调用,在调用之前加上关键字defer。与正常函数执行的区别在于,加上defer关键词后,实际的函数执行会被推迟到外部调用函数结束后(无论正常还是异常),从而确保资源一定会被释放。defer 语句没有使用次数的限制,执行时会按照调用 defer 语句的逆序进行。

8.1 使用defer进行资源的获取和释放

defer 语句经常用于成对的操作,比如打开和关闭、连接和断开、加锁和解锁。即使是在再复杂的控制流中,资源在任何情况下都能够得到正确释放。正确使用 defer 语句的时机是在成功获得资源之后。例如:

package main

import "net/http"

func title(url string) error {
	resp, err := http.Get(url)
	if err != nil {
		return nil
	}
	defer resp.Body.Close()  // 延迟释放资源

	// ... 对获取到的网页内容进行操作
}

同样的操作可以用于文件/网络连接/互斥锁等资源上. 注意:在Go语言中,defer语句指定的函数或方法会在包含它的函数向调用者返回之前执行。更准确地说,defer语句指定的函数调用会在包含函数的返回语句执行完毕后、实际向调用者返回执行结果之前执行。这意味着defer在return语句后处理变量和计算返回值之后,但在函数实际把控制权交回给调用者之前执行。因此,在defer中对返回值进行修改也会生效,例如:在defer中关闭文件句柄,将错误信息赋值给err变量(在return中返回)。

8.2 使用defer进行函数调试

8.2.1 在入口和出口执行调试操作

package main

import (
	"log"
	"time"
)

func slowOperation() {
	defer trace("slowOperation")() // 包含两个函数调用 1. trace本身, 2.trace返回的函数变量调用
	time.Sleep(2 * time.Second)
}

func trace(msg string) func() {    // trace本身会立刻被调用
	start := time.Now()
	log.Printf("enter %s", msg)
	return func() {                // 返回的函数会在slowOperation执行完成后才被调用
		log.Printf("exit %s (%s)", msg, time.Since(start))
	}
}

func main() {
	slowOperation()
}

defer只会延迟执行最外层的函数调用.因此我们可以利用这一点,在执行trace的同时,返回一个函数变量进行调用.这种情况下,trace会被立刻执行,返回的函数会被延迟调用,从而实现在函数的入口和出口执行不同的调试操作.

8.2.2 同时获取函数的参数和结果

package main

import "fmt"

func double(x int) (result int) {
	defer func() {
		fmt.Printf("double(%d) = %d\n", x, result)
	}()
	return x + x
}

func main() {
	double(3)
}

函数的返回结果会被赋值给命名结果变量, 而defer在return之后才被执行,因此可以在defer调用的匿名函数中通过result获取函数的执行结果并打印. 实现输入参数+输出结果的调试记录.

8.3 使用defer进行文件句柄关闭时的注意事项

很多情况下,我们可以直接使用defer对资源进行关闭,例如 defer resp.Body.Close()。但是有些情况下,关闭资源的同时函数会返回一些额外信息,例如文件操作过程中的异常。在NFS等文件系统中, 写错误往往不是立即返回而是推迟到文件关闭的时候。如果无法检查关闭操作的结果,就会导致一系列的数据丢失。这种情况下,我们需要使用匿名函数的形式对关闭操作进行封装,读取关闭操作的结果,并赋值给函数变量,然后由调用函数返回上级进行处理。举例如下:

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"path"
)

func fetch(url string) (filename string, n int64, err error) {
	resp, err := http.Get(url)
	if err != nil {
		return "", 0, err
	}
	defer resp.Body.Close()

	local := path.Base(resp.Request.URL.Path)
	if local == "/" {
		local = "index.html"
	}

	f, err := os.Create(local)
	if err != nil {
		return "", 0, err
	}
	defer func() {
		if closeErr := f.Close(); closeErr != nil && err != nil {
			err = closeErr
		}
	}()

	n, err = io.Copy(f, resp.Body)
	return local, n, err

}

func main() {
	filename, n, err := fetch("https://www.baidu.com/")
	if err != nil {
		fmt.Println(err)
	}
	fmt.Printf("%d bytes written to %s\n", n, filename)
}

9. 宕机

Go语言的类型系统会捕获许多编译时错误,但有些其他的错误(比如数组越界访问或者解引用空指针)都需要在运行时进行检查。当Go语言运行时检测到这些错误,它就会发生宕机。

  1. 一个典型的宕机发生时,正常的程序执行会终止,goroutine中的所有延迟函数会执行,然后程序会异常退出并留下一条日志消息。
  2. 日志消息包括宕机的值,这往往代表某种错误消息,每一个goroutine都会在宕机的时候显示一个函数调用的栈跟踪消息。通常可以借助这条日志消息来诊断问题的原因而不需要再一次运行该程序。
  3. 并不是所有宕机都是在运行时发生的。可以直接调用内置的宕机函数panic();内置的宕机函数可以接受任何值作为参数。如果碰到“不可能发生”的状况,宕机是最好的处理方式,比如语句执行到逻辑上不可能到达的地方时:
switch s := suit(drawCard()); s {
	case "Spades" :   // ...
	case "Hearts" :   // ...
	case "Diamonds" : // ...
	case "Clubs" :    // ...
	default:
		panic(fmt.Sprintf("invalid suit %q", s))	
}
  1. 尽管Go语言的宕机机制和其他语言的异常很相似,但宕机的使用场景不尽相同。由于宕机会引起程序异常退出,因此只有在发生严重的错误时才会使用宕机,比如遇到与预想的逻辑不一致的代码;用心的程序员会将所有可能会发生异常退出的情况考虑在内以证实bug的存在。强健的代码会优雅地处理“预期的”错误,比如错误的输人、配置或者I/O失败等;这时最好能够使用错误值来加以区分

9.1 宕机的使用场景

  1. 针对预想中不可能出现的场景:使用panic -> 前缀Must是这类函数一个通用的命名习
    惯,意味着输入必须合法
// 1. regexp.Compile -> 会对正则表达式的语法进行检查,返回对应类型的错误
re, err := Compile(expr)

// 2. regexp.MustCompile -> 当正则表达式出现异常时,触发panic
func MustCompile(expr string) *Regexp {
	re, err := Compile(expr)
	if err != nil {
		panic(err)
	}
	return re
}

/**
	1. 在测试环境下,如果需要对正则表达式进行调试和修改,那么一般使用 regexp.Compile,针对潜在的err情况进行分类处理; 
	2. 在生产环境下,如果明确使用了正确的正则表达式,例如硬编码的字面量,这种情况下每次Compile后都去检查err就显得有点多余和繁琐;
	3. 为此,Go官方引入了MustCompile,内部调用了Compile但不返回错误。如果Compile出现了异常,那么MustCompile会直接调用panic,使程序崩溃、宕机。**这是一种极端的错误处理方式,适用于那些“理论上不可能发生错误”的情况,因为正则表达式已经被验证过或者是固定不变的字面量。**
*/

9.2 通过runtime.Stack()获取栈信息(避免panic)

  1. 很多情况下,我们并不想要宕机,而只是想要函数的调用栈信息,这时候可以通过runtime.Stack()来获取
func SelfDiv(x int) {
	fmt.Printf("SelfDiv(%d)\n", x+0/x)
	defer fmt.Printf("defer %d\n", x)
	SelfDiv(x - 1)
}

func printStack() {
	var buf [4096]byte
	n := runtime.Stack(buf[:], false)
	_, err := os.Stdout.Write(buf[:n])
	if err != nil {
		return
	}
}

func SelfDivWithStack(x int) {
	defer printStack()
	SelfDiv(x)
}

10. 恢复

发生panic的直接结果通常是退出程序。但在生产环境下,直接退出程序会导致大范围的服务不可用,产生巨大的损失。因此,在多数情况下,我们会尝试去捕获这个panic行为,报告错误、服务降级、启动备用方案,关闭所有连接然后再尝试退出。(针对panic异常回复500:Internal Server Error等,针对正常请求继续维持服务)

针对这种需求,Go语言提供了内置的recovery函数,来终止当前的宕机状态并返回宕机的值。**函数不会从之前宕机的地方继续运行,而是正常返回。**需要注意的是,recover函数仅在defer延迟调用中有效,因为panic发生后执行完延迟调用后就会退出,在其它位置声明或者定义没有任何效果。

func RecoveryFromPanic() (err error) {
	defer func() {
		if p := recover(); p != nil {
			var stack [4096]byte
			n := runtime.Stack(stack[:], false)
			err = fmt.Errorf("500: Internal Server Error. Details: %v.\n%v", p, string(stack[:n]))
		}
	}()
	SelfDiv(3)
	return err
}

需要注意的是:

  1. recover()返回的只有宕机值,不包含栈信息,因此需要手动附加。
  2. 在恢复宕机,并记录日志时,需要有明确的格式;一方面便于报警平台捕获;另外一方面便于识别异常发生原因,进行bug定位和修复。
  3. 宕机恢复应该在同一个包中进行,尽量避免进行跨包的自动宕机恢复,因为这种情况下无法确定问题函数、问题数据所处的状态,如果继续使用可能进入非预期的函数运行状态、造成数据损坏等严重后果;
  4. 可以通过switch case,根据recover()返回的宕机值,对部分可以预料的宕机行为进行处理,例如服务降级、启用备用方案等等,但是其余更多不可预料的情形,建议通过panic继续向上抛出。

总而言之,对于宕机采用无差别的恢复措施是不可靠的。