在 RavenDB 中对如何在应用程序中进行数据建模没有任何要求,我们可以使用任何形式进行建模,RavenDB 只关心如何构建数据,这就是我们后续几篇文章要讲解的内容。
TIP:在本专题后续文章中我们讨论的是具体的方案,而不是通用方案。
为了方便讲解以及读者可以听得懂,我将使用幼儿园作为数据模型,它包含 Child、Parent以及Registration 这三个概念,实体类如下:
public class Parent
{
public string Name { get; set; }
}
public class Registration
{
public DateTime EnrolledAt { get; set; }
public EnrollmentType Type { get; set; }
}
public class Child
{
public string Name { get; set; }
public DateTime Birthday { get; set; }
public Parent Father { get; set; }
public Parent Mother { get; set; }
public Registration Registration { get; set; }
}
我们在建模时应遵循 RavenDB 建模的核心原则,要确定哪些信息可以放在一起,哪些信息是独立的,这就是我们上篇文章介绍的优秀的文档模型应具备独立、隔离和连贯性。从实体模型中可以看出,Child是和Parent绑定在一起的,因此Parent可以放在Child文档中。
文档模型和实体关系模型是不一样的,一般来说在实体关系模型中每个实体都有一个对应的表,但是在文档模型中则不是这样,我们一般会像下面代码这样将所有紧密相关的信息存储在一个地方。
{
"Name": "张感叹",
"Birthday": "2019-03-19",
"Mother": {
"Name": "王加加"
},
"Father": {
"Name": "张油油 "
},
"Registration": {
"EnrolledAt": "2022-03-19",
"Type": "全日制"
}
}
这种方式常用于数据可预测的情况下,我们可以和容易的查看到与这个 Child 紧密相关的信息。这也是在大部分情况下所使用的方式,它可以引导我们获得连贯的文档,我们也可以不必顾及架构限制,在其中保存任意复杂度的数据。
但是这种方法在以下集中情况下是不可用的:
针对上一小节内容,我们可以利用多对一关系进行解决,将父母的标识符存储在 Child 中,如下面所示:
{
"Name": "张感叹",
"Birthday": "2019-03-19",
"MotherId": "Parent/2022-A",
"FatherId": "Parent/2023-A",
"Registration": {
"EnrolledAt": "2022-03-19",
"Type": "全日制"
}
}
那么将上述模型反映到实体中是这样子的:
public class Child
{
public string Name { get; set; }
public string FatherId { get; set; }
public string MotherId { get; set; }
}
我们将 Parent 的 id 放在 Child 中,当我们从 Child 文档遍历到 Parent 文档时可以使用 Id 来进行查找,一般来说我们为了加快速度,会使用 Include 来保证一次远程调用加载所有文档,这样也不会影响到使用数据模型。
当我们需要查询张油油都有哪些孩子是,我们可以使用如下的代码来实现:
using (var session = store.OpenSession())
{
var lorina = session.Load<Parent>("Parent/2023-A");
var lorinaChildren = (
from c in session.Query<Child>()
where c.MotherId == lorina.Id
select c
).ToList();
}
TIP 在 RavenDB Studio 中可以使用如下语句进行查询:
Indexes Query from Children where MotherId = 'Parent/2023-A'
使用这种方式的好处时每个文档都是独立的,并且确保了不会再模型中出现静默依赖项,每个文档都能清晰的描绘出来。多对一关系是最简单的关系,也是很常见的。在使用时应该考虑清楚我们的业务到底是需要跨越文档进行关联还是在文档内部进行关联。
多对多是最复杂的关系,我们来扩展一下幼儿园这个例子,使其具备多对多的关系。当我们需要在 Child 信息中加入爷爷奶奶和姥姥姥爷时,就出现了多对多的关系,因为一个孩子最多有四个祖父母辈的家长,每个祖父母辈的家长又有可能有多个孙子辈的孩子。
那么我们该如何解决这个问题呢?说我们有三种方法:
那么到底哪种方法更好呢?一般来说我们会将关联记录放在较小的一侧,也就是说孩子祖父母辈的数量大部分情况下比祖父母的孙子辈的数量少,因此将这个关联放在 Child 文档中。
当我们从孙子辈遍历数据时,只需要包含并加载祖父母辈就行了,代码如下:
using (var session = store.OpenSession())
{
Child c = session
.Include<Child>(c => c.Grandparents)
.Load("children/2019-A");
Dictionary<string, Parent> gradparents =
session.Load<Parent>(c.Grandparents);
}
从祖父母辈查询时我们可以这么做:
using (var session = store.OpenSession())
{
Parent grandparent = session.Load<Parent>("parent/1940-A");
List<Child> grandchildren = (
from c in session.Query<Child>()
where c.Grandparents.Contain(grandparent.Id)
select c
).ToList();
}
Tip:在 RavenDB Studio 中我们可以这么查询
IndexesQueryfrom Children where Grandparents[] in ('parents/1940-A')
为什么我将一对一的关系放在最后讲呢?因为它是一个非常奇怪的关系。如果存在一对一的关系,那么它应该是嵌入在文档中而不是单独成为一个文档。但是成为单独的一个文档是一个非常好的方法。如果有一个在概念上相同但具有不同的访问模式的文档,就需要形成一个单独的文档。比如在订单案例里,可能经常访问和查看订单的标头,然后是完整的订单。这个订单可能有很多物品,但我们不需要经常去访问它。在这种情况下,仅为订单标头创建文档大概率是有意义的,但是如果使用投影也是可以的(这些内容将在后面的文章讲解),这样就省去了拆分数据的需要,在 RavenDB 中构建一对一关系的典型方法是利用文档 ID 后缀来创建一组相关文档,比如:orders/001/header。这种方法具有更明确意图,但是通常是不可取的。在大多数情况下,只需将其全部放在单个文档中即可。
另一种情况是,如果需要对文档进行并发活动,由于文档是 RavenDB 中的并发单位,因此需要对文档进行建模,以便它们具有更改的单一原因。但有时,允许对文档进行并发更新是有原因的,例如,如果文档中的属性对应用程序有用。例如在订单系统中,想要添加一个跟踪订单的功能,客户可以标记要跟踪的订单,方便查看订单经过的步骤,并在工作流中的各个点采取措施。这样的操作需要被记录下来,但它实际上不会以任何方式影响系统的行为,可以在任何时间点添加或删除订单跟踪,包括在订单的并发更新期间。
一种方法是始终使用修补(后续文章讲解)来更新文档,但是处理这种要求的更好方法是创建一个专用文档,该文档将保存有关跟踪此订单的用户的所有详细信息。这使其成为域中的显式操作,而不仅仅是将其附加到现有对象上。