GO基础篇-包
1 序
任何包系统设计的目的都是为了简化大型程序的设计和维护工作,通过将一组相关的特性放进一个独立的单元以便于理解和更新,在每个单元更新的同时保持和程序中其它单元的相对独立性。这种模块化的特性允许每个包可以被其它的不同项目共享和重用,在项目范围内、甚至全球范围统一的分发和复用。
Golang中的包(Package)是类型、函数、常量和变量的集合,它将相关特性的函数和数据放在统一的文件或文件夹中进行管理,Golang中的包是一种组织代码的机制,它有助于将相关的代码组织在一起,使代码具有更好的可复用性、可维护性以及可读性。在Golang中,包是代码的基本单元,一个程序可以由多个包组成,每个包都有独立的命名空间。
每个包一般都定义了一个不同的名字空间用于它内部的每个标识符的访问。每个名字空间关联到一个特定的包,让我们给类型、函数等选择简短明了的名字,这样可以避免在我们使用它们的时候减少和其它部分名字的冲突。每个包还通过控制包内名字的可见性和是否导出来实现封装特性。通过限制包成员的可见性并隐藏包API的具体实现,将允许包的维护者在不影响外部包用户的前提下调整包的内部实现。通过限制包内变量的可见性,还可以强制用户通过某些特定函数来访问和更新内部变量,这样可以保证内部变量的一致性和并发时的互斥约束。
2 包的分类
2.1 按文件类型
按照文件类型来分,一般情况下源码文件分为三类,分别是命令源码文件、库源码文件和测试源码文件,而三类文件对应所在的两种包。
- main包:命令源码文件所在包就是main包,即
package main
是主函数(可运行的程序)所在的包,main包也是代码人口包; - 普通包:我们自定义包以及第三方库源码文件和测试源码文件所在包;
2.2 按文件范围
按照文件范围,Golang中的包可以分为三种:系统内置包、自定义包和第三方包。
- 系统内置包:Golang语言给我们提供的内置包,引入后可以直接使用,比如
fmt、strconv、strings、sort、errors、time、encoding/json、os、io
等。 - 自定义包:开发者自己写的包。
- 第三方包:它也属于自定义包的一种,只不过是其他开发者开发的自定义包,需要下载安装到本地后才可以使用,比如前面章节介绍的
github.com/jmoiron/sqlx
包。
3 包声明
在每个Golang源文件的开头都必须有包声明语句,它的大致格式为package <包路径>
,包声明语句的主要目的是确定当前包被其它包导入时默认的标识符(也称为包名),例如,math/rand
包的每个源文件的开头都包含package rand
包声明语句,所以当你导入这个包,你就可以用rand.Int
、rand.Float64
类似的方式访问包的成员。
3.1 包的声明规则
同一文件目录下直接包含的文件只能归属一个
package
,同样一个package
的文件不能在多个文件夹下,简单来讲就是,同一文件目录下你可以定义无数个Go文件,但是文件里面声明的包名必须是同一个,否则编译无法通过。包名可以不和文件夹的名字一样,但是最好是保持一致。
包的声明语句package必须位于Go文件的第一行,否则编译错误。
Golang规定,
package main
是主函数(可运行的程序)所在的包,其他的均为库文件的形式存在。
包名由小写字母、数字和下划线_组成,不能包含其他特殊符号。
1 | package main |
通常来说,默认的包名就是包导入路径名的最后一段,因此即使两个包的导入路径不同,它们依然可能有一个相同的包名;例如math/rand
包和crypto/rand
包的包名都是rand,稍后我们将看到如何同时导入两个有相同包名的包。
关于默认包名一般采用导入路径名的最后一段的约定也有三种例外情况:
- 第一个例外,包对应一个可执行程序,也就是main包,这时候main包本身的导入路径是无关紧要的。名字为main的包是给
go build
构建命令一个信息,这个包编译完之后必须调用连接器生成一个可执行程序。 - 第二个例外,包所在的目录中可能有一些文件名是以
_test.go
为后缀的Go源文件,并且这些源文件声明的包名也是以_test
为后缀名的。这种目录可以包含两种包:一种普通包,另外一种则是测试的外部扩展包。所有以_test为后缀包名的测试外部扩展包都由go test
命令独立编译,普通包和测试的外部扩展包是相互独立的。测试的外部扩展包一般用来避免测试代码中的循环导入依赖, - 第三个例外,一些依赖版本号的管理工具会在导入路径后追加版本号信息,例如
gopkg.in/yaml.v2
。这种情况下包的名字并不包含版本号后缀,而是yaml。
3.2 包名的规则
包名和导入路径都是包的重要标识符,代表包包含的所有内容。规范地命名包不仅可以提高代码质量,还可以提高用户的质量,糟糕的包名使代码难以导航和维护。这里有一些识别和纠正不规范包的指导方针。
3.2.1 包名小写
包名其实可以使用大小写、数字和下划线_,但是我们仍然建议包名只要小写字母的,且不要在包名中使用蛇形或驼峰形式,至于为什么如此,你可以认为这是一种规范。
1 | package demo_pacakge // 反例:蛇形 |
3.2.2 简短且具有代表性
避免无意义的包名,比如名为util、common或misc的包让工程师不知道包中包含什么,这使得客户端更难以使用包,也使得维护人员更难以保持包的重点。随着时间的推移,它们积累的依赖关系会使编译显著地、不必要地变慢,尤其是在大型程序中。由于这样的包名是通用的,它们更有可能与客户端代码导入的其他包发生冲突,迫使客户端发明名称来区分它们,所以包名要简短且是唯一并具有代表性。
3.2.3 避免为所有API使用一个包
许多工程师将程序公开的所有接口放入一个名为api、types或interfaces的包中,认为这样更容易找到代码库的入口点。毫无疑问这是错误的做法,这样的包与那些名为util或common的包一样,存在同样的问题: 不受约束地增长,不向用户提供指导,积累依赖,并与其他导入发生冲突。将它们分开,也许可以使用目录将公共包与实现分开。
3.2.4 避免不必要的包名冲突
虽然不同目录中的包可能具有相同的名称,但经常一起使用的包应该具有不同的名称。这减少了混淆和在客户端代码中进行本地重命名的需要。出于同样的原因,避免使用与流行的标准包(如io或http)相同的名称。
4 包导入
我们可以可以在一个Go语言源文件包声明语句之后,其它非导入声明语句之前,导入包含零到多个导入包声明语句。每个导入声明可以单独指定一个导入路径,也可以通过圆括号同时导入多个导入路径。下面两个导入形式是等价的,但是第二种形式更为常见。
1 | // 第一种导入方式 |
导入的包之间可以通过添加空行来分组;通常将来自不同组织的包独自分组,包的导入顺序无关紧要,但是在每个分组中一般会根据字符串顺序排列,gofmt和goimports工具都可以将不同分组导入的包独立排序。
4.1 下划线导入(匿名导入)
下划线导入也叫匿名导入,它的格式类似import _ <包路径>
,通常引入某个包,但不直接使用包里的函数,而是调用该包里面的init函数,比如下面的mysql包的导入,例如:
1 | import ( |
当通过匿名方式导入一个包时,它所有的init()函数就会被执行,但有些时候并非真的需要使用这些包,仅仅是希望它的init()函数被执行而已,这个时候就可以使用_操作引用该包了。即:使用_操作引用包是无法通过包名来调用包中的导出函数,而是只是为了简单的调用其init函数()。
通常情况下,这些init函数里面是注册自己包里面的引擎,让外部可以方便的使用,例如实现database/sql
的包,在init函数里面都是调用了sql.Register(name string, driver driver.Driver)
注册自己,然后外部就可以使用了。
4.2 点导入
使用点导入,它的格式如下: import . <包路径>
,其目的作用就是为了在使用包内的方法、属性以及其他对象时,可以省略包名,例如:
1 | package main |
另外我们可以使用 . 导入多个包,但是多个包最好不能有相同的函数或者其他对象,否则会编译错误。
这里我在另外的db目录中定义了一个Go文件,并在其中定义了一个Abs()
方法,方法定义如下:
1 | package db |
而Golang内置的math包中也有Abs()
方法, 其方法签名如下:
1 | // Copyright 2009 The Go Authors. All rights reserved. |
这时候我们同时我们同时通过 . 引入这两个包,然后调用Abs()
,运行就会发生编译错误。
1 | package main |
根据错误信息可以看出,在main()函数中第7行第2列也即是ratel.com/go-tutorial/db
包,重复定义了Abs函数,因为$GOROOT/src/math/abs.go
的第13行第6列已经定义了Abs
函数,同时我们发现无论两个函数的签名是否一样,只要函数名称相同,那就会出现重复定义的错误。
那么应该怎么解决呢?
其中一种最最简单的解决办法就是其中一个包不要使用 . 导入,例如:
1 | package main |
还有一种解决办法就是别名导入。
4.3 别名导入
别名导入,顾名思义就是给导入的包起一个别名,他的语法格式如下:import alias_name <包路径>
,声明了别名后,我们就可以通过别名来使用包内的函数、对象来构建我们的程序。
1 | package main |
通过上例,我们发现通过别名可以解决4.4的问题。
如果我们导入的两个包路径的包名是一样的,会发生怎样的后果呢?
1 | package main |
根据错误信息可以看出,在main()函数中第7行第2列也即是math/rand
包,重复声明了rand包,因为在main()函数中第7行第2列也即是crypto/rand
包已经声明了rand包。
所以这种情况,我们可以给其中一个包起一个别名即可解决,例如:
1 | package main |
还有种情况就是,如果引入的包名非常长,在后面的使用过程中非常不方便,这时候可以使用别名, 例如:
1 | package main |
4.4 相对路径|绝对路径导入|模块导入
绝对路径导入和相对路径导入是Golang早期版本中导包的两种方式,但是随着Golang版本升级到1.11并且你的项目开启了模块支持的情况下,就不再支持相对路径和绝对路径这两种方式导入包,接下里看看关闭模块支持后相对路径和绝对路径如何导包。
首先我们通过go env -w GO111MODULE="off"
先关闭模块支持打开GOPATH
模式,然后在$GOPATH
之外的目录创建名称为go-samples
工程,并在其目录下创建两个子目录db
和main
,并基于这两个目录分别创建db.go
和main.go
两个文件,文件内容如下:
1 | package db |
4.4.1 相对路径导入
所谓相对路径导入是相当于当前main.go
文件时db包所在目录,根据当前目录结构来看,db
相对于main.go
的相对目录即为../db
。
1 | package main |
对于Golang来说,相对路径的包引入,并不是一个好的方案,首先会与官方标准包的导入相混淆,同时增加相对导入包的软件管理难度,因此不建议使用相对路径导入。
4.4.2 绝对路径导入
在GOPATH
模式下,绝对路径导包会去$GOPATH/src
目录下去找当前包,如果找不到则去$GOROOT/src
目录下去找当前包,都找不到运行则会出现错误。
1 | package main |
这里找不到go-samples/db
包,原因就是$GOPATH/src
和$GOROOT/src
目录都没有目标包,所以我们有两种方式可以解决:
- 可以把要导入的
go-samples/db
包目录复制一份到$GOPATH/src
下即可运行成功。 - 可以把直接把
go-samples
工程迁移到$GOPATH/src
下即可运行成功。
4.4.3 模块导入
随着Golang版本升级到1.11支持模块开发后,就不支持相对路径和绝对路径这两种方式导入包,因为模块支持后,会以模块为根目录然后导其他包。
同样首先通过go env -w GO111MODULE="on"
先开启模块支持,然后创建go-samples
目录,进入该目录通过go mod init "ratel.com/go-samples"
初始化一个模块,这里对于模块名称的定义其实没有做严格控制,字母大小写、数字、 - 和 _ 都可以,执行完毕后会生成一个go.mod文件,内容如下:
1 | module ratel.com/go-samples |
其次在其目录下创建db
和main
两个子目录,并基于这两个目录分别创建db.go
和main.go
两个文件,db.go
文件内容同上,接下来重点看如何在main.go
文件中导入db包。
1 | package main |
我们先尝试使用相对路径导入,则报"../db"
是相对路径且相对路径导入在模块模式下不支持的错误。
1 | package main |
如果我们尝试使用go-samples/db
绝对路径导入,则报go-samples/db
不在标准包(即$GOROOT/src
目录)下的错误,为什么会如此呢?
当开启模块支持后,当import "go-samples/db"
导入包后:
- 首先会从当前模块内搜索该包,如果找不到
- 其次会到
$GOPATH/pkg/mod
目录搜索该包,还是找不到 - 最后去
$GOROOT/src
目录搜索该包,还是找不到则报错go-samples/db is not in std
(std是Golang标准库的包)。
1 | package main |
可以看到当开启模块支持后,同模块内的其他包导入包名全路径为模块名称/包名
。
4.5 导入第三方包
这些三方包从哪里获取,我们可以通过官网(https://pkg.go.dev/) 搜索关键字来找到我们想要的包。
这里我们搜索一个实现MySQL数据库数据查询逻辑的sql包并实现一个例子,如下图:
Go语言中的
database/sql
包提供了保证SQL或类SQL数据库的泛用接口,并不提供具体的数据库驱动,使用database/sql
包时必须注入至少一个数据库驱动, 比如github.com/go-sql-driver/mysql
就是MySQL数据库的驱动实现包。
1 | import ( |
5 包的初始化
在初始化Go包时,Go会按照一定的顺序,逐一地调用这个包的init函数,初始化函数会按照它们在源文件中的顺序被调用,从而确定了包的初始化顺序,每个包都允许有零个、一个或者多个init
函数,当这个包被导入时,会执行该包的这个init
函数,做一些初始化任务;比如数据库连接池的建立。
5.1 初始化包
在Golang语言程序执行时导入包语句会自动触发包内部init函数的调用,init()
函数是Golang内置函数,用来初始化包使用,每个包可以包含一个或者多个初始化函数,这些初始化函数会在程序启动时按照它们在源文件中的顺序被调用。
1 | package main |
根据结果显示,初始化包有优先级:全局声明 --> init() --> main()
需要注意的是:
init
函数没有返回值、没有参数,且不能在代码中主动调用它,违背此原则将会编译错误。
5.2 init
函数执行顺序
任何一个包都可以定义一个或者多个初始化函数,假设我们在main包定义了多个init
函数,大致内容如下:
1 | package main |
在这个示例中,main
包中包含了两个初始化函数,当程序启动时,这两个初始化函数会按照它们在源文件中的顺序被调用,然后才会执行main
函数。
如果main包导入了其他包(三方包或者自定义包),而其他包也声明了初始化函数,那么执行顺序会是怎么的呢?
这里我在另外的db目录中定义了一个go文件,并在其中定义了一个Abs()
方法,方法定义如下:
1 | package db |
然后在当前main包引入ratel.com/go-tutorial/db
自定义包, 然后运行工程。
1 | package main |
从示例中可以看到执行顺序为:db
包的init()
函数 –> main
包的init()
函数 –> main()
函数。
依次类推,假设如果db包中引入了其他带有init()
函数的demo
包,那么执行顺序会是怎么的呢?
Go语言包会从main包开始检查其导入的所有包,每个包中又可能导入了其他的包,Go编译器由此构建出一个树状的包引用关系,再根据引用顺序决定编译顺序,依次编译这些包的代码,在运行时,被最后导入的包会最先初始化并调用其init()
函数,如下图示:
简单来说,就是被依赖包的init函数优先执行。
6 包成员可见性
Golang在控制包内函数、常量、变量和结构体的访问权限时,对关键字的使用非常吝啬,它甚至都没有类似private和public
这些的关键字,他比较简单地使用名称首字母大小写判断对应(函数、常量、变量、结构体等)的访问权限,这是一个全新的权限控制方式,使用这种方式可以省去额外的、繁琐的关键字,让代码变得更加简洁。
- 首字母大写:包外可见,类似于Java类中的public关键字
- 首字母小写:包外不可见,类似于Java类中的private关键字
我们先来看一个例子,首先声明一个db包,然后在包内创建一些有首字母大小写的函数、常量、变量、结构体等变量,如下:
1 | package db |
然后在当前main包引入ratel.com/go-tutorial/db
自定义包, 然后我们观察可以使用db包哪些对象。
1 | package main |
根据示例可以看出,无论是常量、变量、结构体、函数,只要其名称首字母是大写的,我们都是可以导出使用,而首字母小写则会编译出错。
7 包可见性
从标准的Go工程结构章节中,我们发现internal
包是私有应用程序代码包,即internal
包不能其他应用程序或者库中导入该包的代码,这里我们试验一下是否真的不能被导入。
我们首先构建一个工程,模块名称为ratel.com/go-samples
,其目录如下:
1 | go-samples |
db.go、export.go、internal.go、fa.go、fb.go
这些Go文件都定了一个简单的函数,接下来我们来看main.go如何导包。
1 | package main |
先尝试导入其他非internal
包,运行工程发现打印结果非常正常。
1 | package main |
导入internal
包直接编译错误,报内部包ratel.com/go-samples/db/internal
不允许被用在这里的错误。
根据internal
包的导入规则,internal
包的父包下面的其他目录下的Go文件都可以导入internal
包,也就是说db
包下的db.go
文件或者export
包下的export.go
文件可以导入internal
包,来尝试一下在db.go
导入internal
包和internal
包下的fa
和fb
包。
1 | package db |
我们在main.go
文件中只导入ratel.com/go-samples/db
包
1 | package main |
从结果输出来看,db
包下的Go文件都是可以导入internal
包以及internal
包下面的子包,而不能被db
包同级的或者父级的包下面的Go文件导入,而export.go
文件也可以导入internal
包以及internal
包下面的子包,这里不再演示。
简单来说,
internal
包以及internal
包下面的子包能被internal
包的父级包下面Go文件导入,而不能被父包之外的其他包下面的Go文件导入。
8 总结
总体来说,Golang的Package
机制是一种简单而强大的代码组织方式,它有助于构建清晰、模块化、可维护且易于理解的代码结构,Package
的导入机制使得我们可以在不同的项目中重用代码,提高了代码的可重用性,同时Package
提供了一种简单而有效的封装机制,使得代码的实现细节对外部Package
不可见,增强了代码的安全性和可维护性,下一章节我们将介绍函数。