golang 个人使用GORM的一点教训
GORM血泪史。
GORM是个很好用的工具,就是有时候脑抽,容易写出非常隐蔽的错误。
本文总结了个人使用GORM的教训,纯属个人观点。如有错误,请多多指正,谢谢。
一、批量新增
(1)错误示范
表结构:
1 2 3 4 5 |
// User represents table `user`. type User struct { ID string `gorm:"type:varchar(32)"` ..... } |
如果要批量新增几个用户,可能会一时脑抽,写成这样:
user.go
1 2 3 4 5 6 7 8 9 10 11 12 |
func TestInsert(userList []table.User , tx *gorm.DB) error { for _, u := range userList { err := tx.Create(table.User{ ID:u.ID, ..... }).Error if err != nil { return err } } return nil } |
首先这样写是没问题的,能达到批量新增用户的目的。但是这不是最优雅的实现方式,这里应该使用批量新增。
(2)正确实践
那么问题来了,GORM根本没有批量新增的支持,我们只能自己实现一个。
下面是CJK大佬写的批量新增方法:
batch.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// Row ... type Row []interface{} // BatchInsert constructs and executes batch insert SQL. func BatchInsert(db *gorm.DB, table string, columns []string, rows []Row) error { if len(rows) == 0 { return nil } sql := fmt.Sprintf("INSERT INTO %s(%s) VALUES ", table, strings.Join(columns, ",")) length := len(columns) var valueHolders []string for i := 0; i < length; i++ { valueHolders = append(valueHolders, "?") } placeHolder := fmt.Sprintf("(%s)", strings.Join(valueHolders, ",")) var values []string var args []interface{} for _, row := range rows { if len(row) != length { return fmt.Errorf("Row (%v) does not match columns (%v)", row, columns) } values = append(values, placeHolder) args = append(args, row...) } sql += strings.Join(values, ",") return db.Exec(sql, args...).Error } |
实际上是自己封装了要插入的SQL,然后使用执行原生SQL的方式进行插入操作。
使用时,需要指定批量插入的字段,并且封装要插入的数据:
user.go
1 2 3 4 5 6 7 8 9 10 11 12 |
func TestInsert(userList []table.User, tx *gorm.DB) error { rows := make([]model.Row, 0) for _, u := range userList { row := model.Row{u.ID} rows = append(rows, row) } err := model.BatchInsert(tx, "user", []string{"id"}, rows) if err != nil { return err } return nil } |
能一条SQL搞定的批量插入,就不要分多条SQL插入。
二、删除
(1)错误示范
表结构:
1 2 3 4 5 6 |
// Probation represents table `probation`. type Probation struct { ID uint UserID string `gorm:"type:varchar(32);NOT NULL;index:idx_user_id"` ..... } |
写一个删除方法:
1 2 3 4 5 6 7 |
func TestDelete(userID string, tx *gorm.DB) error { err := tx.Delete(table.Probation{UserID:userID}).Error if err != nil { return err } return nil } |
假设userID = “xie4ever”。现在尝试执行TestDelete方法,请问真的能删除 user_id = “xie4ever” 的数据吗?
实际执行的语句为:
1 |
DELETE * FROM probation; |
userID条件没有起任何作用,整张表都被清空了…这个案例破坏力巨大,请不要轻易尝试。
个人认为这是GORM里的一个大坑,如果你这样写:
1 |
tx.Delete(table.Probation{UserID:userID}) |
在delete时,只会以主键(即ID)作为删除条件,因为UserID不是主键,所以delete时没有附带任何where条件。
换句话说,如果你想删除 ID = “xie4ever” 的数据,使用:
1 |
tx.Delete(table.Probation{ID:id}) |
的写法是可行的。
(2)正确实践
正确的删除方式:
1 2 3 4 5 6 7 |
func TestDelete(userID string, tx *gorm.DB) error { err := tx.Delete(table.Probation{}, "user_id = ?", userID).Error if err != nil { return err } return nil } |
这样也可以:
1 2 3 4 5 6 7 |
func TestDelete(userID string, tx *gorm.DB) error { err := tx.Where("user_id = ?", userID).Delete(table.Probation{}).Error if err != nil { return err } return nil } |
但是要注意第二种删除方式,不能把where条件写在delete操作后面:
1 |
tx.Delete(table.Probation{}).Where("user_id = ?", userID) |
这样的删除语句相当于没有带where条件。
三、修改
(1)错误示范
表结构:
1 2 3 4 5 6 |
// Report represents table `report`. type Report struct { UserID string `gorm:"type:varchar(32);PRIMARY_KEY"` SickLeave float64 `gorm:"type:decimal(3,1);NOT NULL"` ..... } |
写一个修改方法:
1 2 3 4 5 6 7 8 9 10 11 |
func TestUpdate(userID string ,sickLeave float64, tx *gorm.DB) error { err := tx.Model(table.Report{}). Where("user_id = ?",userID).Update( table.Report{ SickLeave:sickLeave, }).Error if err != nil { return err } return nil } |
假设userID = “xie4ever”,sickLeave = 0。现在尝试执行TestUpdate方法,请问真的能更新sick_leave字段的值为0吗?
结果是不行的,我们查看一下官方文档:
http://gorm.io/zh_CN/docs/update.html
提到了:
1 2 3 |
// WARNING when update with struct, GORM will only update those fields that with non blank value // For below Update, nothing will be updated as "", 0, false are blank values of their types db.Model(&user).Updates(User{Name: "", Age: 0, Actived: false}) |
如果你用struct为载体进行更新,所有字段都不能被更新为0值(或Null值),而且这次操作是不会报错的,非常隐蔽。
(2)正确实践
正确的更新方式:
1 2 3 4 5 6 7 8 9 10 11 |
func TestUpdate(userID string ,sickLeave float64, tx *gorm.DB) error { err := tx.Model(table.Report{}). Where("user_id = ?",userID).Update( map[string] interface{} { "sick_leave":sickLeave, }).Error if err != nil { return err } return nil } |
可以使用map作为载体进行更新。
1 2 3 4 5 6 7 |
func TestUpdate(userID string ,sickLeave float64, tx *gorm.DB) error { err := tx.Raw("UPDATE report SET sick_leave = ? WHERE user_id = ?",sickLeave,userID).Error if err != nil { return err } return nil } |
使用原生SQL也可以。考虑到可能更新很多字段,还是使用map的方式比较靠谱。
四、查询
(1)错误示范
表结构:
1 2 3 4 5 |
// User represents table `user`. type User struct { ID string `gorm:"type:varchar(32)"` ..... } |
如果要查看一个用户是否存在,可能会手抖写成这样:
1 2 3 |
func IsUserExist(id string, tx *gorm.DB) bool { return !tx.Model(table.User{}).Where("id = ?",id).RecordNotFound() } |
假设id = “xie4ever”。现在尝试执行IsUserExist方法,请问真的能判断 id = “xie4ever” 的数据是否存在吗?
真的不能,因为这里漏写了一个Scan()方法,没有执行任何查询操作,RecordNotFound()的结果永远为true,导致IsUserExist()方法的结果永远为false。
这个错误也是非常隐蔽的,测试正常数据时不容易发现问题(比如我要插入一个User,需要事先校验ID是否会冲突,结果永远为false,顺利通过校验后容易造成主键冲突,事后比较难发现是校验步骤出了问题)。
(2)正确实践
正确的查询方法:
1 2 3 4 |
func IsUserExist(userID string, tx *gorm.DB) bool { var user table.User return !tx.Model(table.User{}).Where("user_id = ?",userID).Scan(&user).RecordNotFound() } |
这样写才会正常执行SQL。
五、执行原生查询SQL
(1)错误示范
之前有个同事尝试使用Exec()方法执行查询SQL,结果没有正常执行。我建议他换用Raw()方法,查询就正常了。结果他非要和我说:“我之前是可以的,Exec()是可以执行查询语句的…”,没办法,我只好亲自试一试了…
表结构:
1 2 3 4 5 |
// User represents table `user`. type User struct { ID string `gorm:"type:varchar(32)"` ..... } |
手写原生SQL,查询指定id的user:
1 2 3 4 5 |
func TestSelectUser(userID string, tx *gorm.DB) *table.User { var user table.User tx.Exec("SELECT * FROM user WHERE id = ?" , userID).Scan(&user) return &user; } |
注意这里使用了Exec()方法,不是通常使用的Raw()方法。
假设id = “xie4ever”。现在尝试执行TestSelectUser方法,请问真的能查询到 id = “xie4ever” 的数据吗?
日志打印出执行的SQL为:
1 2 3 4 5 6 7 |
(D:/GoPath/src/gitlab.xinghuolive.com/HRMS/vermouth/model/user/test.go:45) [2018-11-17 23:36:52] Error 1103: Incorrect table name '' (D:/GoPath/src/gitlab.xinghuolive.com/HRMS/vermouth/model/user/test.go:45) [2018-11-17 23:36:52] [8.97ms] SELECT * FROM `` [0 rows affected or returned ] &{ 0001-01-01 00:00:00 +0000 UTC {0001-01-01 00:00:00 +0000 UTC false} 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC} |
Exec()方法似乎不能很好地执行查询SQL,我们最好回到官方文档看看:
http://gorm.io/zh_CN/docs/sql_builder.html
官方给出的使用案例为:
1 2 3 4 5 6 7 8 9 10 11 |
db.Exec("DROP TABLE users;") db.Exec("UPDATE orders SET shipped_at=? WHERE id IN (?)", time.Now(), []int64{11,22,33}) // Scan type Result struct { Name string Age int } var result Result db.Raw("SELECT name, age FROM users WHERE name = ?", 3).Scan(&result) |
很明显可以看到,Exec()的语义应该是“执行某项数据库操作”,而查询SQL更倾向于使用Raw()。
(2019.3.17补充: Raw()方法之后必须跟上操作方法,比如Scan()方法,Raw()方法才能执行,否则看不到执行的SQL打印。而Exec()方法无论如何都会执行,在后面跟上一个Scan()方法也能实现查询效果)
(2)正确实践
正确地执行原生查询SQL:
1 2 3 4 5 |
func TestSelectUser(userID string, tx *gorm.DB) *table.User { var user table.User tx.Raw("SELECT * FROM user WHERE id = ?" , userID).Scan(&user) return &user; } |
查询相当正常。
六、事务
(1)注意事务的一致性
假设存在以下流程:
- INSERT User 到库中。
- SELECT User,从而获取User中的entry_time字段。
- 如果获取不到User,报错“User Not Found”。
- INSERT Operation 到库中,entry_time是其中的一个字段。
对应伪代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 开启事务 tx.Begin() // 创建User,使用了事务链接 user.createUser(....., tx) // 查询User,故意不使用事务 u := user.getUser(....., mysql.DB) if u == nil { return error } entry_time := u.EntryTime // 创建Operation,使用了事务链接 operation.createOperation(....., entry_time, tx) // 提交事务 tx.Commit() |
如果我故意让getUser()脱离事务,会发生什么情况?
结果是,getUser()拿到的结果永远是nil,该流程永远无法完成。
为什么会发生这种情况?这是因为事务是具有隔离性的,createUser()是在事务进行插入操作,插入的结果(即这个User记录)只有在tx事务内才是可见的。因为getUser()没有使用tx事务,所以这个结果不可见,只能返回nil。
这个问题在事务处理中比较常见,如果不小心调用了一个没有使用事务的查询方法,就会一直得到空结果,比较隐蔽。
(2)事务中遇到panic
使用上文的伪代码,稍微改造一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 开启事务 tx.Begin() // 创建User,使用了事务链接 user.createUser(....., tx) // 查询User,这次使用事务 u := user.getUser(....., mysql.DB) // 删除判空操作 entry_time := u.EntryTime // 创建Operation,使用了事务链接 operation.createOperation(....., entry_time, tx) // 提交事务 tx.Commit() |
这里我特意删除了判空操作,目的就是在事务中制造空指针panic。
如果真的出现了空指针panic,那么事务永远无法结束,出现死锁,整张表的写操作被锁定,只能进行读取。想要让事务结束,解决死锁,只能干掉执行事务的数据库链接,即重启所有正在跑的进程。
对于这个蛋疼问题,我们还是先看看关于事务的官方文档:
http://gorm.io/zh_CN/docs/transactions.html
官方示例为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
func CreateAnimals(db *gorm.DB) err { // 注意:一旦开始事务的处理,请使用tx作为数据库处理器,而不是db tx := db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() if tx.Error != nil { return err } if err := tx.Create(&Animal{Name: "Giraffe"}).Error; err != nil { tx.Rollback() return err } if err := tx.Create(&Animal{Name: "Lion"}).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error } |
可以看见,最重要的部分就是defer:
1 2 3 4 5 |
defer func() { if r := recover(); r != nil { tx.Rollback() } }() |
如果事务没有异常,那么肯定能正常commit()。
不管这个事务执行得如何,只要最后需要recover(),那就是需要回滚,直接Rollback()。
只要开始事务,就在后面跟一个defer处理,这固然是一个好习惯,但是平时经常写这么多事务相关的代码也很麻烦,万一写漏了就更糟糕了(比如忘记commit)。因此,完全可以封装一个事务执行方法。
下面是LJD大佬的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// WithTransaction creates transaction and deals with rollbacks and commits. func WithTransaction(f func(tx *gorm.DB) error) error { tx := mysql.DB.Begin() if tx.Error != nil { return tx.Error } var err error var success bool // 调用f时如果出现panic,err则会无法正常赋值,因此需要此变量 defer func() { if !success { tx.Rollback() // 执行f时出现任何问题,都要Rollback } }() err = f(tx) if err == nil { success = true return tx.Commit().Error // 成功则提交 } return err } |
- 如果一路上都没有异常,那么一定能走到success = true,事务可以成功提交。
- 如果出现了异常,但是不是panic,可以被err = f(tx)捕获,但是不能进入err == nil,最后success依然是false,当前事务会Rollback()。
- 如果出现了panic,那么程序肯定无法正常执行下去,err = f(tx)甚至不能正常赋值。但是这时候success一直都是false,在defer中绝对能保证Rollback(),从而保护了数据的安全。
七、总结
说了这么多乱七八糟的错误示范,写完程序最好还是自己debug一遍,可以有效杜绝低级错误(有时候不这样干是因为懒,有时候是因为时间实在太赶)。
传指针类型似乎没有问题,例如tx.Delete(table.Probation{UserID:userID}),如果改成tx.Delete(&table.Probation),就可以了吧
嗯嗯,可以的