Go项目重构经验分享——ORM框架实践

2023-09-25 11:22:47 浏览数 (1)

一、背景介绍

本文介绍了笔者在重构一个 Go 项目的实践经验,老项目由于迭代速度快,导致了接口杂乱,结构扁平,代码耦合度高等问题,在项目复杂度增加的情况下不再适合扩展,因此对整个项目进行了重构。篇幅有限,本文主要介绍 ORM 层的重构。

二、框架选择

项目的特点是重业务逻辑,且大多数逻辑依赖于数据库操作,相对地,对并发和性能的要求不是特别高

目前主流的 ORM 框架 gormxorm 在功能上都能满足项目的要求,xorm 的性能更高,接口设计简洁;gorm 提供了更多高级功能,如事务、预加载、回调、软删除等,且文档非常详细,缺点是由于内部使用了反射的原因会导致额外的性能开销。鉴于对性能要求不算很高,笔者选择的是功能丰富、文档详细的 gorm 框架。

三、ORM层目录结构

老项目的目录结构比较简单,重构后根据标准Go项目目录结构 推荐的方式重新整理,当前的目录结构大致如下:

代码语言:txt复制
.
├── internal
│   ├── handler
│   ├── usecase
│   ├── repository
│   ├── model
│   └── router
├── cmd
│   └── main
├── configs
├── go.mod
├── tools
├── test

internal 包中的是非共享的核心代码,一些公共库代码在重构时被移入了其他开源公共库,因此这里没有额外创建 pkg 包

handler 包中为业务逻辑,router负责路由注册,剩下的 usecaserepository 以及 model 均为数据库相关的包

四、重构过程

gorm 推荐的方式,model

1. model层

model包下的代码原为数据库表映射的结构体,这部分手工生成既麻烦也容易出现错误,可以直接通过gorm提供的GEN 工具进行生成

通过 go get 引入库代码,调用库提供的脚本即可方便地生成model文件

代码语言:txt复制
go get -u gorm.io/gen

GEN 生成的不仅是由表映射的结构体,还包括一些基本的增删查改的操作接口,这部分接口是否保留可以视具体项目而定

下面是通过 GEN 生成的一张表:

代码语言:txt复制
// 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 会完成这两个字段的更新。但如果项目中有自己的设置规则,记得取消这两个字段的设置

代码语言:txt复制
  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 接口来更改默认表名,例如:

代码语言:txt复制
Copytype Tabler interface {
    TableName() string
}

// TableName 会将 PubInfo 的表名重写为 `pub_info`
func (PubInfo) TableName() string {
    return "pub_info"
}
2. repository层

repository 层包括对数据库的基础增删查改操作,不涉及任何业务逻辑,对于官方文档里有的介绍笔者就不进行搬运了, 用户也可以直接通过 GEN 工具进行生成,下面是笔者在实践过程中碰到的一些坑

  • 错误处理 由于是链式API,gorm的错误处理方式一般如下所示,通过设置 Error 字段来保存执行过程中的错误
代码语言:txt复制
if err := db.Where("name = ?", "jinzhu").First(user).Error; err != nil {  
	// 处理错误
}

这一点本身并不迷惑,但在查询数据库中的对应记录时,如果使用 FirstTakeLast 方法从数据库中检索单个对象,当没有找到记录时,它会返回 ErrRecordNotFound 错误;但如果使用 Find 方法查询多条数据,如果没有命中纪录,其并不会返回 ErrRecordNotFound 错误,而这一点与老项目中数据库的处理逻辑是不兼容的,因此需要特别注意

  • 更新0值/非0值的字段:
代码语言:txt复制
//当通过 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腾讯技术创作特训营第二期有奖征文,瓜分万元奖池和键盘手表

0 人点赞