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

Coding Alan 2年前 (2023-03-12) 1671次浏览 0个评论 扫描二维码

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

大部分编程语言都有将代码组织到命名空间和库的系统,Go也不例外。在学习其它特性时我们看到了,Go对这些老思想引入了新方法。本章中,读者会学习到如何通过包和模块组织代码、如何导入、如何使用第三方库以及如何创建自有库。

仓库、模块和包

Go语言的库管理有三个基础概念:仓库、模块和包。所有开发者对仓库都很熟悉了。它是存储项目源码的版本控制系统。模块是按独立单元分发和打版本的Go源码包。模块存储于仓库中。模块由一个或多个包组成,也就是源代码的目录。包进行模块的组织和结构化。

注:虽然可以在仓库中存储多个模块,不建议这么做。模块中的所有内容都在一起进行版本管理。在一个仓库中维护两个模块会要在单个仓库中分别追踪两个模块。

在使用标准库之外包的代码前,需要确保模块创建正常。每个模块都有一个全局唯一标识符。这不只限于Go语言。Java中使用com.companyname.projectname.library这样的全局唯一 包声明。

在Go语言中,这称之为模块路径。通常是以模块所存储的仓库为基础。例如,作者用Go语言所写的简化关系数据库访问工具Proteus,模块路径为github.com/jonbodner/proteus

注:在Go开发环境配置一章中,我们创建了名为hello_world的模块,显然它不是全局唯一的。如果创建的模块只在本地使用这完全没有问题。如果将非唯一名称模块添加到源代码仓库中的话,就无法由另一个模块导入。

go.mod

在Go源代码目录中有有效go.mod文件时就成为了模块。我们不用手动创建这一文件,可以使用go mod的子命令管理模块。go mod init MODULE_PATH命令会让当前目录变成模块的根目录。MODULE_PATH是用于标识模块的全局唯一名称。模块路径区分大小写。为减少困扰,请不要使用大写字母。

我们来看看go.mod文件中的内容:

每个go.mod 文件都以指令开头,它由单词module和模块唯一路径构成。接着go.mod文件使用go指令指定了最小兼容Go版本。模块中的所有源代码必须与指定版本相兼容。例如,如果指定了比较老的版本1.12,编译器不会允许在数字字面量中使用下划线,因为这一特性在Go 1.13中才添加的。Go版本还控制着Go构建工具的特性。本系列文章中工具行为与写作时有最新版本Go 1.21相匹配。如果需要使用指定了更老版本的模块,可以会存在些微差别。

go.mod中的下一个版块是require指令。只有在模块有依赖时才存在require指令。其中列出模块所依赖的模块及每个模块的最低版本。第一个require版块列举是模块的直接依赖。第二个是这些依赖模块的依赖模块。该版块中每一行依赖的后面会接一条// indirect注释。标记为间接的依赖与没标记的在功能上并没有差别,只是供查看go.mod文件的人查看使用。有一种场景会将直接依赖标记为间接,我们会在讨论go get的不同使用方式时讲解。在导入第三方代码一节中,我们会学习到更多有关添加和管理模块依赖的知识。

go.mod文件中,modulegorequire是最常用的一些指令,但还有其它的指令。我们会在依赖重载一章讲到replaceexclude指令,还会在撤销模块指定版本中讲到retract指令。

构建包

我们已经学习了如何将目录转化为模块,下面可以开始使用包来组织代码了。我们先了解import的原理,接着创建和组织包,然后Go包好的和不好的特性。

导入和导出

示例程序中一直在使用import语句,但我们还没讨论Go语言中的导入与其它语言有什么区别。Go的import语句允许访问其它包导出的常量、变量、函数及类型。包的导出标识符(标识符是变量、常量、类型、函数、方式或结构体中字段的名称)必须使用import语句才能在其它包中使用。

这就带来了一个问题:如何在Go中导出标识符呢?Go中并没有使用特殊的关键字,而是使用首字母大写来决定声明的包级标识符是否对其它包可见。以大写字母开头的标识符即为导出。相对应地,以小写字母或下划线开头的标识符仅能在所声明的包内使用。

所有导出的内容都是包API的一部分。在导出标识符前,请确保意在对客户端开放。对所有导出标识符添加文档,并保持身后兼容,除非是真的要做大版本修改(参见对模块添加版本了解更多信息)。

创建和访问包

在Go中创建包很容易。我们通过一个小程序进行演示。代码可参见GitHub。在package_example中,可以看到两个目录:mathdo-formatmath中有一个名为math.go的文件,包含如下内容:

该文件的第一行称为package语句。包含关键字package和包名。package语句是Go源文件的第一个非空非注释行。

do-format目录中,有一个包含如下内容的formatter.go 文件:

注意这里包声明语句使用了名称format,但目录为do-format。如何使用包一会儿会讲解。

最后,在根目录的main.go文件中有如下内容:

文件的第一行读者已经不陌生了。在本文之前我们代码的第一行都是package main。我们一会儿会讨论其含义。

接下来为导入区。导入了三个包。第一个是标准库的fmt。我们在前面文章已经使用过。接下来两个导入指向程序内的包。在导入标准库以外的包时要指定导入路径。导入路径为包路径接模块路径。

导入包却未使用其所导出的任一标识符会报编译时错误。这可以保障Go编译器所生成的二进制文件只包含程序中实际使用的代码。

警告:虽然可以使用相对路径导入同一模块的依赖包,请不要这么做。绝对导入路径让导入变得清晰,也让代码重构变简单。使用相对路径在将导入移到另一个包时需要进行修改,而如果将文件移到另一个模块的话,则必须让导入引用变成绝对路径。

运行这段程序,会得到如下输出:

main函数通过在函数名前加包名调用了math包中的Double函数。在前面文章中调用标准库中的函数已经使用过了。我们还调用了format包中的Number函数。读者可能会想format包是从哪来的呢?因为导入中用的是github.com/learning-go-book-2e/package_example/do-format

同一目录中的所有Go文件必须使用相同的包声明。(存在特例情况,比如目录中包含测试文件)。我们通过路径github.com/learning-go-book-2e/package_example/do-format导入的是format包。这是因为包名由包的声明决定,而不是导入路径。

通常应当让包名与目录名一致。如两者不一致就很难知道包名是什么。但是,有一些场景会要求使用的包名和目录名不一样。

第一种我们一直在做,可能都没意识到。那就是声明Go语言入口的包,包名为main。因为不能导入main包,这并不会产生导入语句的困扰。

包名与目录名不一致的另一个原因不那么常见。如果目录名称中包含的字符不是合法的Go标识符,那么就需要选择一个与目录名不同的包名。本例中,format不是有效的标识名,所以使用format替换。最好避免创建不是有效标识符的目录名。

最后一个原因是使用目录来支持版本。我们会在对模块添加版本一节中进一步讨论。

包名位于文件作用域中。如在同一个包的不同文件中使用相同包名,则必须导入这些文件的包。

为包命名

包名应具有描述性。不要创建名为util的包,而是创建描述包所提供功能的包名。例如,有两个帮助函数:一个导出字符串的所有名称,另一个格式化名称。不要在util包中创建名为ExtractNamesFormatNames的两个函数。别这么干,否则在每次使用这些函数时,需要使用util.ExtractNamesutil.FormatNamesutil包完全没说明函数的作用是什么。

一种选择是在extract包中创建一个Names函数,在format包中再创建一个Names函数。函数名称一样没有问题,因为可通过包名进行区分。在导入时第一个使用extract.Names,第二个使用format.Names

更好的方法是思考一次演讲的内容。函数或方法执行任务,因此应当是动词。包名应是名词,由包中函数创建或修改的项目的名称。遵循这一规则,我们会创建一个名为names的包,包含两个函数ExtractFormat。第一个通过names.Extract使用,第二个通过names.Format

还应当避免在函数和类型名中重复包名。位于names包中时不要将函数命名为ExtractNames。例外情况是标识符名称与包名相同之时。例如,标准库sort包中有一个Sort函数,而context包中定义了Context接口。

包名重命名

有时会发现导入的两个包名称冲突了。例如,标准库中有两个生成随机数的包:一个进行了安全加密(crypto/rand),另一个不是(math/rand)。在不使用加密生成随机数时普通生成器就可以了,但需要为其加种子。常见的方式是使用加密生成器生成的值来作为常规随机数生成器的种子。在Go语言中,这两者的包名相同(rand)。这时在文件中要为其中一个包提供别名。可在The Go Playground中测试这段代码。首先来看导入区:

我们使用名称crand来导入crypto/rand。这会将该包中声明的rand名称进行重命名。然后正常地导入math/rand。再来看seedRand函数,使用的是rand前缀来访问math/rand中标识符,而对crypto/rand包使用crand前缀:

注:还有两个符号可用于包名。.将导入包的所有导出标识符放到当前包命名空间中,使用时不需要加前缀。不鼓励这么做,因为这会让源代码变得不清晰。这时就无法根据名称知道是在当前包中定义的还是从外部导入的。

还可以使用_。我们会在init函数:能免则免一节中讨论init时会讲到。

我们在代码块,遮蔽和控制结构中讨论过包名遮蔽的情况。将变量、类型或函数声明为包名会让包在其作用域中无法访问。如果无法避免(如新导入的包与现有标识符相冲突),重命名包名解决问题。

使用godoc创建模块文档

创建模块供他人使用的一个重要部分是有合适的文档。Go有编写注释的自有格式可自动转化为文档。称为godoc格式,非常简单。规则如下:

  • 将注释放到添加文档的内容之前,注释行和声明之间没有空行。
  • 每个注释行以双斜线(//)开头,后接一个空格。虽然使用/*和*/标记的块注释完全合法,但使用双斜线更为地道。
  • 代号(函数、类型、常量、变量或方法)注释的第一个词应为代号的名称。如可让注释文本在语法上正确,也可以在代号名称前加上不定冠词A或An。
  • 使用空白注释行(双斜线和新行)来将注释分成多段。

我们在pkg.go.dev一节中会讲到,可以用HTML格式浏览公开的在线文档。如果想让文档更高级些,有如下的格式化方法:

  • 如希望注释包含一些预格式化内容(如表格或源码),在双斜线后多加空格缩进到与内容一致。
  • 如果希望注释有头部,在双斜线后加#和空格。与Markdown不同,这里不能添加多个#来生成多级标题。
  • 要链向另一个包(不管是否在当前模块中),将包路径放到方括号(即[和])之间。
  • 如在注释中包含URL,会被转化为链接。
  • 如希望包含链向网页的文本,将文本放在方括号(即[和])之间。在注释块的最后,使用// [TEXT]: URL这种格式进行文本和URL对应的声明。一会儿会有示例。

包声明之前的注释会创建包级注释。如果对包的注释很长(比如fmt包中很长的格式化文档),按惯例是放到包内一个名为doc.go的文件中。

我们来看一个有良好注释的文档,先从例4-1中的包级注释开始。

例4-1 包级注释

接下来对导出的结构体添加注释(例4-2)。注意其以结构体名称开头。

例4-2 结构体注释

最后对函数添加注释(见例4-3)。

例4-3 添加了注释的函数

Go包含显示文档的go doc命令行工具。go doc PACKAGE_NAME命令展示具体包的文档和包中一系列的标识符。使用go doc PACKAGE_NAME.IDENTIFIER_NAME展示包中具体标识符的文档。

可惜Go不支持在发布到网络前预览文档HTML格式的工具。作者写了一个工具可用于在发布前查看文档。还能用它生成代码注释的静态HTML,还支持Markdown输出。

Go文档注释官方文档中还有更多有关注释和潜在问题的详情。

小贴士:确保合理地注释代码。至少每个导出标识符应当有注释。在Go语言工具一章的代码质量扫描器中我们会看一些报告缺失注释的导出标识符的第三方工具。

内部包

有时会想要在模块内共享函数、类型或常量,但不希望暴露为API。Go通过特别的internal包名来提供支持。

在创建internal包时,包内导出标识符及其子包仅能在internal的父级包或兄弟包中使用。下面来看一个例子。可在GitHub上查看代码。目录结构参见图4-1

我们在internal包的internal.go 文件中声明了一个简单函数:

可在foo包中的foo.go文件及sibling包中的sibling.go文件中访问该函数。

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

图4-1 internal_package_example包的文件树

注意在bar包的bar.go文件或根包的example.go文件中使用内部函数时会报编译错误:

循环依赖

Go的两大目标是编译快、源码易理解。为实现这点,Go不允许在包之间循环依赖。也就是包A直接或间接导入包B,则包B不能再直接或间接导入包A。我们通过一个示例快速讲解这一概念。可通过GitHub下载这段代码。我们的模块有两个子目录,petperson。在pet包的pet.go中,我们导入了github.com/learning-go-book-2e/circular_dependency_example/person

介在personperson.go的文件中又导入了github.com/learning-go-book-2e/circular_dependency_example/pet

尝试构建这一模块时会得到如下错误:

有一些选项可解决循环依赖。有时这是由于对包分割的过细所致。如果两个包彼此依赖,很有可能应将它们合并到一个包中。我们可以将personpet合并为一个包解决这一问题。

如果有充分理由让包保持分享,可能可以将导致循环依赖部分的内容移到其中一个包或是新包中。

如何组织模块

模块中Go包并没有官方的结构,但这些涌现出了一些模式。它们的指导原则是应当聚焦于让代码易于理解和维护。

在模块很小时,保持所有的代码在同一个包中。只要没有其它模块依赖该模块,推迟进行组织并没有什么坏处。

随着模块的增长,需要进一步规整让代码可读性更强。首先要问创建的是什么类型的模块。可大致将模块分为两类:面向单应用的模块以及要成为库的模块。如果确定模块仅用于一个应用,让项目的根为main包。主包的代码应最小化,将逻辑放到internal目录中,在main函数中只添加调用internal的代码。这样可以保证没人创建依赖于应用实现的模块。

如果希望将模块用作库,模块的根应使用与仓库名一致的饭锅。这样导入名称与包名一致。库模块中包含一个或多个工具应用并不罕见。这时在模块的根中创建一个cmd目录。在cmd中,为每个从模块中构建的二进制创建一个目录。例如,可能有一个模块包含web应用以及分析web应用数据库中数据的命令行工具。在每个目录中使用main作为包名。

更多详情,Eli Bendersky的博客关于如何布局简单Go模块提了很好的建议。

项目越来越复杂,我们会倾向拆分包。确保在组织代码时限制依赖。一种常见模式是将代码组织为功能切片。例如,如果用Go编写购物站点,可能会将客户管理的代码放到一个包中,并将库存相关代码放到另一个包中。这种样式可限制包间的依赖,之后将单web网页重构为多个微服务会更容易些。这种方式与很Java应用的组织方式不同,它会将业务逻辑放到一个包中,所有数据库逻辑放到另一个包中,而数据传输对象会放到第三个包中。

在开发库时,善用internal包。如果在模块的internal包外创建多个包,导出符号在模块内其它包中使用,也意味着其它导入模块的人也可以使用。软件工程中有一条海勒姆法则:有了足够数量的 API 用户,在合同中承诺什么并不重要:系统的所有可观察行为都会被其他人依赖。一旦某个功能成为API的一部分,就有责任在决定新版本中不再提供向后支持前一直提供支持。我们会在更新到不兼容版本一节中学习。如果某些符号仅希望在模块内分享,将它们放到internal中。如果改变想法的话,稍后可以将其移出internal包。

要很好地总览Go项目结构的建议,可观看Kat Zien在GopherCon 2018上的演讲,如何布局Go应用

警告:golang-standards GitHub仓库中声称其为“标准”模块布局。Go的开发大当家Russ Cox,曾公开声明Go团队并不认可,其所推荐的结构实际上是一种反模式。不要采纳这个仓库作为代码的组织方式。

优雅重命名及重新组织API

在使用一段时间模块后,可能会意识到其API并不理想。也许会想重命名一些导出标识符或将它们移到模块的其它包中。为避免出现向后的breaking修改,请不要删除原标识符,提供一个替代名称。

对于函数或方法这很容易。声明一个函数或方法调用原函数或方法。对于常量,声明一个同类型和值的新常量,只需更改名称。

如想重命名或移动导出类型,使用别名。非常简单,别名就是类型的新名称。我们在类型、方法和接口一章中使用了type关键字根据已有类型声明一个新类型。我们还使用type关键字声明一个别名。比如有一个类型Foo

如果想让用户通过Bar访问Foo,只需做如下操作:

我们使用type关键字、别名名称、等号及原类型名称创建别名。别名与原类型的字段和方法相同。

甚至可以将别名赋值给原类型的变量,无需进行类型转换:

需要记住一个重点:别名只是一种类型的另一个名称。如果想要对别名结构体添加方法或修改字段,必须在原类型中添加。

可以将在同包或不同包中定义的类型设置的别名类型为原始类型。甚至可以设置另一个模块中的类型别名。对其它包的别名有一个缺点:无法使用别名指向未导出的方式及原始类型的字段。这一局限很好理解,因为别名是为了对包的API做渐进式修改,而API仅由包的导出部分组成。要突破这一局限,可调用类型原始包中的代码来操作未导出字段和方法。

有两种类型的导出标识符无法有替代名称。第一个是包级变量。第二是结构体中的字段。一旦为导出的结构体字段选取名称,就无法为其创建别名。

init函数:能免则免

在阅读Go代码时,通常很清楚调用了哪些方法和函数。Go没有方法或函数重载的一个原因是让运行的代码更易于理解。但有一种方式无需做任何操作就可配置包的状态:init函数。在声明没带参数和返回值的init函数时,它会在另一个包中初次引用时运行。因为init函数没有输入和输出,只能附带调用,与包级函数和变量进行交互。

init函数有另一个特别的特性。Go允许在单个包中甚至是一个包的某个文件中声明多个init函数。对于单个包中的多个init函数有规定的顺序,与其记住它,最好避免这么做。

有些包,比如数据库驱动,使用init函数来注册数据库驱动。但是不使用包中的任何标识符。前面提到了Go不允许存在未使用的导入。应对这一问题,Go允许有空导入,即赋给导入的名称是下划线(_)。正如下划线可以忽略未使用的函数返回值,空导入触发包中的init函数,并且不能访问包中的导出标识符:

这一模式被看成过时,原因在于注册操作是否执行了是不清晰的。Go对标准包的兼容性承诺表示我们必须使用它来注册数据库驱动和图片格式,但如果在自己的代码是有注册模式,请显式注册插件。

如今init函数的主要用途是初始化无法在单条赋值中配置的包级变量。在包的顶级具备可变状态不是个好做法,因为这使用理解应用中的数据流动更加困难。这表示通过init配置的所有包级变量都应是不可变的。虽然Go没有提供方式强制值不可变,应当确保代码中不做修改。如果包级变量需要在程序运行中发生改变,确认是否可重构代码将状态放到初始化的结构体中并由包中的函数返回。

init函数的非显式调用意味着应当对其行为添加文档。例如,带有加载文件或访问网络init函数的包应当在包级注释中说明,这样使用代码又注重安全的用户不会因为预料外的I/O而感到惊讶。

使用模块

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

导入第三方代码

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

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

main.go:中的代码如下:

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

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

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

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

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

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文件的内容:

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

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

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

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

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

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

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

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

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

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

使用版本

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

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

go.mod文件更新为了:

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

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

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

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

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

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

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

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

语义版本

从很早开始软件就有版本号了,但有关版本号的含义却并不一致。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/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导入为:

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

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

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

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

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

go.sum也发生了更新:

虽然不再使用但其中依然引用了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模块。

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

图 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。类似下面这样:

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

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

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

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

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

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

撤销模块指定版本

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

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

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文件:

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

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

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

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

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

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

这时构建workspace_app,一切正常:

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

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

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

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

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

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

做完编辑并希望发布软件时,需要在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设置为:

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

其它知识

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

练习

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

小结

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

喜欢 (0)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址