使用 Go 反射機制封裝 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 函數可以通過反射查詢到不同的對象。

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

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/xx-1szYa3nd1VJs1vrTilA