Contents

sql.DB 以何种方式进行初始化,保存,使用?

起因:

《100 Go Mistakes and How to Avoid Them 》 “2.3 Misusing init functions”:

In the init function, we will open a database using sql.Open. We will make this database a global variable that functions can later use.

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
}

书作者认为,有三个主要问题如下:

  1. 错误处理将会被限制,init() 没有返回值,初始化失败的话,这时候只能 panic,不应该由 db 所在 package,在 init 初始化 dbpackage 的调用者也许会希望进行重试,或者做一些类似灾备方案(发通知给 sentry 等),总之要允许客户端实现他们的错误处理逻辑;
  2. 其次是测试,在测试的时候,init() 会先于测试代码执行;
  3. 以上代码需要赋值给一个全局变量,全局变量则有一下缺点:
    1. 在这个 package 内,所有函数都可以改变它;
    2. 会让测试变得复杂,其他函数会依赖它;

最后,书作者给出的答案是,实现 func createClient(dataSourceName string) (*sql.DB, error)

但是疑问还是没有解开,

问题

因为 sql.DB 有连接池的实现,不需要每次查询都显式获取一个连接。

因此,sql.DB,以何种方式进行初始化,保存,使用才不会导致上面的问题?

其实,看似是三个问题,其实知道以上书中列举的缺点之后,可以简化为一个问题:

sql.DB 如何初始化?

解决了初始化的问题,也就可以解决后面保存,和使用。

解决方案

原有方案

全局变量,一般是结合 init() 进行,看到一些开源的 admin 项目是这样做的,但是这种做法确实如书作者所说,测试代码会很难写,这些项目也确实很少有测试代码,但是测试其实依然可以写(见下文测试部分)。

至于其他函数可以改变它这个危险行为,恐怕只能用严格的代码审查去尽可能消除这部分危险 ⚠️ 因素了。

新方案

sql.DB 其实符合单例模式的使用场景,即

保证一个类、只有一个实例存在,同时提供能对该实例加以访问的全局访问方法。里举一个项目结构完整的例子(代码可见:init_sql.DB_example)

package internal

import (
	"sync"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"how-to-init-sql.DB/config"
)

var once sync.Once

type sqlDB struct {
	dbConn *gorm.DB
}

var db *sqlDB

func DB() *gorm.DB {
	once.Do(func() {
		db = initDBConn(config.DataSourceName)
	})

	return db.dbConn
}

func initDBConn(dataSourceName string) *sqlDB {
	db, err := createClient(dataSourceName)
	if err != nil {
		// handle error
		// panic? retry? send message to sentry? Whatever...
		return nil
	}
	return &sqlDB{dbConn: db}
}

func createClient(dataSourceName string) (*gorm.DB, error) {
	dbConn, err := gorm.Open(mysql.Open(dataSourceName))
	if err != nil {
		return nil, err
	}
	return dbConn, nil
}

使用如下:

type userCRUD interface { // 可进行 mock,用于调用者的测试中
	Create(user *model.User) error
}

type userDao struct {
	db *gorm.DB
}

var (
	user = &userDao{
		internal.DB(),
	}
)

func User() userCRUD {
	return user
}

func (u *userDao) Create(user *model.User) error {
	return u.db.Create(user).Error
}

现在代码结构如下:

├── config
│   └── config.go
├── dao
│   ├── internal
│   │   └── db.go
│   ├── model
│   │   └── user.go
│   └── user.go

此时 sql.DB 对外只提供了一个访问方法 DB(),对修改关闭,解决问题 3.1 (全局变量会被修改);

错误处理可以在 initDBConn() 中进行,解决问题 1(无法进行错误处理);

只剩下问题 2 (测试会执行 init())和 3.2 (测试难写,会依赖于这个全局变量),都是跟测试相关的,

问题 2 中测试与 init() 函数使用有关,这里已经不使用 init() 进行初始化了,因此只剩下对全局变量的依赖这一个可能会阻碍到测试的难点。

涉及到 sql 的执行的,我们使用 github.com/DATA-DOG/go-sqlmock

在初始化 sql.DBinternal 包中 mock 相关的单例实例:

type dbmock struct {
	SqlDB   *sql.DB
	GormDB  *gorm.DB
	SqlMock sqlmock.Sqlmock
}

// Mock4Test return dbmock for sql tests.
// Do not use it in production.
func Mock4Test(t *testing.T) *dbmock {
	t.Helper()
	dbMock, mock, err := sqlmock.New()
	assert.Nil(t, err)
	gormDB, err := gorm.Open(mysql.New(mysql.Config{
		Conn:                      dbMock,
		SkipInitializeWithVersion: true,
	}))
	assert.Nil(t, err)
	return &dbmock{dbMock, gormDB, mock}
}

真正的测试代码,则放在 daouser_test.go 中即可:


func TestInsertuser(t *testing.T) {
	t.Run("pos", func(t *testing.T) {
		userTest := model.User{Username: "testuser_Name", Password: "test"}
		mock := internal.Mock4Test(t)

		mock.SqlMock.ExpectBegin()
		mock.SqlMock.ExpectExec(regexp.QuoteMeta(
			"INSERT INTO `users` (`username`,`password`,`email`) VALUES (?,?,?)")).
			WithArgs(userTest.Username, userTest.Password, userTest.Email).WillReturnResult(sqlmock.NewResult(1, 1))
		mock.SqlMock.ExpectCommit()
		defer mock.SqlDB.Close()

		user = &userDao{mock.GormDB}
		err := User().Create(&userTest)
		assert.Nil(t, err)
		err = mock.SqlMock.ExpectationsWereMet()
		assert.Nil(t, err)
	})
}

现在代码结构如下:

├── config
│   └── config.go (mock external config)
├── dao
│   ├── internal
│   │   ├── db.go
│   │   └── db_sqlmock.go
│   ├── model
│   │   └── user.go (include sql DDL)
│   ├── user.go
│   └── user_test.go

运行测试:

=== RUN   TestInsertuser
=== RUN   TestInsertuser/pos
--- PASS: TestInsertuser (0.00s)
    --- PASS: TestInsertuser/pos (0.00s)
PASS
coverage: 100.0% of statements
ok  	how-to-init-sql.DB/dao	0.152s	coverage: 100.0% of statements

至此,我们解决了问题 3.2,也就回答了给自己提出的 sql.DB 如何初始化,保存在哪里,如何使用这三个问题。

重复一下,以上代码可见:init_sql.DB_example

后结

写这篇的时候,看了不少开源的,商业的项目,都不够满意,总觉得不够优雅,思来想去,想起之前学 Java 学的设计模式,虽然 Java 现在基本没用过(基本忘光了),但是设计模式倒是刻在脑子里,早上灵机一动想起来了这里可以用 单例模式

又一次觉得看这些修炼内功的书(《大话设计模式》等)意义真的非常重大。外面的世界怎么变,这些原理性的,模式化的东西,是通用的。

其次,其实使用设计模式的人写出来的代码和不使用设计模式写出来的代码,不仅是测试难写与否,同时后期可维护性,也可以说是扩展性受影响会很大。

后面我会写一篇完整的服务端,我将挑战单元测试率 → 100%,争取覆盖尽可能多的使用场景 👉 Golang unit test