橘子学ES之分词器
一、简介
分词器在es中是一个不可不提的东西,实际上es所谓的全文检索能力正是基于其分词器的能力提供的。
当数据从外部进入es的时候,分词字段(类型为text的字段)会经过分词器的处理,进过分词的内容会根据分词器的逻辑被拆分为一个一个的token。
经过一系列的排布进行存储。
当我们在检索的时候,我们会输入我们的搜索词,同样的,我们的搜索词也会被分词器以同样的逻辑进行处理。然后去和存储的时候的token进行匹配。
所以对于输入文本以及存储文本的分词就显得很重要,分词器的效果和粒度决定了你检索时候的效果表现。
所以我们这里来介绍一下关于分词器的内容。当然你的索引中如果不包含text类型的字段,那你其实不用关心这个问题。
所以其实根据这段描述我们其实知道了分词器工作的位置,就是发生在数据写入的时候,会经过分词器做结果存储。
其次就是会在检索的时候,对检索词做分词器处理,然后得到的结果在去查询匹配。
众所周知的是,es的文档是非常完备的。分词器文档地址
二、分词器的组成
1、规范化(normalization)
es中的分词器由三个部分组成,字符过滤器(character filter),分词器(tokenizer),token过滤器(token filter)。
这三个部分共同作用,完成了文本的规范化(normalization)
这几个部分其中分词器是最重要的,下面我们就挨个来解释一下这几个部分的作用和逻辑,方便我们后续进行使用。
那么为什么需要规范化呢,我们来看一下
当我在ES中存储了一句话,“I am a student and I Love China”.
此时用户再来做检索,用户输入了一个i love china,这时候我们明显看到用户的输入和我们存入的东西有差异。
1、大小写不一致,我们存进去的是大写的I Love China,但是用户输入的却是小写。
2、我们存储的是一句完整的,用户只是搜索其中一段。
基于这些问题,用户查的和我们存的不一致,此时我们需要对数据做规范化,从而让用户的动作和存储的动作保持一致。在ES中的规范化包含以下几个内容。
- 切词(word segmentation):把输入以及存入的文本按照规则切分然后存储和匹配。
- 规范化(normalization):对切分的词汇做规范化,包括大写转小写,复数转单数,去除介词和停用词等。
- 去重(distinct):对于切分后重复的词汇进行去重处理。
- 字典序(sorted):对于切分后的词汇进行字典序排序进行存储。
有了规范化这个操作,我们就来看一下,我们在存储入ES的时候,我们存储了一句文本
You are not a Nobody, You are My friends.
我们不要去考虑有没有语法问题,可能就是个英语菜鸡(比如我)存进去的。
此时ES在存储的时候,会把这句话进行规范化,包括切词,规范化,去重,字典序。此时这句话被处理为。
friend my not nobody somebody you
你能看到,他的介词和一些副词这种对于检索没意义的词就被去掉了,并且他的大写都被转小写了,并且相同的词汇被移除了,并且复数也被转为了单数,而且按照字典序进行了排序。这样处理之后,用户再搜you are my friend的时候,
就可以命中了,因为用户输入的词汇也会被规范化,用户输入的内容变成了friend you my这样。此时就是可以匹配了。
否则如果双方规则不一致,最后就会导致,明明我要搜的就是那个意思,但是你因为一些时态,或者单复数或者大小写最终导致不能命中。
所以我们可以看到,规范化发生的时机就是分词阶段,不管是存储的分词还是搜索词的分词 都会进行相同规则的规范化,此时就能保持一致的语义然后进行匹配。这就是规范化的作用。
但是我们说规范化是一种思想,他的作用就是存储和检索保持一致的语义,避免因为一些无关的因素影响检索命中。
这种思想下每个分词器都有自己的实现,ES中提供了一种查看分词的语法 _analyze 可以观察到分词的结果。我们来看一下如何使用。
# 我们使用标准分词器来查看他的分词结果
GET _analyze
{
"text": ["You are not a Nobody, You are My friends."],
"analyzer": "standard"
}
结果如下:
{
"tokens": [
{
"token": "you",
... 省略和规范化无关的
},
{
"token": "are",
},
{
"token": "not",
},
{
"token": "a",
},
{
"token": "nobody",
},
{
"token": "you",
},
{
"token": "are",
},
{
"token": "my",
},
{
"token": "friends",
}
]
}
我们看到标准分词器没有帮我们做复数转单数这种,以及一些冠词也没去掉。这就是他的实现,再说一次每种分词器的实现并不相同。
而不管怎么实现,无论是内置的还是自定义的 — 都只是一个包含三个较低级别构建块的包:字符过滤器、 tokenizers 和 token 过滤器。
内置分析器将这些构建块预先打包到适用于不同语言和文本类型的分析器中。Elasticsearch 还公开了各个构建块,以便可以将它们组合起来定义新的自定义分析器。内置的你可以直接使用。
2、字符过滤器(character filter)
字符过滤器用于在将字符流传递给分词器之前对其进行预处理。所以我们可以看到他的作用时机位于传递给分词器之前的一步。
字符筛选器将原始文本作为字符流接收,并可以通过添加、删除或更改字符来转换流。例如,字符过滤器可用于将印度教-阿拉伯数字 (٠١٢٣٤٥٦٧٨٩) 转换为其阿拉伯语-拉丁语等价物 (0123456789),或从流中去除 等 HTML 元素。
而且,分析器可以有零个或多个字符筛选器,这些筛选器是按顺序应用的。
我说白了,他的作用是对原始文本进行一些字符的去除,比如我们源端的文本有一些html标签或者是其他标签我们要在存储的时候进行去除。
es中内置了几种字符过滤器,你可以用这几种来进行组合,实现你自定义的字符过滤器。而且他可以同时配置多个,他是按照配置顺序来生效的。
下面我们来一一看一下这几种内置处理器。
2.1、HTML Strip
2.1.1、基本使用
The html_strip character filter strips out HTML elements like and decodes HTML entities like &.
html_strip字符过滤器去除 HTML 元素(如 )并解码 HTML 实体(如 &)。对应的lucene的类位于HTMLStripCharFilter 。
我们来使用api先测试一下,以下 analyze API 请求使用 html_strip筛选器将文本
I'm so happy!
更改为 \n我太高兴了!GET /_analyze
{
"tokenizer": "keyword",
"char_filter": [
"html_strip"
],
"text": "<p>I'm so <b>happy</b>!</p>"
}
先不要关注tokenizer这个关键词,我们这句代码的含义就是我们对文本内容为
"
I’m so happy!
"的文本,使用html_strip这个字符过滤器进行处理,我们来看看那处理结果是啥。{
"tokens": [
{
"token": """
I'm so happy!
""",
......
}
]
}
我们看到他把html标签成功移除了,符合我们的预期。
这个案例比较简陋,就是用_analyze看了一下,实际上我们开发的时候,在你正式创建索引之前一般都是先看下是不是符合你的要求。下面我们在创建索引中实际使用一下。我们来创建索引 API 请求使用 html_strip过滤器配置新的 自定义分析器。
PUT /my-index-000001
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "keyword",
"char_filter": [
"html_strip"
]
}
}
}
}
}
不要管这个语法,我们在后面自定义分词器的时候会详细来分析。这就是创建了一个索引,配置了一个分词器,分词器我们说是由三个部分组成的(字符过滤器、 tokenizers 和 token 过滤器),我们这里就是指定了两个,tokenizers是keyword,char_filter用的内置的html_strip。而这个分词器我们命名为my_analyzer我们来看下结果。
GET my-index-000001/_analyze
{
"analyzer": "my_analyzer",
"text": "<p>I'm so <b>happy</b>!</p>"
}
结果如下:
{
"tokens": [
{
"token": """
I'm so happy!
""",
......
}
]
}
符合预期。
2.1.2、可配参数
(可选,字符串数组)不带尖括号的 HTML 元素数组 (< >)。从文本中去除 HTML 时,过滤器会跳过这些 HTML 元素。例如,值 [ “p” ] 会跳过
HTML 元素。
这个过滤器在使用的时候是可以配置一个参数的,那就是保留词,有时候我们也不是都要过滤掉的,有些词汇还是要保留。这时候就要使用一个配置。escaped_tags ,这个配置可以添加你过滤之外要保留的词汇。默认这个为空,也就是所有的html标签都被过滤。
假如此时我要保留我文本中的p标签,我要留着分段展示,其他的html都过滤。
我们可以这么做。我们来在索引中自定义一个分词器,分词器中的字符过滤器我们自己来定义。
这个dsl中我们自己定义了一个char_filter叫做my_custom_html_strip_char_filter,我们的分词器名字叫做my_analyzer。
PUT my-index-000001
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "keyword",
"char_filter": [
"my_custom_html_strip_char_filter"
]
}
},
"char_filter": {
"my_custom_html_strip_char_filter": {
"type": "html_strip",
"escaped_tags": [
"p"
]
}
}
}
}
}
我们来看一下。
GET my-index-000001/_analyze
{
"analyzer": "my_analyzer",
"text": "<p>I'm so <b>happy</b>!</p>"
}
输出结果为:
{
"tokens": [
{
"token": """
I'm so <b>happy</b>!
""",
}
]
}
我们看到他去除了p标签之外的html标签。
2.2、Mapping
映射字符筛选器接受键和值的映射。每当遇到与键相同的字符串时,它都会将它们替换为与该键关联的值。替换项可以是空字符串。
映射过滤器使用 Lucene 的 MappingCharFilter中。
我说白了,映射字符筛选器将指定字符串的任何匹配项替换为指定的替换项。
换言之就是可以把他源端的一些字符映射为你定义的,比如源端是刘亦菲,你存入进去的时候变成老婆,也没毛病。或者有些时候有些特殊符号,在你们内部是有别的含义的,你可以做一些映射,比如吧one映射为1,或者一些加密脱敏等等。看你具体的需求。
2.2.1、基本使用
以下分析 API 请求使用映射筛选器将刘亦菲映射成老婆,把刘德华映射成偶像。
GET /_analyze
{
"tokenizer": "keyword",
"char_filter": [
{
"type": "mapping",// 这里指定了类型为mapping
"mappings": [
"刘亦菲 => 老婆",
"刘德华 => 偶像"
]
}
],
"text": "你好,刘亦菲。你也好,刘德华"
}
输出结果为:
{
"tokens": [
{
"token": "你好,老婆。你也好,偶像"
}
]
}
ok,完美符合预期。
我们也可以在自定义分词器中使用。
my_mappings_char_filter 过滤器将 😃 和 😦 表情符号替换为等效文本。
PUT /my-index-000001
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "standard",
"char_filter": [
"my_mappings_char_filter"
]
}
},
"char_filter": {
"my_mappings_char_filter": {
"type": "mapping",
"mappings": [
":) => _happy_",
":( => _sad_"
]
}
}
}
}
}
2.3、Pattern Replace
pattern_replace字符筛选器将匹配正则表达式的任何字符替换为指定的替换。
他是mapping的一种升级版,mapping那种你的自己挨个映射,这种就是直接用正则去映射,更加通用且强大。
2.3.1、注意点
1、模式替换字符筛选器要使用 Java 正则表达式。
2、编写不当的正则表达式可能会运行得非常慢,甚至引发 StackOverflowError 并导致运行它的节点突然退出。es中对于正则捕获实际上一直很避讳。
关于更多的细节可以查看正则
2.3.2、基本使用
我们直接来看案例。
PUT my-index-000001
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "standard",
"char_filter": [
"my_char_filter"
]
}
},
"char_filter": {
"my_char_filter": {
"type": "pattern_replace",
"pattern": "(\\d+)-(?=\\d)",
"replacement": "$1_"
}
}
}
}
}
POST my-index-000001/_analyze
{
"analyzer": "my_analyzer",
"text": "My credit card is 123-456-789"
}
在此示例中,我们配置了 pattern_replace 字符过滤器,以将数字中的任何嵌入破折号替换为下划线,即 123-456-789 → 123_456_789:
其输出为以下内容。
[ My, credit, card, is, 123_456_789 ]
我们可以看到,他被替换成功了,只要符合正则的表达,都会按照我们配置的映射进行替换。
但是存在问题,问题就是使用更改原始文本长度的替换字符串将用于搜索目的,但会导致不正确的高亮展示。我们上面那个例子就是吧-替换为_,并没有改变文本的长度。所以他的每个词汇被拆分之后的相对位置还是不变的。而当我们把长度改变了,就会出现问题。
如以下示例所示。
此示例在遇到小写字母后跟大写字母(即 fooBarBaz → foo Bar Baz)时插入一个空格,从而允许单独查询驼峰式命名法单词:
PUT my-index-000001
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "standard",
"char_filter": [
"my_char_filter"
],
"filter": [
"lowercase"
]
}
},
"char_filter": {
"my_char_filter": {
"type": "pattern_replace",
"pattern": "(?<=\\p{Lower})(?=\\p{Upper})",
"replacement": " "
}
}
}
},
"mappings": {
"properties": {
"text": {
"type": "text",
"analyzer": "my_analyzer"
}
}
}
}
POST my-index-000001/_analyze
{
"analyzer": "my_analyzer",
"text": "The fooBarBaz method"
}
输出结果为:
{
"tokens": [
{
"token": "the",
"start_offset": 0,
"end_offset": 3,
"type": "<ALPHANUM>",
"position": 0
},
{
"token": "foo",
"start_offset": 4,
"end_offset": 6,
"type": "<ALPHANUM>",
"position": 1
},
{
"token": "bar",
"start_offset": 7,
"end_offset": 9,
"type": "<ALPHANUM>",
"position": 2
},
{
"token": "baz",
"start_offset": 10,
"end_offset": 13,
"type": "<ALPHANUM>",
"position": 3
},
{
"token": "method",
"start_offset": 14,
"end_offset": 20,
"type": "<ALPHANUM>",
"position": 4
}
]
}
看着没毛病,
如果你此时用来检索bar,你是 可以正确找到文档,但在结果上突出显示将产生不正确的高亮,因为我们的字符过滤器更改了原始文本的长度:
我们来测试一下。
PUT my-index-000001/_doc/1?refresh
{
"text": "The fooBarBaz method"
}
GET my-index-000001/_search
{
"query": {
"match": {
"text": "bar"
}
},
"highlight": {
"fields": {
"text": {}
}
}
}
输出内容为:
{
......
"hits": [
{
"_source": {
"text": "The fooBarBaz method"
},
"highlight": {
"text": [
"The foo<em>Ba</em>rBaz method"
]
}
}
]
}
}
你可以看到,他高亮的位置不是我们的搜索词,bar,而是偏移了一位。那么这事为什么呢?
我们来看The fooBarBaz method经过我们定义的过滤器之后的分词结果:
我们的预设是 fooBarBaz → foo Bar Baz
{
"tokens": [
{
"token": "the",
"start_offset": 0,
"end_offset": 3,
},
{
"token": "foo",
"start_offset": 4,
"end_offset": 6,
},
{
"token": "bar",
"start_offset": 7,
"end_offset": 9,
},
{
"token": "baz",
"start_offset": 10,
"end_offset": 13,
},
{
"token": "method",
"start_offset": 14,
"end_offset": 20,
}
]
}
3、分析器(tokenizer)
在经过了上面的对于文本中一些没啥卵用的字符的过滤之后,此时我们的文本就留下的都是正经词汇了,此时我们就可以对他进行分词了,我们就来到了分词器阶段。
分词器接收字符流,将其分解为单独的字符 标记(通常是单个单词),并输出标记流。例如,空白分词器会中断 text 转换为标记。它会转换文本 “快棕色狐狸!”变成 [快,棕色,狐狸!
或者是大写变小写,复数变单数都在这个阶段完成。
分词器还负责记录每个术语的顺序或位置,以及该术语所代表的原始单词的开始和结束字符偏移量。
而且要注意,我们的分词器只能有一个tokenizer。
你可能注意到这个tokenizer也被称之为分词器,但是他只是我们常说的分词器的一部分,所以我们这里修改一下他的术语,我们叫他分析器。
分析器还负责记录以下内容:
- 每个词的顺序或位置(用于短语和单词邻近度查询)
- 原始单词的开始和结束字符偏移量(用于突出显示搜索片段)。
- 标记类型,生成的每个术语的分类,例如 , ,或 。更简单的分析器仅生成单词标记类型。
Elasticsearch 有许多内置的分析器,可用于构建 自定义分析器。
分析器分为以下三种。
3.1、面向单词的分词器
以下分词器通常用于将全文分词为单个单词:
- Standard tokenizer(标准分词器)
标准分词器将文本划分为单词边界上的词汇,如 Unicode 文本分割算法所定义。它会删除大多数标点符号。它是大多数语言的最佳选择。他很简单,就是按照单词给你拆分,并且移除很多标点符号。
还有很多类型,后面我们会单独搞一篇文章来写各类常见的分词器。
3.2、部分单词分词器
这些分词器将文本或单词分解成小片段,以实现部分单词匹配:
常见的就是gram分词器,后面我们会着重来描述这个分词器
3.3、结构化文本分词器
以下分词器通常用于结构化文本(如标识符、电子邮件地址、邮政编码和路径),而不是与全文一起使用:
4、令牌筛选器(token filter)
在经过去除一些符号,并且拆分之后,我们得到了一个一个的token,此时我们需要对每个token做处理。
令牌筛选器接收令牌流,并可以添加、删除或更改令牌。例如,小写标记 filter 将所有标记转换为小写,则 停止令牌过滤器会从令牌流中删除常用词(停用词),例如 the ,并且 同义词标记筛选器 将同义词引入标记流。
注意:
- 不允许令牌过滤器更改每个令牌的位置或字符偏移量。
- 分词器可能有零个或多个令牌筛选器,这些筛选器按顺序应用。
令牌筛选器接受来自 tokenizer 并可以修改标记(例如小写)、删除标记(例如删除停用词)或添加标记(例如同义词)。
Elasticsearch 具有许多内置令牌过滤器,您可以使用它们来构建自定义分析器。
同样,他也有很多内置的,我们不需要每个都看一遍,他的位置位于内置分析器
我们只需要来看一个简单的,来明白他如何使用即可。
4.1、Lowercase token filter(小写token筛选器)
以下 analyze API 请求使用默认的 lowercase 过滤器将 Quick FoX JUMP 更改为小写:
GET _analyze
{
"tokenizer" : "standard",
"filter" : ["lowercase"],
"text" : "THE Quick FoX JUMPs"
}
输出为如下:
[ the, quick, fox, jumps ]
以下创建索引 API 请求使用 lowercase 过滤器配置新的 自定义分析器。
PUT lowercase_example
{
"settings": {
"analysis": {
"analyzer": {
"whitespace_lowercase": {
"tokenizer": "whitespace",
"filter": [ "lowercase" ]
}
}
}
}
}
4.2、可配置参数
- language 语言
(可选配置,字符串类型)要使用的特定于语言的小写标记筛选器。有效值包括:
greek 希腊语:使用 Lucene 的 GreekLowerCaseFilter
irish 爱尔兰语:使用 Lucene 的 IrishLowerCaseFilter
turkish 土耳其语:使用 Lucene 的 TurkishLowerCaseFilter
如果未指定,则默认为 Lucene 的 LowerCaseFilter
和其他的组件一样,他的可配置参数的使用需要在自定义组件里面使用,默认的位置无法提供。
例如,以下请求为希腊语创建自定义小写过滤器:
PUT custom_lowercase_example
{
"settings": {
"analysis": {
"analyzer": {
"greek_lowercase_example": {
"type": "custom",
"tokenizer": "standard",
"filter": ["greek_lowercase"]
}
},
"filter": {
"greek_lowercase": {
"type": "lowercase",
"language": "greek"
}
}
}
}
}
至此,我们就完成了es中对于文本的规范化,我们的原始文本经过字符过滤器除去一部分"杂质"字符之后,变成一个干净的文本。
然后经过分析器的处理,变成一个一个的词,并且大写转为小写,复数变为单数,
然后一个一个的词汇进行处理,如果是停用词就移除不处理,如果是介词啥的没用的也移除。
于是我们就得到了一个个的term,可以建立倒排索引了
倒排索引。
三、自定义分词器
我们已经知道了分词器的三个组成,其实es中是支持你自己组合这三个部分,进而形成一个自定义的分词器。
我们来编一个需求,我们从源端过来的数据,可能长这样,
I' am so happy!
, & you give me a gift,so can You say fuck you JAVA. 我很开心,并且你给了我一个礼物,所以你能说一句去你的java吗。 这就是本句话的意思,1、我们看到有很多html标签,我们要去掉。(char_filter用html_strip)
2、但是html标签我们要保留p标签,因为后面要分段落。
3、他的&符号表达不明确,我们要替换为and,还有那种fuck换为love.(char_filter用mapping)
4、你能看到他有很多大写的不规范,我们要换成小写的统一(tokenizer用lowercase)。
5、而且他有脏话,我们的客户有一组停用词包括fuck tmd等等都要屏蔽掉(filter用stop来禁用一部分词汇)。
最后的结果应该是
i am so happy
,and you give me a gift,so can you say you java.虽然不通,但是符合要求。那么我们来定义这个分词器。PUT my-index-000001
{
"settings": {
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"char_filter": [
"my_custom_html_strip_char_filter",
"my_custom_mapping_char_filter"
],
"tokenizer": "my_tokenizer",
"filter": [
"my_filter"
]
}
},
"tokenizer": {
"my_tokenizer": {
"type": "lowercase"
}
},
"char_filter": {
"my_custom_html_strip_char_filter": {
"type": "html_strip",
"escaped_tags": [
"p"
]
},
"my_custom_mapping_char_filter": {
"type": "mapping",
"mappings": [
"& => and",
"| => or",
"fuck => love"
]
}
},
"filter": {
"my_filter": {
"type": "stop",
"ignore_case": true,
"stopwords": [
"fuck",
"tmd",
"nmd"
]
}
}
}
}
}
我们来测试一下,
POST my-index-000001/_analyze
{
"analyzer": "my_custom_analyzer",
"text": "<p>I' am so <b>happy</b>!</p>, & you give me a gift,so can You say fuck you JAVA."
}
输出如下:
{
"tokens": [
{
"token": "p",
"start_offset": 1,
"end_offset": 2,
"type": "word",
"position": 0
},
{
"token": "i",
"start_offset": 3,
"end_offset": 4,
"type": "word",
"position": 1
},
{
"token": "am",
"start_offset": 11,
"end_offset": 13,
"type": "word",
"position": 2
},
{
"token": "so",
"start_offset": 14,
"end_offset": 16,
"type": "word",
"position": 3
},
{
"token": "happy",
"start_offset": 20,
"end_offset": 29,
"type": "word",
"position": 4
},
{
"token": "p",
"start_offset": 32,
"end_offset": 33,
"type": "word",
"position": 5
},
{
"token": "and",
"start_offset": 36,
"end_offset": 37,
"type": "word",
"position": 6
},
{
"token": "you",
"start_offset": 38,
"end_offset": 41,
"type": "word",
"position": 7
},
{
"token": "give",
"start_offset": 42,
"end_offset": 46,
"type": "word",
"position": 8
},
{
"token": "me",
"start_offset": 47,
"end_offset": 49,
"type": "word",
"position": 9
},
{
"token": "a",
"start_offset": 50,
"end_offset": 51,
"type": "word",
"position": 10
},
{
"token": "gift",
"start_offset": 52,
"end_offset": 56,
"type": "word",
"position": 11
},
{
"token": "so",
"start_offset": 57,
"end_offset": 59,
"type": "word",
"position": 12
},
{
"token": "can",
"start_offset": 60,
"end_offset": 63,
"type": "word",
"position": 13
},
{
"token": "you",
"start_offset": 64,
"end_offset": 67,
"type": "word",
"position": 14
},
{
"token": "say",
"start_offset": 68,
"end_offset": 71,
"type": "word",
"position": 15
},
{
"token": "love",
"start_offset": 72,
"end_offset": 76,
"type": "word",
"position": 16
},
{
"token": "you",
"start_offset": 77,
"end_offset": 80,
"type": "word",
"position": 17
},
{
"token": "java",
"start_offset": 81,
"end_offset": 85,
"type": "word",
"position": 18
}
]
}
至此我们完成了分词器的介绍,而内置的一些常用的分词器我们其实很多用不到,因为国内一般用的都是中文分词器,这个后面我们会以实际案例来介绍,并且关于停用词的热更新我们也会在后面介绍。
原文地址:https://blog.csdn.net/liuwenqiang1314/article/details/144024207
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!