视频学习:黑马程序员Java微服务
网盘资源:https://pan.baidu.com/s/1LxIxcHDO7SYB96SE-GZfuQ 提取码:dor4
学习路线及部分内容参考:Kyle’s Blog
初识ElasticSearch 了解es ElasticSearch是一款非常强大的开源的分布式搜索引擎 ,具备非常强大的功能,可以帮助我们从海量数据中快速找到需要的内容
ELK技术栈 ElasticSearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控 等领域
Kibana是一个开源的分析与可视化平台,用于搜索、查看存放在Elasticsearch中的数据。Kibana与Elasticsearch的交互方式是各种不同的图表、表格、地图等,直观的展示数据,从而达到高级的数据分析与可视化的目的。
ElasticSearch是elastic stack的核心,负责存储、搜索、分析数据
Lucene
ElasticSearch底层是基于Lucene来实现的
Lucene是一个Java语言的搜索引擎类库,是Apache公司的顶级项目,由DougCutting于1999年研发,官网地址:https://lucene.apache.org/
Lucene的优势
Lucene的缺点
只限于Java语言开发
学习曲线陡峭
不支持水平扩展
相比于Lucene,ElasticSearch具备以下优势
支持分布式,可水平扩展
提供Restful接口,可以被任意语言调用
倒排索引 正向索引 什么是正向索引 :基于文档(具体的一条数据)id创建索引。查询词条时必须先找到文档,而后判断是否包含词条
传统数据库MySQL采用正向索引,例如下表中的id创建索引
id
title
price
1
小米手机
3499
2
华为手机
4999
3
华为小米充电器
49
4
小米手环
49
如果是按照id查询,就会直接通过id索引获取对应的内容,速度很快
但是如果想按照title进行查询,并且是模糊查询,就只能采用以下sql语句进行逐条的模糊查询 ,这样的效率和性能太低了
select id, title, price from tb_goods where title like %手机%
倒排索引 什么是倒排索引: 对文档内容分词,对词条创建索引,并记录词条所在文档的信息,查询时现根据词条查询到文档id,而后获取到文档
ElasticSercah采用倒排索引,有以下概念:
文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。
词条(Term):文档按照语义分成的词语
以搜索华为手机为例
用户输入条件华为手机
,进行搜索。
对用户输入的内容分词,得到词条:华为、手机。
拿着词条在倒排索引中查找,得到每个词条所在文档id,进而可以得到包含词条的文档id为:1、2、3。
拿着文档id到正向索引中查找具体文档
二者区别 区别
正向索引 是根据id索引的方式。但是根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档查找词条的过程
倒排索引 则是先找到用户要搜索的词条,然后根据词条得到包含词条的文档id,然后根据文档id获取文档,是根据词条查找文档的过程
二者的优缺点:
正向索引
优点:可以给多个字段创建索引,根据索引字段搜索、排序速度非常快
缺点:根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描
倒排索引
优点:根据词条搜索、模糊搜索时,速度非常快
缺点:只能给词条创建索引,而不是字段,无法根据字段做排序
es的一些概念 ElasticSearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式 后存储在ElasticSearch中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 { "id" : 1 , "title" : "小米手机" , "price" : 3499 } { "id" : 2 , "title" : "华为手机" , "price" : 4999 } { "id" : 3 , "title" : "华为小米充电器" , "price" : 49 } { "id" : 4 , "title" : "小米手环" , "price " : 299 }
索引和映射 索引(Index) :相同类型文档的集合
映射(mapping) :索引中文档的字段约束信息(用来定义表的结构、字段的名称、类型等信息),类似表的结构约束
概念对比
MySQL
Elasticsearch
说明
Table
Index
索引(index),就是文档的集合,类似数据库的表(Table)
Row
Document
文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column
Field
字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
Schema
Mapping
Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQL
DSL
DSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD
二者的擅长之处:
MySQL
:擅长事务类型操作,可以保证数据的安全和一致性
ElasticSearch
:擅长海量数据的搜索、分析、计算
在企业中,往往是这二者结合使用
对安全性要求较高的写操作,使用MySQL实现
对查询性能个较高的搜索需求,使用ElasticSearch实现
二者再基于某种方式,实现数据的同步,保证一致性
安装es、kibana 部署单点es 因为我们还需要部署Kibana容器,因此需要让es和kibana容器互联,这里先创建一个网络,让他们都加入这个网络
使用compose部署可以一键互联,不需要这个步骤,但是将来有可能不需要kbiana,只需要es,所以先这里手动部署单点es
1 docker network create es-net
拉取镜像,这里采用的是ElasticSearch的7.12.1版本镜像
1 docker pull elasticsearch:7.12.1
运行docker命令,部署单点ES
1 2 3 4 5 6 7 8 9 10 docker run -d \ --name es \ -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ -e "discovery.type=single-node" \ -v es-data:/usr/share/data \ -v es-plugins:/usr/share/plugins \ --privileged \ --network es-net \ -p 9200:9200 \ elasticsearch:7.12.1
命令解释:
docker run -d
:后台运行
--name es
:给容器起名
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m"
:配置JVM的堆内存大小,默认是1G,但是最好不要低于512M
-e "discovery.type=single-node"
:单点部署
-v es-data:/usr/share/data
:数据卷挂载,绑定es的数据目录
-v es-plugins:/usr/share/plugins
:数据卷挂载,绑定es的插件目录
-privileged
:授予逻辑卷访问权
--network es-net
:让ES加入到这个网络当中
-p 9200
:暴露的HTTP协议端口,供我们用户访问的
elasticsearch:7.12.1
:镜像名称
此时查看启动的容器,可以看到启动成功:
1 2 3 [root@localhost ~] CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 104213e77547 elasticsearch:7.12.1 "/bin/tini -- /usr/l…" 24 seconds ago Up 22 seconds 0.0.0.0:9200->9200/tcp, :::9200->9200/tcp, 9300/tcp es
成功启动之后,打开浏览器访问:http://192.168.186.128:9200/, 即可看到elasticsearch的响应结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 { "name" : "104213e77547" , "cluster_name" : "docker-cluster" , "cluster_uuid" : "WhbLsDQiQAulMMeowYrKhA" , "version" : { "number" : "7.12.1" , "build_flavor" : "default" , "build_type" : "docker" , "build_hash" : "3186837139b9c6b6d23c3200870651f10d3343b7" , "build_date" : "2021-04-20T20:56:39.040728659Z" , "build_snapshot" : false , "lucene_version" : "8.8.0" , "minimum_wire_compatibility_version" : "6.8.0" , "minimum_index_compatibility_version" : "6.0.0-beta1" } , "tagline" : "You Know, for Search" }
安装kibana
前面我们已经load了kibana的tar包并加载了镜像
运行docker命令,部署kibana
1 2 3 4 5 6 docker run -d \ --name kibana \ -e ELASTICSEARCH_HOSTS=http://es:9200 \ --network=es-net \ -p 5601:5601 \ kibana:7.12.1
命令解释
--network=es-net
:让kibana加入es-net
这个网络,与ES在同一个网络中
-e ELASTICSEARCH_HOSTS=http://es:9200
:设置ES的地址,因为kibana和ES在同一个网络,因此可以直接用容器名访问ES
-p 5601:5601
:端口映射配置
注意:kibana和es的版本一定要保持一致!
成功启动后,打开浏览器访问:http://192.168.186.128:5601/ ,即可以看到结果
kibana中提供了一个DevTools界面,在这个界面中我们可以编写DSL来操作ElasticSearch ,并且有对DSL语句的自动补全功能
分词器 测试 es在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。但默认的分词规则对中文处理不太友好。
这里在DevTools进行测试
1 2 3 4 5 GET /_analyze { "analyzer" : "chinese" , "text" : "真是人间太岁神" }
输出结果,可以发现逐词进行了划分
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 { "tokens" : [ { "token" : "真" , "start_offset" : 0 , "end_offset" : 1 , "type" : "<IDEOGRAPHIC>" , "position" : 0 } , { "token" : "是" , "start_offset" : 1 , "end_offset" : 2 , "type" : "<IDEOGRAPHIC>" , "position" : 1 } , { "token" : "人" , "start_offset" : 2 , "end_offset" : 3 , "type" : "<IDEOGRAPHIC>" , "position" : 2 } , ...... ] }
安装 所以这里安装IK分词器插件
在线安装IK插件:
1 2 3 4 5 6 7 8 9 10 docker exec -it es /bin/bash ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip exit docker restart es
我这种安装失败了,分词报错400
离线安装:
查看数据卷目录:安装插件需要知道elasticSearch的plugin目录位置,而我们之前使用了数据卷挂载,因此需要查看es的数据卷目录,通过以下命令查看:
1 docker volume inspect es-plugins
显示结果
1 2 3 4 5 6 7 8 9 10 11 [ { "CreatedAt" : "2023-08-16T10:21:08+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
中
解压缩分词器安装包,并且上传到es容器的插件数据卷中,也就是上述目录:
重启容器,并查看日志
1 2 docker restart es docker logs -f es
可以通过日志看到加载了ik插件
两种模式 IK分词器包含两种模式
ik_smart
:最少切分
ik_max_word
:最细切分
演示:
ik_smart:
1 2 3 4 5 POST /_analyze { "analyzer" : "ik_smart" , "text" : "真是人间太岁神" }
输出结果
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 { "tokens" : [ { "token" : "d.p" , "start_offset" : 0 , "end_offset" : 3 , "type" : "LETTER" , "position" : 0 } , { "token" : "逃兵" , "start_offset" : 4 , "end_offset" : 6 , "type" : "CN_WORD" , "position" : 1 } , { "token" : "通缉令" , "start_offset" : 6 , "end_offset" : 9 , "type" : "CN_WORD" , "position" : 2 } ] }
ik_max_word
1 2 3 4 5 POST /_analyze { "analyzer" : "ik_max_word" , "text" : "D.P:逃兵通缉令" }
输出结果
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 { "tokens" : [ { "token" : "d.p" , "start_offset" : 0 , "end_offset" : 3 , "type" : "LETTER" , "position" : 0 } , { "token" : "d" , "start_offset" : 0 , "end_offset" : 1 , "type" : "ENGLISH" , "position" : 1 } , { "token" : "p" , "start_offset" : 2 , "end_offset" : 3 , "type" : "ENGLISH" , "position" : 2 } , { "token" : "逃兵" , "start_offset" : 4 , "end_offset" : 6 , "type" : "CN_WORD" , "position" : 3 } , { "token" : "通缉令" , "start_offset" : 6 , "end_offset" : 9 , "type" : "CN_WORD" , "position" : 4 } , { "token" : "通缉" , "start_offset" : 6 , "end_offset" : 8 , "type" : "CN_WORD" , "position" : 5 } , { "token" : "令" , "start_offset" : 8 , "end_offset" : 9 , "type" : "CN_CHAR" , "position" : 6 } ] }
扩展和停用词典 要扩展和停用ik分词器的词库,只需要修改一个ik分词器目录中的config目录中的IKAnalyzer.cfg.xml文件,并添加如下内容
1 2 3 4 5 6 7 8 9 10 <?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 >
在IKAnalyzer.cfg.xml同级目录下新建ext.dic和stopword.dic,并编辑内容
ext.dic
stopword.dic
然后重启es:docker restart es
DSL索引库操作 DSL:Domain Specific Language
mapping属性 mapping是对索引库中文档的约束,常见的mapping属性包括
type
:字段数据类型,常见的简单类型有:
字符串:text(可分词文本)、keyword(精确值,例如:品牌、国家、ip地址;因为这些词,分词之后毫无意义)
数值:long、integer、short、byte、double、float
布尔:boolean
日期:date
对象:object
index
:是否创建索引,默认为true。默认情况下会对所有字段创建倒排索引,即每个字段都可以被搜索。但是某些字段是不存在搜索的意义的,例如邮箱,图片(存储的只是图片url),搜索邮箱或图片url的片段,没有任何意义。因此在创建字段映射时,一定要判断一下这个字段是否参与搜索,如果不参与搜索,则将其设置为false
analyzer
:使用哪种分词器,跟text结合使用
properties
:该字段的子字段。对象嵌套时的子字段,比如name中的
1 2 3 4 5 6 7 8 9 10 11 12 { "age" : 23 , "weight" : 75 , "isMarried" : false , "info" : "YSKM" , "email" : "YSKM@qq.com" , "score" : [ 99.1 , 99.5 , 98.9 ] , "name" : { "firstName" : "格" , "lastName" : "温" } }
索引库的CRUD ES中通过Restful请求操作索引库、文档。请求内用DSL语句来表示。
创建索引库和映射 创建索引库和mapping的DSL语法如下:
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 PUT /{ 索引库名称} { "mappings" : { "properties" : { "字段名1" : { "type" : "text " , "analyzer" : "ik_smart" } , "字段名2" : { "type" : "keyword" , "index" : false } , "字段名3" : { "properties" : { "子字段1" : { "type" : "keyword" } , "子字段2" : { "type" : "keyword" } } } , } } }
使用示例:
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 PUT /yskm { "mappings" : { "properties" : { "info" : { "type" : "text" , "analyzer" : "ik_smart" } , "age" : { "type" : "integer" , "index" : false } , "email" : { "type" : "keyword" , "index" : false } , "name" : { "type" : "object" , "properties" : { "firstname" : { "type" : "keyword" } , "lastname" : { "type" : "keyword" } } } } } }
输出以下内容表示成功:
1 2 3 4 5 { "acknowledged" : true , "shards_acknowledged" : true , "index" : "yskm" }
查询、删除索引库 查询索引库
删除索引库
修改索引库 索引库一旦构建,就会根据索引库创建倒排索引。如果对索引库进行修改,就会导致原有的倒排索引失效。因此索引库一旦创建,就禁止修改原有字段 。
但是可以添加新的字段,语法如下:
1 2 3 4 5 6 7 8 PUT /索引库名/_mapping { "properties" : { "新字段名" : { "type" : "integer" } } }
示例
1 2 3 4 5 6 7 8 PUT /yskm/_mapping { "properties" : { "isMarried" : { "type" : "boolean" } } }
DSL文档操作 新增文档 语法:
1 2 3 4 5 6 7 8 9 10 POST /索引库名/_doc/文档id { "字段1" : "值1" , "字段2" : "值2" , "字段3" : { "子属性1" : "值3" , "子属性2" : "值4" } , }
示例:
1 2 3 4 5 6 7 8 9 10 POST /yskm/_doc/1 { "info" : "小小格温" , "age" : 23 , "email" : "wzy@qq.com" , "name" : { "firstname" : "格" , "lastname" : "温" } }
响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "_index" : "yskm" , "_type" : "_doc" , "_id" : "1" , "_version" : 1 , "result" : "created" , "_shards" : { "total" : 2 , "successful" : 1 , "failed" : 0 } , "_seq_no" : 0 , "_primary_term" : 1 }
查询、删除文档 查询文档:
语法:
示例:
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "_index" : "yskm" , "_type" : "_doc" , "_id" : "1" , "_version" : 1 , "_seq_no" : 0 , "_primary_term" : 1 , "found" : true , "_source" : { "info" : "小小格温" , "age" : 23 , "email" : "wzy@qq.com" , "name" : { "firstname" : "格" , "lastname" : "温" } } }
删除文档:
修改文档 修改有两种方式
全量修改:直接覆盖原来的文档
增量修改:修改文档中的部分字段
全量修改 全量修改是覆盖原来的文档,其本质是:根据指定的id删除文档,然后新增一个相同id的文档
这种方式如果id存在就是修改,如果不存在就是新增
语法
1 2 3 4 5 6 PUT /索引库名/_doc/文档id { "字段1" : "值1" , "字段2" : "值2" , }
示例:
1 2 3 4 5 6 7 8 9 10 11 # 修改文档-全量 PUT /yskm/_doc/1 { "info" : "小小阿狸" , "age" : 23 , "email" : "ali@qq.com" , "name" : { "firstName" : "阿" , "lastName" : "狸" } }
增量修改 增量修改只修改指定id匹配文档中的部分字段
语法
1 2 3 4 5 6 7 POST /索引库名/_update/文档id { "doc" : { "字段名" : "新的值" , ... } }
示例:
1 2 3 4 5 6 POST /yskm/_update/1 { "doc" : { "age" : 18 } }
RestClient操作索引库 ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过HTTP请求发送给ES。官方文档地址:https://www.elastic.co/guide/en/client/index.html
其中JavaRestClient又包括两种
Java Low Level Rest Client
Java High Level Rest Client
这里学习的是Java High Level Rest Client
导入Demo工程 首先导入数据库tb_hotel,然后打开资料中的hotel-demo项目。这里因为我使用的mysql8版本,所以修改了数据库驱动以及依赖坐标。
mapping映射分析 创建索引库,最关键的是mapping映射,而mapping映射要考虑的信息包括:字段名、字段数据类型、是否参与搜索、是否需要分词,如果分词,分词器是什么?
表结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 CREATE TABLE `tb_hotel` ( `id` bigint NOT NULL COMMENT '酒店id' , `name` varchar (255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '酒店名称' , `address` varchar (255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '酒店地址' , `price` int NOT NULL COMMENT '酒店价格' , `score` int NOT NULL COMMENT '酒店评分' , `brand` varchar (32 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '酒店品牌' , `city` varchar (32 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '所在城市' , `star_name` varchar (16 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '酒店星级,1星到5星,1钻到5钻' , `business` varchar (255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '商圈' , `latitude` varchar (32 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '纬度' , `longitude` varchar (32 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '经度' , `pic` varchar (255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '酒店图片' , PRIMARY KEY (`id`) USING BTREE ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT= COMPACT;
分析酒店数据的索引库结构:
id
:id的类型比较特殊,不是long
,而是keyword
(在es中id都是字符串类型);而且id后期肯定需要涉及到增删改查,所以需要参与搜索,即index保持默认
name
:需要参与搜索,而且是text
,需要参与分词,分词器选择ik_max_word
address
:是字符串,但是不会有人根据地址去搜索酒店吧,所以index就设置为false,因而也不需要分词,所以类型使用keyword
price
:类型:integer,需要参与搜索(后续做排序)
score
:类型:integer,需要参与搜索(后续做排序)
brand
:类型:keyword,但是不需要分词(品牌名称分词后毫无意义),所以为keyword,需要参与搜索
city
:类型:keyword,分词无意义;很多人根据城市选酒店,所以需要参与搜索
star_name
:类型:keyword,需要参与搜索
business
:类型:keyword,需要参与搜索
latitude
和longitude
:地理坐标在ES中比较特殊,ES中支持两种地理坐标数据类型:
geo_point:
由纬度(latitude)和经度( longitude)确定的一个点。例如:”32.8752345,120.2981576”
geo_shape:
有多个geo_point组成的复杂几何图形。例如一条直线,”LINESTRING (-77.03653 38.897676,-77.009051 38.889939)”
所以这里应该是geo_point类型
pic
:类型:keyword,不需要参与搜索,index为false
因此最终酒店的字段mapping如下:
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 37 38 39 40 41 42 43 PUT /hotel { "mappings" : { "properties" : { "id" : { "type" : "keyword" } , "name" : { "type" : "text" , "analyzer" : "ik_max_word" } , "address" : { "type" : "keyword" , "index" : false } , "price" : { "type" : "integer" } , "score" : { "type" : "integer" } , "brand" : { "type" : "keyword" } , "city" : { "type" : "keyword" } , "starName" : { "type" : "keyword" } , "business" : { "type" : "keyword" } , "location" : { "type" : "geo_point" } , "pic" : { "type" : "keyword" , "index" : false } } } }
但是现在还有一个问题,就是name、brand、city等字段都需要参与搜索,也就意味着用户在搜索的时候,会根据多个字段搜,例如:上海虹桥希尔顿五星酒店
那么ES是根据多个字段搜效率高,还是根据一个字段搜效率高?显然是搜索一个字段效率高。那现在既想根据多个字段搜又想要效率高,怎么解决这个问题呢?ES给我们提供了一种简单的解决方案:
字段拷贝 可以使用copy_to
属性,将当前字段拷贝到指定字段,示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 "all" : { "type" : "text" , "analyzer" : "ik_max_word" } , "name" : { "type" : "text" , "analyzer" : "ik_max_word" , "copy_to" : "all" } , "brand" : { "type" : "keyword" , "copy_to" : "all" }
修改后的:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 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" } } } }
这里其实相当于把这几个字段拷贝到all中,从而能够在一个字段中搜索多个内容
初始化RestCliet 步骤:
引入ES的RestHighLevelClient的依赖
1 2 3 4 <dependency > <groupId > org.elasticsearch.client</groupId > <artifactId > elasticsearch-rest-high-level-client</artifactId > </dependency >
因为SpringBoot管理的ES默认版本为7.6.2,所以我们需要覆盖默认的ES版本
1 2 3 4 <properties> <java.version>1.8</java.version> + <elasticsearch.version>7.12.1</elasticsearch.version> </properties>
初始化RestHighLevelClient
1 2 3 RestHighLevelClient client = new RestHighLevelClient (RestClient.builder( HttpHost.create("http://192.168.186.130:9200" ) ));
这里通过单元测试进行,创建一个HotelIndexTest类,便于后续测试。这里将初始化放在了@BeforeEach下,所有的测试方法都会首先加载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class HotelIndexTest { private RestHighLevelClient client; @Test void testInit () { System.out.println(this .client); } @BeforeEach void setUp () { this .client = new RestHighLevelClient (RestClient.builder( HttpHost.create("http://192.168.186.130:9200" ) )); } @AfterEach void tearDown () throws IOException { this .client.close(); } }
创建索引库 创建索引库的代码如下
1 2 3 4 5 6 7 8 9 @Test void testCreateHotelIndex () throws IOException { CreateIndexRequest request = new CreateIndexRequest ("hotel" ); request.source(MAPPING_TEMPLATE, XContentType.JSON); client.indices().create(request, RequestOptions.DEFAULT); }
创建Request对象,因为是创建索引库的操作,因此Request是CreateIndexRequest,这一步对标DSL语句中的PUT /hotel
添加请求参数:其实就是DSL的JSON参数部分,因为JSON字符很长,所以这里创建了contants
包,在HotelConstants
类中定义了静态常量MAPPING_TEMPLATE
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 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" + "}" ; }
发送请求,client.indics()
方法的返回值是IndicesClient类型,封装了所有与索引库有关的方法
删除索引库 根据上面的创建部分的代码,容易推出:
发送请求也是使用client.indics()
中的方法,这里是delete方法
而可以看到delete方法中需要的request参数类型是DeleteIndexRequest
所以代码如下:
1 2 3 4 5 6 7 @Test void testDeleteHotelIndex () throws IOException { DeleteIndexRequest request = new DeleteIndexRequest ("hotel" ); client.indices().delete(request, RequestOptions.DEFAULT); }
判断索引库是否存在 同理,根据上述分析,判断索引库是否存在的代码也很容易推理出:
1 2 3 4 5 6 @Test void testGetHotelIndex () throws IOException { GetIndexRequest request = new GetIndexRequest ("hotel" ); boolean exists = client.indices().exists(request, RequestOptions.DEFAULT); System.out.println(exists ? "索引库已存在" : "索引库不存在" ); }
总结 JavaRestClient对索引库操作的流程基本类似,核心就是client.indices()
方法来获取索引库的操作对象
索引库操作基本步骤:
初始化RestHighLevelClient
创建XxxIndexRequest。Xxx是Create、Get、Delete
准备DSL(Create时需要,其它是无参)
发送请求,调用ReseHighLevelClient.indices().xxx()方法,xxx是create、exists、delete
RestClient操作文档 首先还是类似前面的操作索引,这里也是创建一个测试类进行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @SpringBootTest public class HotelDocumentTest { @Autowired private IHotelService hotelService; private RestHighLevelClient client; @BeforeEach void setUp () { client = new RestHighLevelClient (RestClient.builder( new HttpHost ("http://192.168.186.130:9200" ) )); } @AfterEach void tearDown () throws IOException { client.close(); } }
新增文档 这里新增文档主要就是把数据库中的酒店数据查询出来,写入ES中。
创建索引库实体类 首先数据库查询出来的结果是一个Hotel类型的对象,结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Data @TableName("tb_hotel") public class Hotel { @TableId(type = IdType.INPUT) private Long id; private String name; private String address; private Integer price; private Integer score; private String brand; private String city; private String starName; private String business; private String longitude; private String latitude; private String pic; }
注意:这里数据库和索引库的结构有所差异:longitude和latitude需要合并为location
所以,我们需要定义一个新类型,与索引库结构吻合
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 @Data @NoArgsConstructor public class HotelDoc { private Long id; private String name; private String address; private Integer price; private Integer score; private String brand; private String city; private String starName; private String business; private String location; private String pic; public HotelDoc (Hotel hotel) { this .id = hotel.getId(); this .name = hotel.getName(); this .address = hotel.getAddress(); this .price = hotel.getPrice(); this .score = hotel.getScore(); this .brand = hotel.getBrand(); this .city = hotel.getCity(); this .starName = hotel.getStarName(); this .business = hotel.getBusiness(); this .location = hotel.getLatitude() + ", " + hotel.getLongitude(); this .pic = hotel.getPic(); } }
API用法 新增文档的DSL语法如下
1 2 3 4 5 POST /索引库名/_doc/id { "name" : "Jack" , "age" : 21 }
对应的Java代码如下
1 2 3 4 5 6 @Test void testIndexDocument () throws IOException { IndexRequest request = new IndexRequest ("indexName" ).id("1" ); request.source("{\"name\":\"Jack\",\"age\":21}" ); client.index(request, RequestOptions.DEFAULT); }
主要步骤如下:
创建Request对象
准备请求参数,即DSL中的JSON文档
发送请求。这里相比新增索引,区别在于直接使用client.xxx()的API,而新增索引则需要client.indices().xxx()
整体流程 代码整体步骤如下:
根据id查询酒店数据Hotel
将Hotel封装为HotelDoc
将HotelDoc序列化为Json
创建IndexRequest,指定索引库名和id
准备请求参数,也就是Json文档
发送请求
这里编写测试方法进行测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Test void testAddDocument () throws IOException { Hotel hotel = hotelService.getById(61083L ); HotelDoc hotelDoc = new HotelDoc (hotel); String jsonString = JSON.toJSONString(hotelDoc); IndexRequest request = new IndexRequest (); request.source(jsonString, XContentType.JSON); client.index(request, RequestOptions.DEFAULT); }
然后在kibana中使用DSL语句GET /hotel/_doc/61083
查询结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 { "_index" : "hotel" , "_type" : "_doc" , "_id" : "61083" , "_version" : 1 , "_seq_no" : 0 , "_primary_term" : 1 , "found" : true , "_source" : { "address" : "自由贸易试验区临港新片区南岛1号" , "brand" : "皇冠假日" , "business" : "滴水湖临港地区" , "city" : "上海" , "id" : 61083 , "location" : "30.890867, 121.937241" , "name" : "上海滴水湖皇冠假日酒店" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/312e971Rnj9qFyR3pPv4bTtpj1hX_w200_h200_c1_t0.jpg" , "price" : 971 , "score" : 44 , "starName" : "五钻" } }
可以看出文档主要是在_source
属性里
查询文档 查询的DSL语句如下
因为这里查询没有请求参数,所以流程比较简单。但是查询的目的是为了得到HotelDoc,在刚刚查询的结果中,我们发现HotelDoc对象的主要内容在_source
属性中,所以要获取这部分内容,然后将其转化为HotelDoc
1 2 3 4 5 6 7 8 9 10 11 @Test void testGetDocumentById () throws IOException { GetRequest request = new GetRequest ("hotel" ).id("61083" ); GetResponse response = client.get(request, RequestOptions.DEFAULT); String jsonStr = response.getSourceAsString(); HotelDoc hotelDoc = JSON.parseObject(jsonStr, HotelDoc.class); System.out.println(hotelDoc); }
修改文档 修改对应前面是两种方式
全量修改:先根据id删除整个文档,再新增文档
增量修改:修改文档中的指定字段值
在RestClient的API中,全量修改与新增的API完全一致 ,判断的依据是ID
若新增时,ID已经存在,则修改(删除再新增)
若新增时,ID不存在,则新增
这里就主要讲增量修改 ,对应的DSL语句如下:
1 2 3 4 5 6 7 POST /yskm/_update/1 { "doc" : { "email" : "yskm@qq.com" , "info" : "you should know me" } }
对应的代码如下:
1 2 3 4 5 6 7 8 9 10 11 @Test void testUpdateDocumentById () throws IOException { UpdateRequest request = new UpdateRequest ("hotel" ,"61083" ); request.doc( "city" ,"北京" , "price" ,1888 ); client.update(request,RequestOptions.DEFAULT); }
删除文档 删除的DSL语句如下
与查询相比,仅仅是请求方式由DELETE变为GET,对应的代码如下:
1 2 3 4 5 6 7 @Test void testDeleteDocumentById () throws IOException { DeleteRequest request = new DeleteRequest ("hotel" ,"61083" ); client.delete(request,RequestOptions.DEFAULT); }
批量导入文档 在前面的测试中,我们都是一条一条新增文档,但实际应用中,需要批量的将数据库数据导入索引库中
需求:批量查询酒店数据,然后批量导入索引库中
思路步骤:
利用mybatis-plus查询酒店数据
将查询到的酒店数据(Hotel)转化为文档类型数据(HotelDoc)
利用JavaRestClient中的Bulk批处理 ,实现批量新增文档,示例代码如下
1 2 3 4 5 6 7 @Test void testBulkAddDoc () throws IOException { BulkRequest request = new BulkRequest (); request.add(new IndexRequest ("hotel" ).id("101" ).source("json source1" , XContentType.JSON)); request.add(new IndexRequest ("hotel" ).id("102" ).source("json source2" , XContentType.JSON)); client.bulk(request, RequestOptions.DEFAULT); }
实际代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 @Test void testBulkAddDoc () throws IOException { BulkRequest request = new BulkRequest (); List<Hotel> hotels = hotelService.list(); for (Hotel hotel : hotels) { HotelDoc hotelDoc = new HotelDoc (hotel); request.add(new IndexRequest ("hotel" ). id(hotelDoc.getId().toString()). source(JSON.toJSONString(hotelDoc), XContentType.JSON)); } client.bulk(request, RequestOptions.DEFAULT); }
使用stream流操作可以简化代码
1 2 3 4 5 6 7 8 9 @Test void testBulkAddDoc () throws IOException { BulkRequest request = new BulkRequest (); hotelService.list().stream().forEach(hotel -> request.add(new IndexRequest ("hotel" ) .id(hotel.getId().toString()) .source(JSON.toJSONString(new HotelDoc (hotel)), XContentType.JSON))); client.bulk(request, RequestOptions.DEFAULT); }
DSL查询文档 在前面的学习中,我们主要学习是数据的存储,但数据的存储不是目的,最终希望的是从海量的数据中检索到所需要的信息。这就用到es的搜索功能
DSL查询分类 ElasticSearch的查询是基于JSON风格的DSL(Domain Specific Language)来实现的。常见的查询包括:
查询所有 :查询出所有数据,一般测试用。例如match_all
全文检索 (full text):利用分词器对用户输入的内容分词,然后去倒排索引库中匹配。例如
match_query
multi_match_query
精确查询 :根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如
地理查询 (geo):根据经纬度查询。例如
geo_distance
geo_bounding_box
复合查询 (compound):复合查询可以将上述各种查询条件组合起来,合并查询条件。例如
DSL Query的基本语法:
查询的语法基本一致
1 2 3 4 5 6 7 8 GET /indexname/_search { "query" : { "查询类型" : { "查询条件" : "条件值" } } }
这里以查询所有为例,查询类型为match_all
,没有查询条件
1 2 3 4 5 6 7 8 GET /hotel/_search { "query" : { "match_all" : { } } }
输出:(因为我这里没放数据,如果有数据都在hits下)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "took" : 1 , "timed_out" : false , "_shards" : { "total" : 1 , "successful" : 1 , "skipped" : 0 , "failed" : 0 } , "hits" : { "total" : { "value" : 0 , "relation" : "eq" } , "max_score" : null , "hits" : [ ] } }
其他查询语法大体类似,区别主要就是查询类型和查询条件 不同
全文检索 全文搜索查询,常用于搜索框搜索。查询流程 基本如下:
根据用户搜索的内容做分词,得到词条
根据词条去倒排索引库中匹配,得到文档id
根据文档id找到的文档,返回给用户
语法 常见的全文检索包括
使用示例 以match搜索:
这里以match为例,搜索”外滩”。注意这里的all字段,这是我们之前定义的拷贝字段,我们将需要进行检索的字段(name
、city
、business
)拷贝到这个字段中,便于统一地检索
1 2 3 4 5 6 7 8 GET /hotel/_search { "query" : { "match" : { "all" : "上海外滩" } } }
输出结果:这里只看hits内的数据
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 37 38 39 40 41 "hits" : [ { "_index" : "hotel" , "_type" : "_doc" , "_id" : "60487" , "_score" : 11.069907 , "_source" : { "address" : "黄浦路199号" , "brand" : "君悦" , "business" : "外滩地区" , "city" : "上海" , "id" : 60487 , "location" : "31.245409, 121.492969" , "name" : "上海外滩茂悦大酒店" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/2Swp2h1fdj9zCUKsk63BQvVgKLTo_w200_h200_c1_t0.jpg" , "price" : 689 , "score" : 44 , "starName" : "五星级" } } , { "_index" : "hotel" , "_type" : "_doc" , "_id" : "434082" , "_score" : 8.015637 , "_source" : { "address" : "复兴东路260号" , "brand" : "如家" , "business" : "豫园地区" , "city" : "上海" , "id" : 434082 , "location" : "31.220706, 121.498769" , "name" : "如家酒店·neo(上海外滩城隍庙小南门地铁站店)" , "pic" : "https://m.tuniucdn.com/fb2/t1/G6/M00/52/B6/Cii-U13eXLGIdHFzAAIG-5cEwDEAAGRfQNNIV0AAgcT627_w200_h200_c1_t0.jpg" , "price" : 392 , "score" : 44 , "starName" : "二钻" } } , ....省略 ]
可以看出,如果涉及上海外滩、外滩、上海这些关键字就会被搜索出来
使用muti_match搜索:
1 2 3 4 5 6 7 8 9 GET /hotel/_search { "query" : { "multi_match" : { "query" : "上海外滩" , "fields" : [ "brand" , "city" , "business" ] } } }
这个搜索地内容与上面一致。这是因为我们前面将name
、city
、business
的值都利用copy_to
复制到了all字段中,因此根据这三个字段搜索和根据all字段搜索的结果当然一样了。随着搜索的字段的增多,对查询性能影响就越大,因此建议采用copy_to
,然后使用单字段match查询的方式
精确查询 精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词 。常见的有
term
:根据词条精确值查询
range
:根据值的范围查询
以上图为例,根据词条精确值查询有:城市、星级、品牌;根据值的范围查询包括:价格、日期等
语法 term查询:
1 2 3 4 5 6 7 8 9 10 GET /indexName/_search { "query" : { "term" : { "FIELD" : { "value" : "VALUE" } } } }
示例:查询上海的酒店数据
1 2 3 4 5 6 7 8 9 10 GET /hotel/_search { "query" : { "term" : { "city" : { "value" : "上海" } } } }
当搜索的内容不是词条时,而是多个词语组成的短语时,就会搜索不到,因为这里不会对搜索词分词
range查询
1 2 3 4 5 6 7 8 9 10 11 GET /hotel/_search { "query" : { "range" : { "FIELD" : { "gte" : 10 , "lte" : 20 } } } }
示例:查询酒店价格在1000~3000的酒店
1 2 3 4 5 6 7 8 9 10 11 GET /hotel/_search { "query" : { "range" : { "price" : { "gte" : 1000 , "lte" : 3000 } } } }
小结 精确查询常见的有哪些?
term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
range查询:根据数值范围查询,可以使数值、日期的范围
地理查询 根据经纬度查询,官方文档 。常见的使用场景包括:
携程:搜索附近的酒店
滴滴:搜索附近的出租车
微信:搜索附近的人
矩形范围查询 矩形范围查询,也就是geo_bounding_box查询,查询坐标落在某个矩形范围内 的所有文档
查询时,指定矩形的左上、右下两个点的坐标,然后画出一个矩形,落在该矩形范围内的坐标,都是符合条件的文档
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 GET /indexName/_search { "query" : { "geo_bounding_box" : { "FIELD" : { "top_left" : { "lat" : 31.1 , "lon" : 121.5 } , "bottom_right" : { "lat" : 30.9 , "lon" : 121.7 } } } } }
示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 GET /hotel/_search { "query" : { "geo_bounding_box" : { "location" : { "top_left" : { "lat" : 31.1 , "lon" : 121.5 } , "bottom_right" : { "lat" : 30.9 , "lon" : 121.7 } } } } }
输出:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 { "took" : 56 , "timed_out" : false , "_shards" : { "total" : 1 , "successful" : 1 , "skipped" : 0 , "failed" : 0 } , "hits" : { "total" : { "value" : 2 , "relation" : "eq" } , "max_score" : 1.0 , "hits" : [ { "_index" : "hotel" , "_type" : "_doc" , "_id" : "2022598930" , "_score" : 1.0 , "_source" : { "address" : "南奉公路3111弄228号" , "brand" : "喜来登" , "business" : "奉贤开发区" , "city" : "上海" , "id" : 2022598930 , "location" : "30.921659, 121.575572" , "name" : "上海宝华喜来登酒店" , "pic" : "https://m.tuniucdn.com/fb2/t1/G6/M00/45/BD/Cii-TF3ZaBmIStrbAASnoOyg7FoAAFpYwEoz9oABKe4992_w200_h200_c1_t0.jpg" , "price" : 2899 , "score" : 46 , "starName" : "五钻" } } , { "_index" : "hotel" , "_type" : "_doc" , "_id" : "2056298828" , "_score" : 1.0 , "_source" : { "address" : "沪南公路7688弄1号" , "brand" : "万豪" , "business" : "南汇/野生动物园" , "city" : "上海" , "id" : 2056298828 , "location" : "31.030053, 121.662943" , "name" : "上海中优城市万豪酒店" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/2gBATEyysyQWmw3wZL863HGdqjaq_w200_h200_c1_t0.jpg" , "price" : 1200 , "score" : 45 , "starName" : "五钻" } } ] } }
附近查询 geo_distance:查询到指定中心点的距离小于等于某个值 的所有文档,也就是以指定中心点为圆心,指定距离为半径,画一个圆,落在圆内的坐标都算符合条件。
语法:
1 2 3 4 5 6 7 8 9 GET /indexName/_search { "query" : { "geo_distance" : { "distance" : "3km" , "location" : "39.9, 116.4" } } }
示例:查询某点附近3km内的酒店文档
1 2 3 4 5 6 7 8 9 GET /hotel/_search { "query" : { "geo_distance" : { "distance" : "3km" , "location" : "39.9, 116.4" } } }
复合查询 复合(compound)查询:复合查询可以将其他简单查询组合起来,实现更复杂的搜索逻辑,例如:
function score:算分函数查询,可以控制文档相关性算分 ,控制文档排名。(例如搜索引擎的排名,第一大部分都是广告)
bool query:布尔查询,利用逻辑关系组合多个其他的查询,实现复杂搜索
相关性算分 当我们利用match查询时,文档结果会根据搜索词条的关联度打分(_score),返回结果时按照分值降序排列
例如我们搜索虹桥如家,结果如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 [ { "_score" : 17.850193 , "_source" : { "name" : "虹桥如家酒店真不错" , } } , { "_score" : 12.259849 , "_source" : { "name" : "外滩如家酒店真不错" , } } , { "_score" : 11.91091 , "_source" : { "name" : "迪士尼如家酒店真不错" , } } ]
在ES中,早期使用的打分算法是TF-IDF算法,再后来的5.1版本升级中,ES将算法改进为BM25算法。TF-IDF算法有一种缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更平滑。
算分函数查询 使用function score query 算分函数查询,可以修改文档的相关性算分,根据新得到的算分排序
语法示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /indexName/_search { "query" : { "function_score" : { "query" : { "match" : { "all" : "外滩" } } , "functions" : [ { "filter" : { "term" : { "id" : "1" } } , "weight" : 10 } ] , "boost_mode" : "multiply" } } }
上述包含四部分内容:
原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(query score)
过滤条件:filter部分,符合该条件的文档才会被重新算分
算分函数:符合filter条件的文档要根据这个函数做运算,得到函数算分(function score),有四种函数
运算模式(boost_mode):算分函数的结果、原始查询的相关性算分,二者之间的运算方式,包括
使用案例:
需求:给如家
这个品牌的酒店排名靠前一点 思路:过滤条件为"brand": "如家"
,算分函数和运算模式可以固定算分结果相乘
对应的DSL语句如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /hotel/_search { "query" : { "function_score" : { "query" : { "match" : { "all" : "外滩" } } , "functions" : [ { "filter" : { "term" : { "brand" : "如家" } } , "weight" : 10 } ] , "boost_mode" : "multiply" } } }
通过算分前后可以看出,如家酒店从原本的"_score" : 3.8000445
变成了"_score" : 38.000446
,并且搜索结果明显提前
布尔查询 布尔查询是一个或多个子查询的组合 ,每一个子句就是一个子查询。子查询的组合方式有
must
:必须匹配每个子查询,类似与
should
:选择性匹配子查询,类似或
must_not
:必须不匹配,不参与算分
,类似非
filter
:必须匹配,不参与算分
例如在搜索酒店时,除了关键字搜索外,我们还可能根据酒店品牌、价格、城市等字段做过滤。每一个不同的字段,其查询条件、方式都不一样,必须是多个不同的查询,而要组合这些查询 ,就需要用到布尔查询了
搜索时,参与打分的字段越多,查询的性能就越差,所以在多条件查询时:搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分;其他过滤条件如城市、星级、品牌等,采用filter和must_not查询,不参与算分。
使用示例:
需求:搜索名字中包含如家
,价格不高于400
,在坐标39.9, 116.4
周围10km
范围内的酒店 分析:
名称搜索,属于全文检索查询,应该参与算分,放到must
中
价格不高于400,用range查询,属于过滤条件,不参与算分,放到must_not
中
周围10km范围内,用geo_distance查询,属于过滤条件,放到filter
中
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 GET /hotel/_search { "query" : { "bool" : { "must" : [ { "match" : { "name" : "如家" } } ] , "must_not" : [ { "range" : { "price" : { "gt" : 400 } } } ] , "filter" : [ { "geo_distance" : { "distance" : "10km" , "location" : { "lat" : 39.9 , "lon" : 116.4 } } } ] } } }
输出:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 "hits" : [ { "_index" : "hotel" , "_type" : "_doc" , "_id" : "1765008760" , "_score" : 1.8745286 , "_source" : { "address" : "西直门北大街49号" , "brand" : "如家" , "business" : "西直门/北京展览馆地区" , "city" : "北京" , "id" : 1765008760 , "location" : "39.945106, 116.353827" , "name" : "如家酒店(北京西直门北京北站店)" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/4CLwbCE9346jYn7nFsJTQXuBExTJ_w200_h200_c1_t0.jpg" , "price" : 356 , "score" : 44 , "starName" : "二钻" } } , { "_index" : "hotel" , "_type" : "_doc" , "_id" : "416121" , "_score" : 1.7920744 , "_source" : { "address" : "莲花池东路120-2号6层" , "brand" : "如家" , "business" : "北京西站/丽泽商务区" , "city" : "北京" , "id" : 416121 , "location" : "39.896449, 116.317382" , "name" : "如家酒店(北京西客站北广场店)" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/42DTRnKbiYoiGFVzrV9ZJUxNbvRo_w200_h200_c1_t0.jpg" , "price" : 275 , "score" : 43 , "starName" : "二钻" } } , { "_index" : "hotel" , "_type" : "_doc" , "_id" : "234719711" , "_score" : 1.4689932 , "_source" : { "address" : "朝阳北路八里庄南里26号" , "brand" : "如家" , "business" : "国贸地区" , "city" : "北京" , "id" : 234719711 , "location" : "39.922472, 116.501118" , "name" : "如家酒店·neo(北京朝阳北路十里堡地铁站店)" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/2rHdXNCmycnUxw99AniFC25ZDSfJ_w200_h200_c1_t0.jpg" , "price" : 378 , "score" : 47 , "starName" : "二钻" } } ]
根据Kyle的博客,must和should一起用的时候,should会不生效果,查询到should之外的品牌。需要在must里再套一个bool,里面再套should,比较麻烦,后续可以在java中修改。
搜索结果处理 排序 ES支持对搜索结果的排序,默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序。可以排序的字段有:keyword类型、数值类型、地理坐标类型、日期类型等
普通字段排序 keyword、数值、日期类型排序的语法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 GET /hotel/_search { "query" : { "match_all" : { } } , "sort" : [ { "FIELD" : { "order" : "desc" } , "FIELD" : { "order" : "asc" } } ] }
排序条件是一个数组,可以写多个排序条件。按照声明顺序,当第一个条件相等时,再按照第二个条件排序,以此类推
案例:
需求:实现酒店数据按照到你的位置坐标的距离升序排序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 GET /hotel/_search { "query" : { "match_all" : { } } , "sort" : [ { "score" : { "order" : "desc" } , "price" : { "order" : "asc" } } ] }
地理坐标排序 语法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /indexName/_search { "query" : { "match_all" : { } } , "sort" : [ { "_geo_distance" : { "FIELD" : { "lat" : 40 , "lon" : -70 } , "order" : "asc" , "unit" : "km" } } ] }
这个查询的含义:
指定一个坐标,作为目标点
计算每一个文档中,指定字段(必须是geo_point类型)的坐标,到目标点的距离是多少
根据距离排序
案例:
需求:实现酒店数据按照到当前位置坐标的距离升序排序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /hotel/_search { "query" : { "match_all" : { } } , "sort" : [ { "_geo_distance" : { "location" : { "lat" : 39.9 , "lon" : 116.4 } , "order" : "asc" , "unit" : "km" } } ] }
分页 ES默认情况下只返回top10
的数据。而如果要查询更多数据就需要修改分页参数了。
ES中通过修改from、size参数来控制要返回的分页结果:
from
:从第几个文档开始
size
:总共查询几个文档
分页的基本语法如下:(类似于mysql中的limit ?, ?
)
1 2 3 4 5 6 7 8 GET /indexName/_search { "query" : { "match_all" : { } } , "from" : 0 , "size" : 10 }
深度分页问题 ES是分布式的,所以会面临深度分页问题。假如按照price排序后,我们要获取990~1000的酒店数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /hotel/_search { "query" : { "match_all" : { } } , "from" : 990 , "size" : 10 , "sort" : [ { "price" : { "order" : "asc" } } ] }
ES内部分页时,必须先查询0~1000
条,然后截取其中990~1000
的这10条,如果ES是单点模式,这样做就可以了。
但是实际场景中ES一定是集群部署的,如下图所示,集群包含四个节点,此时如果查询前1000不是从每个节点获取前250就好了的,因为可能一个节点的前250对于另一个节点来说就排在1000开外了(这个很好理解,就跟班级中的好学生一样,不同班级的学生亦有差距)。
因此想获取整个集群的TOP1000
,就必须先查询出每个节点的TOP1000
,汇总结果后,重新排序,重新截取TOP1000
但这样就又面临了一个新问题 :汇总数据过多时,岂不是每个节点都要拿出大量结果,就会对内存和CPU产生非常大的压力。因此ES会禁止form + size > 10000
的请求
针对深度分页问题,ES提供而两种解决方案 ,官方文档:https://www.elastic.co/guide/en/reference/current/paginate-search-results.html
search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式
scrool:原理是将排序后的文档id形成快照,保存在内存。官方已经不推荐使用
小结 分页查询的常见实现方案以及优缺点
from + size:
优点:支持随机翻页
缺点:深度分页问题,默认查询上限是10000(from + size)
场景:百度、京东、谷歌、淘宝这样的随机翻页搜索(百度现在支持翻页到75页,然后显示提示:限于网页篇幅,部分结果未予显示。
)
after search:
优点:没有查询上限(单词查询的size不超过10000)
缺点:只能向后逐页查询,不支持随机翻页
场景:没有随机翻页需求的搜索,例如手机的向下滚动翻页
scroll:
优点:没有查询上限(单词查询的size不超过10000)
缺点:会有额外内存消耗,并且搜索结果是非实时的(快照保存在内存中,不可能每搜索一次都更新一次快照)
场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议使用after search方案
高亮 高亮就是在搜索结果中把搜索关键字突出显示。
高亮显示的实现 分为两步:
给文档中的所有关键字都添加一个标签,例如<strong>
标签
页面给<strong>
标签编写CSS样式
语法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 GET /indexName/_search { "query" : { "match" : { "FIELD" : "TEXT" } } , "highlight" : { "fields" : { "FIELD" : { "pre_tags" : "<strong>" , "post_tags" : "</strong>" } } } }
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 GET /hotel/_search { "query" : { "match" : { "all" : "如家" } } , "highlight" : { "fields" : { "name" : { "pre_tags" : "<strong>" , "post_tags" : "</strong>" , "require_field_match" : "false" } } } }
注意:
高亮是对关键词高亮,因此搜索条件必须带有关键字 (上述中是all字段),而不能是范围这样的查询
默认情况下,高亮的字段,必须与搜索指定的字段一致(上述中的all和name字段),否则无法高亮。如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false
默认情况下就是加的<em>
标签,所以我们也可以省略使用<strong>
此时搜索出的结果的高亮部分前后就会加上对应定义的标签。
RestClient查询文档 快速入门 这里以match_all为例
发起查询请求 DSL语句的match_all
1 2 3 4 5 6 GET /hotel/_search { "query" : { "match_all" : { } } }
对应的java代码
1 2 3 4 5 6 7 8 9 10 @Test void testMatchAll () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.matchAllQuery()); SearchResponse response = client.search(request, RequestOptions.DEFAULT); System.out.println(response); }
创建SearchRequest对象,指定索引库名
利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
利用client.search()发送请求,得到响应
输出结与浏览器中es看到的JSON字符串一致。但是并不直观,所以就需要对输出进行json解析
解析响应 响应结果的解析
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 @Test void testMatchAll () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.matchAllQuery()); SearchResponse response = client.search(request, RequestOptions.DEFAULT); SearchHits searchHits = response.getHits(); long total = searchHits.getTotalHits().value; System.out.println("共查询到" + total + "条" ); SearchHit[] hits = searchHits.getHits(); for (SearchHit hit : hits) { String json = hit.getSourceAsString(); HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); System.out.println(hotelDoc); } }
对应关系:
match查询 全文检索的match和multi_match查询与match_all的API基本一致。差别是查询条件,也就是query的那部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 GET /hotel/_search { "query" : { "match_all" : { } } } GET /hotel/_search { "query" : { "match" : { "all" : "如家" } } } GET /hotel/_search { "query" : { "multi_match" : { "query" : "如家" , "fields" : [ "brand" , "name" ] } }
根据对比可以推出,对应的Java代码上的差异主要是request.source.query(QueryBuilders.xxx)
中的参数了
示例:
单字段查询:QueryBuilders.matchQuery("all","如家")
1 2 3 4 5 6 7 8 9 10 11 @Test void testMatch() throws IOException { SearchRequest request = new SearchRequest("hotel" ); request.source().query(QueryBuilders.matchQuery("all" , "如家" )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
便于代码重用,我们这里将输出json解析抽取为方法handelResponse
,IDEA中选中这部分代码后,使用快捷键使Ctrl + Alt + M
可以快速抽取
多字段查询:QueryBuilders.multiMatchQuery("如家","brand","name")
1 2 3 4 5 6 7 8 9 10 11 @Test void testMultiMatch() throws IOException { SearchRequest request = new SearchRequest("hotel" ); request.source().query(QueryBuilders.multiMatchQuery("如家" , "brand" , "name" )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
精确查询 精确查询常见有:
term:词条精确匹配
range:范围查询
和前面一样,对应的Java代码上的差异主要是request.source.query(QueryBuilders.xxx)
中的参数
示例:
term词条精确匹配:
精确匹配杭州的店铺:QueryBuilders.termQuery("city","杭州")
1 2 3 4 5 6 7 8 9 10 11 @Test void testTermMatch () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.termQuery("city" ,"杭州" )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
range范围查询
范围查询价格在1000~2000的酒店:QueryBuilders.rangeQuery("price").gt(100).lt(150)
1 2 3 4 5 6 7 8 9 10 11 @Test void testRangeMatch () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.rangeQuery("price" ).gt(100 ).lt(150 )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
布尔查询 布尔查询是用must、must_not、filter等方式组合其他查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Test void testBoolMatch () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); boolQuery.must(QueryBuilders.termQuery("city" , "杭州" )); boolQuery.filter(QueryBuilders.rangeQuery("price" ).lt(150 )); request.source().query(boolQuery); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
排序、分页 搜索结果的排序和分页是与query同级 的参数,因此同样是使用request.source()
来设置
1 2 3 4 5 6 7 8 9 10 11 12 13 @Test void testSortMatch () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.matchAllQuery()) .sort("price" , SortOrder.ASC) .from(0 ).size(5 ); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
高亮 高亮API也是与query同级 的参数。高亮请求的API对应如下:
1 2 3 request.source().highlighter(new HighlightBuilder () .field("name" ) .requireFieldMatch(false ));
对应的DSL语句:
1 2 3 4 5 6 "highlight" : { "fields" : { "name" : { "require_field_match" : "false" } }
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Test void testHighLightMatch () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.matchQuery("all" ,"如家" )) .highlighter(new HighlightBuilder () .field("name" ) .requireFieldMatch(false )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); handelResponse(response); }
但是若直接这样,只修改查询DSL的话,返回的结果并不是高亮的。这就涉及到高亮的结果解析
高亮的结果解析 从下面可以看到,**高亮的结果”highlight”与 查询的文档结果”_source”**是并列的,因此解析高亮的代码需要额外处理
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 "hits" : { "total" : { "value" : 102 , "relation" : "eq" } , "max_score" : null , "hits" : [ { "_index" : "hotel" , "_type" : "_doc" , "_id" : "1765008760" , "_score" : null , "_source" : { "address" : "西直门北大街49号" , "brand" : "如家" , "business" : "西直门/北京展览馆地区" , "city" : "北京" , "id" : 1765008760 , "location" : "39.945106, 116.353827" , "name" : "如家酒店(北京西直门北京北站店)" , "pic" : "https://m.tuniucdn.com/fb3/s1/2n9c/4CLwbCE9346jYn7nFsJTQXuBExTJ_w200_h200_c1_t0.jpg" , "price" : 356 , "score" : 44 , "starName" : "二钻" } , "highlight" : { "name" : [ "<em>如家</em>酒店(北京西直门北京北站店)" ] } , "sort" : [ 6.376497864377032 , 356 ] }
高亮的代码与之前的代码差异较大,包括的请求DSL构建 和结果解析 部分,但结果解析含需要包括高亮结果解析。
查询的DSL,其中除了查询条件,还需要添加高亮条件,同样是与query同级
结果解析,结果除了要解析_source文档,还需要解析高亮结果
代码对应DSL如下:
完整代码如下:
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 37 38 39 @Test void testHighLightMatch () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().query(QueryBuilders.matchQuery("all" , "如家" )) .highlighter(new HighlightBuilder () .field("name" ) .requireFieldMatch(false )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); SearchHits searchHits = response.getHits(); TotalHits total = searchHits.getTotalHits(); System.out.println("共查询到" + total + "条数据" ); SearchHit[] hits = searchHits.getHits(); for (SearchHit hit : hits) { String json = hit.getSourceAsString(); HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); Map<String, HighlightField> highlightFields = hit.getHighlightFields(); if (!CollectionUtils.isEmpty(highlightFields)) { HighlightField highlightField = highlightFields.get("name" ); if (highlightField != null ) { String name = highlightField.getFragments()[0 ].string(); hotelDoc.setName(name); } } System.out.println(hotelDoc); } }
此时查询出的数据,所有的如家都带上了标签, 从而实现了高亮
黑马旅游案例 启动黑马提供的hotel-demo项目,默认端口是8089,访问http://localhost:8089/,就能看到项目页面
酒店搜索和分页 业务分析 首先在搜索框内输入任意关键字,然后进行搜索,可以看到前端发送如下请求,并携带系列参数:
Request URL: http://localhost:8089/hotel/list Request Method: POST
1 2 3 4 5 6 { "key" : "速八" , "page" : 1 , "size" : 5 , "sortBy" : "default" }
说明搜索方法中包含了以上几个请求参数,位于hotelController中的list方法,请求方式为POST
定义一个HotelController,声明查询接口,满足以下要求 :
请求方式:POST
请求路径:/hotel/list
请求参数:JSON对象,包含4个参数
key
:搜索关键字
page
:页码
size
:每页大小
sortBy
:排序,目前暂不实现
返回值:分页查询,需要返回分页结果PageResult,包含两个属性
total
:总条数
List<HotelDoc>
:当页的数据
实现业务的流程如下:
定义实体类,用于接收请求参数的对象和返回响应结果的对象
编写controller,接收页面的请求,调用IHotelService的search方法
定义IHotelService的search方法,编写业务实现,利用RestHighLevelClient实现搜索、分页
定义实体类 根据以上分析需要定义两个实体类,一个是返回结果实体类PageResult,一个是请求参数实体类RequestParams
PageResult
:
1 2 3 4 5 6 @Data @AllArgsConstructor public class PageResult { private long total; private List<HotelDoc> hotels; }
RequestParams
:
1 2 3 4 5 6 7 @Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; }
定义Controller 根据前面的分析,编写对应的controller
1 2 3 4 5 6 7 8 9 10 11 @RestController @RequestMapping("/hotel") public class HotelController { @Autowired private HotelService hotelService; @PostMapping("/list") public PageResult search (@RequestBody RequestParams params) { return hotelService.search(params); } }
对应的Service接口:
1 2 3 public interface IHotelService extends IService <Hotel> { PageResult search (RequestParams params) ; }
此时在这里想要Impl快速实现对应方法,可以在这里使用快捷键Ctrl + Alt + B
即可跳转到对应的实现类
对应的实现类,主要实现在这里编写
1 2 3 4 5 6 7 @Service public class HotelService extends ServiceImpl <HotelMapper, Hotel> implements IHotelService { @Override public PageResult search (RequestParams params) { return null ; } }
实现搜索业务
在以前的练习中,我们都是在测试方法中使用如下方式进行new和close,如下:
1 2 3 4 5 6 7 8 9 10 11 @BeforeEach void setUp () { this .client = new RestHighLevelClient (RestClient.builder( HttpHost.create("http://192.168.186.130:9200" ) )); } @AfterEach void tearDown () throws IOException { this .client.close(); }
但现在若要实现搜索功能,需要将RestHighLevelClient注册到Spring中作为一个Bean,这里将其注册到启动类中即可:
HotelDemoApplication
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @MapperScan("cn.itcast.hotel.mapper") @SpringBootApplication public class HotelDemoApplication { public static void main (String[] args) { SpringApplication.run(HotelDemoApplication.class, args); } @Bean public RestHighLevelClient client () { return new RestHighLevelClient (RestClient.builder( HttpHost.create("http://192.168.186.130:9200" ))); } }
不要忘记虚拟机使用docker启动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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 @Service public class HotelService extends ServiceImpl <HotelMapper, Hotel> implements IHotelService { @Autowired private RestHighLevelClient client; @Override public PageResult search (RequestParams params) { try { SearchRequest request = new SearchRequest ("hotel" ); String key = params.getKey(); if (key == null || "" .equals(key)){ request.source().query(QueryBuilders.matchAllQuery()); }else { request.source().query(QueryBuilders.matchQuery("all" , key)); } int page = params.getPage(); int size = params.getSize(); request.source().from((page-1 )*size).size(size); SearchResponse response = client.search(request, RequestOptions.DEFAULT); return handleResponse(response); } catch (IOException e) { throw new RuntimeException (e); } } private PageResult handleResponse (SearchResponse response) { SearchHits searchHits = response.getHits(); long total = searchHits.getTotalHits().value; SearchHit[] hits = searchHits.getHits(); ArrayList<HotelDoc> hotels = new ArrayList <>(); for (SearchHit hit : hits) { String json = hit.getSourceAsString(); HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); hotels.add(hotelDoc); } return new PageResult (total, hotels); } }
client.search()方法可能会抛出异常,所以这里需要使用try-catch进行包裹,选取代码后使用快捷键:Ctrl + Alt + T
即可
此时再去浏览器搜索如家,就会出现对应关键字的酒店,并且分页显示正常
酒店结果过滤 业务分析 在搜索框下面,有一些过滤项,如下:
然后勾选对应的内容,再进行搜索,可以看到请求参数如下:
1 2 3 4 5 6 7 8 9 10 11 { "key" : "如家" , "page" : 1 , "size" : 5 , "sortBy" : "default" , "city" : "北京" , "brand" : "如家" , "starName" : "二钻" , "minPrice" : 300 , "maxPrice" : 600 }
所以目前的步骤 如下:
修改RequestParams,接收上述参数city、brand、starName、minPrice、maxPrice
修改业务逻辑search方法,在搜索关键字时,如果存在以上参数,对其做过滤
修改实体类 1 2 3 4 5 6 7 8 9 10 11 12 13 @Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; private String brand; private String city; private String starName; private Integer maxPrice; private Integer minPrice; }
修改业务逻辑 这里首先分析这些过滤条件的类型:
city:精确匹配 term
brand:精确匹配 term
starName:精确匹配 term
price:范围过滤 range
注意事项:
多个条件之间是AND关系,组合多条件用BooleanQuery
参数存在才需要过滤,做好非空判断
涉及到了复合查询,所以就需要用到布尔查询
关键字放到must中,参与算分
其余过滤条件放到filter中,不参与算分
代码实现:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public PageResult search (RequestParams params) { try { SearchRequest request = new SearchRequest ("hotel" ); buildBasicQuery(params, request); int page = params.getPage(); int size = params.getSize(); request.source().from((page-1 )*size).size(size); SearchResponse response = client.search(request, RequestOptions.DEFAULT); return handleResponse(response); } catch (IOException 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.getBrand() != null && !params.getBrand().equals("" )) { boolQuery.filter(QueryBuilders.termsQuery("brand" , params.getBrand())); } if (params.getCity() != null && !params.getCity().equals("" )) { boolQuery.filter(QueryBuilders.termsQuery("city" , params.getCity())); } if (params.getStarName() != null && !params.getStarName().equals("" )) { boolQuery.filter(QueryBuilders.termsQuery("starName" , params.getStarName())); } if (params.getMaxPrice() != null && params.getMinPrice() != null ) { boolQuery.filter(QueryBuilders .rangeQuery("price" ) .gt(params.getMinPrice()) .lt(params.getMaxPrice())); } request.source().query(boolQuery); }
因为过滤条件过多,全放在search中观赏有些不雅,所以这里将其抽取为buildBasicQuery方法,选中需要抽取的部分,使用快捷键shift + Alt + M
即可完成抽取
此时选择过滤条件,页面显式的酒店就会自动过滤,此时再搜索关键字,就可以看到搜索结果满足过滤条件:
我周边的酒店 需求分析 在酒店列表页的右侧,有一个地图,点击地图定位按钮,前端会发起查询请求,将你的坐标发送给后台:
1 2 3 4 5 6 7 8 9 { "key" : "如家" , "page" : 1 , "size" : 5 , "sortBy" : "default" , "city" : "北京" , "brand" : "如家" , "location" : "39.963323, 116.355838" }
所以我们需要根据这个坐标,将酒店结果按照这个点的距离升序排序
实现思路:
在RequestParams类中添加一个新字段,接收location坐标
然后修改搜索逻辑,如果location有值,则添加根据geo_distance排序的功能
修改实体类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; private String brand; private String city; private String starName; private Integer maxPrice; private Integer minPrice; private String location; }
距离排序API 1 2 3 4 request.source().sort(SortBuilders .geoDistanceSort("location" ,new GeoPoint ("39.9, 116.3" )) .order(SortOrder.ASC) .unit(DistanceUnit.KILOMETERS));
添加距离排序 在search方法中添加距离排序,按照上述的api
1 2 3 4 5 6 7 8 String location = params.getLocation();if (location != null && !location.equals("" )) { request.source().sort(SortBuilders .geoDistanceSort("location" , new GeoPoint (location)) .order(SortOrder.ASC) .unit(DistanceUnit.KILOMETERS)); }
虽然实现了距离排序,但是距离我们有多远并没有展示,不直观,所以要进行距离排序显示
距离排序显示 其实使用es进行排序后,就会生成对应的sort字段表示和目标点的距离 ,可以看出这个字段是与source同级的,所以在解析结果的时候,还需要获取sort部分,然后放到响应结果中
代码实现:
修改HotelDoc类,添加排序距离字段,用于页面显示
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 @Data @NoArgsConstructor public class HotelDoc { private Long id; private String name; private String address; private Integer price; private Integer score; private String brand; private String city; private String starName; private String business; private String location; private String pic; + // 排序时的距离值 + private Object distance; public HotelDoc(Hotel hotel) { this.id = hotel.getId(); this.name = hotel.getName(); this.address = hotel.getAddress(); this.price = hotel.getPrice(); this.score = hotel.getScore(); this.brand = hotel.getBrand(); this.city = hotel.getCity(); this.starName = hotel.getStarName(); this.business = hotel.getBusiness(); this.location = hotel.getLatitude() + ", " + hotel.getLongitude(); this.pic = hotel.getPic(); } }
修改结果解析方法handleResponse,为HotelDoc对象赋sort值
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 private PageResult handleResponse(SearchResponse response) { // 获取总条数 SearchHits searchHits = response.getHits(); long total = searchHits.getTotalHits().value; // 获取文档数组 SearchHit[] hits = searchHits.getHits(); // 遍历 ArrayList<HotelDoc> hotels = new ArrayList<>(); for (SearchHit hit : hits) { // 获取每条文档 String json = hit.getSourceAsString(); // 反序列化为对象 HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); + // 获取排序值 + Object[] sortValues = hit.getSortValues(); + if (sortValues.length > 0){ + hotelDoc.setDistance(sortValues[0]); + } hotels.add(hotelDoc); // 放入集合 hotels.add(hotelDoc); } // 封装返回 return new PageResult(total, hotels); }
可以看到此时的输出结果显示了排序距离:
酒店竞价排名 其实就是让指定的酒店在搜索结果中排名置顶(一个超级大的算分)。之前学的算分函数function_score可以给带标记的文档增加权重,从而使得广告的排名靠前。
function_score包含3个要素
过滤条件:哪些文档要加分
算分函数:如何计算function score
加权方式:function score
和query score
如何运算
实现步骤分析:
给HotelDoc类添加isAD字段,boolean类型
修改文档,随便挑几个酒店添加isAD字段为true
修改search方法,添加function score功能,给isAD为true的酒店加权重
修改HotelDoc 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 @Data @NoArgsConstructor public class HotelDoc { private Long id; private String name; private String address; private Integer price; private Integer score; private String brand; private String city; private String starName; private String business; private String location; private String pic; private Object distance; private Boolean isAD; public HotelDoc (Hotel hotel) { this .id = hotel.getId(); this .name = hotel.getName(); this .address = hotel.getAddress(); this .price = hotel.getPrice(); this .score = hotel.getScore(); this .brand = hotel.getBrand(); this .city = hotel.getCity(); this .starName = hotel.getStarName(); this .business = hotel.getBusiness(); this .location = hotel.getLatitude() + ", " + hotel.getLongitude(); this .pic = hotel.getPic(); } }
添加isAD字段 然后随意挑选几个加上isAD字段,这里在网页端kibana上进行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 POST /hotel/_update/2056126831 { "doc" : { "isAD" : true } } POST /hotel/_update/1989806195 { "doc" : { "isAD" : true } } POST /hotel/_update/2056105938 { "doc" : { "isAD" : true } }
输出如下即成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "_index" : "hotel" , "_type" : "_doc" , "_id" : "2056105938" , "_version" : 2 , "result" : "updated" , "_shards" : { "total" : 2 , "successful" : 1 , "failed" : 0 } , "_seq_no" : 203 , "_primary_term" : 3 }
增加算分函数 分析 首先回顾算分函数的DSL写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 GET /hotel/_search { "query" : { "function_score" : { "query" : { "match" : { "name" : "外滩" } } , "functions" : [ { "filter" : { "term" : { "brand" : "如家" } } , "weight" : 5 } ] } } }
对应的java代码如下:
1 2 3 4 5 6 7 FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery( QueryBuilders.matchQuery("name" , "外滩" ), new FunctionScoreQueryBuilder .FilterFunctionBuilder[]{ new FunctionScoreQueryBuilder .FilterFunctionBuilder( QueryBuilders.termQuery("brand" , "如家" ), ScoreFunctionBuilders.weightFactorFunction(10 ))});
二者的对应关系:
代码实现 这里将之前写布尔查询的boolQuery作为原始查询条件,放到function_score查询中,在此基础上添加过滤条件、算分函数、加权模式即可,所以主要修改的就是buildBasicQuery
函数中的内容
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 37 38 39 40 41 42 43 44 45 46 47 48 49 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.getBrand() != null && !params.getBrand().equals("" )) { boolQuery.filter(QueryBuilders.termsQuery("brand" , params.getBrand())); } if (params.getCity() != null && !params.getCity().equals("" )) { boolQuery.filter(QueryBuilders.termsQuery("city" , params.getCity())); } if (params.getStarName() != null && !params.getStarName().equals("" )) { boolQuery.filter(QueryBuilders.termsQuery("starName" , params.getStarName())); } if (params.getMaxPrice() != null && params.getMinPrice() != null ) { boolQuery.filter(QueryBuilders .rangeQuery("price" ) .gt(params.getMinPrice()) .lt(params.getMaxPrice())); } FunctionScoreQueryBuilder functionedScoreQuery = QueryBuilders.functionScoreQuery( boolQuery, new FunctionScoreQueryBuilder .FilterFunctionBuilder[]{ new FunctionScoreQueryBuilder .FilterFunctionBuilder( QueryBuilders.termsQuery("isAD" , true ), ScoreFunctionBuilders.weightFactorFunction(10 ) ) }); request.source().query(functionedScoreQuery); }
此时在网页中查看,可以看到带有广告的酒店就会靠前
数据聚合 聚合的种类 聚合(aggregations)可以实现对文档数据的统计、分析和运算 。常见的聚合有三类:
桶(Bucket)聚合:用来对文档分组
TermAggregation:按照文档字段值 分组,例如:按照品牌
、国家
分组
DateHistogram:按照日期阶梯 分组,例如:一周
为一组,或者一月
为一组
度量(Metric)聚合:用于计算一些值,例如:最大值、最小值、平均值等
Avg:求平均值
Max:求最大值
Min:求最小值
Stats:同时求max、min、avg、sum等
管道(pipeline)聚合:以其他聚合的结果为基础做聚合
注意:参加聚合的字段一定是不分词的 ,必须是keyword、日期、数值、布尔类型,不能是text类的
DSL实现聚合 Bucket聚合 现在,我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。此时可以根据酒店品牌的名称做聚合,类型是term类型,DSL示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 GET /hotel/_search { "size" : 0 , "aggs" : { "brandAgg" : { "terms" : { "field" : "brand" , "size" : 20 } } } }
示例:
聚合结果排序 默认情况下,Bucket聚合会统计Bucket内的文档数量,记为count,并且按照count降序排序。我们可以指定order属性,自定义聚合的排序方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 GET /hotel/_search { "size" : 0 , "aggs" : { "bucketAgg" : { "terms" : { "field" : "brand" , "order" : { "_count" : "asc" } , "size" : 10 } } } }
此时就是升序排列:
限定聚合范围 默认情况下,Bucket聚合是对索引库的所有文档做聚合 。我们可以限定要聚合的文档范围 ,只要添加query条件即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 GET /hotel/_search { "query" : { "range" : { "price" : { "lte" : 200 } } } , "size" : 0 , "aggs" : { "bucketAgg" : { "terms" : { "field" : "brand" , "size" : 20 } } } }
可以看出,聚合aggs与query同级
Metric聚合 我们要求获取每个品牌的用户评分的min、max、avg等值。此时的语法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 GET /hotel/_search { "size" : 0 , "aggs" : { "brandtAgg" : { "terms" : { "field" : "brand" , "size" : 10 } , "aggs" : { "scoreAgg" : { "stats" : { "field" : "score" } } } } } }
此外,我们还可以给聚合结果做排序 ,例如按照每个桶的酒店平均分做排序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 GET /hotel/_search { "size" : 0 , "aggs" : { "brandtAgg" : { "terms" : { "field" : "brand" , "order" : { "scoreAgg.avg" : "desc" } , "size" : 10 } , "aggs" : { "scoreAgg" : { "stats" : { "field" : "score" } } } } } }
RestAPI实现聚合 API语法 这里以品牌聚合为例,演示Java的RestClient使用,对照关系如下:
示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Test void testAggregation () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().size(0 ); request.source().aggregation(AggregationBuilders .terms("brandAgg" ) .field("brand" ) .size(10 )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); System.out.println(response); }
此时输出:
1 {"took":2,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":201,"relation":"eq"},"max_score":null,"hits":[]},"aggregations":{"sterms#brandAgg":{"doc_count_error_upper_bound":0,"sum_other_doc_count":39,"buckets":[{"key":"7天酒店","doc_count":30},{"key":"如家","doc_count":30},{"key":"皇冠假日","doc_count":17},{"key":"速8","doc_count":15},{"key":"万怡","doc_count":13},{"key":"华美达","doc_count":13},{"key":"和颐","doc_count":12},{"key":"万豪","doc_count":11},{"key":"喜来登","doc_count":11},{"key":"希尔顿","doc_count":10}]}}}
可以看到返回的结果是json风格,所以还需要对响应进行解析处理。这里给出示例对应关系:
此时完整的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Test void testAggregation () throws IOException { SearchRequest request = new SearchRequest ("hotel" ); request.source().size(0 ); request.source().aggregation(AggregationBuilders .terms("brandAgg" ) .field("brand" ) .size(10 )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); Aggregations aggregations = response.getAggregations(); Terms brandTerms = aggregations.get("brandAgg" ); List<? extends Terms .Bucket> buckets = brandTerms.getBuckets(); for (Terms.Bucket bucket : buckets) { String keyAsString = bucket.getKeyAsString(); System.out.println(keyAsString); } }
对应输出:
1 2 3 4 5 6 7 8 9 10 7天酒店 如家 皇冠假日 速8 万怡 华美达 和颐 万豪 喜来登 希尔顿
业务需求 需求 :搜索页面的品牌、城市等信息,并不是在页面上直接写死的,而是通过聚合索引库中的酒店数据来动态获得的
分析:
目前页面展示的如上,左侧是聚合条件,右侧是聚合的值。这些值是来源于数据库中的聚合结果,所以是动态的,不能随便设定,不能说设置一个老挝而没有对应的数据
此外,还需要根据搜索框的限定条件进行动态变化。比如我输入一个天安门,城市的结果便只能是北京,而不显示其他城市
那么如何实现上述功能?这就用到前面的聚合功能,利用Bucket聚合,对搜索结果中的文档,基于品牌分组、城市分组、星级分组等,就能得知包含哪些品牌、哪些城市了。并且是对搜索结果的聚合 ,因此是限定范围的聚合
根据上述分析,我们可以得知对应方法需要返回一个Map,其key为聚合条件,其value是一个list,包含对应的聚合结果,示例如下:
具体实现 IHotelService
中新增对应方法
1 2 3 4 5 public interface IHotelService extends IService <Hotel> { PageResult search (RequestParams params) ; Map<String, List<String>> filters () ; }
对应的实现:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 @Override public Map<String, List<String>> filters () { try { SearchRequest request = new SearchRequest ("hotel" ); request.source().size(0 ); 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 )); SearchResponse response = client.search(request, RequestOptions.DEFAULT); Map<String, List<String>> result = new HashMap <>(); Aggregations aggregations = response.getAggregations(); Terms brandTerms = aggregations.get("brandAgg" ); List<? extends Terms .Bucket> brandBuckets = brandTerms.getBuckets(); List<String> brandList = new ArrayList <>(); for (Terms.Bucket bucket : brandBuckets) { String keyAsString = bucket.getKeyAsString(); brandList.add(keyAsString); } result.put("brand" , brandList); Terms cityTerms = aggregations.get("cityAgg" ); List<? extends Terms .Bucket> cityBuckets = cityTerms.getBuckets(); List<String> cityList = new ArrayList <>(); for (Terms.Bucket bucket : cityBuckets) { String keyAsString = bucket.getKeyAsString(); cityList.add(keyAsString); } result.put("city" , cityList); Terms starTerms = aggregations.get("starAgg" ); List<? extends Terms .Bucket> starBuckets = starTerms.getBuckets(); List<String> starList = new ArrayList <>(); for (Terms.Bucket bucket : starBuckets) { String keyAsString = bucket.getKeyAsString(); starList.add(keyAsString); } result.put("starName" , starList); return result; } catch (IOException e) { throw new RuntimeException (e); } }
很明显这样的代码过于冗余,这里进行两部分的抽取:一个是DSL部分,可以抽取成一个方法;另一个是解析部分,这里可以抽取为公共方法,传入对应聚合名称即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private static void buildAggregation (SearchRequest request) { request.source().size(0 ); 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 )); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private static List<String> getAggByName (Aggregations aggregations, String aggName) { Terms brandTerms = aggregations.get(aggName); List<? extends Terms .Bucket> brandBuckets = brandTerms.getBuckets(); List<String> brandList = new ArrayList <>(); for (Terms.Bucket bucket : brandBuckets) { String keyAsString = bucket.getKeyAsString(); brandList.add(keyAsString); } return brandList; }
此时代码如下,简洁了很多
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 @Override public Map<String, List<String>> filters () { try { SearchRequest request = new SearchRequest ("hotel" ); buildAggregation(request); SearchResponse response = client.search(request, RequestOptions.DEFAULT); Map<String, List<String>> result = new HashMap <>(); Aggregations aggregations = response.getAggregations(); List<String> brandList = getAggByName(aggregations,"brandAgg" ); result.put("brand" , brandList); List<String> cityList = getAggByName(aggregations,"cityAgg" ); result.put("city" , cityList); List<String> starList = getAggByName(aggregations,"starAgg" ); result.put("starName" , starList); return result; } catch (IOException e) { throw new RuntimeException (e); } }
此时进行测试:注入service
1 2 3 4 5 6 7 8 9 10 11 12 @SpringBootTest class HotelDemoApplicationTests { @Autowired private IHotelService hotelService; @Test void contextLoads () { Map<String, List<String>> filters = hotelService.filters(); System.out.println(filters); } }
测试结果:
1 2 3 4 5 { starName=[二钻, 五钻, 四钻, 五星级, 三钻, 四星级], city=[上海, 北京, 深圳], brand=[7天酒店, 如家, 皇冠假日, 速8, 万怡, 华美达, 和颐, 万豪, 喜来登, 希尔顿, 汉庭, 凯悦, 维也纳, 豪生, 君悦, 万丽, 丽笙] }
对接前端接口 此时我们输入关键字进行搜索,可以看到前端发送了两个请求,一个是list
对应前面的搜索,另一个是filter
,这就是对应聚合过滤,查看其具体请求:
1 2 3 Request URL: http://localhost:8089/hotel/filters Request Method: POST Request Params: {key: "天安门", page: 1, size: 5, sortBy: "default"}
可以看出filter的请求参数和搜索的一致。前面说到,当我们搜索框中输入对应关键字时,会影响聚合的结果,这个功能如何实现呢?其实就是将搜索的结果拿来聚合,而非直接从数据库中聚合。所以这里就需要传入与搜索方法一致的参数.
所以需要:
编写controller接口,接收该请求
修改IHotelService的filter方法,添加param参数
修改filter的业务逻辑,聚合时添加query条件
Controller方法:
1 2 3 4 @PostMapping("/filters") public Map<String, List<String>> filter (@RequestBody RequestParams params) { return hotelService.filters(params); }
那么此时的filter代码实现就会有所变化:(多了个搜索部分)
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 @Override public Map<String, List<String>> filters (RequestParams params) { try { SearchRequest request = new SearchRequest ("hotel" ); buildBasicQuery(params, request); buildAggregation(request); SearchResponse response = client.search(request, RequestOptions.DEFAULT); Map<String, List<String>> result = new HashMap <>(); Aggregations aggregations = response.getAggregations(); List<String> brandList = getAggByName(aggregations,"brandAgg" ); result.put("brand" , brandList); List<String> cityList = getAggByName(aggregations,"cityAgg" ); result.put("city" , cityList); List<String> starList = getAggByName(aggregations,"starAgg" ); result.put("starName" , starList); return result; } catch (IOException e) { throw new RuntimeException (e); } }
此时启动服务,当选择了1500价位后,发现其他聚合的结果变少了,因而实现了动态聚合:
自动补全 数据同步 集群