利用Golang泛型特性优化MongoDB操作封装

本文讨论如何在 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

	// 其他字段...
}
  • IdMongoDB 主键;
  • 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

	// 其他字段...
}
  • IdMongoDB 主键;
  • 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 ,我们还是需要把 FindFindOneById 等代码抄一遍。

泛型写法

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

【小菜学Go语言】系列文章首发于公众号【小菜学编程】,敬请关注: