前言

Gin是一个很常用的Go语言Web框架,总的来说它是一个轻量级的框架,上手比较简易。
然而,Gin的使用文档实在是有一些简陋,在实际的开发中很多时候不能根据文档得到需要的答案。
在文档的简陋性上,MongoDB的Go语言驱动实在是有过之而无不及,且由于其推出的时间相对较晚,所以教材资源相对不太充足,也不够细致。网上许多的教程都是基于mgo包的,该包作为第三方的资源,虽然与官方驱动在很多地方都很相似,但还是有许多不相同之处。
此前,笔者恰好在写关于Gin+MongoDB的相关内容,因此在这里做一些关于数据结构的总结。
笔者的gin与MondoDB驱动版本分别为1.7.1与1.8.1,其获取方法如下:

go get -u github.com/gin-gonic/gin
go get -u go.mongodb.org/mongo-driver

如果下载被卡,就换一个代理,网上文章已有教程,此处不多赘述。

声明

为什么要特别拿数据结构出来说?这是由于MongoDB的Go驱动封装非常底层,也就是说其使用要严格依赖于bson,所以想要用好这个工具,必须要知道相关数据结构的内容。同时,Gin是一个Web框架,这自然不能避免参数获取与转换等等问题。将两者结合起来就需要对数据结构的了解以及转换方式非常的清晰。
由于笔者对mongo的接触并不深入,本文不包含太多针对mongo的使用与设计,而只是对Gin与MongoDB驱动结合的一些经验与技巧进行分享。
由于笔者是第一次使用上述两者,如果不妥指出,望不吝指出,以助改正。


一、关于MongoDB驱动的数据结构问题

接触了mongo自然也无法离开bson的使用。在MongoDB中,常常能见到各种大括号以及嵌套的大括号,笼统来说那就是一种bson结构,如:

{"hello":"world"}

bson的相关内容在mongo-drive/bson与mongo-drive/bson/primitive中有所记录(后者定义了大量的关于MongoDB的数据类型)。下面笔者将分析较为重要的数据结构。

1. bson.M

简单来说,bson.M其实就是常说的“键值对”。查阅源码,可以得知其结构如下:

type M = primitive.M //bson.go
type M map[string]interface{}  //primitive.go

使用go的朋友们一定很熟悉,这就是常用的以字符串为键,任意类型为值的一个hash数组。在bson.go文件中,M被定义成了primitive中的M。这种结构与mongo使用的结构是一致的,假如我要查询某个"name"为"Jack"的人,那么我就可以构造一个filter:

filter := bson.M{"name":"Jack"}

笔者之所以首先提及这个,是因为该结构十分常用且直观,并容易理解。但显然,一种数据结构是不能适用在所有场合的。

2. bson.E

从go的数据结构去讲,bson.E实际上就是结构体。查阅源码,可以得知其结构如下:

type E = primitive.E //bson.go
type E struct { //primitive.go
	Key   string
	Value interface{}
}

看到这个数据结构,相信有读者一定在想“哎呀这不就是变相的键值对吗,为啥要搞得这么复杂”。然而,这个结构的设计确实是有它的作用的,详细的内容见下文。

3. bson.A

bson.A就是空接口组成的数组。查阅源码,可以得知其结构如下:

type A = primitive.A //bson.go
type A []interface{} //primitive.go

这种结构比较简单,但基本只是在特殊情况下有用处,相对其它的还是较少,故不多提。

4. bson.D

bson.D是bson.E的切片。查阅源码,可以得知其结构如下:

type D = primitive.D //bson.go
type D []E //primitive.go

D代表的是document,其实也就是指mongo中的一个文档的结构,而E则是element,即文档中一项的结构。

5.ObjectId

使用过mongo的朋友一定对这个不陌生,ObjectId是插入文档时mongo自生成的一个随机id,可以标识一个文档。这个数据结构自然也囊括在驱动中了,收入在primitive下,使用primitive.ObjectId即可找到(mgo是收入在bson下的,此处结构不同)。查阅源码,其结构如下:

type ObjectID [12]byte

在go中,该数据类型是一个12长度的字节数组。

二、关于Gin的数据结构问题

gin.Context(以下简称c)提供的各种参数绑定方法确实是非常好用的,也十分常用,但这仍然不可以避免一些遇到一些特殊的、不适合使用绑定的情况,如路径参数的问题。虽然路径参数一般不会很多,可以使用c.Param方法获取,但这样做其实无疑是降低了代码的可扩展性。因此,分析相关的数据结构也是很有必要的。

1.URL参数

遇到url参数,当然首选采用ShouldBind方法进行绑定。如果这个方法不能解决,那一般情况下也是采用c.Query或者c.DefaultQuery等来解决。但使用这两个方法的前提是使用者必须得知参数的名字,一旦出现参数名称有可能会变化又或者是参数内容过多的时候,这种方式就不好使了。
因此,在特殊情况下,c.Request.URL.Query()方法就是一个很好的东西。这个方法的返回值是url.Values,这是个什么东西呢?查阅源码可以得知它的结构:

type Values map[string][]string

显然,这个Values是一个以字符串为键,字符串数组为值的hash数组。也就是说,获取到的参数其实已经还原了json本身的结构。

2.表单参数

虽然这么说很啰嗦,表单参数其实还是首选ShouldBind,次选同样也是c.Form等通过参数名获取。而在特殊情况下,可以采用c.Request.Form获取或c.Request.PostForm获取参数。在使用这两者前,必须输入以下内容:

c.Request.ParseForm()

这个东西到底是干啥的呢?我们不妨看看源码的注释:

// ParseForm populates r.Form and r.PostForm.
//
// For all requests, ParseForm parses the raw query from the URL and updates
// r.Form.
//
// For POST, PUT, and PATCH requests, it also reads the request body, parses it
// as a form and puts the results into both r.PostForm and r.Form. Request body
// parameters take precedence over URL query string values in r.Form.

简单来说,调用该方法就是获取前端传输的参数的。而这些参数是包含了URL参数与表单参数,前者的值会被加入到c.Form里。换而言之,如果只是想获取表单参数,就使用PostForm,否则是Form。这两者的定义如下:

PostForm url.Values
Form url.Values

也就是说,两者的结构都是hash数组,与上一小节的一致。

3.路径参数

Gin的文档里会提到使用c.Param方法根据参数名获取,但细心的朋友们应该会发现,其实还存在一个叫做c.Params的东西。这又是个什么东西呢?其实,仔细翻阅一下文档,就会发现有描述了。在源码里,c.Params是这样的:

type Param struct {
	Key   string
	Value string
}
type Params []Param

也就是说,Param是一个键值对结构体,而Params就是Param的一个切片。

三、Gin与MongoDB驱动的结合

让大家看笔者吹了这么长一段,实在是抱歉。但以上内容都是为此处做铺垫的,所以不提不行。那么,接下来开始讲述两者数据结构的相互转换与使用。

1.bson.D与c.Params

相信大家已经注意到了,这两者在go里其实是使用着一样的数据结构的。那么,假如当前有一个路由r,它拥有路径参数,其结构如下:

r.GET("/:name/:pwd",func (c *gin.Context){})

而发送过来的数据是长这个样子的(此处使用json以便明确地表示结构,下同):

{
"name":"Jack",
"pwd":"123456"
}

当请求发送到该路径下时,使用c.Params获取到的内容就会是这个样子的:

params := {{"name","Jack"},{"pwd","123456"}}

最外层的括号就是数组,而内层的括号则是结构体。这个参数同样也是bson.D,换言之这是可以直接丢到驱动里使用的。假如我现在想要插入某一条记录,那么,代码就可以是这个样子的:

collection.InsertOne(context.TODO(), params)

这样就可以灵活地使用路径参数进行数据库操作,而避免臃肿的代码了。当然,前面提到的bson这么多的结构中,bson.M也是能使用的,但这就需要将参数转换成map才能用了。如果读者不太喜欢bson.D的结构(例如我自己)又或者有强迫症的(也是我自己)不想看到,那就需要换成bson.M,也就是map,来处理。然而比较可惜的是,这两者之间没有可以直接转换的方法,笔者也只是采用循环的方式来构造。希望对此有了解的朋友能够留言以告知我。

2.bson.M与URL参数和表单参数

之所以这里把两种参数混在一起讲,是因为他们在数据结构上没有本质上的区别,都是map,而由于bson.M是一个值为空接口的map,所以在两者的结合中可以变相地认为两者在go中是一样的数据结构。
假如当前有路由r有这样的情况:

r.GET("/",func (c *gin.Context){})
r.POST("/",func (c *gin.Context){})

而传输的数据则继续使用上文的。由于json的本质其实也是键值对,所以传输过来后其实没有什么变化。假如现在还是想插入某一条数据,那么,代码可以是这个样子的:

collection.InsertOne(context.TODO(), params)

大家可以看到,其实这与上一小节bson.D的使用别无二致的,代码的书写就统一起来了,而且非常的简洁。
在这里笔者要多提一句,并不是说不能全部用bson.M做所有的内容,而是说要意识到Params这种结构体数组其实是不需要做额外的转换就可以使用的。

3.MongoDB与结构体

前面笔者也提到过,相信接触过gin的朋友们也知道,ShouldBind这个方法是非常好用的,可以直接将参数绑定到结构体里面,无须过多的操作。但看到笔者在上面讲了这么久都没有说到结构体的使用,是不是认为结构体不能够直接参与到MongoDB的使用里面来呢?
其实并不是这样的。这里要讲一下Go结构体的一些特别之处。结构体是C语言就已经存在的一个东西。本身这两门语言就有着一样的作者,内容也非常的相似(笔者是C的爱好者,所以对与Go也有着亲切感)。但在两门语言中,结构体是有不同之处的,其中最显著的,就是Go语言可以通过指定键来设置值。
下面我们来举个例子,假如有个叫做User的结构体,那么在C里面的定义是这个样子的:

typedef struct User{
	char name[128];
	char pwd[20];
}User;

在Go里面的定义是这个样子的:

type User struct{
	Name string
	Pwd string
}

虽然大家应该知道,但还是提一句,这里大小写的不同是因为C结构体的成员本身就是public的,但是Go是依靠首字母大小写来判断该成员是否公开的。还有一点,就是string和char数组并不等价,应当说byte数组和char数组才是等价的。但这里就不纠结这个问题了,因为并不影响说明。
在C里面,可以采用这样的赋值:

User user={"Jack","123456"}

相信大家很熟悉,就不多说了。在Go里,赋值是这个样子的:

user := User{"Jack","123456"}

在Go里,其实更常用的是这样的赋值方式:

user := User{Name:"Jack", Pwd:"123456"}

从这里开始,C与Go结构体的区别就体现出来了,因为,Go还可以这样写:

user := User{Name:"Jack"}
//或者是
user := User{Pwd:"123456"}

这样做的结果就是只有一个成员被赋值了,而C是不能够支持这样的功能的。(虽然gnu的编译器可以支持.成员名=XXX这样的方式赋值,但这不应该被提及到正常的标准中)
为什么要特意提这个问题?大家有没有留意到,这种结构体的赋值方式,其实与写键值对是无异的。
那么,让我们再来留意一下InsertOne方法的第二个参数,其参数名为document,类型是interface{}。也就是说事实上这个参数是可以接受任意类型的。当然,这是理论上的情况,如果出现了不支持的类型其实就会panic了。
那么我们再次回到开始的话题——结构体是不是不能够在MongoDB的驱动中派上用场?答案自然是“不”。虽然说驱动的一系列方法给出的参数都没有提及允许使用结构体,但其实结构体也是可以充当bson的。也就是说,在使用参数绑定获取参数的时候,结构体其实是可以直接当做document丢到相关的方法里面去的。
此处以上一小节的GET为例,如果采用参数绑定,那么代码应当是这样的:

user := new(User)
c.ShouldBind(user)
collection.InsertOne(context.TODO(), user)

当然如果有的朋友想要把结构体搞成bson再处理也不是不行的。bson提供了Marshal与UnMarshal两个方法,前者可以将结构体改成bson编码文档,后者就将文档改成了bson.M。网上已有其它关于这点的赘述,此处不再重复。

4.bson.A与数组参数

bson.A这个结构看上去就比较的鸡肋,总感觉这种空接口的切片好像没啥可以用到的地方。如果前端传入的参数是数组,事实上那也是非常少数的情况,开发者完全是可以特殊处理,使用参数名与c.FormArray或者c.QueryArray这样的方法来获取。那么,下面笔者就来举一个例子。
假如前端需要传来的参数是这个样子的:

{
"ids":[1,2,3]
}

在go中,开发者写了一个结构体用来获取,是这个样子的:

type ID struct {
	Ids []int `json:"ids" form:"ids" bson:"ids"`
}

使用常规的ShouldBind方法自然是可以把数据给绑定进去的,但如果有特殊情况——没有传这个参数呢?对于结构体来说,其实就是Ids成为了一个空的切片。将这个结构体的内容写入数据库的时候,ids一项对应的值就会变成null。
总的来说这其实是一个不太美观的情况,所以遇到这种情况可以采用tag加入omitempty的方法,即:

type ID struct {
	Ids []int `json:"ids,omitempty" form:"ids,omitempty" bson:"ids"`
}

这样如果该项是空,那么就不会有键被加入到数据库里。
那么,如果我有特殊要求,数据库里面要写入一个空的数组呢?这时候,bson.A的用处就出现了。如果没有获取到参数,那么写入的内容就设置为bson.A,就可以完成填入空数组的功能了。

5.关于ObjectId的一些事

ObjectId是一个比较神奇的数据结构,它没有蕴含有隐式转换(指的是string到ObjectId)。前端交付过来的id是字符串形式的,如果用于参数绑定的结构体中id一项是string,那可以正常获取,但如果是ObjectId类型,就会变成一个全0的字节数组。同样的,如果结构体里面id是ObjectId,前端传入的id就无法被绑定到结构体上。
但从MongoDB中获取内容并绑定到结构体里的时候,这一点却不成问题了。这是由于本身字节数组(假设叫做b)就可以通过string(b)直接转换到string,所以ObjectId类型就被直接装载到了string之中。
这实在是一个难办的地方。primitive提供了方法叫做ObjectIdFromHex,这个方法的参数是一个字符串,返回值是一个ObjectId以及error,如果转换失败(失败的理由可能是长度不对、结构不同等)error就会有内容。这个错误在开发中是一定要处理的,因为如果不处理ObjectId就会成为全0值。
这种特殊的情况意味着这个属性是无法在参数绑定中很好的使用。因此,如果出现要通过ObjectId来查数据的时候,前端传入的id只能够通过上述方法转换了。即:

id,_ := primitive.ObjectIdFromHex(c.Query("id")) //这里没有写错误处理,不要学我
filter := bson.M{"_id":id}

但这样的代码实在是不美观。

此外,还有一种特别的情况,则是采用参数绑定时出现的问题。如果开发者有意将ObjectId作为主键,希望其能够随机生成的,就要注意了。这个时候,前端传来的数据一般是没有id的。
假如,用于绑定的结构体是长这个样子的:

type User struct {
	Id string `json:"id" form:"id" bson:"id"`
	XXXXX
	XXXXX
}

这里可能有人会好奇既然不传入id,为什么结构体还要有。这是因为User不仅仅是用于前端参数传入和绑定的,从数据库读取数据并返回的时候,也是需要用到这个结构体的。所以这个属性在某些情况下是不可或缺的。
那么,言归正传,如果前端没有传入id,那么答案就显而易见了——一个空字符串会被写入数据库里。这显然是不被希望的。因此,要避免这个问题,就要在tag里写上omitempty。

总结

本文主要是基于Gin与MongoDB的数据结构做出了一些分析。内容较浅,还没有过多地涉及到使用的部分。
其实,在两者结合的使用上,笔者踩了许多的坑。如果有机会,日后再和大家分享一些相关的经验。希望笔者的文章能够抛砖引玉,通过数据结构的分析给予相关的开发者一些使用的思路。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐