Elasticsearch.Nest 教程系列 4-5 映射:Parent/Child relationships | 子父级关系映射

  • 本系列博文是“伪”官方文档翻译(更加本土化),并非完全将官方文档进行翻译,而是在查阅、测试原始文档并转换为自己真知灼见后的“准”翻译。有不同见解 / 说明不周的地方,还请海涵、不吝拍砖 :)

  • 官方文档见此:https://www.elastic.co/guide/en/elasticsearch/client/net-api/current/introduction.html

  • 本系列对应的版本环境:ElasticSearch@7.3.1,NEST@7.3.1,IDE 和开发平台默认为 VS2019,.NET CORE 2.1


如果把 官方示例 放在你本地进行测试,会发现没有问题,但当你连接测试 ES 服务器的时候,就会报各种错误:

  • 这是因为官方使用了 InMemoryConnection ,所有请求并不会真实发送到测试 ES 服务器,规避掉了不少现实问题。以下示例基于官方文档进行了修正,使用本地搭建测试 ES 服务器。

父子关系的数据结构,在日常开发过程中,使用还是比较平凡的:

  • 在 ES 6.x 之前,你可以在一个索引中包含多种 type(类型)。通过给定类型的特殊 _parent 字段映射,可以创建 1对N 关系 的父-子文档。

    • 通过这种方式在索引子项时,你需要传递一个 _parent id 作为路由键(需要确保父项、其子项及所有祖先都保存在同一个分片上)。
  • 但从 ES 6.x 开始,type 不再支持多类型,固定 _type=_doc。(另外在 ES5 的时候,ES 的 index 相当于 DB,_type 相当于 DB 中的表名,但从 ES7 开始,ES 的 index 相当于 DB中的表,_type 固定为 _doc)

嵌套对象和父子结构文档的差异对比和使用建议:

Nested Object Parent/Child
优点 文档存储再一起,读取性能高 父子文档可以独立更新
缺点 更新嵌套的子文档时,需要更新整个文档 需要额外的内存维护关系。读取性能相对差
适用场景 子文档偶尔更新,以查询为主 子文档更新频繁

既然索引不再允许将不同 _type 类型存储在同一索引中,那么如何创建父联接?

通过 JoinField 属性

唯一的要求是作为子父级关系的类必须要具有数据类型为 JoinField 的属性。通过该属性,在请求的时候,可以生成关联类型。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class MyDocument
{
public int Id { get; set; }
public JoinField ParentChildRelation { get; set; }
}

public class MyParent : MyDocument
{
[Text]
public string ParentProperty { get; set; }
}
public class MyChild : MyDocument
{
[Text]
public string ChildProperty { get; set; }
}
  • MyParent 和 MyChild 均继承自 MyDocument,且注意 MyDocument.ParentChildRelation 的数据类型为 JoinField (该类型是 Nest 库提供的数据类型) 。

    • 这里,parentChildRelation 即为 Join 类型的名称。

将 MyChild 和 MyParent 设置相同的索引和默认文档类型,以确保它们在同一个索引中:

  • 需要将 MyDocument,MyChild 和 MyParent 的索引名称都指定为 “index”确保它们在同一个索引中。

  • DefaultMappingFor 提供了 RelationName ,在 MyParent 上 指定子父级的关联名称为 “parent”。

1
2
3
4
5
6
7
8
9
10
11
public ElasticSearchClient(ElasticSearchSettings esSettings)
{
_esSettings = esSettings;
var settings = new ConnectionSettings(new Uri(_esSettings.ServerUri))
.DefaultMappingFor<MyDocument>(m => m.IndexName("index"))
.DefaultMappingFor<MyChild>(m => m.IndexName("index"))
.DefaultMappingFor<MyParent>(m => m.IndexName("index").RelationName("parent")) //你也可以通过 RelationName 来设定关系名,最终会体现在创建索引的请求上
.EnableDebugMode();

_client = new ElasticClient(settings);
}

通过设置 ConnectionStrings,可以将 MyParent 和 MyChild 映射为索引的一部分,并需要确保子父级文档在同一个分片上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var createIndexResponse = client.Indices.Create("index", c => c
.Index<MyDocument>()
.Map<MyDocument>(m => m
.RoutingField(r => r.Required()) //1.加上 RoutingField,以保证父子文档被分配到同一个分片上
.AutoMap<MyParent>() //2.自动映射所有 MyParent 属性
.AutoMap<MyChild>() //3.自动映射所有 MyChild 属性
.Properties(props => props
.Join(j => j //4.AutoMap 不会映射 JoinField,需要手动指定
.Name(p => p.MyJoinField) // 标记A
.Relations(r => r
.Join<MyParent, MyChild>() //因为 ConnectionSettings 指定了 MyParent 的 RelationName 为 parent,所以这里在映射父/子关系的时候,父类型的名称为 “parent”,而 子类顶的名称会自动反射为类的完整名称(即包含了命名空间)。
)
)
)
)
);
  • 因为 NEST 的 AutoMap 方法不会自动配置 JoinField 映射,所以你需要手动装配。

  • 以上装配方式将 MyChild 设置为 MyParent 的子集。.Join() 方法有许多重载,你可以按需进行选择。

以上命令相当于执行了下面的 ES 命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PUT /index
{
"mappings": {
"properties": {
"childProperty": {
"type": "text"
},
"id": {
"type": "integer"
},
"myJoinField": {
"relations": {
"parent": "mychild"
},
"type": "join"
},
"parentProperty": {
"type": "text"
}
},
"_routing": {
"required": true
}
}
}

  • 在 ConnectionString 上设置映射的时候,把 MyParent 的关系名称设置为了“parent”。稍后在执行强类型的 has_child 和 has_parent 查询时,这也很方便。

添加父、子文档到 ES 中

添加父文档到 ES 中

通过 MyParent 的关系名称(“parent”)标记文档,以下三种方式都是等效的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var parentDocument = new MyParent
{
Id = 1,
ParentProperty = "a parent prop",
MyJoinField = JoinField.Root<MyParent>() // 指明是 MyParent 类的跟文档(会自动对应到"parent")
};

parentDocument = new MyParent
{
Id = 1,
ParentProperty = "a parent prop",
MyJoinField = typeof(MyParent) // 指明是 MyParent 类的跟文档(会自动对应到"parent")
};

parentDocument = new MyParent
{
Id = 1,
ParentProperty = "a parent prop",
MyJoinField = "parent" //可以直接使用在 ConnectionSettings 配置的字段。
};

/* 以下官方示例有问题,虽然作者说包含了 JointField 属性的类不需要再显式指定 routing,但实际测试下来,并非如此---这是一个bug,从 issues 上看已经延续了3年之久,至今未修复。

至于示例文档为什么能跑通且不报错,是因为示例文档并非真实请求---使用了 InMemoryConnection。

// 因为开启了 routing,所以在请求的时候,请求 URL 上需要指定 routing,而以下索引方式并不会添加 ?routing
var indexParent = client.IndexDocument(parentDocument);

*/

//使用 Index 方法来进行索引文档
var indexParent = _client.Index(parentDocument, desc =>
{
desc.Routing(parentDocument.Id); //显式指定 Routing 的值
return desc;
});

最终生成的请求命令 为:

1
2
3
4
5
6
PUT /index/_doc/1?routing=1
{
"parentProperty": "a parent prop",
"id": 1,
"parentChildRelation": "parent"
}

添加子文档到 ES 中

在添加子文档的时候必须指定它的父文档id:

  • 使用 route 参数保证父子文档被分配到相同的分片上。

依葫芦画瓢,将子文档连接到它的父文档上。这里直接使用上面的 parentDocument 对象 来创建连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 同样这里不能使用官方通过 IndexDocument 来索引文档的例子
var indexChild = client.IndexDocument(new MyChild
{
MyJoinField = JoinField.Link<MyChild, MyParent>(parentDocument)
});
*/

//使用 Index 来代替 IndexDocument 以可以显式指定 routing
var child = new MyChild
{
Id = 10002,
ChildProperty = "a child prop",
ParentChildRelation = JoinField.Link<MyChild, MyParent>(parentDocument)
//使用该重载也是可以的 ParentChildRelation = JoinField.Link<MyChild>(1)
};
var indexChild = _client.Index(child, desc =>
{
desc.Routing(parentDocument.Id); //显式指定 routing,同 parentid
return desc;
});

生成的请求命令为:

1
2
3
4
5
6
7
8
9
PUT /index/_doc/10002?routing=1
{
"childProperty": "a child prop",
"id": 10002,
"parentChildRelation": {
"name": "mychild",
"parent": "1"
}
}

最终在 index 索引中,生成的父子文档内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"hits" : [
{
"_index" : "index",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_routing" : "1",
"_source" : {
"parentProperty" : "a parent prop",
"id" : 1,
"parentChildRelation" : "parent"
}
},
{
"_index" : "index",
"_type" : "_doc",
"_id" : "10002",
"_score" : 1.0,
"_routing" : "1",
"_source" : {
"childProperty" : "a child prop",
"id" : 10002,
"parentChildRelation" : {
"name" : "mychild",
"parent" : "1"
}
}
}
]
}

ElasticClient.Inferrer

  • 在索引的时候,需要指定 routing

  • 在过去,你需要在请求中使用 parent= 的方式(在 ES 5.x),或者 routing= 的方式(在 ES 6.x)来路由父子文档,在 Nest 7 中, Nest 提供了一个帮助类来推断正确的路由值,它会根据 JoinField 属性来推断父类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var infer = _client.Infer;
var parentInfo = new MyParent
{
Id = 2,
ParentProperty = "a parent prop 2",
ParentChildRelation = JoinField.Root<MyParent>()
};
var parentRoute = infer.Routing(parentInfo); //结果为父文档id:2

var child = new MyChild
{
Id = 20002,
ChildProperty = "a child prop of P2",
ParentChildRelation = JoinField.Link<MyChild, MyParent>(parentInfo)
};
var childRoute = infer.Routing(child); //结果为 父文档id:2

通过 Routing 方法来改写上面索引父子文档的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//索引父文档
var parentInfo = new MyParent
{
Id = 2,
ParentProperty = "a parent prop 2",
ParentChildRelation = JoinField.Root<MyParent>()
};
var parentResponse = _client.Index(parentInfo, i => i.Routing(Routing.From(parentInfo)));

//索引子文档
var child = new MyChild
{
Id = 20002,
ChildProperty = "a child prop of P2",
ParentChildRelation = JoinField.Link<MyChild, MyParent>(parentInfo)
};
var childResponse = _client.Index(child, i => i.Routing(Routing.From(child)));

最终添加的文档内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"hits" : [
{
"_index" : "index",
"_type" : "_doc",
"_id" : "2",
"_score" : 1.0,
"_routing" : "2",
"_source" : {
"id" : 2,
"parentProperty" : "a parent prop 2",
"parentChildRelation" : "parent"
}
},
{
"_index" : "index",
"_type" : "_doc",
"_id" : "20002",
"_score" : 1.0,
"_routing" : "2",
"_source" : {
"id" : 20002,
"childProperty" : "a child prop of P2",
"parentChildRelation" : {
"name" : "mychild",
"parent" : "2"
}
}
}
]
}