init函数有时候会在Go应用程序中被误用。潜在的后果可能是错误管理不善或代码逻辑难以理解。
首先,我们将重新认识一下什么是init函数。然后,我们看看什么时候该使用init函数,什么时候不推荐使用。
1 概念
一个init函数是一个没有任何参数和返回值的函数(一个func()函数)。当一个包被初始化时,在包中所有声明的常量和变量都被初始化。然后,该init函数被执行。下面是一个main包的例子:
代码语言:javascript复制package main
import "fmt"
var a = func() int {
fmt.Println("var") ①
return 0
}()
func init() {
fmt.Println("init") ②
}
func main() {
fmt.Println("main") ③
}
① 首先被执行
② 第二执行
③ 最后执行
执行该例子将会有如下输出:
代码语言:javascript复制var
init
main
当一个包被初始化的时候,init函数就会被执行。在下面的例子中,我们定义了两个包,main和redis,其中main包依赖redis包。
代码语言:javascript复制package main
import (
"fmt"
"redis"
)
func init() {
// ...
}
func main() {
err := redis.Store("foo", "bar") ①
// ...
}
① 依赖于redis包
代码语言:javascript复制package redis
import ...
func init() {
// ...
}
func Store(key, value string) error {
// ...
}
因为 main依赖于redis,所以会首先执行redis包的init函数,然后是main包的init函数,然后是main函数自身,如下图:
我们在一个包中也可以定义很多init函数。在这种场景中,在同一个包里的init函数的执行顺序是依赖于源码里按字母顺序执行的。例如,如果一个包里包含一个a.go和一个b.go文件,两个文件里都有init函数,a.go中的init函数将先被执行。我们不应该依赖于同一个包中的init函数的执行顺序。实际上,如果源文件被重命名会影响init的执行顺序,这是会很危险的。
我们也能在同一个文件中定义多个init函数。例如,下面的代码是非常合法的:
代码语言:javascript复制package main
import "fmt"
func init() { ①
fmt.Println("init 1")
}
func init() { ②
fmt.Println("init 2")
}
func main() {
}
① 该init会先执行
② 该init会后执行
首先定义的第一个init函数会被优先执行。
代码语言:javascript复制init 1
init 2
我们也可以使用init函数只对包进行初始化,但在main包中不使用该包。在下面的这个例子中,我们定义了一个main包,该包间接依赖于一个foo包(例如,一个公开函数的非直接调用)。然而,它包含foo包的初始化。我们可以使用 _ 操作符来进行初始化:
代码语言:javascript复制package main
import (
"fmt"
_ "foo" ①
)
func main() {
//...
}
① 导入foo包以初始化该包,但不使用该包
在这个案例中,foo包将会在main之前进行初始化。因此,foo的init函数将会被执行。
需要注意的是,init函数是不能直接被调用的:
代码语言:javascript复制package main
func init() {}
func main() {
init() ①
}
① 不合法的引用
该代码将会产生如下编译错误:
代码语言:javascript复制$ go build .
./main.go:6:2:undefined:init
至此,我们回顾了init是如何工作的。接下来让我们看看我们该何时使用它,何时不该使用。
2 何时使用init函数
在下面的例子中,我们会创建一个SQL连接。我们将使用一个init函数并构造一个可用的连接作为全局变量以供后续使用。
代码语言:javascript复制var connection *sql.DB
func init() {
dataSourceName := os.Getenv("MYSQL_DATA_SOURCE_NAME") ①
c, err := sql.Open("mysql", dataSourceName)
if err != nil {
log.Panic(err)
}
err = connection.Ping()
if err != nil {
log.Panic(err)
}
connection = c ②
}
① 环境变量
② 将DB连接赋值给全局connection变量
在这段代码中,有三个主要的缺点。
第一,在init函数中的错误管理是非常受局限的。事实上,因为init函数不会有返回值,所以,如果遇到一些错误时我们才决定使用panic。如果在init函数中发生了panic,是不可能从错误中恢复的,同时该应用程序将会停止。在我们的例子中,如果创建一个连接是绝对必须的,那么遇到panic就停止是可以接受的。但是,是否停止应用程序不一定要由包本身来决定。也许,调用者更希望使用重试机制或使用回调技术。在init函数中进行错误处理阻止了客户端实现错误管理的逻辑处理。
第二,会使单元测试更复杂。如果我们在这个文件中加入了测试,init函数将会在执行测试用例之前执行,这不是我们所期望的。例如,我们可能希望在不需要创建此连接的映射函数上添加单元测试。所以,编写单元测试的方法会很复杂。
第三,是我们创建连接的方法需要一个全局变量。全局变量有一些严重的缺点,例如:
- 它可以被包中的任何函数更改
- 它会使单元测试变得更复杂,因为依赖于共享全局状态的函数不是纯函数。
在大多数场景中,我们更喜欢封装一个变量,而不是全局变量。
这是和init函数相关的主要缺点。那么,我们是不是就不使用它了呢?当然不是。也有一些场景是适合使用init函数的。例如,官方博客中所说的使用init函数来配置静态http的配置文件:
代码语言:javascript复制func init() {
redirect := func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
http.HandleFunc("/blog", redirect)
http.HandleFunc("/blog/", redirect)
static := http.FileServer(http.Dir("static"))
http.Handle("/favicon.ico", static)
http.Handle("/fonts.css", static)
http.Handle("/fonts/", static)
http.Handle("/lib/godoc/", http.StripPrefix("/lib/godoc/",
http.HandlerFunc(staticHandler)))
}
在这个例子中,init函数不会失败(http.HandleFunc会引发panic,但也只有在handler是nil,这里不是这种情况),也没有创建任何全局变量的需要,并且也不会影响单元测试。因此,这个就是一个非常适合用init函数的例子。
总之,我们已经知道init函数可能会导致一些缺点:
- 错误管理是有局限性的
- 对实现单元测试会很复杂(例如,外部依赖设置,对于单元测试来说这不是必须的)
- 如果初始化需要设置一个状态,必须通过全局变量完成
我们必须小心使用init函数。它在一些场景下会很有用,例如定义静态配置;在大多数情况下,我们应该将初始化处理为特殊函数,使代码流更加明确。