使用Go 1.18泛型对Gorm进行分页封装

1 前言

上个月写了篇博客《使用反射机制来对Gorm进行分页封装》来记录如何使用Go的反射机制来实现一个类似Spring中Mybatis-Plus风格的分页功能,当时提到等Go 1.18发布后可以使用泛型来实现封装,刚好上周Go 1.18正式版发布,这里蹭一下热度尝试使用泛型替代反射来实现Gorm的分页封装。第4小节用List的例子介绍了一下泛型的基本使用,第5小节是用泛型封装分页的核心代码,本文的完整可运行代码见我的GitHub

2 项目结构

这次的Demo结构和上次接近,依然使用Gin来搭建,但进行了一定优化。同样是对MySQL 8中内置的world数据库中的国家和城市表进行分页查询。如果Mysql中没有这个world数据库的话可以从MySql的官网(MySQL :: Other MySQL Documentation)获取。database中是数据库的初始化;model中记录了city、country表对应的结构体及条件查询结构体,response.go中记录了返回给前端的结构体;service中是具体的业务查询逻辑;main.go中只有两个路由,一个是分页查询城市的路由,一个是分页查询国家的路由。完整代码见GitHub

|——gorm_page
|    |——database
|        |——mysql.go   // 初始化连接
|        |——model.go  // page[T]结构体及分页封装
|    |——model
|        |——city.go  // city表对应结构体
|        |——country.go  // country表对应结构体
|        |——page.go  // 分页条件
|        |——response.go // 返回给前段的结构体
|    |——service
|        |——city.go
|        |——country.go
|    |——go.mod
|    |——go.sum
|    |——main.go
复制代码

3 结构体

City、Country、分页查询等结构体的具体属性可以查看我之前那篇博客,这里主要详细阐述一下Page[T any]和Response结构体。Page[T any]用于存储从数据库中查询到的分页总数、记录总数,Data字段用泛型存储数据列表。Page一般不直接返回给前端,因为Page中的list可能需要一定的转换(比如从数据库中返回的用户信息包含first_name和last_name,但前端只需要full name,所以要将名字拼接一下),所以再定义一个PageResponse来接收转换后的Page结构体。Response结构体返回给前端,Data字段可能是string、int、map、PageResponse等任意类型的数据,所以不用泛型而用接口。

/* database/model.go */
type Page[T any] struct {
	CurrentPage int64
	PageSize    int64
	Total       int64
	Pages       int64
	Data        []T
}

/* model/response.go */
type Response struct {
	Code int         `json:"code"`
	Msg  string      `json:"msg"`
	Data interface{} `json:"data"`
}

type PageResponse[T any] struct {
	CurrentPage int64 `json:"currentPage"`
	PageSize    int64 `json:"pageSize"`
	Total       int64 `json:"total"`
	Pages       int64 `json:"pages"` // 总页数
	Data        []T   `json:"data"`
}

// 将原始的Page结构体转换为前端需要的PageResponse结构体
func NewPageResponse[T any](page *database.Page[T]) *PageResponse[T] {
	return &PageResponse[T]{
		CurrentPage: page.CurrentPage,
		PageSize:    page.PageSize,
		Total:       page.Total,
		Pages:       page.Pages,
                // 通常要将Data中的元素进行转换,比如拼接名字、时间格式,但这里没有要转换的字段
		Data:        page.Data, 
	}
}
复制代码

4 泛型的基本使用

4.1 函数中使用泛型

之前我们想使用max函数可能需要自己写int、float、string的不同情况,现在有了泛型就可以在函数前面用中括号说明该函数支持所有可比较的类型,参数的类型是T,和其他语言用法差不多。具体用法如下:

// 支持的泛型是comparable
func max[T comparable](T a, b) {
    if a > b {
        return a
    } else {
        return b
    }
}
复制代码

4.2 结构体中使用泛型

这里用泛型定义一下链表结构体,在定义List时用中括号说明该结构体支持任意类型的T,并在要用泛型的字段Element后面用中括号说明该字段是T类型。定义结构体方法时在接收器上也要加[T],参数和返回值如果需要也要指明是T。

type List[T any] struct {
	Len  int
	root *Element[T] // 伪头节点
}
type Element[T any] struct {
	next  *Element[T]
	Value T
}

func (l *List[T]) Front() *Element[T] {
	return l.root.next
}
func (l *List[T]) Init() {
	l.Len = 0
	l.root = &Element[T]{}
}
func (l *List[T]) Add(a T) {
	node := new(Element[T])
	node.Value = a
	node.next = l.root.next
	l.root.next = node
}
func (e *Element[T]) Next() *Element[T] {
	return e.next
}
复制代码

定义好上述泛型List后,写一个简单的程序测试一下,分别声明两个List类型变量,一个是存储int型、一个存储float64,向这两个链表添加数据后再将其打印出来。此时元素的Value字段可以直接赋值给对应类型的变量,不用再做e.Value.(int)这种显式的断言。代码如下:

func main() {
	var list1 List[int]
	list1.Init()
	list1.Add(1)
	list1.Add(2)
	list1.Add(3)
	for e := list1.Front(); e != nil; e = e.Next() {
		var tmp int = e.Value // Value可以直接赋值给int类型变量
		fmt.Print(tmp)
		fmt.Print(" ")
	}
	fmt.Print("\n")
	var list2 List[float64]
	list2.Init()
	list2.Add(11.22)
	list2.Add(23.55)
	list2.Add(39.17)
	for e := list2.Front(); e != nil; e = e.Next() {
		var tmp float64 = e.Value // Value可以直接赋值给float64类型变量
		fmt.Print(tmp)
		fmt.Print(" ")
	}
	fmt.Print("\n")
}
复制代码

测试运行可得到如下结果,说明该泛型List可以用来创建任意类型的链表

$:go run main.go
3 2 1
39.17 23.55 11.22
复制代码

5 用泛型实现Gorm分页

之前用反射来实现分页的核心代码如下,首先要用反射从上层传来的model中提取类型信息,再用反射创建对应类型的切片,最后将查询到的list存储到page的Data字段中.

/* 用反射实现的分页封装 */
// wrapper中是查询条件,model是具体的结构体
// 查询City分页数据——database.SelectPage(page, wrapper, City{})
// 查询Country分页数据——database.SelectPage(page, wrapper, Country{})
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
	page.Data = list
	return
}
// gorm官方提供的分页函数示例
// 可以在此设置总页数、总记录数等分类信息数据,并设置查询条件的limit、offset
func Paginate(page *Page) func(db *gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		if page.CurrentPage <= 0 {
			page.CurrentPage = 0
		}
		switch {
		case page.PageSize > 100:
			page.PageSize = 100
		case page.PageSize <= 0:
			page.PageSize = 10
		}
		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))
	}
}
复制代码

现在有了泛型我们可以对上述代码进行改造,可以避免使用反射。此时page.Data可以作为参数直接传递给Gorm的Find函数,因为在编译的时候page.Data是具有确定类型的。

type Page[T any] struct {
	CurrentPage int64
	PageSize    int64
	Total       int64
	Pages       int64
	Data        []T
}

func (page *Page[T]) SelectPage(wrapper map[string]interface{}) (e error) {
	e = nil
	var model T
	DB.Model(&model).Where(wrapper).Count(&page.Total)
	if page.Total == 0 {
                // 没有符合条件的数据,直接返回一个T类型的空列表
		page.Data = []T{}
		return
	}
        // 查询结果可以直接存到Page的Data字段中,因为编译的时候page.Data是有确定类型的
	e = DB.Model(&model).Where(wrapper).Scopes(Paginate(page)).Find(&page.Data).Error
	return
}
// Paginate加上T就行
func Paginate[T any](page *Page[T]) func(db *gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		if page.CurrentPage <= 0 {
			page.CurrentPage = 0
		}
		switch {
		case page.PageSize > 100:
			page.PageSize = 100
		case page.PageSize <= 0:
			page.PageSize = 10
		}
		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))
	}
}
复制代码

在service中调用分页查询的代码如下所示,queryVo中有当前页数和分页大小以及对应的查询条件。只需要在创建Page结构体实例时指明类型是City或是Country,调用SelectPage时就不需要传入额外的参数指明要查询的类型。这个风格已经非常接近Spring中的Mybatis/Mybatis-plus了,

type CityService struct{}

// 使用泛型调用分页查询,查询符合条件的City数据
func (c *CityService) SelectPageList(queryVo model.CityQueryInfo) (*model.PageResponse[model.City], error) {
	p := &database.Page[model.City]{
		CurrentPage: queryVo.CurrentPage,
		PageSize:    queryVo.PageSize,
	} // 指明Page的具体类型为City类型
	wrapper := make(map[string]interface{}, 0)
	if queryVo.CountryCode != "" {
		wrapper["CountryCode"] = queryVo.CountryCode
	}
	if queryVo.District != "" {
		wrapper["District"] = queryVo.District
	}
        // 不用再传递额外的信息告诉SelectPage函数我们要查询的是City类型的数据
	err := p.SelectPage(wrapper)
	if err != nil {
		return nil, err
	}
        // 将Page结构体转换成前端需要的PageResponse结构体
	pageResponse := model.NewPageResponse(p)
	return pageResponse, err
}
复制代码

6 测试运行

6.1 功能测试

完整项目代码见我的Github示例,输入go run main.go运行Demo程序,这里同样使用Postman进行分别对city、country两个接口发起分页查询,得到如下结果。可以看到response中的有pages、total等信息,且data中的list数据正常,表明用泛型封装的SelectPage函数对不同的类型都有效果。

分页city.png

分页country.png

6.2 性能测试

这里尝试Postman分别对反射和泛型封装的Gorm分页进行性能测试,执行多次相同条件的分页查询看哪种封装方式要高效一点。关于如何用Postman做复杂的测试,以后可能会写一篇博客来做说明。这里用外部csv文件做输入,文件一共85个查询,会将中国、美国、日本、印度等国家的城市都查询一遍。重复5次这85个查询测试,得到如下测试结果:

测试编号/封装技术 反射封装 泛型封装
1 587ms 496ms
2 540ms 509ms
3 501ms 780ms
4 478ms 459ms
5 480ms 463ms

除了第三组测试外(有点异常)反射封装的耗时都比泛型封装稍多一些,但是区别不是很明显,可能因为这里的City结构体比较简单体现不出性能的差距。知乎上有人总结过反射的损耗具体分为两个部分,一个部分是reflect.New()创建对象,另一个部分是value.Field().Set()设置对象属性,详见Golang 反射性能优化 - 知乎 (zhihu.com)

7 思考

Golang在1.18正式推出泛型机制,可以说是Golang发展的里程碑。泛型的加入能够在某些情况下替代反射的应用提升项目的性能,比如本文提到的封装Gorm分页。同时Go社区也出现了争议,有些开发者认为泛型的加入破坏了Go大道至简的理念;有些开发者认为中括号的写法不如尖括号优雅;还有大佬直接开喷谷歌,认为谷歌会造成Go社区的分裂(害怕)。本人有具有强迫症,系统、软件、运行环境都希望用最新版本,因此第一时间将项目升级到了1.18并体验了一波泛型,个人觉得泛型的加入确实方便了自己的编码,有疑虑的小伙伴可以再等一两个版本观望观望。

猜你喜欢

转载自juejin.im/post/7078279187471679518