前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >从编译器角度出发探索如何在go中实现AOP

从编译器角度出发探索如何在go中实现AOP

原创
作者头像
Gospel
发布2022-07-27 17:34:48
1.4K0
发布2022-07-27 17:34:48
举报
文章被收录于专栏:go 源码

起源:在当下微服务盛行,服务的依赖越来越复杂,服务的颗粒越来越细,业务迭代越来越频繁,软件的系统性测试的维护成本越来越高,对于特别复杂的业务场景的单测编写或者接口测试的数据构造是越发困难。所以我们开发了UGO智能单测辅助工具来解决这些问题。

<br/>

<br/>

<br/>

需求:针对单测而言,工具需要做的就是在测试环境或者线上环境录制真实的数据,在线下进行解析,构造单元测试生成高质量且真实的case,在提高了系统的稳定性,也同时降低了编写单测的成本,用真实数据构成的测试用例辅助开发进行构造高质量的单测cases。技术实现的关键点就在如何录制线上流量以及线下解析录制文件和代码生成这三步,而对于录制线上流量就会涉及到类似Java的字节码增强的技术,所以我们今天就来先看看ugo录制模块是怎么实现录制流量的底层原理。

<br/>

<br/>

<br/>

<br/>

<br/>

<br/>

对复杂的且不允许有代码侵入的开发场景,大家可能大家首先想到的是Java的spring里面的AOP,的确作为非常成熟的语言,java想做增强字节码其实方式是非常多且成熟的,常用的静态代理、JavaProxy动态代理、用ASM库动态修改子类的CGLIB,如果想对没有加载到JVM的目标类做字节码增强可以用JavaAssist、修改已加载类的类库的instrument接口等。总之就是实现起来很成熟。所以golang有没有类似的字节码增强技术呢,因为go没有字节码所以遗憾的是暂时没有这种技术支持。

但是,golang实现了自举,(自举 Bootstrapping,“用要编译的目标编程语言编写其编译器(或汇编器)”),自举支持使用更为高级、提供更多高级抽象的语言来编写编译器,意味着我们可以直接修改go的编译器来实现类似字节码增强来实现aop的功能。

<br/>

<br/>

<br/>

首先要了解go的编译器:

编译器的作用就是把人写的代码转成机器码,所有的编译器都是由前端和后端构成,编译器的前端一般承担着词法分析、语法分析、类型检查和中间代码生成几部分工作,而编译器后端主要负责目标代码的生成和优化。 编译过程:go文件 -> AST -> SSA (Static Single Assignment) -> machine-specific SSA -> Machine

代码解释的关键阶段是语法分析阶段,先让一起来看看go的ast构造过程

语法分析过程(AST阶段)

Go 语言的解析器使用了 LALR 的文法来解析词法分析过程中输出的 Token 序列,最右推导加向前查看构成了 Go 语言解析

器的最基本原理,也是大多数编程语言的选择。

Go源码主要会用源码路径下的cmd/compile/internal/gc.parseFiles函数进行ast解析

代码语言:txt
复制
// parseFiles concurrently parses files into *syntax.File structures.
// Each declaration in every *syntax.File is converted to a syntax tree
// and its root represented by *Node is appended to xtop.
// Returns the total count of parsed lines.
func parseFiles(filenames []string) uint {...}
如注释所说这里会用多个goroutine来解析文件,最终这个parseFiles函数会将整个文件对应的语法树存到src/compile/internal/gc/noder.go中的noder结构体中,一个 noder 对象相当于 AST 语法树中的节点,构成了整个语法树。noder 结构体定义如下:

// noder transforms package syntax's AST into a Node tree.
type noder struct {
    basemap   map[*syntax.PosBase]*src.PosBase
    basecache struct {
        last *syntax.PosBase
        base *src.PosBase
    }
    file       *syntax.File
    filename   string
    linknames  []linkname
    pragcgobuf [][]string
    err        chan syntax.Error
    scope      ScopeID
    // scopeVars is a stack tracking the number of variables declared in the
    // current function at the moment each open scope was opened.
    scopeVars []int
    lastCloseScopePos syntax.Pos
}

其中最关键的字段就是*syntax.File

代码语言:txt
复制
type File struct {
    Pragma   Pragma 
    PkgName  *Name
    DeclList []Decl
    Lines    uint
    node
}

Pragma : 是词法分析的结果,其中,此法分析的函数主要是 :type PragmaHandler func(pos Pos, blank bool, text string, current Pragma) Pragma PkgName : 就是编译的 package 的名称 DeclList []Decl : DeclList 是需要编译的每一行代码的 Token 值。Decl 是一个继承了 Node 接口的接口。 Lines : 表示一共有多少行代码需要编译 node : 是一个 Node Tree 的节点,这个 node 结构体中只有在源代码中的位置属性,并且实现了 Node 接口。 语法和词法解析都会围绕*syntax.File 进行所以我们先来看看词法和语法解析所需的依赖结构体

go的语法解析器用到了parser 、词法解析器用到了scanner

先来看看cmd/compile/internal/syntax.Parse

代码语言:txt
复制
type parser struct {
    file  *PosBase
    errh  ErrorHandler
    mode  Mode
    pragh PragmaHandler
    scanner

    base   *PosBase // current position base
    first  error    // first error encountered
    errcnt int      // number of errors encountered
    pragma Pragma   // pragmas

    fnest  int    // function nesting level (for error handling)
    xnest  int    // expression nesting level (for complit ambiguity resolution)
    indent []byte // tracing support

    imports map[string]string // contents of imports
}

上面是解析器的结构体声明会用到相关的变量,可以看到词语解析器scanner是组合到了parser中

scanner 位于src\cmd\compile\internal\syntax\scanner.go 中

代码语言:txt
复制
type scanner struct {
    source
    mode   uint
    nlsemi bool // if set '\n' and EOF translate to ';'

    // current token, valid after calling next()
    line, col uint
    blank     bool // line is blank up to col
    tok       token
    lit       string   // valid if tok is _Name, _Literal, or _Semi ("semicolon", "newline", or "EOF"); may be malformed if bad is true
    bad       bool     // valid if tok is _Literal, true if a syntax error occurred, lit may be malformed
    kind      LitKind  // valid if tok is _Literal
    op        Operator // valid if tok is _Operator, _AssignOp, or _IncOp
    prec      int      // valid if tok is _Operator, _AssignOp, or _IncOp
}

接下来就是具体怎么解析的过程了:

go会在主程序入口文件中调用gc.Main函数,也就是go build的主要构建过程,gc.Main中会调用

cmd/compile/internal/gc.parseFiles方法来实现词法分析和语法分析。

代码语言:txt
复制
// Main parses flags and Go source files specified in the command-line
// arguments, type-checks the parsed Go package, compiles functions to machine
// code, and finally writes the compiled package definition to disk.
func Main(archInit func(*Arch)) {
     ...
     lines := parseFiles(flag.Args())
     ...
}

parseFiles的主要工作流程是

  • 创建所有文件的noder列表,每个文件保存为一个noder
  • 遍历所有文件,其中包括以下几个操作

每个文件对应生成一个noder,添加到noder列表 开一个Goroutine来解析源文件,将解析的结果保存到noder结构体中的file结构中,其实解析的函数就是syntax.Parse() 最主要的函数就是syntax.parser.fileOrNil(fileOrNil很关键,按下不表) 遍历结束后,将该 Node 节点加入到 xtop tree 中,也就是 AST 抽象语法树 生成 Node Tree 树的过程在 p.node() 函数中,就是将 noder 结构体转换成 Node 节点类型,添加到 xtop tree 中,xtop 就是这颗语法树,供后面类型检查使用。

具体代码:

代码语言:txt
复制
func parseFiles(filenames []string) uint {
    //  创建 noder 列表
    noders := make([]*noder, 0, len(filenames))
    // 表示最多能同时开启多少个文件描述符
    sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10)

    //  遍历 所有文件
    for _, filename := range filenames {
        //  创建 noder 对现象,并且添加到 noder 列表中
        p := &noder{
            basemap: make(map[*syntax.PosBase]*src.PosBase),
            err:     make(chan syntax.Error),
        }
        noders = append(noders, p)

        //  每个文件用一个 Goroutine 去解析
        go func(filename string) {
            sem <- struct{}{}
            defer func() { <-sem }()
            defer close(p.err)
            base := syntax.NewFileBase(filename)

            f, err := os.Open(filename)
            if err != nil {
                p.error(syntax.Error{Msg: err.Error()})
                return
            }
            defer f.Close()

            //   文件解析的主要过程
            p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error
        }(filename)
    }

    //  下面代码段主要是将 noder 列表中的节点,添加到 xTop 中。
    var lines uint
    for _, p := range noders {
        for e := range p.err {
            p.yyerrorpos(e.Pos, "%s", e.Msg)
        }
        //  node() 方法将 noders 转成 Node 节点,添加到 xtop 这棵树中
        p.node()
        lines += p.file.Lines
        p.file = nil // release memory

        if nsyntaxerrors != 0 {
            errorexit()
        }
        // Always run testdclstack here, even when debug_dclstack is not set, as a sanity measure.
        testdclstack()
    }

    localpkg.Height = myheight

    return lines
}

解析的过程中调用了syntax.Parse()函数,该函数位于src\cmd\compile\internal\syntax\syntax.go 文件,就是词法解析的过程

该函数初始化了一个新的cmd/compile/internal/syntax.parser结构构体,就是本文上面一部分说的语法解析器,并且该函数通过cmd/compile/internal/syntax.parser.fileOrNil 方法开启了对当前文件的词法和语法解析

代码语言:txt
复制
func Parse(base *PosBase, src io.Reader, errh ErrorHandler, pragh PragmaHandler, mode Mode) (_ *File, first error) {
var p parser
p.init(base, src, errh, pragh, mode)
p.next()(词法分析)
return p.fileOrNil(), p.first
}

词法分析器其实是在p.next()中调用的。

代码语言:txt
复制
// If the scanner mode includes the directives (but not the comments)
// flag, only comments containing a //line, /*line, or //go: directive
// are reported, in the same way as regular comments.
func (s *scanner) next() {
   nlsemi := s.nlsemi
   s.nlsemi = false

可以看到next()方法的接收器是scanner, 在go中因为词法分析器嵌套到了语法分析器中,所以词法分析和语法分析是一起进行的。p.next()调用的实际山scanner的next方法,它会直接回去文件中的下一个Token。

src/cmd/compile/internal/syntax/nodes.go 文件中也定义了其他节点的结构体,其中包含了全部声明类型的

函数声明的结构:

代码语言:txt
复制
type (
    Decl interface {
        Node
        aDecl()
    }

    FuncDecl struct {
        Attr   map[string]bool
        Recv   *Field
        Name   *Name
        Type   *FuncType
        Body   *BlockStmt
        Pragma Pragma
        decl
    }
}

函数主体*BlockStmt其实是一个cmd/compile/internal/syntax.Stmt数组

代码语言:txt
复制
    BlockStmt struct {
        List   []Stmt
        Rbrace Pos
        stmt
    }

syntax.Stmt是一个接口,实现该接口的类型也非常多,在go 15版本中有18不同的实现

image.png
image.png

这些类型其实和go/ast下的类型大概率都是对应的,但是还有一定区别(大佬感兴趣可以研究研究

image.png
image.png

这些不同类型的cmd/compile/internal/syntax.Stmt构成了全部命令式的Go语言代码,从中我们可以看到很多熟悉的控制结构,例如if、for 、switch、select,这些命令式的结构在其他的编程语言中也非常常见。

通过词法解析和语法解析,go会把源文件转换为上面定义的file的树型结构。里面包含了各种各样的stmt,至此结束了ast树的转化。

类型检查和AST转换

解析完之后就是类型检查和AST转换了,简单讲就是会对构建好的ast树进行遍历,在每个节点上都会对当前子树的类型进行验证,所有的类型错误和不匹配都会在这个阶段被暴露出来,其中包括:结构体对接口的实现。实现aop的功能对这一类型检查过程其实不主要涉及,在此就不再展开,感兴趣可以参考 /https://draveness.me/golang/docs/part1-prerequisite/ch02-compile/golang-typecheck//

如何实现aop

在上面主要介绍了go编译器词法分析和语法分析之后,实现aop的方案是显而易见的,我们可以在

cmd/compile/internal/gc.parseFiles函数构建ast的时候,对已经构造好的ast树进行修改,因为go已经有通过实现接口 syntax.Stmt的十几种结构体,所以可以将自己想要植入的代码用对应的结构体构造出来,来实现代码插入的效果。

这也是我们在UGO智能单测辅助工具运用到的核心技术之一,我们需要在接口调用的时候记录整个函数调用的链路,同时录制函数的入参、返回值、调用函数的线程id等相对应的信息,这时候就要在函数的编译构建的时候将记录入参、返回值等信息的切面代码通过改写ast的构建织入业务的代码中,只要构建成功之后就可以将录制的流量输出到我们的存储介质中给解析模块用。改写ast需要改写、定制化go源码,业务代码并不需要改动,真正意义上实现了无侵入aop,只需要配置一个定制版的go即可。

下面通过一个简单的小例子来看看go 的编译增强的具体实现

Hello World

我们实现在执行ugo()函数的前打印"start UGO ..." 后打印 "end UGO ..."。

  • 先去github上下载一份go的源代码到本地,然后切换分支选择合适的版本,我这里选用的是go 15版本,这个go是我们修改ast的go,下文叫做定制版go
代码语言:txt
复制
git clone https://github.com/golang/go.git
cd $HOME/src/github.com/golang/go
git checkout release-branch.go1.15
  • 然后装一个编译源码用的Bootstrap环境 go是自举的语言go编译器通过go语言编译

一般bootstrap的go版本>=编译的go版本就行,本地再下载一个go就可以,确保版本大于定制版的go

这样定制版的go才能用它。

代码语言:txt
复制
mkdir $HOME/go_boostrap
cd $HOME/go_bootstrap
curl https://dl.google.com/go/go1.15.12.darwin-amd64.tar.gz | tar xvzf -
mv go go_bootstrap_15
  • 回到定制版的go目录,export bootstrap环境
代码语言:txt
复制
cd $HOME/src/github.com/golang/go/src
echo 'export GOROOT_BOOTSTRAP=$HOME/go_bootstrap/go1.15.12' >> .envrc
  • 先尝试编译定制版的go
代码语言:txt
复制
cd $HOME/src/github.com/golang/go/src
./make.bash

这一步骤编译会很慢,如果环境正常会有类似以下输出,就代表可以进行下一步操作了

image.png
image.png
  • 在本地创建一个测试项目也就是执行ugo()函数的库,goroot选择刚才编译好的定制版的go的路径

直接在本地ide新建一个项目,然后在终端里面:

代码语言:txt
复制
export GOROOT=$HOME/src/github.com/golang/go   # 定制版Go的路径
export PATH=$GOROOT/bin:$PATH

创建main

代码语言:txt
复制
package main

import (
    "context"
    "fmt"
 )
 
 func UGO(ctx context.Context) {
     fmt.Println("hello world")
}

func main() {
    hello(context.Background())
}

确保你本地的测试项目是由定制版的go完成编译的。

  • 现在准备好了环境要做的就是更改go源码了,直接找到cmd/compile/internal/gc/noder.go这个文件,找到第52行的
代码语言:txt
复制
    defer f.Close()

   p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error
}(filename)

加入下面的代码,修改p.file的值:

代码语言:txt
复制
p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error
            if p.file.PkgName.Value == "main" {
                for _, d := range p.file.DeclList {
                    d, _ := d.(*syntax.FuncDecl)
                    if d == nil {
                        continue
                    }
                    if !strings.HasPrefix(d.Name.Value, "UGO") {
                        continue
                    }
                    d.Body.List = append([]syntax.Stmt{
                        &syntax.ExprStmt{
                            X: &syntax.CallExpr{
                                Fun: &syntax.SelectorExpr{
                                    X:   &syntax.Name{Value: "fmt"},
                                    Sel: &syntax.Name{Value: "Println"},
                                },
                                ArgList: []syntax.Expr{
                                    &syntax.BasicLit{
                                        Value: strconv.Quote("start " + d.Name.Value + "..."),
                                        Kind:  syntax.StringLit,
                                    },
                                },
                            },
                        },
                        &syntax.CallStmt{
                            Tok: syntax.Defer,
                            Call: &syntax.CallExpr{
                                Fun: &syntax.SelectorExpr{
                                    X:   &syntax.Name{Value: "fmt"},
                                    Sel: &syntax.Name{Value: "Println"},
                                },
                                ArgList: []syntax.Expr{
                                    &syntax.BasicLit{
                                        Value: strconv.Quote("end " + d.Name.Value + "..."),
                                        Kind:  syntax.StringLit,
                                    },
                                },
                            },
                        },
                    }, d.Body.List...)
                }
            }  

改完之后重新编译定制版go

代码语言:txt
复制
cd $HOME/src/github.com/golang/go/src
./make.bash

cd $HOME/GolangProject/Hello
go clean -cache
go run hello/main.go

运行结果

代码语言:txt
复制
start UGO...
hello world
end UGO...

但是我们如果在源代码中没有引用fmt包,会咋样呢

删掉后,重新编译:

代码语言:txt
复制
$ go clean -cache; go run Hello/main.go
# command-line-arguments
helloworld/main.go:7:16: undefined: fmt in fmt.Println 

肯定会报错,但是我们在语法树中加上import呢?

代码语言:txt
复制
if p.file.PkgName.Value == "main" {
                for _, d := range p.file.DeclList {
                    d, _ := d.(*syntax.FuncDecl)
                    if d == nil {
                        continue
                    }
                    if !strings.HasPrefix(d.Name.Value, "hello") {
                        continue
                    }
                    hasHello = true
                    d.Body.List = append([]syntax.Stmt{
                        &syntax.ExprStmt{
                            X: &syntax.CallExpr{
                                Fun: &syntax.SelectorExpr{
                                    X:   &syntax.Name{Value: "fmt"},
                                    Sel: &syntax.Name{Value: "Println"},
                                },
                                ArgList: []syntax.Expr{
                                    &syntax.BasicLit{
                                        Value: strconv.Quote("start " + d.Name.Value + "..."),
                                        Kind:  syntax.StringLit,
                                    },
                                },
                            },
                        },
                        &syntax.CallStmt{
                            Tok: syntax.Defer,
                            Call: &syntax.CallExpr{
                                Fun: &syntax.SelectorExpr{
                                    X:   &syntax.Name{Value: "fmt"},
                                    Sel: &syntax.Name{Value: "Println"},
                                },
                                ArgList: []syntax.Expr{
                                    &syntax.BasicLit{
                                        Value: strconv.Quote("end " + d.Name.Value + "..."),
                                        Kind:  syntax.StringLit,
                                    },
                                },
                            },
                        },
                    }, d.Body.List...)
                }
            }
            if hasHello {
                hasFmtImport := false
                for _, d := range p.file.DeclList {
                    d, _ := d.(*syntax.ImportDecl)
                    if d == nil {
                        continue
                    }
                    if d.Path.Value != "fmt" {
                        continue
                    }
                    hasFmtImport = true
                    break
                }
                if !hasFmtImport {
                    p.file.DeclList = append([]syntax.Decl{
                        &syntax.ImportDecl{
                            Path: &syntax.BasicLit{
                                Value: `"fmt"`, Kind: syntax.StringLit,
                            },
                        },
                    }, p.file.DeclList...)
                }
            }  

重新编译golang,执行go run

代码语言:txt
复制
$ go clean -cache; go run Hello/main.go
# command-line-arguments
Hello/main.go:1:9: can't find import: "fmt"

还是会报错,原因是在findpkg的过程中,包变量packageFile是从这个map中获取

代码语言:txt
复制
if packageFile != nil {
        file, ok = packageFile[name]
        return file, ok
    }  

packageFile在readImportCfg中初始化

代码语言:txt
复制
func readImportCfg(file string) {
   packageFile = map[string]string{}
   data, err := ioutil.ReadFile(file)
   if err != nil {
      log.Fatalf("-importcfg: %v", err)
   }

入参的string是调用compile命令时给的-importcfg的值

image.png
image.png

文件 cmd/go/internal/work/exec.go 第634行,有关importcfg的内容的逻辑

代码语言:txt
复制
// Prepare Go import config.
// We start it off with a comment so it can't be empty, so icfg.Bytes() below is never nil.
// It should never be empty anyway, but there have been bugs in the past that resulted
// in empty configs, which then unfortunately turn into "no config passed to compiler",
// and the compiler falls back to looking in pkg itself, which mostly works,
// except when it doesn't.
var icfg bytes.Buffer
fmt.Fprintf(&amp;icfg, "# import config\n")
for i, raw := range a.Package.Internal.RawImports {
   final := a.Package.Imports[i]
   if final != raw {
      fmt.Fprintf(&amp;icfg, "importmap %s=%s\n", raw, final)
   }
}

在文件 go/build/build.go=,先用 =go/parser.ParseFile 解析源文件,然后获取其中的imports

代码语言:txt
复制
        pf, err := parser.ParseFile(fset, filename, data, parser.ImportsOnly|parser.ParseComments)
        if err != nil {
            badFile(err)
            continue
        }

可以修改build.go这个文件,将自己需要的import直接加入,多加的话这里也不会报错因为已经经过了类型检查

代码语言:txt
复制
// add import
hasFmtImport := false
hasEcodingJsonImport := false
hasRuntime := false
for _, i := range pf.Imports {
   if i.Path.Value == `"fmt"` {
      hasFmtImport = true
   }
   if hasFmtImport &amp;&amp; hasEcodingJsonImport &amp;&amp; hasRuntime {
      break
   }
}

//if !onlyMain &amp;&amp; !hasFmtImport {
if !hasFmtImport {
   pf.Imports = append(pf.Imports, &amp;ast.ImportSpec{
      Path: &amp;ast.BasicLit{
         Value: `"fmt"`,
         Kind:  token.STRING,
      },
   })
   if len(pf.Decls) > 0 {
      d, ok := pf.Decls[0].(*ast.GenDecl)
      if ok {
         d.Specs = append(d.Specs, &amp;ast.ImportSpec{
            Path: &amp;ast.BasicLit{
               Kind:  token.STRING,
               Value: `"fmt"`,
            },
         })
      } else {
         // case : no import
         pf.Decls = append([]ast.Decl{
            &amp;ast.GenDecl{
               Specs: []ast.Spec{
                  &amp;ast.ImportSpec{
                     Path: &amp;ast.BasicLit{
                        Kind:  token.STRING,
                        Value: `"fmt"`,
                     },
                  },
               },
            },
         }, pf.Decls...)
      }
   } else {
      pf.Decls = append([]ast.Decl{
         &amp;ast.GenDecl{
            Specs: []ast.Spec{
               &amp;ast.ImportSpec{
                  Path: &amp;ast.BasicLit{
                     Kind:  token.STRING,
                     Value: `"fmt"`,
                  },
               },
            },
         },
      }, pf.Decls...)
   }

重新编译golang,执行go run

代码语言:txt
复制
$ go clean -cache; go run helloworld/main.go
start UGO...
hello world
end UGO...

成功。

当然实际的业务场景对源码的定制远比上面这个例子复杂,他虽然没有像java的AspectJ方案那样的完整,但是与业务代码完全解耦和一键式录制的特点使这种方案能适应更多的场景,瓶颈可能就是定制版的go的开发比较繁琐,还有很大的优化空间。

文章技术参考:

https://draveness.me/golang/docs/part1-prerequisite/ch02-compile/golang-compile-intro/

https://blog.51cto.com/onebig/2510835

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 语法分析过程(AST阶段)
  • 类型检查和AST转换
  • 如何实现aop
  • Hello World
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档