最近使用xorm更新db的时候碰到一个挺有意思的问题,db中字段类型是日期datetime,对应go model中的字段是time.Time类型,在分别使用model和map两种方式进行更新时,使用model的方式更新能得到正确结果,db中的日期能被更新成当地时间;使用map kv的方式进行更新时db中的字段被更新成了格林尼治标准时间。都是取的go time.Now为什么更新结果不一样呢?

// demo
type TimeTest struct {
	ID         int64     `xorm:"not null pk autoincr INT(11) 'id'"`
	UpdateTime time.Time `xorm:"update_time"`
}

func UpdateTime(o *xorm.Session) {
	o.Table(new(TimeTest)).Where("id = ?", 1).Update(&TimeTest{
		UpdateTime: time.Now(),
	})
	o.Table(new(TimeTest)).Where("id = ?", 1).Update(map[string]interface{}{
		"update_time": time.Now(),
	})
}

下文的源码分析基于xorm.io/xorm v1.0.5github.com/go-sql-driver/mysql v1.5.0

通过LastSQL()函数获取执行的SQL语句和参数

xorm自身支持打印执行的sql语句和参数,咱们使用LastSQL()函数打印出两次update执行的sql语句。可以看出使用map时把time.Now转成了格林尼治标准时间。

// model
UPDATE `time_test` SET  `update_time` = ? WHERE (id = ?) [2023-07-21 11:03:13 1]

// map
UPDATE `time_test` SET  `update_time` = ? WHERE (id = ?) [2023-07-21 03:03:13.760772475 +0800 CST m=+21.239008382 1]

Update方法的具体实现

为什么会得到这样的结果呢,接下就得看Update方法的具体实现了,这段代码还是挺明显的使用struct model或使用map会走到两个代码分支里。

// xorm.io/xorm@v1.0.5/session_update.go@Update()
// 167 行
	var isMap = t.Kind() == reflect.Map
	var isStruct = t.Kind() == reflect.Struct
	if isStruct {
		if err := session.statement.SetRefBean(bean); err != nil {
			return 0, err
		}

		if len(session.statement.TableName()) <= 0 {
			return 0, ErrTableNotFound
		}

		if session.statement.ColumnStr() == "" {
			colNames, args, err = session.statement.BuildUpdates(v, false, false,
				false, false, true)
		} else {
			colNames, args, err = session.genUpdateColumns(bean)
		}
// ...	
  • 可以看到map的逻辑比较简单,把参数拼到一个interface的数组中就继续向下执行了
  • 我们继续看下更新参数是model时怎么处理的SQL参数。 因为没有设置db column,这里继续执行的是session.statement.BuildUpdates,接下来看BuildUpdates函数,处理Time类型的字段代码如下,由于代码太长这里就只贴重点逻辑的代码。
// xorm.io/xorm@v1.0.5/internal/statements/update.go@BuildUpdates()
// 194行
    case reflect.Struct:
		if fieldType.ConvertibleTo(schemas.TimeType) {
			t := fieldValue.Convert(schemas.TimeType).Interface().(time.Time)
			if !requiredField && (t.IsZero() || !fieldValue.IsValid()) {
				continue
			}
			val = dialects.FormatColumnTime(statement.dialect, statement.defaultTimeZone, col, t)
// ...			

通过FormatColumnTime函数对时间类型的数据进行了格式化,这里defaultTimeZone实际取的是engine.DatabaseTZ,xorm engine 在初始化的时候,非sqlite3类型的数据库DatabaseTZ会赋值成Time.Local,col.TimeZone是在xorm初始化的时候通过连接串的url参数设置的例如:root:root@tcp(127.0.0.1:3306)/test?charset=utf8&parseTime=true&loc=Local,本文demo里初始化的时候没有设置,所以默认使用的engine.DatabaseTZ也就是time.Local使用的服务器所在时区。

// xorm.io/xorm@v1.0.5/dialects/time.go@FormatColumnTime()
// 37行
func FormatColumnTime(dialect Dialect, defaultTimeZone *time.Location, col *schemas.Column, t time.Time) (v interface{}) {
	if t.IsZero() {
		if col.Nullable {
			return nil
		}
		return ""
	}

	if col.TimeZone != nil {
		return FormatTime(dialect, col.SQLType.Name, t.In(col.TimeZone))
	}
	return FormatTime(dialect, col.SQLType.Name, t.In(defaultTimeZone))
}

以上可以得到使用model进行更新时,日期时间类型的参数会被转成服务器所在时区的时间。

SQL driver执行更新前对参数的处理

下面继续看map中日期时如何处理的,Update函数中拼接完参数args后,最终执行的是session.exec(),继续往下最终执行的各自SQL类型对应的具体实现,这里用的是MySQL下面看MySQL最终执行的具体实现github.com/go-sql-driver/mysql@v1.5.0/connection.go,可以找到是这个函数interpolateParams处理的SQL参数,对时间的处理逻辑如下:

// github.com/go-sql-driver/mysql@v1.5.0/connection.go@interpolateParams()
// 229行
        case time.Time:
			if v.IsZero() {
				buf = append(buf, "'0000-00-00'"...)
			} else {
				v := v.In(mc.cfg.Loc)
				v = v.Add(time.Nanosecond * 500) // To round under microsecond
// ...				

这里取了TimeZone取了mc.cfg.Loc,xorm engine进行初始化的时候会创建db 连接,就是下面代码里的Open方法。

//  xorm.io/xorm@v1.0.5/engine.go@NewEngine()
// 53行
func NewEngine(driverName string, dataSourceName string) (*Engine, error) {
	dialect, err := dialects.OpenDialect(driverName, dataSourceName)
	if err != nil {
		return nil, err
	}

	db, err := core.Open(driverName, dataSourceName)
	if err != nil {
		return nil, err
	}

点到Open方法内,继续往下可以找到MySQLDriver创建连接的实现,可以看到这里进行了配置的初始化,cfg是通过解析dsn连接串里参数初始化的。继续点进ParseDSN函数看到cfg默认初始化函数NewConfigLoc赋值成了格林尼治标准时间time.UTC,初始化xorm engine的时候db连接串没有设置Loc导致这里MySQL driver把时区设置成了UTC

// github.com/go-sql-driver/mysql@v1.5.0/driver.go@OpenConnector()
// 98行
// OpenConnector implements driver.DriverContext.
func (d MySQLDriver) OpenConnector(dsn string) (driver.Connector, error) {
	cfg, err := ParseDSN(dsn)
	if err != nil {
		return nil, err
	}
	return &connector{
		cfg: cfg,
	}, nil
}

// github.com/go-sql-driver/mysql@v1.5.0/dsn.go@ParseDSN()
// 290行
// ParseDSN parses the DSN string to a Config
func ParseDSN(dsn string) (cfg *Config, err error) {
	// New config with some default values
	cfg = NewConfig()
。。。


// github.com/go-sql-driver/mysql@v1.5.0/dsn.go@ParseDSN()
// 64行 
// NewConfig creates a new Config and sets default values.
func NewConfig() *Config {
	return &Config{
		Collation:            defaultCollation,
		Loc:                  time.UTC,
		MaxAllowedPacket:     defaultMaxAllowedPacket,
		AllowNativePasswords: true,
		CheckConnLiveness:    true,
	}
}	

结论

找到这里也就找到答案了,为什么通过map更新的日期时间是UTC时间,通过model struct更新的时间是服务器本地时间,因为map里的更新参数是最终SQL Driver进行解析格式化的,model struct是xorm先进行了参数格式化,两者初始化的默认时区不一样导致日期时间参数格式化的结果不一样。 最后为了避免这种问题,最好是在初始化连接的时候在dsn里显示设置Loc,这样xorm和SQL driver就都是用的显示设置的这个了。