使用GORM过程中遇到的坑,以及源代码分析
更新日期:
GORM是golang下的ORM框架,处理golang struct与数据库结构之间的映射,能根据struct结构自动生成SQL, 方便的CRUD操作,并且支持表关联。 官方网址 http://gorm.io/zh_CN/docs/index.html
发现问题, Find()方法使用的表名不正确
发现的坑来源于下面的代码。相较于GORM的示例代码,用TableName()
设备表名为hax_products
, 同时设置gorm.DefaultTableNameHandler
给默认生成的表名添加前缀hax_
, 并且使用db.LogMode(true)
打印SQL日志。
主要目的是测试: 当显式设置了表名,并且有指定默认表名前缀时, 默认表名前缀是否会被再次添加。
这里gorm 使用的版本为1.9.11
. 在glide.lock中的version值为79a77d771dee4e4b60e9c543e8663bbc80466670。
1 | package main |
运行之后输出结果如下:
可以看到,创建语言及查询单个记录时表名为hax_products
,而查询列表时使用的表名为hax_hax_products
且抛出了表名不存在异常。 查询单个记录时使用了TableName()
返回的表名,而在查询结果为Array时,表名在TableName()
的基础上又添加了前缀。
源代码分析
网上有不少GORM源代码分析的文章可以用来参考。 GORM 主要分析如下几个struct结构体。
type DB struct
(gorm/main.go)代表数据库连接,每次操作数据库会创建出clone对象。 方法gorm.Open()
返回的值类型就是这个结构体指针。type Scope struct
(gorm/scope.go) 当前数据库操作的信息,每次添加条件时也会创建clone对象。type Callback struct
(gorm/callback.go) 数据库各种操作的回调函数, SQL生成也是靠这些回调函数。 每种类型的回调函数放在单独的文件里,比如查询回调函数在gorm/callback_query.go, 创建的在gorm/callback_create.go
为定位表名生成的代码在哪个文件,分析从db.First()
与 db.Find()
入手。
db.First() 代码分析
First()
方法位于gorm/main.go文件中, .callCallbacks(s.parent.callbacks.queries)
调用了query回调函数。
1 | // file: gorm/main.go |
Callback
结构体中定义queries为函数指针数组, 而默认值的初始化在gorm/callback_query.go的init()
方法中, 查询方法为queryCallback
, 而queryCallback()
方法又调用到scope.prepareQuerySQL()
, scope中的方法真正生成SQL的地方。
1 | // file: gorm/callback.go |
跟踪代码到scope.go文件, 函数TableName()
是获取数据库表名的地方。 它按如下顺序来确定表名:
- scope.Search.tableName 查询条件中设置了表名, 则直接使用
- scope.Value.(tabler) 值对象实现了
tabler
接口(方法TableName() string
), 则从调用方法获取 - scope.Value.(dbTabler) 值对象实现了
dbTabler
接口(方法TableName(*DB) string
), 则从调用方法获取 - 若以上条件都不成立,则从
scope.GetModelStruct()
中获取对象的结构体信息,从结构体名生成表名
对比以上条件, 示例中的Product
结构体定义了方法TableName() string
,符合条件2,那么db.First(&product, 1)
使用的表名就是hax_products
。
1 | // file: gorm/scope.go |
db.Find() 代码分析
Find()
代码如下,与First()
同样是使用了.callbacks.queries
回调方法,不同点在于设置了newScope.Search.Limit(1)
只返回一个结果、增加了按id排序。
1 | // Find find records that match given conditions |
在debug模式下跟踪代码到scope.TableName()
中时,两次查询的区别显示出来了: 它们的结果值类型不同。db.First(&product, 1)
的值类型为结构体的指针*Product
,而db.Find(&products)
的值类型是数组的指针*[]Product
, 从而导致db.Find(&products)
进入条件4,需要靠分析struct结构体来生成表名。
1 | // file: gorm/model_struct.go |
默认表名s.defaultTableName
为空值时先进行求值,reflect.New(s.ModelType).Interface().(tabler)
先判断是否实现了tabler
接口,有则调用其TableName()取值; 否则的话从结构体的名字来生成表名。 结果返回之前再调用 DefaultTableNameHandler(db, s.defaultTableName)
方法。
这个ModelStruct
的TableName方法与scope.TableName()
中的逻辑两个不一致的地方:
scope.TableName()
会判断是否实现tabler
与dbTabler
两个接口,而这里只判断了tabler
scope.TableName()
是将tableName结果直接返回的, 而这里多调用了DefaultTableNameHandler()
。
因为逻辑2的存在, 当重写了DefaultTableNameHandler()
方法时, 就会出现表前缀再次被添加了表名前。
另一个坑: DefaultTableNameHandler()在多数据库时出现混乱
通过以上代码的分析,于是发现了另一个坑: 当一个程序中使用两个不同的数据库时, 重写方法DefaultTableNameHandler()
会影响到两个数据库中的表名。 其中一个数据库需要设置表前缀时,访问另一个数据库的表也可能会被加上前缀。 因为是包级别的方法,整个代码里只能设置一次值。
1 | // file: gorm/model_struct.go |
总结
- 当给结构体实现了
TableName()
方法时,就不要设置DefaultTableNameHandler
了。 - 保持所有Model的表名生成方式一致,要么全部使用自动生成的表名,要么全部实现
tabler
接口(实现TableName()
方法) - 当需要使用多个数据库时,要避免设置
DefaultTableNameHandler
- 强烈建议: 所有Model结构体全部实现
tabler
接口