本文讨论如何在 Golang 中,使用泛型化编程思想优化 MongoDB 查询操作,减少重复性代码,提升开发效率。
数据表
为对比不同设计思路的实际效果,我们设计了一个极简的用户任务场景,涉及 2 张表。
用户表
用户表记录系统可登录用户的信息,字段如下:
| 1
2
3
4
5
6
7
8
9
 | type User struct {
	Id primitive.ObjectID `bson:"_id" json:"Id"`
	Uid   string `bson:"Uid" json:"Uid"`     // 登录账号,如:lurenjia
	Name  string `bson:"Name" json:"Name"`   // 姓名,如:路人甲
	Email string `bson:"Email" json:"Email"` // 工作邮箱,如:lurenjia@xxxx.com
	// 其他字段...
}
 | 
 
- Id ,MongoDB 主键;
- Uid ,用户账号,类型为字符串,如 fasionchan;
- Name ,用户姓名,类型为字符串,如 小菜;
- Email ,工作邮箱,类型为字符串,如 fasionchan@gmail.com;
辅助类型定义
为了方便数据处理,我们可以为结构体实现一些方法,例如获取用户账号 Uid :
| 1
2
3
4
5
6
 | func (user *User) GetUid() string {
	if user == nil {
		return ""
	}
	return user.Uid
}
 | 
 
后续我们可以用这个基础方法做很多事情,例如获取用户列表的所有 Uid ,或者按 Uid 为用户列表建映射表。为方便数据处理,用户列表也可以单独定义一下,并把这些场景操作作为方法先实现好:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 | // 指针别名
type UserPtr = *User
// 用户列表
type Users []*User
func (users Users) MappingByUid() UserMappingByString {
	return stl.MappingByKey(users, UserPtr.GetUid)
}
func (users Users) Uids() types.Strings {
	return stl.Map(users, UserPtr.GetUid)
}
 | 
 
注意到,我们自己封装了一个泛型的数据处理类库,所以想 MappingByUid 这样的数据映射操作,仅需要一行函数调用即可完成。另外,为了方便地引用 GetUid 等方法,我们也为结构体指针定义了别名。
任务表
任务表记录一个任务的相关信息,字段如下:
| 1
2
3
4
5
6
7
8
 | type Task struct {
	Id primitive.ObjectID `bson:"_id" json:"Id"`
	Name        string               `bson:"Name" json:"Name"`               // 名称
	OperatorIds []primitive.ObjectID `bson:"OperatorIds" json:"OperatorIds"` // 执行人ID
	// 其他字段...
}
 | 
 
- Id ,MongoDB 主键;
- Name ,任务名称,类型为字符串;
- OperatorIds ,执行人 Id 数组,由此与用户表的用户信息关联;
无技巧写法
根据 Id 查询数据记录是一个很常见的数据库操作,以用户表为例,如果不用任何技巧,代码是这么写的:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 | func FindUserById(ctx context.Context, coll *mongo.Collection, id primitive.ObjectID, opts ...*options.FindOneOptions) (*User, error) {
	result := coll.FindOne(ctx, bson.M{"_id": id}, opts...)
	if err := result.Err(); err != nil {
		return nil, err
	}
	var user User
	if err := result.Decode(&user); err != nil {
		return nil, err
	}
	return &user, nil
}
 | 
 
轮到任务表,我们还得把代码抄一遍:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 | func FindTaskById(ctx context.Context, coll *mongo.Collection, id primitive.ObjectID, opts ...*options.FindOneOptions) (*Task, error) {
	result := coll.FindOne(ctx, bson.M{"_id": id}, opts...)
	if err := result.Err(); err != nil {
		return nil, err
	}
	var task Task
	if err := result.Decode(&task); err != nil {
		return nil, err
	}
	return &task, nil
}
 | 
 
可以看到,这两个函数代码几乎是一模一样的,除了数据类型(变量类型)。
interface{} 写法
在 Golang 泛型特性发布之前,我们可以通过 interface{} 将代码中与类型无关的逻辑给封装起来:
| 1
2
3
4
5
6
7
 | func FindOneById(ctx context.Context, coll *mongo.Collection, id primitive.ObjectID, data interface{}, opts ...*options.FindOneOptions) error {
	result := coll.FindOne(ctx, bson.M{FieldNameCommonId: id}, opts...)
	if err := result.Err(); err != nil {
		return nil
	}
	return result.Decode(data)
}
 | 
 
FindOneById 函数引入了一个 interface{} 类型的参数 data ,来接数据库查询结果。该函数聚焦在数据库查询逻辑上,执行 FindOne 发起查询,然后检查是否有错误,一切正常则把结果数据 Decode 出来。数据类型则不用关心,由上层调用者自行确定,然后通过 interface{} 类型的参数传进来接数据即可。
这样一来,上层函数可以这么写,请看升级版的 FindUserById2 :
| 1
2
3
4
5
6
7
 | func FindUserById2(ctx context.Context, coll *mongo.Collection, id primitive.ObjectID, opts ...*options.FindOneOptions) (*User, error) {
	var user User
	if err := FindOneById(ctx, coll, id, &user, opts...); err != nil {
		return nil, err
	}
	return &user, nil
}
 | 
 
由此一来,程序逻辑得到很好的拆分:
- FindOneById ,聚焦数据库查询逻辑,不关心数据类型;
- FindUserById2 ,聚焦数据类型,不关心数据库查询细节;
由于 FindUserById2 比上个版本 FindUserById 简化很多,因此也可有效避免潜在 BUG 。
然而对于任务表,我们还是需要把上面的 FindUserById2 给抄一遍:
| 1
2
3
4
5
6
7
 | func FindTaskById2(ctx context.Context, coll *mongo.Collection, id primitive.ObjectID, opts ...*options.FindOneOptions) (*Task, error) {
	var task Task
	if err := FindOneById(ctx, coll, id, &task, opts...); err != nil {
		return nil, err
	}
	return &task, nil
}
 | 
 
在 Golang 泛型出来之前,这种样板代码难以避免。
数据表类型定义
目前 interface{} 写法还有一个问题,就是函数都是很零散的,不成体系,调用时可能不太好找。为此,我们可以把数据表类型定义一下,再把这些数据库操作作为表类型的方法来组织。
我们先定义一个通用的 SmartCollection ,它可以对接各种数据表,不关心数据类型:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 | type SmartCollection mongo.Collection
// 转换成原生的mongo.Collection
func (coll *SmartCollection) Native() *mongo.Collection {
	return (*mongo.Collection)(coll)
}
// 根据查询条件filter查询(多个)数据
func (coll *SmartCollection) Find(ctx context.Context, filter bson.M, datas interface{}, opts ...*options.FindOptions) error {
	cursor, err := coll.Native().Find(ctx, filter, opts...)
	if err != nil {
		return err
	}
	return cursor.All(ctx, datas)
}
// 根据ID查(单个)数据
func (coll *SmartCollection) FindOneById(ctx context.Context, id primitive.ObjectID, data interface{}, opts ...*options.FindOneOptions) error {
	result := coll.Native().FindOne(ctx, bson.M{FieldNameCommonId: id}, opts...)
	if err := result.Err(); err != nil {
		return nil
	}
	return result.Decode(data)
}
 | 
 
注意到,SmartCollection 的方法都接收一个 interface{} 的参数来接数据(data/datas),不写死数据类型,因此可以用来查询各种数据表。
上层业务逻辑通常不直接调用 SmartCollection ,因为我们可以把具体的数据表类型和方法定义一下,由它们来调用 SmartCollection 。这样一方面可以隐藏一些实现细节,另一方面类型特化好后上层业务逻辑调用起来更加方便:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 | type UserCollection mongo.Collection
// 转换成原生的mongo.Collection
func (coll *UserCollection) Native() *mongo.Collection {
	return (*mongo.Collection)(coll)
}
// 转换成我们封装的SmartCollection
func (coll *UserCollection) Smart() *SmartCollection {
	return (*SmartCollection)(coll)
}
// 根据查询条件filter查询(多个)用户
func (coll *UserCollection) Find(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (Users, error) {
	var users Users
	if err := coll.Smart().Find(ctx, filter, &users, opts...); err != nil {
		return nil, err
	}
	return users, nil
}
// 根据ID查(单个)用户
func (coll *UserCollection) FindOneById(ctx context.Context, id primitive.ObjectID, opts ...*options.FindOneOptions) (*User, error) {
	var user User
	if err := coll.Smart().FindOneById(ctx, id, &user, opts...); err != nil {
		return nil, err
	}
	return &user, nil
}
 | 
 
同样,对于任务表 TaskCollection ,我们还是需要把 Find 和 FindOneById 等代码抄一遍。
泛型写法
Golang 泛型特性后,以上代码的重复性可以通过泛型函数来解决。我们实现一个泛型的 FindOneById2 函数:
| 1
2
3
4
 | func FindOneIById2[T any](ctx context.Context, coll *mongo.Collection, id primitive.ObjectID, opts ...*options.FindOneOptions) (data T, err error) {
	err = (*SmartCollection)(coll).FindOneById(ctx, id, &data, opts...)
	return
}
 | 
 
这个函数可以类型函数,特化为任意数据类型的版本。借助它,用户的具体业务数据表上的 FindOneById 方法可以进一步简化:
| 1
2
3
 | func (coll *UserCollection) FindOneById2(ctx context.Context, id primitive.ObjectID, opts ...*options.FindOneOptions) (*User, error) {
	return FindOneIById2[*User](ctx, coll.Native(), id, opts...)
}
 | 
 
可以看到,代码量由原来的 5 行,简化成目前的 1 行。问题虽然得到进一步缓解,但并未解决,任务等其他业务数据表还得把  FindOneById2 等代码都抄一遍。为了进一步简化,我们需要定义一种泛型化的数据表:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
 | type GenericCollection[Docs ~[]DocPtr, DocPtr ~*Doc, Doc any] mongo.Collection
func (coll *GenericCollection[Docs, DocPtr, Doc]) Native() *mongo.Collection {
	return (*mongo.Collection)(coll)
}
func (coll *GenericCollection[Docs, DocPtr, Doc]) Smart() *SmartCollection {
	return (*SmartCollection)(coll)
}
func (coll *GenericCollection[Docs, DocPtr, Doc]) Find(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (Docs, error) {
	return Find[Docs](ctx, coll.Native(), filter, opts...)
}
func (coll *GenericCollection[Docs, DocPtr, Doc]) FindOneById(ctx context.Context, id primitive.ObjectID, opts ...*options.FindOneOptions) (DocPtr, error) {
	return FindOneIById2[DocPtr](ctx, coll.Native(), id, opts...)
}
 | 
 
这里我们定义了 GenericCollection ,它需要通过类型参数指定对应的文档(数据记录)类型 Doc ,指针类型 DocPtr ,和数据列表类型 Docs 。因此,可以用来处理任意数据类型。
这样一来,任意业务数据表,只要实现 Generic 方法把表类型转成对应类型的 GenericCollection 类型,即可直接复用这些通用的基础操作,完全不需要任何额外代码:
| 1
2
3
 | func (coll *UserCollection) Generic() *GenericCollection[Users, *User, User] {
	return (*GenericCollection[Users, *User, User])(coll)
}
 | 
 
有了 GenericCollection 的加持,UserCollection 便自动拥有 FindOneById 的功能:
| 1
2
 | var userColl UserCollection = xxxx
user, err := userColl.Generic().FindOneById(nil, someId)
 | 
 
完整代码
【小菜学Go语言】系列文章首发于公众号【小菜学编程】,敬请关注:
