ES(搜索型数据库)
ES介绍
ES全称是elasticsearch,其可以帮助我们从海量数据中快速找到所需的内容
其与kibana、logstash、beats结合总称为ELK,被广泛应用在日志数据分析,实时监控等领域
在其三层架构中,只有ES是核心架构,其他的内容都是可以替换的
ES的核心是Lucene,是Apache公司开发的项目,其具有易扩展,搞性能的优势,之所以高性能,是因为其内部基于倒排索引开发
但是其缺点也很大,首先是只能用java语言,其次是学习难度大,最后是不支持扩展,想要自定义那是根本不可能
后来SB基于Lucene开发了Compass,再后来又重写Compass并取名为ES
相比于Lucene,ES具有支持分布式,可以水平扩展,且提供Restful接口,可以被任何语言调用的好处
最后我们来看看总结
倒排索引
接着我们来介绍下什么是倒排索引
传统数据库采用的是正向索引,会给数据表根据id创建唯一索引并形成B+树结构,如果我们搜索id那就很快,但是搜索关键词的话,其是采用线性扫描方式将符合条件的结果存入结果集中并展示,这个效率在面对大量数据时是不可接受的
而倒排索引是将每一个数据当做一个文档,每一个关键词根据语义分成的词语称之为词条,其会记录所有词条和有该词条的文档id,当用户输入关键词时,会同样分割关键词为词条,根据词条查找id并加入结果集中,结果集中根据id的出现次数来展示结果
这就是其效率高的原因
最后我们来看看总结
es与mysql的概念对比
ES是 面向文档存储,其内部所有的数据都是以json格式存储的
其具有索引,索引就类似于是mysql中的表,映射则类似于mysql中的表结构
具体更加具体的概念对应关系请看下图
es擅长大量数据的搜索和分析,但是不能确保数据的安全性,实际开发中常常搜索分配给ES干,而写则交由给MySQL做,同时会有保证ES和MySQL数据同步的方法
最后我们可以来做个总结
部署和安装
部署单点ES
因为我们还需要部署kibana容器,因此需要让es和kibana容器互联。这里先创建一个网络:
docker network create es-net
如果显示网络已经存在,那就不用创建了
这里我们采用elasticsearch的7.12.1版本的镜像,这个镜像体积非常大,接近1G。不建议大家自己pull。
课前资料提供了镜像的tar包:
大家将其上传到虚拟机中,然后运行命令加载即可:
# 导入数据
docker load -i es.tar
同理还有kibana的tar包也需要这样做。
运行docker命令,部署单点es:
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v es-data:/usr/share/elasticsearch/data \
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1
命令解释:
-e "cluster.name=es-docker-cluster":设置集群名称-e "http.host=0.0.0.0":监听的地址,可以外网访问-e "ES_JAVA_OPTS=-Xms512m -Xmx512m":内存大小-e "discovery.type=single-node":非集群模式-v es-data:/usr/share/elasticsearch/data:挂载逻辑卷,绑定es的数据目录-v es-logs:/usr/share/elasticsearch/logs:挂载逻辑卷,绑定es的日志目录-v es-plugins:/usr/share/elasticsearch/plugins:挂载逻辑卷,绑定es的插件目录--privileged:授予逻辑卷访问权--network es-net:加入一个名为es-net的网络中-p 9200:9200:端口映射配置
启动时报错IPv4 forwarding is disabled. Networking will not work,可以参照这个教程解决blog.csdn.net/qq_42355392…
在浏览器中输入:http://175.178.114.158:9200/ 即可看到elasticsearch的响应结果:
使用插件json-handle,可以达成上面的效果网页浏览json响应的优美效果
部署kibana
kibana可以给我们提供一个elasticsearch的可视化界面,便于我们学习。
运行docker命令,部署kibana
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
--network es-net:加入一个名为es-net的网络中,与elasticsearch在同一个网络中-e ELASTICSEARCH_HOSTS=http://es:9200":设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch-p 5601:5601:端口映射配置
kibana启动一般比较慢,需要多等待一会,可以通过命令:
docker logs -f kibana
查看运行日志,当查看到下面的日志,说明成功:
此时,在浏览器输入地址访问:http://175.178.114:5601,即可看到结果
同时kibana中提供了一个DevTools界面:
这个界面中可以编写DSL来操作elasticsearch。并且对DSL语句有自动补全功能。
IK分词器安装
kibana中原生的分词器虽然也能支持分词,但是效果很烂
我们可以举一个例子,先来看看下面的代码
# 测试分词器 POST /_analyze { "text": "传智教育的课程可以白嫖嗯嗯嗯嗯嗯,而且就业率高达95%,奥利给!", "analyzer": "english" }
使用这种分词最终的效果就是每一个词都被分了,压根就没有词语的说法,这玩毛啊,浪费空间不提,还降低效率,所以我们还需要安装我们的IK分词器
下图是对分词器和查询语法的介绍
我们安装IK分词器有两种方式,一种是在线安装,另一种是离线安装,我们这里推荐使用后者
首先安装插件需要知道elasticsearch的plugins目录位置,而我们用了数据卷挂载,因此需要查看elasticsearch的数据卷目录,通过下面命令查看:
docker volume inspect es-plugins
显示结果:
[
{
"CreatedAt": "2022-05-06T10:06:34+08:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
"Name": "es-plugins",
"Options": null,
"Scope": "local"
}
]
说明plugins目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data这个目录中。
下面我们需要把课前资料中的ik分词器解压缩,重命名为ik
然后上传到也就是/var/lib/docker/volumes/es-plugins/_data中
我的做法是先在该文件夹中创建ik文件夹,然后进入ik中上传压缩包再解压
然后我们要重启容器
# 4、重启容器
docker restart es
# 查看es日志
docker logs -f es
IK分词器包含两种模式:
ik_smart:最少切分ik_max_word:最细切分
最少切分指的是智能切分尽可能少的词用于搜索,而最细切分则会让我们的分词尽可能分出更多的情况,这两者如何使用取决于我们的具体业务情况
更新与停用词典
同时,我们的词典必然不可能包含所有的词,有时候会出现很多新词,此时我们的词汇也就需要更新,IK分词器提供了扩展词汇的功能
首先我们要打开IK分词器config目录:
然后在IKAnalyzer.cfg.xml配置文件内容添加:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 *** 添加扩展词典-->
<entry key="ext_dict">ext.dic</entry>
</properties>
新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改
传智播客
奥力给
重启elasticsearch
docker restart es
# 查看 日志
docker logs -f elasticsearch
日志中已经成功加载ext.dic配置文件
测试效果:
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "传智播客Java就业超过90%,奥力给!"
}
注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑
虽然话是这么说,但我就是用Windows记事本编辑的,也没啥毛病,注意格式要是UTF-8就行了
同时其还提供了停用词典功能,可以让某些词不会被细分
首先IKAnalyzer.cfg.xml配置文件内容添加:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典-->
<entry key="ext_dict">ext.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典 *** 添加停用词词典-->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
然后在 stopword.dic 添加停用词
这是一个停用词测试案例
重启elasticsearch
# 重启服务
docker restart elasticsearch
docker restart kibana
# 查看 日志
docker logs -f elasticsearch
日志中已经成功加载stopword.dic配置文件
测试效果:
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "传智播客Java就业率超过95%,违禁词都点赞,奥力给!"
}
索引库操作
现在开始我们来学习索引库的相关操作,首先我们先来介绍下ES中比较重要的两个属性,Mapping
Mapping
mapping是对索引库中文档的约束,常见的mapping属性包括type、index、analyzer、properties,我们这里来进行逐一的讲解
首先type是字段数据类型,常见的简单类型首先就是字符串,而字符串又分为text和keyword,前者是可以用于分词的文本,而后者则是精确值,常防止品牌、国家、ip地址等信息
type属性下还有数值,数值则是long、integer、byte、double等数据类型,还有布尔类型和日期类型
还有一种情况是某一个数据是包括了其他数据组合而成的数据,这种嵌套的结构则属于是对象object
Mapping下还有属性index,该属性代表的是对应的是对应的字段是否创建索引,默认为true是创建索引的,当然有时候我们是要指定其为false的,比如像邮箱或者是图片url地址一类的东西,完全没有搜索必要的,那些东西还创建索引干嘛啊,没事浪费自己空间吗,如果我们全不指定,那么索引库中的所有字段都会被创建索引,也就都会参与搜索
然后是analyzer,其代表的是使用哪种分词器,这个用得比较少,因为也就结合"text"使用,毕竟也就是有text属性的字段才需要分词,其他不需要
properties则代表的是一个字段的子字段,可以用其来指定某个属性的子属性
索引库CURD
首先我们来学习如何创建一个索引库,在ES中是通过Restful的请求操作索引库以及文档的,请求的内容则用DSL语句来表示,其创建索引库和Mapping的DSL语法如下
我们这里再提一遍,Mapping就相当于是MySQL中的也就是数据表,索引库则相当于一个数据库,每一个字段就相当于是一条数据,每一个数据,也就是字段中都有各种属性可以指定,其中index属性如果不指定,则会默认给这个字段创建索引,令其参与搜索
那么接着我们来看看我们创建索引库的语句,DSL语句的概念就类似于mysql中的sql语句
# 创建索引库 PUT /heima { "mappings": { "properties": { "info": { "type": "text", "analyzer": "ik_smart" }, "email": { "type": "keyword", "index": false }, "name": { "type": "object", "properties": { "firstName": { "type": "keyword" }, "lastName": { "type": "keyword" } } } } } }
我们这里调用PUT命令来创建索引库,指定索引库的名称为heima,然后首先指定mapping,接着指定properties来创建其子字段,其下我们指定info字段,type属性指定为text,分词器则使用智能分词,接着指定email属性,由于是邮箱属性,因此指定其type属性为关键词属性,同时其不参与搜索,因此index属性为false
接着是name属性,先指定其type为object,然后用properties指定其子属性,我们这里指定两个属性,分别是firstName和lastName,并且其type属性都指定为keyword
接着我们来演示查看和删除,查看和删除的语法非常简单,分别使用GET和DELETE关键词后面接/索引库名即可
但是修改的话就有些麻烦了,ES中的数据理论上是不允许修改的,但是可以添加新的字段,其语法是PUT /索引库名/_mapping
下面是演示代码
查询 GET /heima
修改索引库,添加新字段 PUT /heima/_mapping { "properties": { "age":{ "type": "long" } } }
删除 DELETE /heima
最后我可以来做一个总结
文档CURD
接着我们来学习文档的CURD
添加文档的DSL语法非常简单,就是POST /索引库名/_doc/文档id,注意我们这里的文档id是一定要分配的,这是我们后面查询对应文档的一个重要
查询文档的语法是 GET /索引库名/_doc/文档id
删除文档的语法是 DELETE /索引库名/_doc/文档id
接着我们来讲讲修改文档
修改文档就两种方法,一种是全量修改,跟新增文档的相差无几,除了POST要改为PUT之外,这种修改会删除旧文档然后添加新文档,如果旧文档不存在,那么就直接添加对应的新文档
第二种是增量修改,其用于修改指定的字段值,其语法是POST /索引库名/_update/文档id接大括号,然后接doc属性接大括号,括号内指定字段名和新的值
下面是我们的演示代码
查询文档 GET /heima/_doc/1
删除文档 DELETE /heima/_doc/1
全量修改文档 PUT /heima/_doc/1 { "info": "黑马程序员Java讲师", "email": "ZhaoYun@itcast.cn", "name": { "firstName": "云", "lastName": "赵" } }
局部修改文档字段 POST /heima/_update/1 { "doc": { "email": "ZYun@itcast.cn" } }
RestClient
当然,我们都知道我们以后肯定是要跟Java代码打交道的,所以我们当然是要学习如何使用工具来编写DSL语句,通过工具的语句来实现操作ES
而ES官方提供了各种不同语言的客户端用于操作ES,本节我们就来学习其提供给我们的RestClient
接着我们来使用JavaRestClient来实现CURD,先来看看案例步骤
首先我们需要导入我们的课前资料,这没啥特别值得说的
hotel数据结构分析
接着我们来对我们的hotel的数据的结构进行分析,一边分析,一边来编写我们的创建对应的索引库的DSL语句
首先我们既然是创建酒店的mapping,那么我们的当然要使用PUT请求,然后指定名字,写入mappings和properties属性
首先我们指定id,在mysql中id的值是bigint,那我们在这里难道应该指定为long类型的数据吗?显然不是,因为我们的long类型的数据真正进入到ES中,是以字符串的形式存在的,而id又是唯一标识需要参与搜索,同时不需要分割,那么我们用keyword类型显然是最为适合的
而name字段是肯定要参与搜索的,而且是有可能改变的,所以其类型为text比较适合,我们令其拆分器为最大拆分器,这样就尽可能增加酒店被搜索到的概率
然后是地址,我们这里不需要根据地址搜索酒店,因此酒店的地址不做拆分,同时酒店的地址是不会变的,所以选择keyword关键字,index赋值为false
价格很显然是需要参与搜索的,类型当然是integer类型的,score评分同理
品牌是不会变的,因此选择用keyword类型,同理还有城市,星级,商圈
这里需要着重提一下的是我们的经纬度,在ES中是支持两种地理坐标的数据类型的,一种是geo_point,其是由纬度和经度确定的一个点。另一种是geo_shape,其是由多个geo_point组成的复杂集合图形,可以是一条直线,也可以是其他图形
我们这里选择前者来表示酒店的位置,这是当然的,因为酒店再大一般也就是地球上的一个点嘛,你难不成还要用一个圈来表示它啊?这不搞笑吗
因此我们这里用location代表位置,内部属性为geo_point
然后是图片,图片的类型指定为keyword,因为url地址一般是不会变的,然后url地址参与搜索没有什么意义,因此其不参与搜索,也就是不产生索引,所以index为false
最后用户发出搜索请求的时候,我们比起搜索多个可能字段,搜索一个字段显然会效率更高,但是我们又希望我们的用户都可以同时搜索到多个我们创建过索引的字段,ES为了解决这个问题,提供了copy_to属性,可以将当前字段拷贝到指定字段,使用的方法非常简单,在最后面定义一个字段,令其属性为text,拆分器为最大拆分器即可,然后我们在想要拷贝的字段中加入copy_to属性,属性中填入我们之前定义的字段的名字即可
那么最终我们写入的DSL语句就如下所示
# 酒店的mapping PUT /hotel { "mappings": { "properties": { "id": { "type": "keyword" }, "name":{ "type": "text", "analyzer": "ik_max_word", "copy_to": "all" }, "address": { "type": "keyword", "index": false }, "price": { "type": "integer" }, "score": { "type": "integer" }, "brand": { "type": "keyword", "copy_to": "all" }, "city": { "type": "keyword" }, "starName": { "type": "keyword" }, "business": { "type": "keyword", "copy_to": "all" }, "location": { "type": "geo_point" }, "pic": { "type": "keyword", "index": false }, "all": { "type": "text", "analyzer": "ik_max_word" } } } }
注意,我们这里只是创建了我们的索引库而已,如果我们想要具体看到我们的数据,我们还需要进行批量数据的导入
初始化RestClient
接着我们来学习如何初始化JavaRestClient,先来看看步骤
首先我们要引入JavaRestClient的依赖
<!--elasticsearch-->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.12.1</version>
</dependency>
这里我们值得一提的是,由于Springboot内部对各种依赖做了版本控制,而我们这里需要统一我们JavaRestClient及其其他依赖为7.12.1版本的,因此我们需要在properties标签下加入此行代码
<properties>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
然后我们创建一个测试类,里面引入我们的RestHighLevelClient对象,并实现setUp和tearDown方法,注意这里setUp内部是可以放多个地址的,前提是如果我们是集群部署的话,用,隔开即可
public class HotelIndexTest {
private RestHighLevelClient client;
@Test
void testInit(){
System.out.println(client);
}
@BeforeEach
void setUp() {
this.client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://175.178.114.158:9200")
));
}
@AfterEach
void tearDown() throws IOException {
this.client.close();
}
}
索引库CURD
首先我们现在演示如何用RestClient创建索引库
我们可以写入我们的代码如下
@Test
void createHotelIndex() throws IOException {
//1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
//2.准备请求的参数:DSL语句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
//3.发送请求
client.indices().create(request, RequestOptions.DEFAULT);
}
在这里我们的第一个创建Requet对象的代码其实就相当于是PUT /hotel的代码
接着准备请求的参数就相当于是写入我们的DSL语句,我们这里调用request的source方法,前面的内容需要传入我们之前写过的DSL语句,但是直接放到这里面就会让我们的代码显得非常臃肿,因此我们将DSL语句存入到一个常量中,新创建一个类并创建一个常量字符串属性即可
这里我们要注意的是,我们复制的时候,不用复制PUT行,直接从PUT行下的下一行复制并拷贝到Java代码中即可
public class HotelConstants {
public static final String MAPPING_TEMPLATE = "{\n" +
" "mappings": {\n" +
" "properties": {\n" +
" "id": {\n" +
" "type": "keyword"\n" +
" },\n" +
" "name":{\n" +
" "type": "text",\n" +
" "analyzer": "ik_max_word",\n" +
" "copy_to": "all"\n" +
" },\n" +
" "address": {\n" +
" "type": "keyword",\n" +
" "index": false\n" +
" },\n" +
" "price": {\n" +
" "type": "integer"\n" +
" },\n" +
" "score": {\n" +
" "type": "integer"\n" +
" },\n" +
" "brand": {\n" +
" "type": "keyword",\n" +
" "copy_to": "all"\n" +
" },\n" +
" "city": {\n" +
" "type": "keyword"\n" +
" },\n" +
" "starName": {\n" +
" "type": "keyword"\n" +
" },\n" +
" "business": {\n" +
" "type": "keyword",\n" +
" "copy_to": "all"\n" +
" },\n" +
" "location": {\n" +
" "type": "geo_point"\n" +
" },\n" +
" "pic": {\n" +
" "type": "keyword", \n" +
" "index": false\n" +
" },\n" +
" "all": {\n" +
" "type": "text",\n" +
" "analyzer": "ik_max_word"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
接着我们就在前面传入的内容中传入该常量即可,当然记得要导包,由于我们传入的DSL语句是JSON格式,因此后面的内容我们传入XContentType.JSON
最后我们发送请求,调用成员变量client的中的indices()方法,该方法可以获得索引中的所有方法,这里我们调用创建方法,然后传入request,传入RequestOptions.DEFAULT是在传入一些创建对应的Mapping所需要的其他内容,一般来说我们选择DEFAULT即可
最后经过测试我们会发现我们的确可以创建出来,此时我们的创建方法就完成了
接着我们来学习删除索引库和判断索引库是否存在,先来看看步骤
下面是删除的代码
@Test
void testDeleteHotelIndex() throws IOException {
//1.创建Request对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
//2.发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
删除的代码其实很简单,首先创建Request对象时要创建对应的Delete开头的对象,new也是同理,同样写入关键字hotel,发送请求时调用其delete方法即可
判断索引库是否存在的方法也是同理
@Test
void testExistsHotelIndex() throws IOException {
//1.创建Request对象
GetIndexRequest request = new GetIndexRequest("hotel");
//2.发送请求
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists);
}
文档CURD
现在我们来学习文档的CURD,先来看看步骤
首先我们先来学习创建,同样还是先来看看对应情况
我们这里创建request对象就相当于是JSON信息中的POST /索引库名/文档id,而后面准备JSON文档则是相当于我们实际写入的Json文档,最后我们发送请求即可,注意我们这里发送请求调用的是index方法,而不用indices(),因为后者是索引库相关的方法,而前者是与文档相关的方法,我们这里要创建文档,自然是调用index
那么首先我们要解决一个问题,那就是我们的数据JSON数据从哪里来呢?这当然是从数据库里查找啊,同时由于我们实现了MP,所以我们可以使用MP提供的方法来查找我们的所需要的数据
同时由于我们的数据中还有location这个原本的对象中不存在的数据,原版的对象有经纬度两个数据,所以我们这里我们需要一个新的Hotel对象,其中具有location对象并且可以获取数据中的值正确封装到对象中,这里我们课程中就已经提供了
那么我们可以写入我们的创建文档的代码如下,这里我们省略了MP接口的注入代码
@Test
void testAddDocument() throws IOException {
// 根据id查询酒店数据
Hotel hotel = hotelService.getById(61083L);
// 转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 1.准备Request对象
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
// 2.准备Json文档
request.source(JSON.toJSONString(hotelDoc),XContentType.JSON);
// 3.发送请求
client.index(request,RequestOptions.DEFAULT);
}
我们这里首先查询出酒店的数据,然后将其转换为我们所需要的文档类型,然后首先我们准备Request对象,新建一个IndexRequest对象,然后调用其下的id方法,传入hotel的id的字符串形式,接着准备Json文档,前面传入JSON数据,这里调用JSON.toJSONString方法,可以将对象序列化为JSON格式,然后利用JSON格式发送请求即可,这里调用index方法
在tool控制台中调用GET /hotel/_doc/61083,查询到我们的注入的消息即说明我们的文档创建已经成功了
接着我们来学习根据id查询到我们的酒店数据
首先我们创建对应的GetRequest对象,这里我们要指定mapping名和具体的文档id,接着发送请求获得响应结果,得到的结果是JSON形式的字符串,这里调用的是响应对象的getSourceAsString()方法,然后我们我们调用JSON的parseObject方法,传入对应的字符串和对象的字节码文件即可得到我们想要的对象
@Test
void testGetDocumentById() throws IOException {
// 1.准备Request
GetRequest request = new GetRequest("hotel","61083");
// 2.发送请求,得到响应
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.解析响应结果
String json = response.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
接着我们来学习根据id来修改酒店数据,同样的,修改文档数据在这里也有全量更新和局部更新这两种方式,我们这里演示方式二
对应关系就自己看吧,我懒得说了,同样的我们这里也是准备对应的Request,然后我们准备请求参数,调用request.doc()方法,里写入对应的属性和要修改的值,最后调用update方法进行更新即可
@Test
void testUpdateDocument() throws IOException {
// 1.准备Request
UpdateRequest request = new UpdateRequest("hotel","61083");
//2.准备请求参数
request.doc(
"price","952",
"starName","四钻"
);
// 3.发送请求
client.update(request,RequestOptions.DEFAULT);
}
最后我们要演示删除文档的代码,说实话没啥值得说的,直接看图吧
那么我们可以写入其代码如下
@Test
void testDeleteDocument() throws IOException {
// 1.准备Request
DeleteRequest request = new DeleteRequest("hotel","61083");
// 2.发送请求
client.delete(request,RequestOptions.DEFAULT);
}
最后我们可以来做一个总结
批量导入文档
DSL
接着我们就来正式学习我们ES中的查询,由于ES中的查询是最为重要的,所以我们这里也是将其分为独立的一个章节来讲解
查询分类和基本语法
首先我们来看看查询语法的分类,ES中常见的查询类型有查询所有、全文检索、精确查询、地理查询以及复合查询,我们下面对其进行逐一讲解
首先是查询所有,这个没啥好说的,就是查询出所有数据
其次是全文检索,利用分词器对用户输入内容进行分词,然后去倒排索引库中去匹配
精确查询是通过精确的词条值去查找数值,而地理指的是根据经纬度进行查询,因为我们的数据里有经纬度这一项,最后是复合查询,可以将上述的查询条件组合起来,合并查找出结果
那么我们可以写入我们的查询所有的代码如下
GET /hotel/_search { "query": { "match_all": {} } }
这里我们首先写入固定前缀,然后指定查询类型为查询所有,查询所有不需要跟任何条件值,因此我们这里的大括号内不填入任何值
后面得到的结果是这样的
我们这里took不知道啥意思,先不管,timed_out则代表是否超时,shards也不知道,先不管,hits内存放我们真正捕捉到的数据,首先value代表数据数量,relation则代表其匹配方式,max_score代表数据相关度,其下的hits中就存放要展示的数据,一般来说也就展示20条,这是为了防止一次查询过多的内容给我们的内存造成过大的压力,所以我们也只能看到20条
全文检索查询
全文检索查询会对用户输入内容进行分词之后再进行搜索,常用于搜索框搜索
全文检索的方式有两种,第一种方式是match查询,输入对应的词然后其会对该词进行分词,接着去倒排索引库检索结果,其语法非常简单,同样输入固定前缀之后,加入match属性,然后再加入"要搜索的字段":"要搜索的字符串"即可
我们往往是去查询all字段的,因为该字段拥有其他字段的分词,查它就相当于查全部
那么我们可以写入我们的match查询的代码如下
GET /hotel/_search { "query": { "match": { "all": "外滩如家" } } }
我们还有一种全文检索方式是multi_match,其允许同时查询多个字段,写入固定的前缀,然后写入multi_match标签,接着继续写入query属性,该属性指定要搜索的字符串,再指定fields属性,用于指定要搜索的字段
我们之前讲过多字段查询的效率是要比单一字段查询的效率要低的,因此我们推荐使用第一种方式来完成查询
精确查询
接着我们来学习精确查询,精确查询一般是查找keyword、日期、boolean等类型字段,而且不会对搜索进行条件分词,我们的精确查询有两种方式,第一种是term,第二种是range
tern查询是先指定固定前缀,然后指定要搜索的属性,接着输入对应的要搜索的词既可以了,其是精确查找,因此查询的内容必然包括对应的词,这种查找往往应用于品牌或者是名字的搜索中
那么我们可以写入其代码如下
# term查询 GET /hotel/_search { "query": { "term": { "city": { "value": "杭州" } } } }
当然我们也可以进行数值上的精确搜索,一般来说,这个搜索往往用于搜索价格一类的东西,其演示代码
# range查询 GET /hotel/_search { "query": { "range": { "price": { "gte": 100, "lte": 20 } } } }
这里我们值得一提的是,我们这里的gte是大于等于,而lte是小于等于,如果我要设置大于或者小于,将对应命令中的e去除,照样指定即可
地理查询
接着我们来学习地理查询,常见的地理查询有根据经纬度查询
根据经纬度查询有两种方式,第一种方式geo_bounding_box,其作用是查询对应的四个经纬度形成的矩阵的所有文档
这个方法我们并不是经常使用,因为矩阵限定使用的场景并不方便,而且用得也没这么多
第二种方式geo_distance,可以查询到中心点小于某个距离值的所有文档,这个方法就合理得多,也更加符合人类直觉
那么我们可以写入其演示代码如下
# distance查询 GET /hotel/_search { "query": { "geo_distance": { "distance": "15km", "location": "31.21, 121.5" } } }
修改文档算分
在ES中,往往是根据分词出现频率的多少来决定,但是也并不是完全是根据这个决定的,比如没有马的李彦宏会让给了钱的结果显示在第一位,此时为了实现其放置于首位的结果,我们往往需要对用户搜索的结果修改其算分
在学习修改算分之前我们要先学习ES中算分的规则,这个了解下就扎不多得了,总之我们记住目前都是使用BM25算法来算分即可
最后我们来看看总结
那么接着我们来看看我们的修改文档的相关性算分的DSL语句的讲解
然后我们来实现这个案例
那么我们可以写入其代码如下
# function score查询 GET /hotel/_search { "query": { "function_score": { "query": { "match": { "all": "外滩" } }, "functions": [ { "filter": { "term": { "brand": "如家" } }, "weight": 10 } ], "boost_mode": "sum" } } }
在这里,我们搜索的内容就是外滩,我们的进行过滤的内容是品牌属性,指定必须是如家的品牌,然后给其设置算分函数为10,不指定具体的算分函数时,其默认使用的是乘,即会将算分函数的值乘于算分结果,得到的新结果作为算分结果,这里我们指定算分函数为sum,即相加
复合查询
接着我们来学习下复合查询,其又被称为Boolean Query,所以又被称为布尔查询,其是一个或多个查询子句的组合
子查询的组合方式有四个,分别是must、should、must_not、filter,其中must_not和filter不参与算分,在ES中,不参与算分的查询内容越多,效率就越高,所以我们一般是尽可能将必要的查询语句放于must和should属性中,而其他的则放置于must_not或filter中
现在让我们来实现下面这个案例
那么我们可以写入其演示代码如下
GET /hotel/_search { "query": { "bool": { "must": [ { "match": { "name": "如家" } } ], "must_not": [ { "range": { "price": { "gt": 400 } } } ], "filter": [ { "geo_distance": { "distance": "10km", "location": { "lat": 31.21, "lon": 121.5 } } } ] } } }
因为价格要低于四百,因此我们这里将其放置于must_not中,设置其为大于四百,这样反过来就是必须小于四百,能够满足我们的需求,还不参与算分
搜索结果处理
我们之前已经学习过了如何搜索到我们所需要的结果,接着我们来学习如何处理我们的结果
排序
我们首先可以对我们的结果进行排序操作,其默认是根据相关度算分进行排序,但是我们可以指定其按照某种形式进行排序
这种需求的最经典就是我们的指定条件搜索,有时候我们的用户需要指定排序的搜索,比如说按评价倒序一类的
其语法是在查询出所有结果之后调用sort属性,然后在指定的字段中输入升序或者是倒序的指令,当然后面可以继续接其他属性的排序,这样其会在第一个属性相同时按照其他个属性排序
来看看我们要实现的案例
那么我们可以写入我们的示例代码如下
# sort排序 GET /hotel/_search { "query": { "match_all": {} }, "sort": [ { "score": "desc" }, { "price": "asc" } ] }
我们这里就是先查出所有的结果,然后令其先按评价降序排序,再设定当评价相同时,按价格升序排序,写入该代码即可
再来看看我们的另外一个案例
那么我们可以写入我们的演示代码
# 找到121.612282,31.034661周围的酒店,距离升序排序 GET /hotel/_search { "query": { "match_all": {} }, "sort": [ { "_geo_distance": { "location": { "lat": 31.034661, "lon": 121.612282 }, "order": "asc", "unit": "km" } } ] }
我们首先查询出所有结果,然后我们指定排序属性,接着调用_geo_distance,指定一个经纬度,再指定其排序方式,还有显示的距离的单位,这样最终我们显示的结果就优先显示距离我们指定的位置近的酒店,同时在响应的结果的sort属性中会显示我们的酒店与我们指定位置的距离
最后我们值得一提的是,一旦我们指定的排序,那么算分就不会执行,全部结果都为null,可以提升效率,这很好理解,因为自己都指定了排序了,那么算分值就没有意义了
分页
现在我们来学习分页相关的内容,ES默认的情况下只返回前十个数据,如果我们想要查询更多的数据,就需要修改分页参数
在ES中,分页参数是通过from和size两个属性指定的,前者是指定从第几条数据开始展示,后者是指定一次展示几条,前者默认为0,即从0开始展示,后者默认为10,也就是默认只展示10条数据,如果我们想要翻到第X页开始展示,from的值应该设置为size的x倍,这样才能正确展示
那么我们可以写入演示代码如下
# 分页查询 GET /hotel/_search { "query": { "match_all": {} }, "sort": [ { "price": "asc" } ], "from": 6, "size": 2 }
接着我们来深入讲讲ES中的深度分页问题,如果我们希望ES展示第990条之后的10条数据,那么其是先搜索到1000个数据,然后取最后10个数据并展示的逻辑展示方式,在ES的单点部署中也当然没什么的,但是当ES集群时,这个查询就会比较复杂了,其需要聚合每一个结点的结果进行重新排序,再选出前1000的结果,当集群的ES越多,这个性能拖累的就越厉害
因此在ES的设置中,结果集的查询上限是1w,如果用户是不允许查询超越这个值的内容数量的
那如果我们就真的有这个需求呢?ES也提供了对应的解决方案
其提供的解决方案有两个,分别是search after和scroll,后者已经不用了,我们主要来讲前者
search after分页的原理是每次分页是需要排序,然后每次查询就从上一次的排序值开始查询下一页的数据,这样就可以实现查询更多结果的需求,但是其缺点就是不支持往前翻页,重新查看之前的结果
高亮
在搜索的时候,我们经常看到的一种形式就是,我们搜索的字符在页面中的展示效果都是红色高亮字体,而其他的内容则不是,这就是高亮,其意思是在搜索结果中把搜索关键字突出显示
我们查看对应的搜索页面的源码,会发现搜索页面中对应的关键字有了高亮标签,这些高亮标签是服务器端将要返回的页面给添加上的,然后浏览器端只需要负责解析即可,而如果我们希望达到对应的效果,只需要调用ES中对应的命令即可
那么我们可以写入我们的演示代码如下
首先我们要知道,如果我们希望我们的字段高亮显示,那么我们肯定不可以搜索全部,而是要采取字段搜索,这很好理解,因为如果我们需要某一次分词高亮显示,那么我们当然需要指定该分词,要不然我高亮显示个几把
然后我们在查询到结果之后调用highlight属性,指定fields属性,这里之所以是fiels,是因为我们可能需要多个不同的字段都要高亮显示
# 高亮查询,默认情况下,ES搜索字段必须与高亮字段一致 GET /hotel/_search { "query": { "match": { "all": "如家" } }, "highlight": { "fields": { "name": { "require_field_match": "false" } } } }
我们这里指定就指定了name字段高亮显示,但是记住,我们需要在对应的字段属性下再指定require_field_match为false才可以展示不同字段的关键词,不然只能让同字段的关键词高亮显示,比如我们这里搜索的是关键词是all字段的,展示的属性关键词是name字段的,此时我们就需要指定其为false,否则是无法高亮展示的,除非我们展示的也是all字段
RestClient查询文档
现在我们已经讲完了ES的利用DSL语句进行的查询,接着我们来学如何使用RestClient在Java代码中查询我们的文档
match查询与结果处理
首先我们来学习查询全部,先来分析下其API与DSL组织的对应关系
首先当然是准备Request,跟以前一样new一个就完了,只不过对象是SearchRequest,然后是组织DSL参数,这里我们调用request.source()方法,其就类似于是对应请求下的第一个大括号,调用其下的query方法就相当于是往其中写入query属性,其下还有其他很多属性的对应的API供给给用户调用
然后我们接着要指定具体的查询方法,在我们这里构建查询条件的核心是一个名为QueryBuilders的工具类提供的,其下提供了各种查询方法,我们可以随意调用我们所需要的API
我们这里就调用matchAllQuery()来执行查询全部的请求,其会返回SearchRespon对象,该对象就是响应的结果
接着我就需要对这个结果进行对应的处理,我们都知道结果集中只有Hits属性下才保存着我们所需要的结果集,因此我们首先要做的事情是调用getHits()方法来获得我们的结果集,返回SearchHits对象,接着可以调用其下的value属性,获得查询到的结果集的数量
再次调用其下的getHits方法,可以获得真正保存结果集的数组对象,我们这里对其进行遍历,我们想要的旅馆对象转换为json对象再输出
那么我们可以写入我们的演示代码如下
@Test
void testMatchAll() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().query(QueryBuilders.matchAllQuery());
// 3.发送请求
SearchResponse response = client.search(request,RequestOptions.DEFAULT);
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1 获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到"+total+"条数据");
// 4.2. 文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json,HotelDoc.class);
System.out.println("hotelDoc = "+hotelDoc);
}
System.out.println(response);
}
接着要学习的是字段查询,字段查询的API是基本一直的,无非就是方法换成了matchQuery单字段查询和multiMatchQuery而已
这里我们就不演示了,因为这个代码真的很简单,懂的都懂
精确查询
现在我们来学习精确查询,精确查询中常见的查询有term和range查询,其同样是使用QueryBuilders实现
演示代码就不写了,因为变化的内容也是只有QueryBuilders的一行,我懒得放
复合查询
然后我们来学习复合查询,复合查询最经典的例子当然就是布尔查询
在复合查询中,我们首先需要创建BoolQueryBilder对象,创建该对象需要调用QueryBuilders的boolQuery(),然后我们无论是要添加term的精确查询或者是添加range的范围查询,都直接在该对象中调用对应的must或者是filter方法即可,同样要传入的还是QueryBuilders里提供的查询的静态方法
最终我们在request.source().query()方法中传入该BoolQueryBuilder对象即可
那么最终我们可以写入我们的代码如下
@Test
void testBool() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 准备BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.2 添加term
boolQuery.must(QueryBuilders.termQuery("city","上海"));
// 2.3 添加range
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
request.source().query(boolQuery);
// 3.发送请求
SearchResponse response = client.search(request,RequestOptions.DEFAULT);
handleResponse(response);
}
这里我们将对结果的处理的代码集中到handleResponse()函数中了,因此这里最后对结果的处理之后一个方法调用代码
排序、分页、高亮
接着我们来学习对结果的处理中非常重要的三项,排序、分页和高亮,首先我们来学习排序和分页
由于我们对结果的处理是和query同级的,因此我要调用对结果的排序和分页方法也是要通过source()方法调用的,该方法就可以理解为我们的请求中最大的JSON对象
那么我们可以写入其排序分页代码如下
@Test
void testPageAndSort() throws IOException {
// 页码,每页大小
int page = 1, size = 5;
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 query
request.source().query(QueryBuilders.matchAllQuery());
// 2.2 排序 sort
request.source().sort("price", SortOrder.ASC).from((page-1)*size).size(size);
// 3.发送请求
SearchResponse response = client.search(request,RequestOptions.DEFAULT);
handleResponse(response);
}
这里我们为了模拟前端发送过来的数据,我们这里自己定义了页码和每页展示的数量,ES中的所有语句都支持链式编程,因此我们这里可以一行代码写到底,我们这里首先调用sort方法,指定对应的字段和排序规则,接着继续指定开始的页码和每页的大小,这里为了让我们的页码能够实现动态转换,所以我这里写入的是对应的页码和展示数据量的公式
然后我们来学习高亮,高亮的API包括请求的DSL构建和结果解析两部分,我们首先来看看DSL构建
那么我们可以写入我们的演示代码如下
@Test
void testHighlight() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 query
request.source().query(QueryBuilders.matchQuery("all","如家"));
// 2.2 高亮
request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
// 3.发送请求
SearchResponse response = client.search(request,RequestOptions.DEFAULT);
handleHighlightResponse(response);
}
我们这里首先通过字段查询获得我们想要的结果,然后调用对应的方法,传入HighlightBuilder()方法,指定field字段和无视字段匹配的参数
接着我们来做对高亮结果的解析的代码,先来看看分析
我们可以写入其演示代码如下
private void handleHighlightResponse(SearchResponse response){
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1 获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到"+total+"条数据");
// 4.2. 文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json,HotelDoc.class);
// 获取高亮结果
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if(highlightFields!=null && highlightFields.size()!=0){
// 根据字段名获取高亮结果
HighlightField highlightField = highlightFields.get("name");
if(highlightField != null){
// 获取高亮值
String name = highlightField.getFragments()[0].string();
// 覆盖非高亮结果
hotelDoc.setName(name);
}
}
System.out.println("hotelDoc = "+hotelDoc);
}
System.out.println(response);
}
我们这里首先获取我们的总条数,然后得到高亮的响应字段,进行了对应健壮性的判断,如果可以取出值,我们就取出对应的高亮字段值,然后获取其字段值并覆盖到我们的原来的结果中,最终打印的内容就是所需要的具有高亮标签的内容了
黑马旅游案例
接着我们来实现一个旅游案例来加深我们对上面学习过的操作的理解,首先我们要在课程中导入我们的hotel-demo项目,然后根据实现该项目的各项功能
搜索和分页
我们首先要完成的案例就是搜索和分页,当我们启动了启动类之后,可以输入我们的本机地址+8089的端口号来访问我们的黑马旅游的搜索页面,这个属于是梦回瑞吉外卖了
我们要做的第一件事情是定义类来接受前端的参数,这个类就如下图所示
接着我们需要定义controller接口,用于接受前端请求,其下写入对应的分页查询的所需要的代码
那么我们可以构造我们的代码如下,这些代码的原理我们在瑞吉外卖里都已经学习过了,这里就不赘述了
@RestController
@RequestMapping("/hotel")
public class HotelController {
@Autowired
private IHotelService hotelService;
@PostMapping("/list")
public PageResult search(@RequestBody RequestParams params){
return hotelService.search(params);
}
}
然后我们需要构造我们的search方法,那么我们可以写入其具体的实现类如下
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;
@Override
public PageResult search(RequestParams params) {
try {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 query
String key = params.getKey();
if(key == null || "".equals(key)){
request.source().query(QueryBuilders.matchAllQuery());
}else {
request.source().query(QueryBuilders.matchQuery("all",key));
}
// 2.2 分页
Integer page = params.getPage();
Integer size = params.getSize();
request.source().from((page-1)*size).size(size);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
return handleTools.handleResponse(response);
}catch (Exception e){
throw new RuntimeException(e);
}
}
}
这里我们的事情就是判断前端传入的数据是否为空,若为空就查询全部并返回,若不为空就根据关键词查找结果并返回,这里我们解析响应就新构造一个静态方法即可
这个静态方法的代码如下,这里我们做的事情就是遍历我们的所有内容,并将内容加入到分页集合中并返回
public static PageResult handleResponse(SearchResponse response){
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1 获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到"+total+"条数据");
// 4.2. 文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历
List<HotelDoc> hotels = new ArrayList<>();
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json,HotelDoc.class);
hotels.add(hotelDoc);
}
// 4.4 封装返回
return new PageResult(total,hotels);
}
最后我们打开网页进行测试,可以发现该功能的确已经实现了,这就说明我们的案例已经成功了
条件过滤
接着我们来实现我们的条件过滤,我们需要让我们的搜索可以同时搜索城市星级等内容
那么首先我们要做到的第一步就是拓展我们的承载数据的类里的属性,具体拓展的内容如下图所示
然后我们就要来修改我们的search方法
具体将该代码修改如下
@Override
public PageResult search(RequestParams params) {
try {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 query
buildBasicQuery(params,request);
// 2.2 分页
Integer page = params.getPage();
Integer size = params.getSize();
request.source().from((page-1)*size).size(size);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
return handleTools.handleResponse(response);
}catch (Exception e){
throw new RuntimeException(e);
}
}
首先我们将所有处理的内容都放到全新的方法上去,其他的代码我们并不做改动,接着我们可以往新方法上写入其代码如下
private void buildBasicQuery(RequestParams params, SearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//关键字搜索
String key = params.getKey();
if(key == null || "".equals(key)){
boolQuery.must(QueryBuilders.matchAllQuery());
}else {
boolQuery.must(QueryBuilders.matchQuery("all",key));
}
// 城市条件
if(params.getCity() != null && !params.getCity().equals("")){
boolQuery.filter(QueryBuilders.termQuery("city",params.getCity()));
}
// 品牌条件
if(params.getBrand() != null && !params.getBrand().equals("")){
boolQuery.filter(QueryBuilders.termQuery("brand",params.getBrand()));
}
// 星级条件
if(params.getStarName() != null && !params.getStarName().equals("")){
boolQuery.filter(QueryBuilders.termQuery("starName",params.getStarName()));
}
// 价格
if(params.getMinPrice() != null && params.getMaxPrice() != null){
boolQuery.filter(QueryBuilders
.rangeQuery("price")
.gte(params.getMinPrice())
.lte(params.getMaxPrice())
);
}
request.source().query(boolQuery);
}
我们这里做的事情就事获得传入的对象的对应数据,然后判断其是否为空,若为空我们就不进行特定的查找,不为空则进行对应的查询,这里我们只对关键字进行指定的权重的查询,其他的品牌城市星级一类的查询直接放到过滤中去,不需要指定的带有权重的查询,这样可以提高我们的查询效率,价格也是同理
最后我们经过测试发现这个方法是可行的
附近的酒店
接着我们来实现我附近的酒店的功能,我们这里前端页面点击定位之后,就会将所在的位置发给后台,然后后台会根据该坐标对这个点的距离进行升序排序
首先我们需要修改我们的RequestParams参数,令其接收location字段,其实就是增加一个location的String的成员变量而已
接着我们来实现距离排序,首先我们要明确,我们是希望其能够在界面上显示出距离,因此我们还需要在返回的HotelDoc类中再添加一个距离属性distance,类型设为Object即可
然后我们可以写入我们的代码如下
@Override
public PageResult search(RequestParams params) {
try {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 query
buildBasicQuery(params,request);
// 2.2 分页
Integer page = params.getPage();
Integer size = params.getSize();
request.source().from((page-1)*size).size(size);
// 2.3 排序
String location = params.getLocation();
if(location != null && !location.equals("")){
request.source().sort(SortBuilders
.geoDistanceSort("location",new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS)
);
}
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
return handleTools.handleResponse(response);
}catch (Exception e){
throw new RuntimeException(e);
}
}
我们这里先获得地址的信息,然后对地址信息进行为空判断,不为空则执行业务,内部我们首先做排序,我们这里调用Sortbuilders中的geoDistanceSort方法,然后指定排序字段为location,接着我们需要指定经纬度,这里直接new出来然后用传入location值进行指定,然后指定排序方法和距离单位即可
不过这个方法进行测试的时候有问题,主要问题体现再前端我们根本就没有办法正确获取到定位信息,这个bug我就不修了,我懒得
广告置顶
接着我们来学习如何给我们的金主爸爸的广告进行一个指定,首先我们需要给需要指定的酒店文档添加标记,首先我们给HotelDoc类添加boolean的isAD属性
然后我们挑选几个酒店将其值isAD的值赋值为true,其代码如下
POST /hotel/_update/1902197537 { "doc": { "isAD": true } }
POST /hotel/_update/2056126831 { "doc": { "isAD": true } }
POST /hotel/_update/1989806195 { "doc": { "isAD": true } }
POST /hotel/_update/2056105938 { "doc": { "isAD": true } }
然后我们利用对应的代码来控制文档的相关性算分,其代码的构造方式与DSL语句的对应关系如下图
首先我们这里需要FunctionScoreQueryBuilder对象,后续调用query函数时也需要传入这个新对象,其内部首先要传入原始的查询的结果,然后再传入一个FunctionScoreQueryBuilder.FilterFunctionBuilder类数组,其下指定过滤条件,当然,要过滤自然是需要QueryBuilders.termQuery函数进行查询,指定要查询的参数字段和具体值,最后指定权重即可
我们这里同样采用链式编程
// 2. 算分控制
FunctionScoreQueryBuilder functionScoreQuery =
QueryBuilders.functionScoreQuery(
//原始查询,相关性算分的查询
boolQuery,
// function score的数组
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
//其中的一个function score 元素
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
//过滤条件
QueryBuilders.termQuery("isAD",true),
//算分函数
ScoreFunctionBuilders.weightFactorFunction(10)
)
}
);
request.source().query(functionScoreQuery);
}
最后我们重新进入网址就可以看到广告指定的效果了
数据聚合
我们先来讲讲什么是数据聚合以及聚合的分类,所谓聚合就是我们实现对我们文档数据的统计、分析、和运算的功能。
常见的聚合有三类,第一类是桶聚合,桶聚合又分为两种,一种是按照文档字段值进行分组的TermAggregation,另一种是按照日期阶梯分组的Date Histogram
第二类是度量聚合,其实就类似于是MySQL中的聚合函数,可以用于求平均值、最大值或者最小值
第三类是管道聚合,其可以在其他聚合的基础上继续做聚合,这个用得比较少,了解即可
最后我值得一提的是,我们的参与聚合的字段类型是不允许能分词的,这个很容易理解,要是分词还可以分组,那不是能无限分?因此我们参与聚合的字段类型必须是keyword、数值、日期、布尔类型等不可分组的词
DSL实现Bucker聚合
那么首先我们来用DSL来实现我们的Bucker聚合
首先由于我们的这里只是要做聚合,因此我们不需要进行查询,所以我们这里根本就不写入query标签,指定size属性为0,令我们的结果中不包含文档,只包含聚合结果
然后我们调用aggs标签定义聚合,然后下面先指定聚合名字,接着我们要指定聚合类型,我们这里要按照品牌值聚合,自然要选择term聚合方式,其下指定要分组的字段以及要展示多少组数据
# 聚合功能 GET /hotel/_search { "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "size": 20 } } } }
其默认是按照倒序排序的,这很好理解,越多的排越前面,一般也都是这个需求,但是有时候,我们就是需要不同的排序结果,此时我们可以进行自定义排序
我们要自定排序结果很简单,只需要在size下指定order再指定_count,然后指定排序方式即可
# 聚合功能,自定义排序规则 GET /hotel/_search { "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "size": 20, "order": { "_count": "asc" } } } } }
最后我们在默认情况下,是对索引库的所有文档进行聚合,但是这样的聚合效率太低,实际上我们也可以进行限定范围的聚合,只需要先进行对应的查询,然后下面的代码不变即可
那么我们可以写入我们的代码如下
# 聚合功能,限定聚合范围 GET /hotel/_search { "query": { "range": { "price": { "lte": 200 } } }, "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "size": 20, "order": { "_count": "asc" } } } } }
DSL实现Metrics聚合
接着我们来学习如何用DSL来实现Metrics聚合,先来看看我们的需求
那么首先我们这里要指定桶聚合,然后我们继续指定一个聚合,也就是说,我们要对桶聚合内的数据进行再一次的聚合,这种方式也被称为嵌套聚合,我们这里同样调用aggs的属性,指定其名字,然后调用对应的聚合函数,指定我们要聚合的字段,我们这里指定的是评价
最后为了让其倒序显示,我们在第一个桶聚合下调用order然后指定我们要进行排序的桶聚合内的数据
那么我们可以写入其演示代码如下
# 嵌套聚合metric GET /hotel/_search { "size": 0, "aggs": { "brandAgg": { "terms": { "field": "brand", "size": 20, "order": { "scoreAgg.avg": "desc" } }, "aggs": { "scoreAgg": { "stats": { "field": "score" } } } } } }
RestClient实现聚合
那么接着我们就来学习如何使用RestClient来实现聚合,先来看看案例需求
我们这里设置聚合,先设置查询结果的展示数目为0,然后利用aggregation()方法,内部传入AggregationBuilders类,第一个terms是指定聚合的方法,内部的括号指定我们聚合结果名称,接着指定要聚合的字段和展示数目即可
然后我们要解决的事情是如何对我们的结果进行解析,直接打印得到的结果肯定不是我们所需要的
首先我们需要通过响应结果得到整个聚合结果,然后根据名称来获取聚合结果,接着根据调用的结果里的getBuckerts()方法来获取具体的桶集合,然后对每一个桶进行遍历,打印其key,也就是字段内容即可
那么最终我们可以写入我们的代码如下
@Test
void testAggregation() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 设置size和聚合
request.source().size(0).aggregation(AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(10)
);
// 3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析结果
Aggregations aggregations = response.getAggregations();
// 4.1 根据聚合名称获取聚合结果
Terms brandTerms = aggregations.get("brandAgg");
// 4.2 获取buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
// 4.3 遍历
for (Terms.Bucket bucket : buckets) {
// 4.4 获取key
String key = bucket.getKeyAsString();
System.out.println(key);
}
}
RestClient多条件聚合
接着我们来实现RestClient的多条件聚合,现在看看我们的案例需求
那么首先我们就需要在IUserService中定义对应的抽象方法,接着我们去实现该方法,我们这里对异常进行trycatch处理,我们首先进行对应的请求设置和聚合,这就是之前的聚合代码而已,接着重点在于解析结果,之前我们是直接打印,而且只处理一个,但是在这里,我们需要将对应的结果加入到Map集合中,key存放品牌字符串,value存放对应的List集合,List集合内存放字符串,内部存放对应聚合结果里所拥有的关键词,最后返回Map结果
那么最终我们可以写入我们的代码如下
@Override
public Map<String, List<String>> filters(RequestParams params) {
try {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1 设置size
request.source().size(0);
// 2.2 聚合
buildAggregation(request);
// 3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析结果
Map<String,List<String>> map = new HashMap<>();
Aggregations aggregations = response.getAggregations();
// 4.1 根据品牌名称,获取品牌结果
List<String> list = getAggByName(aggregations,"brandAgg");
map.put("品牌",list);
// 4.2 根据品牌名称,获取品牌结果
list = getAggByName(aggregations,"cityAgg");
map.put("城市",list);
// 4.3 根据品牌名称,获取品牌结果
list = getAggByName(aggregations,"starAgg");
map.put("星级",list);
return map;
}catch (Exception e){
throw new RuntimeException(e);
}
}
private List<String> getAggByName(Aggregations aggregations,String name) {
// 4.1 根据聚合名称获取聚合结果
Terms brandTerms = aggregations.get(name);
// 4.2 获取buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
// 4.3 遍历
List<String> list = new ArrayList<>();
for (Terms.Bucket bucket : buckets) {
// 4.4 获取key
String key = bucket.getKeyAsString();
list.add(key);
}
return list;
}
private void buildAggregation(SearchRequest request) {
request.source().aggregation(AggregationBuilders
.terms("brandAgg")
.field("brand")
.size(100)
);
request.source().aggregation(AggregationBuilders
.terms("cityAgg")
.field("city")
.size(100)
);
request.source().aggregation(AggregationBuilders
.terms("starAgg")
.field("starName")
.size(100)
);
}
上面的这份代码就可以获得对应的关键词并存放到Map集合中,但是还没有和前端做交互,我们的前端仍然无法调用该方法,因此我们需要分析前端的请求
首先我们分析前端的请求我们会发现,其调用的方法的地址和之前的查询方法没什么差别,除了地址不同之外,他们传入的数据都是一样的,同样会传入对应的查询数据,为什么要这么做呢?这是因为我们聚合得到的结果必须要在查询的结果之后进行聚合,如果我们是每次都直接查询所有数据,影响效率不说,用户选择北京的酒店,结果你还展示上海的选项,你这不是没事干么?
那么首先我们需要更改我们接口中的方法,令其可以传入对应的查询参数,然后我们构造我们的聚合方法,首先我们在控制层构造对应的方法,然后传入对应参数
@PostMapping("filters")
public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
return hotelService.filters(params);
}
接着我们来实现其方法的具体内容,我们这里要做的内容非常简单,就是在我们设置size为0之前执行BuildBasicQuery的查询方法即可,这里我们就不演示代码了
这个方法本身是没问题的,但是后续再将项目转移到虚拟机的时候,这个方法就出现了问题,主要体现在前端无法正确获得结果上,通过DEBUG发现每次执行方法时都会报出下面的错误
将JDK版本更换为JDK8之后该报错将不再产生,但是同样无法获得正确的结果,在搜索引擎上只能找到一个相关结果,其地址如下zhuanlan.zhihu.com/p/461700900,推测最可能的问题是在项目内部有两个不同的CGLIB依赖,由于两个依赖产生了冲突而导致的问题,试图通过改变导包来解决该问题,但是无论在对应的依赖库还是import代码块里都找不到对对应的包,故该问题最终无法解决,导致该需求无法实现
通过暂时注释掉该方法来停止该功能,等待后续的解决
虚拟机
由于服务器太垃圾了,因此这里转而使用虚拟机,服务器真不行
固定ip
虚拟机重启就会更换ip,所以首先要做的事情就是固定ip,固定ip的教程www.bilibili.com/video/BV1gy…
首先在虚拟机设置中设置网络适配器为NAT模式
然后选择编辑中的虚拟网络编辑器,选择最下方的NAT模式,再选择NAT设置,记录其网关ip地址
在电脑的任务栏中选中网络那里右键打开 网络和共享中心,在左边点击 更改适配器设置,然后右键选属性打开 VMare Network Adapter VMnet8, 在打开的页面上选 Internet协议版本4,点击属性,在打开的页面选择 使用下面的ip地址 ,ip地址可以填192.168.8.1 默认网关必须与前面的第三步配好的网关ip一致也就是 192.168.8.2,就此虚拟机的固定ip地址就配完了
选择使用下面的ip地址,默认网关的地址就是之前记录的网关的地址,ip地址只要保持最后一位和默认网关和子网ip地址的最后一位不同即可,DNS服务器同样选择,并输入图中的内容
然后到到linux操作系统中输入命令 vim /etc/sysconfig/network-scripts/ifcfg-eth33,最后的内容到底是eth33还是eth0每个人都不同,想查看到底是什么只要看输入ifconfig查看即可
进入该文件修改内容如下
最
保存之后输入systemctl restart network重启network即可令配置生效
自动补全
接着我们来学习自动补全,也就是我们要实现用户在搜索框输入字符的时候,就出现提示出与该字符相关的搜索项
拼音分词器安装
实现该功能需要使用拼音分词器,将对应的文档的中文词同时对拼音进行分词并保存到索引库中,这样用户在搜索时可以通过最开始创建的拼音分词来搜索到目标内容
我们这里使用的拼音分词器的版本是7.12.1,注意拼音分词器版本必须符合,否则会导致es崩溃再不可启动
安装过程与IK分词器一样,最后调用docker restart es命令即可完成配置
下面是测试命令
POST /_analyze { "text": ["如家酒店还不错"], "analyzer": "ik_max_word" }
最后得到了我们所需要的对汉字进行拼音分词的结果
自定义分词器
但是上面的分词器存在几点不足
- 其对中文进行拼音分词,但是却忽略了中文分词本身,只保留拼音分词
- 其会分出一些无意义的拼音分词
解决上面的问题可以使用自定义分词器
自定义分词器的组成包括,character filters,作用是对文本进行处理,例如删除或者是替换字符,常用于文字表情处理。tokenizer则是将文本按照一定规则切割成词条,这个我们用得多了,tokenizer filter则是对文本的词条进行进一步处理,如大小写转换或者拼音处理
实际我们的业务处理中就是将这三个分词处理器构建来进行对应的分词的,分词器并不强制要求每一个都要,部分分词器可以不设置
设置分词器索引使用PUT关键词,先设置settings属性,其代表设定,而analyasis则代表我们要设置分词器,然后通过analyzer属性自定义对应的分词器,最后通过filter属性写入过滤器名字的方式来指定对上面的内容进行过滤的过滤器,过滤器必须在后面设置,对应的过滤器处理规则在下面设置,需要指定过滤器类型,我们这里指定其为pinyin,而其他的具体规则设置和作用请参照官方文档
那么我们可以写入其创建索引的代码如下
PUT /test { "settings": { "analysis": { "analyzer": { "my_analyzer": { "tokenizer": "ik_max_word", "filter": "py" } }, "filter": { "py": { "type": "pinyin", "keep_full_pinyin": false, "keep_joined_full_pinyin": true, "keep_original": true, "limit_first_letter_length": 16, "remove_duplicated_term": true, "none_chinese_pinyin_tokenize": false } } } } }
然后我们写入用于搜索的代码如下
POST /test/doc/1 { "id": 1, "name": "狮子" } POST /test/ doc/2 { "id": 2, "name": "虱子" }
GET /test/_search { "query": { "match": { "name": "掉入狮子笼咋办" } } }
此时会产生的问题是,我们搜索狮子,实际却会出现虱子的结果,显然其搜索的时候将同音字也一起搜索了,这显然不是我们要的效果
实际上,拼音分词器适合在创建倒排索引的时候使用,搜索的使用是不建议使用的,而我们上面由于搜索时也使用了拼音分词器,因此导致了预期之外的效果
为此我们可以通过指定创建索引属性的方式来指定创建时和搜索时分别用不同的分词器
那么我们可以写入我们的创建索引库的代码如下
# 自定义分词器 PUT /test { "settings": { "analysis": { "analyzer": { "my_analyzer": { "tokenizer": "ik_max_word", "filter": "py" } }, "filter": { "py": { "type": "pinyin", "keep_full_pinyin": false, "keep_joined_full_pinyin": true, "keep_original": true, "limit_first_letter_length": 16, "remove_duplicated_term": true, "none_chinese_pinyin_tokenize": false } } } }, "mappings": { "properties": { "name":{ "type": "text", "analyzer": "my_analyzer", "search_analyzer": "ik_smart" } } } }
接着进行测试结果就符合预期了
DSL实现自动补全
首先我们来实现DSL语句的自动补全,ES中提供了对应的查询来实现自动补全功能,其要求是参与补全查询的字段必须是completion类型
先来看看演示的DSL语句
# 自动补全的索引库 PUT test2 { "mappings": { "properties": { "title":{ "type": "completion" } } } }
# 示例数据 POST test2/_doc { "title": ["Sony", "WH-1000XM3"] }
POST test2/_doc { "title": ["SK-II", "PITERA"] }
POST test2/_doc { "title": ["Nintendo", "switch"] }
我们这里首先创建一个自动补全的索引库,指定其字段类型为completion,接着添加了三个对应的字段数据
然后我们写入我们的自动补全查询的DSL语句如下
# 自动补全查询 POST /test2/_search { "suggest": { "titleSuggest": { "text": "so", "completion": { "field": "title", "skip_duplicates": true, "size": 10 } } } }
自动补全查询需要先指定suggest属性,接着其下继续指定titleSuggest属性,text下写入要查询的字符串,completion则指定要查询的字段,再往下分别是要不要跳过重复词条和指定展示的数据最大容量
实现自动补全
实现自动补全首先要修改hoteldoc实体的属性,添加suggestion字段,该字段存放我们想要进行补全查询的字段
suggestion字段内存放其他字段的分词字段,因此其属性为集合,并且存放字符串属性的内容
private List<String> suggestion;
要进行补全查询的字段则由人为进行指定,我们这里指定为品牌和所在城区,只要在对应的构造方法中做对应的处理,对对应属性进行分词然后添加到属性中即可
if(this.business.contains("、")){
// business有多个值,需要切割
String[] split = this.business.split("、");
// 添加元素
if(this.suggestion==null){
this.suggestion = new ArrayList<>();
}
this.suggestion.add(this.brand);
this.suggestion.addAll(Arrays.asList(split));
}else if(this.business.contains("/")){
// business有多个值,需要切割
String[] split = this.business.split("/");
// 添加元素
if(this.suggestion==null){
this.suggestion = new ArrayList<>();
}
this.suggestion.add(this.brand);
this.suggestion.addAll(Arrays.asList(split));
}else {
this.suggestion = Arrays.asList(this.brand,this.business);
}
创建索引库
接着我们需要删除我们原先的索引库并创建我们自己的索引库,其创建索引库的语句如下
# 酒店数据索引库 PUT /hotel { "settings": { "analysis": { "analyzer": { "text_anlyzer": { "tokenizer": "ik_max_word", "filter": "py" }, "completion_analyzer": { "tokenizer": "keyword", "filter": "py" } }, "filter": { "py": { "type": "pinyin", "keep_full_pinyin": false, "keep_joined_full_pinyin": true, "keep_original": true, "limit_first_letter_length": 16, "remove_duplicated_term": true, "none_chinese_pinyin_tokenize": false } } } }, "mappings": { "properties": { "id":{ "type": "keyword" }, "name":{ "type": "text", "analyzer": "text_anlyzer", "search_analyzer": "ik_smart", "copy_to": "all" }, "address":{ "type": "keyword", "index": false }, "price":{ "type": "integer" }, "score":{ "type": "integer" }, "brand":{ "type": "keyword", "copy_to": "all" }, "city":{ "type": "keyword" }, "starName":{ "type": "keyword" }, "business":{ "type": "keyword", "copy_to": "all" }, "location":{ "type": "geo_point" }, "pic":{ "type": "keyword", "index": false }, "all":{ "type": "text", "analyzer": "text_anlyzer", "search_analyzer": "ik_smart" }, "suggestion":{ "type": "completion", "analyzer": "completion_analyzer" } } } }
我们这里创建进索引库首先指定setting属性进行对应的设置,首先进行分词器的摄制组,先设置分词器的名字,设定器具体规则为ik分词器最大分词,接着对其进行过滤的过滤器是py,然后再设置一个分词器,但是这个分词器的规则是keyword,其实就是不分词,相当于是将所有的字段中的不会变化的关键词给拦截下来,同样指定过滤器py
然后下面对过滤器进行对应的配置设定,指定过滤器的属性为pinyin,后面的更多属性的设置请参照拼音分词器的官方文档
接着下面则是调用mappings属性进行对应的分词设置,这里除了之前同样的内容之外,还增加了设置创建索引和用户搜索时使用什么分词器的设置
创建好对应的索引之后再调用java代码中的bulk方法将数据库的数据批量产生索引并生成对应的字段到ES中即可
代码实现
在请求中代用哦suggest方法内部需要创建SuggestBuilder对象并通过addSuggestion方法指定对应的补全查询名称,接着调用SuggestBuilders工具类执行自动补全的分段查询,同样是指定字段,跳过重复和指定大小
发送请求之后就会获得结果,我们要对结果进行解析
我们这里进行解析的方式是先获得Suggest对象,然后通过指定属性名获得补全查询之后的结果
@Test
void testSuggest() throws IOException {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().suggest(
new SuggestBuilder().addSuggestion(
"suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix("hs")
.skipDuplicates(true)
.size(10)
)
);
// 3.发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析结果
Suggest suggest = response.getSuggest();
// 4.1.根据补全查询名称,获取补全结果
CompletionSuggestion suggestions = suggest.getSuggestion("suggestions");
// 4.2.获取options
List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions();
// 4.3.遍历
for (CompletionSuggestion.Entry.Option option : options) {
String text = option.getText().toString();
System.out.println(text);
}
}
在前端中我们一旦输入字符其就会自动往客户端中发送对应的请求,我们接下来就来完成该请求的后端逻辑
首先创建对应的方法接口
@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {
return hotelService.getSuggestions(prefix);
}
其具体实现,我们这里就将前端传入的数据传入到对应的即可,没什么难度说实话
@Override
public List<String> getSuggestions(String prefix) {
try {
// 1.准备Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().suggest(
new SuggestBuilder().addSuggestion(
"suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix(prefix)
.skipDuplicates(true)
.size(10)
)
);
// 3.发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析结果
Suggest suggest = response.getSuggest();
// 4.1.根据补全查询名称,获取补全结果
CompletionSuggestion suggestions = suggest.getSuggestion("suggestions");
// 4.2.获取options
List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions();
// 4.3.遍历
List<String> list = new ArrayList<>(options.size());
for (CompletionSuggestion.Entry.Option option : options) {
String text = option.getText().toString();
list.add(text);
}
return list;
}catch (Exception e){
throw new RuntimeException(e);
}
}
不过这个需求有问题,其具体的问题在于虽然拼音可以正确获得对应的联想结果,但是我们直接输入中文却不能,这属实是沾点了,不过这个问题这里我们暂时不做处理
数据同步
ES中的数据来自于MySQL数据库,当MySQL数据库发生改变时,ES也应当要跟着改变,这就是数据同步
同步方案介绍
数据同步的第一个方案就是每次更新酒店数据时调用酒店自身暴露的接口来更新ES,该方案存在较大的效率问题,且业务耦合度高
第二种方案是在酒店管理服务和酒店搜索服务中添加一个MQ队列,每当数据库的数据更新时,就往队列中添加消息,然后队列会通知对应的搜索服务实现数据库的更新,这种方式比较依赖MQ的可靠性
第三种方式是通过MySQL的binlog来进行通知,本质上和MQ差不多
最后我们来看看这三种方式的优缺点
案例实现
接着我们来实现案例,先来看看步骤
首先我们要导入我们的hotel-demo项目,启动并进行测试,测试之后会发现没有什么问题
然后首先我们在hotel-demo中引入amqp的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
然后我们需要配置对应的rabbitMQ的配置
rabbitmq:
host: 192.168.88.128
port: 5672
username: itcast
password: 123321
virtual-host: /
接着我们写入对应的创建交换机和队列的字符串,用常量指代
public class MqConstants {
/**
* 交换机
*/
public final static String HOTEL_EXCHANGE = "hotel.topic";
/**
* 监听新增和修改的队列
*/
public final static String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
/**
* 监听删除的队列
*/
public final static String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
/**
* 新增或修改的RoutingKey
*/
public final static String HOTEL_INSERT_KEY = "hotel.insert";
/**
* 删除的RoutingKey
*/
public final static String HOTEL_DELETE_KEY = "hotel.delete";
}
然后我们再创建一个配置类,配置类主要做的事情就是创建对应的队列以及对队列进行绑定,具体代码如下(由于这里有我们没有学习过的代码,因此我们这里直接记住即可)
@Configuration
public class MqConfig {
@Bean
public TopicExchange topicExchange(){
return new TopicExchange(MqConstants.HOTEL_EXCHANGE,true,false);
}
@Bean
public Queue insertQueue(){
return new Queue(MqConstants.HOTEL_INSERT_QUEUE,true);
}
@Bean
public Queue deleteQueue(){
return new Queue(MqConstants.HOTEL_DELETE_QUEUE,true);
}
@Bean
public Binding insertQueueBinding(){
return BindingBuilder.bind(insertQueue()).to(topicExchange()).with(MqConstants.HOTEL_INSERT_KEY);
}
@Bean
public Binding deleteQueueBinding(){
return BindingBuilder.bind(deleteQueue()).to(topicExchange()).with(MqConstants.HOTEL_DELETE_KEY);
}
}
接着我们需要创建用于处理从队列中获取到监听消息之后进行数据更新的请求,首先我们来确定我们要如何往我们的队列中发送消息,我们往hotel-admin管理端的控制层中写入其代码如下
@PostMapping
public void saveHotel(@RequestBody Hotel hotel){
hotelService.save(hotel);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE,MqConstants.HOTEL_INSERT_KEY,hotel.getId());
}
@PutMapping()
public void updateById(@RequestBody Hotel hotel){
if (hotel.getId() == null) {
throw new InvalidParameterException("id不能为空");
}
hotelService.updateById(hotel);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE,MqConstants.HOTEL_INSERT_KEY,hotel.getId());
}
@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
hotelService.removeById(id);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE,MqConstants.HOTEL_DELETE_KEY,id);
}
首先需要注入对应的rabbitTemplate属性,然后调用其下对应的方法,指定交换机名称、队列名称,传入我们的数据id,当然传入整个数据对象也可以,但是这样比较吃内存,我们不推荐这样做
接着我们来实现客户端的同步方法
首先我们在接口中创建对应的方法
void deleteById(Long id);
void insertById(Long id);
然后我们在控制层中创建一个监听器类,首先写入对应的监听器进行处理的代码,写入对应的注解,监听对应的队列,当队列传入数据时就指定对应的方法
@Component
public class HotelListener {
@Autowired
private IHotelService hotelService;
/**
* 监听酒店新增或修改的业务
* @param id
*/
@RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
public void listenHotelInsertOrUpdate(Long id){
hotelService.insertById(id);
}
/**
* 监听酒店删除的业务
* @param id
*/
@RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
public void listenHotelDelete(Long id){
hotelService.deleteById(id);
}
}
最后我们写入我们的对应的更新内容的代码
@Override
public void deleteById(Long id) {
try {
// 1.准备Request
DeleteRequest request = new DeleteRequest("hotel",id.toString());
// 2.发送请求
client.delete(request,RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void insertById(Long id) {
try {
// 0.根据id查询酒店数据
Hotel hotel = getById(id);
// 转换为文档类型
HotelDoc hotelDoc = new HotelDoc();
// 1.准备Request对象
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
// 2.准备Json文档
request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
// 3.发送请求
client.index(request,RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
到此就完成了,但是这里存在问题,第一个问题是虽然可以更新成功,队列也没有问题,但是只要一更新,客户端里的数据就显示不出来,也没有报异常,我觉得可能是因为一些不明不白的原因,这里就不解决了,反正大概理解就行
ES集群
然后我们来实现我们的最后一个内容,也就是ES集群,单机的ES面临海量数据存储和单点故障问题时是无法处理的,此时必须使用ES集群来解决问题
ES集群在面对海量数据问题时会将索引库从逻辑上拆分为N个分片并存储到多个节点中,并且每一个结点都会保存另一个不同结点的备份,这样即使一个结点挂了,另一个结点也还能保存其数据,保证数据的完整性
搭建集群
我们会在单机上利用docker容器运行多个es实例来模拟es集群,不过生产环境推荐大家每一台服务节点仅部署一个es的实例。
部署es集群可以直接使用docker-compose来完成,但这要求你的Linux虚拟机至少有4G的内存空间
首先编写一个docker-compose文件,内容如下:
version: '2.2'
services:
es01:
image: elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es02,es03
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data02:/usr/share/elasticsearch/data
ports:
- 9201:9200
networks:
- elastic
es03:
image: elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
ports:
- 9202:9200
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
这里我们指定的是镜像名字,通过images指定具体镜像,然后指定容器名字,这里和镜像名保持一致,接着配置环境变量,首先指定结点名字,这里同样保持一直,但实际上这个不保持一致亦可。然后通过cluster.name指定集群名称,这个在每一个配置里都指定为一样的,ES会自动联系这些配置并将其分配到一个集群中,否则就无法正确分配
后面分别是指定其他的结点ip地址和可以参与选举主节点的结点,这里由于我们是在docker容器中,因此可以用名称来代替具体的ip地址
往下则是数据卷地址和端口号以及共同的网络
es运行需要修改一些linux系统权限,修改/etc/sysctl.conf文件
vi /etc/sysctl.conf
添加下面的内容:
vm.max_map_count=262144
然后执行命令,让配置生效:
sysctl -p
通过docker-compose启动集群:
docker-compose up -d
如果提示找不到命令,则需要安装docker-compose
集群监控
kibana可以监控es集群,不过新版本需要依赖es的x-pack 功能,配置比较复杂。
这里推荐使用cerebro来监控es集群状态,官方网址:github.com/lmenezes/ce…
课前资料已经提供了安装包,解压,进入对应的bin目录,双击其中的cerebro.bat文件即可启动服务,访问http://localhost:9000 即可进入管理界面,输入你的elasticsearch的任意节点的地址和端口,点击connect即可
点击cerebro中的more可以创建索引库,我们这里创建对应索引库,令我们的索引库进行分片并保存副本
选中more中的create index
填写索引库信息:
点击create,回到首页,即可查看索引库分片效果:
集群职责
ES集群中的节点角色有不同的职责划分
首先是备选主节点master eligible,主节点可以管理其他集群,为了让主节点挂了之后还有其他主节点继续工作,
可以对其他结点设置该参数,这样主节点挂了被设置的该结点就可以顶替主节点继续工作
其次是data的结点类型,其为数据结点,主要执行存储数据和CURD。ingest可以执行数据存储之前的预处理,比较少用
还有一个coordinating结点为路由结点,主要作用就是路由,合并其他结点处理的结果并返回给用户
默认情况下每个结点都具有除路由结点外的功能,但是规范情况下应该是集群部署时每个结点都有自己独立的角色
ES集群时可能会出现脑裂问题,当网络阻塞时可能会让主副结点失去联系,此时副结点会成为主结点并执行业务,而当网络恢复时就会形成两个主结点的情况
最后我们来看看总结
分布式存储
ES的集群的数据会保存到不同分配,保证数据均衡的方式是如下所示的哈希公式,用于决定哈希值的常量是分片数量,routing则默认是文件的id,无论是新增数据还是按id查询的请求,都是按照该算法来定位到对应的结点上寻找数据,因此分片数量是不能修改的,否则会导致哈希公式的失效
其分布式存储的过程如下图所示,首先路由结点哈希运算得到对应的结点的值,定位到该结点中保存对应数据并同步副本的数据到另一个分片,完成之后将结果返回到路由结点
如果是查询全部,则路由结点会将请求发送到每一个分片,得到其所有的结果之后再聚合并将最终的结果集返回给用户
故障转移
ES集群中有时会出现结点的宕机,一旦发生这种情况,则其会将宕机结点的数据迁移到其他结点中保存来保证数据的安全,该方案称为故障转移
同时一旦原来的结点恢复,其会将多保存的副本重新分配到原先的结点中