8.1 插件系统
熟悉Go语言的开发者一般都非常了解Goroutine和Channel的原理,包括如何设计基于CSP模型(该模型中的并发实体通过消息传递来通信,而不是共享内存)的应用程序,但Go的插件系统是一个很少有人了解的模块,通过插件系统,我们可以在运行时加载动态库实现一些比较有趣的功能。
8.1.1 设计原理
Go语言的插件系统是基于C语言动态库实现的,所以它也继承了C语言动态库的优点和缺点,我们在本节中会对比Linux中的静态库(Static Library)和动态库(Dynamic Library),分析它们各自的特点和优势。
1.静态库或静态链接库是由编译期决定的程序、外部函数、变量构成的,编译器或链接器会将程序和变量等内容拷贝到目标应用并生成一个独立的可执行对象文件;
2.动态库或动态链接库可以在多个可执行文件之间共享,程序使用的模块会在运行时从共享对象中加载,而不是在编译程序时打包成独立的可执行文件;
由于特性不同,静态库和动态库的优缺点也很明显;只依赖静态库且通过静态链接生成的二进制文件因为包含了全部的依赖,所以能够独立执行,但编译的结果也比较大;而动态库可以在多个可执行文件中共享,可以减少内存的占用,其链接过程往往也都是在装载或运行期间触发的,所以可以包含一些可以热插拔的模块并降低内存的占用。
使用静态链接编译二进制文件在部署上有非常明显的优势,最终的编译产物也可以不需要依赖直接运行在大多数机器上,静态链接带来的部署优势远比更低的内存占用显得重要,所以很多编程语言包括Go都将静态链接作为默认的链接方式。
插件系统
在今天,动态链接带来的低内存占用优势虽然已经没有太多作用,但动态链接的机制却可以为我们提供更多的灵活性,主程序可以在编译后动态加载共享库实现热插拔的插件系统。
通过在主程序和共享库直接定义一系列的约定或接口,我们可以通过以下代码动态加载其他人编译的Go语言共享对象,这样做的好处是——主程序和共享库的开发者不需要共享代码,只要双方约定不变,修改共享库后也不再需要重新编译主程序:
// Driver接口,其中包含一个名为Name的函数
type Driver interface {Name() string
}func main() {// 加载一个共享对象(或叫动态链接库)p, err := plugin.Open("driver.so")if err != nil {panic(err)}// 在共享对象中查找名为NewDriver的符号,可能是一个函数或变量,根据下文,是一个函数newDriverSymbol, err := p.Lookup("NewDriver")if err != nil {panic(err)}// 类型断言,将newDriverSymbol符号转换为func() Driver函数类型newDriverFunc = newDriverSymbol.(func() Driver)// 调用转换后的函数,该函数会返回一个新创建的Driver实例newDriver := newDriverFunc()// 调用新创建的Driver实例的Name方法fmt.Println(newDriver.Name())
}
上述代码定义了Driver
接口并认为共享库中一定包含一个func NewDriver() Driver
函数,当我们通过plugin.Open
读取包含Go语言插件的共享库后,获取文件中的NewDriver
符号并转换成正确的函数类型,就可以通过该函数初始化新的Driver
并获取它的名字了。
操作系统
不同的操作系统会实现不同的动态链接机制和共享库格式,Linux中的共享对象会使用ELF(Executable and Linkable Format,用于定义程序、库文件,或其他二进制可执行文件在Unix系统中的结构,是Linux上最常见的二进制格式)格式并提供了一组操作动态链接器的接口,本节的实现中我们会看到以下几个接口:
void *dlopen(const char *filename, int flag);
char *dlerror(void);
void *dlsym(void *handle, const char *symbol);
int dlclose(void *handle);
dlopen
函数会根据传入的文件名加载对应的动态库并返回一个句柄(Handle);我们可以直接使用dlsym
函数在该句柄中搜索特定的符号,也就是函数或变量,它会返回该符号被加载到内存中的地址。因为待查找的符号可能不存在于目标动态库中,所以在每次查找后我们都应该调用dlerror
查看当前查找的结果。
8.1.2 动态库
Go语言插件系统的全部实现几乎都包含在plugin
中,这个包实现了符号系统的加载和决议。插件是一个带有公开函数和变量的main
包,我们需要使用如下命令编译插件:
go build -buildmode=plugin ...
该命令会生成一个共享对象.so
文件,当该文件被加载到Go语言程序时会使用以下plugin.Plugin
结构体表示,该结构体中包含文件的路径以及包含的符号等信息:
type Plugin struct {pluginpath stringsyms map[string]interface{}...
}
与插件系统相关的两个核心方法分别是用于加载共享文件的plugin.Open
和在插件中查找符号的plugin.Plugin.Lookup
方法,本节将详细介绍这两个方法的实现原理。
CGO
在具体分析plugin
包中几个公有方法前,我们需要先了解一下包中使用的两个C语言函数pluginOpen
和pluginLookup
;pluginOpen
只是简单包装了一下标准库中的dlopen
和dlerror
函数并在加载成功后返回指向动态库的句柄:
static uintptr_r pluginOpen(const char *path, char **err) {// 加载path指定的动态库// RTLD_NOW指示在加载库时立即解析所有符号,如果无法解析,则加载失败// RTLD_GLOBAL使得动态库中的所有符号对后续打开的所有库都可见void *h = dlopen(path, RTLD_NOW|RTLD_GLOBAL);// 如果加载失败if (h == NULL) {// 获取错误信息*err = (char *)dlerror();}// 返回动态库句柄return (uintptr_t)h;
}
pluginLookup
使用了标准库中的dlsym
和dlerror
获取动态库句柄中的特定符号:
static void *pluginLookup(uintptr_t h, const char *name, char **err) {void *r = dlsym((void *)h, name);if (r == NULL) {*err = (char *)dlerror();}return r;
}
这两个函数的实现原理都比较简单,它们的作用也只是简单封装标准库中的C语言函数,让它们的函数签名看起来更像是Go语言中的函数签名,方便在Go语言中调用。
加载过程
用于加载共享对象的函数plugin.Open
会接受共享对象文件的路径作为参数并返回plugin.Plugin
结构体:
func Open(path string) (*Plugin, error) {return open(path)
}
上述函数会调用私有函数plugin.open
加载插件,这个私有函数也是插件加载过程中的核心函数,它的实现原理可拆分成以下几个步骤:
1.准备C语言函数pluginOpen
的参数;
2.通过cgo调用C语言函数pluginOpen
并初始化加载的模块;
3.查找加载模块中的init
函数并调用该函数;
4.通过插件的文件名和符号列表构建plugin.Plugin
结构体;
首先是使用cgo提供的一些结构准备调用pluginOpen
所需的参数,以下代码会将文件名转换成*C.char
类型的变量,该类型的变量可作为参数传入C函数:
func open(name string) (*Plugin, error) {// 以下是两个字节切片,用于存储C风格字符串// PATH_MAX是来自C库的常量,表示最长路径,+1为了字符串尾部的nullcPath := make([]byte, C.PATH_MAX+1)cRelName := make([]byte, len(name)+1)// 将字符串参数name复制到cRelName切片中,准备传递给C函数copy(cRelName, name)// 调用C函数realpath获取绝对路径(或叫规范路径),存放到切片cPath中if C.realpath((*C.char)(unsafe.Pointer(&cRelName[0])),(*C.char)(unsafe.Pointer(&cPath[0]))) == nil {// 使用``表示的go原生字符串字面量,其中的\t\n等不会被转义,且可以跨越多行return nil, errors.New(`plugin.Open("` + name + `"): realpath failed`)}// 将绝对路径转换回go字符串filepath := C.GoString((*C.char)(unsafe.Pointer(&cPath[0])))... // 在这段省略的内容中,对pluginsMu进行了加锁var cErr *C.char// 调用自定义的C函数pluginOpen打开指定路径的插件(或叫共享库)h := C.pluginOpen((*C.char)(unsafe.Pointer(&cPath[0])), &cErr)if h == 0 {return nil, errors.New(`plugin.Open("` + name + `"): ` + C.GoString(cErr))}...
}
当我们拿到了指向动态库的句柄后会调用函数plugin.lastmoduleinit
,该函数会被链接到运行时中的runtime.plugin_lastmoduleinit
上,它会解析文件中的符号并返回共享文件的目录和其中包含的全部符号:
func open(name string) (*Plugin, error) {...pluginpath, syms, errstr := lastmoduleinit()// 如果发生了错误if errstr != "" {// 记录错误状态plugins[filepath] = &Plugin{pluginpath: pluginpath,err: errstr,}// 解锁对plugins的保护pluginsMu.Unlock()return nil, errors.New(`plugin.Open("` + name + `): ` + errstr)}...
}
在该函数的最后,我们会构建一个新的plugin.Plugin
结构体并遍历plugin.lastmoduleinit
返回的全部符号,为每一个符号调用pluginLookup
:
func open(name string) (*Plugin, error) {...// 创建一个新的Plugin实例,将其加入plugins map中p := &Plugin{pluginpath: pluginpath,}plugins[filepath] = p...// 初始化符号表updatedSyms := map[string]interface{}{}// 遍历所有符号for symName, sym := range syms {// 如果符号名以点开头,说明是函数isFunc := symName[0] == '.'// 如果是函数if isFunc {// 从原syms中删除该符号delete(syms, symName)// 符号名中去掉点symName = symName[1:]}// 将插件路径和符号名拼接,形成完整符号名fullName := pluginpath + "." + symName// 将完整符号名转换为C风格字符串存入cname切片cname := make([]byte, len(fullName)+1)copy(cname, fullName)// 查找插件中的该符号地址p := C.pluginLookup(h, (*C.char)(unsafe.Pointer(&cname[0])), &cErr)// 根据符号类型不同,以不同方式设置指针valp := (*[2]unsafe.Pointer)(unsafe.Pointer(&sym))if isFunc {(*valp)[1] = unsafe.Pointer(&p)} else {(*valp)[1] = p}// 保存处理后的符号updatedSyms[symName] = sym}// 将处理好的符号表存到syms字段p.syms = updatedSymsreturn p, nil
}
上述函数在最后会返回一个包含符号名到函数或变量的哈希(指map)的plugin.Plugin
结构体,调用方可以将该结构体作为句柄查找其中的符号,需要注意的是,我们在这段代码中省略了查找init
并初始化插件的过程。
符号查找
plugin.Plugin.Lookup
方法可以在plugin.Open
返回的结构体中查找符号plugin.Symbol
,该符号是interface{}
类型的一个别名,我们可以将它转换成变量或函数真实的类型:
func (p *Plugin) Lookup(symName string) (Symbol, error) {return lookup(p, symName)
}func lookup(p *Plugin, symName string) (Symbol, error) {if s := p.syms[symName]; s != nil {return s, nil}return nil, error.New("plugin: symbol " + symName + " not found in plugin " + p.pluginpath)
}
上述方法调用的私有函数plugin.lookup
实现比较简单,它直接利用了结构体中的符号表,如果没有找到对应的符号会直接返回错误。
8.1.3 小结
Go的插件系统利用了操作系统的动态库实现模块化设计,它提供的功能虽然比较有趣,但在实际使用中会遇到比较多的限制,目前的插件系统也仅支持Linux、Darwin、FreeBSD,在Windows上是没有办法使用的。因为插件系统的实现基于一些黑魔法,所以跨平台的编译也会遇到一些比较奇葩的问题,作者在使用插件系统时也遇到过非常多的问题,如果对Go语言不是特别了解,还是不建议使用该模块。
8.2 代码生成
图灵完备的一个重要特性是计算机程序可以生成另一个程序,很多人可能认为生成代码在软件中并不常见,但是实际上它在很多场景中都扮演了重要的角色。Go语言中的测试就使用了代码生成机制,go test
命令会扫描包中的测试用例并生成程序、编译并执行它们,我们在这一节会介绍Go语言中的代码生成机制。
8.2.1 设计原理
元编程(Metaprogramming)是计算机编程中一个非常重要、也很有趣的概念,维基百科上将元编程描述成一种计算机程序可以将代码看待成数据的能力。
Metaprogramming is a programming technique in which computer programs have the ability to treat programs as their data.
如果能够将代码看做数据,那么代码就可以像数据一样在运行时被修改、更新、替换;元编程赋予了编程语言更加强大的表达能力,能够让我们将一些计算过程从运行时挪到编译时、通过编译期间的展开生成代码或允许程序在运行时改变自身的行为。总而言之,元编程是一种使用代码生成代码的方式,无论是编译期间生成代码,还是在运行时改变代码的行为都是“生成代码”的一种。
现代的编程语言大都会为我们提供不同的元编程能力,从总体上看,根据“生成代码”的时机不同,我们将元编程能力分成两种类型,其中一种是编译期间的元编程,例如:宏和模板;另一种是运行期间的元编程,也就是运行时,它赋予了编程语言在运行期间修改行为的能力,当然也有一些特性既可以在编译器实现,也可以在运行期间实现。
Go语言作为编译型的编程语言,它提供了比较有限的运行时元编程能力,例如反射特性,然而由于性能问题,反射在很多场景下都不被推荐使用。当然除了反射之外,Go语言还提供了另一种编译期间的代码生成机制——go generate
,它可以在代码编译之前根据源代码生成代码。
8.2.2 代码生成
Go语言的代码生成机制会读取包含预编译指令的注释,然后执行注释中的命令读取包中的文件,它们将文件解析成抽象语法树并根据语法树生成新的Go语言代码和文件,生成的代码会在项目的编译期间与其它代码一起编译和运行。
//go:generate command argument...
go generate
不会被go build
等命令自动执行,该命令需要显式触发,手动执行go generate
命令时会在文件中扫描上述形式的注释并执行后面的命令,需要注意的是,go:generate
和前面的//
之间没有空格,这种不包含空格的注释一般是Go语言的编译器指令,而我们在代码中的正常注释都应该保留这个空格。
代码生成最常见的例子是官方提供的stringer
,这个工具可以扫描如下所示的常量定义,然后为当前常量类型Piller
生成对应的String()
方法:
// pill.go
package painkiller// 以下注释会生成一个实现了String方法的Pill类型
//go:generate stringer -type=Pill
type Pill int
// 定义几个Pill类型的常量
const (// iota是常量生成器,它从0开始,每声明一个新常量,iota的值就会增加,然后赋值给新常量Placebo Pill = iotaAspirinIbuprofenParacetamol// Acetaminophen和Paracetamol是同一种药的两个不同名字Acetaminophen = Paracetamol
)
当我们在上述文件中加入//go:generate stringer -type=Pill
注释并调用go generate
命令时,在同一目录下会出现如下所示的pill_string.go
文件,该文件中包含两个函数,分别是_
和String
:
// Code generated by "stringer -type=Pill"; DO NOT EDIT.package painkillerimport "strconv"// 匿名函数,用于执行编译时安全检查
func _() {// An "invalid array index" compiler error signifies that the constant values have changes.// Re-run the stringer command to generate them again.// 创建只有一个元素的数组var x [1]struct{}// 分别用常量减去它们的值,预期索引都是0,就不会引发编译错误_ = x[Placebo-0]_ = x[Aspirin-1]_ = x[Ibuprofen-2]_ = x[Paracetamol-3]
}// 所有枚举值的字符串
const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"// 枚举值字符串在_Pill_name中的位置
var _Pill_index = [...]uint8{0, 7, 14, 23, 34}// 根据索引i返回常量名
func (i Pill) String() string {// 如果索引超出,就直接用数字表示常量名if i < 0 || i >= Pill(len(_Pill_index)-1) {return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"}return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}
这段生成的代码很值得我们学习,它通过编译器的检查提供了非常健壮的String
方法。我们不展示具体的使用过程,本节将重点分析从执行go generate
到生成对应String
方法的过程,代码生成的过程可分为以下两部分:
1.扫描Go语言源文件,查找待执行的//go:generate
预编译指令;
2.执行预编译指令,再次扫描源文件并根据源文件中的代码生成代码;
预编译指令
当我们在命令行中执行go generate
命令时,它会调用源代码中的cmd/go/internal/generate.runGenerate
函数扫描包中的预编译指令,该函数会遍历命令行传入的包中的全部文件并依次调用cmd/go/internal/generate.generate
:
func runGenerate(cmd *base.Command, args []string) {...for _, pkg := range load.Packages(args) {...pkgName := pkg.Namefor _, file := range pkg.InternalGoFiles() {if !generate(pkgName, file) {break}}pkgName += "_test"for _, file := range pkg.InternalXGoFiles() {if !generate(pkgName, file) {break}}}
}
cmd/go/internal/generate.generate
函数会打开传入的文件并初始化一个用于扫描的cmd/go/internal/generate.Generator
结构体:
func generate(pkg, absFile string) bool {fd, err := os.Open(absFile)if err != nil {log.Fatalf("generate: %s", err)}defer fd.Close()g := &Generator{r: fd,path: absFile,pkg: pkg,commands: make(map[string][]string),}return g.gun()
}
结构体cmd/go/internal/generator.Generator
的私有方法cmd/go/internal/generate.Generator.run
会在对应的文件中扫描指令并执行,该方法的实现原理很简单,我们展示一下该方法的简化实现:
func (g *Generator) run() (ok bool) {// 从g.r初始化一个缓冲读取器input := bufio.NewReader(g.r)for {var buf []byte// 读取文件的一行buf, err = input.ReadSlice('\n')// 如果读取出错if err != nil {// 如果错误是EOF && 读到的这行包含go generate指令if err == io.EOF && isGoGenerate(buf) {// 将错误重设为文件意外结束err = io.ErrUnexpectedEOF}break}// 如果当前行不包含go generate指令if !isGoGenerate(buf) {continue}// 设置执行命令所需的环境变量g.setEnv()// 分割注释行中的命令words := g.split(string(buf))// 执行指定的指令g.exec(words)}return true
}
上述代码片段会按行读取被扫描的文件并调用cmd/go/internal/generate.isGoGenerate
判断当前行是否以//go:generate
注释开头,如果该行确定以//go:generate
开头,那么就会解析注释中的命令和参数并调用cmd/go/internal/generate.Generator.exec
运行当前命令。
抽象语法树
stringer
充分利用了Go语言标准库对编译器各种能力的支持,其中包括用于解析抽象语法树的go/ast
、用于格式化代码的go/fmt
等,Go通过标准库中的这些包对外直接提供了编译器的相关能力,让使用者可以直接在它们上面构建复杂的代码生成机制并实施元编程技术。
作为二进制文件,stringer
命令的入口就是如下所示的main
函数,在下面的代码中,我们初始化了一个用于解析源文件和生成代码的Generator
,然后开始拼接生成的文件:
func main() {// 所有需要生成代码的类型名的切片types := strngs.Split(*typeNames, ",")...// 创建Generator实例g := Generator{trimPrefix: *trimprefix,lineComment: *linecomment,}...// 生成文件头注释、包名、需要导入的包g.Printf("// Code generated by \"stringer %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " "))g.Printf("\n")g.Printf("package %s", g.pkg.name)g.Printf("\n")g.Printf("import \"strconf\"\n")// 遍历所有要处理的类型for _, typename := range types {// 为该类型生成代码g.generate(typeName)}// 格式化生成的代码src := g.format()// 拼接输出的文件名baseName := fmt.Sprintf("%s_string.go", types[0])outputName = filepath.Join(dir, strings.ToLower(baseName))// 将格式化后的代码写入目标文件中if err := ioutil.WriteFile(outputName, src, 0644); err != nil {log.Fatalf("writing output: %s", err)}
}
从这段代码中我们能看到最终生成文件的轮廓,最上面调用的几次Generator.Printf
会在内存中写入文件头的注释、当前包名、引入的包等,随后会为待处理的类型依次调用Generator.generate
,这里会生成一个签名为_
的函数,通过编译器保证枚举类型的值不会改变:
// 为指定类型生成String方法
func (g *Generator) generate(typeName string) {values := make([]Value, 0, 100)// 遍历包中的所有文件for _, file := range g.pkg.files {// 设置typeName并初始化valuesfile.typeName = typeNamefile.values = nil// file.file是处理过的AST(抽象语法树)if file.file != nil {// 遍历AST,并对节点调用file.genDecl函数// file.genDecl函数会提取与目标类型相关的常量ast.Inspect(file.file, file.genDecl)// 将文件中的常量值累积到values切片中values = append(values, file.values...)}}// 生成检查常量是否改变过的匿名函数g.Printf("func_() {\n")g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n")g.Printf("\t// Re-run the stringer command to generate them again.\n")g.Printf("\tvar x [1]struct{}\n")for _, v := range values {g.Printf("\t_ = x[%s - %s]\n", v.originalName, v.str)}g.Printf("}\n")// 将values切片分割为多个连续的序列切片,如[1,2,4,5]会被分割为[[1,2],[4,5]]runs := splitIntoRuns(values)// 根据切片数量,选择不同的处理方式switch {case len(runs) == 1:g.buildOneRun(runs, typeName)...}
}
随后调用的Generator.buildOneRun
会生成两个常量的声明语句并为类型定义String
方法,其中引用的stringOneRun
常量是方法的模板,与Web服务的前端HTML模板比较类似:
func (g *Generator) buildOneRun(runs [][]Value, typeName string) {values := runs[0]g.Printf("\n")// 常量的名称和索引字符串,见上面生成的文件g.declareIndexAndNameVar(values, typeName)// 生成String方法g.Printf(stringOneRun, typeName, usize(len(values)), "")
}// %[1]s用于插入类型名
// %[3]s用于插入lessThanZero字符串,检查负数
const stringOneRun = `func (i %[1]s) String() string {if %[3]si >= %[1]s(len(_%[1]s_index)-1) {return "%[1]s(" + strconv.FormatInt(int64(i), 10) + ")"}return _%[1]s_name[_%[1]s_index[i]:_%[1]s_index[i+1]]
}
`
整个生成代码的过程就是使用编译器提供的库解析源文件并按照已有的模板生成新的代码,这与Web服务中利用模板生成HTML文件没有太多区别,只是最终生成的文件的用途稍微有些不同。
8.2.3 小结
Go语言的标准库中暴露了编译器的很多能力,其中包括词法分析和语法分析,我们可以直接利用这些现成的解析器编译Go语言的源文件并获得抽象语法树,有了识别源文件结构的能力,我们就可以根据源文件对应的抽象语法树自由地生成更多代码,使用元编程技术来减少代码重复、提高工作效率。