Go 100 常见错误 3: 滥用 init 函数

137 阅读6分钟

有时我们在 Go 应用程序中滥用 init 函数。潜在的后果是错误管理不善或代码流程更难理解。让我们回顾一下 init 函数是什么。然后,我们将看看何时推荐或不推荐使用它。

2.3.1 概念

一个 init 函数是用来初始化应用程序状态的函数。它不接受任何参数,也不返回任何结果(是一个 func() 函数)。当一个包被初始化时,包内所有的常量和变量声明都会被求值。然后,执行 init 函数。

这是一个初始化主包的示例:

package main
import "fmt"
var a = func() int {
    fmt.Println("var") // 第一执行顺序
    return 0
}()
func init() {
    fmt.Println("init") // 第二执行顺序
}

func main() {
  fmt.Println("main") // 最后执行
}

运行这个示例会打印以下输出:

var

init

main

init 函数在包被初始化时执行。在以下示例中,我们定义了两个包:mainredis,其中 main 依赖于 redis。首先是 main 包中的 main.go 文件:

package main
import (
    "fmt"
    "redis"
)
func init() {
    // ...
}
func main() {
    err := redis.Store("foo", "bar") // 对 redis 包的依赖
    // ...
}

然后是 redis 包中的 redis.go 文件:

package redis
// imports
func init() {
    // ...
}
func Store(key, value string) error {
    // ...
}

因为 main 依赖于 redis,所以先执行 redis 包的 init 函数,然后是 main 包的 init 函数,最后是 main 函数本身。图 2.2 显示了这个顺序。

我们可以在每个包中定义多个 init 函数。当我们这样做时,包内 init 函数的执行顺序是基于源文件的字母顺序。例如,如果一个包包含一个 a.go 文件和一个 b.go 文件,并且两个文件中都有 init 函数,那么 a.go 中的 init 函数将首先执行。

WX20240509-175540@2x.png

我们不应该依赖包内 init 函数的顺序。实际上,这样做可能是危险的,因为源文件可以被重命名,这可能会影响执行顺序。

我们还可以在同一个源文件中定义多个 init 函数。例如,这段代码是完全有效的:

package main
import "fmt"
func init() {
    fmt.Println("init 1") // 第一 init 函数
}
func init() {
    fmt.Println("init 2") // 第二 init 函数
}
func main() {
}

首先执行的 init 函数是源代码顺序中的第一个。以下是输出结果:

init 1

init 2

我们还可以使用 init 函数来产生副作用。在下一个示例中,我们定义了一个主包,它对 foo 包没有强依赖(例如,没有直接使用 foo 包的公共函数)。然而,示例需要 foo 包被初始化。我们可以通过使用 _ 操作符以这种方式实现:

package main
import (
    "fmt"
  _ "foo" // 为产生副作用而导入 foo
)
func main() {
    // ...
}

在这种情况下,foo 包在 main 之前被初始化。因此,foo 的 init 函数被执行。

init 函数的另一个特点是它不能被直接调用,如以下示例所示:

package main
func init() {}
func main() {
  init() // 无效引用
}

这段代码产生了以下编译错误:

$ go build .
./main.go:6:2: undefined: init

现在我们已经回顾了 init 函数的工作原理,让我们看看何时应该使用它们,何时不应该使用它们。接下来的部分将对此进行阐述。

2.3.2 何时使用 init 函数

首先,让我们看一个使用 init 函数可能被认为不适当的例子:持有数据库连接池。在示例中的 init 函数里,我们使用 sql.Open 打开一个数据库。我们将这个数据库设置为一个全局变量,其他函数稍后可以使用:

var db *sql.DB
func init() {
    dataSourceName := os.Getenv("MYSQL_DATA_SOURCE_NAME") // 环境变量
    d, err := sql.Open("mysql", dataSourceName)
    if err != nil {
        log.Panic(err)
    }
    err = d.Ping()
    if err != nil {
      log.Panic(err)
    }
    db = d  // 将数据库连接分配给全局的 db 变量
}

在这个例子中,我们打开数据库,检查是否可以 ping 通它,然后将它分配给全局变量。我们应该如何看待这个实现?让我们描述三个主要的缺点。

首先,init 函数中的错误管理是有限的。实际上,由于 init 函数不返回错误,发出错误信号的唯一方法之一是引发 panic,这会导致应用程序停止。在我们的示例中,如果打开数据库失败,停止应用程序可能是可以接受的。然而,是否停止应用程序不必然应由包自身决定。调用者可能更希望实现重试或使用后备机制。在这种情况下,在 init 函数中打开数据库阻止了客户端包实现它们自己的错误处理逻辑。

另一个重要的缺点与测试相关。如果我们向这个文件添加测试,init 函数将在运行测试用例之前被执行,这并不一定是我们想要的(例如,如果我们对不需要创建此连接的实用函数添加单元测试)。因此,这个示例中的 init 函数使得编写单元测试变得复杂。

最后一个缺点是示例要求将数据库连接池分配给一个全局变量。全局变量有一些严重的缺陷;例如:

  • 包内的任何函数都可以修改全局变量。
  • 单元测试可能会变得更加复杂,因为依赖于全局变量的函数不再孤立。

在大多数情况下,我们应该优先考虑封装变量,而不是将其保持为全局变量。

由于这些原因,之前的初始化应该可能作为如下所示的普通函数的一部分来处理:

func createClient(dsn string) (*sql.DB, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
      return nil, err // 返回一个错误
    }
    if err = db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

使用这个函数,我们解决了之前讨论的主要缺点。具体如下:

  • 错误处理的责任由调用者承担。
  • 可以创建一个集成测试来检查这个函数的工作情况。
  • 连接池在函数内部被封装。

是否有必要完全避免使用 init 函数?并非如此。仍然有一些用例中 init 函数可能是有帮助的。例如,官方 Go 博客(mng.bz/PW6w)使用 init 函数来设置静态的 HTTP 配置:

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,但只有在处理器为 nil 时才会这样,这里不是这种情况)。同时,没有必要创建任何全局变量,这个函数也不会影响可能的单元测试。因此,这段代码提供了一个 init 函数可能有用的好例子。总之,我们看到 init 函数可能导致一些问题:

  • 它们可能会限制错误管理。
  • 它们可能会使测试实现变得复杂(例如,可能需要设置外部依赖,这在单元测试的范围中可能并非必要)。
  • 如果初始化需要我们设置状态,那么这必须通过全局变量来完成。

我们应该谨慎使用 init 函数。它们在某些情况下可能是有帮助的,例如,如本节所示,用于定义静态配置。然而,在其他情况和大多数情况下,我们应该通过专门的函数来处理初始化。