视频学习:黑马程序员Java微服务

网盘资源:https://pan.baidu.com/s/1LxIxcHDO7SYB96SE-GZfuQ 提取码:dor4

学习路线及部分内容参考:Kyle’s Blog

初识ElasticSearch

了解es

ElasticSearch是一款非常强大的开源的分布式搜索引擎,具备非常强大的功能,可以帮助我们从海量数据中快速找到需要的内容

  • 例如在网页中搜索PS5,搜索到的内容会以红色标识,也可以实现搜索时的自动补全功能

    image-20230816092825869

ELK技术栈

ElasticSearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域

Kibana是一个开源的分析与可视化平台,用于搜索、查看存放在Elasticsearch中的数据。Kibana与Elasticsearch的交互方式是各种不同的图表、表格、地图等,直观的展示数据,从而达到高级的数据分析与可视化的目的。

image-20230816092713760

ElasticSearch是elastic stack的核心,负责存储、搜索、分析数据

image-20230816092956529

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采用倒排索引,有以下概念:

  1. 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。
  2. 词条(Term):文档按照语义分成的词语

image-20230816094311833

以搜索华为手机为例

  1. 用户输入条件华为手机,进行搜索。
  2. 对用户输入的内容分词,得到词条:华为、手机。
  3. 拿着词条在倒排索引中查找,得到每个词条所在文档id,进而可以得到包含词条的文档id为:1、2、3。
  4. 拿着文档id到正向索引中查找具体文档

image-20230816094655033

二者区别

区别

  • 正向索引是根据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):索引中文档的字段约束信息(用来定义表的结构、字段的名称、类型等信息),类似表的结构约束

image-20230816095605109

概念对比

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实现
  • 二者再基于某种方式,实现数据的同步,保证一致性

image-20230816100233688

安装es、kibana

部署单点es

因为我们还需要部署Kibana容器,因此需要让es和kibana容器互联,这里先创建一个网络,让他们都加入这个网络

使用compose部署可以一键互联,不需要这个步骤,但是将来有可能不需要kbiana,只需要es,所以先这里手动部署单点es

1
docker network create es-net
  • 这里es-net是自己命令的网络名称

拉取镜像,这里采用的是ElasticSearch的7.12.1版本镜像

1
docker pull elasticsearch:7.12.1
  • 但是由于这个包体积比较大,不建议自己pull,可以从资料中获取,上传到虚拟机上,然后运行命令加载即可:

    1
    docker load -i es.tar

    同理还有kibana的tar包也需要这样做

  • 此时查看镜像,可以看到正常加载

    1
    2
    3
    4
    [root@localhost ~]# docker images
    REPOSITORY TAG IMAGE ID CREATED SIZE
    kibana 7.12.1 cf1c9961eeb6 2 years ago 1.06GB
    elasticsearch 7.12.1 41dc8ea0f139 2 years ago 851MB

运行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 ~]# docker ps
    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/ ,即可以看到结果

image-20230816102709535

DevTools

kibana中提供了一个DevTools界面,在这个界面中我们可以编写DSL来操作ElasticSearch,并且有对DSL语句的自动补全功能

image-20230816104820163

分词器

测试

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 # 注意这里es对应前面对容器起的名字

    # 在线下载并安装
    ./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容器的插件数据卷中,也就是上述目录:

      image-20230816112550544

    • 重启容器,并查看日志

      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

1
2
3
白嫖
奥里给
厄斐琉斯

stopword.dic

1
大大怪

然后重启es:docker restart es


DSL索引库操作

DSL:Domain Specific Language

mapping属性

mapping是对索引库中文档的约束,常见的mapping属性包括

  • type:字段数据类型,常见的简单类型有:
    1. 字符串:text(可分词文本)、keyword(精确值,例如:品牌、国家、ip地址;因为这些词,分词之后毫无意义)
    2. 数值:long、integer、short、byte、double、float
    3. 布尔:boolean
    4. 日期:date
    5. 对象: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
    GET /{索引库名}
  • 示例:

    1
    GET /test

删除索引库

  • 格式

    1
    DELETE /{索引库名}
  • 示例:

    1
    DELETE /test

修改索引库

索引库一旦构建,就会根据索引库创建倒排索引。如果对索引库进行修改,就会导致原有的倒排索引失效。因此索引库一旦创建,就禁止修改原有字段

但是可以添加新的字段,语法如下:

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
    GET /索引库名/_doc/id
  • 示例:

    1
    GET /yskm/_doc/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" : "温"
    }
    }
    }

删除文档:

  • 语法:

    1
    DELETE /索引库名/_doc/id
  • 示例:

    1
    DELETE /yskm/_doc/1

修改文档

修改有两种方式

  1. 全量修改:直接覆盖原来的文档
  2. 增量修改:修改文档中的部分字段

全量修改

全量修改是覆盖原来的文档,其本质是:根据指定的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,需要参与搜索

  • latitudelongitude:地理坐标在ES中比较特殊,ES中支持两种地理坐标数据类型:

    1. geo_point:由纬度(latitude)和经度( longitude)确定的一个点。例如:”32.8752345,120.2981576”
    2. 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

步骤:

  1. 引入ES的RestHighLevelClient的依赖

    1
    2
    3
    4
    <dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
    </dependency>
  2. 因为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>
  3. 初始化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 {
//1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
//2.请求参数,MAPPING_TEMPLATE是静态常量字符串,内容是创建索引库的DSL语句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
//3.发起请求
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 {
//1.创建Request对象
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
//2.发起请求
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()方法来获取索引库的操作对象

索引库操作基本步骤:

  1. 初始化RestHighLevelClient
  2. 创建XxxIndexRequest。Xxx是Create、Get、Delete
  3. 准备DSL(Create时需要,其它是无参)
  4. 发送请求,调用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);
}

主要步骤如下:

  1. 创建Request对象
  2. 准备请求参数,即DSL中的JSON文档
  3. 发送请求。这里相比新增索引,区别在于直接使用client.xxx()的API,而新增索引则需要client.indices().xxx()

整体流程

代码整体步骤如下:

  1. 根据id查询酒店数据Hotel
  2. 将Hotel封装为HotelDoc
  3. 将HotelDoc序列化为Json
  4. 创建IndexRequest,指定索引库名和id
  5. 准备请求参数,也就是Json文档
  6. 发送请求

这里编写测试方法进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void testAddDocument() throws IOException {
// 1. 根据id查询酒店数据
Hotel hotel = hotelService.getById(61083L);
// 2. 转换为文档类型
HotelDoc hotelDoc = new HotelDoc(hotel);
// 3. 转换为Json字符串
String jsonString = JSON.toJSONString(hotelDoc);
// 4. 准备request对象
IndexRequest request = new IndexRequest();
// 5. 准备json文档
request.source(jsonString, XContentType.JSON);
// 6. 发送请求
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语句如下

1
GET /索引库名/_doc/id

因为这里查询没有请求参数,所以流程比较简单。但是查询的目的是为了得到HotelDoc,在刚刚查询的结果中,我们发现HotelDoc对象的主要内容在_source属性中,所以要获取这部分内容,然后将其转化为HotelDoc

1
2
3
4
5
6
7
8
9
10
11
@Test
void testGetDocumentById() throws IOException {
// 1. 准备request对象
GetRequest request = new GetRequest("hotel").id("61083");
// 2. 发送请求,得到结果
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3. 解析结果
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 {
// 1. 准备request对象
UpdateRequest request = new UpdateRequest("hotel","61083");
// 2. 准备参数
request.doc(
"city","北京",
"price",1888);
// 3. 发送请求
client.update(request,RequestOptions.DEFAULT);
}

删除文档

删除的DSL语句如下

1
DELETE /hotel/_doc/{id}

与查询相比,仅仅是请求方式由DELETE变为GET,对应的代码如下:

1
2
3
4
5
6
7
@Test
void testDeleteDocumentById() throws IOException {
// 1. 准备request对象
DeleteRequest request = new DeleteRequest("hotel","61083");
// 2. 发送请求
client.delete(request,RequestOptions.DEFAULT);
}

批量导入文档

在前面的测试中,我们都是一条一条新增文档,但实际应用中,需要批量的将数据库数据导入索引库中

需求:批量查询酒店数据,然后批量导入索引库中

思路步骤:

  1. 利用mybatis-plus查询酒店数据

  2. 将查询到的酒店数据(Hotel)转化为文档类型数据(HotelDoc)

  3. 利用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等类型字段。例如
    • ids
    • range
    • term
  • 地理查询(geo):根据经纬度查询。例如
    • geo_distance
    • geo_bounding_box
  • 复合查询(compound):复合查询可以将上述各种查询条件组合起来,合并查询条件。例如
    • bool
    • function_score

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" : [ ]
}
}

其他查询语法大体类似,区别主要就是查询类型和查询条件不同

全文检索

全文搜索查询,常用于搜索框搜索。查询流程基本如下:

  1. 根据用户搜索的内容做分词,得到词条
  2. 根据词条去倒排索引库中匹配,得到文档id
  3. 根据文档id找到的文档,返回给用户

语法

常见的全文检索包括

  • match查询:单字段查询

    • 对应的语法:

      1
      2
      3
      4
      5
      6
      7
      8
      GET /indexName/_search
      {
      "query": {
      "match": {
      "FIELD": "TEXT"
      }
      }
      }
  • multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件

    • 对应的语法:

      1
      2
      3
      4
      5
      6
      7
      8
      GET /indexName/_search
      {
      "query": {
      "multi_match": {
      "fields": ["FIELD1", "FIELD2"]
      }
      }
      }

使用示例

以match搜索:

这里以match为例,搜索”外滩”。注意这里的all字段,这是我们之前定义的拷贝字段,我们将需要进行检索的字段(namecitybusiness)拷贝到这个字段中,便于统一地检索

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"]
}
}
}

这个搜索地内容与上面一致。这是因为我们前面将namecitybusiness的值都利用copy_to复制到了all字段中,因此根据这三个字段搜索和根据all字段搜索的结果当然一样了。随着搜索的字段的增多,对查询性能影响就越大,因此建议采用copy_to,然后使用单字段match查询的方式

精确查询

精确查询一般是查找keyword、数值、日期、boolean等类型字段。所以不会对搜索条件分词。常见的有

  • term:根据词条精确值查询
  • range:根据值的范围查询

image-20230823220333332

以上图为例,根据词条精确值查询有:城市、星级、品牌;根据值的范围查询包括:价格、日期等

语法

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, //这里的gte表示大于等于,gt表示大于
"lte": 20 //这里的let表示小于等于,lt表示小于
}
}
}
}

示例:查询酒店价格在1000~3000的酒店

1
2
3
4
5
6
7
8
9
10
11
GET /hotel/_search
{
"query": {
"range": {
"price": {
"gte": 1000,
"lte": 3000
}
}
}
}

小结

精确查询常见的有哪些?

  1. term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
  2. range查询:根据数值范围查询,可以使数值、日期的范围

地理查询

根据经纬度查询,官方文档。常见的使用场景包括:

  1. 携程:搜索附近的酒店
  2. 滴滴:搜索附近的出租车
  3. 微信:搜索附近的人

矩形范围查询

矩形范围查询,也就是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, // lat: latitude 纬度
"lon": 121.5 // lon: longitude 经度
},
"bottom_right": { // 右下点
"lat": 30.9, // lat: latitude 纬度
"lon": 121.7 // lon: longitude 经度
}
}
}
}
}

示例

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)查询:复合查询可以将其他简单查询组合起来,实现更复杂的搜索逻辑,例如:

  1. function score:算分函数查询,可以控制文档相关性算分,控制文档排名。(例如搜索引擎的排名,第一大部分都是广告)
  2. 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则会让单个词条的算分有一个上限,曲线更平滑。

image-20230823222314840

算分函数查询

使用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),有四种函数

    • weight:函数结果是常量

    • field_value_factor:以文档中的某个字段值作为函数结果

    • random_score:以随机数作为函数结果

    • script_score:自定义算分函数算法

  • 运算模式(boost_mode):算分函数的结果、原始查询的相关性算分,二者之间的运算方式,包括

    • multiply:相乘

    • replace:用function score替换query score

    • 其他,例如:sum、avg、max、min

使用案例:

需求:给如家这个品牌的酒店排名靠前一点
思路:过滤条件为"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,并且搜索结果明显提前

布尔查询

布尔查询是一个或多个子查询的组合,每一个子句就是一个子查询。子查询的组合方式有

  1. must:必须匹配每个子查询,类似
  2. should:选择性匹配子查询,类似
  3. must_not:必须不匹配,不参与算分,类似
  4. filter:必须匹配,不参与算分

例如在搜索酒店时,除了关键字搜索外,我们还可能根据酒店品牌、价格、城市等字段做过滤。每一个不同的字段,其查询条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就需要用到布尔查询了

image-20230823224101981

搜索时,参与打分的字段越多,查询的性能就越差,所以在多条件查询时:搜索框的关键字搜索,是全文检索查询,使用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, //分页开始的位置,默认是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是单点模式,这样做就可以了。

image-20230830203308816

但是实际场景中ES一定是集群部署的,如下图所示,集群包含四个节点,此时如果查询前1000不是从每个节点获取前250就好了的,因为可能一个节点的前250对于另一个节点来说就排在1000开外了(这个很好理解,就跟班级中的好学生一样,不同班级的学生亦有差距)。

因此想获取整个集群的TOP1000,就必须先查询出每个节点的TOP1000,汇总结果后,重新排序,重新截取TOP1000

image-20230830203811046

但这样就又面临了一个新问题:汇总数据过多时,岂不是每个节点都要拿出大量结果,就会对内存和CPU产生非常大的压力。因此ES会禁止form + size > 10000的请求

针对深度分页问题,ES提供而两种解决方案,官方文档:https://www.elastic.co/guide/en/reference/current/paginate-search-results.html

  1. search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式
  2. scrool:原理是将排序后的文档id形成快照,保存在内存。官方已经不推荐使用

小结

分页查询的常见实现方案以及优缺点

  • from + size:
    • 优点:支持随机翻页
    • 缺点:深度分页问题,默认查询上限是10000(from + size)
    • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索(百度现在支持翻页到75页,然后显示提示:限于网页篇幅,部分结果未予显示。)
  • after search:
    • 优点:没有查询上限(单词查询的size不超过10000)
    • 缺点:只能向后逐页查询,不支持随机翻页
    • 场景:没有随机翻页需求的搜索,例如手机的向下滚动翻页
  • scroll:
    • 优点:没有查询上限(单词查询的size不超过10000)
    • 缺点:会有额外内存消耗,并且搜索结果是非实时的(快照保存在内存中,不可能每搜索一次都更新一次快照)
    • 场景:海量数据的获取和迁移。从ES7.1开始不推荐,建议使用after search方案

高亮

高亮就是在搜索结果中把搜索关键字突出显示。

image-20230830204836590

高亮显示的实现分为两步:

  1. 给文档中的所有关键字都添加一个标签,例如<strong>标签
  2. 页面给<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 {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数 对应 "query": {"match_all": {}}
request.source().query(QueryBuilders.matchAllQuery());
// 3. 发送请求,得到相应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
System.out.println(response);
}
  1. 创建SearchRequest对象,指定索引库名
  2. 利用request.source()构建DSL,DSL中可以包含查询、分页、排序、高亮等
  3. 利用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 {
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数 对应 "query": {"match_all": {}}
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 hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println(hotelDoc);
}
}

对应关系:

image-20230905213814654

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 {
    // 1. 准备Request对象
    SearchRequest request = new SearchRequest("hotel");
    // 2. 组织DSL参数
    request.source().query(QueryBuilders.matchQuery("all","如家"));
    // 3. 发送请求,得到响应结果
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4. 解析响应
    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 {
    // 1. 准备Request对象
    SearchRequest request = new SearchRequest("hotel");
    // 2. 组织DSL参数
    request.source().query(QueryBuilders.multiMatchQuery("如家","brand","name"));
    // 3. 发送请求,得到响应结果
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4. 解析响应
    handelResponse(response);
    }

精确查询

精确查询常见有:

  1. term:词条精确匹配
  2. 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 {
    // 1. 准备Request对象
    SearchRequest request = new SearchRequest("hotel");
    // 2. 组织DSL参数
    request.source().query(QueryBuilders.termQuery("city","杭州"));
    // 3. 发送请求,得到响应结果
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4. 解析响应
    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 {
    // 1. 准备Request对象
    SearchRequest request = new SearchRequest("hotel");
    // 2. 组织DSL参数
    request.source().query(QueryBuilders.rangeQuery("price").gt(100).lt(150));
    // 3. 发送请求,得到响应结果
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);
    // 4. 解析响应
    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 {
// 1. 准备Request
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL参数
// 2.1 准备BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.2 添加must条件
boolQuery.must(QueryBuilders.termQuery("city", "杭州"));
// 2.3 添加filter条件
boolQuery.filter(QueryBuilders.rangeQuery("price").lt(150));
request.source().query(boolQuery);
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
handelResponse(response);
}

排序、分页

搜索结果的排序和分页是与query同级的参数,因此同样是使用request.source()来设置

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void testSortMatch() throws IOException {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL参数
request.source().query(QueryBuilders.matchAllQuery())
.sort("price", SortOrder.ASC) //价格排序
.from(0).size(5); //分页
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
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 {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL参数
request.source().query(QueryBuilders.matchQuery("all","如家"))
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
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构建结果解析部分,但结果解析含需要包括高亮结果解析。

  1. 查询的DSL,其中除了查询条件,还需要添加高亮条件,同样是与query同级
  2. 结果解析,结果除了要解析_source文档,还需要解析高亮结果

代码对应DSL如下:

image-20230905221300128

完整代码如下:

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 {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL参数
request.source().query(QueryBuilders.matchQuery("all", "如家"))
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);

// 4. 解析响应
SearchHits searchHits = response.getHits();
TotalHits total = searchHits.getTotalHits();
System.out.println("共查询到" + total + "条数据");
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
// 获取source
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/,就能看到项目页面

image-20230907202832210

酒店搜索和分页

业务分析

首先在搜索框内输入任意关键字,然后进行搜索,可以看到前端发送如下请求,并携带系列参数:

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个参数
    1. key:搜索关键字
    2. page:页码
    3. size:每页大小
    4. sortBy:排序,目前暂不实现
  • 返回值:分页查询,需要返回分页结果PageResult,包含两个属性
    1. total:总条数
    2. List<HotelDoc>:当页的数据

实现业务的流程如下:

  1. 定义实体类,用于接收请求参数的对象和返回响应结果的对象
  2. 编写controller,接收页面的请求,调用IHotelService的search方法
  3. 定义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 {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
// 2.1 关键字搜索
String key = params.getKey();
// 健壮性判断
if (key == null || "".equals(key)){
request.source().query(QueryBuilders.matchAllQuery());
}else {
request.source().query(QueryBuilders.matchQuery("all", key));
}
// 2.2 分页
int page = params.getPage();
int size = params.getSize();
request.source().from((page-1)*size).size(size);
// 3. 发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

// 这里是之前封装的一个函数,用于json结果的解析
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即可

此时再去浏览器搜索如家,就会出现对应关键字的酒店,并且分页显示正常

酒店结果过滤

业务分析

在搜索框下面,有一些过滤项,如下:

image-20230907212616663

然后勾选对应的内容,再进行搜索,可以看到请求参数如下:

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
}

所以目前的步骤如下:

  1. 修改RequestParams,接收上述参数city、brand、starName、minPrice、maxPrice
  2. 修改业务逻辑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 {
// 1. 准备Request对象
SearchRequest request = new SearchRequest("hotel");
// 2. 准备DSL
// 2.1 query
buildBasicQuery(params, request);

// 2.2 分页
int page = params.getPage();
int size = params.getSize();
request.source().from((page-1)*size).size(size);
// 3. 发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

private void buildBasicQuery(RequestParams params, SearchRequest request) {
// 构建BooleanQuery
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即可完成抽取

此时选择过滤条件,页面显式的酒店就会自动过滤,此时再搜索关键字,就可以看到搜索结果满足过滤条件:

image-20230907220110914

我周边的酒店

需求分析

在酒店列表页的右侧,有一个地图,点击地图定位按钮,前端会发起查询请求,将你的坐标发送给后台:

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;
// 新增location字段
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
// 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));
}

虽然实现了距离排序,但是距离我们有多远并没有展示,不直观,所以要进行距离排序显示

距离排序显示

其实使用es进行排序后,就会生成对应的sort字段表示和目标点的距离,可以看出这个字段是与source同级的,所以在解析结果的时候,还需要获取sort部分,然后放到响应结果中

image-20230907221623918

代码实现:

修改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);
}

可以看到此时的输出结果显示了排序距离:

image-20230907222313146

酒店竞价排名

其实就是让指定的酒店在搜索结果中排名置顶(一个超级大的算分)。之前学的算分函数function_score可以给带标记的文档增加权重,从而使得广告的排名靠前。

function_score包含3个要素

  1. 过滤条件:哪些文档要加分
  2. 算分函数:如何计算function score
  3. 加权方式:function scorequery 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))});

二者的对应关系:

image-20230910152456984


代码实现

这里将之前写布尔查询的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) {
// 【1】构建BooleanQuery
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()));
}

// 【2】算分查询
FunctionScoreQueryBuilder functionedScoreQuery =
QueryBuilders.functionScoreQuery(
//原始查询
boolQuery,
//function score 数组
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
// 其中的一个function score 元素
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
//过滤条件
QueryBuilders.termsQuery("isAD", true),
//算分函数
ScoreFunctionBuilders.weightFactorFunction(10) // 算分直接乘10
)
});
request.source().query(functionedScoreQuery);
}

此时在网页中查看,可以看到带有广告的酒店就会靠前

image-20230910153358929

数据聚合

聚合的种类

聚合(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, // 设置size为0,结果中不包含文档,只包含聚合结果
"aggs": { // 定义聚合
"brandAgg": { // 给聚合起个名字
"terms": { // 聚合的类型,这里按照品牌值聚合,所以选择terms
"field": "brand", // 参与聚合的字段
"size": 20 // 希望获取的聚合结果数量
}
}
}
}

示例:

image-20230910195423544

聚合结果排序

默认情况下,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
}
}
}
}

此时就是升序排列:

image-20230910195634075

限定聚合范围

默认情况下,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 // 只对200元以下的文档做聚合
}
}
},
"size": 0,
"aggs": {
"bucketAgg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}

可以看出,聚合aggs与query同级

image-20230910195909143

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": { // 是bucketAgg聚合的子聚合,也就是分组后对每组分别进行计算
"scoreAgg": { // 子聚合名称
"stats": { // 聚合类型,stats可以计算min、max、avg等
"field": "score" // 聚合字段,这里计算用户评分的min、max、avg
}
}
}
}
}
}

image-20230910200516326

此外,我们还可以给聚合结果做排序,例如按照每个桶的酒店平均分做排序

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" // 对scoreAgg.avg做降序排序
},
"size": 10
},
"aggs": {
"scoreAgg": {
"stats": {
"field": "score"
}
}
}
}
}
}

image-20230910200708045

RestAPI实现聚合

API语法

这里以品牌聚合为例,演示Java的RestClient使用,对照关系如下:

image-20230911203651393

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void testAggregation() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
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风格,所以还需要对响应进行解析处理。这里给出示例对应关系:

image-20230911204955587

此时完整的代码:

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 {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
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");
//获取Buckets
List<? extends Terms.Bucket> buckets = brandTerms.getBuckets();
//遍历获取key
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
万怡
华美达
和颐
万豪
喜来登
希尔顿

业务需求

需求:搜索页面的品牌、城市等信息,并不是在页面上直接写死的,而是通过聚合索引库中的酒店数据来动态获得的

image-20230911210047108

分析:

  • 目前页面展示的如上,左侧是聚合条件,右侧是聚合的值。这些值是来源于数据库中的聚合结果,所以是动态的,不能随便设定,不能说设置一个老挝而没有对应的数据
  • 此外,还需要根据搜索框的限定条件进行动态变化。比如我输入一个天安门,城市的结果便只能是北京,而不显示其他城市
  • 那么如何实现上述功能?这就用到前面的聚合功能,利用Bucket聚合,对搜索结果中的文档,基于品牌分组、城市分组、星级分组等,就能得知包含哪些品牌、哪些城市了。并且是对搜索结果的聚合,因此是限定范围的聚合

根据上述分析,我们可以得知对应方法需要返回一个Map,其key为聚合条件,其value是一个list,包含对应的聚合结果,示例如下:

image-20230911211641795

具体实现

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 {
//1.准备request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
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));
//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Map<String, List<String>> result = new HashMap<>();
Aggregations aggregations = response.getAggregations();
//4.1 解析品牌
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);

//4.2 解析品牌
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);

//4.3 解析星级
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) {
//2.准备DSL
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
/**
* 通过聚合名称获取对应的key的集合
* @param aggregations 聚合结果集
* @param aggName 聚合名称
* @return
*/
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 {
//1.准备request
SearchRequest request = new SearchRequest("hotel");
buildAggregation(request);
//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Map<String, List<String>> result = new HashMap<>();
Aggregations aggregations = response.getAggregations();
//4.1 解析品牌
List<String> brandList = getAggByName(aggregations,"brandAgg");
result.put("brand", brandList);

//4.2 解析品牌
List<String> cityList = getAggByName(aggregations,"cityAgg");
result.put("city", cityList);

//4.3 解析星级
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 {
//1.准备request
SearchRequest request = new SearchRequest("hotel");
//2.准备DSL
//2.1 查询
buildBasicQuery(params, request); //这个方法我们在搜索中已经实现了
//2.2 聚合
buildAggregation(request);

//3.发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//4.解析结果
Map<String, List<String>> result = new HashMap<>();
Aggregations aggregations = response.getAggregations();
//4.1 解析品牌
List<String> brandList = getAggByName(aggregations,"brandAgg");
result.put("brand", brandList);

//4.2 解析品牌
List<String> cityList = getAggByName(aggregations,"cityAgg");
result.put("city", cityList);

//4.3 解析星级
List<String> starList = getAggByName(aggregations,"starAgg");
result.put("starName", starList);

return result;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

此时启动服务,当选择了1500价位后,发现其他聚合的结果变少了,因而实现了动态聚合:

image-20230911221032151

自动补全

数据同步

集群