以下文章来源于小爱灵动 ,作者谢顿
深圳小爱灵动(Ai Gamify)网络技术有限公司是游戏化营销解决方案服务商,长期致力于游戏化赋能全行业,让品牌增长简单有趣。
MongoDB 是一个存储文档的非关系型数据库。
数据库、集合和文档
开始使用 MongoDB 之前有必要先搞清楚几个概念,数据库、集合和文档。
它们之间的关系是这样的,一个 MongoDB 服务器可以有多个数据库,一个数据库下可以有多个集合,一个集合下有多个文档。
集合和文档分别对应传统关系型数据库里的表(table)和行(row)。
下面例子就是一个文档,看着是不是和 JSON 很像。
{
"_id" : ObjectId("62a7fd9fbd3e6ff14d853d19"),
"title" : "MongoDB 入门指南",
"content": "学习 MongoDB 基本概念",
"comments" : [
{
"content" : "评论1",
"author" : "name1",
"vote" : 1
},
{
"content" : "评论2",
"author" : "name2",
"vote" : 2
},
]
}
MongoDB 里的文档是 BSON 格式,所谓的 BSON,实际上是 JSON 的超集(好比如 TypeScript 是 JavaScript 的超集一样)它和 JSON 相比能保存更多的格式,例如Date、二进制数据。一个文档可以随意增加或删除它的字段,并且同一集合下的文档也可以互不相同,这就给了开发者很大的灵活性,这也是 MongoDB 的一个优势。
ObjectId 和 _id
刚开始接触 MongoDB 时,一般都会对ObjectId 和 _id 产生些许疑惑。并且这 2 个概念也是项目里面经常需要用到的,因此有必要事先搞清楚它们。
集合中的每个文档都必须要有一个 _id 字段,用来识别每个文档的唯一性。_id 的值可以是任意类型,但默认为 ObjectId 类型。
在 MongoDB 里面可以手动生成一个 ObjectId 类型的值。
// 事实上 ObjectId 是一个类,从 bson 这个模块导出来的
import { ObjectId } from 'mongodb'
// 不带参数
const objectId = new ObjectId() // ObjectId("62a6df547c2aa16d4c0d6553")
// 带参数
const objectId = new ObjectId("62a6df547c2aa16d4c0d6553") // // ObjectId("62a6df547c2aa16d4c0d6553")
// 把 ObjectId 类型转成字符串
new ObjectId().toString() // 62a6df547c2aa16d4c0d6553
做项目的时候,经常需要把 ObjectId 类型转成字符串返回给客户端;同样的客户端传一个字符串到服务端,需要先转成 ObjectId 类型再到数据库里匹配查找。
这显得有点麻烦,不过好在使用 TSRPC 框架不用担心这个问题,定义协议时可以直接使用 ObjectId 类型,后面讲到项目实战时会说明这一点。
另外, ObjectId 中会包含文档创建时的时间戳。
MongoDB Shell 和 MongoDB 驱动
我们安装 MongoDB 时会附带一个 MongoDb Shell,就好比如安装 Node ,会附带一个 REPL(交互式解释器)。在 MongoDB Shell 里面可以执行数据库相关的操作,但是如果我们要在应用程序里面使用 MongoDB 的话,需要安装对应的驱动。下面这些语言都有对应的驱动程序。
不管是 Shell 还是驱动,其实它们的内部都是调 MongoDB 服务完成对数据的操作。
CRUD操作
不管我们使用的是关系型数据库如 MySQL,还是非关系型数据库 MongoDB,都离不开对数据的增删查改这 4 个基本的操作。
下面分别来介绍这 4 种操作。
对比 localStorage 的增查删
其实 MongoDB 里面的增删改查操作和浏览器里面的 localStorage,操作大致上是类似的,只不过存数据的地方不一样,还有就是 MongoDB 提供的方法更多一些且更灵活一些。
// 增
localStorage.setItem('name', 'xiedun');
// 查
localStorage.getItem('name')
// 删
localStorage.removeItem('name')
Create 创建文档
往集合中插入文档
insertOne()
insertOne 方法往集合里插入一个文档
db.user.insertOne({ name: 'xiedun' })
在插入时,可以自己指定 _id 的值,如果不传,系统默认会帮我们生成一个 ObjectId 类型的值。有一点要注意的是,如果自己指定 _id的值,那么要确保在同一个集合里面不会重复,否则会抛出一个 WriteError 。
db.user.insertOne({ _id: 111, name: 'xiedun' })
insertMany()
当有多个文档要一起插入时,可以使用 insertMany 这个方法。使用该方法有 2 点需要注意,一就是,插入的文档最大为 48M 的数据,如果超过的话,驱动程序会自己进行切分再插入。
db.user.insertMany([
{name: 'name1'},
{name: 'name2'},
{name: 'name3'},
{name: 'name4'},
{name: 'name5'},
])
第二点是该方法的第二个参数 ordered ,表示插入时文档是否按照顺序插入,默认为 true,即按照顺序插入。在该值下插入某个文档遇到错误时,后面的文档将不会继续插入。如果值为 false,则只是当前错误的文档不会插入,其它文档会以乱序的方式插入。
db.user.insertMany([
{_id: 1, name: 'name1'},
{_id: 2, name: 'name2'},
{_id: 3, name: 'name3'},
{_id: 3, name: 'name4'},
{_id: 5, name: 'name5'},
], { ordered: false })
Delete 删除文档
删除集合中的文档
deleteOne()
该方法删除集合中的一个文档,当有多个文档满足条件时,它会删除满足条件的第一个文档。
db.user.deleteOne({_id: 1})
deleteMany()
该方法与 deleteOne 不一样的是,它会删除满足条件的所有文档。
db.user.deleteMany({type: '1'})
当我们需要删除一个集合下的所有文档时,可以使用 deleteMany 方法,传入一个空的参数 db.user.deleteMany({}),除此之外还有另外一个更快的方法:db.user.drop()
Update 更新文档
我们知道了如何创建文档,但如果要更新一个文档,要怎么做?
updateOne()
更新运算符 $set $inc $push
此方法主要需要传 2 个参数,第一个是过滤文档,找出需要更改的文档;第二个参数是更新运算符,例如:$set $inc $push
db.user.updateOne(
{ name: 'xiedun' },
{ $set: {name: '谢顿'} }
)
我们可以在第三个参数传一些选项,例如 upsert 表示如果找不到该文档,则会创建它。
db.user.updateOne(
{name: 'xiedun1'},
{$set: {name: '谢顿1'}},
{upsert: true}
)
更新文档,还有一些深入的操作,例如针对下面这个文档,我们想要更新某一条评论的投票数。
{
"_id" : ObjectId("62a710c1bd3e6ff14d853d17"),
"title" : "mongodb学习",
"comments" : [
{
"content" : "评论1",
"author" : "name1",
"vote" : 1
},
{
"content" : "评论2",
"author" : "name2",
"vote" : 3
},
{
"content" : "评论3",
"author" : "name3",
"vote" : 3
},
{
"content" : "评论4",
"author" : "name4",
"vote" : 4
},
{
"content" : "评论5",
"author" : "name5",
"vote" : 5
}
]
}
如果我们明确知道这条评论所在的位置,可以这样做。
db.posts.updateOne(
{_id: ObjectId("62a710c1bd3e6ff14d853d17")},
{$inc: {"comments.0.vote": 10}}
)
这种做法的一个不好之处是,当文档有很多,我们无法明确知道是第几个时,就无法更新了。
定位运算符 $
使用定位运算符可以匹配出查询文档所在确切的位置。
db.posts.updateOne(
{"comments.author": 'name2'},
{$inc: {"comments.$.vote": 1}}
)
需要注意定位运算符只会更改匹配到的第一个文档,即使使用我们下面要介绍的 updateMany() 方法也是一样的效果。对于要更新多个文档的,我们可以使用下面的数组过滤器。
数组过滤器 arrayFilters
在这里,我们将投票数大于等于 3 的评论增加一个 hidden 字段。注意 elem 是满足匹配的一个标识符,名称可以随我们定义。
db.posts.updateOne(
{_id: ObjectId("62a7fd9fbd3e6ff14d853d19")},
{$set: {"comments.$[elem].hidden": true}},
{arrayFilters: [
{"elem.vote": {$gte: 3}},
]
)
updateMany()
该方法与 updateOne 不一样的是,它会更新满足条件的所有文档。
findOneAndUpdate()
平时在开发的时候,会遇到这样一种需求:想要返回修改过的文档。findOneAndUpdate 方法集成了查找、修改和返回修改过的文档,并且它还是原子性的,也就是说,多个用户修改同一个文档,不会产生并发问题,因为它内部会进行加锁,等当前用户操作完,再到下一个用户。
Read 查询文档
findOne()
对于查询,我们可以在第一个参数传入查询文档
db.user.findOne({name: 'xiedun'})
find()
当要查询多个文档时,可以使用 find() 方法,传的参数和 findOne() 是一样的。
一般在查询的时候,都不会只是匹配某个字段就行了。因此 MongoDB 里面提供了很多的查询操作符,可以让我们执行复杂的查询。
例如:
// 查询年龄大于 18 岁的用户
db.user.find({age: {$gt: 18}})
这里的 $gt 就是一个查询操作符。
看下面的这些文档:
{
"_id" : ObjectId("62ac1a89bd3e6ff14d853d25"),
"name" : "name5",
"status" : "A"
},
{
"_id" : ObjectId("62ac1a89bd3e6ff14d853d24"),
"name" : "name4",
"status" : "B"
},
{
"_id" : ObjectId("62ac1a89bd3e6ff14d853d23"),
"name" : "name3",
"status" : "C"
},
{
"_id" : ObjectId("62ac1a89bd3e6ff14d853d22"),
"name" : "name2",
"status" : "D"
},
{
"_id" : ObjectId("62ac1a89bd3e6ff14d853d21"),
"name" : "name1",
"status" : "E"
}
想要查询 status 是 A 或者是 B 的文档,我们可以使用 $in 操作符
db.User.find({status: {$in: ['A', 'B']}})
// {
// "_id" : ObjectId("62ac1a89bd3e6ff14d853d22"),
// "name" : "name2",
// "status" : "B"
// },
// {
// "_id" : ObjectId("62ac1a89bd3e6ff14d853d21"),
// "name" : "name1",
// "status" : "A"
// }
相反的,如果要查询 status 不包含 A 或 B,可以使用 $nin 操作符
db.User.find({status: {$nin: ['A', 'B']}})
// {
// "_id" : ObjectId("62ac1a89bd3e6ff14d853d23"),
// "name" : "name3",
// "status" : "C"
// },
// {
// "_id" : ObjectId("62ac1a89bd3e6ff14d853d22"),
// "name" : "name2",
// "status" : "D"
// },
// {
// "_id" : ObjectId("62ac1a89bd3e6ff14d853d21"),
// "name" : "name1",
// "status" : "E"
// }
官方文档里总结了所有的查询操作符,没必要都背下来,可以对它们有个大致的了解,在使用过程中如果忘记了再回来查阅就好了。
这里要介绍一下什么是 游标 ,以及游标上的一些方法。
使用 find() 方法执行查询,它会返回一个结果,这个结果就是一个游标。游标可以让我们控制结果的输出,以及对结果做一些操作,例如限制数量,跳过一些结果和进行排序等操作。
next() hasNext() toArray()
var cursor = db.user.find({})
document = cursor.next()
// {
// "_id" : ObjectId("62ac1a89bd3e6ff14d853d23"),
// "name" : "name1"
// }
也可以调用 hasNext() 方法判断是否还有文档
var cursor = db.user.find({})
cursor.hasNext() // true or false
一次返回一个结果在某些场景是挺有用的,但我们更多是希望以数组的形式一次返回所有结果,因此可以调用 toArray() 方法。
db.user.find({}).toArray()
不过要注意在 shell 里面调用和在驱动里面调用会有些差别,在驱动里面调用返回的是一个 promise。
const cursor = db.user.find({})
await cursor.hasNext()
await cursor.next()
其实我们平时在项目里就己经用到了游标方法,只不过是使用了链式调用,没有分步执行。因为每个游标方法都是返回了一个 promise
await Global.collection("test").find().sort({createTime:-1}).toArray()
sort() limit() skip() projection()
sort() 方法很常用,例如想要根据创建时间返回查询结果:
// 1 是正序,-1 是倒序
db.user.find({}).sort({ createTime: 1 })
如果没有创建时间字段,或者没有其它字段可以用来排序的。我之前看到有些小伙伴是对返回的结果逆转一下顺序db.user.find({}).toArray().reverse()。其实可以使用 sort 方法,然后根据 _id 来排序。
db.user.find({}).sort({ _id: -1 }).toArray()
limit() 方法和 skip() 一般是配合来使用在分页的场景
// 这里表示跳过前面 20 条数据,查询后面的 20 条
db.user.find({}).skip(20).limit(20)
.projection() 这个方法可以让我们决定返回一个文档的哪些字段,以及哪些字段不返回。利用这个方法,我们还可以做到把 _id 不返回。
// 1 表示返回,0 表示不返回
db.user.find({}).projection({age: 0})
关于游标需要注意的是,只有 find() 方法才会返回游标,像其它方法,例如:findOne(),是不会返回游标的,因为它直接返回查询到的文档。因此它们也无法使用游标上的方法。
这里附上官方文档总结的所有游标上的方法:
总结
MongoDB 涉及到的概念和方法非常的多,我们这次的分享也只是讲到了很小的一部分而已。我自己的一个看法是可以先过一遍官方文档,有个印象,然后在做的过程中逐步加深对某个概念的理解。
链接
MongoDB 官方文档
MongoDB 驱动
TSRPC 框架
查询操作符