个人 二维码




公众号二维码

目录

为什么我推荐你使用 Elasticsearch 实现搜索系统

温馨提示:本案例图文并茂,代码齐全(包括前端)大家只需要跟着文章一步步学习即可实现。想要一步到位直接获取代码的同学,请关注微信公众号「哈喽沃德先生」回复 search 即可

  搜索是一个非常常见的功能,大家肯定都使用过,例如:百度搜索、Google搜索、电商商品搜索、美团商家/食品搜索等等。随着互联网信息爆炸性地飞速增长,网民需要更有效的个性化搜索服务。所以互联网应用几乎没有不开发搜索功能的,既然这个功能这么重要,身为一名合格的程序员必须搞清楚其背后的实现原理。安排!

  本文将通过 Spring Boot + Elasticsearch + Vue 实现一个简易版的电商搜索系统,方便大家理解其背后的原理。

  

案例分析

  

/resources/articles/why/elasticsearch/20210314201922.png

  根据上图可以得知,搜索的业务逻辑其实蛮简单的,用户输入要搜索的商品关键词提交以后,将搜索关键词、页码信息等传往后台,后台查询后将结果集进行处理并返回。需要关注的无非以下几点:

  • 查询速度要快
  • 数据匹配度要高
  • 方便排序
  • 返回结果高亮

  

MySQL

  

  核心表(goods)如下:

/resources/articles/why/elasticsearch/20210314205052.png

  MySQL 商品表的字段是非常多的,而商城搜索页面所需要的数据无非就是商品ID、商品名称、商品价格、商品评论数和商品原始图等(根据自己实际开发情况而定,这里根据本文案例前端样式进行分析)。一般商品表还会添加商品关键词字段专门方便搜索引擎使用,所以一条单表 SQL 查询相信应该难不倒各位。

  我们先不考虑数据库性能方面的问题,就单纯为了实现这个功能来看看都需要做什么:

  • 假设搜索条件是华为手机平板电脑,要求是只要满足了其中任意一个词语组合的数据都要查询出来,SQL 怎么写?换句话说你打算写多少条 SQL?
  • 假设上一步你做完了,拿到那么多的结果集你是不是要考虑去重排序一下?
  • 前两步你都实现了,现在我的要求是返回前端时必须将刚才搜索条件中的词语组合进行高亮处理

  WTF😵,接到这样的需求如果让你用关系型数据库去实现,的确有点强人所难。怎么办?往下看。

  

Elasticsearch

  

  随着互联网信息爆炸性地飞速增长,传统的查询方法已无法为网民提供有效的搜索服务。如果我们做的只是用户量很少的内网项目,并且搜索的字段都是一些内容很简短的字段,比如姓名,编号之类的,那完全可以用数据库 like 语句,但是,数据库 like 查询性能非常低,如果搜索的请求过多,或者需要搜索的是大文本类型的内容(全文搜索),那么这种搜索的方案也是不可取的。

  互联网的飞速发展迫切地需求一种快速、全面、准确且稳定可靠的信息查询方法。既然我们要做性能高的全文搜索,这个需求又不能依赖数据库,只能由我们自己来实现了。但是令我们很受打击的是全文搜索是很难实现的,我们不仅希望能全文搜索,还希望它足够稳定足够快,且搜索结果有关键字高亮,还能按各种匹配分数来排序,希望它能切换不同的分词算法来满足各种分词需求。

  综上所述,如果我们想要做一个功能完善,性能强大的全文搜索其实并非易事,而全文搜索又是一个常见的需求,在这种环境下,市面上出现了一些开源的解决方案。这些解决方案开源出来后,获得了大量的社区开发者支持,不断为其开发插件,使其不断优化和完善,这就成了我们所说的搜索引擎了。而它们中最有名气的就是 ElasticsearchSolr

  Elasticsearch 是一个基于 Apache Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful web 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。Elasticsearch 是最受欢迎的企业搜索引擎,其次是 Apache Solr,也是基于 Lucene。

  如果使用 Elasticsearch 来做这件事,刚才的问题都会迎刃而解:

  • Elasticsearch 易于安装和配置,学习和使用成本较低,开箱即用。
  • Elasticsearch 支持单机也支持分布式,内置分布式协调管理功能,天生集群。
  • Elasticsearch 提供了分片和副本机制,一个索引可以分成多个分片,一个分片可以设置多个复制分片,提高效率和高可用。
  • Elasticsearch 注重于核心功能实现,高级功能多有第三方插件提供,例如图形化界面 Kibana 的支撑。
  • Elasticsearch 建立索引快,实时性查询快,适用于新兴的实时搜索应用,面对海量数据也毫不逊色,速度快,负载强。
  • Elasticseach 有强大的文本分析功能(分词)和倒排索引机制,进一步提升搜索速度。

  

分词

  

  刚才我们提到的问题中,假设搜索条件是华为手机平板电脑,要求是只要满足了其中任意一个词语组合的数据都要查询出来。借助 Elasticseach 的文本分析功能可以轻松将搜索条件进行分词处理,再结合倒排索引实现快速检索。Elasticseach 提供了三种分词方法:单字分词,二分法分词,词库分词。

  

单字分词

  如:“华为手机平板电脑”

  效果:“华”、“为”、“手”、“机”、“平”、“板”、“电”、“脑”

  

二分法分词

  按两个字进行切分。

  如:“华为手机平板电脑”

  效果:“华为”、“为手”、“手机”、“机平”、“平板”、“板电”、“电脑”。

  

词库分词

  按某种算法构造词,然后去匹配已建好的词库集合,如果匹配到就切分出来成为词语。通常词库分词被认为是最理想的中文分词算法。而词库分词最常用的就是 IK 分词。

  IK 分词器提供两种分词模式:

  • ik_max_word:会将文本做最细粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,中华人民,中华,华人,人民共和国,人民,人,民,共和国,共和,和,国国,国歌”,会穷尽各种可能的组合,适合 Term Query。
  • ik_smart:会将文本做最粗粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,国歌”,适合 Phrase Query。

  

倒排索引

  

  对于搜索引擎来讲:

  • 正排索引是文档 ID 到文档内容、单词的关联关系。也就是说通过 ID 获取文档的内容。
  • 倒排索引是单词到文档 ID 的关联关系。也就是说通过单词搜索到文档 ID。

  倒排索引的查询流程是:首先根据关键字搜索到对应的文档 ID,然后根据正排索引查询文档 ID 的完整内容,最后返回给用户想要的结果。

  

组成部分

  

  倒排索引是搜索引擎的核心,主要包含两个部分:

  • 单词词典(Trem Dictionary):记录所有的文档分词后的结果。一般采用 B+Tree 的方式,来保证高效
  • 倒排列表(Posting List):记录单词对应的文档的集合,由倒排索引项(Posting)组成。

/resources/articles/why/elasticsearch/855959-20170224200237991-592489046.png

  倒排索引项(Posting)主要包含如下的信息:

  • 文档 ID,用于获取原始文档的信息。
  • 单词频率(TF,Term Frequency),记录该单词在该文档中出现的次数,用于后续相关性算分。
  • 位置(Position),记录单词在文档中的分词位置(多个),用于做词语搜索。
  • 偏移(Offset),记录单词在文档的开始和结束位置,用于高亮显示。

  

案例

  

  倒排索引参考文献《这就是搜索引擎:核心技术详解》张俊林著。

  假设文档集合包含五个文档,每个文档内容如下图所示。在图中最左端一栏是每个文档对应的文档编号。我们的任务就是对这个文档集合建立倒排索引。

/resources/articles/why/elasticsearch/855959-20170224200300601-1967186316.png

  首先用分词系统将文档自动切分成单词序列。为了系统后续处理方便,需要对每个不同的单词赋予唯一的单词编号,同时记录哪些文档中包含这个单词,在如此处理结束后,我们可以得到最简单的倒排索引,如下图所示。

  在图中,“单词 ID”一栏记录了每个单词的单词编号,第二栏是对应的单词,第三栏即每个单词对应的倒排列表。比如单词 谷歌,其单词编号1倒排列表1,2,3,4,5,说明文档集合中每个文档都包含了这个单词。

/resources/articles/why/elasticsearch/855959-20170224200334195-2052728227.png

  为了方便大家理解,上图只是一个简单的倒排索引,只记录了哪些文档包含哪些单词。而事实上,索引系统还可以记录除此之外的更多信息。

  下图则是一个相对复杂的倒排索引,在单词对应的倒排列表中不仅记录了文档编号,还记录了单词频率信息(TF),即这个单词在某个文档中出现的次数,之所以要记录这个信息,是因为词频信息在搜索结果排序时,计算查询和文档相似度是很重要的一个计算因子,所以将其记录在倒排列表中,以方便后续排序时进行分值计算。

  图中单词 创始人单词编号7,对应的倒排列表内容为 (3;1),其中 3 代表文档编号为 3 的文档包含这个单词,数字 1 代表词频信息,即这个单词在 3 号文档中只出现过 1 次。

/resources/articles/why/elasticsearch/855959-20170224200448148-924219280.png

  实用的倒排索引还可以记录更多的信息,如下图所示索引系统除了记录文档编号和单词频率信息外,额外记载了两类信息,即每个单词对应的“文档频率信息”以及在倒排列表中记录单词在某个文档出现的位置信息(POS)。

/resources/articles/why/elasticsearch/u=1053779474,1559369339&fm=27&gp=0.jpg

/resources/articles/why/elasticsearch/855959-20170224200724179-596243521.png

  有了这个索引系统,搜索引擎可以很方便地响应用户的查询,比如用户输入查询词“Facebook”,搜索系统查找倒排索引,从中读取包含这个单词的文档,这些文档就是提供给用户的搜索结果。

  利用单词频率信息、文档频率信息可以对这些候选搜索结果进行排序,计算文档和查询的相似性,按照相似性的得分由高到低排序输出。

  综上所述,你懂的,废话不多说,下面进入实战环节。

  下图来自:https://db-engines.com/en/ranking

/resources/articles/why/elasticsearch/image-20210306232608614.png

  

准备工作

  

环境

  

  • Elasticsearch:7.9.3
  • Spring Boot:2.4.3
  • JDK:11.0.7
  • 前端:仿京东商城模板 + Vue,文中配有详细代码
  • IDE:IntelliJ IDEA

  

Elasticsearch

  

  安装流程这里不过多赘述,本文使用 Elasticsearch 集群环境,已安装好 IK 中文分词器。

  下图为 elasticsearch-head 插件(浏览器可以直接安装该插件进行连接)显示的集群信息。

/resources/articles/why/elasticsearch/image-20210314181912186.png

  

Spring Boot

  

创建项目

  

  使用 Spring Initializr 初始化 Spring Boot 项目,添加 Spring WebSpring Data ElasticsearchMySQLMyBatisLombok

/resources/articles/why/elasticsearch/image-20210313142302900.png

/resources/articles/why/elasticsearch/image-20210313142326173.png

/resources/articles/why/elasticsearch/image-20210313142440353.png

  

配置文件

  

  application.yml 配置 MySQL、Elasticsearch、MyBatis 相关信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
spring:
  # 数据源
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/example?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false
    username: root
    password: 123456
  # elasticsearch
  elasticsearch:
    rest:
      uris: 192.168.10.10:9200,192.168.10.11:9200,192.168.10.12:9200

mybatis:
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰映射

  

启动类

  

  启动类添加 Mapper 接口扫描。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.example;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("com.example.mapper") // Mapper 接口扫描
@SpringBootApplication
public class SearchDemoApplication {

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

}

  

前端

  

  将我为大家准备好的前端资源文件添加至项目 resources 目录下的 static 目录中。

  前端资源文件获取方式:请关注微信公众号「哈喽沃德先生」回复 search 即可

/resources/articles/why/elasticsearch/image-20210314173131939.png

  在 list.html 中使用 CDN 添加 Vue 和 Axios 免去下载文件的过程。

1
2
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.21.1/dist/axios.min.js"></script>

  

启动

  

  访问:http://localhost:8080/list.html 效果如下:

/resources/articles/why/elasticsearch/20210314174409.png

功能开发

  

创建索引

  

需求说明

  

  该功能主要用于将 MySQL 商品信息导入 Elasticseach。

  如果贵司搭建了 ELK 系统,可以使用 Logstash 将 MySQL 商品信息导入 Elasticseach。这部分内容后期我也会更新,敬请期待,今天我们不聊这部分。

  既然没有 Logstash 那我们就通过代码将 MySQL 数据导入 Elasticseach。

  MySQL 商品表的字段是非常多的,而商城搜索页面所需要的数据无非就是商品ID、商品名称、商品价格、商品评论数和商品原始图等(根据自己实际开发情况而定,这里根据本文案例前端样式进行分析)。一般商品表还会添加商品关键词字段专门方便搜索引擎使用,所以一条单表 SQL 查询相信应该难不倒各位。

/resources/articles/why/elasticsearch/image-20210314180326554.png

  

实体类

  

  创建实体类 Goods.java,添加 @Document 注解用于映射 Elasticsearch 索引库。

  • indexName:索引库名称
  • shards:分片数
  • replicas:副本数
  • createIndex:是否创建索引库
  • @Id:主键
  • @Field:设置索引规则
    • type = FieldType.Text:文本,默认分词
    • analyzer = "ik_max_word":指定分词规则,ik_max_word 会将文本最细粒度进行拆分
    • type = FieldType.Double:双精度浮点型,并作为一个整体不可分
    • type = FieldType.Short:短整型,并作为一个整体不可分
    • type = FieldType.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
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
package com.example.pojo;

import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.io.Serializable;
import java.math.BigDecimal;

@Data
@NoArgsConstructor
/**
 * @Document 映射 Elasticsearch 索引库所需要的注解
 *  indexName:索引库名称
 *  shards:分片数
 *  replicas:副本数
 *  createIndex:是否创建索引库
 */
@Document(indexName = "goods", shards = 5, replicas = 1, createIndex = true)
public class Goods implements Serializable {

    private static final long serialVersionUID = -1989082640160920658L;
    @Id
    private Integer goodsId; // 商品ID
    @Field(type = FieldType.Text, analyzer = "ik_max_word") // 分词
    private String goodsName; // 商品名称
    @Field(type = FieldType.Text, analyzer = "ik_max_word") // 分词
    private String keywords; // 商品关键词
    @Field(type = FieldType.Double)
    private BigDecimal marketPrice; // 市场价
    @Field(type = FieldType.Short)
    private Short commentCount; // 商品评论数
    @Field(type = FieldType.Keyword)
    private String originalImg; // 商品原始图地址
    private String goodsRemark; // 商品简单描述
    private String goodsContent; // 商品详细描述,存储商品详情图地址
    private Integer catId; // 分类ID
    private Integer extendCatId; // 扩展分类ID
    private String goodsNo; // 商品编号
    private Integer clickCount; // 点击数
    private Short brandId; // 品牌ID
    private Short storeCount; // 库存数量
    private Integer weight; // 商品重量,单位:克
    private BigDecimal shopPrice; // 本店价
    private BigDecimal costPrice; // 商品成本价
    private Byte isReal; // 是否为实物
    private Byte isOnSale; // 是否上架
    private Byte isFreeShipping; // 是否包邮 0 否, 1 是
    private Integer onTime; // 商品上架时间
    private Short sort; // 商品排序
    private Byte isRecommend; // 是否推荐
    private Byte isNew; // 是否新品
    private Byte isHot; // 是否热卖
    private Integer lastUpdate; // 最后更新时间
    private Short goodsType; // 商品所属类型ID
    private Short specType; // 商品规格类型
    private Integer giveIntegral; // 购买商品赠送积分
    private Integer exchangeIntegral; // 积分兑换:0 不参与积分兑换
    private Short suppliersId; // 供货商ID
    private Integer salesSum; // 商品销量
    private Byte promType; // 0 普通订单,1 限时抢购, 2 团购, 3 促销优惠
    private Integer promId; // 优惠活动ID
    private BigDecimal commission; // 佣金用于分销分成
    private String spu; // SPU
    private String sku; // SKU

}

  

GoodsMapper.java

  

  从 MySQL 查询 goods 商品表:商品ID、商品名称、商品关键词、市场价、商品评论数、商品原始图地址。这些字段用于搜索引擎使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.mapper;

import com.example.pojo.Goods;
import org.apache.ibatis.annotations.Select;

import java.util.List;

/**
 * @author 哈喽沃德先生
 * @微信公众号 哈喽沃德先生
 * @website https://mrhelloworld.com
 * @wechat 124059770
 */
public interface GoodsMapper {

    /**
     * 查询商品ID、商品名称、商品关键词、市场价、商品评论数、商品原始图地址
     *
     * @return 商品列表
     */
    @Select("SELECT goods_id, goods_name, keywords, market_price, comment_count, original_img FROM goods")
    List<Goods> selectGoodsList();

}

  

GoodsRepository.java

  

  application.yml 配置文件添加一个自定义配置,用于控制创建索引和设置映射的开关。

1
2
3
4
# 自定义配置
search:
  index:
    enabled: true # 是否需要创建索引和设置映射。默认为 false

  GoodsRepository.java 注入 ElasticsearchRestTemplate,编写创建索引和设置映射相关代码。

 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
package com.example.repository;

import com.example.pojo.Goods;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.IndexOperations;
import org.springframework.data.elasticsearch.core.document.Document;
import org.springframework.stereotype.Repository;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.List;

/**
 * @author 哈喽沃德先生
 * @微信公众号 哈喽沃德先生
 * @website https://mrhelloworld.com
 * @wechat 124059770
 */
@Repository
public class GoodsRepository {

    @Resource
    private ElasticsearchRestTemplate elasticsearchRestTemplate;

    // 是否需要创建索引和设置映射。默认为 false
    @Value("${search.index.enabled}")
    private boolean indexEnabled;

    /**
     * 批量新增商品信息至 Elasticsearch
     *
     * @param goodsList
     */
    public void save(List<Goods> goodsList) {
        // 是否需要创建索引和设置映射
        if (indexEnabled) {
            createIndexAndPutMapping();
        }
        // 批量新增
        elasticsearchRestTemplate.save(goodsList);
    }

    /**
     * 新增单个商品信息至 Elasticsearch
     *
     * @param goods
     */
    public void save(Goods goods) {
        save(Arrays.asList(goods));
    }

    /**
     * 创建索引和设置映射
     */
    private void createIndexAndPutMapping() {
        // 设置索引信息(实体类)
        IndexOperations indexOperations = elasticsearchRestTemplate.indexOps(Goods.class);
        // 创建索引
        indexOperations.create();
        // 创建映射
        Document mapping = indexOperations.createMapping();
        // 将映射写入索引
        indexOperations.putMapping(mapping);
    }

}

  

SearchService.java

  

  SearchService.java 注入 GoodsMapperGoodsRepository,实现将 MySQL 商品信息导入 Elasticsearch。

 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
package com.example.service;

import com.example.mapper.GoodsMapper;
import com.example.repository.GoodsRepository;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

/**
 * @author 哈喽沃德先生
 * @微信公众号 哈喽沃德先生
 * @website https://mrhelloworld.com
 * @wechat 124059770
 */
@Service
public class SearchService {

    @Resource
    private GoodsMapper goodsMapper;
    @Resource
    private GoodsRepository goodsRepository;

    /**
     * 将 MySQL 商品信息导入 Elasticsearch
     */
    public void importGoods() {
        goodsRepository.save(goodsMapper.selectGoodsList());
    }

}

  

单元测试

  

  对于当前案例而言,这部分功能没必要暴露一个接口去调用,直接内部编写单元测试完成即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.service;

import com.example.SearchDemoApplication;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;

@SpringBootTest(classes = SearchDemoApplication.class)
public class SearchServiceTest {

    @Resource
    private SearchService searchService;

    @Test
    public void testImportGoods() {
        searchService.importGoods();
    }

}

  执行单元测试以后,刷新 elasticsearch-head 插件页面,结果如下:

/resources/articles/why/elasticsearch/image-20210314190612382.png

  查看一下 goods 索引库的映射信息。

/resources/articles/why/elasticsearch/image-20210314190844835.png

  MySQL 的 goods 表:

/resources/articles/why/elasticsearch/image-20210314185754763.png

  Elasticsearch 的 goods 索引库:

/resources/articles/why/elasticsearch/image-20210314191018444.png

  看到以上结果,说明 MySQL 商品信息已全部导入 Elasticsearch。

  

搜索

  

  接下来就要进入今天的主题了,搜索 功能的实现。要做就做全套,我们使用仿京东商城模板 + Vue 实现一个真正的电商搜索功能。

  搜索的业务逻辑其实蛮简单的,用户输入要搜索的商品关键词提交以后,将搜索关键词、页码信息等传往后台,再配合 Spring Data Elasticseach 即可轻松搞定。

  

GoodsRepository.java

  

  按照国际惯例,DAO 层只负责数据处理,一行代码搞定。

1
2
3
4
5
6
7
8
9
/**
 * 分页、高亮查询
 *
 * @param query
 * @return
 */
public SearchHits<Goods> selectGoodsListForPage(NativeSearchQuery query) {
    return elasticsearchRestTemplate.search(query, Goods.class);
}

  

PageInfo.java

  

  这种功能肯定都是需要搭配分页处理的,封装一个分页对象方便使用。

 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
package com.example.result;

import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.List;

/**
 * @author 哈喽沃德先生
 * @微信公众号 哈喽沃德先生
 * @website https://mrhelloworld.com
 * @wechat 124059770
 */
@Data
@NoArgsConstructor
public class PageInfo<T> implements Serializable {

    private static final long serialVersionUID = 6260163970867016563L;
    private int currentPage; // 当前页
    private int pageSize; // 每页显示条数
    private int total; // 总记录数
    private int totalPage; // 总页数
    private int prePage; // 上一页
    private int nextPage; // 下一页
    private boolean hasPre; // 是否有上一页
    private boolean hasNext; // 是否有下一页
    private List<T> result; // 返回结果集

    public PageInfo(int currentPage, int pageSize) {
        // 当前页
        this.currentPage = currentPage < 1 ? 1 : currentPage;
        // 每页显示条数
        this.pageSize = pageSize;
        // 是否有上一页
        this.hasPre = currentPage == 1 ? false : true;
        // 是否有下一页
        this.hasNext = currentPage == totalPage ? false : true;
        // 上一页
        if (hasPre) {
            this.prePage = currentPage - 1;
        }
        // 下一页
        if (hasNext) {
            this.nextPage = currentPage + 1;
        }
    }

    public PageInfo(int currentPage, int pageSize, int total) {
        // 当前页
        this.currentPage = currentPage < 1 ? 1 : currentPage;
        // 每页显示条数
        this.pageSize = pageSize;
        // 总记录数
        this.total = total;
        // 计算总页数
        if (total == 0) {
            this.totalPage = 0;
        } else {
            this.totalPage = (total - 1) / pageSize + 1;
        }
        // 是否有上一页
        this.hasPre = currentPage == 1 ? false : true;
        // 是否有下一页
        this.hasNext = currentPage == totalPage ? false : true;
        // 上一页
        if (hasPre) {
            this.prePage = currentPage - 1;
        }
        // 下一页
        if (hasNext) {
            this.nextPage = currentPage + 1;
        }
    }

}

  

SearchService.java

  

  Service 业务逻辑层主要关注以下细节:

  • 设置高亮格式 <span style='color:red;'></span>
  • 构建搜索条件对象
    • 设置搜索条件 goodsName 和 keywords
    • 设置高亮
    • 设置分页
  • 调用 DAO 完成搜索
  • 处理搜索结果集构建分页对象并返回
 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
/**
 * 搜索
 *
 * @param searchStr 搜索条件
 * @param pageNum   第几页
 * @param pageSize  每页显示条数
 * @return
 */
public PageInfo<GoodsVo> doSearch(String searchStr, Integer pageNum, Integer pageSize) {
    // 设置高亮格式 <span style='color:red;'></span>
    HighlightBuilder.Field field = new HighlightBuilder.Field("goodsName");
    field.preTags("<span style='color:red;'>");
    field.postTags("</span>");
    // 构建搜索条件对象
    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
    // 设置搜索条件 goodsName 和 keywords
    boolQueryBuilder.must(QueryBuilders.multiMatchQuery(searchStr, "goodsName", "keywords"));
    // 构建搜索对象
    NativeSearchQuery query = new NativeSearchQueryBuilder()
            .withQuery(boolQueryBuilder) // 搜索条件
            .withHighlightFields(field) // 高亮
            .withPageable(PageRequest.of(pageNum - 1, pageSize)) // 分页,当前页从 0 开始
            .build();
    // 搜索
    SearchHits<Goods> searchHits = goodsRepository.selectGoodsListForPage(query);
    // 总条数
    Long total = searchHits.getTotalHits();
    if (0 > total) {
        return new PageInfo<>(pageNum, pageSize, 0);
    }
    // 初始化返回结果集
    List<GoodsVo> goodsVoList = new ArrayList<>();
    // 处理搜索结果集
    for (SearchHit<Goods> searchHit : searchHits) {
        // 初始化返回结果对象
        GoodsVo goodsVo = new GoodsVo();
        // 获取结果对象
        Goods goods = searchHit.getContent();
        // 拷贝属性
        BeanUtils.copyProperties(goods, goodsVo);
        // 处理高亮信息
        Map<String, List<String>> highlightFields = searchHit.getHighlightFields();
        // 是否有高亮信息
        if (highlightFields.containsKey("goodsName")) {
            String goodsNameHl = highlightFields.get("goodsName").get(0);
            goodsVo.setGoodsNameHl(goodsNameHl);
        } else {
            goodsVo.setGoodsNameHl(goods.getGoodsName());
        }
        goodsVoList.add(goodsVo);
    }
    // 初始化分页对象
    PageInfo<GoodsVo> pageInfo = new PageInfo<GoodsVo>(pageNum, pageSize, total.intValue());
    pageInfo.setResult(goodsVoList);
    return pageInfo;
}

  

SearchController.java

  

  控制层提供一个 /search 的 GET 接口。

 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
package com.example.controller;

import com.example.result.PageInfo;
import com.example.service.SearchService;
import com.example.vo.GoodsVo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * @author 哈喽沃德先生
 * @微信公众号 哈喽沃德先生
 * @website https://mrhelloworld.com
 * @wechat 124059770
 */
@RestController
@RequestMapping("search")
public class SearchController {

    @Resource
    private SearchService searchService;

    /**
     * 搜索
     *
     * @param searchStr 搜索条件
     * @param pageNum   第几页
     * @param pageSize  每页显示条数
     * @return
     */
    @GetMapping
    public PageInfo<GoodsVo> doSearch(String searchStr, Integer pageNum, Integer pageSize) {
        return searchService.doSearch(searchStr, pageNum, pageSize);
    }

}

  

list.html

  

  在 list.html 中使用 CDN 添加 Vue 和 Axios 免去下载文件的过程。

1
2
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.21.1/dist/axios.min.js"></script>

  

页面处理

  

  修改头部搜索(善于利用 Ctrl + F)的表单部分:

  • 商品关键字的 <input/> 添加 id="searchStr"
  • 添加两个 hidden 类型的 <input/> 用于存放页码信息
1
2
3
4
5
6
<form action="" name="search" method="get" class="fl" onsubmit="return false;">
    <input id="searchStr" type="text" class="txt" value="请输入商品关键字"/>
    <input type="submit" class="btn" value="搜索"/>
    <input id="pageNum" type="hidden" value="1"/>
    <input id="pageSize" type="hidden" value="12"/>
</form>

  

  修改商品列表(善于利用 Ctrl + F),只留下一个 <li></li>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- 商品列表 start-->
<div id="goodsList" class="goodslist mt10">
    <ul>
        <li>
            <dl>
                <dt><a href=""><img src="images/goods1.jpg" alt=""/></a></dt>
                <dd><a href=""><p class="beyondHidden">清华同方精锐X2 台式电脑(双核E3500 2G 500G DVD 键鼠)带20英寸显示器</p></a></dd>
                <dd><strong>¥2399.00</strong></dd>
                <dd><a href=""><em>已有10人评价</em></a></dd>
            </dl>
        </li>
    </ul>
</div>
<!-- 商品列表 end-->

  

  修改分页信息(善于利用 Ctrl + F):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- 分页信息 start -->
<div id="page" class="page mt20">
    <a href="">首页</a>
    <a href="">上一页</a>
    <a href="" class="cur">3</a>
    <a href="">下一页</a>
    <a href="">尾页</a>&nbsp;&nbsp;
    <span>
        <em>共8页&nbsp;&nbsp;到第<input type="text" id="num" class="page_num"/></em>
        <a href="" class="skipsearch">确定</a>
    </span>
</div>
<!-- 分页信息 end -->

  

Vue and Axios

  

  首先全局添加一个 div,设置 id="app"

/resources/articles/why/elasticsearch/image-20210314194217353.png

  然后初始化 Vue 对象,绑定元素,定义组件数据和组件方法。

 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
<script>
  var app = new Vue({
    // element 的简写,挂载元素,绑定 id 为 app 的 html 代码片段
    el: '#app',
    // 定义组件数据
    data: {
      goodsList: [],
      page: []
    },
    // 定义组件方法
    methods: {
      // 搜索
      doSearch() {
        axios({
          url: "/search",
          method: "GET",
          params: {
            searchStr: $("#searchStr").val(),
            pageNum: $("#pageNum").val(),
            pageSize: $("#pageSize").val()
          }
        }).then(response => { // 返回结果
          $("#pageNum").val(1);// 重置当前页
          if (response.data.total <= 0) {
            $('#goodsList').append('<strong>对不起,没有找到与“' + $("#searchStr").val() + '”相关的商品,请确认搜索关键词是否正确。</strong>');
          }
          this.goodsList = response.data.result;
          this.page = response.data;
        }).catch(error => {// 异常捕获
          alert('系统正在升级中,请稍后再试!');
        });
      },
      // 上一页
      prePage() {
        // 获取当前页的值并减一,然后重新赋值给当前页
        let page = parseInt($("#pageNum").val()) - 1;
        $("#pageNum").val(page);
        // 调用搜索函数
        this.doSearch();
      },
      // 下一页
      nextPage() {
        // 获取当前页的值并加一,然后重新赋值给当前页
        let page = parseInt($("#pageNum").val()) + 1;
        $("#pageNum").val(page);
        // 调用搜索函数
        this.doSearch();
      },
      // 第几页
      whichPage(num) {
        // 获取点击的按钮(首页、1、2...尾页)值,然后重新赋值给当前页
        $("#pageNum").val(num);
        // 调用搜索函数
        this.doSearch();
      },
      // 到第几页
      goToPage() {
        // 获取输入的页码值,然后重新赋值给当前页
        $("#pageNum").val($("#num").val());
        // 调用搜索函数
        this.doSearch();
      }
    }
  });
</script>

  

绑定元素

  

  修改头部搜索(善于利用 Ctrl + F)的表单部分:

  • 添加 v-on:click="doSearch"(简写方式 @click="doSearch")到搜索按钮上
1
2
3
4
5
6
<form action="" name="search" method="get" class="fl" onsubmit="return false;">
    <input id="searchStr" type="text" class="txt" value="请输入商品关键字"/>
    <input v-on:click="doSearch" type="submit" class="btn" value="搜索"/>
    <input id="pageNum" type="hidden" value="1"/>
    <input id="pageSize" type="hidden" value="12"/>
</form>

  

  修改商品列表(善于利用 Ctrl + F):

  • 列表渲染:<li></li> 添加 v-for="(goods, index) in goodsList"
  • 商品图片:<img/> 添加 v-bind:src="goods.originalImg"(简写方式 :src="goods.originalImg"
  • 商品名称并高亮:<p></p> 添加 v-html="goods.goodsNameHl"
  • 商品价格:添加 {{ goods.marketPrice }}
  • 商品评论:添加 {{ goods.commentCount }}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- 商品列表 start-->
<div id="goodsList" class="goodslist mt10">
    <ul>
        <li v-for="(goods, index) in goodsList">
            <dl>
                <dt><a href=""><img v-bind:src="goods.originalImg" alt=""/></a></dt>
                <dd><a href=""><p class="beyondHidden" v-html="goods.goodsNameHl"></p></a></dd>
                <dd><strong>¥{{ goods.marketPrice }}</strong></dd>
                <dd><a href=""><em>已有{{ goods.commentCount }}人评价</em></a></dd>
            </dl>
        </li>
    </ul>
</div>
<!-- 商品列表 end-->

  

  修改分页信息(善于利用 Ctrl + F):

  • 首页:v-on:click="whichPage(1)"
  • 尾页:v-on:click="whichPage(page.totalPage)"
  • 上一页:v-if="page.hasPre" v-on:click="prePage"
  • 下一页:v-if="page.hasNext" v-on:click="nextPage"
  • 某一页:
    • 循环渲染:v-for="i in page.totalPage"
    • 某一页:v-on:click="whichPage(i)"
    • 当前页样式处理:v-bind:class="{ cur:i == page.currentPage }"
  • 共多少页:{{ page.totalPage }}
  • 到第几页:v-on:click="goToPage"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!-- 分页信息 start -->
<div id="page" class="page mt20">
    <a v-on:click="whichPage(1)" href="javascript:void(0);">首页</a>
    <a v-if="page.hasPre" v-on:click="prePage" href="javascript:void(0);">上一页</a>
    <a v-for="i in page.totalPage"
       v-on:click="whichPage(i)"
       v-bind:class="{ cur:i == page.currentPage }"
       href="javascript:void(0);">{{ i }}</a>
    <a v-if="page.hasNext" v-on:click="nextPage" href="javascript:void(0);">下一页</a>
    <a v-on:click="whichPage(page.totalPage)" href="javascript:void(0);">尾页</a>&nbsp;&nbsp;
    <span>
        <em>共{{ page.totalPage }}页&nbsp;&nbsp;到第<input type="text" id="num" class="page_num"/></em>
        <a v-on:click="goToPage" href="javascript:void(0);" class="skipsearch">确定</a>
    </span>
</div>
<!-- 分页信息 end -->

  

测试

  

  访问:http://localhost:8080/list.html 随便输入什么测试一下吧。

/resources/articles/why/elasticsearch/20210314201922.png

彩蛋

  

  开发结束以后,试试搜索 程序员😂 有意外惊喜哦。

/resources/articles/why/elasticsearch/20210314202052.png

结语

  

  至此 Elasticsearch 的实战小项目《电商搜索系统》就完成啦,本文讲解了 Spring Boot 整合 Elasticsearch 的使用,顺便结合前端 Vue 实现了页面效果。作为一款非常热门的搜索引擎,大家非常有必要进行更深入的学习,最后祝大家加薪!加薪!加薪!

温馨提示:本案例图文并茂,代码齐全(包括前端)大家只需要跟着文章一步步学习即可实现。想要一步到位直接获取代码的同学,请关注微信公众号「哈喽沃德先生」回复 search 即可

  

参考

  

/resources/articles/articles_bottom/end02.gif

本文采用 知识共享「署名-非商业性使用-禁止演绎 4.0 国际」许可协议

大家可以通过 分类 查看更多关于 Elasticsearch 的文章。

  

🤗 您的点赞转发是对我最大的鼓励和支持。

📢 扫码关注 哈喽沃德先生「文档 + 视频」每篇文章都配有专门视频讲解,学习更轻松噢 ~

/resources/mrhelloworld/qrcode/OfficialAccounts500-500.gif

「 感谢支持 」
 评论