聊一聊 go 的依赖管理 module

617 阅读5分钟

GO 依赖管理的变迁

GOPATH

GOPATH 是 go 语言中的一个环境变量, 其值相当于我们的工作目录。最早 go 是没有一套依赖管理机制的, 所有的依赖包都存储在 $GOPATH下, 如果项目 A 和项目 B 依赖不同版本库 C「本地目录只会存储一个版本」, 如果 C 是不兼容的版本, 那么项目开发就会遇到问题

go vender

go vendor 是 go 1.5 官方引入管理包依赖的方式。其做法是在当前开发的项目下创建一个 vender 目录, 存储当前项目的外部依赖。go 1.6 之后, 代码编译会优先从 vendor 目录寻找依赖包, 找不到再从 $GOPATH 中寻找。这种做法只是将不同项目的依赖做了隔离, 但其不区分包版本。go vender 会导致项目本身体积越来越大, 且没有记录依赖库的版本。

go moudles

go 在 1.11 之后引入的一种依赖管理解决方案,1.14 之后官方明确建议在生产环境上使用。有以下优势:

  • 准确记录依赖版本
  • 可重复构建
  • 使用语义化版本控制规范
  • 同一个版本的依赖库无需重复下载

go Modules

基本概念

modules:

go 在 1.11 之后引入的一种依赖管理解决方案,1.14 之后官方明确建议在生产环境上使用。

packages:

Go 程序是通过将包链接在一起进行构建程序。 package 由一个或多个源文件构成, 这些源文件共同声明属于该包的常量、类型、变量和函数, 并且可在同一包的所有文件中访问。 对于可导出的元素可以被另一个包使用。

module:

module 是存储在同一根目录下的包的集合, 且根目录包含 go.mod 文件。可以直接从版本控制存储库或代理服务器下载。

version:
版本号用于标识 module 的不可变快照, 以字母 v 开头, 符合语义化版本控制规范。 -

Major version: 主版本号, 对于向后不兼容的更新, 必须将 major version 增加, 同时将 minor version, patch version 置为 0。例如, 对于暴露的接口新增一个必选参数, 或删除了一个 package

Minor version: 次版本号, 对于向后兼容的更新,必须将 minor version 增加, 同时将 patch version 置为 0。例如, 新增一个函数。

Patch version: 补丁版本号, 当 module 的改动对外暴露的接口没有发生变更, 必须将 patch version 增加。例如, 新增一个 bug 修复或优化。

pseudo-version:伪版本号, 当没有明确的版本号时, 会生成一个伪版本号。例如, 使用一个测试分支生成对应的伪版本号,由三部分组成:

使用 Modules

目前 Go modules 并不是默认开启,因此 Go 语言提供了 GO111MODULE 这个环境变量来作为 Go modules 的开关,其允许设置以下参数:

  • auto:只要项目包含了 go.mod 文件的话启用 Go modules,目前在 Go1.11 至 Go1.14 中仍然是默认值。
  • on:启用 Go modules,推荐设置。
  • off:禁用 Go modules,不推荐设置。

设置 GO11MODULE 变量:

$ go env -w GO111MODULE=on

查看 GO11MODULE 的设置:

$ go env | grep GO111MODULE
GO111MODULE = "on"

创建 module

创建一个新的文件夹 cd $GOPATH/src;mkdir -p zjl/learnmodule, 新建一个 hello.go 文件

package hello

func Hello() string {
    return "Hello, world."
}

使用 go mod init 命令将当前目录作为一个 module

$ go mod init example.com/zjl/hello
go: creating new go.mod: module example.com/hello

在当前目录下会生成 go.mod 文件, 对于子目录下的 package, 导入路径由 module 路径加上子目录组成。例如: 在当前目录下新建一个子目录 word, 这个 package 将自动成为 module「``example.com/zjl/hello``」的一部分, 其导入路径为 example.com/hello``/word

添加依赖

hello.go 文件中引入一个 moudle

package hello
import "rsc.io/quote"
func Hello() string {
   return quote.Hello()
}

使用 go mod tidy 命令, 添加当前编译当前项目所需的依赖, 移除不相关的依赖, 更新对应的 go.mod、go.sum 文件, 对应的 moudle 会缓存到本地的 $GOPATH/pkg/mod 目录下, mod 目录下, 每一个版本对应一个文件夹

$ go mod tidy
    go: finding module for package rsc.io/quote
    go: found rsc.io/quote in rsc.io/quote v1.5.2

$ cd $GOPATH/pkg/mod/rsc.io/ && ls
    quote@v1.5.2   sampler@v1.3.0

查看当前项目的依赖,直接依赖 rsc.io/quotev1.5.2,间接依赖 golang.org/x/textv0.0.0-20170915032832-14c0d48ead0crsc.io/samplerv1.3.0,可以通过 go mod why 查看为什么会引入对应的间接依赖

$ cat go.mod

    module example.com/hello
    go 1.17
    require rsc.io/quote v1.5.2
    require (
            golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
            rsc.io/sampler v1.3.0 // indirect
    )

$ go mod why rsc.io/sampler

    #rsc.io/sampler
        example.com/hello
        rsc.io/quote
        rsc.io/sampler

升级依赖

使用 go get 命令,升级依赖 moudle 的版本

命令作用
go get golang.org/x/text拉取依赖,会进行指定性拉取(更新),并不会更新所依赖的其它模块
go get -u golang.org/x/text更新现有的依赖,会强制更新它所依赖的其它全部模块,建议不使用
go get golang.org/x/text@latest拉取最新的版本
go get golang.org/x/text@master通过分支拉取
go get golang.org/x/text@v0.3.7通过版本号拉取
go get golang.org/x/text@342b2e通过 commit hash 拉取

当前项目简介依赖 golang.org/x/textv0.0.0-20170915032832-14c0d48ead0c,查看对应的版本,并进行更新

$ go list -m -versions golang.org/x/text
    golang.org/x/text v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.3.2 v0.3.3 v0.3.4 v0.3.5 v0.3.6 v0.3.7

$ go get golang.org/x/text@v0.3.7

在 go modules 中, 对于主版本的升级, 从 v2 起, 导入的 module 必须指定对应的主版本号。例如, 在项目中导入 rsc.io/sampler/v3, 核心是为了解决版本冲突问题

$ go get rsc.io/quote/v3
// update hello.go
package hello

import (
   "rsc.io/quote"
   v3 "rsc.io/quote/v3"
)

func Hello() string {
   v3.Concurrency()
   return quote.Hello()
}

移除依赖

更新 hello.go, 只使用 rsc.io/quote/v3,但是对应的 go.mod 文件仍然依赖 rsc.io/quote v1.5.2, 因为在构建包的时候, 可以很容易知道哪些需要添加, 但无法明确的知道哪些可以被安全的删除

$ cat hello.go

    package hello
    import (
       v3 "rsc.io/quote/v3"
    )
    func Hello() string {
       return v3.HelloV3()
    }

$ cat go.mod

    module example.com/hello
    go 1.17
    require (
       rsc.io/quote v1.5.2
       rsc.io/quote/v3 v3.1.0
    )
    require (
       golang.org/x/text v0.3.7 // indirect
    rsc.io/sampler v1.3.0 // indirect
    )

使用 go mod tidy 命令, 添加当前编译当前项目所需的依赖, 移除不相关的依赖, 更新对应的 go.mod、go.sum 文件

$ go mod tidy
$ cat go.mod 

    module example.com/hello
    go 1.17
    require rsc.io/quote/v3 v3.1.0
    require (
            golang.org/x/text v0.3.7 // indirect
            rsc.io/sampler v1.3.0 // indirect
    )

版本选择算法

包依赖选择已经被证明是 NPC 问题,貌似通过布尔满足性论证的,具体证明过程「research.swtch.com/version-sat…

  • 一个包有零个或多个版本依赖
  • 安装这个包必须要安装其所有依赖
  • 包的不同版本都可以有不同的依赖关系
  • 不能同时安装一个包的多个版本

可以违背假设条件的方式来避免产生 NPC 问题。例如,允许安装一个包的不同主版本;依赖项不指定具体依赖的版本, 只指定依赖的最低版本。

go 版本选择算法主要基于以下几点出发:

  • 尽可能使用开发者指定的依赖版本
  • 构建过程应该可重现
  • 算法简单, 能够快速的选择出对应依赖集合
  • 版本升级由用户来控制

go 使用最小版本选择算法「MVS」来确定使用的 module 版本,通过此方法可以保证每次构建使用的版本都是确定的; 同一主版本号下,同一个包只依赖一个版本。当前算法存在的问题「前提是遵循办号兼容规则」

go.mod 文件

一个 module 在根路径上会定义一个 go.mod 文件, 用户描述当前项目的 module 信息. 文件中主要包含以下几个指令:

module:

声明当前模块的路径,导入当前模块中的包时,均使用声明的路径作为前缀. 一个 go.mod 文件必须要包含一个 module 指令

module example.com/hello

go:

用于声明当前模块使用哪个版本的 go 的语义进行开发. 对于模块中的包, 编译器不会使用比声明版本更高的特性; 如果使用旧版本的编译器编译高版本声明的 module, 将会报错.

go 1.17

require:

require 指令声明当前项目所需依赖的版本. 对于一些依赖会自动添加 //indirect, 用于声明当前包的间接依赖。

require golang.org/x/net v1.2.3

require (
    golang.org/x/crypto v1.4.5 // indirect
    golang.org/x/text v1.6.7
)

replace:

将指定版本的依赖或任意版本的依赖进行替换,replace 只能作用于当前模块的构建. 可以替换为本地的模块或远程的模块

module A
replace (
    golang.org/x/net v1.2.3 => example.com/fork/net v1.4.5
    golang.org/x/net => example.com/fork/net v1.4.5
    golang.org/x/net v1.2.3 => ./fork/net
    golang.org/x/net => ./fork/net
)

module B // 构建时不会对 golang.org/x/net v1.2.3 进行 replace
require (
    A v0.0.0
    golang.org/x/net v1.2.3
)

使用 R 替换 C1.4, 构建时将使用 A1.2、B1.2、C1.4(R)、D1.3

exclude:

构建时排除指定的版本, 只能作用于当前模块的构建

exclude golang.org/x/net v1.3

C1.3 被移除, 构建时将使用 C1.4

retract:

用于声明相关的版本不应该被依赖,例如版本不再维护或废弃. 被废弃的版本将不再 go list -m -versions命令中被展示,除非增加 -retracted 参数. 同时, 在使用 @latest 升级时, 标记为废弃的版本将会被忽略.

retract (
    v1.0.0 // Published accidentally.
    v1.0.1 // Contains retractions only.
)

go.sum 文件

在模块的根目录会生成对应的 go.sum 文件, 每行由模块名、版本、哈希组成:

bou.ke/monkey v1.0.2 h1:kWcnsrCNUatbxncxR/ThdYqbytgOIArtYWqcQLQzKLI=
bou.ke/monkey v1.0.2/go.mod h1:OqickVX3tNx6t33n1xvtTtu85YN5s6cKwVug+oHMaIA=

正常情况下,每个依赖包版本会包含两条记录,第一条记录为该依赖包版本整体(所有文件)的哈希值,第二条记录仅表示该依赖包版本中 go.mod 文件的哈希值,如果该依赖包版本没有 go.mod 文件,则只有第一条记录。

代码构建时,会从本地缓存$GOPATH/pkg/mod中查找所有 go.mod 中记录的依赖包,并计算本地依赖包的哈希值,然后与 go.sum 中的记录进行对比,即检测本地缓存中使用的依赖包版本是否满足项目 go.sum 文件的期望。如果校验失败,说明本地缓存目录中依赖包版本的哈希值和项目中 go.sum 中记录的哈希值不一致,go 命令将拒绝构建。主要还是用来检查依赖包是否被篡改, 保证可重复构建。

常用命令

  • go get moudle@lastest 添加依赖的最新版本, 不要使用 go get -u****
  • go get moudle@branch_name 添加特定分支作为依赖
  • go mod tidy 重新构建依赖, 移除不相关的依赖
  • go mod why module 查看为什么会有此依赖
  • go mod graph 查看依赖关系图
  • go list -m -versions module 查看对应的版本
  • go clean -modcache 删除模块缓存