使用 Go 反射机制封装 Gorm 分页功能

2022年4月9日 469点热度 0人点赞 0条评论

【导读】本文介绍了作者封装 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), 00).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函数可以通过反射查询到不同的对象。

图片
imagepng
图片
imagepng

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 -

推荐阅读(点击标题可打开)

1、Go 语言 Middleware模式详解

2、Go 如何实现 AOP 切面操作?

3、Go logrus日志框架详解

Go 开发大全

参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。

图片

关注后获取

回复 Go 获取6万star的Go资源库

分享、点赞和在看

支持我们分享更多好文章,谢谢!

13540使用 Go 反射机制封装 Gorm 分页功能

root

这个人很懒,什么都没留下

文章评论