go源码剖析 - go命令

分享:  

1. 本文简介

首先我们看下go命令行有哪些功能,运行go help可以查看go命令的详细帮助信息,go命令有很多子命令,每个子命令有特定的功能。go命令功能之丰富涵盖了源文件编译、汇编、连接、反汇编、逃逸分析、代码生成、模块解析等等非常系统性的功能,了解go命令的实现将有助于系统性掌握整个go编译工具链。本文介绍下go命令的详细功能及大致实现,供后续参考。

2. go子命令列表

go支持的子命令列表如下,下面我们逐一来简单说下。

  • bug, start a bug report
  • build, compile packages and dependencies
  • clean, remove object files and cached files
  • doc, show documentation for package or symbol
  • env, print Go environment information
  • fix, update packages to use new APIs
  • fmt, gofmt (reformat) package sources
  • generate, generate Go files by processing source
  • get, add dependencies to current module and install them
  • install, compile and install packages and dependencies
  • list, list packages or modules
  • mod, module maintenance
  • run, compile and run Go program
  • test, test packages
  • tool, run specified go tool
  • version, print Go version
  • vet, report likely mistakes in packages

3. go subcmds

go bug

go bug,用于快速创建bug report。

作为开源项目的维护人员,非常希望开发人员“会”提问题!为什么这么说呢,就是因为如果不会提问题、问问题,沟通成本就会非常高,这对于开源项目维护人员来说,时间上是个极大的浪费。

在腾讯我也参与维护了好几个比较大型的开源项目,经常受到一些同学的问题,很多时候我真的感谢求学阶段长时间泡stackoverflow的经历,stackoverflow上教会了我怎么提问题,这个在我后面学习、沟通、检索、开源协同中起到了很重要的作用。

为了降低沟通成本,go bug内部定义了一个issue模板(其实github也支持定义模板),包括了几个部分:问题简述、go版本、go env信息、执行的操作、期望的结果、实际的结果。这里呢,为了保护go issuer的体验,go bug会自动获取go环境信息,填充到issue模板中,这里的内容将作为issue的body部分。

接下来会判断go issuer当前系统信息,并决定如何打开web浏览器,浏览器打开一个issue创建页面,通过GET参数填充issue的body,一个简单的issue基本信息就填充完成了。go issuer只需要填充下标题、问题简述、执行操作、期望结果、实际结果就创建完成了。

go build

go build可以细分为如下几个操作:

  • compile, src/cmd/compile/main.go
  • asm, …
  • link, src/cmd/link/main.go
  • ld, …

go build过程分析,假定待编译的工程开启了go module:

  • 初始化操作

    • modload.Init(), 进行go module相关的初始化,如检查环境变量GO111MODULE决定是否启用go module、定位go.mod所在的目录、设置git等;
    • instrumentInit(),代码织入初始化,什么是代码织入呢,比如go build -race对代码中的竞态条件进行检查,需要在原程序中加入一些特殊代码来统计对某些数据结构的并发读写操作,这就称之为代码织入,熟悉Java字节码织入的话应该很容易看懂这里的概念,比如Kilim、Quasa等字节码织入。go build -msan应该是启用与内存清理相关的操作。这里也就是检查一下flag的正确性(如-race、-msan不能同时指定),检查一下平台是否支持race检查、msan检查;
    • buildModeInit(),构建模式初始化,包括决定是构建一个共享库,还是一个可执行程序,还是其他等,也会检查平台是否支持、是否开启go module等;
  • builder初始化: builder保存着一次构建过程中的全局状态,不保存package的全局状态,不同package的编译是并发进行的,builder是单例共享的

    • 初始化打印函数
    • 初始化action cache
    • 初始化mkdir cache
    • 初始化tool id cache
    • 初始化build id cache
    • 初始化临时构建目录
    • 检查GOOS、GOARCH是否合法
    • 检查指定的tag列表
  • 加载要构建的路径对应的package信息

    • 加载路径对应的pacakge
    • 移除纯测试用的package
  • 创建要执行的actions:一个action表示action图中的一个单独的动作

    • 创建一个go build的action(根节点),我猜这个action是一个表示全局构建完成的action;
    • 针对各个要构建的package创建一个auto action,都是前一步go build这个action的前置依赖;
    • 这里的actions构成了一个dag,也称之为action graph;
  • b.Do()开始执行构建

    • 从根节点执行扫描,按照深度优先搜索、后序遍历的方式进行遍历,正好符合前置(子节点)执行完成后,后置才可以执行的问题;
    • 根据查看选项是否指定,决定是否输出action graph;
    • action dag执行的时候,相当于先执行最底层的叶子节点,执行完再执行上一层的父节点…以此类推,直到到达根节点;
    • 根据action.Deps构建反向的触发关系,如a.Deps=b,那么b.Triggers=a,方便b执行完后驱动a执行;
    • action.pending表示当前该action剩下的等待执行完成的前置依赖的数量,如果pending为0,表示无依赖或者依赖都已执行完成,当前action变为就绪状态,转移到b.ready这中,b.readySema信号量也ok了;
    • 启动多个goroutine执行并发的构建任务,当b.readySema就绪后,从b.ready栈中取出要处理的action去执行,记录构建的时间之类的,应该是为了方便统计编译耗时信息;
    • 这里要注意action.Func,默认是挺过(*Builder).build来构建的,层层展开,其实看得就是gc.go的gc方法,这个方法最终调用的其实是go tool compile来完成编译过程;
    • go tool compile的代码位于src/cmd/compile/main.go下;

go tool compile逻辑实现:

  • 我擦,一开始各种a#239?fafx8—
  • 构建语法树,不是用的go/ast包,而是syntax包,据说这是因为之前是用c写的,即便后面用go重写了,但基本上是翻译了一遍,工程结构没再改;
  • 基于语法树进行语义分析,如检查类型是否正确,typecheck(node,...),这里又分为几个步骤:
    • const、type、以及func的类型和名称的检查;
    • 变量赋值检查;
    • 函数体类型检查,检查返回值类型?
    • 类型检查完之后,检查map类型的keys
  • 检查如何捕获closed的变量,需要在逃逸分析之前进行;
  • 内联检查;
  • 逃逸分析;
  • 将闭包中对外部变量的引用,根据使用方式转换为按值捕获、按引用捕获的对应形式;
  • 编译顶层函数,详见函数:funccompile(node),这个函数就是根据函数定义(参数列表、返回值)以及语句,生成一系列的操作(操作码、操作数等);
  • ….
  • 写对象数据到磁盘,object data,翻译是“对象数据”,不是“目标文件数据”。详见函数dumpdata(),貌似是在函数compileSSA()中实现的,还要确认具体逻辑;

哇,好复杂!

go clean

我们在编译构建的过程中,一般都会生成一些临时文件,比如.o文件,如果是使用Makefile管理工程构建的时候一般会定义个PHONY Target clean,通过make clean来清理临时文件、目标文件、程序等,MVN构建也会定义clean这样的target,go也不例外。

go build,编译输出可执行程序,go install还会将可执行程序安装到GOBIN或者GOPATH/bin,那现在要清理的话,go clean会清理当前module下的编译产物,go clean -i还会把安装到GOBIN或者GOPATH/bin下安装的程序给清理掉,另外go modules之间也有依赖关系,go clean -r还可以递归地清理依赖产物。

举个例子,假如现在有个工程目录叫hello,那么在该工程目录下执行go clean,将清理目录下的下述文件:hello, hello.exe, hello.test, hello.test.exe, main, main.exe, main.test, main.test.exe。那假如hello目录下go.mod定义的module是a.b.c呢?会清理a.b.c, a.b.c.exe, a.b.c.test, a.b.c.test.exe吗?不会!但是go clean -i会从GOBIN或GOPATH/bin下清理这些文件。为啥?目前go clean就是这么实现的。

go clean之前实现的有bug,我稍微修改了下,实现了清理${module}, ${module}.exe的功能。

go doc

go doc 可以用来显示指定package下的类型、函数、方法及其注释信息,其用法比较多,如go docgo doc pkg.symbol.fieldOrMethodgo doc pkg.Function等等。

比如我们运行go doc os.Signal,会显示如下信息:

package os // import "os"

type Signal interface {
    String() string
    Signal() // to distinguish from other Stringers
}
    A Signal represents an operating system signal. The usual underlying
    implementation is operating system-dependent: on Unix it is syscall.Signal.

var Interrupt Signal = syscall.SIGINT ...

从这里我们可以看到整个接口的定义,及其godoc注释信息,那么不禁要问,go doc 是如何准确找到这个符号os.Signal定义的呢?

如果之前有了解过go/ast的用法、用途之后,应该就不难理解了。我还写过一篇讲微服务代码逻辑可视化的文章,也是使用了go/ast。

go doc的逻辑其实很简单,它首先会将os.Signal split一下,发现是os这个package,然后是Signal这个符号,然后它就会根据build package提供的信息来定位到os对应的目录,然后通过parser.ParseDir(...)来对目录下go文件进行语法分析。分析完之后就将得到AST,然后再基于AST去查找符号Symbol的定义,比如这里是个类型定义,找到AST中对应的节点之后,再提取出注释信息。最后将这些信息格式化输出到stdout。

go doc大致就是这样实现的。

go env

go env 命令用来查看、设置、取消设置go相关的一些环境变量。

我们知道go env会显示出一个环境变量列表,这里面这些环境变量名称都是go envCmd里面预定义好的,比如要设置一个不相干的变量名go env -w xxx是会报错的。

go env 列出的环境变量一般都有一个默认值,如GOSUMDB=sum.golang.org,但是我们有时候希望对齐进行调整,那么可以通过go env -w GOSUMDB=off来进行设置,如果要取消设置恢复到原来的默认设置,则可以执行go env -u GOSUMDB

那这里不禁要问,用户手动设置的环境变量存储在哪里呢?其实是存储在环境变量GOENV对应的文件中,macOS下为/Users/zhangjie/Library/Application Support/go,linux下为~/.config/go/env,其实就是os.UserConfigDir()+/go/env路径下。当我们设置、取消设置的时候,会更新文件中的数据。

go env大致就是这么工作的。

go fix

一门快速演进中的编程语言也会面临一些调整的时候,如果发生了变化,比如将golang.org/x/tools/net/context内容转移到标准库context中,可能已经存在一些存量代码了,或者说开发者已经习惯了使用老的import的包路径了,那怎么办呢?想让开发者付出最小的迁移成本而转到使用最新的标准库context上来。go fix就是干这个事情的。

fix命令执行的时候会检查当前支持那些修复操作,每一个修复操作都指定了要搜索的代码,以及要替换成的代码,比如上面提及的context包导入路径的问题。fix命令会首先解析源文件得到抽象语法树AST,然后基于对AST的操作,搜索出可以修复的问题代码,然后将其替换成对应的新代码,然后再将AST转换成代码输出到源文件中。

这大概就是go fix (go tool fix) 的一个执行过程,$GOROOT/pkg/tool/$GOOS_$GOARCH/下保存了go tool对应的一些工具,如fix,vet等,运行go vet, go fix就会最终转换成执行上述路径下的vet、fix命令。go fix的入口在$GOROOT/src/cmd/go/fix/fix.go,实际调用的是go tool fix,其入口在$GOROOT/src/cmd/fix/main.go

ps: go fix的内部实现,是基于go/ast实现,通过对源码进行语法分析构建ast,通过对ast进行查找、修改,完成对代码的调整,最后再将ast转换为源码输出。

go fmt

go fmt实际上是调用的命令gofmt,它其实也是利用了package go/ast完成对特定源文件或者目录下所有源文件的语法分析,构建出语法树,然后基于对语法树的理解和操作,来最终完成对代码的格式化。

go fmt在使用的时候,有几个地方比较方便,选项-d可以将格式化后的代码打印到标准输出,-w则可以直接将文件写入到文件,-s则支持对代码进行简化。

这里也没有特别多要强调的,继续看其他子命令的实现逻辑。

go generate

go提供了代码生成能力,在go源文件中通过//go:generate ....定义的注释,其实是一种特殊的指令,它告诉go工具可以提取出这些指令来生成代码。当然理论上通过go:generate可以执行任何指令,但是从go工具设计者的初衷来看,它主要是为了用来作为一种包开发者的工具,用来生成或者更新特定源文件的。

比如一个代码生成工具可以通过代码模板来生成一个完整的服务工程,代码模板里面就可以包含这样的//go:generate mockgen ...指令来生成mock测试相关的桩代码。

go generate实现的逻辑比较简单,它就是遍历源文件,然后去逐行读取每个源文件,检查读取行是不是匹配//go:generate ...,是的话则解析出命令来,然后执行对应的命令。

go get

go get用来下载对应模块并安装,同时将该模块添加到当前模块的依赖文件go.mod中。当然go get也有很多一些常用选项,如-u用来更新模块等。这里可以通过go help get来了解详细的信息。

go install

go install,它主要是下载对应模块,并完成程序的构建、安装逻辑。需要注意的是,这里在go1.13前后发生了一点变化。

在go1.13之前的版本中,是要通过go install来安装的,go1.13及之后的版本go get会自动下载、并构建、安装,go install只能安装本地已经下载下来的模块。

go list

go list列出packages或依赖,具体如何实现的呢?这个命令怎么实现的不是很感兴趣,先跳过了。

go mod

go mod主要是用来对依赖进行管理,常用操作包括go mod init, go mod tidygo mod vendor等等吧。

这个有时间再看,当前不是很感兴趣,先跳过了。

go run

go run一般用来快速执行一个go文件,这个go文件必须是package main下的,并且包含一个方法func main(){}go run现在也支持指定一个package main的路径名,要求是一样的,就是这个目录下的go源文件必须是package main,并且有一个源文件中包含func main(){}的定义。

go run其实也需要进行编译、链接过程,只不过这个过程都在一个临时目录中完成,结果产物没有输出到源码目录或者命令执行时的工作目录下。并且go run执行完会后,会自动清理掉这些临时目录。执行完成前是怎么清理的呢?它这里模拟了c里面的函数atexit()来注册退出时要执行的函数,go run进程最终在调用os.Exit()之前会先将之前注册过的处理函数执行一遍,跟c库函数atexit的逻辑类似。这里注册的处理函数就包括清理go run触发编译、链接时生成的临时目录。

go test

go test主要是用来执行单元测试用的,它还比较牛,不仅仅可以用来支持"test.go"文件的单元测试,还支持像"cmd/go/testdata/scripts/“下的通过txt文件+自定义指令来实现的测试。这里还是有点创新点的。

这里先说下"test.go"文件的单元测试是如何实现的吧。实际上是这样执行的,它会为当前package下的"test.go"文件,生成一个新的go文件,这个go文件的内容是根据预先定义好的go测试模板文件生成的,内部会通过-test.run选项来选择我们自定义的TestXXX(t)来执行。

当然go test也支持覆盖率测试等等的操作,这个覆盖率测试实际上也简单,其实是把源文件重写了,每一行语句都对应了一个计数器,执行语句之后,紧跟着一条增加计数器的操作,最后测试程序跑完,检查所有语句的计数器就可以统计出覆盖率信息。

其他的,当前也不是很关心了。

go tool

go这个命令行工具是一个集大成者,它内部其实也是调用了一些其他的工具的,如通过go fmtgo tool fmt来调用命令gofmt,还有一些其他的工具,这些工具可以在如下路径中找到:$GOROOT/pkg/tool/$GOOS_$GOARCH

这里的工具有很多,我在后面的文章中会有选择的进行介绍。

go version

go version就是打印当前go程序的版本信息,这个信息是在go编译构建期间由工具go tool dist生成的,详见runtime/sys/zversion.go

还有一种常见的做法,是设置好一个包级别的变量,然后通过go build -ldflags="-X 'pkg.Varable=value'",这也是一种办法。

go vet

go vet用来报道packages中可能的错误,之前有了解过并发map读写的问题,可以通过go vet检查出来,它是怎么检查的呢?它还能检查出什么其他的错误呢?

go vet默认使用的vet工具是go自带的vet工具,也可以通过go vet -vettool=$prog替换成自定义的vet工具。OK,现在我们来看一下go自带的vet分析工具都支持分析哪些类型的错误,以及怎么实现的。

go自带的vet工具,其实现位于:src/cmd/vet/main.go,从源码中不难看出,vet支持对如下类型的错误进行检查:

  • asmdecl: reports mismatches between assembly files and Go declarations
  • assign: detects useless assignments
  • atomic: checks for common mistakes using the sync/atomic package
  • bools: detects common mistakes involving boolean operators
  • buildtag: check that +build tags are well-formed and correctly located
  • cgocall: detect some violations of the cgo pointer passing rules
  • composite: check for unkeyed composite literals
  • copylock: check for locks erroneously passed by value
  • errorsas: report passing non-pointer or non-error values to errors.As
  • httpresponse: check for mistakes using HTTP response
  • ifaceassert: detect impossible interface-to-interface type assertions
  • loopclosure: check references to loop variables from within nested functions
  • lostcancel: check cancel func returned by context.WithCancel is called
  • nilfunc: check for useless comparisons between functions and nil
  • printf: check consistency of Printf format strings and arguments
  • shift: check for shifts that equal or exceed the width of the integer
  • stdmethods: check signature of methods of well-known interfaces
  • stringintconv: check for string(int) conversions
  • structtag: check that struct field tags conform to reflect.StructTag.Get
  • tests: check for common mistaken usages of tests and examples
  • unmarshal: report passing non-pointer or non-interface values to unmarshal
  • unreachable: check for unreachable code
  • unsafeptr: check for invalid conversions of uintptr to unsafe.Pointer
  • unusedresult: check for unused results of calls to some functions

如何实现的呢,单独查看各个analyzer的实现就可以了,举例copylock是基于go/ast语法分析来检测出来的。

4. 总结