布景

前一段时间线上呈现了一个问题:在压测后偶然会呈现一台机器查询数据无成果但是没有返回 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(), "\t \n") 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 mainimport ( "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 mainimport ( "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 ,而且在 logrus 的 Hooks 中将其在打印前放入到 Fields 中,如许搜刮的时候能够将统一恳求的所有日记聚合在一路。

INFO[0000] #fn start, params: 1, 2, 3, true filepath=/Users/idealism/Workspaces/Go/golang-log-annotation/testdata/main.goINFO[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 找到。