摘要 函数是Go语言的一等公民,本文采用一种高阶函数的方式,抽象了使用gorm查询DB的查询条件,将多个表的各种复杂的组合查询抽象成了一个统一的方法和一个配置类,提升了代码的简洁和优雅,同时可以提升开发人员的效率。背景 有一张DB表,业务上需要按照这个表里的不同字段做筛选查询,这是一个非常普遍的需求,我相信这种需求对于每个做业务开发的人都是绕不开的。比如我们有一张存储用户信息的表,简化之后的表结构如下:CREATETABLEuserinfo(idbigintunsignedNOTNULLAUTOINCREMENTCOMMENT自增主键,useridbigintNOTNULLCOMMENT用户id,usernamevarcharNOTNULLCOMMENT用户姓名,roleintNOTNULLDEFAULT0COMMENT角色,statusintNOTNULLDEFAULT0COMMENT状态,PRIMARYKEY(id),)ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT用户信息表; 这个表里有几个关键字段,userid、username、role、status。如果我们想按照userid来做筛选,那我们一般是在dao层写一个这样的方法(为了示例代码的简洁,这里所有示例代码都省去了错误处理部分):funcGetUserInfoByUid(ctxcontext。Context,userIDint64)(〔〕resource。UserInfo){db:GetDB(ctx)dbdb。Table(resource。UserInfo{}。TableName())varinfos〔〕resource。UserInfodbdb。Where(userid?,userID)db。Find(infos)returninfos} 如果业务上又需要按照username来查询,那我们就需要再写一个类似的方法按照username来查询:funcGetUserInfoByName(ctxcontext。Context,namestring)(〔〕resource。UserInfo){db:GetDB(ctx)dbdb。Table(resource。UserInfo{}。TableName())varinfos〔〕resource。UserInfodbdb。Where(username?,name)db。Find(infos)returninfos} 可以看到,两个方法的代码极度相似,如果再需要按照role或者status查询,那不得不再来几个方法,导致相似的方法非常多。当然很容易想到,我们可以用一个方法,多几个入参的形式来解决这个问题,于是,我们把上面两个方法合并成下面这种方法,能够支持按照多个字段筛选查询:funcGetUserInfo(ctxcontext。Context,userIDint64,namestring,roleint,statusint)(〔〕resource。UserInfo){db:GetDB(ctx)dbdb。Table(resource。UserInfo{}。TableName())varinfos〔〕resource。UserInfoifuserID0{dbdb。Where(userid?,userID)}ifname!{dbdb。Where(username?,name)}ifrole0{dbdb。Where(role?,role)}ifstatus0{dbdb。Where(status?,status)}db。Find(infos)returninfos} 相应地,调用该方法的代码也需要做出改变:只根据UserID查询infos:GetUserInfo(ctx,userID,,0,0)只根据UserName查询infos:GetUserInfo(ctx,0,name,0,0)只根据Role查询infos:GetUserInfo(ctx,0,,role,0)只根据Status查询infos:GetUserInfo(ctx,0,,0,status) 这种代码无论是写代码的人还是读代码的人,都会感觉非常难受。我们这里只列举了四个参数,可以想想这个表里如果有十几个到二十个字段都需要做筛选查询,这种代码看上去是一种什么样的感觉。首先,GetUserInfo方法本身入参非常多,里面充斥着各种!0和!的判断,并且需要注意的是,0一定不能作为字段的有效值,否则!0这种判断就会有问题。其次,作为调用方,明明只是根据一个字段筛选查询,却不得不为其他参数填充一个0或者来占位,而且调用者要特别谨慎,因为一不小心,就可能会把role填到了status的位置上去,因为他们的类型都一样,编译器不会检查出任何错误,很容易搞出业务bug。解决方案 如果说解决这种问题有段位,那么以上的写法只能算是青铜,接下来我们看看白银、黄金和王者。白银 解决这种问题,一种比较常见的方案是,新建一个结构体,把各种查询的字段都放在这个结构体中,然后把这个结构体作为入参传入到dao层的查询方法中。而在调用dao方法的地方,根据各自的需要,构建包含不同字段的结构体。在这个例子中,我们可以构建一个UserInfo的结构体如下:typeUserInfostruct{UserIDint64NamestringRoleint32Statusint32} 把UserInfo作为入参传给GetUserInfo方法,于是GetUserInfo方法变成了这样:funcGetUserInfo(ctxcontext。Context,infoUserInfo)(〔〕resource。UserInfo){db:GetDB(ctx)dbdb。Table(resource。UserInfo{}。TableName())varinfos〔〕resource。UserInfoifinfo。UserID0{dbdb。Where(userid?,info。UserID)}ifinfo。Name!{dbdb。Where(username?,info。Name)}ifinfo。Role0{dbdb。Where(role?,info。Role)}ifinfo。Status0{dbdb。Where(status?,info。Status)}db。Find(infos)returninfos} 相应地,调用该方法的代码也需要变动:只根据userD查询info:UserInfo{UserID:userID,}infos:GetUserInfo(ctx,info)只根据name查询info:UserInfo{Name:name,}infos:GetUserInfo(ctx,info) 这个代码写到这里,相比最开始的方法其实已经好了不少,至少dao层的方法从很多个入参变成了一个,调用方的代码也可以根据自己的需要构建参数,不需要很多空占位符。但是存在的问题也比较明显:仍然有很多判空不说,还引入了一个多余的结构体。如果我们就到此结束的话,多少有点遗憾。 另外,如果我们再扩展一下业务场景,我们使用的不是等值查询,而是多值查询或者区间查询,比如查询statusin(a,b),那上面的代码又怎么扩展呢?是不是又要引入一个方法,方法繁琐暂且不说,方法名叫啥都会让我们纠结很久;或许可以尝试把每个参数都从单值扩展成数组,然后赋值的地方从改为in()的方式,所有参数查询都使用in显然对性能不是那么友好。黄金 接下来我们看看黄金的解法。在上面的方法中,我们引入了一个多余的结构体,并且无法避免在dao层的方法中做了很多判空赋值。那么我们能不能不引入UserInfo这个多余的结构体,并且也避免这些丑陋的判空?答案是可以的,函数式编程可以很好地解决这个问题,首先我们需要定义一个函数类型:typeOptionfunc(gorm。DB) 定义Option是一个函数,这个函数的入参类型是gorm。DB,返回值为空。 然后针对DB表中每个需要筛选查询的字段定义一个函数,为这个字段赋值,像下面这样:funcUserID(userIDint64)Option{returnfunc(dbgorm。DB){db。Where(userid?,userID)}}funcUserName(namestring)Option{returnfunc(dbgorm。DB){db。Where(username?,name)}}funcRole(roleint32)Option{returnfunc(dbgorm。DB){db。Where(role?,role)}}funcStatus(statusint32)Option{returnfunc(dbgorm。DB){db。Where(status?,status)}} 上面这组代码中,入参是一个字段的筛选值,返回的是一个Option函数,而这个函数的功能是把入参赋值给当前的【dbgorm。DB】对象。这也就是我们在文章一开始就提到的高阶函数,跟我们普通的函数不太一样,普通的函数返回的是一个简单类型的值或者一个封装类型的结构体,而这种高阶函数返回的是一个具备某种功能的函数。这里多说一句,虽然go语言很好地支持了函数式编程,但是由于其目前缺少对泛型的支持,导致高阶函数编程的使用并没有给开发者带来更多的便利,因此在平时业务代码中写高阶函数还是略为少见。而熟悉JAVA的同学都知道,JAVA中的Map、Reduce、Filter等高阶函数使用起来非常的舒服。 好,有了这一组函数之后,我们来看看dao层的查询方法怎么写:funcGetUserInfo(ctxcontext。Context,options。。。func(optiongorm。DB))(〔〕resource。UserInfo){db:GetDB(ctx)dbdb。Table(resource。UserInfo{}。TableName())for,option:rangeoptions{option(db)}varinfos〔〕resource。UserInfodb。Find(infos)returninfos} 没有对比就没有伤害,通过和最开始的方法比较,可以看到方法的入参由多个不同类型的参数变成了一组相同类型的函数,因此在处理这些参数的时候,也无需一个一个的判空,而是直接使用一个for循环就搞定,相比之前已经简洁了很多。 那么调用该方法的代码怎么写呢,这里直接给出来:只使用userID查询infos:GetUserInfo(ctx,UserID(userID))只使用userName查询infos:GetUserInfo(ctx,UserName(name))使用role和status同时查询infos:GetUserInfo(ctx,Role(role),Status(status)) 无论是使用任意的单个参数还是使用多个参数组合查询,我们都随便写,不用关注参数顺序,简洁又清晰,可读性也是非常好。 再来考虑上面提到的扩展场景,如果我们需要多值查询,比如查询多个status,那么我们只需要在Option中增加一个小小的函数即可:funcStatusIn(status〔〕int32)Option{returnfunc(dbgorm。DB){db。Where(statusin?,status)}} 对于其他字段或者等值查询也是同理,代码的简洁不言而喻。王者 能优化到上面黄金的阶段,其实已经很简洁了,如果止步于此的话,也是完全可以的。但是如果还想进一步追求极致,那么请继续往下看! 在上面方法中,我们通过高阶函数已经很好地解决了对于一张表中多字段组合查询的代码繁琐问题,但是对于不同的表查询,仍然要针对每个表都写一个查询方法,那么还有没有进一步优化的空间呢?我们发现,在Option中定义的这一组高阶函数,压根与某张表没关系,他只是简单地给gorm。DB赋值。因此,如果我们有多张表,每个表里都有userid、isdeleted、createtime、updatetime这些公共的字段,那么我们完全不用再重复定义一次,只需要在Option中定义一个就够了,每张表的查询都可以复用这些函数。进一步思考,我们发现,Option中维护的是一些傻瓜式的代码,根本不需要我们每次手动去写,可以使用脚本生成,扫描一遍DB的表,为每个不重复的字段生成一个Equal方法、In方法、Greater方法、Less方法,就可以解决所有表中按照不同字段做等值查询、多值查询、区间查询。 解决了Option的问题之后,对于每个表的各种组合查询,就只需要写一个很简单的Get方法了,为了方便看,我们在这里再贴一次:funcGetUserInfo(ctxcontext。Context,options。。。func(optiongorm。DB))(〔〕resource。UserInfo){db:GetDB(ctx)dbdb。Table(resource。UserInfo{}。TableName())for,option:rangeoptions{option(db)}varinfos〔〕resource。UserInfodb。Find(infos)returninfos} 上面这个查询方法是针对userinfo这个表写的,如果还有其他表,我们还需要为每个表都写一个和这个类似的Get方法。如果我们仔细观察每个表的Get方法,会发现这些方法其实就有两点不同:返回值类型不一样;TableName不一样。 如果我们能解决这两个问题,那我们就能够使用一个方法解决所有表的查询。首先对于第一点返回值不一致的问题,可以参考json。unmarshal的做法,把返回类型以一个参数的形式传进来,因为传入的是指针类型,所以就不用再给返回值了;而对于tableName不一致的问题,其实可以和上面处理不同参数的方式一样,增加一个Option方法来解决:funcTableName(tableNamestring)Option{returnfunc(dbgorm。DB){db。Table(tableName)}} 这样改造之后,我们的dao层查询方法就变成了这样:funcGetRecord(ctxcontext。Context,ininterface{},options。。。func(optiongorm。DB)){db:GetDB(ctx)for,option:rangeoptions{option(db)}db。Find(in)return} 注意,我们把方法名从之前的GetUserInfo变成了GetRecord,因为这个方法不仅能支持对于userinfo表的查询,而且能够支持对一个库中所有表的查询。也就是说从最开始为每个表建一个类,每个类下面又写很多个查询方法,现在变成了所有表所有查询适用一个方法。 然后我们看看调用这个方法的代码怎么写:根据userID和userName查询varinfos〔〕resource。UserInfoGetRecord(ctx,infos,TableName(resource。UserInfo{}。TableName()),UserID(userID),UserName(name)) 这里还是给出了查询userinfo表的示例,在调用的地方指定tableName和返回类型。 经过这样的改造之后,我们最终实现了用一个简单的方法【GetRecord】一个可自动生成的配置类【Option】对一个库中所有表的多种组合查询。代码的简洁和优雅又有了一些提升。美中不足的是,在调用查询方法的地方多传了两个参数,一个是返回值变量,一个是tableName,多少显得有点不那么美观。总结 这里通过对grom查询条件的抽象,大大简化了对DB组合查询的写法,提升了代码的简洁。对于其他update、insert、delete三种操作,也可以借用这种思想做一定程度的简化,因为篇幅关系我们不在这里赘述。如果大家还有其他想法,欢迎留言讨论!参考文献https:commandcenter。blogspot。com201401selfreferentialfunctionsanddesign。htmlhttps:coolshell。cnarticles21146。html加入我们 我们是字节直播中台创作管理团队,专注于直播创作与管理端的业务研发,为主播、工会、用户运营提供一站式的创作管理及创作激励平台和运营工具,并为各行业直播提供通用的解决方案和基础能力,持续为直播业务创造价值。 内推链接:https:job。toutiao。comsLts3xLP 内推邮箱:liuzhibing。buaabytedance。com