65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 MongoDB 存储类似树的层级结构,第二部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.43/5 (5投票s)

2013年1月6日

CC (ASA 3U)

6分钟阅读

viewsIcon

33999

downloadIcon

338

存储类似树的结构的三个方法, 使用 NoSQL 数据库

引言

在现实生活中,几乎任何项目都涉及树状结构。各种分类法、站点结构等都需要模拟层级关系。在本文中,我将以 MongoDB 数据库为例,演示五种典型处理层级数据的第五和第六种方法。请参考系列的第一篇文章,了解前三种方法。这些方法是

  • 使用子节点引用建模树结构
  • 使用父节点引用建模树结构
  • 使用祖先节点数组建模树结构
  • 使用物化路径建模树结构
  • 使用嵌套集建模树结构

注意:本文的灵感来自于 10gen 的另一篇文章《在 MongoDB 中建模树结构》,但并非抄袭,而是提供了关于树管理典型操作的额外示例。请参考 10gen 的文章以更深入地理解该方法。

背景

作为演示数据集,我使用了一个虚构的电子商店商品分类。

需要解决的挑战

在典型的网站场景中,我们应该能够

  • 操作树(在特定父节点下插入新节点、更新/删除现有节点、将节点移动到树的其他位置)
  • 获取节点的路径(例如,用于构建面包屑导航)
  • 获取节点的所有后代(例如,以便选择更通用类别的商品,如“手机及配件”,其中应包含所有子类别下的商品。

在下面的每个示例中,我们将

  • 在 electronics(电子产品)下添加一个名为 'LG' 的新节点
  • 将 'LG' 节点移动到 Cell_Phones_And_Smartphones(手机及智能手机)节点下
  • 从树中删除 'LG' 节点
  • 获取 Electronics(电子产品)节点的子节点
  • 获取 'Nokia' 节点的路径
  • 获取 'Cell_Phones_and_Accessories'(手机及配件)节点的所有后代

请参考上图以获得视觉表示。

使用物化路径的树结构

对于每个节点,我们存储 (ID, PathToNode)

这种方法与存储祖先节点数组类似,但我们存储的是 string 格式的路径。在上面的示例中,我特意使用逗号(,)作为路径元素分隔符,以便使正则表达式更简单。

添加新节点

新节点的插入通过一次 select 和一次 insert 操作完成。

var ancestorpath = db.categoriesMP.findOne({_id:'Electronics'}).path;
ancestorpath += 'Electronics,'
db.categoriesMP.insert({_id:'LG', path:ancestorpath});
//{ "_id" : "LG", "path" : "Electronics," } 

更新/移动节点

节点可以使用一次 select 和一次 update 操作进行移动。

ancestorpath = db.categoriesMP.findOne({_id:'Cell_Phones_and_Smartphones'}).path;
ancestorpath +='Cell_Phones_and_Smartphones,'
db.categoriesMP.update({_id:'LG'},{$set:{path:ancestorpath}});
//{ "_id" : "LG", "path" : "Electronics,Cell_Phones_and_Accessories,
//Cell_Phones_and_Smartphones," } 

删除节点

节点可以使用单个数据库查询进行删除

db.categoriesMP.remove({_id:'LG'}); 

获取节点子节点(无序)

请注意,除非您添加一个排序字段,否则无法获得有序的子节点列表。如果需要顺序,您应该考虑其他方法。

db.categoriesMP.find({$query:{path:'Electronics,'}})
//{ "_id" : "Cameras_and_Photography", "path" : "Electronics," }
//{ "_id" : "Shop_Top_Products", "path" : "Electronics," }
//{ "_id" : "Cell_Phones_and_Accessories", "path" : "Electronics," } 

获取所有节点后代

单个 selectregexp^ 开头,允许使用索引进行匹配

var descendants=[]
var item = db.categoriesMP.findOne({_id:"Cell_Phones_and_Accessories"});
var criteria = '^'+item.path+item._id+',';
var children = db.categoriesMP.find({path: { $regex: criteria, $options: 'i' }});
while(true === children.hasNext()) {
  var child = children.next();
  descendants.push(child._id);
}

descendants.join(",")
//Cell_Phones_and_Smartphones,Headsets,Batteries,Cables_And_Adapters,
//Nokia,Samsung,Apple,HTC,Vyacheslav 

获取节点路径

我们可以直接从节点获取路径,而无需执行额外的 select 查询。

var path=[]
var item = db.categoriesMP.findOne({_id:"Nokia"})
print (item.path)
//Electronics,Cell_Phones_and_Accessories,Cell_Phones_and_Smartphones, 

索引

建议的索引是在 path 字段上建立索引

db.categoriesAAO.ensureIndex( { path: 1 } ) 

使用嵌套集的树结构

对于每个节点,我们存储 (ID, left, right)。

Left 字段也可以作为排序字段。

添加新节点

请参考上图。假设我们要将 LG 节点插入到 shop_top_products(14,23) 之后。
新节点的 left 值将是 24,根据遍历规则影响所有剩余的 left 值,right 值将是 25,影响包括根节点在内的所有剩余 right 值。

步骤

  1. 考虑遍历树中的下一个节点。
  2. 新节点的 left 值将是其下一个同级节点的值,right 值将是下一个同级节点 left 值加二。
  3. 现在,我们必须为新节点腾出空间。更新操作会影响所有祖先节点的 right 值,也会影响遍历中剩余的所有节点。
  4. 只有在腾出空间后,才能插入新节点。
var followingsibling = db.categoriesNSO.findOne({_id:"Cell_Phones_and_Accessories"});
var newnode = {_id:'LG', left:followingsibling.left,right:followingsibling.left+1}

db.categoriesNSO.update({right:{$gt:followingsibling.right}},{$inc:{right:2}}, false, true)

db.categoriesNSO.update({left:{$gte:followingsibling.left}, 
   right:{$lte:followingsibling.right}},{$inc:{left:2, right:2}}, false, true)
db.categoriesNSO.insert(newnode) 

让我们检查一下结果。

  +-Electronics (1,46)
     +---Cameras_and_Photography (2,13)
           +------Digital_Cameras (3,4)
           +------Camcorders (5,6)
           +------Lenses_and_Filters (7,8)
           +------Tripods_and_supports (9,10)
           +------Lighting_and_studio (11,12)
       +----Shop_Top_Products (14,23)
           +------IPad (15,16)
           +------IPhone (17,18)
           +------IPod (19,20)
           +------Blackberry (21,22)
       +----LG (24,25)
       +----Cell_Phones_and_Accessories (26,45)
           +------Cell_Phones_and_Smartphones (27,38)
                 +---------Nokia (28,29)
                 +---------Samsung (30,31)
                 +---------Apple (32,33)
                 +---------HTC (34,35)
                 +---------Vyacheslav (36,37)
             +-------Headsets (39,40)
             +-------Batteries (41,42)
             +-------Cables_And_Adapters (43,44) 

删除节点

虽然在同一父节点下重新排列节点顺序与交换节点的 left 和 right 值是相同的,但正式的移动节点的方法是先从树中删除节点,然后再将其插入到新位置。注意:不移除子节点的节点删除在此文章范围内不讨论。目前,我们假设要删除的节点没有子节点,即 right-left=1

步骤与添加节点相同 - 即,我们通过减小受影响的 left/right 值来调整空间,
然后删除原始节点。

var nodetoremove = db.categoriesNSO.findOne({_id:"LG"});

if((nodetoremove.right-nodetoremove.left-1)>0.001) {
    print("Only node without childs can be removed")
    exit
}

var followingsibling = db.categoriesNSO.findOne({_id:"Cell_Phones_and_Accessories"});

//update all remaining nodes
db.categoriesNSO.update({right:{$gt:nodetoremove.right}},{$inc:{right:-2}}, false, true)
db.categoriesNSO.update({left:{$gt:nodetoremove.right}},{$inc:{left:-2}}, false, true)
db.categoriesNSO.remove({_id:"LG"}); 

让我们检查一下结果。

  +-Electronics (1,44)
   +--Cameras_and_Photography (2,13)
         +-----Digital_Cameras (3,4)
         +-----Camcorders (5,6)
         +-----Lenses_and_Filters (7,8)
         +-----Tripods_and_supports (9,10)
         +-----Lighting_and_studio (11,12)
     +---Shop_Top_Products (14,23)
         +-----IPad (15,16)
         +-----IPhone (17,18)
         +-----IPod (19,20)
         +-----Blackberry (21,22)
     +---Cell_Phones_and_Accessories (24,43)
         +-----Cell_Phones_and_Smartphones (25,36)
               +--------Nokia (26,27)
               +--------Samsung (28,29)
               +--------Apple (30,31)
               +--------HTC (32,33)
               +--------Vyacheslav (34,35)
         +------Headsets (37,38)
         +------Batteries (39,40)
         +------Cables_And_Adapters (41,42)  

更新/移动单个节点

节点移动可以是同一父节点内,也可以是移动到另一个父节点。如果是同一父节点且节点没有子节点,那么您只需要交换节点的 (left, right) 对。

正式的方法是删除节点并将其插入到新的目标位置,因此限制条件相同 - 只能移动没有子节点的节点。如果您需要移动子树,请考虑在新的位置下创建现有父节点的镜像,然后逐个移动节点到新父节点下。一旦所有节点都已移动,就删除过时的旧父节点。

例如,让我们将 LG 节点从插入示例中移动到 Cell_Phones_and_Smartphones 节点下,作为最后一个同级节点(即,没有下一个同级节点,如插入示例)。

步骤

  1. 使用上述节点删除过程从树中删除 LG 节点
  2. 获取新父节点的 right 值。新节点的 left 值将是父节点的 right 值,right 值将是父节点的 right 值加一。现在我们必须为新节点腾出空间:更新操作会影响进一步遍历路径上所有节点的 right 值。
var newparent = db.categoriesNSO.findOne({_id:"Cell_Phones_and_Smartphones"});
var nodetomove = {_id:'LG', left:newparent.right,right:newparent.right+1}

//3rd and 4th parameters: false stands for upsert=false and true stands for multi=true
db.categoriesNSO.update({right:{$gte:newparent.right}},{$inc:{right:2}}, false, true)
db.categoriesNSO.update({left:{$gte:newparent.right}},{$inc:{left:2}}, false, true)
db.categoriesNSO.insert(nodetomove) 

让我们检查一下结果。

  +-Electronics (1,46)
   +--Cameras_and_Photography (2,13)
         +-----Digital_Cameras (3,4)
         +-----Camcorders (5,6)
         +-----Lenses_and_Filters (7,8)
         +-----Tripods_and_supports (9,10)
         +-----Lighting_and_studio (11,12)
     +---Shop_Top_Products (14,23)
         +-----IPad (15,16)
         +-----IPhone (17,18)
         +-----IPod (19,20)
         +-----Blackberry (21,22)
     +---Cell_Phones_and_Accessories (24,45)
         +-----Cell_Phones_and_Smartphones (25,38)
                 +---------Nokia (26,27)
                 +---------Samsung (28,29)
                 +---------Apple (30,31)
                 +---------HTC (32,33)
                 +---------Vyacheslav (34,35)
                 +---------LG (36,37)
             +-------Headsets (39,40)
             +-------Batteries (41,42)
             +-------Cables_And_Adapters (43,44) 

获取所有节点后代

这是该方法的优势所在 - 所有后代节点都可以通过一次数据库 select 查询检索。此外,通过按节点 left 值排序,数据集就可以按正确的顺序进行遍历。

var descendants=[]
var item = db.categoriesNSO.findOne({_id:"Cell_Phones_and_Accessories"});
print ('('+item.left+','+item.right+')')
var children = db.categoriesNSO.find({left:{$gt:item.left}, 
               right:{$lt:item.right}}).sort(left:1);
while(true === children.hasNext()) {
  var child = children.next();
  descendants.push(child._id);
}

descendants.join(",")
//Cell_Phones_and_Smartphones,Headsets,Batteries,Cables_And_Adapters,
//Nokia,Samsung,Apple,HTC,Vyacheslav 

获取节点路径

检索节点路径也非常简洁,并且可以通过单个数据库查询完成。

var path=[]
var item = db.categoriesNSO.findOne({_id:"Nokia"})

var ancestors = db.categoriesNSO.find({left:{$lt:item.left}, 
                right:{$gt:item.right}}).sort({left:1})
while(true === ancestors.hasNext()) {
  var child = ancestors.next();
  path.push(child._id);
}

path.join('/')
// Electronics/Cell_Phones_and_Accessories/Cell_Phones_and_Smartphones 

索引

建议的索引是在 left 和 right 值上建立索引。

db.categoriesAAO.ensureIndex( { left: 1, right:1 } ) 

如果您有耐心读到这里,还有一个奖励。

结合使用嵌套集和经典的父节点引用(带顺序)方法的树结构

对于每个节点,我们存储 (ID, Parent, Order, left, right)。

Left 字段也作为排序字段,因此我们可以省略 Order 字段。但另一方面,
我们可以保留它,这样在出现意外损坏时,或者例如在初始导入期间,可以使用 Parent Reference 和 Order 数据来重建 left/right 值。

添加新节点

可以通过这种方式采用嵌套集的方法来添加新节点。

var followingsibling = db.categoriesNSO.findOne({_id:"Cell_Phones_and_Accessories"});
var previoussignling = db.categoriesNSO.findOne({_id:"Shop_Top_Products"});
var neworder = parseInt((followingsibling.order + previoussignling.order)/2);
var newnode = {_id:'LG', left:followingsibling.left,
              right:followingsibling.left+1, parent:followingsibling.parent, order:neworder};
db.categoriesNSO.update({right:{$gt:followingsibling.right}},{$inc:{right:2}}, false, true)
db.categoriesNSO.update({left:{$gte:followingsibling.left}, 
   right:{$lte:followingsibling.right}},{$inc:{left:2, right:2}}, false, true)
db.categoriesNSO.insert(newnode)  

插入前

 +----Cameras_and_Photography (2,13)  ord.[10]
 +-----Shop_Top_Products (14,23)  ord.[20]
 +-----Cell_Phones_and_Accessories (26,45)  ord.[30] 

插入后

    +--Electronics (1,46) 
           +----Cameras_and_Photography (2,13)  ord.[10]
                       +-------Digital_Cameras (3,4)  ord.[10]
                       +-------Camcorders (5,6)  ord.[20]
                       +-------Lenses_and_Filters (7,8)  ord.[30]
                       +-------Tripods_and_supports (9,10)  ord.[40]
                       +-------Lighting_and_studio (11,12)  ord.[50]
           +-----Shop_Top_Products (14,23)  ord.[20]
                       +-------IPad (15,16)  ord.[10]
                       +-------IPhone (17,18)  ord.[20]
                       +-------IPod (19,20)  ord.[30]
                       +-------Blackberry (21,22)  ord.[40]
           +-----LG (24,25)  ord.[25]
           +-----Cell_Phones_and_Accessories (26,45)  ord.[30]
                       +-------Cell_Phones_and_Smartphones (27,38)  ord.[10]
                                   +----------Nokia (28,29)  ord.[10]
                                   +----------Samsung (30,31)  ord.[20]
                                   +----------Apple (32,33)  ord.[30]
                                   +----------HTC (34,35)  ord.[40]
                                   +----------Vyacheslav (36,37)  ord.[50]
                       +--------Headsets (39,40)  ord.[20]
                       +--------Batteries (41,42)  ord.[30]
                       +--------Cables_And_Adapters (43,44)  ord.[40] 

更新/移动单个节点

与插入方法相同。

删除节点

使用了嵌套集的方法。

获取节点子节点(有序)

现在可以通过 (Parent, Order) 对来实现

 db.categoriesNSO.find({parent:"Electronics"}).sort({order:1});
/*

{ "_id" : "Cameras_and_Photography", "parent" : "Electronics", 
  "order" : 10, "left" : 2, "right" : 13 }
{ "_id" : "Shop_Top_Products", "parent" : "Electronics", 
  "order" : 20, "left" : 14, "right" : 23 }
{ "_id" : "LG", "left" : 24, "right" : 25, 
  "parent" : "Electronics", "order" : 25 }
{ "_id" : "Cell_Phones_and_Accessories", "parent" : "Electronics", 
  "order" : 30, "left" : 26, "right" : 45 }
*/ 

获取所有节点后代

使用了嵌套集的方法。

获取节点路径

使用了嵌套集的方法。

代码示例

代码可以从仓库下载:https://github.com/Voronenko/Storing_TreeView_Structures_WithMongoDB

所有文件都按照以下命名约定打包:

  • MODELReference.js - 包含 MODEL 方法树数据的初始化文件
  • MODELReference_operating.js - 添加/更新/移动/删除/获取子节点示例
  • MODELReference_pathtonode.js - 说明如何获取节点路径的代码
  • MODELReference_nodedescendants.js - 说明如何检索节点所有后代代码

所有文件都已准备好在 mongo shell 中使用。您可以通过调用 mongo < file_to_execute 来运行示例,或者,如果您愿意,也可以在 shell 中或使用 RockMongo 网页 shell 进行交互式操作。

历史

  • 2013 年 1 月 6 日:初版
© . All rights reserved.