返回介绍

5.3 用 Ruby 来操作 MongoDB

发布于 2023-05-19 13:36:37 字数 20419 浏览 0 评论 0 收藏 0

关系型数据库为了保持其基本的 ACID 原则(原子性、一致性、隔离性、持久性),需要以付出种种开销作为代价。而相对地,MongoDB 这样的面向文档数据库由于可以突破这一局限,因此工作起来显得比较轻快。

MongoDB 具有下列这些主要特点:

· 以 JSON(JavaScript Object Notation)格式保存数据

· 不需要结构定义

· 支持分布式环境

· 乐观的事务机制

· 通过 JavaScript 进行操作

· 支持从多种语言进行访问

MongoDB 最重要的特点就是不需要结构定义。很少有应用程序在开发之前就能确定数据库中需要保存的数据项目。由于开发过程中的疏漏,或者是需求的变化等,经常导致数据库结构在开发中发生改变。在关系型数据库(RDB)中,遇到这种情况每次都需要重做数据库。Ruby on Rails 中可以通过 migration 方法对 RDB 结构迁移提供支持,但即便如此,这个过程依然相当麻烦。

而 MongoDB 本来就没有结构定义,即便数据库中保存的项目发生变化,只要程序做出应对就可以了。当然,已经存在的数据中不包含新增的项目,但要做出应对也很容易。

使用 Ruby 驱动

MongoDB 的另一个特点,就是可以由多种语言进行访问。为各种语言访问 MongoDB 所提供的库被称为驱动(driver)。MongoDB 分别为 JavaScript、C++、C#、Java、JVM 语言、Perl、PHP、Python 和 Ruby 提供了相应的驱动。

MongoDB 的服务器中集成了 JavaScript 解释器,因此回调等服务器端的处理只能用 JavaScript 来编写。不过,因为有了支持各种语言的驱动,客户端(除了发送给服务器的程序以外)则可以用自己喜欢的语言来编写。

在 5.2 中我们使用 mongo 命令访问数据库,并使用 JavaScript 对数据库进行操作,不过如果可以用我们所习惯的 Ruby 来操作数据库就好了。RubyGems 中提供了相应的 Ruby 驱动,使用 gem 命令就可以轻松完成安装(以下命令均以 Debian 为例):

% sudo gem install mongo
此外,最好也一并安装用于加速访问的 C 语言库1,通过这个库可以提升与 MongoDB 服务器通信需要用到的“二进制 JSON”(BSON)的处理速度。

1 由于 mongo_ext 是二进制 gems,因此需要另外安装开发环境(编译器以及编译用的库文件)。(原书注)

% sudo gem install mongo_ext
要使用 MongoDB 的 Ruby 驱动,需要在程序中对 mongo 库进行 require。此外,在 Ruby 中还需要在 mongo 库之前对 rubygems 库进行 require。

require 'rubygems'
require 'mongo'
好了,我们来尝试访问一下 5.2 中创建的数据库服务器吧。首先我们需要创建一个表示服务器连接的 Mongo::Connection 对象。

m = Mongo::Connection.new
=> #<Mongo::Connection>
在后面的程序示例中,“=>”后面的部分表示表达式的求值结果。返回值的表示是以 irb 为基准的,但由于版面所限进行了大量的省略。此外,相当于 ID 的数值(包括数字位数在内)也会和实际情况有所不同。Mongo::Connection.new 可以带两个可选参数:第一个是主机名,第二 个是端口号。这相当于 mongo 命令中的“--host”和“--port”参数。

通过这个连接,我们来尝试获取服务器所管理的数据库清单。

m.database_names
=> ["local", "admin", "test"]
要删除一个数据库,可以对数据库连接对象调用 drop_database 方法。

m.drop_database('test')
=> {"dropped"=>"test.$cmd",
    "ok"=>1.0}

对数据库进行操作

对数据库连接调用 db 方法可以获得一个数据库对象,但在创建数据库对象的时间点上,实际上还并没有真正创建数据库。

db = m.db("nikkei_linux")
=> #<Mongo::DB>
m.database_names
=> ["local", "admin", "test"]
通过调用数据库对象的 collection 方法,可以获取相应的集合(相当于关系型数据库中的表)。如果要获取的集合不存在,则会创建一个新的集合。但是,和数据库一样,实际的集合也是要等到真正插入数据的时候才会被创建。

coll = db.collection("articles")
=> #<Mongo::Collection>
db.collection_names
=> []

数据的插入

使用 insert 方法或者 save 方法可以向集合中插入数据。

coll.insert({
  :title => "技术的剖析",
  :author => "matz"})
=> ObjectID('4bbf93')
当插入数据时,数据库和集合才会真正被创建出来。

m.database_names
=> ["local", "admin", "nikkei_linux"]
db.collection_names
=> ["articles", "system.indexes"]
这样,我们就创建了 nikkei_linux 数据库和 articles 集合。system.indexes 集合是 MongoDB 用于查询索引的集合。

数据的查询

当然,数据不光要能够插入,还要能够取出。当需要仅取出一个文档时,可以使用 find_one 方法。

coll.find_one()
=> {"_id"=>ObjectID('4bbf93'),
    "title"=>"技术的剖析",
    "author"=>"matz"}
这里我们没有指定查询条件,因为这个集合里面本来就只有一个文档,所以用这条语句便 取出了这个唯一的文档。

我们再来尝试一下从更多的数据中进行查询吧。首先,我们用 insert 对数据库填充一定量的数据。

coll = db.collection("bench")
1000000.times {|i|
 coll.insert({:x => 4, :j => i})
}=
> 1000000
这样我们就在 bench 集合中插入了 100 万个文档。下面我们来查询一下看看。

coll.find_one({:j => 999999})
 => {"_id"=>ObjectID('4bbf93'),
     "x"=>4, "j"=>999999}
在我的电脑上查询到这个结果用了差不多 1 秒的时间。因为要将 100 万个文档全部查询一遍,所以这个速度不是很快,于是我们来创建一个索引。

coll.create_index("j")
=> "j_1"
这样我们就对 j 这个成员创建了一个索引。再查询一次试试看,瞬间就可以得到结果,索引的效果是非常明显的。

如果用 find 方法代替 find_one 方法的话,就可以得到一个指向所有符合条件的文档的“游标”(cursor)对象。

coll.find({:j => 999999})
=> #<Mongo::Cursor>

高级查询

刚才我们所进行的查询,都是像“集合中所有文档”或者“字段满足一定条件的文档”等简单的情况,但实际的查询并非都如此简单。在关系型数据库中,可以使用 SQL 来指定条件进行查询,MongoDB 当然也可以。例如,SQL 查询:

SELECT * FROM bench WHERE x = 4
这样的查询条件,用 Ruby 可以写成:

coll.find({:x => 4})
=> #<Mongo::Cursor>
也可以进行大小比较,如 SQL 查询:

SELECT j FROM bench WHERE x >= 4
用 Ruby 可以写成:

coll.find({:x => {"$gte" => 4}})
 => #<Mongo::Cursor>
除了等于的比较以外,其他的比较都是用以“$”开头的操作符来表达的。MongoDB 中可以使用的比较操作符如表 1 所示。

表1 比较操作符

名 称

语 法

含 义

$gt

{"$gt" => val}

大于 val

$lt

{"$gt" => val}

小于 val

$gte

{"$gte" => val}

大于等于 val

$lte

{"$lte" => val}

小于等于 val

$ne

{"$ne" => val}

不等于 val

$in

{"$in" => val}

包含 val

$nin

{"$nin" => val}

不包含 val

$mod

{"$mod" => [n, m]}

除以 n 的余数为m

$all

{"$all" => ary}

包含 ary 的所有元素

$size

{"$size" => n}

数组长度为 n

$exists

{"$exists" => true}

存在

$exists

{"$exists" => false}

不存在

$not

{"$not" => cond}

否定条件

正则表达式

^foo

等正则匹配

$where

{"$where" => str}

将 str 作为 JavaScript 进行求值,用this来引用文档

当然,通过多个条件的组合,也可以编写出像“大于 2,小于 8”这样的条件。

coll.find(:j=>{"$gt"=>2,"$lt"=>8})
=> #<Mongo::Cursor>

find 方法的选项

find 方法可以通过第二个参数来指定一些查询选项。

coll.find({:x=>4},{:limit=>10})
这表示在查询结果中只取出 10 个结果的意思。在 Ruby 中,末尾的参数为 Hash 时可以省略花括号,因此上述 find 调用也可以写成下面的形式:

coll.find({:x=>4},:limit=>10)
而且,在 Ruby 1.9 中,当 Hash 以符号(symbol)为键时,也可以写成省略形式:

coll.find({:x=>4},limit:10)
find 方法的选项及其含义如表 2 所示。

表2 find方法的选项

选 项

含 义

:skip

整数

整数跳过指定数量的结果。

:limit

整数

整数只取出指定数量的结果。

:sort

文字列

字符串按指定字段进行排序。

:sort

配列

数组按[ 字段, 顺序] 的格式指定排序条件。指定顺序时,升序为:asc,降序为:desc。

:fields

配列

数组指定结果文档中要包含的字段名。

:hint

文字列

字符串使用指定字段的索引进行查询。

:snapshot

真伪值

布尔值是否对文档进行快照。

:timeout

TRUE

TRUE是否超时。当指定:timeout时,可附带代码块调用find。

在 find 方法的选项中,skip、limit、sort 也可以作为 find 所返回的游标对象的方法来进行调用。因此,像:

coll.find({:x=>4},limit:10)
也可以写成:

coll.find(:x=>4).limit(10)
sort 在调用时可以不在数组中指定顺序,而是在参数中指定。因此,像:

coll.find({:x=>4},sort:[:j,:desc])
用游标方法的形式,也可以写成:

coll.find(:x=>4).sort(:j,:desc)
这两种写法,在内部处理上是完全相同的,因此选一种自己喜欢的写法就可以了。

原子操作

只有文档的插入和查询并不能构成数据库的完整功能,我们还需要进行更新和删除。文档的插入我们使用了 save 方法,保存好的文档会被赋予一个 _id 成员,因此,当要保存的文档的 _id 已存在时,就会覆盖相应 _id 的文档。也就是说,用 find 或 find_one 方法取出文档之后,将文档内容进行改写,然后再重新 save 的话(唯独不能改变 _id 成员的值),就可以替换原来的文档了。

MongoDB 中不支持事务机制,对于其他连接对同一文档进行更新的行为,是无法做出保护的。MySQL 在最开始不支持事务的时候还是非常有用的,由此可见,Web 应用中的数据库系统,即便不支持事务,貌似也不是很大的问题。

MongoDB 中虽然不支持事务,但可以通过 update 方法,在更新文档时排除来自其他连接的干扰。update 方法与文档的更新操作是互斥的,其操作结果只有“更新成功”和由于某些原因“出错失败”这两种状态。也就是说,当多个连接同时对同一个文档进行 update 操作时,更新操作也不会发生“混淆”,而是保证其中只有某一个操作能够成功。失败的操作可以进行重试,从结果来看,和按顺序执行更新操作是一样的。

像这样,更新操作不会半路中断,也不会留下不完整状态的操作,被称为“原子操作”。

update 方法最多可以接受三个参数。第一个是原始文档,第二个是新文档,最后一个是选项。其中选项是可以省略的。原始文档指的是更新之前的文档,但这里并不需要给出完整的文档,而是写成和 find 方法查询相同的格式即可。新文档指的是更新后的文档,这里也不需要给出完整的文档,只要给出包含更新后字段的 Hash 即可。当给出的字段已存在时就会更新其中的值,否则,就会添加一个新的字段。

update 方法的选项和 find 方法一样,是通过 Hash 来指定的。update 方法的选项如表 3 所示。我们用 update 方法,来进行对一个文档中的成员的值累加 1 这样的原子操作(图 1)。在图 1 中,如果在调用 update 的地方用 save 方法来代替,在取出文档到累加并保存的这段时间内,如果该文档被其他连接改写,累加操作就会失效。以图 2 为例,两个连接几乎同时进行累加操作,但由于取出和保存文档的顺序是混杂的,因此虽然进行了两次累加 1 的操作,但实际上 x 的值只增加了 1。

表3 update方法的选项

选 项

默认值

含 义

:upsert

布尔值

布尔值假当与“原始文档”相匹配的文档不存在时,则创建新文档。

:multi

布尔值

布尔值假当“原始文档”匹配到多个文档且该选项为真时,则更新所有文档。为假,则只更新其中一个文档。

:safe

布尔值

布尔值假为真时,会对是否真的完成了更新进行确认

loop
  doc = coll.find_one(:j=>0) ←---取出一个文档
  orig = doc.dup ←---将更新前的文档保存下来
  d["x"] += 1 ←---更新字段x
  r = coll.update(orig, doc, :safe=>true) ←---调用update。不可以用coll.save(doc),为了确认更新结果设置了:safe选项

  if r[0][0]["n"] == 1 ←---更新成功则跳出循环
    break
  end ←---循环:返回开头。从取出文档的步骤开始重试
end
图 1 乐观并发控制

图 2 失败的并发控制

相对地,像图 1 这样使用 update 方法的话, 在进行更新操作时,就会像图 3 一样发现文档被改写这一情况,并通过重试最终得到正确的结果。

图 3 成功的并发控制

不过,像累加这样的典型操作,要是能编写得更简单一些就好了。其实,只要在 update 中对更新文档的指定上稍微优化一下,就可以用一个 update 语句实现某些典型的原子操作了。

例如,图 1 中的累加操作,也可以写成下面这样:

coll.update({:j=>0},
           {"$inc"=>{:j=>1}})
这行代码的意思是:“找到字段 j 为 0 的文档,然后将 j 的值加 1”。

update 方法中可以使用的原子操作如表 4 所示。原子操作的名称都是以“$”开头的。表 3 中的“f”表示字段名,字段名可以通过字符串或者符号的形式进行指定。

表4 update的原子操作

名 称

功 能

语 法

$inc

对f 的值加n

{"$inc" => {f => n}}

$set

将f 的值设为val

{"$set" => {f => val}}

$unset

删除f

{"$unset" => {f => 1}}

$push

在f 所指的数组中插入val

{"$push" => {f => val}}

$pushAll

在f 所指的数组中插入ary 的元素

{"$pushAll" => {f => ary}}

$addToSet

当f 所指的数组中不存在val 时则插入

{"$addToSet" => {f => val}}

$pop

删除f 所指的数组的最后一个元素

{"$pop" => {f => 1}}

$pop

删除f 所指的数组的第一个元素

{"$pop" => {f => -1}}

$pull

从f 所指的数组中删除所有val

{"$pull" => {f => val}}

$pullAll

从f 所指的数组中删除ary 的元素

{"$pullAll" => {f => ary}}

ActiveRecord

在 Ruby 的世界中,作为面向对象的数据库访问手段,最有名的莫过于 ActiveRecord 了。在 Ruby on Rails 中,对关系型数据库的操作并不是直接进行的,而是通过 ActiveRecord 库将表中的各条记录作为对象来进行操作的。像这种将对象与记录进行对应的库,被称为 OR Mapper。其中 O 代表对象(Object),R 代表关系(Relation),因此它就是将关系与对象进行映射的意思。

有了 ActiveRecord 的帮助,在 Rails 程序设计中,可以不必关心底层的关系型数据库,完全以面向对象的方式来进行编程。ActiveRecord 是一种十分易用的 OR Mapper,但也并非完美无缺。其中一个令人不满意的地方,就是数据库结构信息和模型2定义是分离的。由于 Rails 的组件遵循 DRY(Don't Repeat Yourself)原则,因此 ActiveRecord 中不会对数据库结构定义进行重复描述。数据库结构信息只存在于它原本存在的地方,也就是数据库中。

2 模型指的是 MVC(Model-View-Controller)架构中的 Model。

信息的重复往往是造成 bug 的元凶,从这一点上来看,DRY 原则是非常优秀的。但另一方面,对数据的操作以及对象之间的关系,则是在模型中进行定义的。从这个意义上来说,对象结构的信息和对其进行操作的信息是分开的。如果要查看字段信息,就必须要查看运行中的数据库。因此,想要将相关信息都整合在一起,是再自然不过的需求了。

另一个不满意的地方,则是 ActiveRecord 所提供的“一条记录 = 一个对象”这样的抽象化模型,并非总是最优的。在简单的水平上,“一条记录 = 一个对象”这样的抽象化模型实现起来很容易,只要将 SQL 调用所得到的记录封装成对象就可以了。但是,当对象的调用变得越来越频繁和复杂时,就会产生性能上的问题。结果,关系型数据库中的记录,并没有成为真正意义上的对象,在特殊情况下,会暴露出抽象化中的纰漏。这样的问题,被称为抽象泄漏(leaky abstraction)。

如果为了改善性能而使用缓存等手段的话,模型的逻辑会变得越来越复杂。另一方面,为了得到更优化的 SQL,会对 find 方法等指定非常详细的选项,结果则是无法用 Ruby 来编写,最后变成直接写 SQL 了。这也许已经是 ActiveRecord 的极限了吧。

OD Mapper

这个时候,就轮到 MongoDB 发挥威力了。由于 MongoDB 是不需要数据库结构的,因此结构定义和模型定义分离的问题也就不存在了。此外,MongoDB 中也没有 SQL,原本也无法进行复杂的查询,因此也不必担心会写出“SQL 式的 Ruby”。当然,也许大家会觉得这样是治标不治本,不过这个问题本来就应该分开来看。也就是说,如果使用 MongoDB 的话(如果应用程序能够使用 MongoDB 来实现的话),对 ActiveRecord 的那些不满也就可以得到缓解了。

要在 Rails 中使用 MongoDB,有一些是可以与 ActiveRecord 互换的库。

· MongoMapper

· Mongoid

· activerecord-alt-mongo-adapter

由于 MongoDB 不是关系型数据库,因此这些库也不能称之为 OR Mapper,而是应该称之为 Object Document Mapper(OD Mapper)了吧。

1. MongoMapper

MongoMapper 是 MongoDB 用 ActiveRecord 驱动中资历最老的一个,作者是 John Nunemaker。话说,MongoDB 本身是 2009 年才诞生的,因此所谓资历最老其实也老不到哪里去呢。它与 ActiveRecord 之间的兼容性很高,在 Rails 应用程序中,即便将 MongoMapper 当成 ActiveRecord 的替代品来使用,各种 Rails 插件也都能正常工作。

MongoMapper 中的模型定义如图 4 所 示。MongoDB 中对模型进行定义时,本来就不需要数据库结构定义,实际上,在 MongoMapper 中结构定义也不是必需的,但也可以通过 key 方法对字段及其类型进行显式的声明。通过这样的显式声明,可以利用类型检查,使得一些问题更容易被发现,同时也使得数据库结构能够文档化。刚才我们讲过对 ActiveRecord 的不满意的地方,而在这里,我们可以将数据库的结构和操作在同一个地方进行定义,感觉非常好。

require 'rubygems'
require 'mongo_mapper'
 
class Employee
  include MongoMapper::Document
  key :first_name
  key :last_name
  many :addresses
end
 
class Address
  include MongoMapper::EmbeddedDocument
  key :street
  key :city
  key :state
  key :post_code
end
图 4 用 MongoMapper 进行模型定义

在 MongoDB 中,可以在一个文档中嵌入另一个文档(JSON),这在一般的关系型数据库中是很难做到的3。当然,原本就是基于关系型数据库的 ActiveRecord 自然也不支持嵌入文档,而在 MongoMapper 中,对于嵌入的文档只要将 include 的类由 MongoMapper::Document 改成 MongoMapper::EmbeddedDocument,就表示该对象不是保存在独立的集合中,而是一个嵌入文档。

3 也有一些关系型数据库提供了可以直接引用另外一张表指定部分的功能。(原书注)

2. Mongoid

Mongoid 是一个比 MongoMapper 更新一些的库,作者是 Durran Jordan。Mongoid 的功能很丰富,通过和 ActiveRecord 类似的 API 可以充分发挥 MongoDB 的全部功能。用 Mongoid 定义一个和图 4 相同的模型,代码如图 5 所示。在一些细节上有所差别,但大意是一样的。不过,Mongoid 中是没有对于嵌入文档的定义的,但 Mongoid 中其实有一条规则:凡是指定了“inverse_of”的对象关系都会自动被视为嵌入。这一点还是相当智能的。

require 'rubygems'
require 'mongoid'
 
class Employee
  include Mongoid::Document
  field :first_name
  field :last_name
  has_many :addresses
end
 
class Address
  include Mongoid::Document
  field :street
  field :city
  field :state
  field :post_code
  belongs_to :employee, :inverse_of =>
:addresses
end
图 5 用 Mongoid 进行模型定义

此外,对于 MongoDB 所提供的如支持文档的类继承、验证、版本控制、回调,以及基于 JavaScript 的 MapReduce 等功能,Mongoid 都通过和 ActiveRecord(以及 ActiveModel)相类似的 API 进行了实现,这也是 Mongoid 的设计思想之一。

3. activerecord-alt-mongo-adapter

activerecord-alt-mongo-adapter 是最新的一个库,作者是 SUGAWARA Genki。MongoMapper 和 Mongoid 都是替代 ActiveRecord 来使用的库,相比之下,activerecord-alt-mongo-adapter 则是一个用于 ActiveRecord 的 DB 适配器(图 6)。换句话说,它并不是一个独立的替代库,而是一个通过 ActiveRecord 的数据库切换功能来使用的 MongoDB 访问适配器。因此,ActiveRecord 本身的功能都可以直接使用。仔细想想的话,ActiveRecord 虽然通过提供相应的适配器的方式实现了对各种数据库的支持,而且只要修改配置文件就可以对数据库系统进行切换,但其工作方式总归还是基于 SQL 的。

class Employee < ActiveRecord::Base
  include ActiveMongo::Collection
  has_many :addresses
end
 
class Address < ActiveRecord::Base
  include ActiveMongo::Collection
end
图 6 用 activerecord-alt-mongo-adapter 进行模型定义

然而,MongoDB 属于 NoSQL,当然是无法用 SQL 来进行解释的。为了搞清楚这个适配器是如何实现对 MongoDB 的支持的,我看了一下它的源代码。它为了能够解释从 ActiveRecord 传来的 SQL,居然用 Ruby 编写了一个 SQL 语法解析器,而对于 MongoDB 的访问是通过这个 SQL 语法解析器来完成的。唔,好厉害啊。

不过,以 ActiveRecord 适配器的形式工作也并非尽善尽美,在我查到的范围内,像对嵌入文档的支持、模型内部字段声明、在模型定义中创建索引等功能都是不支持的。这些功能在关系型数据库中本来就不存在,而 ActiveRecord 也原本就没有考虑到在非关系型数据库上进行应用,因此在这一点上也不能指望 ActiveRecord。即便如此,通过利用 ActiveRecord,它只用了相当于 MongoMapper 和 Mongoid 十分之一的代码量,就实现了对 MongoDB 的访问,可以说是很了不起的。

说到底,activerecord-alt-mongo-adapter 只是一个 ActiveRecord 适配器。因此,作为 ActiveRecord 的特点之一,它可以通过 database.yml 在开发环境 DB 和正式环境 DB 之间进行自动切换,这可以说是一个比较大的优点。

但与此同时,仅通过这个适配器很难用到 MongoDB 的全部功能,而且由于需要经过一个额外的 SQL 解析层,性能方面也很让人担心。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文