分布式搜索引擎ElasticSearch

目录

1、ElasticSearch简介

2、ELK简介

 3、为什么要使用ES?

4、ES能干什么?

5、环境准备(软件安装)

5.1 安装ES—Windows

5.2 ElasticSearch-Head插件安装

5.3   ElasticSearch的可视化工具Kibana安装

5.4 IK分词器插件

5.5 拼音分词器插件

 6、ES核心概念

 6.1 物理设计

6.2 倒排索引

6.3 文档和字段

 6.4 索引和映射

7、Rest风格说明

8、 mysql和elasticsearch对比

9、通过Kibana操作文档和索引库

9.1 索引库操作

9.2 文档操作

10、SpringBoot整合ES【环境搭建】

11、在SpringBoot中操作索引库

11.1 创建索引

11.2 删除索引

11.3 判断索引是否存在

12、在SpringBoot中操作文档【重要】

12.1 新增文档

12.2 修改文档

12.3 查询文档

12.4 删除文档

12.5 批量导入文档

13、DSL查询文档

13.1 查询所有数据(match_all)

13.2 全文检索查询

13.3 精确查询

13.4 地理坐标查询(geo_distance )

13.5 复合(compound)查询【重要】

14、DSL搜索结果处理

14.1 排序

14.2 分页

14.3 高亮

15、RestClient查询文档

15.1 match_all查询

15.2 全文检索查询

15.3 精准查询

15.4 布尔查询

15.5 排序、分页

15.6 高亮

16、数据聚合

16.1 Bucket聚合语法【对文档分组】

16.2 Metric聚合语法【度量】

 16.3 RestApi实现聚合

17、自动补全

17.1 自定义分词器

17.2 自动补全查询

17.3 实现酒店搜索框自动补全

18、数据同步功能

19、热搜词(猜你想搜)功能实现

19.1 创建history_keywords索引库

19.2 编写UserMapper.xml

19.3 编写controller

19.4 编写service(关键字插入索引库)

19.5 编写service实现类(关键字插入索引库)

19.6 编写controller(关键字聚合)

19.7 编写service(关键字聚合操作)

19.8 编写service实现类(关键字聚合操作)


完整代码有需要的私信

1、ElasticSearch简介

        ES是一个开源的高扩展分布式全文检索引擎,它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理PB级别(GB<TB<PB<EB)的数据。ES也使用Java开发并使用Lucene作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的RESTful API来隐藏Lucene的复杂性,从而让全文搜索变得简单。

2、ELK简介

        ELK是Elasticsearch、Logstash、 Kibana三大开源框架首字母大写简称市面上也被成为Elastic Stack

  • 其中Elasticsearch基于Lucene、分布式、通过Restful方式进行交互的近实时搜索平台框架。
    • 像类似百度、谷歌这种大数据全文搜索引擎的场景都可以使用Elasticsearch作为底层支持框架,可见Elasticsearch提供的搜索能力确实强大,市面上很多时候我们简称Elasticsearch为es。
    • Logstash是ELK的中央数据流引擎,用于从不同目标(文件/数据存储/MQ )收集的不同格式数据,经过过滤后支持输出到不同目的地(文件/MQ/redis/elasticsearch/kafka等)。
  • Kibana可以将elasticsearch的数据通过友好的页面展示出来,提供实时分析的功能。
  • 市面上很多开发只要提到ELK能够一致说出它是一个日志分析架构技术栈总称,但实际上ELK不仅仅适用于日志分析,它还可以支持其它任何数据分析和收集的场景,日志分析和收集只是更具有代表性。并非唯一性。

 3、为什么要使用ES?

        (1)使用SQL数据量大的话,就十分慢,这时候就需要用到ES。项目中使用ES存储机构信息(大概有几千万条数据)。

        (2)ES基本是开箱即用(解压就可以用),非常简单。

        (3)如果你要做搜索这个功能。它将会极大地帮助你提高搜索效率。

        另一个方面原因就是如果你了解并且会熟练使用ES,在找工作时很大程度上会成为你的一个加分项,薪资待遇会有一定的改善,而且ES在大厂中也在普遍使用。

4、ES能干什么?

        说了这么多,ES到底能干什么呢,下面我们以百度为例来简单说一下,最后我们会逐一实现这些功能。

(1)在使用搜索框搜索时,会进行搜索补全

(2)使用拼音可以进行检索

(3)搜索关键字高亮显示

(4)热搜词(猜你想搜)功能实现

        这里简单描述一下,热搜词的关键在于,智能检测我们需要查询什么。一般来说热搜词为搜索数量最多的词。所以,我在全文检索时对流媒体进行查询时,会写一个切面类,在查询之前,将输入框中的词保存到ES的热搜词索引库中。然后通过聚合的方式对他们进行排序,本项目中我们以截取前十个进行显示。

5、环境准备(软件安装)

这里为大家提供一个下载链接,下载速度比较快、版本齐全。 下载中心 - Elastic 中文社区

5.1 安装ES—Windows

(1)下载

        这里需要强调一点,就是版本对应问题,在这里以7.6.2版本为例。如果你下载了elasticsearch-7.6.2,那么接下来kibana以及分词器等插件版本也必须是7.6.2

 (2)解压即可(尽量将ElasticSearch相关工具放在统一目录下)

(3)目录结构

        bin:启动文件目录
        config:配置文件目录
        log4j2:日志配置文件
        jvm.options:java 虚拟机相关的配置(默认启动占1g内存默认在20行左右,内容不够需要自己调整)
        elasticsearch.ym1:elasticsearch 的配置文件! 默认9200端口!跨域!
        lib:相关jar包
        modules:功能模块目录
        plugins:插件目录(ik分词器)

(4)启动ES:运行bin目录下的elasticsearch.bat文件

(5)测试访问:http://127.0.0.1:9200

        从运行结果我们可以发现即使是单机情况下,ElasticSearch也是集群形式的,且集群名称默认是elasticsearch。

5.2 ElasticSearch-Head插件安装

在这里我们采用本地web项目安装。安装步骤如下:

(1)下载并解压(版本一致,这里不过多赘述)

(2)在根目录下打开cmd窗口

(3)运行 cnpm install 命令
(4)执行启动命令  npm run start
(5)测试访问  http://127.0.0.1:9100/ ,此时我们发现在连接ES时会出现跨域的问题,需要配置ElasticSearch的跨域,不然不能访问。

修改elasticsearch.yml文件:

  • http.cors.enabled: true
  • http.cors.allow-origin: "*"

 (6)重启ES进行访问,此处我已经创建了几个索引:

我们来对上图做一个简单的解析:

  • 索引 可以看做 “数据库”

  • 类型 可以看做 “表”

  • 文档 可以看做 “库中的数据(表中的行)”

        这里的这个Head,我们只是把它当做可视化数据展示工具,可以查看索引和里面的数据,但是因为不支持json格式化,不方便;所以我们之后所有的查询都在kibana中进行。

5.3   ElasticSearch的可视化工具Kibana安装

        Kibana是一个针对ElasticSearch的开源分析及可视化平台,用来搜索、查看交互存储在Elasticsearch索引中的数据。使用Kibana,可以通过各种图表进行高级数据分析及展示。Kibana让海量数据更容易理解。它操作简单,基于浏览器的用户界面可以快速创建仪表板( dashboard )实时显示Elasticsearch查询动态。设置Kibana非常简单。无需编码或者额外的基础架构,几分钟内就可以完成Kibana安装并启动Elasticsearch索引监测。

(1)下载解压即可使用(版本一致性)

(2)修改config/kibana.yml,进行kibana汉化

// 在kibana.yml的116行
il8n.locale: "zh-CN"
// 启动
kibana.bat
// 访问
http://127.0.0.1:5601/

(3)运行bin目录下的kibana.bat文件

5.4 IK分词器插件

IK提供了两个分词算法: ik_smart和ik_max_word

  • ik_smart为最少切分
  • ik_max_word为最细粒度划分(穷尽词库的可能)

(1)下载并解压(版本一致性),这里要注意的的解压存放的位置。

(2)这时候重启ES可以发现ik分词器插件被加载。

(3)使用Kibana测试IK分词器

最少切分:

GET _analyze
{
  "analyzer":"ik_smart",
  "text":"勇敢牛牛,不怕困难"
}

 最细粒度划分:

GET _analyze
{
  "analyzer":"ik_max_word",
  "text":"勇敢牛牛,不怕困难"
}

(4)扩展词条、停用词条

  • 利用config目录的IkAnalyzer.cfg.xml文件添加拓展词典和停用词典

  • 在词典中添加拓展词条或者停用词条

5.5 拼音分词器插件

(1)下载并解压(版本一致性),这里要注意的的解压存放的位置。

 (2)这时候重启ES可以发现 pinyin分词器插件被加载。

 6、ES核心概念

  1. ElasticSearch是面向文档,一切都是JSON!
  2. Elasticsearch(集群)中可以包含多个索引(数据库),每个索引中可以包含多个类型(表),每个类型下又包含多个文档(行),每个文档中又包含多个字段(列)

Relational DBElasticSearch
数据库(database)索引(indices)
表(tables)types (慢慢会被弃用)
行(rows)documents
字段(columns)fields

 6.1 物理设计

        Elasticsearch在后台把每个索引划分成多个分片,每个分片可以在集群中的不同服务器间迁移,一个ES就是一个集群! 即启动的ElasticSearch服务,默认就是一个集群,且默认集群名为elasticsearch。
        一个集群至少有一个节点,而一个节点就是一个Elasricsearch进程,节点可以有多个索引;默认情况下,如果你创建索引,那么索引将会有个5个分片(primary shard ,又称主分片)构成的,每一个主分片会有一个副本(replica shard,又称复制分片)


        上图是一个有3个节点的集群,可以看到主分片P和对应的复制分片R都不会在同一个节点内,这样有利于某个节点挂掉了,数据也不至于丢失。实际上,一个分片是一个Lucene索引(一个ElasticSearch索引包含多个Lucene索引),一个包含倒排索引的文件目录,倒排索引的结构使得Eelasticsearch在不扫描全部文档的情况下,就能告诉你哪些文档包含特定的关键字。

6.2 倒排索引

        倒排索引的概念是基于MySQL这样的正向索引而言的。那么什么是正向索引呢?

(1)正向索引

       如果是根据id查询,那么直接走索引,查询速度非常快。但如果是基于title做模糊查询,只能是逐行扫描数据,流程如下:

        1)用户搜索数据,条件是title符合"%手机%"

        2)逐行获取数据,比如id为1的数据

        3)判断数据中的title是否符合用户搜索条件

        4)如果符合则放入结果集,不符合则丢弃。回到步骤1

        如上如果基于title就需要逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。

(2)倒排索引(将文档拆分成一个个的词条)

倒排索引中有两个非常重要的概念:文档、词条(后面我们会遇到,这里了解一下这个概念)

  • 文档(Document):用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息。

  • 词条(Term):对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条。

创建倒排索引是对正向索引的一种特殊处理,流程如下:

  • 将每一个文档的数据利用算法分词(分词器),得到一个个词条

  • 创建表,每行数据包括词条、词条所在文档id、位置等信息

  • 因为词条唯一性,可以给词条创建索引,例如hash表结构索引

倒排索引的搜索流程:(以搜索"华为手机"为例):

1)用户输入条件"华为手机"进行搜索。

2)对用户输入内容分词,得到词条:华为手机

3)拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3。

4)拿着文档id到正向索引中查找具体文档。

         虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档id都建立了索引,这样的话查询速度就非常快!无需进行全表扫描。

正向索引和倒排索引的区别在哪?

  • 正向索引是根据id索引的方式。当根据词条查询时,必须先逐条获取每个文档,进行全表扫描,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程

  • 倒排索引则相反,先找到用户要搜索的词条,根据词条得到词条所在的文档的id,然后根据id获取文档。是根据词条找文档的过程

6.3 文档和字段

        (1)在前面我们提过ES是面向文档的,那么这就说明索引和搜索数据的最小单位是文档;这里的文档可以是数据库中的一条商品数据,一个订单信息。在向ES进行存储时,文档数据会被序列化为json格式。

        (2)json格式的数据中包含有若干个字段,其实也就相当于我们数据库中的列。

 6.4 索引和映射

        (1)ES中索引是一个比较重要的概念,它指的是相同类型文档的集合。学习ES也是基于项目中对人员和部门开发的需要,这里就需要建立人员和部门两个索引(这里以用户为例):

  所有用户文档组织在一起,构成用户的索引。

        (2)映射就是创建索引时指定都包含哪些字段以及字段的数据类型、分词器等一些设置。(如果你仅仅是想完成这样的一个功能,ES的DSL可以不用过多了解,后面我们直接套用模板)

        常见的mapping属性(做一下了解,后面在进行DSL操作时要求能够看懂)

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

    • 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)

    • 数值:long、integer、short、byte、double、float、

    • 布尔:boolean

    • 日期:date

    • 对象:object

  • index:是否创建索引,默认为true

  • analyzer:使用哪种分词器

  • properties:该字段的子字段

7、Rest风格说明

        一种软件架构风格,而不是标准,只是提供了一组设计原则和约束条件。它主要用于客户端和服务器交互类的软件。基于这个风格设计的软件可以更简洁更有层次更易于实现缓存等机制。

8、 mysql和elasticsearch对比

        前面我们大致上了解了什么是ES,下面我们来看一下它和mysql有什么区别呢,是不是学会了ES我们就可以抛弃mysql了?

·        事实上并非如此,两者各有各的优势,在工作中往往是两者结合使用:

        (1)Mysql:擅长事务类型操作,可以确保数据的安全和一致性

        (2)Elasticsearch:擅长海量数据的搜索、分析、计算

        那么具体什么情况下用MySQL,什么情况下用ES呢?

        (1)对安全性要求较高的写操作,使用mysql实现

        (2)对查询性能要求较高的搜索需求,使用elasticsearch实现

MySQLElasticsearch说明
TableIndex索引(index),就是文档的集合,类似数据库的表(table)
RowDocument文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
ColumnField字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
SchemaMappingMapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQLDSLDSL是elasticsearch提供的JSON风格的请求语句,用来操作elasticsearch,实现CRUD

9、通过Kibana操作文档和索引库

9.1 索引库操作

9.2 文档操作

10、SpringBoot整合ES【环境搭建

(1)创建数据库、建表,数据结构如下:

CREATE TABLE `tb_hotel` (
  `id` bigint(20) NOT NULL COMMENT '酒店id',
  `name` varchar(255) NOT NULL COMMENT '酒店名称',
  `address` varchar(255) NOT NULL COMMENT '酒店地址',
  `price` int(10) NOT NULL COMMENT '酒店价格',
  `score` int(2) NOT NULL COMMENT '酒店评分',
  `brand` varchar(32) NOT NULL COMMENT '酒店品牌',
  `city` varchar(32) NOT NULL COMMENT '所在城市',
  `star_name` varchar(16) DEFAULT NULL COMMENT '酒店星级',
  `business` varchar(255) DEFAULT NULL COMMENT '商圈',
  `latitude` varchar(32) NOT NULL COMMENT '纬度',
  `longitude` varchar(32) NOT NULL COMMENT '经度',
  `pic` varchar(255) DEFAULT NULL COMMENT '酒店图片',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

(2)建Moudle、改pom、主启动、yml、业务类(个人编程习惯),这里仅介绍一些重点内容。

    改POM:

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
<properties>
    <java.version>1.8</java.version>
    <elasticsearch.version>7.6.2</elasticsearch.version>
</properties>

    主启动:

@MapperScan("cn.itcast.hotel.mapper")
@SpringBootApplication
public class HotelDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(HotelDemoApplication.class, args);
    }

    @Bean
    public RestHighLevelClient restHighLevelClient(){
        return new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://127.0.0.1:9200")
        ));
    }
}

11、在SpringBoot中操作索引库

操作索引库的代码整体上分为三步:

         1)创建Request对象。

         2)添加请求参数,其实就是DSL的JSON参数部分。因为json字符串很长,这里是定义了静态字符串常量MAPPING_TEMPLATE,让代码看起来更加优雅。

         3)发送请求,client.indices()方法的返回值是IndicesClient类型,封装了所有与索引库操作有关的方法。

 准备工作:初始化代码同时注入RestHighLevelClie

//索引库的增删改查
@SpringBootTest
class HotelIndexTest {

    @Resource
    private RestHighLevelClient client;

    //初始化代码
    @BeforeEach
    void setUp() {
        client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://127.0.0.1:9200")
        ));
    }
    @AfterEach
    void tearDown() throws IOException {
        client.close();
    }
}

创建一个类,定义mapping映射的JSON字符串常量:

//定义mapping映射的JSON字符串常量
public class HotelIndexConstants {
    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" +
            "      \"pic\": {\n" +
            "        \"type\": \"keyword\",\n" +
            "        \"index\": false\n" +
            "      },\n" +
            "      \"location\": {\n" +
            "        \"type\": \"geo_point\"\n" +
            "      },\n" +
            "      \"all\": {\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\"\n" +
            "      }\n" +
            "    }\n" +
            "  }\n" +
            "}";
}

11.1 创建索引

//创建索引库
    @Test
    void testCreateIndex() throws IOException {
        // 1.创建request对象      PUT /hotel
        CreateIndexRequest request = new CreateIndexRequest("hotel");
        // 2.准备请求参数 ,MAPPING_TEMPLATE是静态常量字符串,在cn.itcast.hotel.constants中已经声明,里面内容是创建索引库的DSL语句
        request.source(MAPPING_TEMPLATE, XContentType.JSON);
        // 3.发送请求 ,client.indices()方法来获取索引库的操作对象
        client.indices().create(request, RequestOptions.DEFAULT);
    }

11.2 删除索引

 //删除索引库
    @Test
    void testDeleteIndex() throws IOException {
        // 1.准备Request
        DeleteIndexRequest request = new DeleteIndexRequest("hotel");
        // 2.发送请求
        client.indices().delete(request, RequestOptions.DEFAULT);
    }

11.3 判断索引是否存在

//判断索引库是否存在
    @Test
    void testExistsIndex() throws IOException {
        // 1.准备Request
        GetIndexRequest request = new GetIndexRequest("hotel");
        // 2.发送请求
        boolean isExists = client.indices().exists(request, RequestOptions.DEFAULT);

        System.out.println(isExists ? "存在" : "不存在");
    }

12、在SpringBoot中操作文档【重要】

        为了与索引库操作分离,在这里我们另外在写一个测试类来对存储在数据库中的数据进行系列化的操作。首先我们来做一下准备工作:

  • 初始化RestHighLevelClient

  • 新建一个Hotel实体类

  • 我们的酒店数据在数据库,需要利用IHotelService去查询,所以注入这个接口

 新建一个Hotel实体类:

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("tb_hotel") //实体类和数据库中的表实现映射
public class Hotel {
    @TableId(type = IdType.INPUT)
    private Long id;//酒店id
    private String name;//酒店名称
    private String address;//酒店地址
    private Integer price;//酒店价格
    private Integer score;//酒店评分
    private String brand;//酒店品牌
    private String city;//所在城市
    private String star_name;//酒店星级
    private String business;//商圈
    private String longitude;//经度
    private String latitude;//纬度
    private String pic;//酒店图片
}

        可以看出,这个实体类与我们定义的索引库是存在一些差异的,有几个特殊字段需要在这里说明一下:

  • location:地理坐标,里面包含精度、纬度

  • all:一个组合字段,其目的是将多字段的值 利用copy_to合并,提供给用户进行搜索

 所以我们需要在此基础上定义一个新的实体类来与索引库进行衔接:

@Data
@AllArgsConstructor
@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;
}

初始化操作:

@SpringBootTest
public class HotelDocumentTest {
    @Autowired
    private IHotelService hotelService;

    private RestHighLevelClient client;

    @BeforeEach
    void setUp() {
        this.client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://192.168.150.101:9200")
        ));
    }

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

12.1 新增文档

我们这里的目的是 将数据库中的数据查询出来存储到ES索引库中。

新增文档的Java代码:【这里面对应的查询语句比较简单,这里就不过多赘述】整体步骤如下:

//新增文档,将某条数据从数据库中查询出来然后添加到elasticsearch中
    @Test
    void testAddDocument() throws IOException {
        // 1.查询数据库hotel数据,返回一个Hotel对象
        Hotel hotel = hotelService.getById(2062643523L);
        // 2.转换为和索引库对应的实体类
        HotelDoc hotelDoc = new HotelDoc(hotel);
        // 3.转JSON
        String json = JSON.toJSONString(hotelDoc);

        // 4.准备Request
        IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
        // 5.准备请求参数DSL,其实就是文档的JSON字符串
        request.source(json, XContentType.JSON);
        // 6.发送请求
        client.index(request, RequestOptions.DEFAULT);
    }

12.2 修改文档

修改存在两种方式:

  • 全量修改:本质上是先根据id删除,然后再新增

  • 增量修改:修改文档中的指定字段值【重点关注】

在RestClient的API中,全量修改与新增的API完全一致,其判断依据是ID:

  • 如果修改时,ID已经存在,则修改。

  • 如果修改时,ID不存在,则新增。

修改文档的Java 代码:

//根据id修改文档中的某条记录
    @Test
    void testUpdateById() throws IOException {
        // 1.准备Request
        UpdateRequest request = new UpdateRequest("hotel", "61083");
        // 2.准备参数
        request.doc(
                "price", "870"
        );
        // 3.发送请求
        client.update(request, RequestOptions.DEFAULT);
    }

12.3 查询文档

查询文档的 Java代码:

//根据id查询文档中的具体数据
    @Test
    void testGetDocumentById() throws IOException {
        // 1.准备Request      // GET /hotel/_doc/{id}
        GetRequest request = new GetRequest("hotel", "61083");
        // 2.发送请求
        GetResponse response = client.get(request, RequestOptions.DEFAULT);
        // 3.解析响应结果,返回json字符串
        String json = response.getSourceAsString();
        // 4.转换为HotelDoc类型的对象
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println("hotelDoc = " + hotelDoc);
    }

12.4 删除文档

删除文档的 Java 代码:

//根据id删除文档中的某条记录
    @Test
    void testDeleteDocumentById() throws IOException {
        // 1.准备Request      // DELETE /hotel/_doc/{id}
        DeleteRequest request = new DeleteRequest("hotel", "61083");
        // 2.发送请求
        client.delete(request, RequestOptions.DEFAULT);
    }

12.5 批量导入文档

操作步骤如下:

  • 利用mybatis-plus查询酒店数据

  • 将查询到的酒店数据(Hotel)转换为索引库类型数据(HotelDoc)

  • 利用JavaRestClient中的BulkRequest批处理,实现批量新增文档

//批量导入文档,利用BulkRequest批量将数据库数据导入到索引库中
    @Test
    void testBulkRequest() throws IOException {
        // 查询所有的酒店数据
        List<Hotel> list = hotelService.list();
        // 1.准备Request,创建Bulk请求
        BulkRequest request = new BulkRequest();
        // 2.准备参数,添加多个新增的Request
        for (Hotel hotel : list) {
            // 2.1.转为文档类型HotelDoc
            HotelDoc hotelDoc = new HotelDoc(hotel);
            // 2.2.转json
            String json = JSON.toJSONString(hotelDoc);
            // 2.3.添加要批量提交的请求,创建新增文档的Request对象
            request.add(new IndexRequest("hotel")
                    .id(hotel.getId().toString())
                    .source(json, XContentType.JSON));
        }
        // 3.发送请求
        client.bulk(request, RequestOptions.DEFAULT);
    }

13、DSL查询文档

查询的语法基本一致,除了match_all查询,其它查询无非就是查询类型查询条件发生变化

GET /indexName/_search
{
  "query": {
    "查询类型": {
      "查询条件": "条件值"
    }
  }
}

13.1 查询所有数据(match_all)

// 查询所有,没有查询条件
GET /indexName/_search
{
  "query": {
    "match_all": {
    }
  }
}

13.2 全文检索查询

        全文检索查询利用了我们前面提到过的分词器,先分词,再利用倒排索引去索引库中进行匹配。全文检索查询的基本步骤如下:

  • 1)对用户搜索的内容做分词,得到词条

  • 2)根据词条去倒排索引库中匹配,得到文档id

  • 3)根据文档id找到文档,返回给用户

全文检索查询通常情况下包括如下两种:

  • 1)match查询:单字段查询

GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  }
}
  • 2)multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件

GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query": "TEXT",
      "fields": ["FIELD1", " FIELD12"]
    }
  }
}

参与查询字段越多,查询性能越差 ,因此在搜索字段比较多的情况下建议采用copy_to的方式。

13.3 精确查询

精确查询一般是查找keyword、数值、日期、boolean等不会分词类型的字段,常见方式:

  • term:根据词条精确值查询(例如web页面的头部标签,相当于条件过滤的作用)

  • range:根据数值范围查询,可以是数值、日期的范围

(1)term查询

// term查询
GET /indexName/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}

 (2)range查询

范围查询,一般应用在对数值类型做范围过滤的时候。比如京东淘宝中做价格范围过滤。

// range查询
GET /indexName/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte": 10, // 这里的gte代表大于等于,gt则代表大于
        "lte": 20 // lte代表小于等于,lt则代表小于
      }
    }
  }
}

13.4 地理坐标查询(geo_distance

        在实体类中我们已经定义了经纬度,这里的地理坐标查询就是根据经纬度来进行查询。根据前面学习的知识,我们很容易明白附近查询就是查询到指定中心点小于某个距离值的所有文档。

常见的应用场景:

  • 美团:搜索我附近的酒店

  • 滴滴:搜索我附近的车

  • 微信:搜索我附近的人

// geo_distance 查询
GET /indexName/_search
{
  "query": {
    "geo_distance": {
      "distance": "15km", // 半径
      "FIELD": "31.21,121.5" // 圆心
    }
  }
}

13.5 复合(compound)查询【重要】

复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。

  • fuction_score:算分函数查询,可以控制文档相关性算分,控制文档排名

  • bool_query:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索

(1)相关性算分

        当我们利用match查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。【这是ES默认的一种打分机制】

        ES 5.1版本以后采用的是BM25打分机制,它会根据词条和文档的相关度进行打分,此外单个词条的打分也会有一个上限,并不会因为词条频率而决定最后的打分结果。

(2)算分函数查询

        根据相关度打分其实是比较合理的需求,但实际生活中合理的不一定是产品经理需要的。有时候在你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱越多谁的排名越靠前。

        要想人为的控制相关性算分,就需要利用elasticsearch中的function_score 查询。

        function_score 查询中包含四部分内容:

function score的运行步骤如下:

  • 1)根据原始条件查询搜索文档,并且计算相关性算分,称为原始算分(query score)

  • 2)根据过滤条件,过滤文档

  • 3)符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)

  • 4)将原始算分(query score)和函数算分(function score)基于运算模式做运算,得到最终结果,作为相关性算分。

相关性算分关键点:

  • 过滤条件:决定哪些文档参与函数算分(function score)

  • 算分函数:决定函数算分的算法

  • 运算模式:决定最终算分结果

(3)示例:给“如家”这个品牌的酒店排名靠前一些

  • 原始条件:不确定,可以任意变化

  • 过滤条件:brand = "如家"

  • 算分函数:可以简单粗暴,直接给固定的算分结果,weight

  • 运算模式:比如求和

最终的DSL语句如下:

GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {  .... }, // 原始查询,可以是任意条件
      "functions": [ // 算分函数
        {
          "filter": { // 满足的条件,品牌必须是如家
            "term": {
              "brand": "如家"
            }
          },
          "weight": 2 // 算分权重为2
        }
      ],
      "boost_mode": "sum" // 加权模式,求和
    }
  }
}

 (4)布尔查询

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

  • must:必须匹配每个子查询,类似“与”

  • should:选择性匹配子查询,类似“或”

  • must_not:必须不匹配,不参与算分,类似“非”

  • filter:必须匹配,不参与算分

GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海" }}
      ],
      "should": [
        {"term": {"brand": "皇冠假日" }},
        {"term": {"brand": "华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte": 500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte": 45 } }}
      ]
    }
  }
}

项目需求:除了关键字搜索外,我们还可能根据品牌、价格、城市等字段进行过滤

        这里的每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用到我们这里所说的bool查询。

        这里需要注意的一点是,在搜索时,如果参与打分的字段越多,那么查询的性能也越差。因此遇到这种多条件查询时,建议做如下操作:

  • 搜索框的关键字搜索,使用must查询,参与算分

  • 其它过滤条件,采用filter、must_not查询,不参与算分

(5)示例:搜索名字包含“如家”,价格不高于400,在坐标31.21,121.5周围10km范围内的酒店。

  • 名称搜索,属于全文检索查询,应该参与算分。放到must中

  • 价格不高于400,用range查询,属于过滤条件,不参与算分。放到must_not中

  • 周围10km范围内,用geo_distance查询,属于过滤条件,不参与算分。放到filter中

14、DSL搜索结果处理

14.1 排序

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

(1)普通字段排序

        注意:排序条件是一个数组,也就是可以写多个排序条件。按照声明的顺序,当第一个条件相等时,再按照第二个条件排序,以此类推。

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc"  // 排序字段、排序方式ASC、DESC
    }
  ]
}

(2)地理坐标排序

  • 指定一个坐标,作为目标点

  • 计算每一个文档中,指定字段(必须是geo_point类型)的坐标 到目标点的距离是多少

  • 根据距离排序

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance" : {
          "FIELD" : "纬度,经度", // 文档中geo_point类型的字段名、目标坐标点
          "order" : "asc", // 排序方式
          "unit" : "km" // 排序的距离单位
      }
    }
  ]
}

14.2 分页

        elasticsearch 默认情况下只返回前10的数据。如果要查询更多数据就需要修改分页参数。elasticsearch中通过修改from、size参数来控制要返回的分页结果:

  • from:从第几个文档开始

  • size:总共查询几个文档(每页返回几条)

(1)基本的分页

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 0, // 分页开始的位置,默认为0
  "size": 20, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}

(2)深度分页【有需要的可以去了解一下,这里不做过多赘述】

14.3 高亮

实现步骤:

  • 1)给文档中的所有关键字都添加一个标签,例如<em>标签

  • 2)页面给<em>标签编写CSS样式

注意:

  • 1)高亮一定要使用全文检索查询
  • 2)高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。

  • 3)默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮

  • 4)如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false

GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
    }
  },
  "highlight": {
    "fields": { // 指定要高亮的字段
      "FIELD": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

15、RestClient查询文档

 准备工作:创建一个测试类进行初始化操作并注入RestHighLevelClient 。

执行步骤如下:

  • 1)创建SearchRequest对象,指定索引库名

  • 2)利用request.source().query()构建DSL,DSL中可以包含查询、分页、排序、高亮等,其中query()代表查询条件,利用QueryBuilders.xxxQuery()构建一个xxx查询的DSL。

  • 3)利用client.search()发送请求,得到响应

  • 4)解析响应

//RestClient查询文档
@SpringBootTest
class HotelSearchTest {

    private RestHighLevelClient client;

    //解析响应
    private void handleResponse(SearchResponse response) {
        // 4.解析响应
        SearchHits searchHits = response.getHits();
        // 4.1.总条数
        long total = searchHits.getTotalHits().value;
        System.out.println("总条数:" + total);
        // 4.2.获取文档数组
        SearchHit[] hits = searchHits.getHits();
        // 4.3.遍历
        for (SearchHit hit : hits) {
            // 4.4.获取source
            String json = hit.getSourceAsString();
            // 4.5.反序列化,非高亮
            HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
            // 4.6.处理高亮结果
            // 1)获取高亮map
            Map<String, HighlightField> map = hit.getHighlightFields();
            // 2)根据字段名,获取高亮结果
            HighlightField highlightField = map.get("name");
            if (highlightField!=null){
                // 3)获取高亮结果字符串数组中的第1个元素
                String hName = highlightField.getFragments()[0].toString();
                // 4)把高亮结果放到HotelDoc中
                hotelDoc.setName(hName);
            }
            // 4.7.打印
            System.out.println(hotelDoc);
        }
    }
    @BeforeEach
    void setUp() {
        client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://127.0.0.1:9200")
        ));
    }
    @AfterEach
    void tearDown() throws IOException {
        client.close();
    }
}

15.1 match_all查询

//match_all查询
    @Test
    void testMatchAll() throws IOException {
        // 1.准备request,指定索引库名
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备请求参数,利用`request.source()`构建DSL,DSL中可以包含查询、分页、排序、高亮等。利用QueryBuilders.matchAllQuery()构建一个match_all查询的DSL
        request.source().query(QueryBuilders.matchAllQuery());//`QueryBuilders`包含了match、term、function_score、bool等各种查询
        // 3.发送请求,得到响应
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.结果解析
        handleResponse(response);
    }

15.2 全文检索查询

//全文检索查询
    @Test
    void testMatch() throws IOException {
        // 1.准备request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备请求参数
        // 1)单字段查询:match查询
        // request.source().query(QueryBuilders.matchQuery("all", "外滩如家"));
        // 2)多字段查询 :multi_match查询,任意一个字段符合条件就算符合查询条件
        request.source().query(QueryBuilders.multiMatchQuery("如家", "name", "business"));
        // 3.发送请求,得到响应
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.结果解析
        handleResponse(response);
    }

15.3 精准查询

//精准查询termQuery【词条精准匹配】,range【范围查询】
    @Test
    void testBool() throws IOException {
        // 1.准备request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备请求参数
         BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        // 2.1.must
        boolQuery.must(QueryBuilders.termQuery("city", "杭州"));
        // 2.2.filter
        boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
        // 3.发送请求,得到响应
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.结果解析
        handleResponse(response);
    }

15.4 布尔查询

//布尔查询
    @Test
    void testBool() throws IOException {
        // 1.准备request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备请求参数
        request.source().query(
                QueryBuilders.boolQuery()
                        .must(QueryBuilders.termQuery("city", "杭州"))
                        .filter(QueryBuilders.rangeQuery("price").lte(250))
        );
        // 3.发送请求,得到响应
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.结果解析
        handleResponse(response);
    }

15.5 排序、分页

//排序、分页
    //搜索结果的排序和分页是与query同级的参数,因此同样是使用request.source()来设置
    @Test
    void testSortAndPage() throws IOException {
        //页码  每页大小
        int page = 2,size = 5;

        // 1.准备request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备请求参数
        // 2.1.query
        request.source()
                .query(QueryBuilders.matchAllQuery());
        // 2.2.排序sort
        request.source().sort("price", SortOrder.ASC);
        // 2.3.分页 from\size
        request.source().from((page - 1) * size).size(size);

        // 3.发送请求,得到响应
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.结果解析
        handleResponse(response);
    }

15.6 高亮

//高亮查询
    //高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字高亮。
    @Test
    void testHighlight() throws IOException {
        // 1.准备request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备请求参数
        // 2.1.query
        request.source().query(QueryBuilders.matchQuery("all", "如家"));
        // 2.2.高亮
        request.source().highlighter(new HighlightBuilder().field("name").requireFieldMatch(false));
        // 3.发送请求,得到响应
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.结果解析
        handleResponse(response);
    }

16、数据聚合

        聚合可以使得数据的统计、分析、运算变得更加的简单。比如什么品牌的手机最受欢迎、这些手机的平均价格、销售情况........对于ES来说,实现这些功能要比数据库的SQL方便的多,而且查询速度也非常快,可以实现近实时搜索的效果。

16.1 Bucket聚合语法【对文档分组】

        (1)举个栗子:现在我们要统计所有数据中的酒店品牌有几种,其实就是按照品牌对数据分组。此时可以根据酒店品牌的名称做聚合,也就是Bucket聚合。

GET /hotel/_search
{
  "size": 0,  // 设置size为0,结果中不包含文档,只包含聚合结果
  "aggs": { // 定义聚合
    "brandAgg": { //给聚合起个名字
      "terms": { // 聚合的类型,按照品牌值聚合,所以选择term
        "field": "brand", // 参与聚合的字段
        "size": 20 // 希望获取的聚合结果数量
      }
    }
  }
}

         (2)聚合结果排序:在默认情况下,Bucket聚合会统计Bucket内的文档数量,记为count,并且按照count的数量进行降序排序,此时我们可以指定order属性,自定义聚合的排序方式:

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "order": {
          "_count": "asc" // 按照_count升序排列
        },
        "size": 20
      }
    }
  }
}

        (3)限定聚合范围:在默认情况下,Bucket聚合是对索引库的所有文档做聚合,但真实场景下,用户会输入搜索条件,因此聚合必须是对搜索结果聚合。那么我们在聚合时就必须要添加限定条件。

        这个在ES中也给出了解决方案,我们可以限定要聚合的文档范围,只要添加query条件即可:

GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 200 // 只对200元以下的文档聚合
      }
    }
  }, 
  "size": 0, 
  "aggs": {
    "brandAgg": {
      "terms": {
        "field": "brand",
        "size": 20
      }
    }
  }
}

16.2 Metric聚合语法【度量】

        在16.1中我们按照品牌分组后形成了一个个的桶,那么如果我们需要对桶内的酒店做运算,获取每个品牌的用户评分的min、max、avg等值。这就要用到Metric聚合了,通过Metric聚合:就可以获取min、max、avg等值。

GET /hotel/_search
{
  "size": 0, 
  "aggs": {
    "brandAgg": { 
      "terms": { 
        "field": "brand", 
        "size": 20
      },
      "aggs": { // 是brand聚合的子聚合,也就是分组后对每组分别计算
        "score_stats": { // 聚合名称
          "stats": { // 聚合类型,这里stats可以计算min、max、avg、sum等值
            "field": "score" // 聚合字段,这里是score
          }
        }
      }
    }
  }
}

此外,我们还可以对聚合的结果依据指定字段值进行排序:

 16.3 RestApi实现聚合

(1)聚合条件与query条件同级别,因此需要使用request.source()来指定聚合条件。

(2)聚合的结果与查询结果不同,API也比较特殊,不过同样是JSON逐层解析。

         (3)业务需求:当前页面的城市列表、星级列表、品牌列表都是写死的,并不会随着搜索结果的变化而变化。但是用户搜索条件改变时,我们需要的是搜索结果会跟着变化。比如我点击了济南,那么城市列表中就不应该再 展示其他城市的信息了。

        换句话说,搜索结果中包含哪些城市,页面就应该列出哪些城市;搜索结果中包含哪些品牌,页面就应该列出哪些品牌。

        解决方案:使用聚合功能,利用Bucket聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了。

        这里需要注意的一点是:因为是对搜索结果聚合,因此聚合是限定范围的聚合,也就是说聚合的限定条件跟搜索文档的条件一致。

        (4)业务实现:

        在controller中添加一个方法,请求参数为RequestParams(封装的实体类),返回值类型 Map<String, List<String>>

RequestParams(封装的实体类):标记前端请求参数的实体

/**
 * 标记前端的请求参数实体
 * */

@Data
@AllArgsConstructor
@NoArgsConstructor
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 minPrice; //价格区间(range)
    private Integer maxPrice;
    private String location; // 当前地理坐标

}

Controller层代码:

@PostMapping("filters")
    public Map<String, List<String>> getFilters(@RequestBody RequestParams params){
        return hotelService.getFilters(params);
    }

Service层接口方法:

Map<String, List<String>> filters(RequestParams params);

ServiceImpl接口实现类:

    @Override
    public Map<String, List<String>> getFilters(RequestParams params) {
        try {
            // 1.准备请求
            SearchRequest request = new SearchRequest("hotel");
            // 2.请求参数
            // 2.1.query
            buildBasicQuery(params, request);
            // 2.2.size
            request.source().size(0);
            // 2.3.聚合
            buildAggregations(request);
            // 3.发出请求
            SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
            // 4.解析结果
            Aggregations aggregations = response.getAggregations();
            Map<String, List<String>> filters = new HashMap<>(3);
            // 4.1.解析品牌
            List<String> brandList = getAggregationByName(aggregations, "brandAgg");
            filters.put("brand", brandList);
            // 4.1.解析城市
            List<String> cityList = getAggregationByName(aggregations, "cityAgg");
            filters.put("city", cityList);
            // 4.1.解析星级
            List<String> starList = getAggregationByName(aggregations, "starAgg");
            filters.put("starName", starList);

            return filters;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
//构造条件进行关键字的查询和条件过滤【keyword类型用term查询,数值类型用range进行查询】
    //多个查询条件组合,肯定是通过boolean查询来进行组合【关键字放到must中参与算分,过滤条件放到filter中不参与算分】
    private void buildBasicQuery(RequestParams params, SearchRequest request) throws IOException {
        // 1.准备Boolean查询
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        // 1.1.关键字搜索,match查询,放到must中
        String key = params.getKey();
        if (StringUtils.isNotBlank(key)) {

            //将获取到的关键字放入到关键字索引库中保存
            User user = new User();
            user.setKeywords(key);
            userService.insertKeyWords(user);
            //获取当前插入记录id
            Integer user_id = user.getUser_id();
            System.out.println(user_id);
            // 1.查询数据库user数据,返回一个user对象
            User byId = userService.getById(user_id);
            // 3.转JSON
            String json = JSON.toJSONString(byId);
            // 4.准备Request
            IndexRequest request1 = new IndexRequest("history_keywords").id(byId.getUser_id().toString());
            // 5.准备请求参数DSL,其实就是文档的JSON字符串
            request1.source(json, XContentType.JSON);
            // 6.发送请求
            restHighLevelClient.index(request1, RequestOptions.DEFAULT);

            // 不为空,根据关键字查询
            boolQuery.must(QueryBuilders.matchQuery("all", key));
            request.source().highlighter(new HighlightBuilder().field("name").field("address").field("business").requireFieldMatch(false));
        } else {
            // 为空,查询所有
            boolQuery.must(QueryBuilders.matchAllQuery());
        }

        // 1.2.品牌
        String brand = params.getBrand();
        if (StringUtils.isNotBlank(brand)) {
            //不为空,根据对应的品牌进行过滤
            boolQuery.filter(QueryBuilders.termQuery("brand", brand));
        }
        // 1.3.城市
        String city = params.getCity();
        if (StringUtils.isNotBlank(city)) {
            boolQuery.filter(QueryBuilders.termQuery("city", city));
        }
        // 1.4.星级
        String starName = params.getStarName();
        if (StringUtils.isNotBlank(starName)) {
            boolQuery.filter(QueryBuilders.termQuery("starName", starName));
        }
        // 1.5.价格范围
        Integer minPrice = params.getMinPrice();
        Integer maxPrice = params.getMaxPrice();
        if (minPrice != null && maxPrice != null) {
            maxPrice = maxPrice == 0 ? Integer.MAX_VALUE : maxPrice;
            boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice));
        }

        // 2.算分函数查询
        FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
                boolQuery, // 原始查询,boolQuery,相关性算分的查询
                new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{ // function数组
                        // 其中的一个function score 元素
                        new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                                QueryBuilders.termQuery("isAD", true), // 过滤条件
                                ScoreFunctionBuilders.weightFactorFunction(10) // 算分函数
                        )
                }
        );

        // 3.设置查询条件,放入source
        request.source().query(functionScoreQuery);
    }
private void buildAggregations(SearchRequest request) {
        request.source().aggregation(
                AggregationBuilders.terms("brandAgg").field("brand").size(100));
        request.source().aggregation(
                AggregationBuilders.terms("cityAgg").field("city").size(100));
        request.source().aggregation(
                AggregationBuilders.terms("starAgg").field("starName").size(100));
    }
private List<String> getAggregationByName(Aggregations aggregations, String aggName) {
        // 4.1.根据聚合名称,获取聚合结果
        Terms terms = aggregations.get(aggName);
        // 4.2.获取buckets
        List<? extends Terms.Bucket> buckets = terms.getBuckets();
        // 4.3.遍历
        List<String> list = new ArrayList<>(buckets.size());
        for (Terms.Bucket bucket : buckets) {
            String brandName = bucket.getKeyAsString();
            list.add(brandName);
        }
        return list;
    }

17、自动补全

        当用户在搜索框输入字符时,我们应该提示出与该字符有关的搜索项,这种根据用户输入的字母,提示完整词条的功能,就是自动补全。

        这里需要用到我们前面所安装的拼音分词器,安装步骤就不再赘述。

        重启ES测试安装是否成功:

POST /_analyze
{
  "text": "如家酒店还不错",
  "analyzer": "pinyin"
}

17.1 自定义分词器

        默认的拼音分词器会将每个汉字单独分为拼音,而我们希望的是每个词条形成一组拼音,需要对拼音分词器做个性化定制,形成自定义分词器。

声明自定义分词器的语法如下:【可以直接复用】

PUT /test
{
  "settings": {
    "analysis": {
      "analyzer": { // 自定义分词器
        "my_analyzer": {  // 分词器名称
          "tokenizer": "ik_max_word",
          "filter": "py"
        }
      },
      "filter": { // 自定义tokenizer filter
        "py": { // 过滤器名称
          "type": "pinyin", // 过滤器类型,这里是pinyin
		  "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "my_analyzer",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

17.2 自动补全查询

        安装好拼音分词器插件,接下来我们来实现自动补全这一功能。elasticsearch提供了Completion Suggester查询来实现自动补全功能。这个查询会匹配以用户输入内容开头的词条并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:

  • 参与补全查询的字段必须是completion类型。

  • 字段的内容一般是用来补全的多个词条形成的数组。

(1)创建索引库:

// 创建索引库
PUT test
{
  "mappings": {
    "properties": {
      "title":{
        "type": "completion"
      }
    }
  }
}

(2)插入下面的数据:

// 示例数据
POST test/_doc
{
  "title": ["Sony", "WH-1000XM3"]
}
POST test/_doc
{
  "title": ["SK-II", "PITERA"]
}
POST test/_doc
{
  "title": ["Nintendo", "switch"]
}

(3)查询的DSL语句:

// 自动补全查询
GET /test/_search
{
  "suggest": {
    "title_suggest": {
      "text": "s", // 关键字
      "completion": {
        "field": "title", // 补全查询的字段
        "skip_duplicates": true, // 跳过重复的
        "size": 10 // 获取前10条结果
      }
    }
  }
}

17.3 实现酒店搜索框自动补全

        (1)此时我们hotel索引库还没有设置拼音分词器,需要修改索引库中的配置。但是我们知道索引库是无法修改的,只能删除然后重新创建。

        除此之外,我们需要添加一个字段,用来做自动补全,将brand、suggestion、city等都放进去,用来作为自动补全的提示。

  • 修改hotel索引库结构,设置自定义拼音分词器

  • 修改索引库的name、all字段,使用自定义分词器

  • 索引库添加一个新字段suggestion,类型为completion类型,使用自定义的分词器

  • 给HotelDoc类添加suggestion字段,内容包含brand、business

  • 重新导入数据到hotel库

  将原有的hotel索引库删除,在kibana中重新设置一下:

// 酒店数据索引库
PUT /hotel
{
  "settings": {
    "analysis": {
      "analyzer": {
        "text_anlyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        },
        "completion_analyzer": {
          "tokenizer": "keyword",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "id":{
        "type": "keyword"
      },
      "name":{
        "type": "text",
        "analyzer": "text_anlyzer",
        "search_analyzer": "ik_smart",
        "copy_to": "all"
      },
      "address":{
        "type": "keyword",
        "index": false
      },
      "price":{
        "type": "integer"
      },
      "score":{
        "type": "integer"
      },
      "brand":{
        "type": "keyword",
        "copy_to": "all"
      },
      "city":{
        "type": "keyword"
      },
      "starName":{
        "type": "keyword"
      },
      "business":{
        "type": "keyword",
        "copy_to": "all"
      },
      "location":{
        "type": "geo_point"
      },
      "pic":{
        "type": "keyword",
        "index": false
      },
      "all":{
        "type": "text",
        "analyzer": "text_anlyzer",
        "search_analyzer": "ik_smart"
      },
      "suggestion":{
          "type": "completion",
          "analyzer": "completion_analyzer"
      }
    }
  }
}

        (2)修改HotelDoc实体,给用户添加可自动补全的suggestion,并将一些字段变成集合放到suggestion里面去。

@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;
    private List<String> suggestion; //给用户做自动补全的一些内容

    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();
        // 组装suggestion
        if(this.business.contains("/")){
            // business有多个值,需要切割
            String[] arr = this.business.split("/");
            // 添加元素
            this.suggestion = new ArrayList<>();
            this.suggestion.add(this.brand);
            Collections.addAll(this.suggestion, arr);
        }else {
            this.suggestion = Arrays.asList(this.brand, this.business);
        }
    }
}

        (3)重新执行上述所提到的批量导入数据功能,此时我们可以发现suggestion中已经包含了suggestion。

         (4)自动补全 JavaAPI如下所示

在controller中创建一个新的接口:

@GetMapping("suggestion")
public List<String> getSuggestions(@RequestParam("key") String prefix) {
    return hotelService.getSuggestions(prefix);
}

在service中添加新的方法:

List<String> getSuggestions(String prefix);

实现类 serviceImpl:

@Override
public List<String> getSuggestions(String prefix) {
    try {
        // 1.准备Request
        SearchRequest request = new SearchRequest("hotel");
        // 2.准备DSL
        request.source().suggest(new SuggestBuilder().addSuggestion(
            "suggestions",
            SuggestBuilders.completionSuggestion("suggestion")
            .prefix(prefix)
            .skipDuplicates(true)
            .size(10)
        ));
        // 3.发起请求
        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        // 4.解析结果
        Suggest suggest = response.getSuggest();
        // 4.1.根据补全查询名称,获取补全结果
        CompletionSuggestion suggestions = suggest.getSuggestion("suggestions");
        // 4.2.获取options
        List<CompletionSuggestion.Entry.Option> options = suggestions.getOptions();
        // 4.3.遍历
        List<String> list = new ArrayList<>(options.size());
        for (CompletionSuggestion.Entry.Option option : options) {
            String text = option.getText().toString();
            list.add(text);
        }
        return list;
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

18、数据同步功能

        elasticsearch中的酒店数据来自于mysql数据库,因此mysql数据发生改变时,elasticsearch也必须跟着改变,这个就是elasticsearch与mysql之间的数据同步

        那么具体要怎么去实现呢,这里有两种解决方案,一种是利用Rabmit MQ消息队列机制来对文档的增删改进行监听,另一种就是在增删改数据库的同时调用索引库中对应的方法来进行同步的更新。因为这里我们涉及到的数据量并不是特别大,所以我们先采用第二种方式对数据进行一个同步操作。

        在上述第12个模块中我们讲解了 在springboot中操作文档,这里以添加为例,删除和修改操作步骤是一样的:

        这里仅仅写了关键部分代码,其余比较简单大家可自行编写,相关代码注释前面已经详细说过就不过多阐述:

(1)HotelMapper.xml,这里执行数据库的添加操作,添加完成后返回当前插入数据的id

<mapper namespace="cn.itcast.hotel.mapper.HotelMapper">

    <insert id="insert" keyProperty="id" useGeneratedKeys="true" >

     INSERT INTO  tb_hotel (name,address,price,score,brand,city,star_name,business,latitude,longitude,pic)
     VALUES
     (#{name},#{address},#{price},#{score},#{brand},#{city},#{star_name},#{business},#{latitude},#{longitude},#{pic})
      </insert>

</mapper>

(2)编写测试类:

/**
 * @author 星悦糖
 * @createDate 2022/9/2 15:49
 */
@SpringBootTest
public class TestInsert {

    @Autowired
    private IHotelService hotelService;
    @Autowired
    private RestHighLevelClient client;
    @Test
    void contextLoads() {
    }
    //新增文档,将数据从数据库中查询出来然后添加到elasticsearch中
    @Test
    void testAddDocument() throws IOException {
        Hotel hotel1 = new Hotel();
        hotel1.setName("奥利给酒店1");
        hotel1.setAddress("济南");
        hotel1.setPrice(23);
        hotel1.setScore(46);
        hotel1.setBrand("奥利给");
        hotel1.setCity("济南");
        hotel1.setStar_name("一钻");
        hotel1.setBusiness("济南国际会展中心商圈");
        hotel1.setLatitude("23.1");
        hotel1.setLongitude("53.6");
        hotel1.setPic("https://m.tuniucdn.com/fb3/s1/2n9c/2SHUVXNrN5NsXsTUwcd1yaHKbrGq_w200_h200_c1_t0.jpg");
        hotelService.insert(hotel1);
        //获取当前插入记录id
        Long id = hotel1.getId();
        System.out.println(id);
        // 1.查询数据库hotel数据,返回一个Hotel对象
        Hotel hotel = hotelService.getById(id);
        System.out.println(hotel);
        // 2.转换为HotelDoc
        HotelDoc hotelDoc = new HotelDoc(hotel);
        // 3.转JSON
        String json = JSON.toJSONString(hotelDoc);
        System.out.println(json);

        // 1.准备Request
        IndexRequest request = new IndexRequest("hotel").id(hotelDoc.getId().toString());
        // 2.准备请求参数DSL,其实就是文档的JSON字符串
        request.source(json, XContentType.JSON);
        // 3.发送请求
        client.index(request, RequestOptions.DEFAULT);
    }
}

19、热搜词(猜你想搜)功能实现

        在第16个模块中我们已经提到过数据聚合的概念,在这次项目中我们要求的是对人员和部门进行检索,所以在这里我们借助聚合来实现一下我们的这个功能:

        实现方案:

  • 提取搜索的关键词,写入ES历史关键字索引库(history_keywords)
  • 利用Redis循环计数器只保存最近访问的历史数据
  • 聚合ES历史关键字中的所有关键字,提取出数量最多的前几个
  • 用提取的关键字作为推荐结果
  • 点击搜索框显示搜索频率最高的几个关键字

        实现代码(先将关键字存储到索引库,在对索引库执行聚合操作):

19.1 创建history_keywords索引库

启动ES和kibana,在kibana中直接执行下面的代码即可。

PUT /history_keywords
{
  "settings": {
    "analysis": {
      "analyzer": {
        "text_anlyzer": {
          "tokenizer": "ik_max_word",
          "filter": "py"
        },
        "completion_analyzer": {
          "tokenizer": "keyword",
          "filter": "py"
        }
      },
      "filter": {
        "py": {
          "type": "pinyin",
          "keep_full_pinyin": false,
          "keep_joined_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "remove_duplicated_term": true,
          "none_chinese_pinyin_tokenize": false
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "user_id":{
        "type": "keyword"
      },
      "keywords": {
        "type": "keyword"
      }
    }
  }
}

19.2 编写UserMapper.xml

目的是添加完成后返回一个id,然后同步到关键词索引库。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >

<mapper namespace="cn.itcast.hotel.mapper.UserMapper">

    <insert id="insertKeyWords" keyProperty="user_id" useGeneratedKeys="true" >

        INSERT INTO  key_words (keywords) VALUES (#{keywords})

    </insert>

</mapper>

19.3 编写controller

//根据关键字搜索酒店数据
    @PostMapping("list")
    public PageResult search(@RequestBody RequestParams params) {
        System.out.println(params);
        return hotelService.search(params);
    }

19.4 编写service(关键字插入索引库)

//根据关键字搜索酒店信息(请求参数包含用户输入的关键字信息),返回查询总数和当前页酒店的列表,并将关键字存储到关键字索引库
    PageResult search(RequestParams params);

19.5 编写service实现类(关键字插入索引库)

//根据关键字搜索酒店信息(请求参数包含用户输入的关键字信息),返回查询总数和当前页酒店的列表
    @Override
    public PageResult search(RequestParams params) {
        try {
            // 1.准备Request
            SearchRequest request = new SearchRequest("hotel");

            // 2.准备请求参数
            // 2.1.query
            buildBasicQuery(params,request);  //整个查询过程比较复杂,涉及搜索框下面的条件过滤,所以封装为一个函数进行调用

            // 2.2.分页
            int page = params.getPage();
            int size = params.getSize();
            request.source().from((page - 1) * size).size(size);

            // 2.3.按距离对周围酒店排序
            String location = params.getLocation();
            if (StringUtils.isNotBlank(location)) {
                request.source().sort(SortBuilders
                        .geoDistanceSort("location", new GeoPoint(location))//文档中geo_point类型的字段名、目标坐标点
                        .order(SortOrder.ASC) //排序方式
                        .unit(DistanceUnit.KILOMETERS) //排序的距离单位
                );
            }
            // 3.发送请求
            SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
            // 4.解析响应
            return handleResponse(response);
        } catch (IOException e) {
            throw new RuntimeException("搜索数据失败", e);
        }
    }

如果搜索的条件不为空,那么执行插入索引库的操作:

//构造条件进行关键字的查询和条件过滤【keyword类型用term查询,数值类型用range进行查询】
    //多个查询条件组合,肯定是通过boolean查询来进行组合【关键字放到must中参与算分,过滤条件放到filter中不参与算分】
    private void buildBasicQuery(RequestParams params, SearchRequest request) throws IOException {
        // 1.准备Boolean查询
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        // 1.1.关键字搜索,match查询,放到must中
        String key = params.getKey();
        if (StringUtils.isNotBlank(key)) {

            //将获取到的关键字放入到关键字索引库中保存
            User user = new User();
            user.setKeywords(key);
            userService.insertKeyWords(user);
            //获取当前插入记录id
            Integer user_id = user.getUser_id();
            System.out.println(user_id);
            // 1.查询数据库user数据,返回一个user对象
            User byId = userService.getById(user_id);
            // 3.转JSON
            String json = JSON.toJSONString(byId);
            // 4.准备Request
            IndexRequest request1 = new IndexRequest("history_keywords").id(byId.getUser_id().toString());
            // 5.准备请求参数DSL,其实就是文档的JSON字符串
            request1.source(json, XContentType.JSON);
            // 6.发送请求
            restHighLevelClient.index(request1, RequestOptions.DEFAULT);

            // 不为空,根据关键字查询
            boolQuery.must(QueryBuilders.matchQuery("all", key));
            request.source().highlighter(new HighlightBuilder().field("name").field("address").field("business").requireFieldMatch(false));
        } else {
            // 为空,查询所有
            boolQuery.must(QueryBuilders.matchAllQuery());
        }

        // 1.2.品牌
        String brand = params.getBrand();
        if (StringUtils.isNotBlank(brand)) {
            //不为空,根据对应的品牌进行过滤
            boolQuery.filter(QueryBuilders.termQuery("brand", brand));
        }
        // 1.3.城市
        String city = params.getCity();
        if (StringUtils.isNotBlank(city)) {
            boolQuery.filter(QueryBuilders.termQuery("city", city));
        }
        // 1.4.星级
        String starName = params.getStarName();
        if (StringUtils.isNotBlank(starName)) {
            boolQuery.filter(QueryBuilders.termQuery("starName", starName));
        }
        // 1.5.价格范围
        Integer minPrice = params.getMinPrice();
        Integer maxPrice = params.getMaxPrice();
        if (minPrice != null && maxPrice != null) {
            maxPrice = maxPrice == 0 ? Integer.MAX_VALUE : maxPrice;
            boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice));
        }

        // 2.算分函数查询
        FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
                boolQuery, // 原始查询,boolQuery,相关性算分的查询
                new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{ // function数组
                        // 其中的一个function score 元素
                        new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                                QueryBuilders.termQuery("isAD", true), // 过滤条件
                                ScoreFunctionBuilders.weightFactorFunction(10) // 算分函数
                        )
                }
        );

        // 3.设置查询条件,放入source
        request.source().query(functionScoreQuery);
    }

19.6 编写controller(关键字聚合)

/**
 * @author 星悦糖
 * @createDate 2022/9/5 10:55
 */
@RestController
@RequestMapping("user")
public class UserController {
    @Autowired
    private UserService userService;
    //聚合条件查询(数据聚合)
    @PostMapping("filters")
    public Map<String, List<String>> getFilters(@RequestBody User user){
        return userService.getFilters(user);
    }
}

19.7 编写service(关键字聚合操作)

/**
 * @author 星悦糖
 * @createDate 2022/9/5 9:45
 */
public interface UserService extends IService<User> {

    int insertKeyWords(User user);

    Map<String, List<String>> getFilters(User user);
}

19.8 编写service实现类(关键字聚合操作)

/**
 * @author 星悦糖
 * @createDate 2022/9/5 9:46
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @Autowired
    private RestHighLevelClient restHighLevelClient;
    @Autowired
    private UserMapper userMapper;
    //插入数据
    @Override
    public int insertKeyWords(User user) {
        return userMapper.insertKeyWords(user);
    }

    //根据查询的历史记录关键字进行过滤
    @Override
    public Map<String, List<String>> getFilters(User user) {
        try {
            // 1.准备请求
            SearchRequest request = new SearchRequest("history_keywords");
            // 2.请求参数
            // 2.1.query
            buildBasicQuery(user, request);
            // 2.2.size
            request.source().size(0);
            // 2.3.聚合
            buildAggregations(request);
            // 3.发出请求
            SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
            // 4.解析结果
            Aggregations aggregations = response.getAggregations();
            Map<String, List<String>> filters = new HashMap<>(1);
            // 4.1.解析关键字
            List<String> brandList = getAggregationByName(aggregations, "keywordsAgg");
            filters.put("keywords", brandList);

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

    private List<String> getAggregationByName(Aggregations aggregations, String aggName) {
        // 4.1.根据聚合名称,获取聚合结果
        Terms terms = aggregations.get(aggName);
        // 4.2.获取buckets
        List<? extends Terms.Bucket> buckets = terms.getBuckets();
        // 4.3.遍历
        List<String> list = new ArrayList<>(buckets.size());
        for (Terms.Bucket bucket : buckets) {
            String brandName = bucket.getKeyAsString();
            list.add(brandName);
        }
        return list;
    }

    private void buildAggregations(SearchRequest request) {
        request.source().aggregation(
                AggregationBuilders.terms("keywordsAgg").field("keywords").size(5));
    }

    //构造条件进行关键字的查询和条件过滤【keyword类型用term查询,数值类型用range进行查询】
    //多个查询条件组合,肯定是通过boolean查询来进行组合【关键字放到must中参与算分,过滤条件放到filter中不参与算分】
    private void buildBasicQuery(User user, SearchRequest request) {
        // 1.准备Boolean查询
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // 1.1.关键字过滤
        String keywords = user.getKeywords();
        if (StringUtils.isNotBlank(keywords)) {
            //不为空,根据对应的品牌进行过滤
            boolQuery.filter(QueryBuilders.termQuery("keywords", keywords));
        }

        // 2.设置查询条件,放入source
        request.source().query(boolQuery);
    }
}

  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

星悦糖

你的鼓励是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值