前言

这是Go常见错误系列的第13篇:init函数的常见错误和最佳实践。

素材来源于Go布道者,现Docker公司资深工程师Teiva Harsanyi

本文涉及的源代码全部开源在:Go常见错误源代码,欢迎大家关注公众号,及时获取本系列最新更新。

常见错误和最佳实践

很多Go语言开发者会错误地使用package里的init函数,导致代码难懂,维护困难。

我们先回顾下package里init函数的概念,然后讲解init函数的常见错误和最佳实践。

init基本概念

Go语言里的init函数有如下特点:

  • init函数没有参数,没有返回值。如果加了参数或返回值,会编译报错。
  • 一个package下面的每个.go源文件都可以有自己的init函数。当这个package被import时,就会执行该package下的init函数。
  • 一个.go源文件里可以有一个或者多个init函数,虽然函数签名完全一样,但是Go允许这么做。
  • .go源文件里的全局常量和变量会先被编译器解析,然后再执行init函数。

示例1

我们来看如下的代码示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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执行这段程序的结果是:

1
2
3
4
var
init
0
main

全局变量a的定义虽然放在了最后面,但是先被编译器解析,然后执行init函数,最后执行main函数。

示例2

有2个package: mainredismain这个package依赖了redis这个package。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main
 
import (
    "fmt"
 
    "redis"
)
 
func init() {
    // ...
}
 
func main() {
    err := redis.Store("foo", "bar")
    // ...
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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函数,那按照代码里的先后顺序执行。

img

  • 我们在工程实践里,不要去依赖init函数的执行顺序。如果预设了init函数的执行顺序,通常是很危险的,也不是Go语言的最佳实践。因为源文件名是有可能被修改的。

  • init函数不能被直接调用,否则会编译报错。

    1
    2
    3
    4
    5
    6
    7
    
    package main
    
    func init() {}
    
    func main() {
        init()
    }
    

    上面这段代码编译报错如下:

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

到现在为止,大家对package里的init函数应该有了一个比较清晰的理解,接下来我们看看init函数的常见错误和最佳实践。

init函数的错误用法

我们先看看init函数一种常见的不太好的用法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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里的其它函数可以修改这个全局变量的值,导致被误修改;一些和数据库连接无关的单元测试也得考虑这个全局变量。

那我们如何对上面的程序做修改来解决以上问题呢?参考如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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函数

init函数也并不是完全不建议用,在有些场景下是可以考虑使用的。比如Go的官方blog的源码实现就用到了init函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 领取资料礼包,这份资料会不定期更新,加入我觉得有价值的资料。

发送消息「进群」,和同行一起交流学习,答疑解惑。

References