下一代数据存储的演示一般都是围绕着社交媒体:以Twitter类演示应用居多。遗憾的是此类应用倾向于使用简单的数据模型。这就是为什么本章以及后续各章中要使用更丰富的电子商务领域模型了,其中包含了很多为人熟知的数据建模模式。而且也不难想象产品、分类、产品评论与订单是如何在RDBMS中建模的。这会让即将登场的示例更具启发性,因为可以将它们与预想的Schema设计进行对比。
电子商务通常是专属于RDBMS的一块领域,这是有原因的。首先,电子商务站点通常要求有事务,而事务是RDBMS的主要特性。其次,直到最近为止,要求有富数据模型和完善的查询的领域都会假定自己最适合RDBMS。下面的例子会对第二个假设提出质疑。
在继续之前,需要做一点说明。在本书中介绍如何构建完整的电子商务后端并不实际。我们要做的是选取少量的电子商务实体,演示如何在MongoDB中对其进行建模,尤其会关注产品与分类、用户与订单,还有产品评论。针对每个实体,我都将展示示例文档。随后,我们还会看到一些数据库特性,它们能进一步补充文档的结构。
对很多开发者而言,数据建模总会伴随着对象映射,为此你可能使用过对象关系映射库,比如Java的Hibernate或者Ruby的ActiveRecord,这些库几乎就是在RDBMS上有效构建应用程序的必需品。但是MongoDB对此几乎没什么需要,部分原因是文档已经是类似对象的表述了。此外还和驱动有关,驱动为MongoDB提供了相当高阶的接口,仅用驱动接口就能在MongoDB之上构建完整的应用程序。
有人说,对象映射器很方便,因为它们有助于进行验证、类型检查和关联。很多成熟的MongoDB对象映射器在基本语言驱动之上又提供了一层额外的抽象,在大项目中可以考虑选择其一。1但是,不管是否使用对象映射器,最终总是在和文档打交道。这就是本章关注于文档本身的原因。知道在一个精心设计的MongoDB Schema里文档是什么样的,这能让你更好地使用该数据库,有没有对象映射器都是如此。
1. 想知道哪个对象映射器是你语言里最流行的,可以看看http://mongodb.org里的建议。
4.2.1 产品与分类
产品和分类是任何电子商务站点都必不可少的内容。在一个正规化的RDBMS模型中,产品倾向于使用大量的数据表,总会有张表用来存储基本产品信息,比如名称和SKU2,还有一些其他表用来关联送货信息和价格历史。如果系统允许产品带有任意属性,那么还需要一系列复杂的表来定义并存储那些属性,正如你在第1章的Magento示例中看到的那样。这种多表Schema在RDBMS表联结能力的帮助下很有用。
2. SKU是Stock Keeping Unit的缩写,商品最小分类单元。——译者注
在MongoDB中对产品建模应该会简单很多,因为集合并不一定要有Schema,任何产品文档都可以容纳产品所需的各种动态属性。通过使用数组来容纳内部文档结构,还可以将RDBMS里的多表表述精简成一个MongoDB的集合。更具体一点,下面是一个取自园艺商店的示例产品。
代码清单4-1 示例产品文档
doc ={ _id: new ObjectId(/"4c4b1476238d3b4dd5003981/"), slug: /"wheel-barrow-9092/", sku: /"9092/", name: /"Extra Large Wheel Barrow/", description: /"Heavy duty wheel barrow.../", details: { weight: 47, weight_units: /"lbs/", model_num: 4039283402, manufacturer: /"Acme/", color: /"Green/" }, total_reviews: 4, average_review: 4.5, pricing: { retail: 589700, sale: 489700, }, price_history: [ {retail: 529700, sale: 429700, start: new Date(2010, 4, 1), end: new Date(2010, 4, 8) }, {retail: 529700, sale: 529700, start: new Date(2010, 4, 9), end: new Date(2010, 4, 16) }, ], category_ids: [new ObjectId(/"6a5b1476238d3b4dd5000048/"), new ObjectId(/"6a5b1476238d3b4dd5000049/")], main_cat_id: new ObjectId(/"6a5b1476238d3b4dd5000048/"), tags: [/"tools/", /"gardening/", /"soil/"], }
该文档包含基本的name、sku
和description
字段。_id
字段里还存储着标准的MongoDB对象ID。此外,这里定义了一个短名称wheel-barrow-9092
,以便提供有意义的URL。MongoDB的用户有时会抱怨URL里的对象ID太难看了,通常来说,你不会喜欢下面这样的URL:
http://mygardensite.org/products/4c4b1476238d3b4dd5003981
有意义的ID会更好一点:
http://mygardensite.org/products/wheel-barrow-9092
如果要为文档生成一个URL,我通常会建议增加一个短名称字段。这个字段应该有唯一性索引,这样就能把其中的值用作主键。假设将这个文档存储在 products
集合里,可以像下面这样创建唯一性索引:
db.products.ensureIndex({slug: 1}, {unique: true})
如果在slug
上有唯一性索引,那么需要在插入产品文档时使用安全模式,这样就能得知插入成功与否。需要的话,可以换一个不同的短名称进行重试。举个例子,假设园艺商店里销售多种手推车,在开售新的手推车时,代码需要为新产品生成一个唯一的短名称。以下是在Ruby中执行插入的代码:
@products.insert({:name => /"Extra Large Wheel Barrow/", :sku => /"9092/", :slug => /"wheel-barrow-9092/"}, :safe => true)
这里需要重点说明的是指定了:safe => true
。如果插入成功,没有抛出异常,表明选择了一个唯一的短名称。但如果抛出异常,代码就需要用一个新的短名称进行重试。
接下来,有一个名为details
的键,指向包含不同产品详细信息的子文档,其中规定了重量、计重单位以及厂家的型号代码,你也可以存储其他特定属性。举例来说,如果在销售种子,可以在其中包含预期产量与收获时间;如果在销售割草机,可以包含马力、燃料类型和护根选项。details
属性为这些动态属性提供了一个很好的容器。
请注意,还可以在同一个文档中存储产品的当前价格和历史价格。pricing
键指向一个包含零售价和特价的对象。price_history
则恰恰相反,指向一个价格数组。像这样存储文档副本是一种常见的版本化技术。
随后是一个产品标签名称的数组,在第1章里我们看到过类似的标签示例,这个技术值得反复演示。这是最简单、最佳的存储条目相关标签的途径,同时还能保证查询的高效性,因为可以索引数组键。
那么关系呢?我们可以使用富文档结构,比如子文档和数组,在单个文档中存储产品细节、价格和标签,但最终可能需要关联其他集合中的文档。开始时,我们会把产品关联到一个分类里,这种产品与分类之间的关系通常会表示为多对多关系,每个产品属于多个分类,而每个分类又能包含多个产品。在RDBMS中,我们会使用联结表表示这样的多对多关系。联结表在单个表中存储两个表间的所有关系引用。使用SQL的join
可以发起一条查询,检索产品以及它的全部分类,反之亦然。
MongoDB不支持联结操作,因此需要一种不同的多对多策略。看看手推车的文档,你会发现一个名为category_ids
的字段,其中包含一个对象ID的数组。每个对象ID都是一个指针,指向某个分类文档的_id
字段。下面是一个演示用的分类文档。
代码清单4-2 分类文档
doc ={ _id: new ObjectId(/"6a5b1476238d3b4dd5000048/"), slug: /"gardening-tools/", ancestors: [{ name: /"Home/", _id: new ObjectId(/"8b87fb1476238d3b4dd500003/"), slug: /"home/" }, { name: /"Outdoors/", _id: new ObjectId(/"9a9fb1476238d3b4dd5000001/"), slug: /"outdoors/" } ], parent_id: new ObjectId(/"9a9fb1476238d3b4dd5000001/"), name: /"Gardening Tools/", description: /"Gardening gadgets galore!/",}
如果回头看看产品文档,仔细观察category_ids
字段里的对象ID,你会发现该产品关联了刚才的Gardening Tools分类。在产品文档中放入category_ids
数组键让那些多对多查询成为可能。举例来说,查询Gardening Tools分类里的所有产品,代码很简单:
db.products.find({category_ids => category[/'_id/']})
要查询指定产品的所有分类,可以使用$in
操作符,它类似于SQL的IN指令:
db.categories.find({_id: {$in: product[/'category_ids/']}})
有了刚才描述的多对多关系,再来说说分类文档本身。你一定已经注意到了标准的_id、slug
、name
和description
字段,它们都很直截了当,可是父文档数组的含义就不那么清楚了。为什么要用这么大的篇幅为每个文档冗余存储祖先分类?事实是分类总是被设想为有层级的,在数据库中表示这种层级的方式有很多种。3选择的策略总是依赖于应用程序的需求。本例中,由于MongoDB不支持关联查询,我们选择了去正规化,将上级分类的名称放入每个子分类的文档里。这样一来,查询Gardening Products分类时,就不需要执行额外的查询来获取上级分类(Outdoors和Home)的名称和URL了。
3. 在这篇MySQL开发者的文章里(http://mng.bz/83w4)介绍了两种方法——邻接列表和内嵌集合。
一些开发者可能会觉得这种级别的去正规化是不可接受的。还有其他方式可以用来表示树结构,附录B里就讨论了其中一种方式。但就目前而言,最佳的Schema是由应用程序需求决定的,无需受制于理论,试着接受各种可能性吧。在接下来的两章里你将看到更多对这种结构进行查询与更新的例子,其中的基本原理会变得越来越明朗。
4.2.2 用户与订单
看看如何对用户与订单建模,以此阐明另一种常见关系——一对多关系,就是说每个用户都有多张订单。在RDBMS中,会在订单表里使用外键;此处的惯例很相似。请看代码清单4-3。
代码清单4-3 电子商务订单,带有条目明细、价格和送货地址
doc ={ _id: ObjectId(/"6a5b1476238d3b4dd5000048/") user_id: ObjectId(/"4c4b1476238d3b4dd5000001/") state: /"CART/", line_items: [ { _id: ObjectId(/"4c4b1476238d3b4dd5003981/"), sku: /"9092/", name: /"Extra Large Wheel Barrow/", quantity: 1, pricing: { retail: 5897, sale: 4897, } }, { _id: ObjectId(/"4c4b1476238d3b4dd5003981/"), sku: /"10027/", name: /"Rubberized Work Glove, Black/", quantity: 2, pricing: { retail: 1499, sale: 1299 } } ], shipping_address: { street: /"588 5th Street/", city: /"Brooklyn/", state: /"NY/", zip: 11215 }, sub_total: 6196 }
订单中的第二个属性user_id
保存了一个用户的_id
,它实际是一个指向示例用户(代码清单4-4中的用户,我们稍后会讨论这段代码)的指针。这一设计能方便地查询关系中的任意一方。要找到一个用户的所有订单非常简单:
db.orders.find({user_id: user[/'_id/']})
要获取指定订单的用户同样很简单:
user_id = order[/'user_id/']db.users.find({_id: user_id})
像这样使用对象ID,能很方便地在订单与用户之间建立起一对多关系。
我们再来看看订单文档中的其他亮点。一般来说,我们会使用丰富的表示方式来承载文档数据模型,文档中既有订单条目明细又有送货地址。在正规化的关系型模型中,这些属性会被放在不同的数据表里。而这里,条目明细包含一个子文档数组,每个子文档都描述了购物车里的一个产品。送货地址属性指向一个对象,其中包含了地址信息。
让我们花点时间讨论一下这个表述的优点。首先,它易于人们理解,完整的订单概念都能被封装在一个实体里,包括条目明细、送货地址以及最终的支付信息。查询数据库时,可以通过一条简单的查询返回整个订单对象。其次,可以把产品在购买时的信息保存在订单文档里。最后,正如接下来的两章里会看到的,能轻而易举地查询并修改订单文档,这应该也是你能想到的。
用户文档也用了类似的模式,其中保存了一个地址文档的列表,还有一个支付方法文档的列表。此外,在文档的最上层还能找到任何用户模型里都有的基本常见属性。与产品的短名称字段一样,在用户名字段上添加了唯一索引。
代码清单4-4 用户文档,带有地址和支付方法
{ _id: new ObjectId(/"4c4b1476238d3b4dd5000001/"), username: /"kbanker/", email: /"[email protected]/", first_name: /"Kyle/", last_name: /"Banker/", hashed_password: /"bd1cfa194c3a603e7186780824b04419/", addresses: [ {name: /"home/", street: /"588 5th Street/", city: /"Brooklyn/", state: /"NY/", zip: 11215}, {name: /"work/", street: /"1 E. 23rd Street/", city: /"New York/", state: /"NY/", zip: 10010} ], payment_methods: [ {name: /"VISA/", last_four: 2127, crypted_number: /"43f6ba1dfda6b8106dc7/", expiration_date: new Date(2014, 4) } ]}
4.2.3 评论
最后出场的示例数据模型是产品评论。一般而言,每个产品都会有多条评论,而该关系是用对象ID引用product_id
来编码的,正如你在示例评论文档中看到的那样。
代码清单4-5 产品评论文档
{ _id: new ObjectId(/"4c4b1476238d3b4dd5000041/"), product_id: new ObjectId(/"4c4b1476238d3b4dd5003981/"), date: new Date(2010, 5, 7), title: /"Amazing/", text: /"Has a squeaky wheel, but still a darn good wheel barrow./", rating: 4, user_id: new ObjectId(/"4c4b1476238d3b4dd5000041/"), username: /"dgreenthumb/", helpful_votes: 3, voter_ids: [ new ObjectId(/"4c4b1476238d3b4dd5000041/"), new ObjectId(/"7a4f0376238d3b4dd5000003/"), new ObjectId(/"92c21476238d3b4dd5000032/") ]}
大多数剩余属性的含义都不言而喻。我们存储了评论的日期、标题和内容、用户的评分,以及用户的ID。有些意外的是还存储了用户名。毕竟,如果是RDBMS,可以通过关联 用户表来获取用户名。但因为在MongoDB中没有关联查询,所以有两个可选方案:针对每条评论再去查询一次用户集合,或者是接受去正规化。当所查询的属性(用户名)极有可能不会改变时,针对每条评论发起一次查询会很浪费。诚然,我们可以选择正规化的做法,通过两次MongoDB查询来显示所有的评论,但这里正在为常见情况设计Schema。因为修改用户名时需要在每个出现用户名的地方都做修改,这意味着修改用户名的代价更高了。但它的发生频率非常低,这足以让这种做法成为一个合理的设计选择。
另一点值得注意的地方是在评论文档里保存了投票信息。用户通常能对评论进行投票,这里在投票者ID数组中保存了每个投票用户的对象ID,这能避免用户对同一评论多次投票,同时也让我们有能力查询某个用户投过票的所有评论。注意,这里还缓存了有用投票的总数,以便能基于有用程度对评论进行排序。
目前,我们已经覆盖基本的电子商务数据模型了。如果这是你第一次接触MongoDB数据模型,那么要对其实用程度有所期待还是需要一定信心的。接下来的两章里会详细探讨该模型中剩下的东西,包括不重复地添加投票、修改订单、智能地查询产品,借此分别阐述查询与更新。