一、背景介绍
本文介绍了笔者在重构一个 Go 项目的实践经验,老项目由于迭代速度快,导致了接口杂乱,结构扁平,代码耦合度高等问题,在项目复杂度增加的情况下不再适合扩展,因此对整个项目进行了重构。篇幅有限,本文主要介绍 ORM 层的重构。
二、框架选择
项目的特点是重业务逻辑,且大多数逻辑依赖于数据库操作,相对地,对并发和性能的要求不是特别高
目前主流的 ORM 框架 gorm
和 xorm
在功能上都能满足项目的要求,xorm
的性能更高,接口设计简洁;gorm
提供了更多高级功能,如事务、预加载、回调、软删除等,且文档非常详细,缺点是由于内部使用了反射的原因会导致额外的性能开销。鉴于对性能要求不算很高,笔者选择的是功能丰富、文档详细的 gorm
框架。
三、ORM层目录结构
老项目的目录结构比较简单,重构后根据标准Go项目目录结构 推荐的方式重新整理,当前的目录结构大致如下:
代码语言:txt复制.
├── internal
│ ├── handler
│ ├── usecase
│ ├── repository
│ ├── model
│ └── router
├── cmd
│ └── main
├── configs
├── go.mod
├── tools
├── test
internal
包中的是非共享的核心代码,一些公共库代码在重构时被移入了其他开源公共库,因此这里没有额外创建 pkg 包
handler
包中为业务逻辑,router
负责路由注册,剩下的 usecase
、repository
以及 model
均为数据库相关的包
四、重构过程
gorm
推荐的方式,model
1. model层
model
包下的代码原为数据库表映射的结构体,这部分手工生成既麻烦也容易出现错误,可以直接通过gorm
提供的GEN 工具进行生成
通过 go get 引入库代码,调用库提供的脚本即可方便地生成model文件
代码语言:txt复制go get -u gorm.io/gen
但 GEN
生成的不仅是由表映射的结构体,还包括一些基本的增删查改的操作接口,这部分接口是否保留可以视具体项目而定
下面是通过 GEN
生成的一张表:
// PubInfo mapped from table <pub_info>
type PubInfo struct {
Id NullInt64 `gorm:"column:id;type:bigint(20);primaryKey;autoIncrement:true" json:"id"`
CreateTime NullTime `gorm:"column:create_time;type:datetime" json:"create_time"`
UpdateTime NullTime `gorm:"column:update_time;type:datetime" json:"update_time"`
Status NullInt64 `gorm:"column:status;type:tinyint(4)" json:"status"`
Env NullString `gorm:"column:env;type:varchar(128);default:dev" json:"env"`
SType NullInt64 `gorm:"column:stype;type:tinyint(4);default:(-)" json:"stype"`
}
// TableName PubInfo's table name
func (PubInfo) TableName() string {
return "pub_info"
}
这里并没有逻辑代码,但对于 gorm
的 tag 设置,在实际应用中有几点踩坑经历:
一是 gorm
对于 default 值的处理方式,如上面代码所示,Env
字段设置了默认值 dev
,当调用插入接口时,如该值为空则会填写默认值,但这种情况只适用于所有数据库的默认值设置都相同的情况。如果想要在插入数据时使用数据库设置的默认值,需得在 tag 中设置 default:(-)
,如上述 SType
字段,否则 gorm
会在插入时为其设置默认的零值(更加具体的解释可参考这篇文章 )
二是 gorm
可以声明默认的 update_time 和 create_time 字段,在 tag 中设置 autoUpdateTime
即可,在记录创建和更新的时候,gorm
会完成这两个字段的更新。但如果项目中有自己的设置规则,记得取消这两个字段的设置
CreateTime time.Time `gorm:"column:createtime;type:datetime(0);autoUpdateTime" json:"createtime"`
UpdateTime time.Time `gorm:"column:updatetime;type:datetime(0);autoUpdateTime" json:"updatetime"`
三是 gorm
的表名默认使用结构体名的 蛇形命名
作为表名。对于结构体 PubInfo
,根据约定,其表名为 pubinfos
,在 model 层可以通过重新设定表名来替换,实现 Tabler
接口来更改默认表名,例如:
Copytype Tabler interface {
TableName() string
}
// TableName 会将 PubInfo 的表名重写为 `pub_info`
func (PubInfo) TableName() string {
return "pub_info"
}
2. repository层
repository 层包括对数据库的基础增删查改操作,不涉及任何业务逻辑,对于官方文档里有的介绍笔者就不进行搬运了, 用户也可以直接通过 GEN
工具进行生成,下面是笔者在实践过程中碰到的一些坑
- 错误处理
由于是链式API,
gorm
的错误处理方式一般如下所示,通过设置 Error 字段来保存执行过程中的错误
if err := db.Where("name = ?", "jinzhu").First(user).Error; err != nil {
// 处理错误
}
这一点本身并不迷惑,但在查询数据库中的对应记录时,如果使用 First
、 Take
、 Last
方法从数据库中检索单个对象,当没有找到记录时,它会返回 ErrRecordNotFound
错误;但如果使用 Find
方法查询多条数据,如果没有命中纪录,其并不会返回 ErrRecordNotFound
错误,而这一点与老项目中数据库的处理逻辑是不兼容的,因此需要特别注意
- 更新0值/非0值的字段:
//当通过 struct 更新时,GORM 只会更新非零字段。 如果您想确保指定字段被更新,你应该使用 Select 更新选定字段,或使用 map 来完成更新操作
func UpdateSelective(user model.User) (effected int64, err error) {
tx := db.Model(user).Updates(model.User{
Id: user.Id,
Name: user.Name,
Age: user.Age,
Sex: user.Sex,
Phone: user.Phone,
})
}
如果你想更新0值的字段,那么可以使用 Select 函数先选择指定的列名,或者使用 map 来完成:
代码语言:txt复制tx = constants.GVA_DB.Model(user).Updates(map[string]interface{}{
"Id": user.Id,
"Name": user.Name,
"Age": user.Age,
"Sex": user.Sex,
"Phone": user.Phone,
})
在 gorm
中一定要严格注意零值和非零值的判断,否则可能出现意想不到的结果
3. usecase层
这一层主要是业务逻辑,业务逻辑相关代码都应该在这一层写,当然有时候代码可能就只是保存一下数据,直接封装调用一下 repository 层接口即可
因为是对原始代码进行重构,这一层需要注意的是多使用 Select(column1, column2)
方法指定需要查询、更新的字段,防止改变之前的业务逻辑
相对于项目自己封装数据库操作接口,gorm
在带来便利的同时也提供了一些不确定性,主要体现在 gorm
自身的一些设置或者特性所致,需要在测试阶段仔细检查数据库操作相关的业务逻辑表现是否一致
原创不易,转载请注明出处
我正在参与2023腾讯技术创作特训营第二期有奖征文,瓜分万元奖池和键盘手表