本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
大部分编程语言都有将代码组织到命名空间和库的系统,Go也不例外。在学习其它特性时我们看到了,Go对这些老思想引入了新方法。本章中,读者会学习到如何通过包和模块组织代码、如何导入、如何使用第三方库以及如何创建自有库。
仓库、模块和包
Go语言的库管理有三个基础概念:仓库、模块和包。所有开发者对仓库都很熟悉了。它是存储项目源码的版本控制系统。模块是按独立单元分发和打版本的Go源码包。模块存储于仓库中。模块由一个或多个包组成,也就是源代码的目录。包进行模块的组织和结构化。
注: 虽然可以在仓库中存储多个模块,不建议这么做。模块中的所有内容都在一起进行版本管理。在一个仓库中维护两个模块会要在单个仓库中分别追踪两个模块。
com.companyname.projectname.library
在Go语言中,这称之为模块路径。通常是以模块所存储的仓库为基础。例如,作者用Go语言所写的简化关系数据库访问工具Proteus,模块路径为。
hello_world
go.mod
go modgo mod initMODULE_PATHMODULE_PATH
我们来看看go.mod文件中的内容:
modulego1.12
go.modrequirerequirerequire// indirectgo.modgo get
go.modmodulegorequirereplaceexcluderetract
构建包
import
导入和导出
importimportimport
这就带来了一个问题:如何在Go中导出标识符呢?Go中并没有使用特殊的关键字,而是使用首字母大写来决定声明的包级标识符是否对其它包可见。以大写字母开头的标识符即为导出。相对应地,以小写字母或下划线开头的标识符仅能在所声明的包内使用。
所有导出的内容都是包API的一部分。在导出标识符前,请确保意在对客户端开放。对所有导出标识符添加文档,并保持身后兼容,除非是真的要做大版本修改(参见对模块添加版本了解更多信息)。
创建和访问包
package_example
package
在do-format目录中,有一个包含如下内容的formatter.go 文件:
format
最后,在根目录的main.go文件中有如下内容:
package main
fmt
导入包却未使用其所导出的任一标识符会报编译时错误。这可以保障Go编译器所生成的二进制文件只包含程序中实际使用的代码。
警告:虽然可以使用相对路径导入同一模块的依赖包,请不要这么做。绝对导入路径让导入变得清晰,也让代码重构变简单。使用相对路径在将导入移到另一个包时需要进行修改,而如果将文件移到另一个模块的话,则必须让导入引用变成绝对路径。
运行这段程序,会得到如下输出:
mainmathDoubleformatNumberformatgithub.com/learning-go-book-2e/package_example/do-format
format
通常应当让包名与目录名一致。如两者不一致就很难知道包名是什么。但是,有一些场景会要求使用的包名和目录名不一样。
mainmain
formatformat
最后一个原因是使用目录来支持版本。我们会在对模块添加版本一节中进一步讨论。
包名位于文件作用域中。如在同一个包的不同文件中使用相同包名,则必须导入这些文件的包。
为包命名
utilutilExtractNamesFormatNamesutil.ExtractNamesutil.FormatNamesutil
extractNamesformatNamesextract.Namesformat.Names
namesExtractFormatnames.Extractnames.Format
namesExtractNamessortSortcontextContext
包名重命名
crypto/randmath/randrand
crandcrypto/randrandmath/randseedRandrandmath/randcrypto/randcrand
.
_init
我们在代码块,遮蔽和控制结构中讨论过包名遮蔽的情况。将变量、类型或函数声明为包名会让包在其作用域中无法访问。如果无法避免(如新导入的包与现有标识符相冲突),重命名包名解决问题。
使用godoc创建模块文档
创建模块供他人使用的一个重要部分是有合适的文档。Go有编写注释的自有格式可自动转化为文档。称为godoc格式,非常简单。规则如下:
- 将注释放到添加文档的内容之前,注释行和声明之间没有空行。
- 每个注释行以双斜线(//)开头,后接一个空格。虽然使用/和/标记的块注释完全合法,但使用双斜线更为地道。
- 代号(函数、类型、常量、变量或方法)注释的第一个词应为代号的名称。如可让注释文本在语法上正确,也可以在代号名称前加上不定冠词A或An。
- 使用空白注释行(双斜线和新行)来将注释分成多段。
我们在pkg.go.dev一节中会讲到,可以用HTML格式浏览公开的在线文档。如果想让文档更高级些,有如下的格式化方法:
- 如希望注释包含一些预格式化内容(如表格或源码),在双斜线后多加空格缩进到与内容一致。
- 如果希望注释有头部,在双斜线后加#和空格。与Markdown不同,这里不能添加多个#来生成多级标题。
- 要链向另一个包(不管是否在当前模块中),将包路径放到方括号(即[和])之间。
- 如在注释中包含URL,会被转化为链接。
- 如希望包含链向网页的文本,将文本放在方括号(即[和])之间。在注释块的最后,使用 // [TEXT]: URL这种格式进行文本和URL对应的声明。一会儿会有示例。
fmt
我们来看一个有良好注释的文档,先从例4-1中的包级注释开始。
例4-1 包级注释
接下来对导出的结构体添加注释(例4-2)。注意其以结构体名称开头。
例4-2 结构体注释
最后对函数添加注释(见例4-3)。
例4-3 添加了注释的函数
go docgo docPACKAGE_NAMEgo docPACKAGE_NAME.IDENTIFIER_NAME
可惜Go不支持在发布到网络前预览文档HTML格式的工具。作者写了一个工具可用于在发布前查看文档。还能用它生成代码注释的静态HTML,还支持Markdown输出。
在Go文档注释官方文档中还有更多有关注释和潜在问题的详情。
小贴士:确保合理地注释代码。至少每个导出标识符应当有注释。在Go语言工具一章的代码质量扫描器中我们会看一些报告缺失注释的导出标识符的第三方工具。
内部包
internal
internalinternal
internal
foosibling
internal_package_example
bar
循环依赖
petgithub.com/learning-go-book-2e/circular_dependency_example/person
persongithub.com/learning-go-book-2e/circular_dependency_example/pet
尝试构建这一模块时会得到如下错误:
personpet
如果有充分理由让包保持分享,可能可以将导致循环依赖部分的内容移到其中一个包或是新包中。
如何组织模块
模块中Go包并没有官方的结构,但这些涌现出了一些模式。它们的指导原则是应当聚焦于让代码易于理解和维护。
在模块很小时,保持所有的代码在同一个包中。只要没有其它模块依赖该模块,推迟进行组织并没有什么坏处。
maininternalmaininternal
main
更多详情,Eli Bendersky的博客关于如何布局简单Go模块提了很好的建议。
项目越来越复杂,我们会倾向拆分包。确保在组织代码时限制依赖。一种常见模式是将代码组织为功能切片。例如,如果用Go编写购物站点,可能会将客户管理的代码放到一个包中,并将库存相关代码放到另一个包中。这种样式可限制包间的依赖,之后将单web网页重构为多个微服务会更容易些。这种方式与很Java应用的组织方式不同,它会将业务逻辑放到一个包中,所有数据库逻辑放到另一个包中,而数据传输对象会放到第三个包中。
internalinternalinternalinternal
要很好地总览Go项目结构的建议,可观看Kat Zien在GopherCon 2018上的演讲,如何布局Go应用。
警告:golang-standards GitHub仓库中声称其为“标准”模块布局。Go的开发大佬Russ Cox,曾公开声明Go团队并不认可,其所推荐的结构实际上是一种反模式。不要采纳这个仓库作为代码的组织方式。
优雅重命名及重新组织API
在使用一段时间模块后,可能会意识到其API并不理想。也许会想重命名一些导出标识符或将它们移到模块的其它包中。为避免出现向后的breaking修改,请不要删除原标识符,提供一个替代名称。
对于函数或方法这很容易。声明一个函数或方法调用原函数或方法。对于常量,声明一个同类型和值的新常量,只需更改名称。
typetypeFoo
BarFoo
type
甚至可以将别名赋值给原类型的变量,无需进行类型转换:
需要记住一个重点:别名只是一种类型的另一个名称。如果想要对别名结构体添加方法或修改字段,必须在原类型中添加。
可以将在同包或不同包中定义的类型设置的别名类型为原始类型。甚至可以设置另一个模块中的类型别名。对其它包的别名有一个缺点:无法使用别名指向未导出的方式及原始类型的字段。这一局限很好理解,因为别名是为了对包的API做渐进式修改,而API仅由包的导出部分组成。要突破这一局限,可调用类型原始包中的代码来操作未导出字段和方法。
有两种类型的导出标识符无法有替代名称。第一个是包级变量。第二是结构体中的字段。一旦为导出的结构体字段选取名称,就无法为其创建别名。
init函数:能免则免
initinitinit
initinitinit
init_init
这一模式被看成过时,原因在于注册操作是否执行了是不清晰的。Go对标准包的兼容性承诺表示我们必须使用它来注册数据库驱动和图片格式,但如果在自己的代码是有注册模式,请显式注册插件。
initinit
initinit
使用模块
我们已经学习了如何在单个模块中使用包,接下来该学习如何集成第三方模块及其中的包。然后,我们会学习如何发布自己模块并添加版本,以及Go的中央服务:pkg.go.dev、模块代理和校验和(checksum)数据库。
导入第三方代码
fmterrorsosmath
decimal
main.go:中的代码如下:
github.com/learning-go-book-2e/formattergithub.com/shopspring/decimal
在构建应用前,查看go.mod文件。其内容应为:
如果尝试进行构建,会得到如下消息:
go.modgo getgo.modgo getgo getimportgo.mod
go get
requireformatter
requireformatterrequirego.mod
除了会更新go.mod,还会创建一个go.sum文件。对于项目依赖树中的每个模块,go.sum 文件中都有两条记录:一条模块及其版本和模块的哈希,另一条是模块的go.mod 文件的哈希。下面是本例go.sum文件的内容:
我们会学到这些哈希用于模块代理服务器中。读者可能还会注意到有些模块有多个版本。我们会在最小版本选择一节中讨论。
go buildmoney
注:我们的示例代码才提交时没有go.sum并且go.mod也不完整。这是为了读者体会什么时候添加这些文件。在将你自己的代码提交到版本控制时,请保持go.mod和go.sum 文件为最新状态。这样可以指定依赖所使用的版本。这样便实现了可重复构建,在其他人(包括未来的你自己)构建这一模块时,会得到同样的二进制文件。
go getgo getgo.modgo.sum
go get
go gego: downloadinggo clean -modcache
go.mod
formattergo get
go mod tidygo.modgo.sum
go get
使用版本
我们来看Go的模块系统如何使用版本。作者编写了一个简单模块可用于另一个税收程序。在main.go中有如下的第三方导入:
和之前一样,示例程序没有提交更新后的go.mod 和 go.sum,这样可以明白背后发生的事。在构建程序时,会看到如下操作:
go.mod文件更新为了:
还有一个包含依赖哈希的go.sum。运行代码看看结果:
go list
go list-m-versionsgo listgo get
go get
此时再看go.mod,,会发现版本发生了改变:
simpletax
这没问题,如果修改模块版本,甚至从模块中删除某一模块,go.sum中依然会有相应记录。这不会导致任何问题。
再次构建、运行代码,问题得以修复:
语义版本
从很早开始软件就有版本号了,但有关版本号的含义却并不一致。Go模块的版本号遵循语义版本规则,也称为SemVer。通过模块的语义版本,Go使得模块管理代码更简化,同时又保障模块使用者理解新版本的功能。
major.minor.patchv
最小版本选择
有时模块会依赖同时依赖相同模块的两个或以上模块。这经常发生,这些模块声明它们依赖于该模块的不同小版本或补丁版本。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/colorgithub.com/mattn/go-isattygithub.com/mattn/go-colorablegithub.com/mattn/go-isatty
这一系统并不完美。读者可能会发现虽然模块A可兼容模块D的v1.1.0,但却不兼容v1.2.3。这时该怎么办呢?Go给出的答案是你应该联系模块作者修改不兼容性问题。导入兼容性规则说“如果老包和新包有同要瓣导入路径,新包必须对老包提供向后兼容。”也就是说模块所有的小版本及补丁版本都必须保持向后兼容。如若不然,就是个bug。在我们假设的示例中,要么模块D因打破了向后兼容性需要进行修复,要么模块A需要进行修复,因其错误地假定了模块D的行为。
这个答案差强人意,但却也开诚布公。有些构建系统,比如npm,会包含同一个包的多个版本。这可能带来一系列问题,尤其是在有包级状态时。它还增加了应用的大小。最终有些问题社区解决比代码解决要更好。
更新到兼容版本
simpletax
go get -u=patch github.com/learning-go-book-2e/simpletax
go get github.com/learning-go-book-2e/simpletax@v1.1.0go get -u=patch github.com/learning-go-book-2e/simpletax
go get -u github.com/learning-go-book-2e/simpletaxsimpletax
更新到不兼容版本
simpletax
要处理不兼容性,Go模块遵循语义导入版本规则。这个规则有两个部分:
- 必须提升模块的大版本。
- 对于0和1以外的大版本,模块的路径必须以vN结尾,其中N为大版本号。
修改路径的原因是导入路径唯一标识一个包。在定义上,包的不兼容版本不是同一个包。使用不同的路径意味着可以在程序的不同部分导入包的两个不兼容版本,允许我们进行优雅升级。
simpletax
这会修改导入为引用v2模块。
main
simpletaxgo get ./...
我们可以构建和运行程序,查看新的输出:
simpletax
go.sum也发生了更新:
simpletaxgo mod tidy
Vendoring
go mod vendor
go getgo mod vendorgo buildgo rungo test
老的Go依赖管理系统要求用vendoring,但随着Go模块以及代理服务器(在模块代理服务器一节详细讲解)的出现,就不再推荐了。还希望用vendor的一个原因可能是它会在使用某些CI/CD(持续集成/持续发布)管道时让构建更快更高效。如果管道的构建服务器是外部的,则不会保留模块缓存。vendoring依赖可让这些管道避免在每次触发构建时进行多次网络调用。缺点是它会大幅增加版本管理中代码的大小。
pkg.go.dev
simpletax
图 4-2 使用pkg.go.dev查找、学习第三方模块
发布模块
让模块可供他人使用和将其放到版本控制系统中一样简单。不论是发布到GitHub这种对公版本控制系统还是自托管的私有系统都一样。因为Go程序通过源码构建,使用仓库路径来进行标识,无需显式像Maven或npm那样上传模块到中央仓库。请确保提交go.mod 和go.sum 这两个文件。
在发布开源模块时,应当在仓库根目录下包含一个LICENSE文件,指定代码所使用的开源证书。It’s FOSS有很好的资源可了解各种各样的开源证书。
大致来讲,可以将开源证书分成两类:许可式(允许代码使用者保持自己的代码私有)和非许可式(要求代码使用者将其代码开源)。虽然选什么证书由你来定,Go社区更喜欢许可式证书,比如BSD、MIT和Apache。因为Go直接将第三方代码编译成应用,使用GPL这样的非许可式证书会要求使用代码的人将代码也开源。这对很多组织是不可接受的。
最后一点:不要编写自己的证书。很少有人会相信它由专业律师审过,他们也无法分辨在模块中做了什么声明。
对模块添加版本
不论模块是公开还是私有,都必须为模块添加适当的版本,才能正常使用Go的模块系统。只要对模块添加功能或修复补丁,过程就很简单。在源代码仓库中保存修改,然后应用遵循语义化版本的标签。
v1.3.4v1.4.0-beta1v1.4.0-rc2go get
simpletax
- 在模块中创建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及以上一文中了解升级到不兼容版本代码的更多内容。
依赖重载
replace
⇒
replace
replace
replacereplace
exclude
go.modgo get
撤销模块指定版本
go.modretractretract
retract
go.modretract
go getgo mod tidygo list@latest
retractexcluderetractexclude
使用工作空间同步修改模块
使用源代码仓库及标签来追踪依赖及版本有一个缺点。如果想同步修改两个或以上的模块,希望跨模块体验这些修改,需要有一种方式用模块的本地拷贝覆盖源代码仓库的模块版本。
go.modreplace
Go使用工作空间来解决这一问题。工作空间允许我们在电脑上下载多个模块,相互引用并自动解析至本地源代码而非远程仓库中的代码。
learning-go-book-2e
my_workspaceworkspace_libworkspace_appworkspace_libgo mod init github.com/learning-go-book-2e/workspace_liblib.go
workspace_appgo mod init github.com/learning-go-book-2e/workspace_appapp.go
go get ./...go.modrequire
workspace_libgo build
workspace_libworkplace_appmy_workspace
my_workspacego.work
go.work
workspace_app
workspace_libworkspace_libworkspace_lib
learning-go-book-2e
go get ./...require
requireworkspace_liblib.go
workspace_appapp.gomain
go build
go.mod
go getgo.mod
workspace_libworkspace_appgit pull
模块代理服务器
go getgo get
除代理服务器,Google还维护了一个校验和(checksum)数据库。它存储代理服务器所缓存的所有模块所有版本的信息。代理服务器防止模块或模块的版本从互联网上删除,而校验和数据库防止模块版本的修改。这可能是恶意(某人支持了模块并插入恶意代码),或是粗心所致(模块维护者修复bug或添加新功能又复用已有的版本标签。)不论哪种情况,都不希望使用修改了内容的模块版本去构建相同的二进制,而不知道应用的效果。
go getgo mod tidy
指定代理服务器
有些人反对向Google发送第三方库的请求。有如下选择:
GOPROXYdirectGOPROXY
私有仓库
大部分组织在私有仓库中维护自己的代码。如果希望在另一个Go模块中使用私有模块,就不能从Google的代理服务器上请求了。Go会退回直接从私有仓库中检查,但你可能不希望对外部服务泄漏私有服务器和仓库的名称。
如使用自有代理服务器,或是禁用了代理 ,这不是问题。运行私有代理服务器还有其它好处。首先,它加速了第三方模块的下载,因为缓存位于公司自己的网络中。如果访问私有仓库需要身份验证,使用私有代理服务器意味着无需担扰暴露CI/CD的需进行身份验证的信息。私有代理服务器配置为授权给私有仓库(参见Athens的身份验证配置文档),但对私有代理服务器的调用未经过身份验证。
GOPRIVATEGOPRIVATE
存储于http://example.com子域名或以开头的URL的仓库都会被直接下载。
其它知识
git
练习
intintAddAddgolang.org/x/exp/constraintsIntegerFloatNumberAddNumberNumber
小结
本章中,我们学习了如何组织代码并与Go源码的生态进行交互。我们学到了模块的原理、如何将代码组组织成包、如何使用第三方模块以及如何发布自己的模块。在下一章中,我们会学习Go所包含的更多的开发工具,学习一些基本第三方工具,并探索更好地控制构建过程的一些技术。