Kubernetes源码剖析
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.7 gengo代码生成核心实现

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

gengo代码目录结构说明如下。

args:代码生成器的通用flags参数。

examples:包含deepcopy-gen、defaulter-gen、import-boss、set-gen等代码生成器的生成逻辑。

generator:代码生成器通用接口Generator。

namer:命名管理,支持创建不同类型的名称。例如,根据类型生成名称,定义type foo string,能够生成func FooPrinter(f*foo){Print(string(*f))}。

parser:代码解析器,用来构造抽象语法树。

types:类型系统,用于数据类型的定义及类型检查算法的实现。

2.7.1 代码生成逻辑与编译器原理

gengo的代码生成逻辑与编译器原理非常类似,大致可分为如下几个过程,gengo代码生成原理如图2-5所示。

图2-5 gengo代码生成原理

gengo代码生成原理的流程如下。

(1)Gather The Info:收集Go语言源码文件信息及内容。

(2)Lexer/Parser:通过Lexer词法分析器进行一系列词法分析。

(3)AST Generator:生成抽象语法树。

(4)Type Checker:对抽象语法树进行类型检查。

(5)Code Generation:生成代码,将抽象语法树转换为机器代码。

2.7.2 收集Go包信息

Go语言没有用预处理器、宏定义或#define声明来控制指定平台,相反,Go语言标准库提供了go/build工具,该工具支持Go语言的构建标签(Build Tag)机制来构建约束条件(Build Constraint)。我们在看Kubernetes源码时经常会看到类似于//+build linux darwin的包注释信息,这就是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包信息可分为两步:第1步,为生成的代码文件设置构建标签;第2步,收集Go包信息并读取源码内容。详细过程如下。

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

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

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

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

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

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

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

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

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

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

通过build.Import方法获得Go包信息以后,就可以得到包下面的所有源码文件的路径了,将所有Go源码内容读入内存中,等待Lexer词法解析器的下一步处理,代码示例如下:

2.7.3 代码解析

Go语言的优势在于它是一个静态类型语言,语法很简单,与动态类型语言相比更简单一些。幸运的是,Go语言标准库支持代码解析功能,而Kubernetes在该基础上进行了功能封装。代码解析流程可分为3步,gengo代码解析流程如图2-6所示。

图2-6 gengo代码解析流程

代码解析流程:第1步,通过标准库go/tokens提供的Lexer词法分析器对代码文本进行词法分析,最终得到Tokens;第2步,通过标准库go/parser和go/ast将Tokens构建为抽象语法树(AST);第3步,通过标准库go/types下的Check方法进行抽象语法树类型检查,完成代码解析过程。

1.Lexer词法分析器

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

例如“x*i+1”文本表达式,通过Lexer词法分析器处理后得到Token序列。Lexer词法分析器示例如图2-7所示。

图2-7 Lexer词法分析器示例

2.Parse解析器

通过Lexer词法分析器得到Token序列以后,它将被传递给Parser解析器。解析器是编译器的一个阶段,它将Token序列转换为抽象语法树(AST,Abstract Syntax Tree)。抽象语法树也被称为语法树(Syntax Tree),是编程语言源码的抽象语法结构的树状表现形式,树上的每个节点都表示源码中的一种结构。

抽象语法树是源码的结构化表示。在抽象语法树中,我们能够看到程序结构,例如函数和常量声明。可通过Go语言标准库go/ast打印出完整的抽象语法树结构。Parse解析器示例如图2-8所示。

图2-8 Parse解析器示例

3.Type-Checking类型检查

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

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

4.代码解析过程实现

通过上面的内容,可以知道实现代码解析需要通过Lexer词法分析器、Parser解析器和Type-Checking类型检查。理解上面的内容后,下面来看看gengo的代码解析实现,代码示例如下:

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

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

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

2.7.4 类型系统

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

提示:go/types是Go语言程序的类型检查器,由Robert Griesemer设计。在Go语言1.5版本中,它成为Go语言标准库的一部分。它也是Go语言标准库中最复杂的包之一,完全掌握并使用它需要深入了解Go语言程序的结构。

gengo类型系统提供如下类型:

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

所有的类型都通过vendor/k8s.io/gengo/parser/parse.go的walkType方法进行识别。gengo类型系统中的Struct、Map、Pointer、Interface等,与Go语言提供的类型并无差别。下面介绍一下gengo与Go语言不同的类型,例如Builtin、Alias、DeclarationOf、Unknown、Unsupported及Protobuf。另外,Signature并非是一个类型,它依赖于Func函数类型,用来描述Func函数的接收参数信息和返回值信息等。

1.Builtin(内置类型)

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

● 内置字符串类型——string。

● 内置布尔类型——bool。

● 内置数字类型——int、float、complex64等。

2.Alias(别名类型)

Alias类型是Go 1.9版本中支持的特性,代码示例如下:

代码第2行,通过等于(=)符号,基于一个类型创建了一个别名。这里的T2相当于T1的别名。但在Go语言标准库的reflect(反射)包识别T2的原始类型时,会将它识别为Struct类型,而无法将它识别为Alias类型。原因在于,Alias类型在运行时是不可见的,详情请参考Go语言官方提议(参见链接[2])。

如何让Alias类型在运行时可被识别呢?答案是因为gengo依赖于go/types的Named类型,所以要让Alias类型在运行时可被识别,在声明时将TypeName对象绑定到Named类型即可。

3.DeclarationOf(声明类型)

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

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

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

4.Unknown(未知类型)

当对象匹配不到以上所有类型的时候,它就是Unknown类型的。

5.Unsupported(未支持类型)

当对象属于Unknown类型时,则会设置该对象为Unsupported类型,并在其使用过程中报错。

6.Protobuf(Protobuf类型)

由go-to-protobuf代码生成器单独处理的类型。

2.7.5 代码生成

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

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

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代码生成器为例,详细讲解其代码生成原理,执行命令如下:

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

图2-9 代码生成流程

下面对代码生成流程进行详解。

1.实例化generator.Packages对象

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

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

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

2.执行代码生成

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

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

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

图2-10 代码结构

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

(1)Header代码块信息,包括build tag和license boilerplate文件(存放开源软件作者及开源协议等信息),其中license boilerplate文件可以从hack/boilerplate/boilerplate.go.txt中获取。

(2)Imports代码块信息,引入外部包。

(3)Vars全局变量代码块信息,当前代码文件未使用Vars。

(4)Consts常量代码块信息,当前代码文件未使用Consts。

(5)Body代码块信息,生成DeepCopy深复制函数。

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

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

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

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

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

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