【导读】本文介绍了作者封装 gorm 分页功能的实现。
1 需求
在前后端分离项目中分页是非常常见的需求,最近在使用Gin重构SpringBoot项目,整合的orm框架是Gorm。然而Golang生态相对来Java说比较灵活(低情商:简陋),很多东西都需要自己封装 。网上查了一圈Gorm的分页方案感觉都不是自己需要的,因此打算使用Gorm封装一个类似Mybatis-Plus的分页方案,可以对实现类似“泛型”查询的效果。由于用到了Golang的反射机制,映像比较深刻所以在这里记录一下。
第4小节提出的方法已经能正常实现分页功能,只不过存在一点冗余代码。第6小节提出的方法使用到了反射来解决冗余,但是反射存在性能问题,不在乎冗余的话使用第4小节的封装方法就行了
2 项目结构
本次的Demo结构如下,搭建了一个小型的Gin项目,打算对MySQL 8中内置world数据库中的国家和城市表进行分页查询。database中是数据库的初始化,通用分页查询逻辑的底层封装;model中记录了city、country表对应的结构体及条件查询结构体;service中是具体的业务查询逻辑;main.go中只有两个路由,一个是分页查询城市的路由,一个是分页查询国家的路由。完整代码见GitHub (https://github.com/yafeng-Soong/blog-example)
|——gorm_page
| |——database
| |——mysql.go // 初始化及分页底层封装
| |——model.go // pageResponse结构体
| |——model
| |——city.go // city表对应结构体
| |——country.go // country表对应结构体
| |——page.go // 分页条件
| |——service
| |——city.go
| |——country.go
| |——go.mod
| |——go.sum
| |——main.go
3 结构体
city结构体包含城市名、国家代码、地区省份和人口数,country结构体包含代码、国家名、所属大陆和地区、建国时间。pageInfo结构体指明了查询的当前分页与分页大小,会嵌套进city和country对应的结构体中。city可以使用国家代码、地区省份作为查询条件,country可以使用所属大陆、区域、建国时间作为查询条件。Page结构体返回给前端,记录了总记录数、总页数等信息。
type PageInfo struct {
CurrentPage int64 `json:"currentPage"`
PageSize int64 `json:"pageSize"`
} // 分页条件
type City struct {
ID int `json:"id"`
Name string `json:"name"`
CountryCode string `json:"countryCode"`
District string `json:"district"`
Population int `json:"population"`
} // city表结构体
type CityQueryInfo struct {
PageInfo
CountryCode string `json:"countryCode"`
District string `json:"district"`
} // city查询条件
type Country struct {
Code string `json:"code"`
Name string `json:"name"`
Continent string `json:"continent"`
Region string `json:"region"`
IndepYear int `json:"indepYear"`
} // country表结构体
type CountryQueryInfo struct {
PageInfo
Continent string `json:"continent"`
Region string `json:"region"`
IndepYear int `json:"indepYear"`
} // country查询条件
type Page struct {
CurrentPage int64 `json:"currentPage"`
PageSize int64 `json:"pageSize"`
Total int64 `json:"total"` // 总记录数
Pages int64 `json:"pages"` // 总页数
Data interface{} `json:"data"` // 实际的list数据
} // 分页response返回给前端
4 存在冗余的Gorm分页封装
4.1 Gorm官方的分页示例
Gorm官方提供了简单的分页分装,如下所示。但实际使用过程中我们的业务是比较复杂的,比如我们希望得到总页数pages、记录总数total,这么简单的封装不能满足我们的需求。
// gorm官方的分页实例
func Paginate(r *http.Request) func(db *gorm.DB) *gorm.DB {
return func (db *gorm.DB) *gorm.DB {
page, _ := strconv.Atoi(r.Query("page"))
if page == 0 {
page = 1
}
pageSize, _ := strconv.Atoi(r.Query("page_size"))
switch {
case pageSize > 100:
pageSize = 100
case pageSize <= 0:
pageSize = 10
}
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
db.Scopes(Paginate(r)).Find(&users)
db.Scopes(Paginate(r)).Find(&articles)
4.2 封装额外的分页信息
尝试自己封装一下额外的分页信息,首先在cityModel中编写CountAll和SelectList函数,用以查询总记录数和list数据。并扩展Paginate,可以获取到pages、total等信息,并对边界进行限制。
package model
import (
"gorm_page/database"
"gorm.io/gorm"
)
type City struct {
ID int `json:"id"`
Name string `json:"name"`
CountryCode string `json:"countryCode"`
District string `json:"district"`
Population int `json:"population"`
}
// wrapper中包含查询条件,service传过来
func (c *City) CountAll(wrapper map[string]interface{}) int64 {
var total int64
database.DB.Model(&City{}).Where(wrapper).Count(&total)
return total
}
func (c *City) SelectList(p *database.Page, wrapper map[string]interface{}) error {
list := []City{}
if err := database.DB.Model(&City{}).Scopes(Paginate(p)).Where(wrapper).Find(&list).Error; err != nil {
return err
}
p.Data = list
return nil
}
func Paginate(page *database.Page) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page.CurrentPage <= 0 {
page.CurrentPage = 0
} // 当前页小于0则置为0
switch {
case page.PageSize > 100:
page.PageSize = 100
case page.PageSize <= 0:
page.PageSize = 10
} // 限制size大小
page.Pages = page.Total / page.PageSize
if page.Total%page.PageSize != 0 {
page.Pages++
} // 计算总页数
p := page.CurrentPage
if page.CurrentPage > page.Pages {
p = page.Pages
} // 若当前页大于总页数则使用总页数
size := page.PageSize
offset := int((p - 1) * size)
return db.Offset(offset).Limit(int(size)) // 设置limit和offset
}
}
4.3 Service中调用分页
首先从CityQueryInfo中取出查询条件设置到wrapper中,再调用cityModel的CountAll得到记录总数,如果为0直接返回(节约时间)。然后再调用SelelctList将实际的list数据保存到Page的Data字段中。
package service
import (
"gorm_page/database"
"gorm_page/model"
)
type CityService struct{}
var cityModel model.City
func (c *CityService) SelectPageList(p *database.Page, queryVo model.CityQueryInfo) error {
p.CurrentPage = queryVo.CurrentPage
p.PageSize = queryVo.PageSize
wrapper := make(map[string]interface{}, 0)
if queryVo.CountryCode != "" {
wrapper["CountryCode"] = queryVo.CountryCode
}
if queryVo.District != "" {
wrapper["District"] = queryVo.District
}
p.Total = cityModel.CountAll(wrapper)
if p.Total == 0 {
return nil // 若记录总数为0直接返回,不再执行Limit查询
}
return cityModel.SelectList(p, wrapper)
}
4.4 冗余代码
以上封装方法虽然能运行,但是并不完美,当需要分页查询的对象很多时,会存在许多冗余代码。比如我再按照上面的方法对country的分页查询进行封装,就会得到以下的冗余代码。可以看到CountAll、SelectList这些函数中不同的部分只有类型而已,若有10个对象需要分页查询,那么这两个函数就要重复10次。那么有没有什么办法简化一下,只写一次CountAll和SelectList呢?
// model/city.go中的冗余代码
func (c *City) CountAll(wrapper map[string]interface{}) int64 {
var total int64
database.DB.Model(&City{}).Where(wrapper).Count(&total)
return total
}
func (c *City) SelectList(p *database.Page, wrapper map[string]interface{}) error {
list := []City{}
if err := database.DB.Model(&City{}).Scopes(Paginate(p)).Where(wrapper).Find(&list).Error; err != nil {
return err
}
p.Data = list
return nil
}
// model/country.go中的冗余代码
func (c *Country) CountAll(wrapper map[string]interface{}) int64 {
var total int64
database.DB.Model(&Country{}).Where(wrapper).Count(&total)
return total
}
func (c *Country) SelectList(p *database.Page, wrapper map[string]interface{}) error {
list := []Country{}
if err := database.DB.Model(&Country{}).Scopes(Paginate(p)).Where(wrapper).Find(&list).Error; err != nil {
return err
}
p.Data = list
return nil
}
5 Mybatis-Plus中的分页
在简化Gorm的分页封装前,我们先来看看Spring中的分页。Spring中用得最多的ORM框架是Mybatis,其分页功能需要使用额外的分页插件,基本原理是使用拦截器拦截SQL语句并加上Limit、Offset条件。Mybatis-Plus则整合了分页插件,使用起来非常简单,只需要新建对应的Page对象和QueryWrapper对象,传给mapper层的selectPage方法即可。selectPage是BaseMapper中继承而来的,对UserMapper和CityMapper是透明的。selectPage执行完毕后当前页currentPage、记录总数total、总页数pages以及记录列表list都保存在了Page对象中。
// 查询用户分页
public Page<User> selectPageList(UserQueryVo queryVo) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("career", queryVo.getCareer());
queryWrapper.orderByDesc("update_time");
Page<User> page = new Page<>(queryVo.getCurrentPage(), queryVo.getPageSize());
return userMapper.selectPage(page, queryWrapper);
}
// 查询城市分页
public Page<City> selectPageList(CityQueryVo queryVo) {
QueryWrapper<City> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("country", queryVo.getCountry()); // 按国家查找城市
queryWrapper.orderByDesc("update_time");
Page<User> page = new Page<>(queryVo.getCurrentPage(), queryVo.getPageSize());
return cityMapper.selectPage(page, queryWrapper);
}
从上面得代码中我们可以看到 Mybatis-Plus中对分页代码的复用使用到了Java的泛型机制,然而Golang要等到1.18版本发布后才能支持泛型。那么现阶段有没有什么办法再golang中实现类似泛型的效果呢?答案是反射机制
6 使用反射来简化分页封装
仔细观察4.4中的冗余代码可以发现DB.Model()和DB.Find()中的参数与具体的类型相关,当时想到的第一个方案是使用interface{}抽象成一下代码。但实际运行时会报错,因为Find函数内部其实还是使用了反射,它需要确定list数组元素的类型,所以不能将interface{}数组传给Find函数。
func SelectPage(p *database.Page, wrapper map[string]interface{}, model interface{}) error {
database.DB.Model(&model).Where(wrapper).Count(&p.Total)
if p.Total == 0 {
p.Data = []interface{}{}
return nil
}
list := []interface{}{}
err := database.DB.Model(&model).Scopes(Paginate(p)).Where(wrapper).Find(&list).Error
if err != nil {
return err
}
p.Data = list
return nil
}
既然Find函数要使用反射获取数组元素的类型,那么我们就在传参之前将通过反射从model参数中获取具体的参数类型,再通过反射创建出对应类型的数组,代码如下:
func SelectPage(page *Page, wrapper map[string]interface{}, model interface{}) (e error) {
e = nil
DB.Model(&model).Where(wrapper).Count(&page.Total)
if page.Total == 0 {
page.Data = []interface{}{}
return
}
// 反射获得类型
t := reflect.TypeOf(model)
// 再通过反射创建创建对应类型的数组
list := reflect.Zero(reflect.SliceOf(t)).Interface()
e = DB.Model(&model).Where(wrapper).Scopes(Paginate(page)).Find(&list).Error
if e != nil {
return
}
page.Data = list
return
}
通过反射创建对应类型的数组还可以写成这样:
list := reflect.MakeSlice(reflect.SliceOf(t), 0, 0).Interface()
调用分页封装函数,可以看到在City和Country的结构体方法中只剩下了调用SelelctPage这一行相同代码,通过第三个参数指明了具体的查询结构体。
// model/city.go中调用分页
func (c *City) SelectPageList(p *database.Page, wrapper map[string]interface{}) error {
err := database.SelectPage(p, wrapper, City{}) // 指明查询City
return err
}
// model/country.go中调用分页
func (c *Country) SelectPageList(p *database.Page, wrapper map[string]interface{}) error {
err := database.SelectPage(p, wrapper, Country{}) // 指明查询Country
return err
}
7 测试运行
完整项目代码见我的Github示例(https://github.com/yafeng-Soong/blog-example),输入go run main.go
运行Demo程序,这里使用Postman进行分别对city、country两个接口发起分页查询,得到如下结果。可以看到response中的有pages、total等信息,且data中的list数据正常,表明底层封装的SelectPage函数可以通过反射查询到不同的对象。
8 总结
Golang相对于Java体系来说更灵活,许多东西都要动手自己封装,这里只是简单示范了一下使用反射来做分页,但是反射存在一定的性能问题。并且Demo中的查询条件使用的是map传递,这样只能传递相等的查询条件,对于有like、大于小于条件的情况可以使用DB来传递:
query := database.DB.Model(&Country{}).Where(wrapper)
query = query.Where("IndepYear > ?", 1949) // 设置大于条件
database.SelectPage(p, query, Country{})
// SelectPage函数修改为如下
func SelectPage(page *Page, query *gorm.DB, model interface{}) (e error) {
e = nil
query.Model(&model).Count(&page.Total)
if page.Total == 0 {
page.Data = []interface{}{}
return
}
// 反射获得类型
t := reflect.TypeOf(model)
// 再通过反射创建创建对应类型的数组
list := reflect.Zero(reflect.SliceOf(t)).Interface()
e = query.Model(&model).Scopes(Paginate(page)).Find(&list).Error
if e != nil {
return
}
page.Data = list
return
}
转自:
juejin.cn/post/7067532738940633119
- EOF -
Go 开发大全
参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。
关注后获取
回复 Go 获取6万star的Go资源库
分享、点赞和在看
支持我们分享更多好文章,谢谢!
文章评论