摘要
函數(shù)是 Go 語言的一等公民,本文采用一種高階函數(shù)的方式,抽象了使用 gorm 查詢 DB 的查詢條件,將多個(gè)表的各種復(fù)雜的組合查詢抽象成了一個(gè)統(tǒng)一的方法和一個(gè)配置類,提升了代碼的簡(jiǎn)潔和優(yōu)雅,同時(shí)可以提升開發(fā)人員的效率。
背景
有一張 DB 表,業(yè)務(wù)上需要按照這個(gè)表里的不同字段做篩選查詢,這是一個(gè)非常普遍的需求,我相信這種需求對(duì)于每個(gè)做業(yè)務(wù)開發(fā)的人都是繞不開的。比如我們有一張存儲(chǔ)用戶信息的表,簡(jiǎn)化之后的表結(jié)構(gòu)如下:
CREATE TABLE `user_info` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主鍵', `user_id` bigint NOT NULL COMMENT '用戶id', `user_name` varchar NOT NULL COMMENT '用戶姓名', `role` int NOT NULL DEFAULT '0' COMMENT '角色', `status` int NOT NULL DEFAULT '0' COMMENT '狀態(tài)', PRIMARY KEY (`id`), ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用戶信息表';
這個(gè)表里有幾個(gè)關(guān)鍵字段,user_id、user_name 、 role、status。如果我們想按照 user_id 來做篩選,那我們一般是在 dao 層寫一個(gè)這樣的方法(為了示例代碼的簡(jiǎn)潔,這里所有示例代碼都省去了錯(cuò)誤處理部分):
func GetUserInfoByUid(ctx context.Context, userID int64) ([]*resource.UserInfo) { db := GetDB(ctx) db = db.Table(resource.UserInfo{}.TableName()) var infos []*resource.UserInfo db = db.Where("user_id = ?", userID) db.Find(&infos) return infos }
如果業(yè)務(wù)上又需要按照 user_name 來查詢,那我們就需要再寫一個(gè)類似的方法按照 user_name 來查詢:
func GetUserInfoByName(ctx context.Context, name string) ([]*resource.UserInfo) { db := GetDB(ctx) db = db.Table(resource.UserInfo{}.TableName()) var infos []*resource.UserInfo db = db.Where("user_name = ?", name) db.Find(&infos) return infos }
可以看到,兩個(gè)方法的代碼極度相似,如果再需要按照 role 或者 status 查詢,那不得不再來幾個(gè)方法,導(dǎo)致相似的方法非常多。當(dāng)然很容易想到,我們可以用一個(gè)方法,多幾個(gè)入?yún)⒌男问絹斫鉀Q這個(gè)問題,于是,我們把上面兩個(gè)方法合并成下面這種方法,能夠支持按照多個(gè)字段篩選查詢:
func GetUserInfo(ctx context.Context, userID int64, name string, role int, status int) ([]*resource.UserInfo) { db := GetDB(ctx) db = db.Table(resource.UserInfo{}.TableName()) var infos []*resource.UserInfo if userID > 0 { db = db.Where("user_id = ?", userID) } if name != "" { db = db.Where("user_name = ?", name) } if role > 0 { db = db.Where("role = ?", role) } if status > 0 { db = db.Where("status = ?", status) } db.Find(&infos) return infos }
相應(yīng)地,調(diào)用該方法的代碼也需要做出改變:
//只根據(jù)UserID查詢 infos := GetUserInfo(ctx, userID, "", 0, 0) //只根據(jù)UserName查詢 infos := GetUserInfo(ctx, 0, name, 0, 0) //只根據(jù)Role查詢 infos := GetUserInfo(ctx, 0, "", role, 0) //只根據(jù)Status查詢 infos := GetUserInfo(ctx, 0, "", 0, status)
這種代碼無論是寫代碼的人還是讀代碼的人,都會(huì)感覺非常難受。我們這里只列舉了四個(gè)參數(shù),可以想想這個(gè)表里如果有十幾個(gè)到二十個(gè)字段都需要做篩選查詢,這種代碼看上去是一種什么樣的感覺。首先,GetUserInfo 方法本身入?yún)⒎浅6啵锩娉涑庵鞣N != 0 和 != ""的判斷,并且需要注意的是,0 一定不能作為字段的有效值,否則 != 0 這種判斷就會(huì)有問題。其次,作為調(diào)用方,明明只是根據(jù)一個(gè)字段篩選查詢,卻不得不為其他參數(shù)填充一個(gè) 0 或者""來占位,而且調(diào)用者要特別謹(jǐn)慎,因?yàn)橐徊恍⌒模涂赡軙?huì)把 role 填到了 status 的位置上去,因?yàn)樗麄兊念愋投家粯樱幾g器不會(huì)檢查出任何錯(cuò)誤,很容易搞出業(yè)務(wù) bug。
解決方案
如果說解決這種問題有段位,那么以上的寫法只能算是青銅,接下來我們看看白銀、黃金和王者。
白銀
解決這種問題,一種比較常見的方案是,新建一個(gè)結(jié)構(gòu)體,把各種查詢的字段都放在這個(gè)結(jié)構(gòu)體中,然后把這個(gè)結(jié)構(gòu)體作為入?yún)魅氲?dao 層的查詢方法中。而在調(diào)用 dao 方法的地方,根據(jù)各自的需要,構(gòu)建包含不同字段的結(jié)構(gòu)體。在這個(gè)例子中,我們可以構(gòu)建一個(gè) UserInfo 的結(jié)構(gòu)體如下:
type UserInfo struct { UserID int64 Name string Role int32 Status int32 }
把 UserInfo 作為入?yún)鹘o GetUserInfo 方法,于是 GetUserInfo 方法變成了這樣:
func GetUserInfo(ctx context.Context, info *UserInfo) ([]*resource.UserInfo) { db := GetDB(ctx) db = db.Table(resource.UserInfo{}.TableName()) var infos []*resource.UserInfo if info.UserID > 0 { db = db.Where("user_id = ?", info.UserID) } if info.Name != "" { db = db.Where("user_name = ?", info.Name) } if info.Role > 0 { db = db.Where("role = ?", info.Role) } if info.Status > 0 { db = db.Where("status = ?", info.Status) } db.Find(&infos) return infos }
相應(yīng)地,調(diào)用該方法的代碼也需要變動(dòng):
//只根據(jù)userD查詢 info := &UserInfo{ UserID: userID, } infos := GetUserInfo(ctx, info) //只根據(jù)name查詢 info := &UserInfo{ Name: name, } infos := GetUserInfo(ctx, info)
這個(gè)代碼寫到這里,相比最開始的方法其實(shí)已經(jīng)好了不少,至少 dao 層的方法從很多個(gè)入?yún)⒆兂闪艘粋€(gè),調(diào)用方的代碼也可以根據(jù)自己的需要構(gòu)建參數(shù),不需要很多空占位符。但是存在的問題也比較明顯:仍然有很多判空不說,還引入了一個(gè)多余的結(jié)構(gòu)體。如果我們就到此結(jié)束的話,多少有點(diǎn)遺憾。
另外,如果我們?cè)贁U(kuò)展一下業(yè)務(wù)場(chǎng)景,我們使用的不是等值查詢,而是多值查詢或者區(qū)間查詢,比如查詢 status in (a, b),那上面的代碼又怎么擴(kuò)展呢?是不是又要引入一個(gè)方法,方法繁瑣暫且不說,方法名叫啥都會(huì)讓我們糾結(jié)很久;或許可以嘗試把每個(gè)參數(shù)都從單值擴(kuò)展成數(shù)組,然后賦值的地方從 = 改為 in()的方式,所有參數(shù)查詢都使用 in 顯然對(duì)性能不是那么友好。
黃金
接下來我們看看黃金的解法。在上面的方法中,我們引入了一個(gè)多余的結(jié)構(gòu)體,并且無法避免在 dao 層的方法中做了很多判空賦值。那么我們能不能不引入 UserInfo 這個(gè)多余的結(jié)構(gòu)體,并且也避免這些丑陋的判空?答案是可以的,函數(shù)式編程可以很好地解決這個(gè)問題,首先我們需要定義一個(gè)函數(shù)類型:
type Option func(*gorm.DB)
定義 Option 是一個(gè)函數(shù),這個(gè)函數(shù)的入?yún)㈩愋褪?gorm.DB,返回值為空。
然后針對(duì) DB 表中每個(gè)需要篩選查詢的字段定義一個(gè)函數(shù),為這個(gè)字段賦值,像下面這樣:
func UserID(userID int64) Option { return func(db *gorm.DB) { db.Where("`user_id` = ?", userID) } } func UserName(name string) Option { return func(db *gorm.DB) { db.Where("`user_name` = ?", name) } } func Role(role int32) Option { return func(db *gorm.DB) { db.Where("`role` = ?", role) } } func Status(status int32) Option { return func(db *gorm.DB) { db.Where("`status` = ?", status) } }
上面這組代碼中,入?yún)⑹且粋€(gè)字段的篩選值,返回的是一個(gè) Option 函數(shù),而這個(gè)函數(shù)的功能是把入?yún)①x值給當(dāng)前的【db *gorm.DB】對(duì)象。這也就是我們?cè)谖恼乱婚_始就提到的高階函數(shù),跟我們普通的函數(shù)不太一樣,普通的函數(shù)返回的是一個(gè)簡(jiǎn)單類型的值或者一個(gè)封裝類型的結(jié)構(gòu)體,而這種高階函數(shù)返回的是一個(gè)具備某種功能的函數(shù)。這里多說一句,雖然 go 語言很好地支持了函數(shù)式編程,但是由于其目前缺少對(duì)泛型的支持,導(dǎo)致高階函數(shù)編程的使用并沒有給開發(fā)者帶來更多的便利,因此在平時(shí)業(yè)務(wù)代碼中寫高階函數(shù)還是略為少見。而熟悉 JAVA 的同學(xué)都知道,JAVA 中的 Map、Reduce、Filter 等高階函數(shù)使用起來非常的舒服。
好,有了這一組函數(shù)之后,我們來看看 dao 層的查詢方法怎么寫:
func GetUserInfo(ctx context.Context, options func(option *gorm.DB)) ([]*resource.UserInfo) { db := GetDB(ctx) db = db.Table(resource.UserInfo{}.TableName()) for _, option := range options { option(db) } var infos []*resource.UserInfo db.Find(&infos) return infos }
沒有對(duì)比就沒有傷害,通過和最開始的方法比較,可以看到方法的入?yún)⒂啥鄠€(gè)不同類型的參數(shù)變成了一組相同類型的函數(shù),因此在處理這些參數(shù)的時(shí)候,也無需一個(gè)一個(gè)的判空,而是直接使用一個(gè) for 循環(huán)就搞定,相比之前已經(jīng)簡(jiǎn)潔了很多。
那么調(diào)用該方法的代碼怎么寫呢,這里直接給出來:
//只使用userID查詢 infos := GetUserInfo(ctx, UserID(userID)) //只使用userName查詢 infos := GetUserInfo(ctx, UserName(name)) //使用role和status同時(shí)查詢 infos := GetUserInfo(ctx, Role(role), Status(status))
無論是使用任意的單個(gè)參數(shù)還是使用多個(gè)參數(shù)組合查詢,我們都隨便寫,不用關(guān)注參數(shù)順序,簡(jiǎn)潔又清晰,可讀性也是非常好。
再來考慮上面提到的擴(kuò)展場(chǎng)景,如果我們需要多值查詢,比如查詢多個(gè) status,那么我們只需要在 Option 中增加一個(gè)小小的函數(shù)即可:
func StatusIn(status []int32) Option { return func(db *gorm.DB) { db.Where("`status` in ?", status) } }
對(duì)于其他字段或者等值查詢也是同理,代碼的簡(jiǎn)潔不言而喻。
王者
能優(yōu)化到上面黃金的階段,其實(shí)已經(jīng)很簡(jiǎn)潔了,如果止步于此的話,也是完全可以的。但是如果還想進(jìn)一步追求極致,那么請(qǐng)繼續(xù)往下看!
在上面方法中,我們通過高階函數(shù)已經(jīng)很好地解決了對(duì)于一張表中多字段組合查詢的代碼繁瑣問題,但是對(duì)于不同的表查詢,仍然要針對(duì)每個(gè)表都寫一個(gè)查詢方法,那么還有沒有進(jìn)一步優(yōu)化的空間呢?我們發(fā)現(xiàn),在 Option 中定義的這一組高階函數(shù),壓根與某張表沒關(guān)系,他只是簡(jiǎn)單地給 gorm.DB 賦值。因此,如果我們有多張表,每個(gè)表里都有 user_id、is_deleted、create_time、update_time 這些公共的字段,那么我們完全不用再重復(fù)定義一次,只需要在 Option 中定義一個(gè)就夠了,每張表的查詢都可以復(fù)用這些函數(shù)。進(jìn)一步思考,我們發(fā)現(xiàn),Option 中維護(hù)的是一些傻瓜式的代碼,根本不需要我們每次手動(dòng)去寫,可以使用腳本生成,掃描一遍 DB 的表,為每個(gè)不重復(fù)的字段生成一個(gè) Equal 方法、In 方法、Greater 方法、Less 方法,就可以解決所有表中按照不同字段做等值查詢、多值查詢、區(qū)間查詢。
解決了 Option 的問題之后,對(duì)于每個(gè)表的各種組合查詢,就只需要寫一個(gè)很簡(jiǎn)單的 Get 方法了,為了方便看,我們?cè)谶@里再貼一次:
func GetUserInfo(ctx context.Context, options func(option *gorm.DB)) ([]*resource.UserInfo) { db := GetDB(ctx) db = db.Table(resource.UserInfo{}.TableName()) for _, option := range options { option(db) } var infos []*resource.UserInfo db.Find(&infos) return infos }
上面這個(gè)查詢方法是針對(duì) user_info 這個(gè)表寫的,如果還有其他表,我們還需要為每個(gè)表都寫一個(gè)和這個(gè)類似的 Get 方法。如果我們仔細(xì)觀察每個(gè)表的 Get 方法,會(huì)發(fā)現(xiàn)這些方法其實(shí)就有兩點(diǎn)不同:
- 返回值類型不一樣;
- TableName 不一樣。
如果我們能解決這兩個(gè)問題,那我們就能夠使用一個(gè)方法解決所有表的查詢。首先對(duì)于第一點(diǎn)返回值不一致的問題,可以參考 json.unmarshal 的做法,把返回類型以一個(gè)參數(shù)的形式傳進(jìn)來,因?yàn)閭魅氲氖侵羔橆愋停跃筒挥迷俳o返回值了;而對(duì)于 tableName 不一致的問題,其實(shí)可以和上面處理不同參數(shù)的方式一樣,增加一個(gè) Option 方法來解決:
func TableName(tableName string) Option { return func(db *gorm.DB) { db.Table(tableName) } }
這樣改造之后,我們的 dao 層查詢方法就變成了這樣:
func GetRecord(ctx context.Context, in interface{}, options func(option *gorm.DB)) { db := GetDB(ctx) for _, option := range options { option(db) } db.Find(in) return }
注意,我們把方法名從之前的 GetUserInfo 變成了GetRecord,因?yàn)檫@個(gè)方法不僅能支持對(duì)于 user_info 表的查詢,而且能夠支持對(duì)一個(gè)庫中所有表的查詢。也就是說從最開始為每個(gè)表建一個(gè)類,每個(gè)類下面又寫很多個(gè)查詢方法,現(xiàn)在變成了所有表所有查詢適用一個(gè)方法。
然后我們看看調(diào)用這個(gè)方法的代碼怎么寫:
//根據(jù)userID和userName查詢 var infos []*resource.UserInfo GetRecord(ctx, &infos, TableName(resource.UserInfo{}.TableName()), UserID(userID), UserName(name))
這里還是給出了查詢 user_info 表的示例,在調(diào)用的地方指定 tableName 和返回類型。
經(jīng)過這樣的改造之后,我們最終實(shí)現(xiàn)了用一個(gè)簡(jiǎn)單的方法【GetRecord】 + 一個(gè)可自動(dòng)生成的配置類【Option】對(duì)一個(gè)庫中所有表的多種組合查詢。代碼的簡(jiǎn)潔和優(yōu)雅又有了一些提升。美中不足的是,在調(diào)用查詢方法的地方多傳了兩個(gè)參數(shù),一個(gè)是返回值變量,一個(gè)是 tableName,多少顯得有點(diǎn)不那么美觀。
總結(jié)
這里通過對(duì) grom 查詢條件的抽象,大大簡(jiǎn)化了對(duì) DB 組合查詢的寫法,提升了代碼的簡(jiǎn)潔。對(duì)于其他 update、insert、delete 三種操作,也可以借用這種思想做一定程度的簡(jiǎn)化,因?yàn)槠P(guān)系我們不在這里贅述。如果大家還有其他想法,歡迎留言討論!
參考文獻(xiàn)
https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html
https://coolshell.cn/articles/21146.html
原文地址:https://mp.weixin.qq.com/s/w1ebAgnzfDzoGG0sn6KGlQ