跳过正文

gengo代码生成核心实现

·1268 字·6 分钟
K8s-Codegen - 这篇文章属于一个选集。
§ 5: 本文

gengo 代码生成核心实现
#

Kubernetes 的代码生成器都是在 k8s.io/gengo 包的基础上实现的。我们在前面章节中介绍 deepcopy-gen、defaulter-gen、conversion-gen、openapi-gen、prerelease-lifecycle-gen、client-gen、lister-gen、informer-gen、applyconfiguration-gen 等代码生成器的用法。代码生成器都会通过一个输入包路径 (--input-dirs) 参数,根据 gengo 的词法分析、生成抽象语法树 (Abstract Syntax Tree, AST) 等操作,最终生成并输出 (--output-file-base) 代码。gengo 代码目录结构如下。

$ tree vendor/k8s.io/gengo/ -L 1
vendor/k8s.io/gengo/
├── args
├── examples
├── generator
├── namer
├── parser
└── types

gengo 代码目录结构说明如表 13-7 所示。

表 13-7 gengo 代码目录结构说明

目录说明
args代码生成器的通用 flag 参数
examplesgengo 提供的官方示例代码
generator代码生成器通用接口 Generator
namer命名管理,支持创建不同类型的命名。例如,根据类型生成名称,代码定义 type foo string,能够生成 func FooPrinter(f *foo) { Print(string(*f)) }
parser代码解析器,用来构造抽象语法树
types定义了 gengo 中的核心数据结构

代码生成与编译器原理
#

gengo 的代码生成逻辑与编译器原理非常类似,大致可分为以下几个过程,gengo 生成代码的过程如下:

image-20260516014501670
  • Gather The Info:收集 Go 语言源码文件信息及内容。
  • Lexer/Parser:通过 Lexer 词法分析器进行一系列词法分析。
  • AST Generator:生成抽象语法树。
  • Type Checker:对抽象语法树进行类型检查。
  • Code Generation:生成代码,将抽象语法树转换为机器代码。

收集Go 包信息
#

Go 语言没有预处理器、宏定义或 #define 声明来控制指定平台。相反,Go 语言标准库提供 go/build 工具,该工具支持通过 Go 语言的 build tag 机制来构建约束条件 (Build Constraints),我们在阅读 Kubernetes 源码时会经常看到与 // +build linux,darwin 类似的包注释信息。这就是 Go 语言编译时的约束条件,也被称为条件编译。

Go 语言的条件编译有两种方法定义,分别介绍如下。

  • 构建标签 (build tag):在源码里添加注释信息,如 // +build linux,该标签决定了这个源码文件只在 Linux 平台下才会被编译。
  • 文件后缀:改变 Go 语言代码文件名的后缀,如 foo_linux.go,该后缀决定了这个源码文件只在 Linux 平台下才会被编译。

另外,go/build 包有几个重要的类型和方法。Context 类型指定构建上下文环境,如 GOARCH、GOOS、GOROOT、GOPATH 等。Package 类型用于描述 Go 包信息。Import 方法用于导入指定的包,返回该包的 Package 指针类型,收集 Go 包的信息。它们用于处理 Go 项目的目录结构、源码、语法、基本操作等。

gengo 收集 Go 包信息可分为两步。第一步,为生成的代码文件设置构建标签;第二步,收集 Go 包信息并读取源码内容。详细过程如下。

为生成的代码文件设置构建标签
#

代码路径:vendor/k8s.io/gengo/args/args.go

func Default() *GeneratorArgs {
    return &GeneratorArgs{
        GeneratedBuildTag: "ignore_autogenerated",
    }
}

func (g *GeneratorArgs) NewBuilder() (*parser.Builder, error) {
    b := parser.New()
    // Ignore all auto-generated files.
    b.AddBuildTags(g.GeneratedBuildTag)
    // ...
}

Default 函数中定义了默认的 GeneratedBuildTag 字符串,每次构建时,代码生成器会将 GeneratedBuildTag 字符串作为构建标签添加到生成的代码文件中。每一个代码生成器都会通过 Packages 函数执行该操作。以 deepcopy-gen 生成器为例,代码示例如下。

代码路径:vendor/k8s.io/gengo/examples/deepcopy-gen/generators/deepcopy.go

func Packages(...) generator.Packages {
    header := append([]byte(fmt.Sprintf("// +build !%s\n\n", arguments.GeneratedBuildTag)), boilerplate...)
    // ...
}

deepcopy-gen 代码生成器中的 Packages 函数将 GeneratedBuildTag 字符串进行拼接,每一个通过 deepcopy-gen 代码生成器生成的代码文件 (zz_generated.deepcopy.go),第一行总是构建标签。生成构建标签的效果如下。

$ head -n 1 pkg/apis/abac/v1beta1/zz_generated.deepcopy.go
// +build !ignore_autogenerated

!ignore_autogenerated 在 Kubernetes 中表示该文件是由代码生成器自动生成的,不需要人工干预或人工编辑该文件。

收集Go包信息并读取源码内容
#

代码路径:vendor/k8s.io/gengo/args/args.go

func (g *GeneratorArgs) NewBuilder() (*parser.Builder, error) {
    // ...
    for _, d := range g.InputDirs {
        var err error
        if strings.HasSuffix(d, "/...") {
            err = b.AddDirRecursive(strings.TrimSuffix(d, "/..."))
        } else {
            err = b.AddDir(d)
        }
    }
    return b, nil
}

代码生成器通过 --input-dirs 参数指定传入的 Go 包路径,通过 build.Import 函数收集 Go 包的信息。build.Import 函数支持多种模式,其中,build.ImportComment 用于解析 import 语句后的注释信息;build.FindOnly 用于查找 Go 包所在的目录,但不读取其中的源码的内容。代码函数层级为 b.AddDir -> b.importPackage -> b.addDir,代码示例如下。

代码路径:vendor/k8s.io/gengo/parser/parse.go

func (b *Builder) importBuildPackage(dir string) (*build.Package, error) {
    buildPkg, err := b.importWithMode(dir, build.ImportComment)
    if buildPkg == nil {
        buildPkg, err = b.importWithMode(dir, build.FindOnly)
    }
    // ...
}

通过 build.Import 函数获取 Go 包信息后,就可以得到 Go 包下的所有源码文件路径,将所有 Go 源码内容读入内存,等待 Lexer 进行下一步处理,代码示例如下。

func (b *Builder) addDir(dir string, userRequested bool) error {
    // ...
    for _, n := range buildPkg.GoFiles {
        if !strings.HasSuffix(n, ".go") {
            continue
        }
        absPath := filepath.Join(buildPkg.Dir, n)
        data, err := ioutil.ReadFile(absPath)
        err = b.addFile(pkgPath, absPath, data, userRequested)
    }
    return nil
}

代码解析
#

Go 语言的优势在于它是一种静态类型的语言,语法简单,与动态类型的语言相比,其特性更简单些。幸运的是,Go 语言标准库支持代码解析功能,而 Kubernetes 在该基础上进行了封装。代码解析流程可以分为 3 步:

image-20260516014546471

首先,通过 Go 语言标准库 go/tokens 提供的 Lexer 将代码文本进行词法分析,得到 Tokens。然后,通过 Go 语言标准库 go/parsergo/ast 将 Tokens 构建为抽象语法树。最后,通过 Go 语言标准库 go/types 下的 Check 方法进行抽象语法树类型检查。

Lexer 词法分析器
#

Go 语言标准库 go/tokens 提供词法分析器 (Lexical Analyzer,简称为 Lexer,也被称为扫描器)。词法分析器将字符序列转换为单词序列 (Tokens 或 Token List),其工作原理是对输入的代码文本进行词法分析,将一个个字符按照从左到右的顺序读入,根据构词规则识别单词,最终得到 Tokens。Token 是语言中的最小单位,它可以是变量、函数、运算符或数字。

x * i + 1 文本表达式为例,通过 Lexer 处理后得到 Token List。Lexer 的词法分析过程如下:

image-20260516014605465

Parser 解析器
#

通过 Lexer 得到 Token List 以后,它将被传递给 Parser 解析器。Parser 解析器将 Token List 转换为抽象语法树。抽象语法树也被称为语法树 (Syntax Tree),是编程语言源码的抽象语法结构的树状表现形式,树上的每个节点都表示源码中的一种结构。可以通过 Go 标准库 go/ast 打印出完整的抽象语法树结构。

抽象语法树是源码的结构化表示。通过抽象语法树,能够看到程序结构,如函数和常量声明。可以通过Go标准库go/ast打印出完整的抽象语法树结构。

image-20260516014703749

Type-Checking 类型检查
#

通过 Parser 解析器得到抽象语法树后,需要对抽象语法树中定义和使用的类型进行检查。对抽象语法树的节点进行遍历,在每个节点上对当前子树的类型进行检查,进而保证不会出现类型错误。通过 Go 标准库 go/types 下的 Check 方法可以进行抽象语法树检查。

另外,遍历抽象语法树有多种方式,如 DFS(深度优先搜索)遍历和 BFS(广度优先搜索)遍历。

代码解析过程实现
#

实现代码解析需要通过 Lexer 词法分析器、Parser 解析器和 Type-Checking 类型检查。理解了上面的内容后,下面我们来学习 gengo 的代码解析实现,代码示例如下。

代码路径:vendor/k8s.io/gengo/parser/parse.go

func New() *Builder {
    c := build.Default
    return &Builder{
        context: &c,
        fset:    token.NewFileSet(),
    }
}

func (b *Builder) addFile(...) error {
    klog.V(6).Infof("addFile %s %s", pkgPath, path)
    p, err := parser.ParseFile(b.fset, path, src, parser.DeclarationErrors|parser.ParseComments)
    // ...
}

首先,token.NewFileSet 实例化得到 token.FileSet 对象,该对象用于记录文件中的偏移量、类型、原始字面量,以及词法分析的数据结构和方法等。得到 Tokens 后,在 addFile 函数中,使用 parser.ParseFile 对 Token 数据进行处理,Parser 解析器传入两种标识,其中,parser.DeclarationErrors 表示报告声明错误,parser.ParseComments 表示解析代码中的注释并将它们添加到抽象语法树。最终得到抽象语法树结构。

得到抽象语法树后,可以对抽象语法树进行类型检查,通过 Go 语言标准库 go/types 下的 Check 方法进行检查,检查过程中会进行一些优化,使程序的执行更快,代码示例如下。

func (b *Builder) typeCheckPackage(pkgPath importPathString) (*tc.Package, error) {
    c := tc.Config{
        IgnoreFuncBodies: true,
        Importer:         importAdapter{b},
        Error: func(err error) {
            klog.V(2).Infof("type checker: %v\n", err)
        },
    }
    pkg, err := c.Check(string(pkgPath), b.fset, files, nil)
    b.typeCheckedPackages[pkgPath] = pkg
    return pkg, err
}

类型系统
#

gengo 的类型系统 (Type System) 在 Go 语言本身的类型系统归类的基础上添加了几种类型。gengo 的类型系统在 Go 语言标准库 go/types 基础上进行了封装。gengo 的类型系统提供了以下类型。

代码路径:vendor/k8s.io/gengo/types/types.go

type Kind string

const (
    Builtin       Kind = "Builtin"
    Struct        Kind = "Struct"
    Map           Kind = "Map"
    Slice         Kind = "Slice"
    Pointer       Kind = "Pointer"
    Alias         Kind = "Alias"
    Interface     Kind = "Interface"
    Array         Kind = "Array"
    Chan          Kind = "Chan"
    Func          Kind = "Func"
    DeclarationOf Kind = "DeclarationOf"
    Unknown       Kind = "Unknown"
    Unsupported   Kind = "Unsupported"
    Protobuf      Kind = "Protobuf"
)

type Signature struct {
    Receiver       *Type
    Parameters     []*Type
    ParameterNames []string
    Results        []*Type
    ResultNames    []string
    Variadic       bool
    CommentLines   []string
}

所有的类型都通过 vendor/k8s.io/gengo/parser/parse.gowalkType 方法进行识别。在这里介绍下 gengo 与 Go 语言不同的类型,如 Builtin、Alias、DeclarationOf、Unknown、Unsupported 和 Protobuf。另外,Signature 并不是一种类型,它依赖于 Func 函数类型,用来描述 Func 函数的接收参数信息和返回值信息等。

1) Builtin 内置类型 Builtin 将多种 Base 类型归类成一种类型。以下几种类型在 gengo 中统称为 Builtin 类型。

  • 内置字符串类型——string
  • 内置布尔类型——bool
  • 内置数字类型——intfloatcomplex64 等。

2) Alias 别名类型 Alias 别名类型是 Go 1.9 版本中支持的特性,代码示例如下。

type T1 struct{}
type T2 = T1

这里的 T2 相当于 T1 的别名。但在 Go 语言标准库的 reflect (反射) 包识别 T2 原始类型时,它是 Struct 类型,无法识别它是 Alias 类型。gengo 依赖于 go/types 的 Named 类型,在声明时将 TypeName 对象绑定到 Named 类型。

3) DeclarationOf 声明类型 DeclarationOf 并不是严格意义上的类型,它是一种声明过的函数、全局变量或常量,但并未被引用过,代码示例如下。

代码路径:pkg/apis/abac/v1beta1/register.go

AddToScheme = localSchemeBuilder.AddToScheme

例如,在 register.go 中,AddToScheme 变量被声明后未被其他对象使用,则可以认为它是 DeclarationOf 类型的。

4) Unknown——未知类型 当对象与以上所有类型都不匹配时,它就是 Unknown 类型。

5) Unsupported——未支持类型 当对象属于 Unknown 类型时,会将该对象设置为 Unsupported 类型,并且在使用过程中会报错。

6) Protobuf——protobuf类型 由 go-to-protobuf 代码生成器单独处理。

代码生成
#

编译器的代码生成一般生成的是二进制代码,而 Kubernetes 的代码生成器生成的是 Go 语言代码。首先了解一下 gengo 的 Generator 接口,接口定义如下。

代码路径:vendor/k8s.io/gengo/generator/generator.go

type Generator interface {
    Name() string
    Filter(*Context, *types.Type) bool
    Namers(*Context) namer.NameSystems
    Init(*Context, io.Writer) error
    Finalize(*Context, io.Writer) error
    PackageVars(*Context) []string
    PackageConsts(*Context) []string
    GenerateType(*Context, *types.Type, io.Writer) error
    Imports(*Context) []string
    Filename() string
    FileType() string
}

Generator 接口的说明如下:

  • Name:代码生成器的名称,返回值为生成的目标代码文件名的前缀,如 deepcopy-gen 代码生成器的目标代码文件名为 zz_generated.deepcopy
  • Filter:类型过滤器,过滤不符合当前代码生成器所需的类型。
  • Namers:命名管理器,支持创建不同类型的命名,如根据类型生成名称。
  • Init:代码生成器生成代码之前的初始化操作。
  • Finalize:代码生成器生成代码之后的收尾操作。
  • PackageVars:生成全局变量代码块,如 var(...)
  • PackageConsts:生成常量代码块,如 consts(...)
  • GenerateType:生成代码块,根据传入的类型生成代码。
  • Imports:获得需要生成的 import 代码块,通过该方法生成 Go 语言的 import 代码块。如 import(...)
  • Filename:生成的目标代码文件的全名,如 deepcopy-gen 代码生成器的目标代码文件的全名为 zz_generated.deepcopy.go
  • FileType:生成代码文件的类型,一般为 golang,也有 protoidl、api-violation 等代码文件类型。

Kubernetes 目前提供的每个代码生成器都实现了以上接口。如果代码生成器没有实现某些接口,则继承默认生成器 (DefaultGen) 的方法,DefaultGen 定义在 vendor/k8s.io/gengo/generator/default_generator.go 中。

以 deepcopy-gen 代码生成器为例,详细讲解其代码生成原理,命令如下。

$hack/make-rules/build.sh k8s.io/code-generator/cmd/deepcopy-gen$ ./hack/run-in-gopath.sh ./_output/bin/deepcopy-gen \
  --v 1 \
  --logtostderr \
  -i "k8s.io/kubernetes/pkg/apis/abac/v1beta1" \
  --bounding-dirs k8s.io/kubernetes,"k8s.io/api" \
  -O zz_generated.deepcopy

首先,通过 build.sh 脚本手动构建 deepcopy-gen 代码生成器二进制文件。然后,将需要生成的包 k8s.io/kubernetes/pkg/apis/abac/v1beta1 作为 deepcopy-gen 的输入源,内部进行一系列解析,最终通过 -O 参数,生成名为 zz_generated.deepcopy 的代码文件。

image-20260516014841392

实例化 generator.Packages 对象
#

deepcopy-gen 代码生成器根据输入的包的目录路径 (输入源) 实例化 generator.Packages 对象,根据 generator.Packages 结构生成代码,代码示例如下。

代码路径:vendor/k8s.io/gengo/examples/deepcopy-gen/generators/deepcopy.go

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(...),
            }
        },
        FilterFunc: func(c *generator.Context, t *types.Type) bool {
            return t.Name.Package == pkg.Path
        },
    })

在 deepcopy-gen 代码生成器的 packages 函数中,实例化 generator.Packages 对象并返回该对象。根据输入源信息,实例化当前 packages 对象,使其 PackageName 字段为 v1beta1,PackagePath 字段为 k8s.io/kubernetes/pkg/apis/abac/v1beta1。其中,最主要的 GeneratorFunc 字段定义了 Generator 接口的实现 (NewGenDeepCopy 实现了 Generator 接口方法)。

执行代码生成
#

在 gengo 中,generator 定义代码生成器通用接口 Generator。通过 ExecutePackage 函数,调用不同代码生成器 (例如 deepcopy-gen) 的 Generator 接口方法,执行代码生成,代码示例如下。

代码路径:vendor/k8s.io/gengo/generator/execute.go

func (c *Context) ExecutePackage(outDir string, p Package) error {
    for _, g := range p.Generators(packageContext) {
        genContext := packageContext.filteredBy(g.Filter)
        f := files[g.Filename()]
        if f == nil {
            f = &File{
                Name:        g.Filename(),
                FileType:    fileType,
                PackageName: p.Name(),
                Header:      p.Header(g.Filename()),
                Imports:     map[string]struct{}{},
            }
            files[f.Name] = f
        }
        
        if vars := g.PackageVars(genContext); len(vars) > 0 {
            // ...
        }
        if consts := g.PackageConsts(genContext); len(consts) > 0 {
            // ...
        }
        if err := genContext.executeBody(&f.Body, g); err != nil {
            return err
        }
        if imports := g.Imports(genContext); len(imports) > 0 {
            // ...
        }
    }
    // ...
    err = assembler.AssembleFile(f, finalPath)
}

ExecutePackage 函数生成代码的执行流程为:生成 Header 代码块 → 生成 Imports 代码块 → 生成 Vars 全局变量代码块 → 生成 Consts 常量代码块 → 生成 Body 代码块。最后,调用 assembler.AssembleFile 函数,将生成的代码块信息写入 zz_generated.deepcopy.go 文件。生成的代码结构图如下:

image-20260516014918398

deepcopy-gen 代码生成器最终生成代码文件 zz_generated.deepcopy.go,该文件的整体结构可分为以下部分:

  • Header:代码块信息,包括 build tag 和 license boilerplate 文件 (存放开源软件作者及开源协议等信息)。
  • Imports:代码块信息,引入外部包。
  • Vars:全局变量,当前代码文件未使用 Vars。
  • Consts:常量代码块信息,当前代码文件未使用 Consts。
  • Body:代码块信息,生成的 DeepCopy 函数。

在生成代码的过程中,Filter 函数和 GenerateType 函数非常重要。deepcopy-gen 代码生成器根据 Filter 类型过滤器筛选哪些结构是需要生成的,deepcopy-gen 的 Filter 类型过滤器实现如下。

代码路径:vendor/k8s.io/gengo/examples/deepcopy-gen/generators/deepcopy.go

func copyableType(t *types.Type) bool {
    if t.Kind != types.Struct {
        return false
    }
    return true
}

可以看到 Filter -> copyableType 的实现,deepcopy-gen 代码生成器只筛选出类型为 Struct 的数据 (只为 Struct 结构的数据生成 DeepCopy 函数)。

GenerateType 函数根据传入的类型生成 Body 代码块信息。内部通过 Go 语言标准库 text/template 模板语言渲染出生成的 Body 代码块信息,代码示例如下。

func (g *genDeepCopy) GenerateType(...) error {
    // ...
    sw := generator.NewSnippetWriter(w, c, "$", "$")
    args := argsFromType(t)
    
    if deepCopyMethodOrDie(t) == nil {
        sw.Do("// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new $.type|raw$.\n", args)
        if isReference(t) {
            sw.Do("func (in $.type|raw$) DeepCopy() $.type|raw$ {\n", args)
        } else {
            sw.Do("func (in *$.type|raw$) DeepCopy() *$.type|raw$ {\n", args)
            sw.Do("if in == nil { return nil }\n", nil)
        }
    }
    return sw.Error()
}

generator.NewSnippetWriter 内部封装了 text/template 模板语言,通过将模板应用于数据结构来执行模板。SnippetWriter 对象在实例化时传入模板指令的标识符 (指令开始为 $,指令结束为 $,有时也会使用 {{}} 作为模板指令的标识符)。

SnippetWriter 通过 Do 函数加载模板字符串,并且执行渲染模板。模板指令中的点 (.) 表示引用 args 参数并将其传递到模板指令中。模板指令中的 | 表示管道符,将左边的值传递给右边。

凉柠
作者
凉柠
专注于 Kubernetes、分布式系统与 AI Agent 架构探索。
K8s-Codegen - 这篇文章属于一个选集。
§ 5: 本文