自学内容网 自学内容网

Go语言设计与实现 学习笔记 第八章 元编程

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 string
    syms       map[string]interface{}
    ...
}

与插件系统相关的两个核心方法分别是用于加载共享文件的plugin.Open和在插件中查找符号的plugin.Plugin.Lookup方法,本节将详细介绍这两个方法的实现原理。

CGO

在具体分析plugin包中几个公有方法前,我们需要先了解一下包中使用的两个C语言函数pluginOpenpluginLookuppluginOpen只是简单包装了一下标准库中的dlopendlerror函数并在加载成功后返回指向动态库的句柄:

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使用了标准库中的dlsymdlerror获取动态库句柄中的特定符号:

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为了字符串尾部的null
    cPath := 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 = updatedSyms
    return 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 = iota
    Aspirin
    Ibuprofen
    Paracetamol
    // 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 painkiller

import "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.Name
        for _, 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并初始化values
        file.typeName = typeName
        file.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语言的源文件并获得抽象语法树,有了识别源文件结构的能力,我们就可以根据源文件对应的抽象语法树自由地生成更多代码,使用元编程技术来减少代码重复、提高工作效率。


原文地址:https://blog.csdn.net/tus00000/article/details/142435538

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!