这篇文章介绍了作者在参与一个golang日志系统的开发的时候,解决需要打印出执行日志打印操作时的业务函数名,业务文件名与所在行数的需求过程中,遇到的问题和解决方案
需求场景
在平日里使用日志的时候,一个好的日志系统,往往会打印出类似如下的信息
1 | <log_level>:<log_message>:<package_path>/<filename>:<line_no>:<function_name> |
这样子在打印出日志等级,日志消息的同时,输出业务逻辑所在的文件,行数,函数,对后期的bug排查,性能分析都有很大的帮助
那么,如何在golang中实现这一功能呢?
实现方式
golang的runtime包提供了与之相应的函数接口,主要是runtime.Caller
和runtime.FuncForPC
先看一下二者的函数签名
1 | func Caller(skip int) (pc uintptr, file string, line int, ok bool) |
单看函数签名就比较容易了解到:
runtime.Caller
能够返回在函数栈中的PC(指令寄存器,可以认为存储了当前执行到了哪里),所在的文件,所在文件的具体哪一行runtime.FuncForPC
能够根据给定的指令寄存器给出其所在的行数
其中runtime.FuncForPC
的参数比较容易理解,就是指指令寄存器,但是runtime.Caller
的参数需要解释一下
这里的skip
指的是跳过多少个函数栈:
skip == 0
,不跳过函数栈,返回当前函数PC,文件名,所在行skip == 1
,跳过当前函数栈,返回上层调用者调用当前函数时的PC,文件名,所在行skip == 2
,以此类推
一般情况下这两个函数都是连在一起使用,如
1 | // 获取上层调用者PC,文件名,所在行 |
实现重点与自动获取的优化
可以看到,在我们使用runtime.Caller
和runtime.FuncForPC
这一组合击的时候,实际上的输入参数只有一个,那就是runtime.Caller
的skip
。
如何确定skip
呢?在实践中,我一般使用两种方式:
- 写死
- 尝试自动获取
听起来第二种方法要比第一种方法好,但是事实上并不是这样的,在看完实现之后,大家就会明白了
将skip
写死
这种方式是比较常见的,通常适用于设计时确定了调用层数的情况,以日志系统为例,我们现在要提供一个接口log
,那么我知道外界肯定是要直接调用log
的,我最终要打印的就是调用log
的函数的文件名,所在行,函数名
那么如果我是在log
里使用runtime.Caller
,那么我的skip
就应该是1
1 | func log(logLevel int, logMessage string) { |
如果我还做了封装,那么就要根据编写代码时的封装层数调整skip
,比如
1 | func log(logLevel int, logMessage string) { |
上述示例中,由于多了两层封装,所以要把skip
更改为3
尝试自动获取
这次的尝试自动获取是我在编写日志系统时遇到的一个比较特殊的情况
在上面说的#将skip写死中,其实我们有一个重要的前提,那就是
业务函数全部直接调用日志接口
log
但是这次在开发日志系统时,遇到了这样的场景:
日志拥有接口
log1
和log2
,log2
调用log1
,业务代码既可能调用log2
,也可能直接调用log1
log1
下层调用runtime.Caller
和runtime.FuncForPC
组合
这种情况下,skip
是不可能写死在源代码里的,于是采取的解决方案如下
由于日志系统在一个独立的包里,所以在
FuncForPC
将函数名取出来以后,判断是否是日志包中的函数,如果是,就增加skip
的值
实现:
1 | for skip := 1; true; skip++ { |
这样就算是一个能够解决问题的方案了