260×260

科学搜查官yuchanns

理想的生活是纯粹地热爱技术
  • Shenzhen, China
  • 后端开发工程师
Posted a month ago

sqlmock的使用

今天q群一哥们儿说,他使用beego orm的InserOrUpdate的时候出现了相同主键还是会执行新增插入的bug,找我帮忙看看什么情况。

当时我的第一反应是让他先在debug模式下打印sql语句看看有没有什么问题,但小伙子可能是比较紧张一直打印不出来。

由于我当时不在生产电脑前,对beego也不是很熟悉,只能临时用普通电脑装一个go,设置一下环境拉一下代码写一个测试用例。因为安装mysql太麻烦了,所以我打算简单的用DATA-DOG/go-sqlmock来mock数据库返回。

于是就顺手写一下使用记录,算是给那位大兄弟的一个教程科普吧。

情景简述

案例情景介绍如下:有一个TExchangeInfo结构体,实例化后填充数据,然后执行InsertOrUpdate,当数据存在时,使用更新,当数据不存在时才插入:

type TExchangeInfo struct { ID int64 `orm:"column(id);auto"` DeparmentID int64 `orm:"column(deparment_id)"` Times uint `orm:"column(times)"` Number uint `orm:"column(number)"` Lastmodified time.Time `orm:"column(lastmodified);type(datetime);auto_now"` }

sqlmock使用

sqlmock的使用其实很简单,参照文档就可以。我这里简单说明一下。

首先大家都知道,go标准库有一个datebase/sql/driver包,内部定义了数据库驱动标准接口,不管什么方言的数据库,只要实现了这些接口,就可以统一调用接口定制的方法来进行数据库交互。

而sqlmock也是通过sqlmock.New()这个方法返回一个标准的sql.DB结构体实例指针,这是一个数据库连接句柄。当然除此之外还返回了一个sqlmock.Sqlmock结构体实例。

而我们拿到*sql.DB之后,就可以递交给orm来使用了。

以beego orm为例,它有一个orm.NewOrmWithDB方法,用来实例化并指定连接句柄。

func InsertOrUpdatePrintSql() error { db, mock, err := sqlmock.New() if err != nil { return err } defer db.Close() orm.Debug = true // 开启debug模式才能打印出拼装的sql语句 o, err := orm.NewOrmWithDB("mysql", "default", db) if err != nil { return err } }

写到这里,似乎我们已经能够和往常一样使用orm了。试着写一个测试用例运行这个函数,结果会发现报错了,一个panic

panic: all expectations were already fulfilled, call to Prepare 'SELECT TIMEDIFF(NOW(), UTC_TIMESTAMP)' query was not expected [recovered]

一时之间令人摸不着头脑?这和接下来我们要讲的sqlmock.Sqlmock有关。

mock数据

mock的核心就在于mock这个词,也就是说,屏蔽上游细节,使用一些实现设定好的数据来模拟上游返回的数据。

sqlmock也同样如此,你需要在mock测试过程中,指定你期望(Expectations)执行的查询语句,以及假定的返回结果(WillReturnResult)。

注:beego orm在启动时候,会先执行SELECT TIMEDIFF...SELECT ENGINE...两个语句,所以我们也需要把它添加到我们的期望中。

func InsertOrUpdatePrintSql() error { db, mock, err := sqlmock.New() if err != nil { return err } defer db.Close() // ExpectPrepare,期望执行一条Prepare语句 mock.ExpectPrepare("SELECT TIMEDIFF") mock.ExpectPrepare("SELECT ENGINE") // ExpectExec,期望执行一条Exec语句 // 然后假定会返回(1, 1),也就是自增主键为1,1条影响结果 mock.ExpectExec("INSERT"). WillReturnResult(sqlmock.NewResult(1, 1)) orm.Debug = true o, err := orm.NewOrmWithDB("mysql", "default", db) if err != nil { return err } _ = o.Using("db1") // beego要求需要先注册结构体 orm.RegisterModel(new(TExchangeInfo)) u := &TExchangeInfo{ ID: 10086, DeparmentID: 1, Times: 0, Number: 10, } _, err = o.InsertOrUpdate(u) return err }

添加你的期望,然后执行orm动作。接着我们在标准输出口看到打印出来的sql语句

=== RUN TestInsertOrUpdatePrintSql [ORM]2020/09/16 23:43:39 -[Queries/default] - [ OK / db.Exec / 0.1ms] - [INSERT INTO `t_exchange_info` (`deparment_id`, `times`, `number`, `lastmodified`) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE `deparment_id`=?, `times`=?, `number`=?, `lastmodified`=?] - `1`, `0`, `10`, `2020-09-16 23:43:39.178543 +0800 CST`, `1`, `0`, `10`, `2020-09-16 23:43:39.178543 +0800 CST` --- PASS: TestInsertOrUpdatePrintSql (0.00s) PASS

分析问题

整理一下输出语句,我们发现,beego orm使用的是数据库自身的insert or update功能来实现的新增插入修改更新的交互。但是整条语句中却毫无主键的痕迹——

INSERT INTO `t_exchange_info` (`deparment_id`, `times`, `number`, `lastmodified`) VALUES (`1`, `0`, `10`, `2020-09-16 23:43:39.178543 +0800 CST`) ON DUPLICATE KEY UPDATE `deparment_id`=`1`, `times`=`0`, `number`=`10`, `lastmodified`=`2020-09-16 23:43:39.178543 +0800 CST`

那么我们应该意识到,很可能是beego orm在执行过程中,过滤掉了主键。这难道是个bug吗?

在追溯源码之后,我们判定问题在于github.com/astaxie/beego@v1.12.2/orm/db_mysql.go第122行代码这里。快速使用Goland自带的断点debug功能打一个断点,然后进行单步调试。

最终我们发现真正问题在于在github.com/astaxie/beego@v1.12.2/orm/db.go第91行这里,在结构体字段的tag中包含有auto属性时,会被跳过,这就是造成过滤的原因。

beego orm

结论

经过咨询得知,那位大兄弟在建立数据库交互所使用的数据结构体时,习惯在主键上打一个autotag,认为这样表示主键自增的意思。

我告诉他,auto标签只是用于告诉框架进行自增操作,属于框架代码层面的操作,而不是数据库层面的操作,并不表示为主键。如果要表示主键,也应该是pk

去掉auto,问题解决。

附:sqlmock更多用法

查询语句mock

package sqlmock import ( "bytes" "crypto/rand" "database/sql/driver" "encoding/json" "fmt" "github.com/DATA-DOG/go-sqlmock" _ "github.com/go-sql-driver/mysql" "github.com/jinzhu/gorm" "math/big" "time" ) type OrderStatus uint8 const ( OrderPending OrderStatus = iota OrderTransferring OrderSuccess OrderReturning OrderRefunded OrderCancelled ) type Order struct { ID int64 `json:"id" gorm:"primary_key"` UserID int64 `json:"user_id"` GID int64 `json:"g_id"` UnitPrice int64 `json:"unit_price"` Count int64 `json:"count"` Status OrderStatus `json:"status"` TotalPrice int64 `json:"total_price"` CreatedAt int64 `json:"-"` UpdateAt int64 `json:"-"` } func (o *Order) MarshalJSON() ([]byte, error) { type Alias Order const layout = "2006-01-02 15:04:05" return json.Marshal(&struct { *Alias CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` }{ Alias: (*Alias)(o), CreatedAt: time.Unix(o.CreatedAt, 0).Format(layout), UpdatedAt: time.Unix(o.UpdateAt, 0).Format(layout), }) } func QueryRows() error { db, mock, err := sqlmock.New() if err != nil { return err } autoGenOrder := func() func() []driver.Value { i := 0 userId := 10 good := new(big.Int).SetInt64(999) price := new(big.Int).SetInt64(99) counts := new(big.Int).SetInt64(9) sts := new(big.Int).SetInt64(5) allSts := []OrderStatus{ OrderPending, OrderTransferring, OrderSuccess, OrderReturning, OrderRefunded, OrderCancelled, } currentTime := time.Now().Unix() return func() []driver.Value { i++ gid, _ := rand.Int(rand.Reader, good) unitePrice, _ := rand.Int(rand.Reader, price) count, _ := rand.Int(rand.Reader, counts) totalPrice := unitePrice.Int64() * count.Int64() status, _ := rand.Int(rand.Reader, sts) return []driver.Value{ i, userId, gid.Int64(), unitePrice.Int64(), count.Int64(), totalPrice, allSts[status.Int64()], currentTime, currentTime + int64(i)*price.Int64(), } } }() rows := sqlmock.NewRows([]string{ "id", "user_id", "g_id", "unit_price", "count", "total_price", "status", "created_at", "update_at", }) for i := 0; i < 20; i++ { rows.AddRow(autoGenOrder()...) } o, err := gorm.Open("mysql", db) if err != nil { return err } defer o.Close() o.LogMode(true) mock.ExpectQuery("SELECT").WillReturnRows(rows) var results []*Order o.Where("id > ?", 0).Find(&results) jsonBytes, err := json.Marshal(results) if err != nil { return err } fmt.Println(bytes.NewBuffer(jsonBytes).String()) return nil }