本文讨论如何在 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语言】系列文章首发于公众号【小菜学编程】,敬请关注: