golang 个人使用GORM的一点教训

GORM血泪史。

GORM是个很好用的工具,就是有时候脑抽,容易写出非常隐蔽的错误。

本文总结了个人使用GORM的教训,纯属个人观点。如有错误,请多多指正,谢谢。

一、批量新增

(1)错误示范

表结构:

如果要批量新增几个用户,可能会一时脑抽,写成这样:

user.go

首先这样写是没问题的,能达到批量新增用户的目的。但是这不是最优雅的实现方式,这里应该使用批量新增。

(2)正确实践

那么问题来了,GORM根本没有批量新增的支持,我们只能自己实现一个。

下面是CJK大佬写的批量新增方法:

batch.go

实际上是自己封装了要插入的SQL,然后使用执行原生SQL的方式进行插入操作。

使用时,需要指定批量插入的字段,并且封装要插入的数据:

user.go

能一条SQL搞定的批量插入,就不要分多条SQL插入。

二、删除

(1)错误示范

表结构:

写一个删除方法:

假设userID = “xie4ever”。现在尝试执行TestDelete方法,请问真的能删除 user_id = “xie4ever” 的数据吗?

实际执行的语句为:

userID条件没有起任何作用,整张表都被清空了…这个案例破坏力巨大,请不要轻易尝试。

个人认为这是GORM里的一个大坑,如果你这样写:

在delete时,只会以主键(即ID)作为删除条件,因为UserID不是主键,所以delete时没有附带任何where条件。

换句话说,如果你想删除 ID = “xie4ever” 的数据,使用:

的写法是可行的。

(2)正确实践

正确的删除方式:

这样也可以:

但是要注意第二种删除方式,不能把where条件写在delete操作后面:

这样的删除语句相当于没有带where条件。

三、修改

(1)错误示范

表结构:

写一个修改方法:

假设userID = “xie4ever”,sickLeave = 0。现在尝试执行TestUpdate方法,请问真的能更新sick_leave字段的值为0吗?

结果是不行的,我们查看一下官方文档:

http://gorm.io/zh_CN/docs/update.html

提到了:

如果你用struct为载体进行更新,所有字段都不能被更新为0值(或Null值),而且这次操作是不会报错的,非常隐蔽。

(2)正确实践

正确的更新方式:

可以使用map作为载体进行更新。

使用原生SQL也可以。考虑到可能更新很多字段,还是使用map的方式比较靠谱。

四、查询

(1)错误示范

表结构:

如果要查看一个用户是否存在,可能会手抖写成这样:

假设id = “xie4ever”。现在尝试执行IsUserExist方法,请问真的能判断 id = “xie4ever” 的数据是否存在吗?

真的不能,因为这里漏写了一个Scan()方法,没有执行任何查询操作,RecordNotFound()的结果永远为true,导致IsUserExist()方法的结果永远为false。

这个错误也是非常隐蔽的,测试正常数据时不容易发现问题(比如我要插入一个User,需要事先校验ID是否会冲突,结果永远为false,顺利通过校验后容易造成主键冲突,事后比较难发现是校验步骤出了问题)。

(2)正确实践

正确的查询方法:

这样写才会正常执行SQL。

五、执行原生查询SQL

(1)错误示范

之前有个同事尝试使用Exec()方法执行查询SQL,结果没有正常执行。我建议他换用Raw()方法,查询就正常了。结果他非要和我说:“我之前是可以的,Exec()是可以执行查询语句的…”,没办法,我只好亲自试一试了…

表结构:

手写原生SQL,查询指定id的user:

注意这里使用了Exec()方法,不是通常使用的Raw()方法。

假设id = “xie4ever”。现在尝试执行TestSelectUser方法,请问真的能查询到 id = “xie4ever” 的数据吗?

日志打印出执行的SQL为:

Exec()方法似乎不能很好地执行查询SQL,我们最好回到官方文档看看:

http://gorm.io/zh_CN/docs/sql_builder.html

官方给出的使用案例为:

很明显可以看到,Exec()的语义应该是“执行某项数据库操作”,而查询SQL更倾向于使用Raw()。

(2019.3.17补充: Raw()方法之后必须跟上操作方法,比如Scan()方法,Raw()方法才能执行,否则看不到执行的SQL打印。而Exec()方法无论如何都会执行,在后面跟上一个Scan()方法也能实现查询效果)

(2)正确实践

正确地执行原生查询SQL:

查询相当正常。

六、事务

(1)注意事务的一致性

假设存在以下流程:

  1. INSERT User 到库中。
  2. SELECT User,从而获取User中的entry_time字段。
  3. 如果获取不到User,报错“User Not Found”。
  4. INSERT Operation 到库中,entry_time是其中的一个字段。

对应伪代码为:

如果我故意让getUser()脱离事务,会发生什么情况?

结果是,getUser()拿到的结果永远是nil,该流程永远无法完成。

为什么会发生这种情况?这是因为事务是具有隔离性的,createUser()是在事务进行插入操作,插入的结果(即这个User记录)只有在tx事务内才是可见的。因为getUser()没有使用tx事务,所以这个结果不可见,只能返回nil。

这个问题在事务处理中比较常见,如果不小心调用了一个没有使用事务的查询方法,就会一直得到空结果,比较隐蔽。

(2)事务中遇到panic

使用上文的伪代码,稍微改造一下:

这里我特意删除了判空操作,目的就是在事务中制造空指针panic。

如果真的出现了空指针panic,那么事务永远无法结束,出现死锁,整张表的写操作被锁定,只能进行读取。想要让事务结束,解决死锁,只能干掉执行事务的数据库链接,即重启所有正在跑的进程。

对于这个蛋疼问题,我们还是先看看关于事务的官方文档:

http://gorm.io/zh_CN/docs/transactions.html

官方示例为:

可以看见,最重要的部分就是defer:

如果事务没有异常,那么肯定能正常commit()。

不管这个事务执行得如何,只要最后需要recover(),那就是需要回滚,直接Rollback()。

只要开始事务,就在后面跟一个defer处理,这固然是一个好习惯,但是平时经常写这么多事务相关的代码也很麻烦,万一写漏了就更糟糕了(比如忘记commit)。因此,完全可以封装一个事务执行方法。

下面是LJD大佬的代码:

  1. 如果一路上都没有异常,那么一定能走到success = true,事务可以成功提交。
  2. 如果出现了异常,但是不是panic,可以被err = f(tx)捕获,但是不能进入err == nil,最后success依然是false,当前事务会Rollback()。
  3. 如果出现了panic,那么程序肯定无法正常执行下去,err = f(tx)甚至不能正常赋值。但是这时候success一直都是false,在defer中绝对能保证Rollback(),从而保护了数据的安全。

七、总结

说了这么多乱七八糟的错误示范,写完程序最好还是自己debug一遍,可以有效杜绝低级错误(有时候不这样干是因为懒,有时候是因为时间实在太赶)。

发布者

xie4ever

发表评论

电子邮件地址不会被公开。