更进一步理解不定参数在GORM中的优雅应用

写在前面

最近一段时间的工作主要以业务逻辑编写为主,重度使用了一些Go的框架,比如Web框架Gin、ORM框架GORM、模板渲染框架Simplate等等。在高强度的情况下编写高复杂度的代码,难免会遇到各种坑,自然而然地也会积累很多宝贵的经验,这些经验是我个人比较看重的,因此总乐意花一些时间总结。

上一篇博客写了在GROM中使用不定参数的技巧,有朋友私信询问,于是感觉有必要基于上一篇内容再续一篇把这个点讲透彻。

适应人群

入门√——初级——中级——高级;本文适应入门及以上。

GORM最佳实践之不定参数的用法(二)

再看GROM中不定参数的最佳实践

承接上一篇博客,看下面的代码:

// Topic 话题
type Topic struct {
	gorm.Model
	Title         string    `gorm:"index"`
	Content       string    `gorm:"type:text"`
	ViewCount     int       `json:"view_count"`
	ReplyCount    int       `json:"reply_count"`
	UserID        int       `gorm:"index" json:"user_id"`
}

// QueryByUserID 根据UserID获取话题
func (t *Topic) QueryByUserID(userID int, args ...interface{}) (topics []Topic, err error) {
	db := config.DB.Where("user_id = ?", userID)
	if len(args) >= 2 {
		db = db.Where(args[0], args[1:]...)
	} else if len(args) >= 1 {
		db = db.Where(args[0])
	}
	err = db.Find(&topics).Error
	return
}

为了根据用户ID(UserID)获取到相应的话题(Topic),我们定义了一个QueryByUserID 的方法,为了表意刻意传入了一个参数,同时在QueryByUserID的最后强行添加了一个args不定参数,从而接受调用时传入的附加的参数,在函数体内根据args的长度对它进行不同方式的应用,达到了优雅扩展QueryByUserID的功能。

在添加了不定参数的情况下,调用QueryByUserID时如果想要进一步精细地搜索话题,比如想要获取UserID为1且最近一周发布的话题,可以这么写了:

topics, err := (&Topic{}).QueryByUserID(1, "created_at > ?", time.Now().Add(-7*24*time.Hour)
// ... 其他对topics 与 err 的处理

总之,非常灵活!

所述最佳实践中不定参数的约定

QueryByUserID 中根据传入的不定参数的数量,采用不同的传入方式,其实这里遵循了GORM中Where条件语句的作用方式

// ...
	if len(args) >= 2 {
		db = db.Where(args[0], args[1:]...)
	} else if len(args) >= 1 {
		db = db.Where(args[0])
	}
// ...

首先我们先把这里的约定给出来

  1. 当传入的args长度为1时,如果args[0]不是一个map[string]interface{}的值,这个值必须能够作用到主键上面。比如1,表示主键ID为1ID=1)的记录;比如[]int{1,2},表示主键ID为1和2ID IN (1,2))的记录;
  2. 否则,当传入的args长度为1时,args[0]必须是一个map[string]interface{}的值;
  3. 当传入的args长度大于等于2时,args[0]必须是一个string类型的值,且包含了args[1:]中同等个数的占位符。打个比方,如果args[0]真实值为"created_at > ? AND deleted_at > ?",那么args[1:]则应该包含两个值,一个对应第一个问号(即创建时间),一个对应第二个问号(即删除时间)。

GORM中相关函数源码解读

Where函数

当在GORM中调用Where方法时,通过查看源码可以知道,这个函数最底层也会使用一个不定参数,将传入到Where第一个后面的参数看成一个数组进行处理。具体地,第一个参数保存在map[string]interface{}类型的query字段,其他参数保存在args字段。

func (s *search) Where(query interface{}, values ...interface{}) *search {
	s.whereConditions = append(s.whereConditions, map[string]interface{}{"query": query, "args": values})
	return s
}

buildCondition 函数

通过分析源码,可以找到GORM中的WhereOrNot定义的搜索语句最终都通过 buildCondition函数 渲染成为SQL语句。

func (scope *Scope) buildCondition(clause map[string]interface{}, include bool) (str string) {
//...
	// 这里会把传入Where的第一个参数拿出来,进行类型断言
	switch value := clause["query"].(type) {
	case sql.NullInt64:
	// 如果传入Where的第一个参数是SQL中的空类型,执行逻辑...
	case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
	//如果传入Where的第一个参数是一个数字,执行逻辑...
	case []int, []int8, []int16, []int32, []int64, []uint, []uint8, []uint16, []uint32, []uint64, []string, []interface{}:
	//如果传入Where的第一个参数是一个数字类型的数组,执行逻辑...
	case string:
	//如果传入Where的第一个参数是一个字符串,结合后面的
	// for _, arg := range args {...} 会把参数塞进
	// 占位符...
	case map[string]interface{}:
	// 恩,map[string]interface{} 类型的值比较特殊
	// 也就是说,如果在Where中只传入一个值,且这个值是
	// 个map,则必须要是 map[string]interface{}类型
	// 的值,不能是 map[string]string 或其他类型的值
	// 否则就匹配到 interface{} 去了...
	case interface{}:
	// 最后,如果以上类型都不是,则执行到了这里...
	default:
	// 如果都没有匹配,报错...
	}
	// ...
	args := clause["args"].([]interface{})
	for _, arg := range args {
	// 这里会遍历传入Where的第一个参数后面的值
	// 来替换掉占位符...
	}
// ...
}

其他问题

通过上面的分析可以知道,在Where中传入的不定参数具有一定的约束,第二 、三…个参数不能随便写。

如果期望传入多个查询条件

在这种情况下,建议通过构造不定参数中第一个string类型的参数达成目的,比如同时通过创建时间和删除时间查找数据,则构建"created_at > ? AND deleted_at > ?"这种语句,后面跟着传入创建时间删除时间即可。

如果期望传入一个函数回调

在我所遇到的应用场景中,还没有传入回调的场景,就个人经验来看,在没有强规范制约时(当前确实想不到一种合适的规范),传入回调会增加函数复杂度,造成后期代码维护困难。

不定参数用在可有可无的搜索条件

推荐不定参数只用在那些可有可无的搜索条件,如果一个函数定义时已经明确知道函数中需要包含哪些参数,推荐把这些参数显式写在函数定义中,利用Go静态语言的特定,减少编码逻辑上的错误。

小结

本文介绍了使用GORM时加入不定参数后的灵活性,以及这种灵活性背后的约束。通过对GORM中 Where函数buildCondition函数 源码的分析,更进一步阐释了这种不定参数的应用规范(及其缘由):

  1. 当传入的args长度为1时,如果args[0]不是一个map[string]interface{}的值,这个值必须能够作用到主键上面;
  2. 否则,当传入的args长度为1时,args[0]必须是一个map[string]interface{}的值;
  3. 当传入的args长度大于等于2时,args[0]必须是一个string类型的值,且包含了args[1:]中同等个数的占位符。

参考