更多精彩内容,请关注微信公众号:后端技术小屋

在用golang进行后端开发时,总免不了要和mysql打交道。我们一般使用库github.com/go-sql-driver/mysql作为mysql driver。这篇文章主要阐述初学者在使用mysql driver时容易犯的几个错误及其解决方案

1 时间戳解析

如何将mysql中的timestamp转化为golang中的time.Time结构,这是一个问题…

1.1 问题

首先在mysql上创建表结构并插入数据

CREATE TABLE `test` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=136 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 
insert into test (create_time) values (now()); 

golang代码如下

package main

import (
	"log"
	"net"
	"strings"
	"time"

	"github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/jmoiron/sqlx/reflectx"
	"github.com/k0kubun/pp"
)

type TestData struct {
	Id         int       `json:"id"`
	CreateTime time.Time `json:"create_time"`
}

func main() {
	c := mysql.NewConfig()
	c.DBName = "test"
	c.Net = "tcp"
	c.User = "user"
	c.Passwd = "password"
	c.Addr = net.JoinHostPort("localhost", "3306")
	c.Params = map[string]string{}

	db, err := sqlx.Open("mysql", c.FormatDSN())
	if err != nil {
		log.Fatal(err)
	}
	db.Mapper = reflectx.NewMapperFunc("json", strings.ToLower)

	res := []TestData{}
	err = db.Select(&res, "select id, create_time from test.test")
	if err != nil {
		log.Fatal(err)
	}
	pp.Println(res)
}

运行结果

$ go run main.go               
2021/07/19 18:00:00 sql: Scan error on column index 1, name "create_time": unsupported Scan, storing driver.Value type []uint8 into type *time.Time
exit status 1 

1.2 解决

在Config.Params中加入"parseTime"开关

c.Params = map[string]string{}

变成

c.Params = map[string]string{
		"parseTime": "true",
}

再次运行,我们就可以读到mysql中的值了

[]main.TestData{
  main.TestData{
    Id:         136,
    CreateTime: 2021-07-19 17:52:08 UTC,
  },
}

1.3 原理

为什么增加一个"parseTime"开关就好使了呢?我们分析下mysql driver库的代码

1.3.0 三个依赖库的关系

从代码中可可知,我们的代码依赖三个库,这三个库都与mysql相关。

database/sql

github.com/jmoiron/sqlx
github.com/go-sql-driver/mysql

其中database/sql是golang内置库,它约定了一系列访问支持SQL的数据库的接口,其中并不包含实现。

github.com/go-sql-driver/mysql是mysql driver, 它实现了database/sql库中的一系列接口。因此只需要将mysql driver中的实现注册到database/sql中,即可通过database/sql中的接口访问clickhouse.

github.com/jmoiron/sqlx是对database/sql中一系列接口的封装和扩展,使得用户更方便使用.

1.3.1 mysql driver的注册和使用

在mysql driver的初始化函数中,将MySQLDriver注册到了database/sql中

func init() {
	sql.Register("mysql", &MySQLDriver{})
}

1.3.1.1 如何注册mysql driver

sql.Register的实现如下,这里将key=mysql, value=MySQLDriver加入到了sql.drivers中

func Register(name string, driver driver.Driver) {
	driversMu.Lock()
	defer driversMu.Unlock()
	if driver == nil {
		panic("sql: Register driver is nil")
	}
	if _, dup := drivers[name]; dup {
		panic("sql: Register called twice for driver " + name)
	}
	drivers[name] = driver
}

而sql.driver的数据结构如下, 即MysqlDriver需要实现Driver中的方法

var (
	driversMu sync.RWMutex
	drivers   = make(map[string]driver.Driver)
)

type Driver interface {
	Open(name string) (Conn, error)
}

type Conn interface {
	Prepare(query string) (Stmt, error)
	Close() error
	Begin() (Tx, error)
}

1.3.1.2 如何使用mysql driver

到此为止,mysql driver已经注册到了database/sql中,那么database/sql如何调用呢? 下面的代码中,

  • 首先从sql.drivers中根据driverName(对于mysql driver来说,就是mysql)获取对应的sql.Driver实例,
  • 然后将该实例转化为sql.driver.DriverContext类型(mysql driver中也实现了sql.driver.DriverContext接口)
  • 接着调用driverCtx.OpenConnector返回sql.driver.Connector实例(底层类型是mysql.connector, 它实现了sql.driver.Connector实例)
  • 最后将connector传给OpenDB, 返回sql.DB实例
func Open(driverName, dataSourceName string) (*DB, error) {
	driversMu.RLock()
	driveri, ok := drivers[driverName]
	driversMu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
	}

	if driverCtx, ok := driveri.(driver.DriverContext); ok {
		connector, err := driverCtx.OpenConnector(dataSourceName)
		if err != nil {
			return nil, err
		}
		return OpenDB(connector), nil
	}
	return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}

其实dsnConnector实现了sql.driver.Connector接口,如何实现以及实现中如何调用mysql driver实例留待读者分析

1.3.1 mysql database的打开

在我们的应用代码中,调用了sqlx.Open

	db, err := sqlx.Open("mysql", c.FormatDSN())

而sqlx.Open中调用了sql.Open, 返回sqlx.DB对象,其中封装了sql.DB对象(来自于database/sql)

func Open(driverName, dataSourceName string) (*DB, error) {
	db, err := sql.Open(driverName, dataSourceName)
	if err != nil {
		return nil, err
	}
	return &DB{DB: db, driverName: driverName, Mapper: mapper()}, err
}

sql.Open的实现见1.3.1.2

1.3.2 mysql database的查询

在应用代码中, 执行对应的sql语句,结果放置于res中

	err = db.Select(&res, "select id, create_time from test.test")

应用代码调用了sqlx.Select, 实现如下:

func Select(q Queryer, dest interface{}, query string, args ...interface{}) error {
	rows, err := q.Queryx(query, args...)
	if err != nil {
		return err
	}
	// if something happens here, we want to make sure the rows are Closed
	defer rows.Close()
	return scanAll(rows, dest, false)
}

因此,查询整体上分为两步

  • 查询mysql database中,获取rows数据
  • 将rows数据反序列化到res中

1.3.2.1 从mysql database中获取rows数据

在1.3.2 sqlx.Select函数中,q即为从应用传入的db,db类型为sqlx.DB 因此rows, err := q.Queryx(query, args...)调用了函数sqlx.DB.Queryx

func (db *DB) Queryx(query string, args ...interface{}) (*Rows, error) {
	r, err := db.DB.Query(query, args...)
	if err != nil {
		return nil, err
	}
	return &Rows{Rows: r, unsafe: db.unsafe, Mapper: db.Mapper}, err
}

而sqlx.DB.Queryx之后的调用链如下:

sql.DB.Query
sql.DB.QueryContext
sql.DB.query
sql.DB.queryDC
sql.ctxDriverQuery
sql.driver.Queryer.Query

sql.driver.Queryer是interface, 底层实际调用的是mysql.mysqlConn.Query -> mysql.mysqlConn.query:

  • 向mysql database发送查询命令
  • 读取数据,放置于textRows结构中
func (mc *mysqlConn) query(query string, args []driver.Value) (*textRows, error) {
	if mc.closed.IsSet() {
		errLog.Print(ErrInvalidConn)
		return nil, driver.ErrBadConn
	}
	if len(args) != 0 {
		if !mc.cfg.InterpolateParams {
			return nil, driver.ErrSkip
		}
		// try client-side prepare to reduce roundtrip
		prepared, err := mc.interpolateParams(query, args)
		if err != nil {
			return nil, err
		}
		query = prepared
	}
	// Send command
	err := mc.writeCommandPacketStr(comQuery, query)
	if err == nil {
		// Read Result
		var resLen int
		resLen, err = mc.readResultSetHeaderPacket()
		if err == nil {
			rows := new(textRows)
			rows.mc = mc

			if resLen == 0 {
				rows.rs.done = true

				switch err := rows.NextResultSet(); err {
				case nil, io.EOF:
					return rows, nil
				default:
					return nil, err
				}
			}

			// Columns
			rows.rs.columns, err = mc.readColumns(resLen)
			return rows, err
		}
	}
	return nil, mc.markBadConn(err)
}

1.3.2.2 将rows数据反序列化到res中

到此为止,我们已经获得了rows数据, 类型为sqlx.Rows,结构中嵌套者sql.Rows结构

type Rows struct {
	*sql.Rows
	unsafe bool
	Mapper *reflectx.Mapper
	// these fields cache memory use for a rows during iteration w/ structScan
	started bool
	fields  [][]int
	values  []interface{}
}

而sql.Rows结构中嵌套着driver.Rows interface, 在mysql driver中对应的底层结构为textRows

scanAll实现了将sqlx.Rows反序列化到数组中的逻辑, 其中调用了sqlx.Rows.Next 接下来的调用链:

sql.Rows.nextLocked 
textRows.Next
textRows.readRow

1.3.2.3 问题原因

截取mysql.textRows.readRow中部分代码如下 对于时间戳字段来说,mysql返回的是形同yyyy-mm-dd HH:MM:SS的[]byte

如果开关parseTime的值缺省,默认值为false, 则直接将[]byte赋值给dest[i]

	for i := range dest {
		// Read bytes and convert to string
		dest[i], isNull, n, err = readLengthEncodedString(data[pos:])
		pos += n
		if err == nil {
			if !isNull {
				if !mc.parseTime {
					continue
				} else {
					switch rows.rs.columns[i].fieldType {
					case fieldTypeTimestamp, fieldTypeDateTime,
						fieldTypeDate, fieldTypeNewDate:
						dest[i], err = parseDateTime(
							dest[i].([]byte),
							mc.cfg.Loc,
						)
						if err == nil {
							continue
						}
					default:
						continue
					}
				}

			} else {
				dest[i] = nil
				continue
			}
		}
		return err // err != nil
	}

后续执行sql.convertAssignRows将[]byte转化为time.Time的时候,会返回错误

	return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, dest)

而如果开关parseTime的值设置为true, 则mysql.textRows.readRow中会将[]byte转化为time.Time类型,后续执行sql.convertAssignRows时,才能成功注入time.Time类型的值

	case time.Time:
		switch d := dest.(type) {
		case *time.Time:
			*d = s
			return nil

2 时区设置

2.1 问题

细心的同学可能会发现, 即使加上了parseTime = true的开关,main.go输出的时间戳还是有点问题:时区是UTC,不太对

[]main.TestData{
  main.TestData{
    Id:         136,
    CreateTime: 2021-07-19 17:52:08 UTC,
  },
}

2.2 解决

前面我们已经完整的走读了一遍代码。遇到时区问题,我们第一时间想到可能是mysql.textRows.readRow中将[]byte转化为time.Time出了问题

						dest[i], err = parseDateTime(
							dest[i].([]byte),
							mc.cfg.Loc,
						)

我们看到,执行parseDateTime解析[]byte的时候,会指定一个参数代表时区,而这个参数来自mc.cfg.Loc, 即main.go中的mysql config,因此解决方案很简单,只需在mysql config中加入本地时区信息即可

	c.Loc, _ = time.LoadLocation("Local")

重新运行main.go即可得到正确的时区

[]main.TestData{
  main.TestData{
    Id:         136,
    CreateTime: 2021-07-19 17:52:08 Local,
  },
}

推荐阅读

更多精彩内容,请扫码关注微信公众号:后端技术小屋。如果觉得文章对你有帮助的话,请多多分享、转发、在看。
二维码