大部分编程语言都有将代码组织到命名空间和库的系统,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文件中的内容:
module github.com/learning-go-book-2e/money go 1.21 require ( github.com/learning-go-book-2e/formatter v0.0.0-20220918024742-18... 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 文件都以指令开头,它由单词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
文件中,module
、go
和require
是最常用的一些指令,但还有其它的指令。我们会在依赖重载一章讲到replace
和exclude
指令,还会在撤销模块指定版本中讲到retract
指令。
构建包
我们已经学习了如何将目录转化为模块,下面可以开始使用包来组织代码了。我们先了解import
的原理,接着创建和组织包,然后Go包好的和不好的特性。
导入和导出
示例程序中一直在使用import
语句,但我们还没讨论Go语言中的导入与其它语言有什么区别。Go的import
语句允许访问其它包导出的常量、变量、函数及类型。包的导出标识符(标识符是变量、常量、类型、函数、方式或结构体中字段的名称)必须使用import
语句才能在其它包中使用。
这就带来了一个问题:如何在Go中导出标识符呢?Go中并没有使用特殊的关键字,而是使用首字母大写来决定声明的包级标识符是否对其它包可见。以大写字母开头的标识符即为导出。相对应地,以小写字母或下划线开头的标识符仅能在所声明的包内使用。
所有导出的内容都是包API的一部分。在导出标识符前,请确保意在对客户端开放。对所有导出标识符添加文档,并保持身后兼容,除非是真的要做大版本修改(参见对模块添加版本了解更多信息)。
创建和访问包
在Go中创建包很容易。我们通过一个小程序进行演示。代码可参见GitHub。在package_example
中,可以看到两个目录:math和do-format。math中有一个名为math.go的文件,包含如下内容:
package math func Double(a int) int { return a * 2 }
该文件的第一行称为package语句。包含关键字package
和包名。package语句是Go源文件的第一个非空非注释行。
在do-format目录中,有一个包含如下内容的formatter.go 文件:
package format import "fmt" func Number(num int) string { return fmt.Sprintf("The number is %d", num) }
注意这里包声明语句使用了名称format
,但目录为do-format。如何使用包一会儿会讲解。
最后,在根目录的main.go文件中有如下内容:
package main import ( "fmt" "github.com/learning-go-book-2e/package_example/do-format" "github.com/learning-go-book-2e/package_example/math" ) func main() { num := math.Double(2) output := format.Number(num) fmt.Println(output) }
文件的第一行读者已经不陌生了。在本文之前我们代码的第一行都是package main
。我们一会儿会讨论其含义。
接下来为导入区。导入了三个包。第一个是标准库的fmt
。我们在前面文章已经使用过。接下来两个导入指向程序内的包。在导入标准库以外的包时要指定导入路径。导入路径为包路径接模块路径。
导入包却未使用其所导出的任一标识符会报编译时错误。这可以保障Go编译器所生成的二进制文件只包含程序中实际使用的代码。
警告:虽然可以使用相对路径导入同一模块的依赖包,请不要这么做。绝对导入路径让导入变得清晰,也让代码重构变简单。使用相对路径在将导入移到另一个包时需要进行修改,而如果将文件移到另一个模块的话,则必须让导入引用变成绝对路径。
运行这段程序,会得到如下输出:
$ go build $ ./package_example The number is 4
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
包中创建名为ExtractNames
和FormatNames
的两个函数。别这么干,否则在每次使用这些函数时,需要使用util.ExtractNames
和util.FormatNames
,util
包完全没说明函数的作用是什么。
一种选择是在extract
包中创建一个Names
函数,在format
包中再创建一个Names
函数。函数名称一样没有问题,因为可通过包名进行区分。在导入时第一个使用extract.Names
,第二个使用format.Names
。
更好的方法是思考一次演讲的内容。函数或方法执行任务,因此应当是动词。包名应是名词,由包中函数创建或修改的项目的名称。遵循这一规则,我们会创建一个名为names
的包,包含两个函数Extract
和Format
。第一个通过names.Extract
使用,第二个通过names.Format
。
还应当避免在函数和类型名中重复包名。位于names
包中时不要将函数命名为ExtractNames
。例外情况是标识符名称与包名相同之时。例如,标准库sort
包中有一个Sort
函数,而context
包中定义了Context
接口。
包名重命名
有时会发现导入的两个包名称冲突了。例如,标准库中有两个生成随机数的包:一个进行了安全加密(crypto/rand
),另一个不是(math/rand
)。在不使用加密生成随机数时普通生成器就可以了,但需要为其加种子。常见的方式是使用加密生成器生成的值来作为常规随机数生成器的种子。在Go语言中,这两者的包名相同(rand
)。这时在文件中要为其中一个包提供别名。可在The Go Playground中测试这段代码。首先来看导入区:
import ( crand "crypto/rand" "encoding/binary" "fmt" "math/rand" )
我们使用名称crand
来导入crypto/rand
。这会将该包中声明的rand
名称进行重命名。然后正常地导入math/rand
。再来看seedRand
函数,使用的是rand
前缀来访问math/rand
中标识符,而对crypto/rand
包使用crand
前缀:
func seedRand() *rand.Rand { var b [8]byte _, err := crand.Read(b[:]) if err != nil { panic("cannot seed with cryptographic random number generator") } r := rand.New(rand.NewSource(int64(binary.LittleEndian.Uint64(b[:])))) return r }
注:还有两个符号可用于包名。.
将导入包的所有导出标识符放到当前包命名空间中,使用时不需要加前缀。不鼓励这么做,因为这会让源代码变得不清晰。这时就无法根据名称知道是在当前包中定义的还是从外部导入的。
还可以使用_
。我们会在init函数:能免则免一节中讨论init
时会讲到。
我们在代码块,遮蔽和控制结构中讨论过包名遮蔽的情况。将变量、类型或函数声明为包名会让包在其作用域中无法访问。如果无法避免(如新导入的包与现有标识符相冲突),重命名包名解决问题。
使用godoc创建模块文档
创建模块供他人使用的一个重要部分是有合适的文档。Go有编写注释的自有格式可自动转化为文档。称为godoc格式,非常简单。规则如下:
- 将注释放到添加文档的内容之前,注释行和声明之间没有空行。
- 每个注释行以双斜线(//)开头,后接一个空格。虽然使用/*和*/标记的块注释完全合法,但使用双斜线更为地道。
- 代号(函数、类型、常量、变量或方法)注释的第一个词应为代号的名称。如可让注释文本在语法上正确,也可以在代号名称前加上不定冠词A或An。
- 使用空白注释行(双斜线和新行)来将注释分成多段。
我们在pkg.go.dev一节中会讲到,可以用HTML格式浏览公开的在线文档。如果想让文档更高级些,有如下的格式化方法:
- 如希望注释包含一些预格式化内容(如表格或源码),在双斜线后多加空格缩进到与内容一致。
- 如果希望注释有头部,在双斜线后加#和空格。与Markdown不同,这里不能添加多个#来生成多级标题。
- 要链向另一个包(不管是否在当前模块中),将包路径放到方括号(即[和])之间。
- 如在注释中包含URL,会被转化为链接。
- 如希望包含链向网页的文本,将文本放在方括号(即[和])之间。在注释块的最后,使用// [TEXT]: URL这种格式进行文本和URL对应的声明。一会儿会有示例。
包声明之前的注释会创建包级注释。如果对包的注释很长(比如fmt
包中很长的格式化文档),按惯例是放到包内一个名为doc.go的文件中。
我们来看一个有良好注释的文档,先从例4-1中的包级注释开始。
例4-1 包级注释
// money provides various utilities to make it easy to manage money. package money
接下来对导出的结构体添加注释(例4-2)。注意其以结构体名称开头。
例4-2 结构体注释
// Money represents the combination of an amount of money // and the currency the money is in. // // The value is stored using a [github.com/shopspring/decimal.Decimal] type Money struct { Value decimal.Decimal Currency string }
最后对函数添加注释(见例4-3)。
例4-3 添加了注释的函数
// Convert converts the value of one currency to another. // // It has two parameters: a Money instance with the value to convert, // and a string that represents the currency to convert to. Convert returns // the converted currency and any errors encountered from unknown or unconvertible // currencies. // // If an error is returned, the Money instance is set to the zero value. // // Supported currencies are: // USD - US Dollar // CAD - Canadian Dollar // EUR - Euro // INR - Indian Rupee // // More information on exchange rates can be found at [Investopedia]. // // [Investopedia]: https://www.investopedia.com/terms/e/exchangerate.asp func Convert(from Money, to string) (Money, error) { // ... }
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 文件中声明了一个简单函数:
func Doubler(a int) int { return a * 2 }
可在foo
包中的foo.go文件及sibling
包中的sibling.go文件中访问该函数。
图4-1 internal_package_example
包的文件树
注意在bar
包的bar.go文件或根包的example.go文件中使用内部函数时会报编译错误:
$ go build ./... package github.com/learning-go-book-2e/internal_example example.go:3:8: use of internal package github.com/learning-go-book-2e/internal_example/foo/internal not allowed package github.com/learning-go-book-2e/internal_example/bar bar/bar.go:3:8: use of internal package github.com/learning-go-book-2e/internal_example/foo/internal not allowed
循环依赖
Go的两大目标是编译快、源码易理解。为实现这点,Go不允许在包之间循环依赖。也就是包A直接或间接导入包B,则包B不能再直接或间接导入包A。我们通过一个示例快速讲解这一概念。可通过GitHub下载这段代码。我们的模块有两个子目录,pet和person。在pet
包的pet.go中,我们导入了github.com/learning-go-book-2e/circular_dependency_example/person
:
var owners = map[string]person.Person{ "Bob": {"Bob", 30, "Fluffy"}, "Julia": {"Julia", 40, "Rex"}, }
介在person
包person.go的文件中又导入了github.com/learning-go-book-2e/circular_dependency_example/pet
:
var pets = map[string]pet.Pet{ "Fluffy": {"Fluffy", "Cat", "Bob"}, "Rex": {"Rex", "Dog", "Julia"}, }
尝试构建这一模块时会得到如下错误:
$ go build package github.com/learning-go-book-2e/circular_dependency_example imports github.com/learning-go-book-2e/circular_dependency_example/person imports github.com/learning-go-book-2e/circular_dependency_example/pet imports github.com/learning-go-book-2e/circular_dependency_example/person: import cycle not allowed
有一些选项可解决循环依赖。有时这是由于对包分割的过细所致。如果两个包彼此依赖,很有可能应将它们合并到一个包中。我们可以将person
和pet
合并为一个包解决这一问题。
如果有充分理由让包保持分享,可能可以将导致循环依赖部分的内容移到其中一个包或是新包中。
如何组织模块
模块中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
:
type Foo struct { x int S string } func (f Foo) Hello() string { return "hello" } func (f Foo) goodbye() string { return "goodbye" }
如果想让用户通过Bar
访问Foo
,只需做如下操作:
type Bar = Foo
我们使用type
关键字、别名名称、等号及原类型名称创建别名。别名与原类型的字段和方法相同。
甚至可以将别名赋值给原类型的变量,无需进行类型转换:
func MakeBar() Bar { bar := Bar{ x: 20, S: "Hello", } var f Foo = bar fmt.Println(f.Hello()) return bar }
需要记住一个重点:别名只是一种类型的另一个名称。如果想要对别名结构体添加方法或修改字段,必须在原类型中添加。
可以将在同包或不同包中定义的类型设置的别名类型为原始类型。甚至可以设置另一个模块中的类型别名。对其它包的别名有一个缺点:无法使用别名指向未导出的方式及原始类型的字段。这一局限很好理解,因为别名是为了对包的API做渐进式修改,而API仅由包的导出部分组成。要突破这一局限,可调用类型原始包中的代码来操作未导出字段和方法。
有两种类型的导出标识符无法有替代名称。第一个是包级变量。第二是结构体中的字段。一旦为导出的结构体字段选取名称,就无法为其创建别名。
init函数:能免则免
在阅读Go代码时,通常很清楚调用了哪些方法和函数。Go没有方法或函数重载的一个原因是让运行的代码更易于理解。但有一种方式无需做任何操作就可配置包的状态:init
函数。在声明没带参数和返回值的init
函数时,它会在另一个包中初次引用时运行。因为init
函数没有输入和输出,只能附带调用,与包级函数和变量进行交互。
init
函数有另一个特别的特性。Go允许在单个包中甚至是一个包的某个文件中声明多个init
函数。对于单个包中的多个init
函数有规定的顺序,与其记住它,最好避免这么做。
有些包,比如数据库驱动,使用init
函数来注册数据库驱动。但是不使用包中的任何标识符。前面提到了Go不允许存在未使用的导入。应对这一问题,Go允许有空导入,即赋给导入的名称是下划线(_
)。正如下划线可以忽略未使用的函数返回值,空导入触发包中的init
函数,并且不能访问包中的导出标识符:
import ( "database/sql" _ "github.com/lib/pq" )
这一模式被看成过时,原因在于注册操作是否执行了是不清晰的。Go对标准包的兼容性承诺表示我们必须使用它来注册数据库驱动和图片格式,但如果在自己的代码是有注册模式,请显式注册插件。
如今init
函数的主要用途是初始化无法在单条赋值中配置的包级变量。在包的顶级具备可变状态不是个好做法,因为这使用理解应用中的数据流动更加困难。这表示通过init
配置的所有包级变量都应是不可变的。虽然Go没有提供方式强制值不可变,应当确保代码中不做修改。如果包级变量需要在程序运行中发生改变,确认是否可重构代码将状态放到初始化的结构体中并由包中的函数返回。
对init
函数的非显式调用意味着应当对其行为添加文档。例如,带有加载文件或访问网络init
函数的包应当在包级注释中说明,这样使用代码又注重安全的用户不会因为预料外的I/O而感到惊讶。
本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。