0%

golang获取执行函数名,执行文件名与所在行数

这篇文章介绍了作者在参与一个golang日志系统的开发的时候,解决需要打印出执行日志打印操作时的业务函数名,业务文件名与所在行数的需求过程中,遇到的问题和解决方案

需求场景

在平日里使用日志的时候,一个好的日志系统,往往会打印出类似如下的信息

1
2
3
<log_level>:<log_message>:<package_path>/<filename>:<line_no>:<function_name>
比如
INFO:connect to sql:/users/admin/home/go/src/io/rivers/demoProject/main.go:45:io.rivers.demoProject.testFunction

这样子在打印出日志等级,日志消息的同时,输出业务逻辑所在的文件,行数,函数,对后期的bug排查,性能分析都有很大的帮助

那么,如何在golang中实现这一功能呢?

实现方式

golang的runtime包提供了与之相应的函数接口,主要是runtime.Callerruntime.FuncForPC

先看一下二者的函数签名

1
2
3
func Caller(skip int) (pc uintptr, file string, line int, ok bool)

func FuncForPC(pc uintptr) *Func

单看函数签名就比较容易了解到:

  • runtime.Caller能够返回在函数栈中的PC(指令寄存器,可以认为存储了当前执行到了哪里),所在的文件,所在文件的具体哪一行
  • runtime.FuncForPC能够根据给定的指令寄存器给出其所在的行数

其中runtime.FuncForPC的参数比较容易理解,就是指指令寄存器,但是runtime.Caller的参数需要解释一下

这里的skip指的是跳过多少个函数栈:

  • skip == 0,不跳过函数栈,返回当前函数PC,文件名,所在行
  • skip == 1,跳过当前函数栈,返回上层调用者调用当前函数时的PC,文件名,所在行
  • skip == 2,以此类推

一般情况下这两个函数都是连在一起使用,如

1
2
3
4
5
6
7
8
9
10
11
12
// 获取上层调用者PC,文件名,所在行
pc, codePath, codeLine, ok := runtime.Caller(1)
if !ok{
// 不ok,函数栈用尽了
code = "-"
func = "-"
} else {
// 拼接文件名与所在行
code = fmt.Sprintf("%s:%d", codePath, codeLine)
// 根据PC获取函数名
func = runtime.FuncForPC(pc).Name()
}

实现重点与自动获取的优化

可以看到,在我们使用runtime.Callerruntime.FuncForPC这一组合击的时候,实际上的输入参数只有一个,那就是runtime.Callerskip

如何确定skip呢?在实践中,我一般使用两种方式:

  1. 写死
  2. 尝试自动获取

听起来第二种方法要比第一种方法好,但是事实上并不是这样的,在看完实现之后,大家就会明白了

skip写死

这种方式是比较常见的,通常适用于设计时确定了调用层数的情况,以日志系统为例,我们现在要提供一个接口log,那么我知道外界肯定是要直接调用log的,我最终要打印的就是调用log的函数的文件名,所在行,函数名

那么如果我是在log里使用runtime.Caller,那么我的skip就应该是1

1
2
3
4
5
func log(logLevel int, logMessage string) {
//....
pc, file, line, ok := runtime.Caller(1)
//....
}

如果我还做了封装,那么就要根据编写代码时的封装层数调整skip,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func log(logLevel int, logMessage string) {
//....
logHelper(logLevel, logMessage)
//....
}

func logHelper(logLevel int, logMessage string) {
//....
logReal(logLevel, logMessage)
//....
}

func logReal(logLevel int, logMessage string) {
//...
pc, file, line, ok := runtime.Caller(3)
//...
}

上述示例中,由于多了两层封装,所以要把skip更改为3

尝试自动获取

这次的尝试自动获取是我在编写日志系统时遇到的一个比较特殊的情况

在上面说的#将skip写死中,其实我们有一个重要的前提,那就是

业务函数全部直接调用日志接口log

但是这次在开发日志系统时,遇到了这样的场景:

日志拥有接口log1log2log2调用log1,业务代码既可能调用log2,也可能直接调用log1
log1下层调用runtime.Callerruntime.FuncForPC组合

这种情况下,skip是不可能写死在源代码里的,于是采取的解决方案如下

由于日志系统在一个独立的包里,所以在FuncForPC将函数名取出来以后,判断是否是日志包中的函数,如果是,就增加skip的值

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for skip := 1; true; skip++ {
pc, codePath, codeLine, ok := runtime.Caller(skip)
if !ok{
// 不ok,函数栈用尽了
auto.Code = prevCode
auto.Func = prevFunc
return auto
} else{
prevCode = fmt.Sprintf("%s:%d", codePath, codeLine)
prevFunc = runtime.FuncForPC(pc).Name()
auto.Code = prevCode
auto.Func = prevFunc
if !strings.Contains(prevFunc, "<package_name>") {
// 找到包外的函数了
return auto
}
}
}

这样就算是一个能够解决问题的方案了