go 版本管理(g)源码阅读笔记

851 阅读3分钟
  • go 版本管理(g)源码阅读笔记(一个 go 版本管理工具)
  • 原文作者:suhanyujie
  • 文章来自:github.com/suhanyujie/…
  • ps:水平有限,如有不当之处,欢迎指正。
  • 标签:go 版本管理器,Go,版本管理器

g 是 go 的一个命令行程序,主要用于 go 的本地版本管理。比如,你要同时开发两个 go 项目,一个是 go1.17 版本,另一个是 go1.18 版本,这个时候,比较好的选择就是用 g

g 的简单使用

在这篇笔记中,我打算逐个了解一下 g 主要命令的使用,然后针对该主要用法的实现去剖析,了解学习实现原理。

g 的安装可以直接参考官方仓库的 readme 文档,其中描述的很详细,这里就不再赘述。

查询可用版本的 go

安装完成后,我们可以通过 g ls-remote stable 查看可用的 go 版本:

$ g ls-remote stable
  1.17.9
* 1.18.1

它列出来当前最新的两个稳定版。

实现分析

g 程序的入口很简单只有一行代码:

cli.Run()

Run 函数定义在 cli 包中。通过依赖可以看出,它的实现是基于 github.com/urfave/cli/v2,这是一个专门用于写命令行程序的包。通过它,实例化一个 app,并初始化应用的名称、版本、作者等基础信息以及命令执行前要做的操作:

app := cli.NewApp()
app.Name = "g"
app.Usage = "Golang Version Manager"

// 省略片段 ...

app.Before = func(ctx *cli.Context) (err error) {
  ghomeDir = ghome()
  goroot = filepath.Join(ghomeDir, "go")
  downloadsDir = filepath.Join(ghomeDir, "downloads")
  if err = os.MkdirAll(downloadsDir, 0755); err != nil {
    return err
  }
  versionsDir = filepath.Join(ghomeDir, "versions")
  return os.MkdirAll(versionsDir, 0755)
}

通常情况下,一个命令行程序可以根据输入的参数而执行不同的操作,比如 git addgit commit 等,这里的 g 程序也是类似,也就是说根据输入参数不同,需要有对应的 handlers,所以就有了 commands:

app.Commands = commands

commands 的定义如下:

commands = []*cli.Command{
		{
			Name:      "ls",
			Usage:     "List installed versions",
			UsageText: "g ls",
			Action:    list,
		},
		{
			Name:      "ls-remote",
			Usage:     "List remote versions available for install",
			UsageText: "g ls-remote [stable|archived|unstable]",
			Action:    listRemote,
		},

    // 省略 ...
}

可见,每一个 Command 中定义了命令的名字、用法说明、以及 action(即前面提到的 handler),以 g ls-remote stable 为例,对应的 action 是 listRemote,它的定义位于文件 cli/ls_remote.go

func listRemote(ctx *cli.Context) (err error) {
    // ...

    c, err := version.NewCollector(url)
    if err != nil {
        return cli.Exit(errstring(err), 1)
    }

    var vs []*version.Version
    switch channel {
    case stableChannel:
        vs, err = c.StableVersions()
    case unstableChannel:
        vs, err = c.UnstableVersions()
    case archivedChannel:
        vs, err = c.ArchivedVersions()
    default:
        vs, err = c.AllVersions()
    }

    // ...
    render(inuse(goroot), items, ansi.NewAnsiStdout())
    return nil
}

// StableVersions 返回所有稳定版本
func (c *Collector) StableVersions() (items []*Version, err error) {
	var divs *goquery.Selection
	if c.HasUnstableVersions() {
		divs = c.doc.Find("#stable").NextUntil("#unstable")
	} else {
		divs = c.doc.Find("#stable").NextUntil("#archive")
	}

	divs.Each(func(i int, div *goquery.Selection) {
		vname, ok := div.Attr("id")
		if !ok {
			return
		}
		items = append(items, &Version{
			Name:     strings.TrimPrefix(vname, "go"),
			Packages: c.findPackages(div.Find("table").First()),
		})
	})
	return items, nil
}

这段代码中的大致流程如下:

  • 识别 stable 参数
  • 请求 url(默认为 golang.google.cn/dl/ )查询 go 可下载列表
  • 利用 goquery 包匹配 html 页面中的版本名称、对应的链接以及其他相关属性,如图 1.1 所示
  • 过滤,取出符合当前命令查询的版本名
  • 把结果输出展示

图 1.1 图 1.1

安装指定版本的 go

g 安装一个 go 版本的命令是 g install 1.18.1

$ g install 1.18.1
Downloading 100% |███████████████| (135/135 MB, 14.563 MB/s)                      
Computing checksum with SHA256
Checksums matched
Now using go1.18.1

通过输出可以看出,不仅可以实时显示安装进度、下载速度,还展示了计算文件 hash 的校验和及其结果。

实现分析

还记得前面提到的 commands 吗?我们回到这个变量定义的地方:cli/commands.go。其中 install 子命令配置:

{
    Name:      "install",
    Usage:     "Download and install a version",
    UsageText: "g install <version>",
    Action:    install,
}

其中的 action 是 install,其实现位于 cli/install.go。函数的大致逻辑有:

  • 匹配拿到子命令后的第一个参数,即要安装的版本号
  • 获取 url 页面内容,匹配出特定版本的名字和下载链接。如果有多个,则会让用户选择。
  • 如果本地不存在,则下载该版本的安装包。
  • 解压安装包
  • 移除旧的 go 安装目录,并建立新安装的 go 的软链接

可见,关键步骤就是将 go 的安装包解压后,覆盖之前已经存在的 go,如此就完成了目标版本的安装。

切换 go 版本

使用 g 对本地的 go 版本管理最常用的功能可能就是因为平时需要,则多个不同版本之前进行随意切换。g 中切换的命令为:g use 1.18.1

$ g use 1.18.1
go version go1.18.1 linux/amd64

实现分析

同样的,回到 commands 定义的地方,可以看到 use 的配置:

{
    Name:      "use",
    Usage:     "Switch to specified version",
    UsageText: "g use <version>",
    Action:    use,
}

use 子命令后需要追加一个版本号参数。进入参 use 实现代码中看看具体实现:

func use(ctx *cli.Context) (err error) {
	vname := ctx.Args().First()
	if vname == "" {
		return cli.ShowSubcommandHelp(ctx)
	}
	targetV := filepath.Join(versionsDir, vname)

	if finfo, err := os.Stat(targetV); err != nil || !finfo.IsDir() {
		return cli.Exit(fmt.Sprintf("[g] The %q version does not exist, please install it first.", vname), 1)
	}

	_ = os.Remove(goroot)

	if err = mkSymlink(targetV, goroot); err != nil {
		return cli.Exit(errstring(err), 1)
	}
	if output, err := exec.Command(filepath.Join(goroot, "bin", "go"), "version").Output(); err == nil {
		fmt.Print(string(output))
	}
	return nil
}

代码大致逻辑如下:

  • 从 context 拿到子命令后的版本号参数
  • 检查该版本 go 安装包是否存在,如果不存在,则直接提示用户先进行下载。
  • 如果已经存在,则移除旧版本(删除软链接)
  • 建立新版本的软链接
  • 用切换后的 go 执行 go version 命令,并输出版本信息

总结

好了,虽然没有将 g 支持的所有命令都一一拿出来分析,但是核心命令的实现都分析完了。至此,你应该心中有了大概的思路 —— 如何实现一个 go 版本管理器。你可以自己尝试实现一个作为练习。如果你也喜欢 g 项目,请去官方仓库下载使用。

参考