起源:在当下微服务盛行,服务的依赖越来越复杂,服务的颗粒越来越细,业务迭代越来越频繁,软件的系统性测试的维护成本越来越高,对于特别复杂的业务场景的单测编写或者接口测试的数据构造是越发困难。所以我们开发了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构造过程
Go 语言的解析器使用了 LALR 的文法来解析词法分析过程中输出的 Token 序列,最右推导加向前查看构成了 Go 语言解析
器的最基本原理,也是大多数编程语言的选择。
Go源码主要会用源码路径下的cmd/compile/internal/gc.parseFiles函数进行ast解析
// 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
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
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 中
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方法来实现词法分析和语法分析。
// 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列表 开一个Goroutine来解析源文件,将解析的结果保存到noder结构体中的file结构中,其实解析的函数就是syntax.Parse() 最主要的函数就是syntax.parser.fileOrNil(fileOrNil很关键,按下不表) 遍历结束后,将该 Node 节点加入到 xtop tree 中,也就是 AST 抽象语法树 生成 Node Tree 树的过程在 p.node() 函数中,就是将 noder 结构体转换成 Node 节点类型,添加到 xtop tree 中,xtop 就是这颗语法树,供后面类型检查使用。
具体代码:
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
方法开启了对当前文件的词法和语法解析
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()中调用的。
// 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 文件中也定义了其他节点的结构体,其中包含了全部声明类型的
函数声明的结构:
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数组
BlockStmt struct {
List []Stmt
Rbrace Pos
stmt
}
syntax.Stmt是一个接口,实现该接口的类型也非常多,在go 15版本中有18不同的实现
这些类型其实和go/ast下的类型大概率都是对应的,但是还有一定区别(大佬感兴趣可以研究研究
这些不同类型的cmd/compile/internal/syntax.Stmt构成了全部命令式的Go语言代码,从中我们可以看到很多熟悉的控制结构,例如if、for 、switch、select,这些命令式的结构在其他的编程语言中也非常常见。
通过词法解析和语法解析,go会把源文件转换为上面定义的file的树型结构。里面包含了各种各样的stmt,至此结束了ast树的转化。
解析完之后就是类型检查和AST转换了,简单讲就是会对构建好的ast树进行遍历,在每个节点上都会对当前子树的类型进行验证,所有的类型错误和不匹配都会在这个阶段被暴露出来,其中包括:结构体对接口的实现。实现aop的功能对这一类型检查过程其实不主要涉及,在此就不再展开,感兴趣可以参考 /https://draveness.me/golang/docs/part1-prerequisite/ch02-compile/golang-typecheck//
在上面主要介绍了go编译器词法分析和语法分析之后,实现aop的方案是显而易见的,我们可以在
cmd/compile/internal/gc.parseFiles函数构建ast的时候,对已经构造好的ast树进行修改,因为go已经有通过实现接口 syntax.Stmt的十几种结构体,所以可以将自己想要植入的代码用对应的结构体构造出来,来实现代码插入的效果。
这也是我们在UGO智能单测辅助工具运用到的核心技术之一,我们需要在接口调用的时候记录整个函数调用的链路,同时录制函数的入参、返回值、调用函数的线程id等相对应的信息,这时候就要在函数的编译构建的时候将记录入参、返回值等信息的切面代码通过改写ast的构建织入业务的代码中,只要构建成功之后就可以将录制的流量输出到我们的存储介质中给解析模块用。改写ast需要改写、定制化go源码,业务代码并不需要改动,真正意义上实现了无侵入aop,只需要配置一个定制版的go即可。
下面通过一个简单的小例子来看看go 的编译增强的具体实现
我们实现在执行ugo()函数的前打印"start UGO ..." 后打印 "end UGO ..."。
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就可以,确保版本大于定制版的go
这样定制版的go才能用它。
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
cd $HOME/src/github.com/golang/go/src
echo 'export GOROOT_BOOTSTRAP=$HOME/go_bootstrap/go1.15.12' >> .envrc
cd $HOME/src/github.com/golang/go/src
./make.bash
这一步骤编译会很慢,如果环境正常会有类似以下输出,就代表可以进行下一步操作了
直接在本地ide新建一个项目,然后在终端里面:
export GOROOT=$HOME/src/github.com/golang/go # 定制版Go的路径
export PATH=$GOROOT/bin:$PATH
创建main
package main
import (
"context"
"fmt"
)
func UGO(ctx context.Context) {
fmt.Println("hello world")
}
func main() {
hello(context.Background())
}
确保你本地的测试项目是由定制版的go完成编译的。
defer f.Close()
p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error
}(filename)
加入下面的代码,修改p.file的值:
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
cd $HOME/src/github.com/golang/go/src
./make.bash
cd $HOME/GolangProject/Hello
go clean -cache
go run hello/main.go
运行结果
start UGO...
hello world
end UGO...
但是我们如果在源代码中没有引用fmt包,会咋样呢
删掉后,重新编译:
$ go clean -cache; go run Hello/main.go
# command-line-arguments
helloworld/main.go:7:16: undefined: fmt in fmt.Println
肯定会报错,但是我们在语法树中加上import呢?
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
$ go clean -cache; go run Hello/main.go
# command-line-arguments
Hello/main.go:1:9: can't find import: "fmt"
还是会报错,原因是在findpkg的过程中,包变量packageFile是从这个map中获取
if packageFile != nil {
file, ok = packageFile[name]
return file, ok
}
packageFile在readImportCfg中初始化
func readImportCfg(file string) {
packageFile = map[string]string{}
data, err := ioutil.ReadFile(file)
if err != nil {
log.Fatalf("-importcfg: %v", err)
}
入参的string是调用compile命令时给的-importcfg的值
文件 cmd/go/internal/work/exec.go
第634行,有关importcfg的内容的逻辑
// 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(&icfg, "# import config\n")
for i, raw := range a.Package.Internal.RawImports {
final := a.Package.Imports[i]
if final != raw {
fmt.Fprintf(&icfg, "importmap %s=%s\n", raw, final)
}
}
在文件 go/build/build.go=,先用 =go/parser.ParseFile
解析源文件,然后获取其中的imports
pf, err := parser.ParseFile(fset, filename, data, parser.ImportsOnly|parser.ParseComments)
if err != nil {
badFile(err)
continue
}
可以修改build.go这个文件,将自己需要的import直接加入,多加的话这里也不会报错因为已经经过了类型检查
// add import
hasFmtImport := false
hasEcodingJsonImport := false
hasRuntime := false
for _, i := range pf.Imports {
if i.Path.Value == `"fmt"` {
hasFmtImport = true
}
if hasFmtImport && hasEcodingJsonImport && hasRuntime {
break
}
}
//if !onlyMain && !hasFmtImport {
if !hasFmtImport {
pf.Imports = append(pf.Imports, &ast.ImportSpec{
Path: &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, &ast.ImportSpec{
Path: &ast.BasicLit{
Kind: token.STRING,
Value: `"fmt"`,
},
})
} else {
// case : no import
pf.Decls = append([]ast.Decl{
&ast.GenDecl{
Specs: []ast.Spec{
&ast.ImportSpec{
Path: &ast.BasicLit{
Kind: token.STRING,
Value: `"fmt"`,
},
},
},
},
}, pf.Decls...)
}
} else {
pf.Decls = append([]ast.Decl{
&ast.GenDecl{
Specs: []ast.Spec{
&ast.ImportSpec{
Path: &ast.BasicLit{
Kind: token.STRING,
Value: `"fmt"`,
},
},
},
},
}, pf.Decls...)
}
重新编译golang,执行go run
$ 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/
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。