Go 的构建模式,解决包依赖管理问题

834 阅读7分钟

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

Go 构建模式是怎么演化的?

Go 程序由 Go 包组合而成的,Go 程序的构建过程就是确定包版本、编译包以及将编译后得到的目标文件链接在一起的过程

Go 语言的构建模式历经了三个迭代和演化过程

  1. 最初期的 GOPATH
  2. 1.5 版本的 Vendor 机制
  1. 以及现在的 Go Module

GOPATH

Go 编译器可以在本地 GOPATH 环境变量配置的路径下,搜寻 Go 程序依赖的第三方包。如果存在,就使用这个本地包进行编译;如果不存在,就会报编译错误

缺点

  • 在这种构建模式下,所有构建都离不开 GOPATH 环境变量
  • 在这个模式下,Go 编译器并没有关注依赖包的版本,开发者也无法控制第三方依赖的版本,导致开发者无法实现可重现的构建
  • 在未来可能会移除 GOPATH 构建模式,Go Module 构建模式将成为 Go 唯一的标准构建模式

Vendor 机制

  • 开发者可以在项目目录下缓存项目的所有依赖,实现可重现构建
  • 但 vendor 机制依旧不够完善,开发者还需要手工管理 vendor 下的依赖包,这就给开发者带来了不小的负担

Go Module

  • 一个 Go Module 是一个 Go 包的集合
  • module 是有版本的,所以 module 下的包也就有了版本属性
  • 这个 module 与这些包会组成一个独立的版本单元,它们一起打版本、发布和分发
  • 在 Go Module 模式下,通常一个代码仓库对应一个 Go Module
  • 一个 Go Module 的顶层目录下会放置一个 go.mod 文件,每个 go.mod 文件会定义唯一一个 module,也就是说 Go Module 与 go.mod 是一一对应的
  • Go 命令使用最小版本选择机制进行包依赖版本选择

main module

  • go.mod 文件所在的顶层目录也被称为 module 的根目录
  • module 根目录以及它子目录下的所有 Go 包均归属于这个 Go Module,这个 module 也被称为 main module

创建一个 Go Module

  1. 通过 go mod init 创建 go.mod 文件,将当前项目变为一个 Go Module;
  2. 通过 go mod tidy 命令自动更新当前 module 的依赖信息;
  1. 执行 go build,执行新 module 的构建

项目中创建一个 main.go

package main

import (
	"github.com/valyala/fasthttp"
	"go.uber.org/zap"
)

var logger *zap.Logger

func init() {
	logger, _ = zap.NewProduction()
}

func fastHTTPHandler(ctx *fasthttp.RequestCtx) {
	logger.Info("hello, go module", zap.ByteString("uri", ctx.RequestURI()))
}

func main() {
	fasthttp.ListenAndServe(":8081", fastHTTPHandler)
}

这里依赖了两个第三方包,fasthttp、zap

go mod init

$ go mod init github.com/poloyy/hellomodule
go: creating new go.mod: module github.com/poloyy/hellomodule
go: to add module requirements and sums:
	go mod tidy
  • go mod init 在当前项目目录下创建了一个 go.mod 文件
  • 这个 go.mod 文件将当前项目变为了一个 Go Module
  • 项目根目录变成了 module 根目录

看看 go.mod 的文件内容

$ cat go.mod
module github.com/poloyy/hellomodule

go 1.17
  • 第一行内容是用于声明 module 路径(module path)的
  • 最后一行是一个 Go 版本指示符,用于表示这个 module 是在某个特定的 Go 版本的 module 语义的基础上编写的

go mod tidy

  • 上面 go mod init 命令还输出了两行日志,提示可以使用 go mod tidy 命令,添加module 依赖以及校验和
  • go mod tidy 命令会扫描 Go 源码,并自动找出项目依赖的外部 Go Module 以及版本,下载这些依赖并更新本地的 go.mod 文件
$ go mod tidy
go: finding module for package go.uber.org/zap
go: finding module for package github.com/valyala/fasthttp
go: found github.com/valyala/fasthttp in github.com/valyala/fasthttp v1.31.0
go: found go.uber.org/zap in go.uber.org/zap v1.19.1
  • 由 go mod tidy 下载的依赖 module 会被放置在本地的 module 缓存路径下,默认值为$GOPATH/pkg/mod
  • Go 1.15 及以后版本可以通过 GOMODCACHE 环境变量,自定义本地 module 的缓存路径

再次查看 go.mod 的文件内容

module github.com/poloyy/hellomodule

go 1.17

require (
	github.com/valyala/fasthttp v1.31.0
	go.uber.org/zap v1.19.1
)

具体的版本信息也会写到 go.mod 中

go.sum 文件

在 go.mod 下还有一个 go.sum 文件,内容大致如下

golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
  • 它存放了特定版本 module 内容的哈希值
  • 这是 Go Module 的一个安全措施
  • 当将来这里的某个 module 的特定版本被再次下载的时候,go 命令会使用 go.sum 文件中对应的哈希值,和新下载的内容的哈希值进行比对,只有哈希值比对一致才是合法的,这样可以确保你的项目所依赖的 module 内容,不会被恶意或意外篡改
  • 因此,推荐把 go.mod 和 go.sum 两个文件与源码,一并提交到代码版本控制服务器上

再次执行构建

$ go build main.go

这样就能成功构建出可执行文件 main,然后运行它

$ ./main
{"level":"info","ts":1638496109.6682,"caller":"hello-go/01_bianyi.go:15","msg":"hello, go module","uri":"/foo/bar,"}
{"level":"info","ts":1638496113.153574,"caller":"hello-go/01_bianyi.go:15","msg":"hello, go module","uri":"/foo/ba"}

新开一个终端窗口,执行以下命令就能看到上面的日志了

curl localhost:8081/foo/bar

深入 Go Module 构建模式

Go 语言设计者在设计 Go Module 构建模式,来解决“包依赖管理”的问题时,进行了几项创新,这其中就包括语义导入版本 (Semantic Import Versioning),以及和其他主流语言不同的最小版本选择 (Minimal Version Selection) 等机制

Go Module 的语义导入版本机制

  • go.mod 的 require 段中依赖的版本号,都符合vX.Y.Z的格式
  • 在 Go Module 构建模式下,一个符合 Go Module 要求的版本号,由前缀 v 和一个满足语义版本规范的版本号组成

按照语义版本规范

  • 主版本号不同的两个版本是相互不兼容的
  • 在主版本号相同的情况下,次版本号大都是向后兼容次版本号小的版本
  • 补丁版本号也不影响兼容性

\

简单的🌰

以 logrus 为例,它有很多发布版本,从中选出两个版本 v1.7.0 和 v1.8.1 按照上面的语义版本规则,这两个版本的主版本号相同,新版本 v1.8.1 是兼容老版本 v1.7.0 的,所以都可以使用下面的包导入语句导入 logrus 包

import "github.com/sirupsen/logrus"

但假设要用 v2.0.0 版本时,主版本号和 1.7、1.8 明显不同了,它们互不兼容

如果想导入 logrus v2.0.0 版本,可以将将包主版本号引入到包导入路径中

import "github.com/sirupsen/logrus/v2"

Go 的语义导入版本机制,就是通过在包导入路径中加入主版本号的方式,来区别同一个包的不兼容版本。这样就可以同时导入一个包的两个不兼容版本了

import (
    "github.com/sirupsen/logrus"
    logv2 "github.com/sirupsen/logrus/v2"
)

Go Module 的最小版本选择原则

  • 假设项目依赖 A、B 两个包,A、B 有一个共同的依赖包 C
  • 但 A 依赖 C 的 v1.1.0 版本,而 B 依赖的是 C 的 v1.3.0 版本,并且此时 C 包的最新发布版为 C v1.7.0

问题来了:Go 命令是如何为 myproject 选出间接依赖包 C 的版本呢?选出的究竟是 v1.7.0、v1.1.0 还是 v1.3.0 呢

\

最新最大的版本 Latest Greatest

当前存在的主流编程语言,以及 Go Module 出现之前的很多 Go 包依赖管理工具都会选择依赖项的最新最大 (Latest Greatest) 版本;对应上面的就是 v1.7.0

Go Module 的最小版本

  • Go Module 不光要考虑最新最大的稳定与安全,还要尊重各个 module 的述求:A 明明说只要求 C v1.1.0,B 明明说只要求 C v1.3.0;所以 Go 会在该项目依赖项的所有版本中,选出符合项目整体要求的【最小版本】
  • 在上述 🌰 中,C v1.3.0 是符合项目整体要求的版本集合中的版本最小的那个,于是 Go 命令选择了 C v1.3.0,而不是最新最大的 C v1.7.0

Go 团队认为最小版本选择为Go 程序实现持久的和可重现的构建提供了最佳的方案

Go 各版本构建模式机制和切换

  • 在 Go 1.11 版本中,Go 开发团队引入 Go Modules 构建模式
  • 这个时候,GOPATH 构建模式与 Go Modules 构建模式各自独立工作,可以通过设置环境变量 GO111MODULE 的值在两种构建模式间切换
  • 随着 Go 语言的逐步演进,从 Go 1.11 到 Go 1.16 版本,不同的 Go 版本在GO111MODULE为不同值的情况下,开启的构建模式都会变化,直到 Go 1.16 版本,GoModule 构建模式成为了默认模式

对比 GO111MODULE 不同版本下、不同构建模式下的行为特性

这里将 Go 1.13 版本之前、Go 1.13 版本、Go 1.16 版本

GO111MODULE< Go 1.13Go 1.13Go 1.16
on任何路径下都开启 Go Module 构建模式任何路径下都开启 Go Module 构建模式默认值:任何路径下都开启 Go Module 构建模式
auto默认值:使用 GOPATH 模式还是 Go Module 模式,取决于构建的源码目录所在位置,以及是否包含 go.mod 文件;如果构建的源码目录不在 $GOPATH/src 为根的目录体系下,且包含 go.mod 文件(两个缺一不可),那么就使用 Go Module 构建模式,否则使用 GOPATH 模式默认值:只要当前目录或父目录下有 go.mod 文件时,就使用 Go Module 构建模式,无论源码目录是否在 $GOPATH 外面只要当前目录或父目录下有 go.mod 文件时,就使用 Go Module 构建模式,无论源码目录是否在 $GOPATH 外面
offGOPATH 模式GOPATH 模式GOPATH 模式