第 24 期 2018-12-23 go mod 源码阅读 part 1

Go 标准包阅读

基于 Go 1.11.5

分享者

杨文

观看视频

阅读重点

  1. os.Stat
  2. filepath.SplitList
  3. os.Getwd()
  4. switch
  5. sync.Once
  6. os.IsNotExist(errMod)
  7. MustQuote
  8. AutoQuote
  9. modcmd.runGraph
format := func(m module.Version) string {
		if m.Version == "" {
			return m.Path
		}
		return m.Path + "@" + m.Version
	}
  1. sort.Slice

什么是 go mod

module 是相关 Go 依赖包的集合。module 是源代码交换和版本控制的单元。go 工具链会直接支持使用 go module,其功能包含记录和解析对其他第三方包的依赖项。模块将会替换旧的基于 $GOPATH 的模式

目前 Go1.11 是初步支持,后续建议持续观望一下。详见 godoc

开关

由于当前还在试验阶段,需要设置环境变量 GO111MODULE=on,才能够使用 go mod。支持一下选项:

  • off:禁用 go module,按原有 $GOPATH、vendor 的寻址逻辑
  • on:启用 go module
  • auto:若当前不在 $GOPATH 下,且当前目录的根目录下含有 go.mod 文件。则启用 go module

go mod 一下

$ go mod
Go mod provides access to operations on modules.

Note that support for modules is built into all the go commands,
not just 'go mod'. For example, day-to-day adding, removing, upgrading,
and downgrading of dependencies should be done using 'go get'.
See 'go help modules' for an overview of module functionality.

Usage:

	go mod <command> [arguments]

The commands are:

	download    download modules to local cache
	edit        edit go.mod from tools or scripts
	graph       print module requirement graph
	init        initialize new module in current directory
	tidy        add missing and remove unused modules
	vendor      make vendored copy of dependencies
	verify      verify dependencies have expected content
	why         explain why packages or modules are needed

Use "go help mod <command>" for more information about a command.
  • download:将 modules 下载到本地缓存
  • edit:对 go.mod 进行编辑。具体可参见 go help mod edit
  • init:初始化 go module
  • tidy:检索代码,新增缺少的依赖,删除不需要的依赖
  • vendor:拷贝依赖,生成 vendor 目录
  • verify:验证依赖是否正确
  • why:解释为什么需要依赖和 modules

怎么找源码

在这里我们用最粗暴也是最简洁的办法,直接搜一下。在这里,我们找到了如下苗头:

  • cmd/go/alldocs.go:go cmd 的文档。是通过 mkalldocs.sh 在其他文件中收集注解生成的 godoc
  • cmd/go/internal/modcmd/mod.go:今天的男主角,go module 的代码就存储在 modcmd 目录下。而其实我们搜索到的 mod.go 就是 go mod 这个命令的启动文件

看一看源码

modcmd
├── download.go
├── edit.go
├── graph.go
├── init.go
├── mod.go
├── tidy.go
├── vendor.go
├── verify.go
└── why.go

通过 modcmd 的文件结构,可以惊喜地发现与 go mod <command> 的指令集其实是一致的。那么阅读的方向就很清晰了,我们可以按逻辑顺序看下去

init.go

package modcmd

import (
	"cmd/go/internal/base"
	"cmd/go/internal/modload"
	"os"
)

var cmdInit = &base.Command{
	UsageLine: "go mod init [module]",
	Short:     "initialize new module in current directory",
	Long: `Init initializes and writes a new go.mod to the current directory...`,
	Run: runInit,
}

func runInit(cmd *base.Command, args []string) {
	modload.CmdModInit = true
	if len(args) > 1 {
		base.Fatalf("go mod init: too many arguments")
	}
	if len(args) == 1 {
		modload.CmdModModule = args[0]
	}
	if _, err := os.Stat("go.mod"); err == nil {
		base.Fatalf("go mod init: go.mod already exists")
	}
	modload.InitMod() // does all the hard work
}

cmdInit

cmdInit 实际为定义 cmd 命令的基础结构体,其包含成员变量如下:

  • UsageLine:用法
  • Short:简短描述
  • Long:详细描述
  • Run:运行命令

其对应的触发场景主要是 help 和执行命令时,如下:

➜  ~ go help mod init
usage: go mod init [module]

Init initializes and writes a new go.mod to the current directory,
in effect creating a new module rooted at the current directory.
The file go.mod must not already exist.
If possible, init will guess the module path from import comments
(see 'go help importpath') or from version control configuration.
To override this guess, supply the module path as an argument.

runInit

  • 声明正在执行 go mod init 命令集
  • 判断参数是否不合法
  • 参数合法下,该入参赋值给 go mod init 的 module 参数(将 args[0] 赋予 CmdModModule
  • 判断是否存在 go.mod 文件(也就是判断是否已经初始化过)
  • modload.InitMod() 中正式进行初始化的所有工作项

modload.InitMod()

源码中用 “does all the hard work” 来评价这个方法,它是 go mod init 的核心处理逻辑。接下来一起来看看 完整代码,我们将分为两个部分去阅读,如下:

一、MustInit

func MustInit() {
	if Init(); ModRoot == "" {
		die()
	}
	if c := cache.Default(); c == nil {
		base.Fatalf("go: cannot use modules with build cache disabled")
	}
}

在该方法中,我们先进行必要的初始化,再读取构建缓存(不是本文重点),接下来详细阅读一下 Init 方法,如下:

func Init() {
	...
	env := os.Getenv("GO111MODULE")
	switch env {
	default:
		base.Fatalf("go: unknown environment setting GO111MODULE=%s", env)
	case "", "auto":
		// leave MustUseModules alone
	case "on":
		MustUseModules = true
	case "off":
		if !MustUseModules {
			return
		}
	}
	
	if os.Getenv("GIT_TERMINAL_PROMPT") == "" {
		os.Setenv("GIT_TERMINAL_PROMPT", "0")
	}

	if os.Getenv("GIT_SSH") == "" && os.Getenv("GIT_SSH_COMMAND") == "" {
		os.Setenv("GIT_SSH_COMMAND", "ssh -o ControlMaster=no")
	}

	var err error
	cwd, err = os.Getwd()
	if err != nil {
		base.Fatalf("go: %v", err)
	}

	inGOPATH = false
	for _, gopath := range filepath.SplitList(cfg.BuildContext.GOPATH) {
		if gopath == "" {
			continue
		}
		if search.InDir(cwd, filepath.Join(gopath, "src")) != "" {
			inGOPATH = true
			break
		}
	}

	if inGOPATH && !MustUseModules {
		if root, _ := FindModuleRoot(cwd, "", false); root != "" {
			cfg.GoModInGOPATH = filepath.Join(root, "go.mod")
		}
		return
	}

	if CmdModInit {
		ModRoot = cwd
	} else {
        ...
		if search.InDir(ModRoot, os.TempDir()) == "." {
			ModRoot = ""
			fmt.Fprintf(os.Stderr, "go: warning: ignoring go.mod in system temp root %v\n", os.TempDir())
			return
		}
	}
    ...

	search.SetModRoot(ModRoot)
}
  • 判断环境变量 GO111MODULE 选项,主要是设置是否支持 go.mod 和处理一些异常
  • 判断环境变量 GIT_TERMINAL_PROMPT 选项,主要是涉及 Git 的密码弹窗输出提示的处理
  • 判断环境变量 GIT_SSH 选项,主要是判断 Git SSH 连接池,默认为禁用
  • 判断当前路径是否在 $GOPATH 下(可以注意 filepath.SplitList 相关联的代码。主要是读取了 $GOPATH 后利用特定标志位 : 进行了分隔,解决多 $GOPATH 的问题)
  • 判断当前是否在 $GOPATH 下且没有打开 GO111MODULE 选项。若是则检索当前根目录下是否包含 go.mod 文件,存在则代表当前 $GOPATH 下存在 go.mod 文件,这里相对应的是 auto 选项时的逻辑
  • 若当前 CmdModInit 为启用,则在当前目录下创建 go.mod 文件,否则将尽量尝试去临时目录寻找标志文件

modRoot 为空时,则触发异常处理,常见的一些错误提示如下:

func die() {
	if os.Getenv("GO111MODULE") == "off" {
		base.Fatalf("go: modules disabled by GO111MODULE=off; see 'go help modules'")
	}
	if inGOPATH && !MustUseModules {
		base.Fatalf("go: modules disabled inside GOPATH/src by GO111MODULE=auto; see 'go help modules'")
	}
	base.Fatalf("go: cannot find main module; see 'go help modules'")
}

二、具体实现逻辑

func InitMod() {
	...
	if modFile != nil {
		return
	}

	list := filepath.SplitList(cfg.BuildContext.GOPATH)
	if len(list) == 0 || list[0] == "" {
		base.Fatalf("missing $GOPATH")
	}
	gopath = list[0]
	if _, err := os.Stat(filepath.Join(gopath, "go.mod")); err == nil {
		base.Fatalf("$GOPATH/go.mod exists but should not")
	}

	oldSrcMod := filepath.Join(list[0], "src/mod")
	pkgMod := filepath.Join(list[0], "pkg/mod")
	infoOld, errOld := os.Stat(oldSrcMod)
	_, errMod := os.Stat(pkgMod)
	if errOld == nil && infoOld.IsDir() && errMod != nil && os.IsNotExist(errMod) {
		os.Rename(oldSrcMod, pkgMod)
	}

	modfetch.PkgMod = pkgMod
	modfetch.GoSumFile = filepath.Join(ModRoot, "go.sum")
	codehost.WorkRoot = filepath.Join(pkgMod, "cache/vcs")

	if CmdModInit {
		// Running go mod init: do legacy module conversion
		legacyModInit()
		modFileToBuildList()
		WriteGoMod()
		return
	}

	gomod := filepath.Join(ModRoot, "go.mod")
	data, err := ioutil.ReadFile(gomod)
	if err != nil {
		if os.IsNotExist(err) {
			legacyModInit()
			modFileToBuildList()
			WriteGoMod()
			return
		}
		base.Fatalf("go: %v", err)
	}

	f, err := modfile.Parse(gomod, data, fixVersion)
	if err != nil {
		// Errors returned by modfile.Parse begin with file:line.
		base.Fatalf("go: errors parsing go.mod:\n%s\n", err)
	}
	modFile = f

	if len(f.Syntax.Stmt) == 0 || f.Module == nil {
		// Empty mod file. Must add module path.
		path, err := FindModulePath(ModRoot)
		if err != nil {
			base.Fatalf("go: %v", err)
		}
		f.AddModuleStmt(path)
	}

	if len(f.Syntax.Stmt) == 1 && f.Module != nil {
		// Entire file is just a module statement.
		// Populate require if possible.
		legacyModInit()
	}

	excluded = make(map[module.Version]bool)
	for _, x := range f.Exclude {
		excluded[x.Mod] = true
	}
	modFileToBuildList()
	WriteGoMod()
}
  • 判断是否已存在 go.mod 的文件句柄(代指已经处理过相应的逻辑)
  • 检查 $GOPATH 是否设置,并判断 go.mod 文件是否已存在(代指是否已经初始化过)
  • 若 oldSrcMod(src/mod)存在,则 pkgMod(pkg/mod) 不存在,则进行重命名。这里考虑为兼容性操作
  • 若为初次初始化,则执行以下步骤
    • 第一步先做兼容处理,也就是执行 legacyModInit() 对前身(vgo)的一些东西进行兼容处理转换为 go module 现在的模式
    • 第二步执行 modFileToBuildList() 方法 从 modFile 中初始化 mod 构建列表
    • 最后通过 WriteGoMod 进行逻辑处理后(例:处理最小依赖版本)将当前构建列表反写回 go.mod 文件
  • 若并非初次初始化,将会读取 go.mod 文件,根据语法解析 go.mod 的文件内容。接下来会进行一些基准操作
    • 若 go.mod 文件是否为空,则先通过 FindModulePath 检索现有的路径。另外在这里也做了 godepsgovendor 的兼容处理。寻找到 module path 后通过 AddModuleStmt() 添加 module path 到文件中
    • 若只存在 module path,则通过 legacyModInit() 进行兼容处理
    • 最后与上小点一致,均为构建回写等动作

总结

在 go module 中,更多的是本身对包管理工具的思考和实现。如果你仔细阅读过,可以想想如下方面:

  • 为什么要这么做
  • 为什么要在这个地方做
  • 有没有更好的方法

依赖包管理工具,是 Go 一个比较要命的痛点。那为什么 go module 又能 “解决” 呢?请想想…

基于篇幅我没有把所有内容都写出来,但是写法、思维是类似的。有兴趣的同学可以认真看看视频,举一反三

问题

Go 1.11 在 go mod edit -module a/new/mod/name 命令中的一个 bug
go mod edit 命令的 -module flag 是用于修改当前 module 的 path。也就是 go.mod 文件中,module 那一行。
在这个命令的源码 src/cmd/go/internal/modcmd/edit.go 文件 177 行开始:

if *editModule != "" {
	modFile.AddModuleStmt(modload.CmdModModule)
}

AddModuleStmt 这个函数的参数应该是 *editModule,而不是 modload.CmdModule
modload.CmdModule 只在 go mod init 命令启动时初始化。

// src/cmd/go/internal/modcmd/init.go
func runInit(cmd *base.Command, args []string) {
	modload.CmdModInit = true
	if len(args) > 1 {
		base.Fatalf("go mod init: too many arguments")
	}
	if len(args) == 1 {
		modload.CmdModModule = args[0] // INITIALIZATION IS HERE!
	}
	if _, err := os.Stat("go.mod"); err == nil {
		base.Fatalf("go mod init: go.mod already exists")
	}
	modload.InitMod() // does all the hard work
}

因此,由于 string 类型变量的 empty value 是空字符串,所以每次使运行 go mod edit -module a/new/module/name 并不会把 module path 修改为 a/new/module/name,而是修改为空字符串。

$ go mod init github.com/ziyi-yan/hello
go: creating new go.mod: module github.com/ziyi-yan/hello
$ cat go.mod
module github.com/ziyi-yan/hello
$ go mod edit -module github.com/ziyi-yan/hello-new
$ cat go.mod
module ""

go 语言最新的代码 已经修复了这个 bug,预计在 Go 1.12 中发布。