Golang 中使用“注释注解”打印日志

背景

前一段时间线上出现了一个问题:在压测后偶尔会出现一台机器查询数据无结果但是没有返回 err 的情况,导致后续处理都出错。由于当时我们仅在最外层打印了 err ,没有打印入参和出参,所以导致很难排查问题到底出现在哪一环节。

经过艰难地排查出问题后,感觉需要在代码里添加打印关键函数的入参和出参数,但这个逻辑都是重复的,也不想将这一逻辑侵入开发流程,所以就想到了代码生成方式的注释注解。即我们提前定义好一个注释注解(例如:// @Log()),并且在 Docker 中编译前运行代码生成的逻辑,将所有拥有该注释注解的函数进行修改,在函数体前面添加打印入参和出参的逻辑。这样就不需要让日志打印侵入到业务代码中,并且后续可以很方便替换成其他的打印逻辑(例如根据@Log 内的参数或者返回值等自定义日志级别)。

编写代码

我们可以使用 AST 的方式去解析、识别并修改代码, go/ast 已经提供了相应的功能,我们查看我们关心的节点部分及其相关的信息结构,可以使用 goast-viewer 直接查看 AST ,当然也可以本地进行调试。

遍历 .go 文件

首先需要使用 filepath.Walk 函数遍历指定文件夹下的所有文件,对每一个文件都会执行传入的 walkFn 函数。 walkFn 函数会将 .go 文件解析成 AST ,将其交由注释注解的处理器处理,然后根据是否修改了 AST 决定是否生成新的代码。

// walkFn 函数会对每个 .go 文件处理,并调用注解处理器
func walkFn(path string, info os.FileInfo, err error) error {
    // 如果是文件夹,或者不是 .go 文件,则直接返回不处理
    if info.IsDir() || !strings.HasSuffix(info.Name(), ".go") {
        return nil
    }


    // 将 .go 文件解析成 AST
    fileSet, file, err := parseFile(path)
    // 如果注解修改了内容,则需要生成新的代码
    if logannotation.Overwrite(path, fileSet, file) {
        buf := &bytes.Buffer{}
        if err := format.Node(buf, fileSet, file); err != nil {
            panic(err)
        }


        // 如果不需要替换,则生成到另一个文件
        if !replace {
            lastSlashIndex := strings.LastIndex(path, "/")
            genDirPath :=path[:lastSlashIndex] + "/_gen/"
            if err := os.Mkdir(genDirPath, 0755); err != nil && os.IsNotExist(err){
                panic(err)
            }
            path = genDirPath + path[lastSlashIndex+1:]
        }


        if err := ioutil.WriteFile(path, buf.Bytes(), info.Mode()); err != nil {
            panic(err)
        }
    }


    return nil
}

遍历 AST

当注释注解处理器拿到 AST 后,就需要使用 astutil.Apply 函数遍历整颗 AST ,并对每个节点进行处理,同时为了方便修改时添加 import ,我们包一层函数供内部调用,并把一些关键信息打包在一起。

// Overwrite 会对每个 file 处理,运行注册的注解 handler ,并返回其是否被修改
func Overwrite(filepath string, fileSet *token.FileSet, file *ast.File) (modified bool) {
    // 初始化处理本次文件所需的信息对象
    info := &Info{
        Filepath: filepath,
        NamedImportAdder: func(name string, path string) bool {
            return astutil.AddNamedImport(fileSet, file, name, path)
        },
    }


    // 遍历当前文件 ast 上的所有节点
    astutil.Apply(file, nil, func(cursor *astutil.Cursor) bool {
        // 处理 log 注解
        info.Node = cursor.Node()
        nodeModified, err := Handler.Handle(info)
        if err != nil {
            panic(err)
        }


        if nodeModified {
            modified = nodeModified
        }


        return true
    })


    return
}

识别注释注解

接下来我们就需要识别注释注解,跳过不相关的节点,示例中不做额外处理,仅当注释为 @Log() 才认为需要处理,可以根据需要添加相应的逻辑。

func (h *handler) Handle(info *Info) (modified bool, err error) {
    // log 注解只用于函数
    funcDecl, ok := info.Node.(*ast.FuncDecl)
    if !ok {
        return
    }


    // 如果没有注释,则直接处理下一个
    if funcDecl.Doc == nil {
        return
    }


    // 如果不是可以处理的注解,则直接返回
    doc := strings.Trim(funcDecl.Doc.Text(), "	 
")
    if doc != "@Log()" {
        return
    }
    ...
}

获取函数入参和出参

首先我们需要获取函数的入参和出参,这里我们以出参举例。出参定义在 funcDecl.Type.Results ,并且可能没有指定名称,所以需要先为以 _0, _1, ... 这样的形式为没有名称的变量设置默认名称,然后按照顺序获取所有变量的名称列表。

// SetDefaultNames 给没有名字的 Field 设置默认的名称
// 默认名称格式:_0, _1, ...
// true: 表示至少设置了一个名称
// false: 表示未设置过名称
func SetDefaultNames(fields ...*ast.Field) bool {
    index := 0
    for _, field := range fields {
        if field.Names == nil {
            field.Names = NewIdents(fmt.Sprintf("_%v", index))
            index++
        }
    }


    return index > 0
}

获取打印语句

假设我们所需的打印语句为: log.Logger.WithContext(ctx).WithField("filepath", filepath).Infof(format, arg0, arg1) ,那么函数选择器的表达式可以直接使用 parser.ParseExpr 函数生成,其中的参数 (format, arg0, arg1) 手动拼接即可。

// NewCallExpr 产生一个调用表达式
// 待产生表达式:log.Logger.WithContext(ctx).Infof(arg0, arg1)
// 其中:
//    funcSelector = "log.Logger.WithContext(ctx).Infof"
//     args = ("arg0", "arg1")
// 调用语句:NewCallExpr("log.Logger.WithContext(ctx).Infof", "arg0", "arg1")
func NewCallExpr(funcSelector string, args ...string) (*ast.CallExpr, error) {
    // 获取函数对应的表达式
    funcExpr, err := parser.ParseExpr(funcSelector)
    if err != nil {
        return nil, err
    }


    // 组装参数列表
    argsExpr := make([]ast.Expr, len(args))
    for i, arg := range args {
        argsExpr[i] = ast.NewIdent(arg)
    }


    return &ast.CallExpr{
        Fun:  funcExpr,
        Args: argsExpr,
    }, nil
}

由于出参需要等函数执行完毕后执行,所以打印出参的语句还需要放在 defer 函数内执行。

// NewFuncLitDefer 产生一个 defer 语句,运行一个匿名函数,函数体是入参语句列表
func NewFuncLitDefer(funcStmts ...ast.Stmt) *ast.DeferStmt {
    return &ast.DeferStmt{
        Call: &ast.CallExpr{
            Fun: NewFuncLit(&ast.FuncType{}, funcStmts...),
        },
    }
}

修改函数体

至此我们已经获得了打印入参和出参的语句,接下来就是把他们放在原本函数体的最前面,保证开始和结束时执行。

toBeAddedStmts := []ast.Stmt{
    &ast.ExprStmt{X: beforeExpr},
    // 离开函数时的语句使用 defer 调用
    NewFuncLitDefer(&ast.ExprStmt{X: afterExpr}),
}


// 我们将添加的语句放在函数体最前面
funcDecl.Body.List = append(toBeAddedStmts, funcDecl.Body.List...)

运行

为了测试我们的注释注解是否工作正确,我们使用如下代码进行测试:

package main


import (
    "context"
    "logannotation/testdata/log"
)


func main() {
    fn(context.Background(), 1, "2", "3", true)
}


// @Log()
func fn(ctx context.Context, a int, b, c string, d bool) (int, string, string) {
    log.Logger.WithContext(ctx).Infof("#fn executing...")
    return a, b, c
}

运行 go run logannotation/cmd/generator /Users/idealism/Workspaces/Go/golang-log-annotation/testdata 执行代码生成,在 /Users/idealism/Workspaces/Go/golang-log-annotation/testdata/_gen 下可找到生成的代码:

package main


import (
    "context"
    "logannotation/testdata/log"
)


func main() {
    fn(context.Background(), 1, "2", "3", true)
}


func fn(ctx context.Context, a int, b, c string, d bool) (_0 int, _1 string, _2 string) {
    log.Logger.WithContext(
        ctx).WithField("filepath", "/Users/idealism/Workspaces/Go/golang-log-annotation/testdata/main.go").Infof("#fn start, params: %+v, %+v, %+v, %+v", a, b, c, d)
    defer func() {
        log.Logger.WithContext(
            ctx).WithField("filepath", "/Users/idealism/Workspaces/Go/golang-log-annotation/testdata/main.go").Infof("#fn end, results: %+v, %+v, %+v", _0, _1, _2)
    }()


    log.Logger.WithContext(ctx).Infof("#fn executing...")
    return a, b, c
}

可以看到已经按照我们的想法正确生成了代码,并且运行后能按照正确的顺序打印正确的入参和出参。实际使用时会在 ctx 中加入 apm 的 traceId ,并且在 logrusHooks 中将其在打印前放入到 Fields 中,这样搜索的时候可以将同一请求的所有日志聚合在一起。

INFO[0000] #fn start, params: 1, 2, 3, true              filepath=/Users/idealism/Workspaces/Go/golang-log-annotation/testdata/main.go
INFO[0000] #fn executing...                             
INFO[0000] #fn end, results: 1, 2, 3                     filepath=/Users/idealism/Workspaces/Go/golang-log-annotation/testdata/main.go

扩展

以上代码是一种简单方式地定制化处理注解,仅处理了打印日志这一逻辑,当然还存在更多扩展的可能性和优化。

相关 Demo 可以在 golang-log-annotation 找到。

展开阅读全文

页面更新:2024-03-08

标签:注解   注释   遍历   语句   函数   逻辑   名称   参数   代码   文件   日志

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top