这是Go常见错误系列的第13篇:init函数的常见错误和最佳实践。
素材来源于Go布道者,现Docker公司资深工程师Teiva Harsanyi。
本文涉及的源代码全部开源在:Go常见错误源代码,欢迎大家关注公众号,及时获取本系列最新更新。
很多Go语言开发者会错误地使用package里的init函数,导致代码难懂,维护困难。
我们先回顾下package里init函数的概念,然后讲解init函数的常见错误和最佳实践。
Go语言里的init函数有如下特点:
- init函数没有参数,没有返回值。如果加了参数或返回值,会编译报错。
- 一个package下面的每个.go源文件都可以有自己的init函数。当这个package被import时,就会执行该package下的init函数。
- 一个.go源文件里可以有一个或者多个init函数,虽然函数签名完全一样,但是Go允许这么做。
- .go源文件里的全局常量和变量会先被编译器解析,然后再执行init函数。
我们来看如下的代码示例:
package main
import "fmt"
func init() {
fmt.Println("init")
}
func init() {
fmt.Println(a)
}
func main() {
fmt.Println("main")
}
var a = func() int {
fmt.Println("var")
return 0
}()
go run main.go
执行这段程序的结果是:
var
init
0
main
全局变量a
的定义虽然放在了最后面,但是先被编译器解析,然后执行init函数,最后执行main函数。
有2个package: main
和redis
,main
这个package依赖了redis
这个package。
package main
import (
"fmt"
"redis"
)
func init() {
// ...
}
func main() {
err := redis.Store("foo", "bar")
// ...
}
package redis
// imports
func init() {
// ...
}
func Store(key, value string) error {
// ...
}
因为main
import了redis
,所以redis
这个package里的init函数先执行,然后再执行main
这个package里的init函数。
- 如果一个package下面有多个.go源文件,每个.go源文件里都有各自的init函数,那会按照.go源文件名的字典序执行init函数。比如有a.go和b.go这2个源文件,里面都有init函数,那a.go里的init函数比b.go里的init函数先执行。
- 如果一个.go源文件里有多个init函数,那按照代码里的先后顺序执行。
-
我们在工程实践里,不要去依赖init函数的执行顺序。如果预设了init函数的执行顺序,通常是很危险的,也不是Go语言的最佳实践。因为源文件名是有可能被修改的。
-
init函数不能被直接调用,否则会编译报错。
package main func init() {} func main() { init() }
上面这段代码编译报错如下:
$ go build . ./main.go:6:2: undefined: init
到现在为止,大家对package里的init函数应该有了一个比较清晰的理解,接下来我们看看init函数的常见错误和最佳实践。
我们先看看init函数一种常见的不太好的用法。
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
}
上面的程序做了如下几个事情:
- 创建一个数据库连接实例。
- 对数据库做ping检查。
- 如果连接数据库和ping检查都通过的话,会把数据库连接实例赋值给全局变量
db
。
大家可以先思考下这段程序会有哪些问题。
-
第一,init函数里面做错误管理的方式是很有限的。比如,init函数没法返回error,因为init函数是不能有返回值的。那如果init函数出现了error要让外界感知的话,得主动触发panic,让程序停止。对于上面的示例程序,虽然init函数遇到错误时,表示数据库连接失败,去停止程序运行或许是可以的。但是在init函数里去创建数据库连接,如果失败的话,就不好做重试或者容错处理。试想,如果是在一个普通函数里去创建数据库连接,那这个普通函数可以在创建数据库连接失败的时候返回error信息,然后函数的调用者来决定做重试或者退出的操作。
-
第二,会影响代码的单元测试。因为init函数在测试代码执行之前就会运行,如果我们仅仅是想测试这个package里某个不需要做数据库连接的基础函数,那测试的时候还是会执行init函数,去创建数据库连接,这显然并不是我们想要的效果,增加了单元测试的复杂性。
-
第三,这段程序把数据库连接赋值给了全局变量。用全局变量会有一些潜在的风险,比如这个package里的其它函数可以修改这个全局变量的值,导致被误修改;一些和数据库连接无关的单元测试也得考虑这个全局变量。
那我们如何对上面的程序做修改来解决以上问题呢?参考如下代码:
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
}
通过这个函数来创建数据库连接就可以解决以上3个问题了。
- 错误处理可以交给createClient函数的调用者去管理,调用者可以选择退出程序或者重试。
- 单元测试既可以测试和数据库无关的基础函数,也可以测试createClient来检查数据库连接的代码实现。
- 没有暴露全局变量,数据库连接实例在createClient函数里面创建和返回。
init函数也并不是完全不建议用,在有些场景下是可以考虑使用的。比如Go的官方blog的源码实现就用到了init函数。
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只有在第2个handler参数为nil的时候才会panic,显然这段程序里http.HandleFunc的第2个handler参数都是合法值,所以init函数不会失败。
同时,这里也无需创建全局变量,而且这个函数也不会影响单元测试。
因此这是一个适合用init函数的场景示例。
init函数要慎用,如果使用不当可能会带来问题,千万不要在代码里依赖同一package下不同.go文件init的执行顺序。
最后回顾下Go语言init函数的注意事项:
- init函数没有参数,没有返回值。如果加了参数或返回值,会编译报错。
- 一个package下面的每个.go源文件都可以有自己的init函数。当这个package被import时,就会执行该package下的init函数。
- 一个.go源文件里可以有一个或者多个init函数,虽然函数签名完全一样,但是Go允许这么做。
- .go源文件里的全局常量和变量会先被编译器解析,然后再执行init函数。
文章和示例代码开源在GitHub: Go语言初级、中级和高级教程。
公众号:coding进阶。关注公众号可以获取最新Go面试题和技术栈。
个人网站:Jincheng's Blog。
知乎:无忌。
我为大家整理了一份后端开发学习资料礼包,包含编程语言入门到进阶知识(Go、C++、Python)、后端开发技术栈、面试题等。
关注公众号「coding进阶」,发送消息 backend 领取资料礼包,这份资料会不定期更新,加入我觉得有价值的资料。
发送消息「进群」,和同行一起交流学习,答疑解惑。