预见猿份
主题
首页面试题在线工具关于我们老苗一对一私教学员评价
实战项目
项目前置基础创新WMS项目Java微服务框架与实战云岚到家项目闪聚支付项目学成在线项目青橙电商项目JVM原理与实战调优分布式事务专题Java高频面试题MySQL从入门到精通Java数据结构与算法老苗一对一私教学员评价blog
blog
  • Java微服务框架与实战

    • 内容介绍
    • 项目安装配置
    • day01-微服务服务注册与发现
    • day01-作业参考
    • day02-服务保护、分布式事务
    • day03-微服务网关与配置中心
    • day04-MQ基础
    • day05-MQ高级
    • day05-RabbitMQ集群部署
    • day06-Elasticsearch入门
    • day07-Elasticsearch高级
    • day08-微服务阶段面试篇上
    • day08-配置同步环境
    • day09-微服务阶段面试篇下
    • day09-redis集群
    • day10-微服务阶段面试题







----- 到底线了 -----

×

欢迎来到预见猿份,本站项目均为站长原创,学习中有问题可直接提交给站长老苗解决(微信:mrt_0607)。

苗润土老师,20余年一线项目经验,2014年加入黑马,星辰wms、云岚到家、学成在线项目作者,历任高级讲师、教学主管及课程研究员。 b站老苗

day06 Elasticsearch入门 ​

1 学习目标 ​

  1. 能够说出什么是倒排索引
  2. 能够说出我们用的中文分词器是什么
  3. 能够说出IK分词器如何扩展词典
  4. 能够使用Java Client向索引增删改查文档
  5. 能够使用Java Client向索引批量导入文档
  6. 能够使用Java Client进行Term查询
  7. 能够使用Java Client进行全文检索
  8. 能够使用Java Client实现排序和分页
  9. 能够使用Java Client实现布尔查询

📺 视频链接

2.索引操作 ​

Mapping映射就类似表的结构。我们要向es中存储数据,必须先创建Index和Mapping

2.1 Mapping映射属性 ​

在MySQL中创建表结构时需要指定每个字段的类型,同样,创建索引的映射也需要指定每个字段的类型及其它属性。

常见的Mapping属性包括:

  • type:字段数据类型,常见的简单类型有:

    • 字符串:text(可分词的文本)
    • keyword(精确值,例如:品牌、国家、ip地址),keyword 类型主要用于存储不需要分词处理的字符串,例如电子邮件地址、标签、ID 等,这些字符串通常用于精确匹配搜索。
    • 数值:long、integer、short、byte、double、float、
    • 布尔:boolean
    • 日期:date
    • 对象:object
  • index:是否索引

index为true时可对此字段搜索,并且如果type为text则会对文本内容进行分词

index为false表示不分词也不能搜索。

  • analyzer:添加索引时使用哪种分词器分词
  • properties:该字段的子字段
  • search_analyzer: 搜索时使用哪种分词器分词

通常情况下,我们在搜索和创建索引时使用的是同一分析器,默认情况下搜索将会使用字段映射时定义的分析器,也能通过search_analyzer 设置不同的分词器。

拿ik分词器举例:

通常会设置如下:

JavaScript
    "analyzer": "ik_max_word",
    "search_analyzer":"ik_smart"
1
2

添加索引时分词使用细粒度模式ik_max_word ,因为ik_max_word 会尽可能多地分出词条,这对于分词创建索引是有益的,因为它可以增加索引中的词条数量

搜索时使用智能模式ik_smart 因为ik_smart 分词器会更加注重分词的准确性,减少不必要的词条,以提高搜索的精度。

比如:搜索“中华人民共和国”,如果搜索时采用ik_max_word模式,会将“中华人民共和国”分为中华、人民、华人等词语,此时就会拿这些词去匹配文档,搜索出来的文章可能并不是用户想要的结果,用户想要的结果是“中华人民共和国”相关的文章 。

如果搜索时使用ik_smart模式,此时“中华人民共和国”分词为“中华人民共和国”,此时去搜索出的文档正是用户想要的。

2.2 创建索引 ​

基本语法:

  • 请求方式:PUT
  • 请求路径:/索引名,可以自定义
  • 请求参数:mapping映射

格式:

JSON
PUT /索引名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "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

举例:

例如下面的json文档:

JSON
{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "云",`
        "lastName": "赵"
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

对应的每个字段映射(Mapping):

字段名字段类型类型说明是否
参与搜索
是否
参与分词
分词器
ageinteger整数——
weightfloat浮点数——
isMarriedboolean布尔——
infotext字符串,但需要分词IK
emailkeyword字符串,但是不分词——
scorefloat只看数组中元素类型——
namefirstNamekeyword字符串,但是不分词——
lastNamekeyword字符串,但是不分词——

AI:根据下边文档内容生成创建Elasticsearch映射语句:

JavaScript
elasticsearch的文档如下:
{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

根据生成结果再对照所学知识进行微调。

正确的语句如下:

JavaScript
PUT /heima
{
  "mappings": {
    "properties": {
      "age": {
        "type": "integer"
      },
      "weight": {
        "type": "float"
      },
      "isMarried": {
        "type": "boolean"
      },
      "info": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer":"ik_smart"
      },
      "email": {
        "type": "keyword",
        "index": false // 不对email字段进行索引,既不分词也不搜索
      },
      "score": {
        "type": "float"
      },
      "name": {
        "properties": {
          "firstName": {
            "type": "keyword"
          },
          "lastName": {
            "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
31
32
33
34
35
36
37
38
39

info字段说明:

"analyzer": "ik_max_word":表示索引时用细粒度分词,尽可能多的分多个词条

"search_analyzer":"ik_smart":表示搜索时用智能模式(粗粒度)

执行上边的语句如果报:index [heima/fQsg0fUbTfyUk3L-c6in0w] already exists

说明heima 索引已存在,需要先删除再创建。

执行:DELETE /heima 删除heima索引

创建成功返回下边的结果

Java
{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "heima"
}
1
2
3
4
5
6

注意:如果在虚拟机中创建时报错resource_already_exists_exception,则需要先删除此索引。

执行:DELETE /heima

2.3 查询索引 ​

基本语法:

  • 请求方式:GET
  • 请求路径:/索引名
  • 请求参数:无

格式:

Plain
GET /索引名
1

示例:

Plain
GET /heima
1

2.4 修改索引 ​

倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引一旦创建,无法修改mapping。

虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。因此修改索引能做的就是向索引中添加新字段,或者更新索引的基础属性。

语法说明:

JSON
PUT /索引名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}
1
2
3
4
5
6
7
8

示例:

添加新字段age

JSON
PUT /heima/_mapping
{
  "properties": {
    "age":{
      "type": "integer"
    }
  }
}
1
2
3
4
5
6
7
8

2.5 删除索引 ​

语法:

  • 请求方式:DELETE
  • 请求路径:/索引名
  • 请求参数:无

格式:

Plain
DELETE /索引名
1

示例:

Plain
DELETE /heima
1

2.6 总结 ​

索引操作有哪些?

  • 创建索引:PUT /索引名
  • 查询索引:GET /索引名
  • 删除索引:DELETE /索引名
  • 修改索引,添加字段:PUT /索引名/_mapping

可以看到,对索引的操作基本遵循的Restful的风格,因此API接口非常统一,方便记忆。

3.文档操作 ​

有了索引,接下来就可以向索引中添加数据了。

Elasticsearch中的数据其实就是JSON风格的文档。操作文档自然保护增、删、改、查等几种常见操作,我们分别来学习。

3.1 新增文档 ​

语法:

JSON
POST /索引名/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    "字段3": {
        "子属性1": "值3",
        "子属性2": "值4"
    },
}
1
2
3
4
5
6
7
8
9

示例:

JSON
POST /heima/_doc/1
{
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}
1
2
3
4
5
6
7
8
9

响应:

3.2 查询文档 ​

根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。

语法:

JSON
GET /{索引名称}/_doc/{id}
1

示例:

JavaScript
GET /heima/_doc/1
1

查看结果:

3.3 删除文档 ​

删除使用DELETE请求,同样,需要根据id进行删除:

语法:

JavaScript
DELETE /{索引名}/_doc/id值
1

示例:

JSON
DELETE /heima/_doc/1
1

结果:

3.4 修改文档 ​

修改有两种方式:

  • 全量修改:直接覆盖原来的文档
  • 局部修改:修改文档中的部分字段

3.4.1 全量修改 ​

全量修改是覆盖原来的文档,其本质是两步操作:

  • 根据指定的id删除文档
  • 新增一个相同id的文档

注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。

语法:

JSON
PUT /{索引名}/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    // ... 略
}
1
2
3
4
5
6

示例:

JSON
PUT /heima/_doc/1
{
    "info": "黑马程序员高级Java讲师",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}
1
2
3
4
5
6
7
8
9

由于id为1的文档已经被删除,所以第一次执行时,得到的反馈是created:

所以如果执行第2次时,得到的反馈则是updated:

3.4.2 局部修改 ​

局部修改是只修改指定id匹配的文档中的部分字段。

语法:

JSON
POST /{索引名}/_update/文档id
{
    "doc": {
         "字段名": "新的值",
    }
}
1
2
3
4
5
6

示例:

JSON
POST /heima/_update/1
{
  "doc": {
    "email": "ZhaoYun@itcast.cn"
  }
}
1
2
3
4
5
6

执行结果:

3.5 批处理 ​

批处理采用POST请求,基本语法如下:

Java
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }
1
2
3
4
5
6
7
8

其中:

  • index代表全量修改

    • _index:指定索引名
    • _id指定要操作的文档id
    • { "field1" : "value1" }:则是要新增的文档内容
  • create代表新增加,如果文档已存在则报错

    • _index:指定索引名
    • _id指定要操作的文档id
    • { "field1" : "value1" }:则是要新增的文档内容
  • delete代表删除操作

    • _index:指定索引名
    • _id指定要操作的文档id
  • update代表更新操作

    • _index:指定索引名
    • _id指定要操作的文档id
    • { "doc" : {"field2" : "value2"} }:要更新的文档字段

示例,批量新增:

Java
POST /_bulk
{"index": {"_index":"heima", "_id": "3"}}
{"info": "黑马程序员C++讲师", "email": "ww@itcast.cn", "name":{"firstName": "五", "lastName":"王"}}
{"index": {"_index":"heima", "_id": "4"}}
{"info": "黑马程序员前端讲师", "email": "zhangsan@itcast.cn", "name":{"firstName": "三", "lastName":"张"}}
1
2
3
4
5

批量删除:

Java
POST /_bulk
{"delete":{"_index":"heima", "_id": "3"}}
{"delete":{"_index":"heima", "_id": "4"}}
1
2
3

3.6 总结 ​

文档操作有哪些?

  • 创建文档:POST /{索引名}/_doc/文档id { json文档 }

  • 查询文档:GET /{索引名}/_doc/文档id

  • 删除文档:DELETE /{索引名}/_doc/文档id

  • 修改文档:

    • 全量修改:PUT /{索引名}/_doc/文档id { json文档 }
    • 局部修改:POST /{索引名}/_update/文档id { "doc": {字段}}
  • 批量操作:POST _bulk

4 Java Client ​

4.1. 配置Java client ​

前边我们都是在Kibana中使用DSL语法直接请求Elasticsearch的HTTP 接口进行测试,DSL是Elasticsearch的领域特定语言(Domain Specific Language, DSL)是一种基于JSON的查询语言。

在项目开发中为了提高开发效率ES官方提供了各种不同语言的客户端,这些客户端的本质就是组装DSL语句,通过http请求发送给ES。

官方文档地址:

https://www.elastic.co/guide/en/elasticsearch/client/index.html

由于ES目前最新版本是8.x,提供了全新版本的客户端Java Client,老版本的客户端Java REST Client已经被标记为过时。

我们使用的是7.17.x版本,新版本和老版本都支持,将来的版本会全面抛弃老版本的Java REST Client ,所以本教程使用新版本的Java Client。

Java Client要求:

  • Java 8 或更高版本。
  • JSON 对象映射库,可将您的应用程序类与 Elasticsearch API 无缝集成。Java 客户端支持Jackson或 JSON-B库(如 Eclipse Yasson)。

如何集成Java Client,可参考文档,地址在:https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/installation.html

在hmall-parent中添加依赖管理:

JavaScript
<properties>
    <es.version>7.17.7</es.version>
    <jackson.version>2.13.0</jackson.version>
    <jakarta.json-ai.version>2.0.1</jakarta.json-ai.version>
</properties>

<!-- 对依赖包进行管理 -->
<dependencyManagement>
  <dependencies>
    <!--es-->
    <dependency>
        <groupId>co.elastic.clients</groupId>
        <artifactId>elasticsearch-java</artifactId>
        <version>${es.version}</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>${jackson.version}</version>
    </dependency>
    <dependency>
        <groupId>jakarta.json</groupId>
        <artifactId>jakarta.json-api</artifactId>
        <version>${jakarta.json-ai.version}</version>
    </dependency>
  </dependencies>
</dependencyManagement>
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

在hmall-item添加如下依赖:

JavaScript
<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
    <groupId>jakarta.json</groupId>
    <artifactId>jakarta.json-api</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12

这里为了单元测试方便,我们创建一个测试类IndexTest,然后将初始化的代码编写在@BeforeEach方法中:

参考:https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/connecting.html

Java
package com.hmall.item.es;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;

/**
 * @author Mr.M
 * @version 1.0
 * @description 使用Java Client 操作ES
 * @date 2024/8/18 17:40
 */
public class IndexTest {

    private ElasticsearchClient esClient;

    private RestClient restClient;

    @BeforeEach
    void setUp() {
        // Create the low-level client
        this.restClient = RestClient.builder(
                new HttpHost("192.168.101.68", 9200)).build();

        // Create the transport with a Jackson mapper
        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper());

        // And create the API client
        this.esClient = new ElasticsearchClient(transport);
    }

    @AfterEach
    void tearDown() throws IOException {
        this.restClient.close();
    }

}
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

4.2. 创建索引 ​

下边我们以商城项目为例,使用Java Client维护索引数据。

搜索页面的效果如图所示:

最终我们使用Elaticsearch实现搜索接口。

4.2.1 分析索引的映射 ​

首先我们需要创建索引,配置映射,首先针对上图去分析映射结构,包括哪些字段以及字段的类型等属性。

实现搜索功能需要的字段包括三大部分:

  • 搜索关键字字段:

    • 商品名称
  • 过滤字段

    • 分类
    • 品牌
    • 价格
  • 排序字段

    • 默认:按照更新时间降序排序
    • 销量
    • 价格
  • 展示字段

    • 商品id:用于点击后跳转
    • 图片地址
    • 是否是广告推广商品
    • 名称
    • 价格
    • 评价数量
    • 销量

对应的商品表结构如下,索引库无关字段已经划掉:

结合数据库表结构,以上字段对应的mapping映射属性如下:

字段名字段类型类型说明是否
参与搜索
是否
参与分词
分词器
idlong长整数——
nametext字符串,参与分词搜索IK
priceinteger以分为单位,所以是整数——
stockinteger字符串,但是不分词——
imagekeyword字符串,但是不分词——
categorykeyword字符串,但是不分词——
brandkeyword字符串,但是不分词——
soldinteger销量,整数——
commentCountinteger评价,整数——
isADboolean布尔类型——
updateTimeDate更新时间——

4.2.2 创建索引 ​

根据分析我们使用下边的语句创建items索引:

JSON
PUT /items
{
  "mappings": {
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "price":{
        "type": "integer"
      },
      "stock":{
        "type": "integer"
      },
      "image":{
        "type": "keyword",
        "index": false
      },
      "category":{
        "type": "keyword"
      },
      "brand":{
        "type": "keyword"
      },
      "sold":{
        "type": "integer"
      },
      "commentCount":{
        "type": "integer",
        "index": false
      },
      "isAD":{
        "type": "boolean"
      },
      "updateTime":{
        "type": "date"
      }
    }
  }
}
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

我们为什么不用Java Client去创建索引呢?

这就好比在MySQL中创建表,通常我们使用DDL语句通过MySQL客户端执行,而对于数据的CRUD及复杂的SQL语句我们会通过jdbc 去访问mysql数据库一样。

所以,使用Elasticsearch通常我们使用Kibana通过DSL语句去创建索引,而不会使用Java Client去创建索引,使用Java Client主要是为了向索引中添加文档、从索引中搜索文档。

4.3. 新增文档 ​

接下来我们使用Java Client向索引中添加文档。

Java Client的使用方法可以参考文档学习:

https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/indexing.html

4.3.1 创建模型类 ​

就和使用MyBatis一样操作数据库需要一个模型对象,使用Elasticsearch向索引执行CRUD操作也需要模型类。

依据索引映射创建模型类:

小技巧:创建模型类可以提供索引映射由AI去生成模型类。

JavaScript
package com.hmall.item.domain.po;

import co.elastic.clients.elasticsearch._types.mapping.FieldType;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * @author Mr.M
 * @version 1.0
 * @description Elasticsearch模型类
 * @date 2024/8/18 20:26
 */
@Data
@ApiModel(description = "索引库实体")
public class ItemDoc {

    @ApiModelProperty("商品id")
    private String id;

    @ApiModelProperty("商品名称")
    private String name;

    @ApiModelProperty("价格(分)")
    private Integer price;

    @ApiModelProperty("库存")
    private Integer stock;

    @ApiModelProperty("商品图片")
    private String image;

    @ApiModelProperty("类目名称")
    private String category;

    @ApiModelProperty("品牌名称")
    private String brand;

    @ApiModelProperty("销量")
    private Integer sold;

    @ApiModelProperty("评论数")
    private Integer commentCount;

    @ApiModelProperty("是否是推广广告,true/false")
    private Boolean isAD;

    @ApiModelProperty("更新时间")
    private LocalDateTime updateTime;
}
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

4.3.2 编写客户端代码 ​

下边参考ES文档编写客户端代码。

JavaScript
@SpringBootTest
@Slf4j
public class IndexTest {
    ...

    @Autowired
    private IItemService itemService;
    

    @Test
    void testAddDocument() throws IOException {
        //商品id
        Long id = 100002644680L;
        // 1.根据id查询商品数据
        Item item = itemService.getById(id);
        // 2.转换为文档类型
        ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
        IndexResponse response = esClient.index(i -> i
                .index("items")//指定索引名称
                .id(itemDoc.getId())//指定主键
                .document(itemDoc)//指定文档对象
        );
        //结果
        String s = response.result().jsonValue();
        log.info("result:"+s);
    
    }

}
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

4.3.3 测试 ​

下边进行运行测试方法。

报错:

JavaScript
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.hmall.item.domain.po.ItemDoc["updateTime"])
1

根据提示猜测是数据绑定出问题,现在是要把Java 对象的信息映射为ES的索引文档,jackson-datatype-jsr310对于LocalDateTime不支持。

使用AI解决:

JavaScript
elasticsearch使用的是7.17.7,使用co.elastic.clients.elasticsearch.ElasticsearchClient 向索引新增文档报错如下:
Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.hmall.item.domain.po.ItemDoc["updateTime"])
1
2

根据AI提示修改如下:

添加 JavaTimeModule 以支持 LocalDateTime 类型。

完整代码如下:

JavaScript
package com.hmall.item.es;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.json.JSONUtil;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.AcknowledgedResponse;
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.elasticsearch.core.InfoRequest;
import co.elastic.clients.elasticsearch.indices.CreateIndexResponse;
import co.elastic.clients.elasticsearch.indices.PutMappingResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.hmall.item.domain.po.Item;
import com.hmall.item.domain.po.ItemDoc;
import com.hmall.item.service.IItemService;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * @author Mr.M
 * @version 1.0
 * @description 使用Java Client 操作ES
 * @date 2024/8/18 17:40
 */
@SpringBootTest
@Slf4j
public class IndexTest {

    private ElasticsearchClient esClient;

    private RestClient restClient;

    @Autowired
    private IItemService itemService;

    @BeforeEach
    void setUp() {
        // Create the low-level client
        this.restClient = RestClient.builder(
                new HttpHost("192.168.101.68", 9200)).build();
        // 创建 ObjectMapper 实例
        ObjectMapper objectMapper = new ObjectMapper();
        // 添加 JavaTimeModule 以支持 LocalDateTime 类型
        objectMapper.registerModule(new JavaTimeModule());

        // Create the transport with a Jackson mapper
        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper(objectMapper));

        // And create the API client
        this.esClient = new ElasticsearchClient(transport);
    }

    //创建文档
    @Test
    void testAddDocument() throws IOException {
        // 1.根据id查询商品数据
        Item item = itemService.getById(100002644680L);
        // 2.转换为文档类型
        ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
        IndexResponse response = esClient.index(i -> i
                .index("items")//指定索引名称
                .id(itemDoc.getId())//指定主键
                .document(itemDoc)//指定文档对象
        );
        //结果
        String s = response.result().jsonValue();
        log.info("result:"+s);

    }

    @AfterEach
    void tearDown() throws IOException {
        this.restClient.close();
    }

}
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95

执行成功进行验证

我们使用DSL查询:

JavaScript
GET /items/_search
1

结果:

确定ES的索引中存在刚才添加的文档。

4.4 查询文档 ​

参考文档:https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/7.17/reading.html

通过阅读文档可知,这里实现的是根据ID查询文档。

编写代码:

JavaScript
@Test
void testGetDocumentById() throws IOException {
    GetResponse<ItemDoc> response = esClient.get(g -> g
                    .index("items")
                    .id("100002644680"),
            ItemDoc.class
    );

    if (response.found()) {
        ItemDoc itemDoc = response.source();
        log.info("itemDoc: " + itemDoc);
    } else {
        log.info ("itemDoc not found");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

4.5 删除文档 ​

通过两个API方法的学习:

新增文档:esClient.index()方法

查询文档:esClient.get()方法

对于删除文档的方式可以根据代码提示自行编写,如下:

JavaScript
@Test
void testDeleteDocumentById() throws IOException {
    DeleteResponse response = esClient.delete(d -> d
            .index("items")
            .id("100002644680")
    );
    String s = response.result().jsonValue();
    log.info("result:"+s);
}
1
2
3
4
5
6
7
8
9

4.6 修改文档 ​

  1. 局部修改

如果要更新的文档不存在会报错。

JavaScript
@Test
void testUpdateDocumentById() throws IOException {
    //更新对象
    ItemDoc itemDoc = new ItemDoc();
    itemDoc.setName("更新名称");
    UpdateResponse<ItemDoc> response = esClient.update(u -> u
            .index("items")
            .id("100002644680")
            .doc(itemDoc), ItemDoc.class);
    String s = response.result().jsonValue();
    log.info("result:"+s);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. 有则更新,没有则添加。
JavaScript
@Test
void testUpdateDocumentById2() throws IOException {
    //更新对象
    ItemDoc itemDoc = new ItemDoc();
    itemDoc.setName("更新名称");
    UpdateResponse<ItemDoc> response = esClient.update(u -> u
            .index("items")
            .id("100002644680aa")
            .doc(itemDoc)
            .docAsUpsert(true), ItemDoc.class);
    String s = response.result().jsonValue();
    log.info("result:"+s);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

通过docAsUpsert(true)控制,如果没有该文档则添加新文档。

4.7. 批量导入 ​

文档地址:

测试代码如下:

JavaScript
@Test
void testBatchAddDocment() throws Exception {
    //取第一页10条数据
    Page<Item> page = Page.of(0, 10);
    //查询所有商品信息
    Page<Item> itemPage = itemService.page(page, new LambdaQueryWrapper<Item>());
    //获取商品集合
    List<Item> items = itemPage.getRecords();
    //拷贝属性
    List<ItemDoc> itemDocs = BeanUtils.copyList(items, ItemDoc.class);
    //批量添加请求
    BulkRequest.Builder br = new BulkRequest.Builder();
    itemDocs.forEach(itemDoc ->
            br.operations(op -> op
                    .index(i -> i
                            .index("items")
                            .id(itemDoc.getId().toString())
                            .document(itemDoc))));
    //构建请求
    BulkRequest build = br.build();
    //批量添加
    BulkResponse bulkResponse = esClient.bulk(build);
    //遍历结果
    bulkResponse.items().forEach(item -> log.info("添加结果:{}",item.result().toString()));
    //如果有错误
    if(bulkResponse.errors()){
        log.error("批量添加失败");
        //遍历错误
        bulkResponse.items().forEach(item -> log.error("添加失败:{}",item.error().reason()));
    }

}
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

5 搜索 ​

5.1 搜索入门 ​

5.1.1 介绍 ​

最终我们使用Elasticsearch实现搜索功能,现在已经将文档添加到了索引中,接下来学习搜索的方法。

首先我们学习DSL搜索方式,参考DSL搜索语法再学习Java Client方式,最终在微服务中使用Java Client方式完成搜索接口的开发。

Elasticsearch的查询可以分为两大类:

文档参考:

  1. 叶子查询(Leaf query clauses):叶查询子句在特定字段中查找特定值,例如 match、term或 range查询。
  • 精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如:

    • ids: 根据文档 ID 查找文档
    • range:返回包含指定范围内的文档,比如:查询年龄在10到20岁的学生信息。
    • term: 根据精确值(例如价格、产品 ID 或用户名)查找文档。
  • 全文检索(full text)查询:利用分词器对用户输入内容分词,然后去倒排索引库中匹配。例如:

    • match_query:对一个字段进行全文检索
    • multi_match_query:对多个字段进行全文检索
  1. 复合查询(Compound query clauses):以逻辑方式组合多个叶子查询或者更改叶子查询的行为方式。
  • 第一类:基于逻辑运算组合叶子查询,实现组合条件,例如

    • bool:实现组合条件查询,项目使用较多。
  • 第二类:基于某种算法修改查询时的文档相关性算分,从而改变文档排名。例如:

    • function_score:通过条件,指定算分函数,控制文档得分,得分越高排名越靠前,比如:百度竞价排名。
    • dis_max:从多个查询中选择得分最高的结果。

其它复合查询及相关语法可以参考官方文档:

5.1.2 精确查询 ​

精确查询,英文是Term-level query,顾名思义,词条级别的查询。也就是说不会对用户输入的搜索条件再分词,而是作为一个词条,与搜索的字段内容精确值匹配。因此推荐查找keyword、数值、日期、boolean类型的字段。例如:

  • id
  • price
  • 城市
  • 地名
  • 人名

等等,作为一个整体才有含义的字段。

详情可以查看官方文档:

5.1.2.1 term ​

以term查询为例,其语法如下:

JSON
GET /{索引库名}/_search
{
  "query": {
    "term": {
      "字段名": {
        "value": "搜索条件"
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10

示例:

当你输入的搜索条件不是词条,而是短语时,由于不做分词,你反而搜索不到:

5.1.2.2 range ​

再来看下range查询,语法如下:

JSON
GET /{索引库名}/_search
{
  "query": {
    "range": {
      "字段名": {
        "gte": {最小值},
        "lte": {最大值}
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11

range是范围查询,对于范围筛选的关键字有:

  • gte:大于等于
  • gt:大于
  • lte:小于等于
  • lt:小于

示例:

5.1.3 全文检索 ​

全文检索的种类也很多,详情可以参考官方文档:

5.1.3.1 match ​

以全文检索中的match为例,语法如下:

JSON
GET /{索引库名}/_search
{
  "query": {
    "match": {
      "字段名": "搜索条件"
    }
  }
}
1
2
3
4
5
6
7
8

示例:

5.1.3.2 multi_match ​

与match类似的还有multi_match,区别在于可以同时对多个字段搜索,而且多个字段都要满足,语法示例:

JSON
GET /{索引库名}/_search
{
  "query": {
    "multi_match": {
      "query": "搜索条件",
      "fields": ["字段1", "字段2"]
    }
  }
}
1
2
3
4
5
6
7
8
9

示例:

5.1.4 排序 ​

elasticsearch默认是根据相关度算分(_score)来排序,但是也支持自定义方式对搜索结果排序。不过分词字段无法排序,能参与排序字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等。

详细说明可以参考官方文档:

语法说明:

JSON
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "排序字段": {
        "order": "排序方式asc和desc"
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13

示例,我们按照商品价格排序:

JSON
GET /items/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "price": {
        "order": "desc"
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13

5.1.5 分页查询 ​

elasticsearch 默认情况下只返回top10的数据。而如果要查询更多数据就需要修改分页参数了。

elasticsearch中通过修改from、size参数来控制要返回的分页结果:

  • from:从第几个文档开始
  • size:总共查询几个文档

类似于mysql中的limit ?, ?

官方文档如下:

语法如下:

JSON
GET /items/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为0
  "size": 10,  // 每页文档数量,默认10
  "sort": [
    {
      "price": {
        "order": "desc"
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

5.2. Java Client实现搜索 ​

5.2.1. Term查询 ​

文档:

根据DSL语句编写java代码:

Java
GET /items/_search
{
  "query": {
    "term": {
      "category": {
        "value": "拉杆箱"
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10

代码如下:

JavaScript
@Test
void testTerm() throws IOException {
    
    SearchResponse<ItemDoc> response = esClient.search(s -> s
                    .index("items")//指定索引名
                    .query(q -> q
                            .term(t -> t
                                    .field("category").value("拉杆箱"))
                    ),
            ItemDoc.class
    );
    // 解析响应
    handleResponse(response);
}

private void handleResponse(SearchResponse<ItemDoc> searchResponse) {
    //获取总条数
    long total = searchResponse.hits().total().value();
    log.info("查询到{}条数据",total);
    //解析结果
    List<Hit<ItemDoc>> hits = searchResponse.hits().hits();
    //遍历hits
    hits.forEach(hit -> {
        ItemDoc source = hit.source();
        log.info("查询到数据:{}",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

说明:

esClient.search()方法的签名为:

JavaScript
public final <TDocument> SearchResponse<TDocument> search(Function<SearchRequest.Builder, ObjectBuilder<SearchRequest>> fn, Class<TDocument> tDocumentClass) throws IOException, ElasticsearchException {
    return this.search((SearchRequest)((ObjectBuilder)fn.apply(new SearchRequest.Builder())).build(), tDocumentClass);
}
1
2
3

search()方法第一个参数是一个函数式接口,第二个参数为模型类的Class类型。

SearchResponse为响应类型,可以依据DSL查询结果进行解析,如下:

JSON
{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 60,
      "relation" : "eq"
    },
    "max_score" : 0.35950777,
    "hits" : [
      {
        "_index" : "items",
        "_type" : "_doc",
        "_id" : "317578",
        "_score" : 0.35950777,
        "_source" : {
          "id" : "317578",
          "name" : "RIMOWA 21寸托运箱拉杆箱 SALSA AIR系列果绿色 820.70.36.4",
          "price" : 28900,
          "stock" : 9985,
          "image" : "https://m.360buyimg.com/mobilecms/s720x720_jfs/t6934/364/1195375010/84676/e9f2c55f/597ece38N0ddcbc77.jpg!q70.jpg.webp",
          "category" : "拉杆箱",
          "brand" : "RIMOWA",
          "sold" : 0,
          "commentCount" : 0,
          "isAD" : false,
          "updateTime" : [
            2024,
            10,
            25,
            17,
            52,
            8
          ]
        }
      },
    ...
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

结合解析handleResponse()方法阅读代码:

代码解读:

elasticsearch返回的结果是一个JSON字符串,结构包含:

  • hits:命中的结果

    • total:总条数,其中的value是具体的总条数值

    • max_score:所有结果中得分最高的文档的相关性算分

    • hits:搜索结果的文档数组,其中的每个文档都是一个json对象

      • _source:文档中的原始数据,也是json对象

因此,我们解析响应结果,就是逐层解析JSON字符串,流程如下:

  • SearchHits:通过response.getHits()获取,就是JSON中的最外层的hits,代表命中的结果

    • SearchHits#getTotalHits().value:获取总条数信息

    • SearchHits#getHits():获取SearchHit数组,也就是文档数组

      • SearchHit#getSourceAsString():获取文档结果中的_source,也就是原始的json文档数据

我们是根据DSL编写java代码,也可以跟踪java代码查看最终执行的DSL是否正确。

上边的代码改为如下代码,并打断点:

再次运行跟踪断点

复制DSL语句

Java
SearchRequest: POST /items/_search?typed_keys=true {"query":{"term":{"category":{"value":"拉杆箱"}}}}
1

去掉前边的请求地址拿到DSL语句

Java
{"query":{"term":{"category":{"value":"拉杆箱"}}}}
1

然后在kibana中进行测试。

5.2.2. 全文检索 ​

下边使用Java Client实现全文检索:

match查询:

根据DSL编写java代码:

Java
GET /items/_search
{
  "query": {
    "match": {
      "name": "绿色拉杆箱"
    }
  }
}
1
2
3
4
5
6
7
8

java代码:

JavaScript
@Test
void testFullTextQuery() throws IOException {
    SearchResponse<ItemDoc> searchResponse = esClient.search(s -> s
            .index("items")
            .query(q -> q
                    .match(t -> t
                            .field("name").query("绿色拉杆箱"))), ItemDoc.class);

    //解析结果
    handleResponse(searchResponse);
}
1
2
3
4
5
6
7
8
9
10
11
12

multi_match查询:

DSL:

Java
GET /items/_search
{
  "query": {
    "multi_match": {
      "query": "绿色拉杆箱",
      "fields": ["name","category"]
    }
  }
}
1
2
3
4
5
6
7
8
9

Java:

Java
@Test
void testMultiMatchQuery() throws IOException {
    SearchResponse<ItemDoc> searchResponse = esClient.search(s -> s
            .index("items")
            .query(q -> q
                    .multiMatch(t -> t
                            .fields("name", "category").query("绿色拉杆箱"))), ItemDoc.class);

    //解析结果
    handleResponse(searchResponse);
}
1
2
3
4
5
6
7
8
9
10
11

5.2.3 排序和分页 ​

DSL:

Java
GET /items/_search
{
  "query": {
    "multi_match": {
      "query": "绿色拉杆箱",
      "fields": ["name","category"]
    }
  },
  "sort": [
    {
      "price": {
        "order": "asc"
      }
    }
  ],
  "size": 20,
  "from": 0
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Java:

JavaScript
//测试multi_match查询并加入排序和分页查询
@Test
void testPageAndSort() throws IOException {
    int pageNo = 1, pageSize = 5;
    SearchResponse<ItemDoc> searchResponse = esClient.search(s -> s
            .index("items")
            .query(q -> q
                    .multiMatch(t -> t
                            .fields("name", "category").query("绿色拉杆箱")))
                    .sort(sort->sort
                            .field(field->field.field("price").order(SortOrder.Asc)))//排序
                    .size(pageSize)//每页显示条数
                    .from((pageNo - 1) * pageSize)//从第几条开始
            , ItemDoc.class);

    //解析结果
    handleResponse(searchResponse);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

5.3 复合查询 ​

第一类:基于逻辑运算组合叶子查询,实现组合条件,例如

  • bool:实现组合条件查询,项目使用较多。

第二类:基于某种算法修改查询时的文档相关性算分,从而改变文档排名。例如:

  • function_score:通过条件,指定算分函数,控制文档得分,得分越高排名越靠前,比如:百度竞价排名。
  • dis_max:从多个查询中选择得分最高的结果。

其它复合查询及相关语法可以参考官方文档:

其它复合查询及相关语法可以参考官方文档:

5.3.1.布尔查询 ​

bool查询,即布尔查询。就是利用逻辑运算来组合一个或多个查询子句的组合。bool查询支持的逻辑运算有:

  • must:必须匹配每个子查询,类似“与”
  • should:选择性匹配子查询,类似“或”
  • must_not:必须不匹配,不参与算分,类似“非”
  • filter:必须匹配,不参与算分

bool查询的语法如下:

JSON
GET /items/_search
{
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "手机"}}
      ],
      "should": [
        {"term": {"brand": { "value": "vivo" }}},
        {"term": {"brand": { "value": "小米" }}}
      ],
      "must_not": [
        {"range": {"price": {"gte": 2500}}}
      ]
    }
  },
  "sort": [
    {
      "brand": {
        "order": "desc"
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

这个查询的整体逻辑如下:

  1. 必须条件 (must):

    • 文档的 name 字段必须包含“手机”。
  2. 可选条件 (should):

    • 文档的 brand 字段应该是“vivo”或者“小米”。只要满足其中一个条件即可。
  3. 排除条件 (must_not):

    • 文档的 price 字段不能大于等于 2500 元。
  4. 过滤条件 (filter):

    • 文档的 price 字段必须小于等于 1000 元。

最终,这个查询会返回所有符合条件的文档,即名称包含“手机”,品牌为“vivo”或“小米”,并且价格在 1000 元以内,同时价格不低于 2500 元的文档会被排除在外。

运行上边的语句发现型号除了vivo和小米的记录也出现在查询结果中了,这说明should条件没有起作用。

当should与must、must_not同时使用时should会失效,需要指定minimum_should_match。

minimum_should_match:指定should中至少满足几个条件,默认为0。

语句修改如下:

Java
GET /items/_search
{
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "手机"}}
      ],
      "should": [
       {"term": {"brand": { "value": "小米" }}},
        {"term": {"brand": { "value": "vivo" }}}
      ],
      "minimum_should_match": 1
    }
  },
  "sort": [
    {
      "brand": {
        "order": "desc"
      }
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

minimum_should_match也可以指定百分比例,比如:minimum_should_match: "50%" 表示至少有一半的条件满足。

5.3.2 尽量使用filter ​

出于性能考虑,与搜索关键字无关的查询尽量采用must_not或filter逻辑运算,避免参与相关性算分。

例如黑马商城的搜索页面:

其中输入框的搜索条件肯定要参与相关性算分,可以采用match。但是价格范围过滤、品牌过滤、分类过滤等尽量采用filter,不要参与相关性算分。

比如,我们要搜索手机,但品牌必须是华为,价格必须是900~1599,那么可以这样写:

JSON
GET /items/_search
{
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "手机"}}
      ],
      "filter": [
        {"term": {"brand": { "value": "华为" }}},
        {"range": {"price": {"gte": 90000, "lt": 159900}}}
      ]
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

5.3.3. Java Client ​

对照dsl语句结合AI编写Java Client程序

JavaScript
@Test
void testBoolQuery() throws Exception {
    //构建请求
    SearchRequest.Builder builder = new SearchRequest.Builder();
    //设置索引
    builder.index("items");
    //设置查询条件
    SearchRequest.Builder searchRequestBuilder = builder.query(q -> q
            .bool(b -> b
                    .must(m -> m
                            .match(mm -> mm
                                    .field("name")
                                    .query("手机")))
                    .should(s1 -> s1
                            .term(t -> t
                                    .field("brand")
                                    .value("小米")
                                    )
                    )
                    .should(s1 -> s1
                            .term(t -> t
                                    .field("brand")
                                    .value("vivo")
                            )
                    )

                    .minimumShouldMatch("1")
            )
    ).sort(sort -> sort
            .field(f -> f.field("brand").order(SortOrder.Asc)));
    SearchRequest build = searchRequestBuilder.build();
    //执行请求
    SearchResponse<ItemDoc> searchResponse = esClient.search(build, ItemDoc.class);
    //解析结果
    handleResponse(searchResponse);
}
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

5.4.高亮显示 ​

5.4.1 高亮显示原理 ​

什么是高亮显示呢?

我们在百度,京东搜索时,关键字会变成红色,比较醒目,这叫高亮显示:

观察页面源码,你会发现两件事情:

  • 高亮词条都被加了<em>标签
  • <em>标签都添加了红色样式

css样式肯定是前端实现页面的时候写好的,但是前端编写页面的时候是不知道页面要展示什么数据的,不可能给数据加标签。而服务端实现搜索功能,要是有elasticsearch做分词搜索,是知道哪些词条需要高亮的。

因此词条的高亮标签肯定是由服务端提供数据的时候已经加上的。

因此实现高亮的思路就是:

  • 用户输入搜索关键字搜索数据
  • 服务端根据搜索关键字到elasticsearch搜索,并给搜索结果中的关键字词条添加html标签
  • 前端提前给约定好的html标签添加CSS样式

5.4.2 实现高亮 ​

事实上elasticsearch已经提供了给搜索关键字加标签的语法,无需我们自己编码。

基本语法如下:

JSON
GET /{索引库名}/_search
{
  "query": {
    "match": {
      "搜索字段": "搜索关键字"
    }
  },
  "highlight": {
    "fields": {
      "高亮字段名称": {
        "pre_tags": "<em>",
        "post_tags": "</em>"
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

示例:

下边用Java Client实现,对照dsl语句结合AI编写Java Client程序

JavaScript
@Test
void testHighlight() throws IOException {
    SearchRequest.Builder builder = new SearchRequest.Builder();
    builder.index("items");
    builder.query(q -> q
            .match(m -> m
                    .field("name").query("脱脂牛奶")));
    builder.highlight(h -> h
            .fields("name", f -> f
                    .preTags("<em>")
                    .postTags("</em>")));
    SearchRequest request = builder.build();
    SearchResponse<ItemDoc> response = esClient.search(request, ItemDoc.class);
    //解析出高亮结果
    for (Hit<ItemDoc> hit: response.hits().hits()) {
        ItemDoc source = hit.source();
        Map<String, List<String>> highlightFields = hit.highlight();
        if (highlightFields != null) {
            List<String> name = highlightFields.get("name");
            if (name != null && name.size() > 0 ) {
                String highlightName = name.get(0);
                source.setName(highlightName);
            }
        }
        log.info("查询结果:{}",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

6 数据聚合 ​

6.1 介绍 ​

聚合(aggregations)可以让我们极其方便的实现对数据的统计、分析、运算。例如:

  • 什么品牌的手机最受欢迎?
  • 这些手机的平均价格、最高价格、最低价格?
  • 这些手机每月的销售情况如何?

实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现近实时搜索效果。

应用场景:

  1. 对数据进行统计
  2. 在搜索界面显示符合条件的品牌、分类、规格等信息,如下图:

聚合常见的有三类:

官方文档:

  • 桶(Bucket)聚合:用来对文档做分组

  • TermAggregation:按照文档字段值分组,例如按照品牌值分组、按照国家分组

  • Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组

  • 度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等

  • Avg:求平均值

  • Max:求最大值

  • Min:求最小值

  • Stats:同时求max、min、avg、sum等

  • 管道(pipeline)聚合:其它聚合的结果为基础做进一步运算

注意:参加聚合的字段必须是keyword、日期、数值、布尔类型

6.2. Bucket聚合 ​

6.2.1 语法 ​

例如我们要统计所有商品中共有哪些商品分类,其实就是以分类(category)字段对数据分组。category值一样的放在同一组,属于Bucket聚合中的Term聚合。

基本语法如下:

JSON
GET /items/_search
{
  "size": 0, 
  "aggs": {
    "category_agg": {
      "terms": {
        "field": "category",
        "size": 20,
        "order": {
              "_count": "desc"
         }
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

语法说明:

  • size:设置size为0,查询0条数据即结果中不包含文档,只包含聚合

  • aggregations:定义聚合

    • category_agg:聚合名称,自定义,但不能重复

      • terms:聚合的类型,按分类聚合,所以用term

        • field:参与聚合的字段名称
        • size:希望返回的聚合结果的最大数量
        • order: 对聚合结果排序

来看下查询的结果:

6.2.2 多级聚合 ​

同时对品牌分组统计,此时需要按分类统计,按品牌统计,这时需要定义多个桶,如下:

TypeScript
GET /items/_search
{
  "size": 0, 
  "aggs": {
    "category_agg": {
      "terms": {
        "field": "category",
        "size": 20
      }
    },
    "brand_agg":{
       "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

结果:

TypeScript
{
  "took" : 7,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2000,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "category_agg" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "拉杆箱",
          "doc_count" : 1323
        },
        {
          "key" : "真皮包",
          "doc_count" : 210
        },
        {
          "key" : "手机",
          "doc_count" : 151
        },
        {
          "key" : "牛奶",
          "doc_count" : 145
        }
      ]
    },
    "brand_agg" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 863,
      "buckets" : [
        {
          "key" : "美旅箱包",
          "doc_count" : 110
        },
        {
          "key" : "汉客",
          "doc_count" : 90
        }
      ]
    }
  }
}
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

现在需要统计同一分类下的不同品牌的商品数量,这时就需要对桶内的商品二次聚合,如下:

TypeScript
GET /items/_search
{
   "aggs" : {
      "category_agg" : {
         "aggs" : {
            "brand_agg" : {
               "terms" : {
                  "field" : "brand",
                  "size" : 20
               }
            }
         },
         "terms" : {
            "field" : "category",
            "size" : 20
         }
      }
   },
   "size" : 0
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

结果:

截取部分结果如下,拉杆箱是按分类聚合的一级聚合结果,其下的“汉客”、"新秀丽"是按品牌聚合的二级聚合结果。

TypeScript
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4002,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "category_agg" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "拉杆箱",
          "doc_count" : 2088,
          "brand_agg" : {
            "doc_count_error_upper_bound" : 0,
            "sum_other_doc_count" : 594,
            "buckets" : [
              {
                "key" : "美旅箱包",
                "doc_count" : 187
              },
              {
                "key" : "PointKid",
                "doc_count" : 128
              },
              {
                "key" : "汉客",
                "doc_count" : 120
              },
              {
                "key" : "新秀丽",
                "doc_count" : 104
              },
              {
                "key" : "DELSEY",
                "doc_count" : 99
              },
              {
                "key" : "莎米特",
                "doc_count" : 91
              },
              {
                "key" : "文森保罗",
                "doc_count" : 89
              },
              {
                "key" : "旅行之家",
                "doc_count" : 78
              },
              {
                "key" : "爱华仕",
                "doc_count" : 72
              },
              {
                "key" : "梵地亚",
                "doc_count" : 70
              },
              {
                "key" : "瑞动",
                "doc_count" : 66
              },
              {
                "key" : "银座",
                "doc_count" : 59
              },
              {
                "key" : "Diplomat",
                "doc_count" : 58
              },
              {
                "key" : "博兿",
                "doc_count" : 46
              },
              {
                "key" : "宾豪",
                "doc_count" : 41
              },
              {
                "key" : "卡拉羊",
                "doc_count" : 40
              },
              {
                "key" : "瑞界",
                "doc_count" : 38
              },
              {
                "key" : "Kamiliant",
                "doc_count" : 37
              },
              {
                "key" : "ITO",
                "doc_count" : 36
              },
              {
                "key" : "EAZZ",
                "doc_count" : 35
              }
            ]
          }
        },
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112

6.2.3 思考 ​

下边的语句中包含两个“brand_agg”,它们有什么不同?

TypeScript
GET /items/_search
{
   "aggs" : {
      "brand_agg" : {
         "terms" : {
            "field" : "brand",
            "size" : 20,
            "order": {
              "_count": "desc"
            }
         }
      },
      "category_agg" : {
         "aggs" : {
            "brand_agg" : {
               "terms" : {
                  "field" : "brand",
                  "size" : 20
               }
            }
         },
         "terms" : {
            "field" : "category",
            "size" : 20
         }
      }
   },
   "size" : 0
}
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

6.3 带条件聚合 ​

默认情况下,Bucket聚合是对索引库的所有文档做聚合,例如我们统计商品中所有的品牌,结果如下:

可以看到统计出的品牌非常多。

但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么聚合必须添加限定条件。

例如,我想知道价格高于3000元的手机品牌有哪些,该怎么统计呢?

我们需要从需求中分析出搜索查询的条件和聚合的目标:

  • 搜索查询条件:

    • 价格高于3000
    • 必须是手机
  • 聚合目标:统计的是品牌,肯定是对brand字段做term聚合

语法如下:

增加"query"标签。

JSON
GET /items/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "category": "手机"
          }
        },
        {
          "range": {
            "price": {
              "gte": 300000
            }
          }
        }
      ]
    }
  }, 
  "size": 0, 
  "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}
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

聚合结果如下:

JSON
{
  "took" : 2,
  "timed_out" : false,
  "hits" : {
    "total" : {
      "value" : 13,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  },
  "aggregations" : {
    "brand_agg" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "华为",
          "doc_count" : 7
        },
        {
          "key" : "Apple",
          "doc_count" : 5
        },
        {
          "key" : "小米",
          "doc_count" : 1
        }
      ]
    }
  }
}
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

可以看到,结果中只剩下3个品牌了。

6.4 Metric聚合 ​

上节课,我们统计了价格高于3000的手机品牌,形成了一个个桶。现在我们需要对桶内的商品做运算,获取每个品牌价格的最小值、最大值、平均值。

这就要用到Metric聚合了,例如stats聚合,就可以同时获取min、max、avg等结果。

语法如下:

JSON
GET /items/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "category": "手机"
          }
        },
        {
          "range": {
            "price": {
              "gte": 300000
            }
          }
        }
      ]
    }
  }, 
  "size": 0, 
  "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brand",
        "size": 20,
        "order": {
              "stats_metric.avg": "desc"
            }
      },
      "aggs": {
        "stats_metric": {
          "stats": {
            "field": "price"
          }
        }
      }
    }
  }
}
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

query部分就不说了,我们重点解读聚合部分语法。

可以看到我们在brand_agg聚合的内部,我们新加了一个aggs参数。这个聚合就是brand_agg的子聚合,会对brand_agg形成的每个桶中的文档分别统计。

  • stats_meric:聚合名称,自定义名称

    • stats:聚合类型,stats是metric聚合的一种

      • field:聚合字段,这里选择price,统计价格

由于stats是对brand_agg形成的每个品牌桶内文档分别做统计,因此每个品牌都会统计出自己的价格最小、最大、平均值。

结果如下:

另外,我们还可以让聚合按照每个品牌的价格平均值排序:

6.5. Java Client ​

参考DSL语句编写Java Client代码。

JavaScript
@Test
void testAggs() throws Exception {
    //构建请求
    SearchRequest.Builder builder = new SearchRequest.Builder();
    //设置索引名
    builder.index("items");
    //设置查询条件
    builder.query(q -> q.bool(b -> b
            .filter(f -> f.term(t -> t.field("category").value("手机")))
            .filter(f -> f.range(r -> r.field("price").gte(JsonData.of(3000))))));
    //设置返回数量
    builder.size(0);
    //设置聚合
    builder.aggregations("brand_agg", a -> a
            .terms(t -> t
                    .field("brand")
                    .size(10)
                    .order(NamedValue.of("stats_metric.avg", SortOrder.Desc)))
            .aggregations("stats_metric", a1 -> a1.stats(s -> s.field("price")))
    );
    SearchRequest build = builder.build();
    //执行请求
    SearchResponse<ItemDoc> searchResponse = esClient.search(build, ItemDoc.class);
    //解析出聚合结果
    Aggregate brandAgg = searchResponse.aggregations().get("brand_agg");
    brandAgg.sterms().buckets().array().forEach(bucket -> {
        String key = bucket.key().stringValue();
        Long docCount = bucket.docCount();
        StatsAggregate statsMetric = bucket.aggregations().get("stats_metric").stats();
        //平均价格
        Double avg = statsMetric.avg();
        //最大价格
        Double max = statsMetric.max();
        //最小价格
        Double min = statsMetric.min();
        log.info("品牌:{},商品数量:{},平均价格:{},最大价格:{},最小价格:{}", key, docCount, avg, max, min);
    });
}
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

作业 ​

批量导入数据 ​

使用批量导入将商品表的数据全部导入到ES中。

提示:一页一页查询商品数据库,每查询一页数据将其同步至ES中。

开发搜索服务 ​

需求 ​

搜索业务并发压力可能会比较高,目前与商品服务在一起,不方便后期优化。

将搜索相关功能抽取到这个微服务中。

搜索功能支持:

  1. 输入关键字进行全文检索,匹配商品名称、品牌、分类信息。
  2. 按分类、品牌、价格进行检索
  3. 商品名称高亮显示

示例:

提示 ​

修改原来的搜索接口,改为使用Elasticsearch实现,实现根据关键字搜索,实现分页、排序。

创建搜索服务,命名为search-service,端口为8087。

创建配置类

Java
package com.hmall.search.config;

import cn.hutool.core.date.DatePattern;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.hmall.search.properties.EsProperties;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * @author Mr.M
 * @version 1.0
 * @description Elasticsearch配置类
 * @date 2024/8/19 18:09
 */
@Configuration
@EnableConfigurationProperties(EsProperties.class)
public class EsConfiguration {

    @Bean
    public ElasticsearchClient esClient(EsProperties esProperties) {
        // Create the low-level client
        RestClient restClient = RestClient.builder(
                new HttpHost(esProperties.getHost(), esProperties.getPort())).build();
        // 创建 ObjectMapper 实例
        ObjectMapper objectMapper = new ObjectMapper();
        // 添加 JavaTimeModule 以支持 LocalDateTime 类型
        objectMapper.registerModule(new JavaTimeModule());

        // Create the transport with a Jackson mapper
        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper(objectMapper));

        // And create the API client
        ElasticsearchClient esClient = new ElasticsearchClient(transport);
        return esClient;
    }

}
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

EsProperties类如下:

Java
package com.hmall.search.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "hm.es")
@Data
public class EsProperties {
    /**
     * es host
     */
    private String host;
    /**
     * es 端口
     */
    private Integer port;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在application.yaml中配置:

Java
hm:
  es:
    host: 192.168.101.68
    port: 9200
1
2
3
4

屏蔽item-service中的SearchController

在网关中添加搜索服务的路由配置,修改完配置文件重启网关,如果网关无法启动需要查看控制台是否无法加载配置文件。

← day05-RabbitMQ集群部署day07-Elasticsearch高级 →








如果发现文档内容有错误或排版错乱,请及时联系站长老苗修改,不胜感激。联系方式
关于我们 | 隐私政策 | 豫ICP备2026003386号-4 | 豫公网安备41010202004008号
目录

本页无章节