云原生系列Go语言篇-模块、包和导入 Part 2

1,525 阅读7分钟

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

使用模块

我们已经学习了如何在单个模块中使用包,接下来该学习如何集成第三方模块及其中的包。然后,我们会学习如何发布自己模块并添加版本,以及Go的中央服务:pkg.go.dev、模块代理和校验和(checksum)数据库。

导入第三方代码

我们已导入过标准库中的包,如fmterrorsosmath。Go使用同样的导入系统来集成第三方包。和很多其它编译语言不同,Go总是通过源代码将应用构建为单个二进制文件。它包含模块的源代码及其所依赖的所有模块的源代码。和我们学过的模块内导包一样,在导入第三方包时,指定包所处的源代码的位置。

我们来看一个例子。前面提到过在需要十进制的精确表示时应避免使用浮点数。如需精准表示,一个不错的选项是ShopSpring中的decimal模块。我们还会学习作者为本书所写的简单的格式化模块。这两个模块都在本书Github的小程序中进行了使用。这个程序精确计算了含税物品的价格并以整洁的格式打印了输出。

main.go:中的代码如下:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/learning-go-book-2e/formatter"
    "github.com/shopspring/decimal"
)

func main() {
    if len(os.Args) < 3 {
        fmt.Println("Need two parameters: amount and percent")
        os.Exit(1)
    }
    amount, err := decimal.NewFromString(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    percent, err := decimal.NewFromString(os.Args[2])
    if err != nil {
        log.Fatal(err)
    }
    percent = percent.Div(decimal.NewFromInt(100))
    total := amount.Add(amount.Mul(percent)).Round(2)
    fmt.Println(formatter.Space(80, os.Args[1], os.Args[2],
                                total.StringFixed(2)))
}

两条导入github.com/learning-go-book-2e/formattergithub.com/shopspring/decimal指定了第三方导入。注意它们包含了仓库中包的位置。导入后我就可以像其它导入包一样访问这些包的导出项。

在构建应用前,查看go.mod文件。其内容应为:

module github.com/learning-go-book-2e/money

go 1.19

如果尝试进行构建,会得到如下消息:

$ go build
main.go:8:2: no required module provides package
    github.com/learning-go-book-2e/formatter; to add it:
        go get github.com/learning-go-book-2e/formatter
main.go:9:2: no required module provides package
    github.com/shopspring/decimal; to add it:
        go get github.com/shopspring/decimal

按照错误提示,只有在go.mod文件中添加对第三方包的引用时才能构建应用。go get命令会下载模块并更新go.mod文件。在使用go get时有两具选项。最简单的选项是告诉go get扫描模块源代码,添加import语句中的所有模块至go.mod

$ go get ./...
go: downloading github.com/shopspring/decimal v1.3.1
go: downloading github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a89362c9
go: downloading github.com/fatih/color v1.13.0
go: downloading github.com/mattn/go-colorable v0.1.9
go: downloading github.com/mattn/go-isatty v0.0.14
go: downloading golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c
go: added github.com/fatih/color v1.13.0
go: added github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a89362c9
go: added github.com/mattn/go-colorable v0.1.9
go: added github.com/mattn/go-isatty v0.0.14
go: added github.com/shopspring/decimal v1.3.1
go: added golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c

因为包的位置位于源代码中,所以go get可以获取到包的模块并下载。这时再查看go.mod文件,会看到:

module github.com/learning-go-book-2e/money

go 1.19

require (
    github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a89362c9
    github.com/shopspring/decimal v1.3.1
)

require (
    github.com/fatih/color v1.13.0 // indirect
    github.com/mattn/go-colorable v0.1.9 // indirect
    github.com/mattn/go-isatty v0.0.14 // indirect
    golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
)

go.mod文件的第一个require区列出了导入到模块中的模块。模块名之后为版本号。在formatter模块中,并没有版本标记,因此Go生成了一个伪版本。

可以看到第二个require指令区的模块都有一条indirect注释。其中一个模块(github.com/fatih/color)由formatter直接使用。它又依赖第二个require区中的另三个模块。模块依赖(及依赖的依赖等)所使用的模块都在模块的go.mod文件中有包含。那些只在依赖中使用的模块标记为间接引用。

除了会更新go.mod,还会创建一个go.sum文件。对于项目依赖树中的每个模块,go.sum 文件中都有两条记录:一条模块及其版本和模块的哈希,另一条是模块的go.mod 文件的哈希。下面是本例go.sum文件的内容:

github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs...
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46q...
github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a8...
github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a8...
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9Zo...
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJr...
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1...
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP...
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj...
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG...
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WC...
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce...
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce...
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci...
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1M...

我们会学到这些哈希用于模块代理服务器中。读者可能还会注意到有些模块有多个版本。我们会在最小版本选择一节中讨论。

下面验证模块配置是否正确。再次运行go build,然后运行money二进制并传递一些参数:

$ go build
$ ./money 99.99 7.25
99.99           7.25

注:我们的示例代码才提交时没有go.sum并且go.mod也不完整。这是为了读者体会什么时候添加这些文件。在将你自己的代码提交到版本控制时,请保持go.modgo.sum 文件为最新状态。这样可以指定依赖所使用的版本。这样便实现了可重复构建,在其他人(包括未来的你自己)构建这一模块时,会得到同样的二进制文件。

我们说过,还有一种使用go get的方式。不用告诉它扫描源代码发现模块,可以传递模块路径给go get。要进行操作,回滚go.mod文件并删除go.sum。在类Unix系统中,执行的命令如下:

$ git restore go.mod
$ rm go.sum

接下来直接将模块传递给go get

$ go get github.com/learning-go-book-2e/formatter
go: added github.com/learning-go-book-2e/formatter v0.0.0-20200921021027-5abc380940ae
$ go get github.com/shopspring/decimal
go: added github.com/shopspring/decimal v1.3.1

注:细心的读者会注意到第二次使用go ge时,没有显示go: downloading消息。这是因为Go在本地电脑上维护了一个模块缓存。下载完模块后,会在缓存中保留一个拷贝。源代码不大并且硬盘很大,所以这不会是问题。但是,如果希望删除模块,可使用go clean -modcache命令。

再来看go.mod文件的内容,会有一些差别:

module github.com/learning-go-book-2e/money

go 1.19

require (
    github.com/fatih/color v1.13.0 // indirect
    github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-1835a89362c9 // indirect
    github.com/mattn/go-colorable v0.1.9 // indirect
    github.com/mattn/go-isatty v0.0.14 // indirect
    github.com/shopspring/decimal v1.3.1 // indirect
    golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
)

注意所有的导入都标记成了indirect,不只是来自的formatter模块。在运行go get并对其传递模块名时,它不会查看源代码确定所指定的模块是否在主模块中使用。为保证安全,便添加了indirect注释。

如果想自动修复这一标签,使用go mod tidy命令。它会扫描源代码并根据模块源码同步go.modgo.sum文件,添加并删除模块引用。也会确保间接引用的注释是否正确。

读者可能会想为什么还要用带模块名的go get呢?原因就是可以通过它更新某个模块的版本。

使用版本

我们来看Go的模块系统如何使用版本。作者编写了一个简单模块可用于另一个税收程序。在main.go中有如下的第三方导入:

"github.com/learning-go-book-2e/simpletax"
"github.com/shopspring/decimal"

和之前一样,示例程序没有提交更新后的go.mod 和 go.sum,这样可以明白背后发生的事。在构建程序时,会看到如下操作:

$ go get ./...
go: downloading github.com/learning-go-book-2e/simpletax v1.1.0
go: added github.com/learning-go-book-2e/simpletax v1.1.0
go: added github.com/shopspring/decimal v1.3.1
$ go build

go.mod文件更新为了:

module github.com/learning-go-book-2e/region_tax

go 1.19

require (
    github.com/learning-go-book-2e/simpletax v1.1.0
    github.com/shopspring/decimal v1.3.1
)

还有一个包含依赖哈希的go.sum。运行代码看看结果:

$ ./region_tax 99.99 12345
2022/09/19 22:04:38 unknown zip: 12345

这和预想不一样。在模块的最新版本中可能有bug。默认Go在我们添加模块依赖时会选择最新版本。但版本的用处之一是可以指定模块之前的版本。首先我们可以通过go list命令查看有哪些模块版本:

$ go list -m -versions github.com/learning-go-book-2e/simpletax
github.com/learning-go-book-2e/simpletax v1.0.0 v1.1.0

默认go list命令列举模块中使用的包。-m参数将输出修改为列举模块,-versions标记修改go list为报告指定模块的可用版本。本例中我们看到有两个版本v1.0.0和v1.1.0。我们将版本降级为v1.0.0,试试能不能解决问题。通过命令go get执行该操作:

$ go get github.com/learning-go-book-2e/simpletax@v1.0.0
go: downloading github.com/learning-go-book-2e/simpletax v1.0.0
go: downgraded github.com/learning-go-book-2e/simpletax v1.1.0

go get命令让我们可以操作模块、升级依赖版本。

此时再看go.mod,,会发现版本发生了改变:

module github.com/learning-go-book-2e/region_tax

go 1.19

require (
    github.com/learning-go-book-2e/simpletax v1.0.0
    github.com/shopspring/decimal v1.3.1
)

go.sum中我们还看到了它包含simpletax的两个版本:

github.com/learning-go-book-2e/simpletax v1.0.0 h1:KZU8aXRCHkvgFmBWkV...
github.com/learning-go-book-2e/simpletax v1.0.0/go.mod h1:lR4YYZwbDTI...
github.com/learning-go-book-2e/simpletax v1.1.0 h1:sG83gscauX/b8yKKY9...
github.com/learning-go-book-2e/simpletax v1.1.0/go.mod h1:lR4YYZwbDTI...

这没问题,如果修改模块版本,甚至从模块中删除某一模块,go.sum中依然会有相应记录。这不会导致任何问题。

再次构建、运行代码,问题得以修复:

$ go build
$ ./region_tax 99.99 12345
107.99
语义版本

从很早开始软件就有版本号了,但有关版本号的含义却并不一致。Go模块的版本号遵循语义版本规则,也称为SemVer。通过模块的语义版本,Go使得模块管理代码更简化,同时又保障模块使用者理解新版本的功能。

如果对SemVer不熟悉的话,可查看完整规范。简短的解释是语义版本将版本号分为三部分:主版本、小版本和补丁版本,写作major.minor.patch并以v开头。在修改bug时补丁版本号会递增,小版本号会在添加新的向后兼容特性时增加(补丁版本重置为0),而在做出破坏向后兼容的修改时增加主版本号(小版本和补丁版本重置为0)。

最小版本选择

有时模块会依赖同时依赖相同模块的两个或以上模块。这经常发生,这些模块声明它们依赖于该模块的不同小版本或补丁版本。Go如何解决这一问题呢?

模块系统使用最小版本选择原则 。也就是说总是会获取到所有go.mod 中所有依赖中声明可用的最小依赖版本。假设有模块直接依赖模块A、B和C。这三个模块都依赖于模块D。模块A的go.mod文件声明其依赖v1.1.0,模块B声明其依赖v1.2.0,模块C声明其依赖v1.2.3。Go仅会一次性导入模块D,这会选择v1.2.3,这就是Go模块手册中所说的满足所有要求的最低版本。

我们可以通过导入第三方代码一节的示例实时查看。go mod graph命令展示了模块及其所有依赖的依赖图。以下是输出中的几行:

github.com/learning-go-book-2e/money github.com/fatih/color@v1.13.0
github.com/learning-go-book-2e/money github.com/mattn/go-colorable@v0.1.9
github.com/learning-go-book-2e/money github.com/mattn/go-isatty@v0.0.14
github.com/fatih/color@v1.13.0 github.com/mattn/go-colorable@v0.1.9
github.com/fatih/color@v1.13.0 github.com/mattn/go-isatty@v0.0.14
github.com/mattn/go-colorable@v0.1.9 github.com/mattn/go-isatty@v0.0.12

每行列出两个模块,第一个是父模块,第二个是其依赖及版本。我们看到github.com/fatih/color模块声明为依赖github.com/mattn/go-isatty的v0.0.14,而github.com/mattn/go-colorable依赖的是v0.0.12。Go编译器选择使用v0.0.14,因为这是满足要求的最小版本。而事实上在写本文时github.com/mattn/go-isatty最新版本是v0.0.16。我们的最小版本要求是v0.0.14,所以使用了该版本。

这一系统并不完美。读者可能会发现虽然模块A可兼容模块D的v1.1.0,但却不兼容v1.2.3。这时该怎么办呢?Go给出的答案是你应该联系模块作者修改不兼容性问题。导入兼容性规则说“如果老包和新包有同要瓣导入路径,新包必须对老包提供向后兼容。”也就是说模块所有的小版本及补丁版本都必须保持向后兼容。如若不然,就是个bug。在我们假设的示例中,要么模块D因打破了向后兼容性需要进行修复,要么模块A需要进行修复,因其错误地假定了模块D的行为。

这个答案差强人意,但却也开诚布公。有些构建系统,比如npm,会包含同一个包的多个版本。这可能带来一系列问题,尤其是在有包级状态时。它还增加了应用的大小。最终有些问题社区解决比代码解决要更好。

更新到兼容版本

想要显式升级依赖的情况如何呢?假设写完程序后,又出现了三个simpletax版本。第一个修复了初始的v1.1.0版本的问题。因其是一个补丁,没有新功能,所以版本为v1.1.1。第二个保留了当前的功能,但增加了新功能。版本号升为v1.2.0。最后又修复了v1.2.0版本中的一个bug。版本号升为v1.2.1。

要升级为当前小版本的补丁修复版本,使用go get -u=patch github.com/learning-go-book-2e/simpletax命令。因为我们降级为了v1.0.0,我们会保留该版本,因为该小版本没有补丁版本。

如果使用go get github.com/learning-go-book-2e/simpletax@v1.1.0升级为v1.1.0,然后运行go get -u=patch github.com/learning-go-book-2e/simpletax,会升级为v1.1.1版本。

最后,使用go get -u github.com/learning-go-book-2e/simpletax命令获取simpletax的最新版本。升级为v1.2.1版本。

更新到不兼容版本

再回到程序。很幸运我们的业务扩展到了加拿大,有一个simpletax模块版本同时处理US和Canada的税务。但是这个版本的API与前一个有些不同,版本为v2.0.0。

要处理不兼容性,Go模块遵循语义导入版本规则。这个规则有两个部分:

  • 必须提升模块的大版本。
  • 对于0和1以外的大版本,模块的路径必须以vN结尾,其中N为大版本号。

修改路径的原因是导入路径唯一标识一个包。在定义上,包的不兼容版本不是同一个包。使用不同的路径意味着可以在程序的不同部分导入包的两个不兼容版本,允许我们进行优雅升级。

我们来看看如何修改程序。首先,修改的simpletax导入为:

"github.com/learning-go-book-2e/simpletax/v2"

这会修改导入为引用v2模块。

然后,我们修改main中的代码如下:

func main() {
    amount, err := decimal.NewFromString(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    zip := os.Args[2]
    country := os.Args[3]
    percent, err := simpletax.ForCountryPostalCode(country, zip)
    if err != nil {
        log.Fatal(err)
    }
    total := amount.Add(amount.Mul(percent)).Round(2)
    fmt.Println(total)
}

现在通过命令行读入第三个参数,即国家代码,然后相应地在simpletax包中调用不同函数。然后我们调用go get ./...,依赖会自动更新:

$ go get ./...
go: downloading github.com/learning-go-book-2e/simpletax/v2 v2.0.0
go: added github.com/learning-go-book-2e/simpletax/v2 v2.0.0

我们可以构建和运行程序,查看新的输出:

$ go build
$ ./region_tax 99.99 M4B1B4 CA
112.99
$ ./region_tax 99.99 12345 US
107.99

此时查看go.mod文件,会发现包含了simpletax的新版本:

module github.com/learning-go-book-2e/region_tax

go 1.19

require (
    github.com/learning-go-book-2e/simpletax v1.0.0
    github.com/learning-go-book-2e/simpletax/v2 v2.0.0
    github.com/shopspring/decimal v1.3.1
)

go.sum也发生了更新:

github.com/learning-go-book-2e/simpletax v1.0.0 h1:KZU8aXRCHkvgFmBWkV...
github.com/learning-go-book-2e/simpletax v1.0.0/go.mod h1:lR4YYZwbDTI...
github.com/learning-go-book-2e/simpletax v1.1.0 h1:sG83gscauX/b8yKKY9...
github.com/learning-go-book-2e/simpletax v1.1.0/go.mod h1:lR4YYZwbDTI...
github.com/learning-go-book-2e/simpletax/v2 v2.0.0 h1:EUFWy1BBA2omgkm...
github.com/learning-go-book-2e/simpletax/v2 v2.0.0/go.mod h1:yGLh6ngH...
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG...
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WC...

虽然不再使用但其中依然引用了simpletax的老版本。我们可以使用go mod tidy来删除未使用的版本。之后就会在go.modgo.sum中看到只有simpletax的v2.0.0了。

Vendoring

为保证模块使用相同的依赖进行构建,一些组织会在模块内保留依赖的拷贝。这称为vendoring。通过运行go mod vendor进行启用。它会在模块根下创建一个vendor目录,包含模块的所有依赖。之后会在本机的模块缓存中读取这些依赖。

如果go.mod 中添加了新依赖或是通过go get升级了已有的依赖版本,我们需要再次运行go mod vendor来更新vendor目录。如果忘记这么做的话,go buildgo rungo test会拒绝运行并显示错误消息。

老的Go依赖管理系统要求用vendoring,但随着Go模块以及代理服务器(在模块代理服务器一节详细讲解)的出现,就不再推荐了。还希望用vendor的一个原因可能是它会在使用某些CI/CD(持续集成/持续发布)管道时让构建更快更高效。如果管道的构建服务器是外部的,则不会保留模块缓存。vendoring依赖可让这些管道避免在每次触发构建时进行多次网络调用。缺点是它会大幅增加版本管理中代码的大小。

pkg.go.dev

虽然Go模块并没有单独的中央仓库,但有一个服务汇集了Go模块的文档。Go团队创建一个网站pkg.go.dev,它自动索引开源Go模块。对每个模块,包索引发布其文档、所使用的证书、README、模块依赖以及依赖该模块的开源模块。可在图4-2中看到pkg.go.dev上有simpletax模块。

图 4-2 使用pkg.go.dev查找、学习第三方模块

图 4-2 使用pkg.go.dev查找、学习第三方模块

发布模块

让模块可供他人使用和将其放到版本控制系统中一样简单。不论是发布到GitHub这种对公版本控制系统还是自托管的私有系统都一样。因为Go程序通过源码构建,使用仓库路径来进行标识,无需显式像Maven或npm那样上传模块到中央仓库。请确保提交go.mod 和go.sum 这两个文件。

在发布开源模块时,应当在仓库根目录下包含一个LICENSE文件,指定代码所使用的开源证书。It’s FOSS有很好的资源可了解各种各样的开源证书。

大致来讲,可以将开源证书分成两类:许可式(允许代码使用者保持自己的代码私有)和非许可式(要求代码使用者将其代码开源)。虽然选什么证书由你来定,Go社区更喜欢许可式证书,比如BSD、MIT和Apache。因为Go直接将第三方代码编译成应用,使用GPL这样的非许可式证书会要求使用代码的人将代码也开源。这对很多组织是不可接受的。

最后一点:不要编写自己的证书。很少有人会相信它由专业律师审过,他们也无法分辨在模块中做了什么声明。

对模块添加版本

不论模块是公开还是私有,都必须为模块添加适当的版本,才能正常使用Go的模块系统。只要对模块添加功能或修复补丁,过程就很简单。在源代码仓库中保存修改,然后应用遵循语义化版本的标签。

Go的语义化版本支持预发布的概念。假设模块的当前版本标签为v1.3.4。你在开发1.4.0版本,还没太完成,但又希望在其它模块中导入它。这时需要在版本标签后加连字符(-),接预发布构建的标识符。本例中,我们使用v1.4.0-beta1这样的标签来表明这是1.4.0版本的beta 1或v1.4.0-rc2来表示其它发布的第2个候选版本。如果希望依赖预发布候选版本,必须显式地在go get中指定版本,因为Go不会自动选择预发布版本。

如果需要做出非身后兼容的修改,流程就更复杂些。我们在导入simpletax模块的版本2时了解到,非向后兼容的修改需要用不同的路径。有几个步骤。

  • 在模块中创建vN子目录,其中N为模块的大版本号。例如,在创建模块的版本2时,将目录命名为v2。将代码、README以及LICENSE文件都拷贝到该目录中。
  • 在版本控制系统中创建一个分支。可将老代码或新代码放到新分支中。如果在分支中放的是新代码将分支命名为vN,而如果是老代码则命名为N-1。例如,创建版本2又想将版本1放到分支中,使用分支名v1

在决定如何保存新代码后,需要修改子目录或分支代码中的导入路径。go.mod 文件中的模块路径必须以 /vN结尾,查看所有代码会很枯燥,Marwan Sulaiman创建了一个自动化执行的工具。确定好路径后,继续实现修改。

注:技术上讲,只需要修改go.mod 和导入语句,将主分支标记为最新版本,无需去碰子目录或版本分支。但这不是一种良好实践。它会使用老版本进行构建的Go代码崩溃,并且很难知道你的模块更老的大版本。

在准备好发布新代码时,为仓库添加一个类似vN.0.0的标签。如果使用子目录系统或将最新代码放在主分支,则对主分支添加标签。而如果新代码放在其它分支,则对该分支打标签。

读者可在Go博客的Go模块: v2及以上一文中了解升级到不兼容版本代码的更多内容。

依赖重载

Fork代码也是常有之事。虽然在开源社区中对fork持有偏见,但难免有时一些模块会停止维护或是需要体验一下模块作者所不接受的修改。replace指令可将跨模块的所有引用重定向到一个模块,并替换为指定的fork。类似下面这样:

replace github.com/jonbodner/proteus => github.com/someone_else/my_proteus v1.0.0

原模块的位置在左侧指定,替换内容居于右侧。右侧必须指定版本,但左侧不一定要指定版本。如若指定了版本,只会替换所指定版本。如未指定版本,则原模块的所有版本都替换为所指定的fork。

replace指令也可以指向本地文件系统的路径:

replace github.com/jonbodner/proteus => ../projects/proteus

使用本地replace指令,左侧和右侧的版本都可以不指定。

警告:避免使用本地replace指令。在出现Go工作空间前它提供了一种方式用于同步修改多个模块,但现在则是一种导致模块崩溃的潜在来源。(稍后我们会讲到工作空间。)如果通过版本控制分享模块,replace指令中带有本地引用的模块可能无法供其它人构建,因为无法保证其他人的硬盘的同样位置上会存在替代模块。

也有可能会希望阻止使用某个版本的模块。也许是因为一个bug或是它与你的模块不兼容。Go提供了一个exclude指令用于阻止使用指定版本的模块:

exclude github.com/jonbodner/proteus v0.10.1

在排除了模块的某个版本时,依赖模块中任何该模块版本的引用都会被忽略。如果所排除的版本是在你的模块依赖中唯一指令的版本,在go.mod中使用go get来添加该模块其它版本的间接导入,这样模块方可编译。

撤销模块指定版本

迟早你会不小心发布一个不希望其他人使用的模块版本。也许是测试未完全就不小发布了。也许是在发布后,发现了一个严重漏洞,不应再投入使用。不管是出于什么原因,Go都提供了一种方式用于表明某个模块版本应当被忽略。这通过在模块的go.mod文件中添加retract指令进行实现。它包含有关键字retract和不再使用的语义版本。如果有一组版本都不应再使用,可在方括号添加上限和下限并以逗号分隔来排除某个范围内的所有版本。虽然并未要求,但推荐在版本或版本范围后添加注释说明撤销的原因。

如果想撤销多个非连续版本,可以通过多条retract指令予以指定。在下面的例子中,排除了版本1.50,以及1.7.0到1.8.5(含上下限)之间的所有版本。

retract v1.5.0 // not fully tested
retract [v1.7.0, v.1.8.5] // posts your cat photos to LinkedIn w/o permission

go.mod中添加retract指令要求为模块创建一个新版本。如果新版本仅用于撤销,也应撤销该版本。

在撤销了某个版本时,指定了该版本的已有构建仍可使用,但go getgo mod tidy不会更新到该版本。使用go list命令也不会出现这些版本。如果一个模块的大部分最新版本都被撤销了则无法使用@latest匹配,而会匹配最高的未撤销版本。

注:虽然retractexclude会产生混淆,但有重要的分别。使用retract来防止其他人使用你模块的指定版本。而exclude用于防止使用其它模块的某些版本。

使用工作空间同步修改模块

使用源代码仓库及标签来追踪依赖及版本有一个缺点。如果想同步修改两个或以上的模块,希望跨模块体验这些修改,需要有一种方式用模块的本地拷贝覆盖源代码仓库的模块版本。

警告:读者会在网上看到一些过时的建议,在go.mod中使用临时replace指令指向本地目录解决这一问题。别这么干!在提交、推送代码时很容易忘记撤销这些修改。引入工作空间就是为避免这种反模式。

Go使用工作空间来解决这一问题。工作空间允许我们在电脑上下载多个模块,相互引用并自动解析至本地源代码而非远程仓库中的代码。

注:本节我们假定读者已有GitHub账号。没有的读者也可跟着操作。这里使用的组织名是learning-go-book-2e,读者应将其替换为自己的GitHub账号名或组织。

先从两个示例模块开始。创建my_workspace目录,在其中再创建两个目录workspace_libworkspace_app。在workspace_lib目录中,运行go mod init github.com/learning-go-book-2e/workspace_lib。使用如下内容创建一个lib.go文件:

package workspace_lib

func AddNums(a, b int) int {
    return a + b
}

workspace_app目录中,运行go mod init github.com/learning-go-book-2e/workspace_app。创建app.go并添加如下内容:

package main

import (
    "fmt"
    "github.com/learning-go-book-2e/workspace_lib"
)

func main() {
    fmt.Println(workspace_lib.AddNums(2, 3))
}

在前面的小节中,我们使用go get ./...go.mod中添加require指令。看看在这里运行会发生什么:

$ go get ./...
github.com/learning-go-book-2e/workspace_app imports
    github.com/learning-go-book-2e/workspace_lib: cannot find module
        providing package github.com/learning-go-book-2e/workspace_lib

因为workspace_lib尚未推到GitHub,所以无法拉取。如查运行go build,会得到类似的错误:

$ go build
app.go:5:2: no required module provides
    package github.com/learning-go-book-2e/workspace_lib; to add it:
        go get github.com/learning-go-book-2e/workspace_lib

我们来利用工作空间让workspace_lib的拷贝对workplace_app可见。进入my_workspace目录运行如下命令:

$ go work init ./workspace_app
$ go work use ./workspace_lib

这会在my_workspace中创建包含如下内容的go.work文件:

go 1.19

use (
    ./workspace_app
    ./workspace_lib
)

警告:go.work仅用于本地电脑,不要将其提交到版本控制系统。

这时构建workspace_app,一切正常:

$ cd workspace_app
$ go build
$ ./workspace_app
5

既然已确定workspace_lib运行正常,就可以推到GitHub上了。在GitHub上,创建一个空的公共仓库workspace_lib,然后在workspace_lib中运行如下命令:

$ git init
$ git add .
$ git commit -m "first commit"
$ git remote add origin git@github.com:learning-go-book-2e/workspace_lib.git
$ git branch -M main
$ git push -u origin main

运行这些命令后,进入github.com/learning-go…(将learning-go-book-2e替换为自己的账号或组织),再使用v0.1.0标签创建一个相关的的发布。

这时再运行go get ./...,就会添加require指令,因为可从公共模块中进行下载了:

$ go get ./...
go: downloading github.com/learning-go-book-2e/workspace_lib v0.1.0
go: added github.com/learning-go-book-2e/workspace_lib v0.1.0
$ cat go.mod
module github.com/learning-go-book-2e/workspace_app

go 1.19

require github.com/learning-go-book-2e/workspace_lib v0.1.0

虽然有了require指令指向公有格式塔民,我们仍可在本地工作空间中做出修改并使用该修改。在workspace_lib中,修改lib.go文件并添加如下函数:

func SubNums(a, b int) int {
    return a - b
}

workspace_app中,修改app.go文件并添加如下代码到main函数的最后:

    fmt.Println(workspace_lib.SubNums(2,3))

现在运行go build,我们看到的是本地模块而是公有模块:

$ go build
$ ./workspace_app
5
-1

做完编辑并希望发布软件时,需要在go.mod文件中修改模块版本信息指向更新后的代码。这要求按指令顺序操作:

  • 按依赖顺序将模块提交回代码仓库
  • 更新代码仓库中的模块版本标签
  • 使用go getgo.mod中更新依赖的提交模块的版本
  • 重复以上操作直至所有修改的模块都完成提交

如果未来对workspace_lib做出修改并希望在提交到GitHub前在workspace_app中进行测试,同时创建了很多临时版本,可以使用git pull将模块拉回工作空间再做出修改、

模块代理服务器

Go没有对库使用单个中央仓库,而是使用了混合模型。所有Go模块都存储在源代码仓库中,比如GitHub或GitLab。但默认go get不会从源代码仓库中拉取代码。而是向Google运行代理服务器发送请求。在代理服务器接收到go get请求时,会检查缓存确认此前是否有对该模块该版本的请求。如果有,则返回缓存信息。如果模块或相应版本没有在代理服务器中缓存,会从模块仓库下载模块,存储一个拷贝并返回模块。这样代理服务器几乎可存储所有公共Go模块所有版本的拷贝。

除代理服务器,Google还维护了一个校验和(checksum)数据库。它存储代理服务器所缓存的所有模块所有版本的信息。代理服务器防止模块或模块的版本从互联网上删除,而校验和数据库防止模块版本的修改。这可能是恶意(某人支持了模块并插入恶意代码),或是粗心所致(模块维护者修复bug或添加新功能又复用已有的版本标签。)不论哪种情况,都不希望使用修改了内容的模块版本去构建相同的二进制,而不知道应用的效果。

每次通过go getgo mod tidy下载模块时,Go工具会计算模块的哈希,并联系检验和数据库比较计算后的哈希与所存储的模块版本的哈希。如不匹配,则不安装模块。

指定代理服务器

有些人反对向Google发送第三方库的请求。有如下选择:

  • 我们可以将GOPROXY环境变量设置为direct来整体禁用代理。这时会直接从仓库下载模块,但如果依赖了仓库中删除了的版本,就无法访问了。
  • 可以运行自己的代理服务器。Artifactory和Sonatype的企业仓库产品都内置了Go代理服务器。Athens Project提供了开源代理服务器。在自己的网络中安装其中一个产品并将GOPROXY指向该URL。

私有仓库

大部分组织在私有仓库中维护自己的代码。如果希望在另一个Go模块中使用私有模块,就不能从Google的代理服务器上请求了。Go会退回直接从私有仓库中检查,但你可能不希望对外部服务泄漏私有服务器和仓库的名称。

如使用自有代理服务器,或是禁用了代理 ,这不是问题。运行私有代理服务器还有其它好处。首先,它加速了第三方模块的下载,因为缓存位于公司自己的网络中。如果访问私有仓库需要身份验证,使用私有代理服务器意味着无需担扰暴露CI/CD的需进行身份验证的信息。私有代理服务器配置为授权给私有仓库(参见Athens的身份验证配置文档),但对私有代理服务器的调用未经过身份验证。

如果使用的是公有代理服务顺,可将GOPRIVATE环境变量设置为私有仓库的逗号分隔列表。例如,将GOPRIVATE设置为:

GOPRIVATE=*.example.com,company.com/repo

存储于example.com子域名或以company.com/repo开头的URL的仓库都会被直接下载。

其它知识

Go团队在线上有完整的Go模块手册。除本章中所讲解的内容,模块手册还涵盖了其它内容:比如git以外的版本控制系统、模块缓存的结构、其它控制模块查询行为的环境变量以及校验和数据库的REST API 。

练习

  1. 在自己的公共仓库中创建模块。这个模块有一个接收两个int参数和一个int返回值的函数。该函数对两个参数想回,返回和。将版本设置为v1.0.0。
  2. 对模块添加描述包及Add函数的godoc注释。为Add函数的godoc添加链接www.mathsisfun.com/numbers/add…。版本设置为v1.0.1。
  3. 修改Add使其更通用。导入golang.org/x/exp/constraints包。将该包中的IntegerFloat类型合并为名为Number的接口。重写Add接收类型为Number的两个参数,并返回一个类型为Number的值。再次为模块打版本。因为这是向后不兼容的修改,所以版本应为v2.0.0。

小结

本章中,我们学习了如何组织代码并与Go源码的生态进行交互。我们学到了模块的原理、如何将代码组组织成包、如何使用第三方模块以及如何发布自己的模块。在下一章中,我们会学习Go所包含的更多的开发工具,学习一些基本第三方工具,并探索更好地控制构建过程的一些技术。