08|自研or借力(上):集成Gin替换已有核心

思考并回答以下问题:

和Gin对比

Recovery的错误捕获

还记得在第四课的时候,我们实现了一个Recovery中间件么?放在框架middleware文件夹的recovery.go中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// recovery 机制,将协程中的函数异常进行捕获
func Recovery() framework.ControllerHandler {
// 使用函数回调
return func(c *framework.Context) error {
// 核心在增加这个 recover 机制,捕获 c.Next()出现的 panic
defer func() {
if err := recover(); err != nil {
c.Json(500, err)
}
}()
// 使用 next 执行具体的业务逻辑
c.Next()

return nil
}
}

这个中间件的作用是捕获协程中的函数异常。我们使用defer、recover函数,捕获了c.Next中抛出的异常,并且在HTTP请求中返回500内部错误的状态码。

乍看这段代码,是没有什么问题的。但是我们再仔细思考下是否有需要完善的细节?

首先是异常类型,我们原先认为,所有异常都可以通过状态码,直接将异常状态返回给调用方,但是这里是有问题的。这里的异常,除了业务逻辑的异常,是不是也有可能是底层连接的异常?

以底层连接中断异常为例,对于这种连接中断,我们是没有办法通过设置HTTP状态码来让浏览器感知的,并且一旦中断,后续的所有业务逻辑都没有什么作用了。同时,如果我们持续给已经中断的连接发送请求,会在底层持续显示网络连接错误(brokenpipe)。

所以在遇到这种底层连接异常的时候,应该直接中断后续逻辑。来看看Gin对于连接中断的捕获是怎么处理的。(你可以查看Gin的Recovery

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
return func(c *Context) {
defer func() {
if err := recover(); err != nil {
// 判断是否是底层连接异常,如果是的话,则标记 brokenPipe
var brokenPipe bool
if ne, ok := err.(*net.OpError); ok {
if se, ok := ne.Err.(*os.SyscallError); ok {
if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
brokenPipe = true
}
}
}
...


if brokenPipe {
// 如果有标记位,我们不能写任何的状态码
c.Error(err.(error)) // nolint: errcheck
c.Abort()
} else {
handle(c, err)
}
}
}()
c.Next()
}

这段代码先判断了底层抛出的异常是否是网络异常(net.OpError),如果是的话,再根据异常内容是否包含“broken pipe”或者“connection reset by peer”,来判断这个异常是否是连接中断的异常。如果是,就设置标记位,并且直接使用c.Abort()来中断后续的处理逻辑。

这个处理异常的逻辑可以说是非常细节了,区分了网络连接错误的异常和普通的逻辑异常,并且进行了不同的处理逻辑。这一点,可能是绝大多数的开发者都没有考虑到的。

Recovery的日志打印

我们再看下Recovery的日志打印部分,也非常能体现出Gin框架对细节的考量。

当然在第四讲我们只是完成了最基础的部分,没有考虑到打印Recovery捕获到的异常,不妨在这里先试想下,如果是自己实现,你会如何打印这个异常呢?如果你有想法了,我们再来对比看看Gin是如何实现这个异常信息打印的(GitHub地址)。

首先,打印异常内容是一定得有的。这里直接使用logger.Printf就可以打印出来了。

1
logger.Printf("%s\n%s%s", err, ...)

其次,异常是由某个请求触发的,所以触发这个异常的请求内容,也是必要的调试信息,需要打印。
1
2
3
4
5
6
7
8
9
10
httpRequest, _ := httputil.DumpRequest(c.Request, false)
headers := strings.Split(string(httpRequest), "\r\n")
// 如果 header 头中有认证信息,隐藏,不打印。
for idx, header := range headers {
current := strings.Split(header, ":")
if current[0] == "Authorization" {
headers[idx] = current[0] + ": *"
}
}
headersToStr := strings.Join(headers, "\r\n")

分析一下这段代码,Gin使用了一个DumpRequest来输出请求中的数据,包括了HTTP header头和HTTP Body。

这里值得注意的是,为了安全考虑Gin还注意到了,如果请求头中包含Authorization字段,即包含HTTP请求认证信息,在输出的地方会进行隐藏处理,不会由于panic就把请求的认证信息输出在日志中。这一个细节,可能大多数开发者也都没有考虑到。

最后看堆栈信息打印,Gin也有其特别的实现。我们打印堆栈一般是使用runtime库的Caller来打印:

1
2
// 打印堆栈信息,是否有这个堆栈
func Caller(skip int) (pc uintptr, file string, line int, ok bool)

caller方法返回的是堆栈函数所在的函数指针、文件名、函数所在行号信息。但是在使用过程中你就会发现,使用Caller是打印不出真实代码的。

比如下面这个例子,我们使用Caller方法,将文件名、行号、堆栈函数打印出来:

1
2
3
4
5
6
7
// 在 prog.go 文件,main 库中调用 call 方法
func call(skip int) bool { //24 行
pc,file,line,ok := runtime.Caller(skip) //25 行
pcName := runtime.FuncForPC(pc).Name() //26 行
fmt.Println(fmt.Sprintf("%v %s %d %s",pc,file,line,pcName)) //27 行
return ok //28 行
} //29 行

打印出的第一层堆栈函数的信息:
1
4821380 /tmp/sandbox064396492/prog.go 25 main.call

这个堆栈信息并不友好,它告诉我们,第一层信息所在地址为prog.go文件的第25行,在main库的call函数里面。所以如果想了解下第25行有什么内容,用这个堆栈信息去源码中进行文本查找,是做不到的。这个时候就非常希望信息能打印出具体的真实代码。

在Gin中,打印堆栈的时候就有这么一个逻辑:先去本地查找是否有这个源代码文件,如果有的话,获取堆栈所在的代码行数,将这个代码行数直接打印到控制台中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 打印具体的堆栈信息
func stack(skip int) []byte {
...
// 循环从第 skip 层堆栈到最后一层
for i := skip; ; i++ {
pc, file, line, ok := runtime.Caller(i)
// 获取堆栈函数所在源文件
if file != lastFile {
data, err := ioutil.ReadFile(file)
if err != nil {
continue
}
lines = bytes.Split(data, []byte{'\n'})
lastFile = file
}
// 打印源代码的内容
fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line))
}
return buf.Bytes()
}

这样,打印出来的堆栈信息形如:
1
2
/Users/yejianfeng/Documents/gopath/pkg/mod/github.com/gin-gonic/gin@v1.7.2/context.go:165 (0x1385b5a)
(*Context).Next: c.handlers[c.index](c)

这个堆栈信息就友好多了,它告诉我们,这个堆栈函数发生在文件的165行,它的代码为c.handlers[c.index],这行代码所在的父级别函数为(*Context).Next

最终,Gin打印出来的panic信息形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2021/08/15 14:18:57 [Recovery] 2021/08/15 - 14:18:57 panic recovered:
GET /first HTTP/1.1
Host: localhost:8080
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/ *;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
...
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36



%!s(int=121321321)
/Users/yejianfeng/Documents/UGit/gindemo/main.go:19 (0x1394214)
main.func1: panic(121321321)
/Users/yejianfeng/Documents/gopath/pkg/mod/github.com/gin-gonic/gin@v1.7.2/context.go:165 (0x1385b5a)
(*Context).Next: c.handlers[c.index](c)
/Users/yejianfeng/Documents/gopath/pkg/mod/github.com/gin-gonic/gin@v1.7.2/recovery.go:99 (0x1392c48)
CustomRecoveryWithWriter.func1: c.Next()
/Users/yejianfeng/Documents/gopath/pkg/mod/github.com/gin-gonic/gin@v1.7.2/context.go:165 (0x1385b5a)
(*Context).Next: c.handlers[c.index](c)
...
/usr/local/Cellar/go/1.15.5/libexec/src/net/http/server.go:1925 (0x12494ac)
(*conn).serve: serverHandler{c.server}.ServeHTTP(w, w.req)
/usr/local/Cellar/go/1.15.5/libexec/src/runtime/asm_amd64.s:1374 (0x106bb00)
goexit: BYTE $0x90 // NOP

可以说这个调试信息就非常丰富了,对我们在实际工作中的调试会有非常大的帮助。但是这些丰富的细节都是在开源过程中,不断补充起来的。

路由对比

刚才只挑Recovery中间件的错误捕获和日志打印简单说了一下,我们可以再挑核心一些的功能,例如比较一下我们实现的路由和Gin的路由的区别。

在第三节课使用trie树实现了一个路由,它的每一个节点是一个segment。

而Gin框架的路由选用的是一种压缩后的基数树(radixtree),它和我们之前实现的trie树相比最大的区别在于,它并不是把按照分隔符切割的segment作为一个节点,而是把整个URL当作字符串,尽可能匹配相同的字符串作为公共前缀。

为什么Gin选了这个模型?其实radixtree和trie树相比,最大的区别就在于它节点的压缩比例最大化。直观比较上面两个图就能看得出来,对于URL比较长的路由规则,trie树的节点数就比radixtree的节点数更多,整个数的层级也更深。

针对路由这种功能模块,创建路由树的频率远低于查找路由点频率,那么减少节点层级,无异于能提高查找路由的效率,整体路由模块的性能也能得到提高,所以Gin用radixtree是更好的选择。

另外在路由查找中,Gin也有一些细节做得很好。首先,从父节点查找子节点,并不是像我们之前实现的那样,通过遍历所有子节点来查找是否有子节点。Gin在每个node节点保留一个indices字符串,这个字符串的每个字符代表子节点的第一个字符:

在Gin源码的tree.go中可以看到。

1
2
3
4
5
6
7
8
// node 节点
type node struct {
path string
indices string // 子节点的第一个字符
...
children []*node // 子节点
...
}

这个设计是为了加速子节点的查询效率。使用Hash查找的时间复杂度为O(1),而我们使用遍历子节点的方式进行查找的效率为O(n)。

在拼接indices字符串的时候,这里Gin还有一个代码方面的细节值得注意,在tree.go中有一段这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
path = path[i:]
c := path[0]

...
// 插入子节点
if c != ':' && c != '*' && n.nType != catchAll {
// []byte for proper unicode char conversion, see #65
// 将字符串拼接进入 indices
n.indices += bytesconv.BytesToString([]byte{c})
...
n.addChild(child)
...

将字符c拼接进入indices的时候,使用了一个bytesconv.BytesToString方法来将字符c转换为string。你可以先想想,这里为什么不使用string关键字直接转换呢?

因为在Golang中,string转化为byte数组,以及byte数组转换为string,都是有内存消耗的。以byte数组使用关键字string转换为字符串为例,Golang会先在内存空间中重新开辟一段字符串空间,然后将byte数组复制进入这个字符串空间,这样不管是在内存使用的效率,还是在CPU资源使用的效率上,都存在一定消耗。

而在Gin的第#2206号提交中,有开源贡献者就使用自研的bytesconv包,将Gin中的字符数组和string的转换统一做了一次修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package bytesconv

// 字符串转化为字符数组,不需要创建新的内存
func StringToBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(
&struct {
string
Cap int
}{s, len(s)},
))
}

// 字符数组转换为字符串,不需要创建新的内存
func BytesToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}

bytesconv包的原理就是,直接使用unsafe.Pointer来强制转换字符串或者字符数组的类型。

这些细节的修改,一定程度上减少了Gin包内路由的内存占用空间。

生态

提供跨域请求的cors中间件、提供本地缓存的cache中间件、集成了pprof工具的pprof中间件、自动生成全链路trace的opengintracing中间件等等。

思考题

在Gin框架中,也和我们第5讲一样提供了Query系列方法,你能看出Gin实现的Query系列方法和我们实现的有什么不同么?

0%