一、代码生成逻辑和编译器原理

- 收集Go语言源码文件信息及内容
- 通过Lexer词法分析器进行一系列词法分析
- 生成抽象语法树
- 对抽象语法树进行类型检查
- 生成代码,将抽象语法树转换为机器代码
二、收集Go包信息
Go语言的条件编译有两种定义方法,分别介绍如下。
- 构建标签:在源码里添加注释信息,比如
//+build linux
,该标签决定了源码文件只在Linux平台上才会被编译。 - 文件后缀:改变Go语言代码文件的后缀,比如
foo_linux.go
,该后缀决定了源码文件只在Linux平台上才会被编译。
另外,go/build
工具有几个重要的类型和方法,其中Context
类型指定构建上下文环境,例如GOARCH、GOOS、GOROOT、GOPATH等;Package类型用于描述Go包信息;Import方法导入指定的包,返回该包的Package指针类型,用于收集有关Go包的信息。它们用于处理Go项目目录结构、源码、语法、基本操作等。
gengo收集包信息可以分为两步:为生成的代码文件设置构建标签;收集Go包信息并且读取源码内容
在聊这部分之前,我们可以先看一下支持的命令行参数有哪些
golang
func (g *GeneratorArgs) AddFlags(fs *pflag.FlagSet) {
fs.StringSliceVarP(&g.InputDirs, "input-dirs", "i", g.InputDirs, "Comma-separated list of import paths to get input types from.")
fs.StringVarP(&g.OutputBase, "output-base", "o", g.OutputBase, "Output base; defaults to $GOPATH/src/ or ./ if $GOPATH is not set.")
fs.StringVarP(&g.OutputPackagePath, "output-package", "p", g.OutputPackagePath, "Base package path.")
fs.StringVarP(&g.OutputFileBaseName, "output-file-base", "O", g.OutputFileBaseName, "Base name (without .go suffix) for output files.")
fs.StringVarP(&g.GoHeaderFilePath, "go-header-file", "h", g.GoHeaderFilePath, "File containing boilerplate header text. The string YEAR will be replaced with the current 4-digit year.")
fs.BoolVar(&g.VerifyOnly, "verify-only", g.VerifyOnly, "If true, only verify existing output, do not write anything.")
fs.StringVar(&g.GeneratedBuildTag, "build-tag", g.GeneratedBuildTag, "A Go build tag to use to identify files generated by this command. Should be unique.")
fs.StringVar(&g.TrimPathPrefix, "trim-path-prefix", g.TrimPathPrefix, "If set, trim the specified prefix from --output-package when generating files.")
}
为生成的代码文件构建标签
golang
func Default() *GeneratorArgs {
return &GeneratorArgs{
OutputBase: DefaultSourceTree(),
GoHeaderFilePath: filepath.Join(DefaultSourceTree(), "k8s.io/gengo/boilerplate/boilerplate.go.txt"),
GeneratedBuildTag: "ignore_autogenerated",
GeneratedByCommentTemplate: "// Code generated by GENERATOR_NAME. DO NOT EDIT.",
defaultCommandLineFlags: true,
}
}
在Default中默认指定了GeneratedBuildTag,在每次构建的时候,代码生成器会把它作为构建标签打入生成的代码文件中。
比如在deepcopy_gen的代码生成器中,生成Package信息的时候就会加上这个标签。
golang
func Packages(context *generator.Context, arguments *args.GeneratorArgs) generator.Packages {
boilerplate, err := arguments.LoadGoBoilerplate()
if err != nil {
klog.Fatalf("Failed loading boilerplate: %v", err)
}
inputs := sets.NewString(context.Inputs...)
packages := generator.Packages{}
header := append([]byte(fmt.Sprintf("//go:build !%s\n// +build !%s\n\n", arguments.GeneratedBuildTag, arguments.GeneratedBuildTag)), boilerplate...)
.......
}
最后,生成的代码header如下

这在k8s中表示该文件是由代码生成器自动生成的,不需要人工干预或者编辑。
收集Go包信息并且读取源码内容
golang
// NewBuilder makes a new parser.Builder and populates it with the input
// directories.
func (g *GeneratorArgs) NewBuilder() (*parser.Builder, error) {
b := parser.New()
// flag for including *_test.go
b.IncludeTestFiles = g.IncludeTestFiles
// Ignore all auto-generated files.
b.AddBuildTags(g.GeneratedBuildTag)
for _, d := range g.InputDirs {
var err error
if strings.HasSuffix(d, "/...") {
err = b.AddDirRecursive(strings.TrimSuffix(d, "/..."))
} else {
err = b.AddDir(d)
}
if err != nil {
return nil, fmt.Errorf("unable to add directory %q: %v", d, err)
}
}
return b, nil
}
代码生成器会通过input-dirs传入Go包路径,并且调用build.importPackage
方法
golang
// If we have not seen this before, process it now.
ignoreError := false
if _, found := b.parsed[pkgPath]; !found {
// Ignore errors in paths that we're importing solely because
// they're referenced by other packages.
ignoreError = true
// Add it.
if err := b.addDir(dir, userRequested); err != nil {
if isErrPackageNotFound(err) {
klog.V(6).Info(err)
return nil, nil
}
return nil, err
}
// Get the canonical path now that it has been added.
if buildPkg, _ := b.getLoadedBuildPackage(dir); buildPkg != nil {
canonicalPackage := canonicalizeImportPath(buildPkg.ImportPath)
klog.V(5).Infof("importPackage %s, canonical path is %s", dir, canonicalPackage)
pkgPath = canonicalPackage
}
}
在上面的代码中会去先判断一下path有没有加入到b.parsed
中,如果没有的话就把它"加入进去"。
加入解析map的过程:
golang
// Get package information from the go/build package. Automatically excludes
// e.g. test files and files for other platforms-- there is quite a bit of
// logic of that nature in the build package.
func (b *Builder) importBuildPackage(dir string) (*build.Package, error) {
if buildPkg, ok := b.getLoadedBuildPackage(dir); ok {
return buildPkg, nil
}
// This validates the `package foo // github.com/bar/foo` comments.
buildPkg, err := b.importWithMode(dir, build.ImportComment)
if err != nil {
if _, ok := err.(*build.NoGoError); !ok {
return nil, fmt.Errorf("unable to import %q: %v", dir, err)
}
}
if buildPkg == nil {
// Might be an empty directory. Try to just find the dir.
buildPkg, err = b.importWithMode(dir, build.FindOnly)
if err != nil {
return nil, err
}
}
// Remember it under the user-provided name.
klog.V(5).Infof("saving buildPackage %s", dir)
b.setLoadedBuildPackage(dir, buildPkg)
return buildPkg, nil
}
其中,build.ImportComment
用于解析import语句后面的注释信息,build.FindOnly
用于查找包所在的目录,但是这个地方不会读取源码内容。
这个时候我们就获取到了所有的包路径信息,并且可以准备go源码文件给解析器了,等待Lexer词法解析的处理。
golang
files := []string{}
files = append(files, buildPkg.GoFiles...)
if b.IncludeTestFiles {
files = append(files, buildPkg.TestGoFiles...)
}
for _, file := range files {
if !strings.HasSuffix(file, ".go") {
continue
}
absPath := filepath.Join(buildPkg.Dir, file)
//源码内容
data, err := ioutil.ReadFile(absPath)
if err != nil {
return fmt.Errorf("while loading %q: %v", absPath, err)
}
//解析
err = b.addFile(pkgPath, absPath, data, userRequested)
if err != nil {
return fmt.Errorf("while parsing %q: %v", absPath, err)
}
}
三、代码解析
Go是一个静态语言,它的语法很简单。Go语言标准库就支持代码解析功能。Kubernetes在该基础上进行了功能的封装。整体来说可以分为三步:
- 通过标准库
go/tokens
提供的Lexer词法分所器对代码文本进行词法分析,最终得到Tokens - 通过标准库
go/parser
和go/ast 将 Tokens构建为抽象语法树(AST) - 通过标准库
go/types
下的Check方法进行抽象语法树类型检查,完成代码解析过程
源文件的token保存在b.fset
中,这个对象用于记录文件中偏移量、类型、原始字面量及词法分析的数据结构和方法等。拿到token之后,使用parser.ParseFil
e解析器对Tokens数据进行处理,Parrser解析器将传入两种标识,其中parser.DeclarationErrors
表示报告声明错误,parser.ParseComment
s表示解析代码中的注释并将它们添加到抽象语法树中。最终得到抽象语法树结构。
golang
func (b *Builder) addFile(pkgPath importPathString, path string, src []byte, userRequested bool) error {
for _, p := range b.parsed[pkgPath] {
if path == p.name {
klog.V(5).Infof("addFile %s %s already parsed, skipping", pkgPath, path)
return nil
}
}
klog.V(6).Infof("addFile %s %s", pkgPath, path)
p, err := parser.ParseFile(b.fset, path, src, parser.DeclarationErrors|parser.ParseComments)
if err != nil {
return err
}
......
}
拿到了抽象语法树之后,会对它进行类型检查,使用的是Go标准库的。但是这里增加了缓存,性能优化。
golang
func (b *Builder) typeCheckPackage(pkgPath importPathString, logErr bool) (*tc.Package, error) {
if pkg, ok := b.typeCheckedPackages[pkgPath]; ok {
........
}
......
pkg, err := c.Check(string(pkgPath), b.fset, files, nil)
b.typeCheckedPackages[pkgPath] = pkg // record the result whether or not there was an error
return pkg, err
}
四、类型系统
gengo的类型系统在Go语言本身的类型系统之上归类并添加了几种类型。gengo的类型系统在Go语言标准库go/types
的基础上进行了封装。
所有的类型都通过vendor/k8s.io/gengo/parser/parse.go的walkType方法进行识别,gengo类型系统中的Struct、Map、Pointer、Interface等,与Go语言是供的类型并无差别。
小工具
抽取tag,并且转化成结构化数据,可以用于IOC或者自定义tag中。
golang
// ExtractCommentTags parses comments for lines of the form:
//
// 'marker' + "key=value".
//
// Values are optional; "" is the default. A tag can be specified more than
// one time and all values are returned. If the resulting map has an entry for
// a key, the value (a slice) is guaranteed to have at least 1 element.
//
// Example: if you pass "+" for 'marker', and the following lines are in
// the comments:
// +foo=value1
// +bar
// +foo=value2
// +baz="qux"
// Then this function will return:
// map[string][]string{"foo":{"value1, "value2"}, "bar": {""}, "baz": {"qux"}}
func ExtractCommentTags(marker string, lines []string) map[string][]string {
out := map[string][]string{}
for _, line := range lines {
line = strings.Trim(line, " ")
if len(line) == 0 {
continue
}
if !strings.HasPrefix(line, marker) {
continue
}
// TODO: we could support multiple values per key if we split on spaces
kv := strings.SplitN(line[len(marker):], "=", 2)
if len(kv) == 2 {
out[kv[0]] = append(out[kv[0]], kv[1])
} else if len(kv) == 1 {
out[kv[0]] = append(out[kv[0]], "")
}
}
return out
}
// ExtractSingleBoolCommentTag parses comments for lines of the form:
//
// 'marker' + "key=value1"
//
// If the tag is not found, the default value is returned. Values are asserted
// to be boolean ("true" or "false"), and any other value will cause an error
// to be returned. If the key has multiple values, the first one will be used.
func ExtractSingleBoolCommentTag(marker string, key string, defaultVal bool, lines []string) (bool, error) {
values := ExtractCommentTags(marker, lines)[key]
if values == nil {
return defaultVal, nil
}
if values[0] == "true" {
return true, nil
}
if values[0] == "false" {
return false, nil
}
return false, fmt.Errorf("tag value for %q is not boolean: %q", key, values[0])
}
五、代码生成
代码生成部分主要要去看Generator
接口。
- Name:代码生成器的名称,返回值为生成的目标代码文件名的前缀,例如deepcopy-gen代码生成器的目标代码文件名的前缀为zz_generatted.deepcopy。
- Filter:类型过滤器,过滤掉不符合当前代码生成器所需的类型。
- Namers:命名管理器,支持创建不同类型的名称。例如,根据类型生成名称。
- Init:代码生成器生成代码之前的初始化操作。
- Finalize:代码生成器生成代码之后的收尾操作。
- PackageVars:生成全局变量代码块,例如var(...)。
- PackageConsts:生成常量代码块,例如consts(...)。
- GenerateType:生成代码块。根据传入的类型生成代码。
- Imports:获得需要生成的import代码块。通过该方法生成Go语言的impport代码块,例如import(...)。
- Filename:生成的目标代码文件的全名,例如deepcopy-gen代码生成器的目标代码文件名为zz_generated.deepcopy.go。
- FileType:生成代码文件的类型,一般为golang,也有protoidl等文件类型
golang
// The call order for the functions that take a Context is:
// 1. Filter() // Subsequent calls see only types that pass this.
// 2. Namers() // Subsequent calls see the namers provided by this.
// 3. PackageVars()
// 4. PackageConsts()
// 5. Init()
// 6. GenerateType() // Called N times, once per type in the context's Order.
// 7. Imports()
//
// You may have multiple generators for the same file.
type Generator interface {
// The name of this generator. Will be included in generated comments.
Name() string
// Filter should return true if this generator cares about this type.
// (otherwise, GenerateType will not be called.)
//
// Filter is called before any of the generator's other functions;
// subsequent calls will get a context with only the types that passed
// this filter.
Filter(*Context, *types.Type) bool
// If this generator needs special namers, return them here. These will
// override the original namers in the context if there is a collision.
// You may return nil if you don't need special names. These names will
// be available in the context passed to the rest of the generator's
// functions.
//
// A use case for this is to return a namer that tracks imports.
Namers(*Context) namer.NameSystems
// Init should write an init function, and any other content that's not
// generated per-type. (It's not intended for generator specific
// initialization! Do that when your Package constructs the
// Generators.)
Init(*Context, io.Writer) error
// Finalize should write finish up functions, and any other content that's not
// generated per-type.
Finalize(*Context, io.Writer) error
// PackageVars should emit an array of variable lines. They will be
// placed in a var ( ... ) block. There's no need to include a leading
// \t or trailing \n.
PackageVars(*Context) []string
// PackageConsts should emit an array of constant lines. They will be
// placed in a const ( ... ) block. There's no need to include a leading
// \t or trailing \n.
PackageConsts(*Context) []string
// GenerateType should emit the code for a particular type.
GenerateType(*Context, *types.Type, io.Writer) error
// Imports should return a list of necessary imports. They will be
// formatted correctly. You do not need to include quotation marks,
// return only the package name; alternatively, you can also return
// imports in the format `name "path/to/pkg"`. Imports will be called
// after Init, PackageVars, PackageConsts, and GenerateType, to allow
// you to keep track of what imports you actually need.
Imports(*Context) []string
// Preferred file name of this generator, not including a path. It is
// allowed for multiple generators to use the same filename, but it's
// up to you to make sure they don't have colliding import names.
// TODO: provide per-file import tracking, removing the requirement
// that generators coordinate..
Filename() string
// A registered file type in the context to generate this file with. If
// the FileType is not found in the context, execution will stop.
FileType() string
}
下面以deepcopy-gen代码生成器为例,看一下代码生成的原理。
首先通过build.sh脚本,手动构建deepcopy-gen代码生成器二进制文件,然后将需要生成的包k8s.io/kubernetes/pkg/apis/abac/v1betal作为deepcopy-gen的输入源,并在内部进行一系列解析,最终通过-O参数生成名为zz_generated.deepcopy.go的代码文件。
实例化generator.Packages
golang
packages = append(packages,
&generator.DefaultPackage{
PackageName: strings.Split(filepath.Base(pkg.Path), ".")[0],
PackagePath: path,
HeaderText: header,
GeneratorFunc: func(c *generator.Context) (generators []generator.Generator) {
return []generator.Generator{
NewGenDeepCopy(arguments.OutputFileBaseName, pkg.Path, boundingDirs, (ptagValue == tagValuePackage), ptagRegister),
}
},
FilterFunc: func(c *generator.Context, t *types.Type) bool {
return t.Name.Package == pkg.Path
},
})
在deepcopy-gen代码生成器的Packages
函数中,实例化generator.Packages
对象并返回该对象。根据输入源信息,实例化当前Packages
对象的结构:PackageName
字段为vlbetal,PackagePath
字段为k8s.io/kubernetes/pkg/apis/abac/vIbetal。其中,最主要的是GeneratorFunc定义了Generator接口的实现(即NewCGenDeepCopy实现了Generator接口方法)。
执行代码生成
在gengo中,generator定义代码生成器通用接口Generator
。通过ExecutePackage
函数,调用不同代码生成器(如deepcopy-gen)的Generator
接口方法,并生成代码。代码示例如下:
golang
func (c *Context) ExecutePackage(outDir string, p Package) error {
path := filepath.Join(outDir, p.Path())
........
for _, g := range p.Generators(packageContext) {
// Filter out types the *generator* doesn't care about.
genContext := packageContext.filteredBy(g.Filter)
// Now add any extra name systems defined by this generator
genContext = genContext.addNameSystems(g.Namers(genContext))
fileType := g.FileType()
if len(fileType) == 0 {
return fmt.Errorf("generator %q must specify a file type", g.Name())
}
f := files[g.Filename()]
..........
}
在第一步中返回的DefaultPackage
就是Package
的一个实例,而Generators
会直接调用d.GeneratorFunc(c)
ExecutePackage
的执行流程如下:
- 生成Header代码块
- 生成Imports代码块
- 生成Vars全局变量代码块
- 生成Consts常量代码块
- 生成Body代码块。
最后通过
golang
err = assembler.AssembleFile(f, finalPath)
将生成代码写入文件当中。
真正执行代码生成的逻辑其实是在GenerateType
方法中的,在Generator
接口里。deepcopy-gen实现了这个接口:
在这个代码内部,通过Go语言标准库text/template
模版语言渲染出生成的Body代码块信息。
golang
sw := generator.NewSnippetWriter(w, c, "$", "$")
args := argsFromType(t)
if deepCopyIntoMethodOrDie(t) == nil {
sw.Do("// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\n", args)
if isReference(t) {
sw.Do("func (in $.type|raw$) DeepCopyInto(out *$.type|raw$) {\n", args)
sw.Do("{in:=&in\n", nil)
} else {
sw.Do("func (in *$.type|raw$) DeepCopyInto(out *$.type|raw$) {\n", args)
}
if deepCopyMethodOrDie(t) != nil {
if t.Methods["DeepCopy"].Signature.Receiver.Kind == types.Pointer {
sw.Do("clone := in.DeepCopy()\n", nil)
sw.Do("*out = *clone\n", nil)
} else {
sw.Do("*out = in.DeepCopy()\n", nil)
}
sw.Do("return\n", nil)
} else {
g.generateFor(t, sw)
sw.Do("return\n", nil)
}
if isReference(t) {
sw.Do("}\n", nil)
}
sw.Do("}\n\n", nil)
}
generator.NewSnippetWriter
内部封装了text/template
模板语言,通过将模板应用于数据结构来执行模板。SnippetWriter
对象在实例化时传入模板指令的标识符(即指令开始为 <math xmlns="http://www.w3.org/1998/Math/MathML"> ,指令结束为 ,指令结束为 </math>,指令结束为,有时候也会使用{{}}作为模板指令的标识符)。
SnippetWriter
通过Do
函数加载模板字符串,并执行渲染模板。模板指令中的点表示引用args参数传递到模板指令中。模板指令中的|表示管道符,即把左边的值传递给右边。
六、扩展-iocli
最近在调研一些代码生成的框架,其中看到了Alibaba开源的IOC-Golang中利用controller-tools提供的能力实现了一套简单的代码生成cli。
Repo:github.com/alibaba/IOC...
代码结构

该cli主要是解析一些注释上面的信息,并且根据注释信息来生成代码,达到IOC和AOP的部分能力。暂且不说代码生成的方式来实现IOC的优劣,个人感觉面对不可读的项目代码是很难受的,况且如果出现了引入的bug,进行排查将会是一场噩梦。虽然代码生成是主流,毕竟性能高,并且非极端case可能风险也比较小。
我们先来看一下iocli-gen的项目,作为一个代码生成的学习项目还是非常好的。
项目主要分为两个部分:
- generator:维护代码生成的上下文,生成代码的主要逻辑
- marker:解析tag的定义,如下:
golang
// +ioc:autowire=true
// +ioc:autowire:type=allimpls
// +ioc:autowire:proxy=false
// +ioc:autowire:implements=github.com/alibaba/ioc-golang/iocli/gen/marker.DefinitionGetter
Marker
marker是用来配置tag的解析规则的,实际上需要的是一个*markers.Definition
。比如:
golang
type enableIOCGolangAutowireMarker struct {
}
func (m *enableIOCGolangAutowireMarker) GetMarkerDefinition() *markers.Definition {
return markers.Must(markers.MakeDefinition("ioc:autowire", markers.DescribesType, false))
}
通过下面的方法可以定义作用类型(Package、Type、Field),返回类型和名称
golang
func MakeDefinition(name string, target TargetType, output interface{}) (*Definition, error) {
def := &Definition{
Name: name,
Target: target,
Output: reflect.TypeOf(output),
Strict: true,
}
if err := def.loadFields(); err != nil {
return nil, err
}
return def, nil
}
Generator
generator
实现了Generator
接口,并且完成了Marker的注册,和代码生成的真正逻辑。
Marker注册
golang
// RegisterMarkers is called in main, register all markers
func (Generator) RegisterMarkers(into *markers.Registry) error {
allImplGettersVal, err := allimpls.GetImpl(util.GetSDIDByStructPtr(new(marker.DefinitionGetter)))
if err != nil {
return err
}
defs := make([]*markers.Definition, 0)
for _, g := range allImplGettersVal.([]marker.DefinitionGetter) {
defs = append(defs, g.GetMarkerDefinition())
}
return markers.RegisterAll(into, defs...)
}
代码生成
golang
func (d Generator) Generate(ctx *genall.GenerationContext) error {
.......
for _, root := range ctx.Roots {
// 2. generate codes under current pkg
outContents := objGenCtx.generateForPackage(ctx, root)
if outContents == nil {
continue
}
// 3. write codes to file
writeOut(ctx, nil, root, outContents)
}
return nil
}
在generateForPackage
当中,根据传入的路径package会先进行一个类型检查。
golang
infos := make([]*markers.TypeInfo, 0)
if err := markers.EachType(ctx.Collector, root, func(info *markers.TypeInfo) {
infos = append(infos, info)
}); err != nil {
root.AddError(err)
return nil
}
然后会从上下文的容器中,读取出来在源文件目录下所有加载出来的markers.TypeInfo
信息,这个信息实际上就保存了通过marker
解析出来的数据,这个会是一个map形式。真正的代码生成逻辑如下所示:
golang
copyCtx.generateMethodsFor(genCtx, root, imports, infos)
outBytes := outContent.Bytes()
outContent = new(bytes.Buffer)
writeHeader(root, outContent, root.Name, imports, ctx.HeaderText)
writeMethods(root, outContent, outBytes)
outBytes = outContent.Bytes()
formattedBytes, err := format.Source(outBytes)
在generateMethodsFor
中就是根据业务需求,依据markers.TypeInfo
信息进行代码生成,其中可以根据业务需求定制模板。writeHeader
和writeMethods
就是来补充生成文件信息的。
最后一步,就是输出文件到指定的目录下面了。
golang
func writeOut(ctx *genall.GenerationContext, outputFile io.WriteCloser, root *loader.Package, outBytes []byte) {
if outputFile == nil {
var err error
name := "zz_generated.ioc.go"
outputFile, err = ctx.Open(root, name)
if err != nil {
root.AddError(err)
return
}
}
n, err := outputFile.Write(outBytes)
if err != nil {
root.AddError(err)
return
}
if n < len(outBytes) {
root.AddError(io.ErrShortWrite)
}
}
所以,如果需要自己定制 cli 的话,也是可以借鉴这个项目的,只需要重写marker的定义,以及 代码生成 部分即可。这个项目为我们提供了比较好的cli模板。