从程序逻辑结构角度来看,包(package)是Go程序逻辑封装的基本单元,每个包都可以理解为一个“自治”的、封装良好的、对外部暴露有限接口的基本单元。一个Go程序就是由一组包组成的。
在Go包这一基本单元中分布着常量、包级变量、函数、类型和类型方法、接口等,我们要保证包内部的这些元素在被使用之前处于合理有效的初始状态,尤其是包级变量。在Go语言中,我们一般通过包的init函数来完成这一工作。
Go语言中有两个特殊的函数:一个是main包中的main函数,它是所有Go可执行程序的入口函数;另一个就是包的init函数。
init函数是一个无参数、无返回值的函数:
func init() {
…
}
如果一个包定义了init函数,Go运行时会负责在该包初始化时调用它的init函数。在Go程序中我们不能显式调用init,否则会在编译期间报错:
package main
import "fmt"
func init() {
fmt.Println("init invoked")
}
func main() {
init()
}
运行结果:
undefined: init
一个Go包可以拥有多个init函数,每个组成Go包的Go源文件中可以定义多个init函数。在初始化Go包时,Go运行时会按照一定的次序逐一调用该包的init函数。Go运行时不会并发调用init函数,它会等待一个init函数执行完毕并返回后再执行下一个init函数,
且每个init函数在整个Go程序生命周期内仅会被执行一次。因此,init函数极其适合做一些包级数据的初始化及初始状态的检查工作。
一个包内的、分布在多个文件中的多个init函数的执行次序是什么样的呢?一般来说,先被传递给Go编译器的源文件中的init函数先被执行,同一个源文件中的多个init函数按声明顺序依次执行。但Go语言的惯例告诉我们:不要依赖init函数的执行次序
init函数为何适合做包级数据的初始化及初始状态检查工作呢?除了init函数是顺序执行并仅被执行一次之外,Go程序初始化顺序也给init函数提供了胜任该工作的前提条件。
Go程序由一组包组合而成,程序的初始化就是这些包的初始化。每个Go包都会有自己的依赖包,每个包还包含有常量、变量、init函数等(其中main包有main函数),这些元素在程序初始化过程中的初始化顺序是什么样的呢?我们用下图来说明一下。
● main包直接依赖pkg1、pkg4两个包;
● Go运行时会根据包导入的顺序,先去初始化main包的第一个依赖包pkg1;
● Go运行时遵循“深度优先”原则查看到pkg1依赖pkg2,于是Go运行时去初始化pkg2;
● pkg2依赖pkg3,Go运行时去初始化pkg3;
● pkg3没有依赖包,于是Go运行时在pkg3包中按照常量→变量→init函数的顺序进行初始化;
● pkg3初始化完毕后,Go运行时会回到pkg2并对pkg2进行初始化,之后再回到pkg1并对pkg1进行初始化;
● 在调用完pkg1的init函数后,Go运行时完成main包的第一个依赖包pkg1的初始化;
● Go运行时接下来会初始化main包的第二个依赖包pkg4;
● pkg4的初始化过程与pkg1类似,也是先初始化其依赖包pkg5,然后再初始化自身;
● 在Go运行时初始化完pkg4后,也就完成了对main包所有依赖包的初始化,接下来初始化main包自身;
● 在main包中,Go运行时会按照常量→变量→init函数的顺序进行初始化,执行完这些初始化工作后才正式进入程序的入口函数main函数
到这里,我们知道了init函数适合做包级数据的初始化及初始状态检查工作的前提条件是,init函数的执行顺位排在其所在包的包级变量之后。
init函数就好比Go包真正投入使用之前的唯一“质检员”,负责对包内部以及暴露到外部的包级数据(主要是包级变量)的初始状态进行检查。在Go运行时和标准库中,我们能发现很多init检查包级变量的初始状态的例子。
func init() {
CommandLine.Usage = commandLineUsage
}
CommandLine是flag包的一个导出包级变量,它也是默认情况下(如果你没有新创建一个FlagSet)代表命令行的变量,我们从其初始化表达式即可看出:
var CommandLine = NewFlagSet(os.Args[0], ExitOnError)
CommandLine的Usage字段在NewFlagSet函数中被初始化为FlagSet实例(也就是CommandLine) 的 方 法 值 defaultUsage。 如 果 一 直 保 持 这 样, 那 么 使 用 Flag 默 认CommandLine的外部用户就无法自定义usage输出了。于是flag包在init函数中,将ComandLine的Usage字段设置为一个包内未导出函数commandLineUsage,后者则直接使用了
flag包的另一个导出包变量Usage。这样就通过init函数将CommandLine与包变量Usage关联在一起了。在用户将自定义usage赋值给Usage后,就相当于改变了CommandLine变量的Usage。
下面这个例子来自标准库的context包:
// closedchan是一个可重用的处于关闭状态的channel
var closedchan = make(chan struct{})
func init() {
close(closedchan)
}
context包在cancelCtx的cancel方法中需要一个可复用的、处于关闭状态的channel,于是context包定义了一个未导出包级变量closedchan并对其进行了初始化。但初始化后的closedchan并不满足context包的要求,唯一能检查和更正其状态的地方就是context包的init函数,于是上面的代码在init函数中将closedchan关闭了。
有些包级变量的初始化过程较为复杂,简单的初始化表达式不能满足要求,而init函数则非常适合完成此项工作。标准库regexp包的init函数就负责完成对内部特殊字节数组的初始化,这个特殊字节数组被包内的special函数使用,用于判断某个字符是否需要转义:
var specialBytes [16]byte
func special(b byte) bool {
return b < utf8.RuneSelf && specialBytes[b%16]&(1<<(b/16)) != 0
}
func init() {
for _, b := range []byte(`\.+*?()|[]{}^$`) {
specialBytes[b%16] |= 1 << (b / 16)
}
}
标准库net包在init函数中对rfc6724policyTable这个未导出包级变量进行反转排序:
func init() {
sort.Sort(sort.Reverse(byMaskLength(rfc6724policyTable)))
}
标准库http包则在init函数中根据环境变量GODEBUG的值对一些包级开关变量进行赋值:
var (
http2VerboseLogs bool
http2logFrameWrites bool
http2logFrameReads bool
http2inTests bool
)
func init() {
e := os.Getenv("GODEBUG")
if strings.Contains(e, "http2debug=1") {
http2VerboseLogs = true
}
if strings.Contains(e, "http2debug=2") {
http2VerboseLogs = true
http2logFrameWrites = true
http2logFrameReads = true
}
}
下面是使用lib/pq包 [1] 访问PostgreSQL数据库的一段代码示例:
import (
"database/sql"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=verify-full")
if err != nil {
log.Fatal(err)
}
age := 21
rows, err := db.Query("SELECT name FROM users WHERE age = $1", age)
...
}
对于初学Go的Gopher来说,这是一段神奇的代码,因为在以空别名方式导入lib/pq包后,main函数中似乎并没有使用pq的任何变量、函数或方法。这段代码的奥秘全在pq包的init函数中:
// github.com/lib/pq/conn.go
...
func init() {
sql.Register("postgres", &Driver{})
}
...
空别名方式导入lib/pq的副作用就是Go运行时会将lib/pq作为main包的依赖包并会初始化pq包,于是pq包的init函数得以执行。我们看到在pq包的init函数中,pq包将自己实现的SQL驱动(driver)注册到sql包中。这样,只要应用层代码在打开数据库的时候传入驱动的名字(这里是postgres),通过sql.Open函数返回的数据库实例句柄对应的就是pq这个驱动的相应实现。
这种在init函数中注册自己的实现的模式降低了Go包对外的直接暴露,尤其是包级变量的暴露,避免了外部通过包级变量对包状态的改动。从database/sql的角度来看,这种注册模式实质是一种工厂设计模式的实现,sql.Open函数就是该模式中的工厂方法,它根据外部传入的驱动名称生产出不同类别的数据库实例句柄。
这种注册模式在标准库的其他包中亦有广泛应用,比如,使用标准库image包获取各种格式的图片的宽和高。
package main
import (
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
)
func main() {
// 支持PNG、JPEG、GIF
width, height, err := imageSize(os.Args[1])
if err != nil {
fmt.Println("get image size error:", err)
return
}
fmt.Printf("image size: [%d, %d]\n", width, height)
}
func imageSize(imageFile string) (int, int, error) {
f, _ := os.Open(imageFile)
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return 0, 0, err
}
b := img.Bounds()
return b.Max.X, b.Max.Y, nil
}
这个程序支持PNG、JPEG和GIF三种格式的图片,而达成这一目标正是因为image/png、image/jpeg和image/gif包在各自的init函数中将自己注册到image的支持格式列表中了:
// $GOROOT/src/image/png/reader.go
func init() {
image.RegisterFormat("png", pngHeader, Decode, DecodeConfig)
}
// $GOROOT/src/image/jpeg/reader.go
func init() {
image.RegisterFormat("jpeg", "\xff\xd8", Decode, DecodeConfig)
}
// $GOROOT/src/image/gif/reader.go
func init() {
image.RegisterFormat("gif", "GIF8?a", Decode, DecodeConfig)
}
4. init函数中检查失败的处理方法
init函数是一个无参数、无返回值的函数,它的主要目的是保证其所在包在被正式使用之前的初始状态是有效的。一旦init函数在检查包数据初始状态时遇到失败或错误的情况(尽管极少出现),则说明对包的“质检”亮了红灯,如果让包“出厂”,那么只会导致更为严重的影响。
因此,在这种情况下,快速失败是最佳选择。我们一般建议直接调用
panic或者通过log.Fatal等函数记录异常日志,然后让程序快速退出。