温馨提示:本案例图文并茂,代码齐全(包括前端)大家只需要跟着文章一步步学习即可实现。想要一步到位直接获取代码的同学,请关注微信公众号「哈喽沃德先生」回复 search 即可 。
 
  搜索是一个非常常见的功能,大家肯定都使用过,例如:百度搜索、Google搜索、电商商品搜索、美团商家/食品搜索等等。随着互联网信息爆炸性地飞速增长,网民需要更有效的个性化搜索服务。所以互联网应用几乎没有不开发搜索功能的,既然这个功能这么重要,身为一名合格的程序员必须搞清楚其背后的实现原理。安排!
  本文将通过 Spring Boot + Elasticsearch + Vue 实现一个简易版的电商搜索系统,方便大家理解其背后的原理。
  
案例分析 
  
           
      根据上图可以得知,搜索的业务逻辑其实蛮简单的,用户输入要搜索的商品关键词提交以后,将搜索关键词、页码信息等传往后台,后台查询后将结果集进行处理并返回。需要关注的无非以下几点:
查询速度要快 
数据匹配度要高 
方便排序 
返回结果高亮 
 
  
MySQL 
  
  核心表(goods)如下:
           
      MySQL 商品表的字段是非常多的,而商城搜索页面所需要的数据无非就是商品ID、商品名称、商品价格、商品评论数和商品原始图等(根据自己实际开发情况而定,这里根据本文案例前端样式进行分析)。一般商品表还会添加商品关键词字段专门方便搜索引擎使用,所以一条单表 SQL 查询相信应该难不倒各位。
  我们先不考虑数据库性能方面的问题,就单纯为了实现这个功能来看看都需要做什么:
假设搜索条件是华为手机平板电脑,要求是只要满足了其中任意一个词语组合的数据都要查询出来,SQL 怎么写?换句话说你打算写多少条 SQL? 
假设上一步你做完了,拿到那么多的结果集你是不是要考虑去重排序一下? 
前两步你都实现了,现在我的要求是返回前端时必须将刚才搜索条件中的词语组合进行高亮处理 
 
  WTF😵,接到这样的需求如果让你用关系型数据库去实现,的确有点强人所难。怎么办?往下看。
  
Elasticsearch 
  
  随着互联网信息爆炸性地飞速增长,传统的查询方法已无法为网民提供有效的搜索服务。如果我们做的只是用户量很少的内网项目,并且搜索的字段都是一些内容很简短的字段,比如姓名,编号之类的,那完全可以用数据库 like 语句,但是,数据库 like 查询性能非常低,如果搜索的请求过多,或者需要搜索的是大文本类型的内容(全文搜索),那么这种搜索的方案也是不可取的。
  互联网的飞速发展迫切地需求一种快速、全面、准确且稳定可靠的信息查询方法。既然我们要做性能高的全文搜索,这个需求又不能依赖数据库,只能由我们自己来实现了。但是令我们很受打击的是全文搜索是很难实现的,我们不仅希望能全文搜索,还希望它足够稳定足够快,且搜索结果有关键字高亮,还能按各种匹配分数来排序,希望它能切换不同的分词算法来满足各种分词需求。
  综上所述,如果我们想要做一个功能完善,性能强大的全文搜索其实并非易事,而全文搜索又是一个常见的需求,在这种环境下,市面上出现了一些开源的解决方案。这些解决方案开源出来后,获得了大量的社区开发者支持,不断为其开发插件,使其不断优化和完善,这就成了我们所说的搜索引擎了。而它们中最有名气的就是 Elasticsearch  和 Solr 。
  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)组成。 
 
           
      倒排索引项(Posting)主要包含如下的信息:
文档 ID,用于获取原始文档的信息。 
单词频率(TF,Term Frequency),记录该单词在该文档中出现的次数,用于后续相关性算分。 
位置(Position),记录单词在文档中的分词位置(多个),用于做词语搜索。 
偏移(Offset),记录单词在文档的开始和结束位置,用于高亮显示。 
 
  
案例 
  
  倒排索引参考文献《这就是搜索引擎:核心技术详解》张俊林著。
  假设文档集合包含五个文档,每个文档内容如下图所示。在图中最左端一栏是每个文档对应的文档编号。我们的任务就是对这个文档集合建立倒排索引。
           
      首先用分词系统将文档自动切分成单词序列。为了系统后续处理方便,需要对每个不同的单词赋予唯一的单词编号,同时记录哪些文档中包含这个单词,在如此处理结束后,我们可以得到最简单的倒排索引,如下图所示。
  在图中,“单词 ID”一栏记录了每个单词的单词编号,第二栏是对应的单词,第三栏即每个单词对应的倒排列表。比如单词  谷歌,其单词编号 为 1,倒排列表 为 1,2,3,4,5,说明文档集合中每个文档都包含了这个单词。
           
      为了方便大家理解,上图只是一个简单的倒排索引,只记录了哪些文档包含哪些单词。而事实上,索引系统还可以记录除此之外的更多信息。
  下图则是一个相对复杂的倒排索引,在单词对应的倒排列表中不仅记录了文档编号,还记录了单词频率信息(TF),即这个单词在某个文档中出现的次数,之所以要记录这个信息,是因为词频信息在搜索结果排序时,计算查询和文档相似度是很重要的一个计算因子,所以将其记录在倒排列表中,以方便后续排序时进行分值计算。
  图中单词  创始人 的单词编号 为 7,对应的倒排列表 内容为 (3;1),其中 3 代表文档编号 为 3 的文档包含这个单词,数字 1 代表词频信息 ,即这个单词在 3 号文档中只出现过 1 次。
           
      实用的倒排索引还可以记录更多的信息,如下图所示索引系统除了记录文档编号和单词频率信息外,额外记载了两类信息,即每个单词对应的“文档频率信息”以及在倒排列表中记录单词在某个文档出现的位置信息(POS)。
           
    
           
      有了这个索引系统,搜索引擎可以很方便地响应用户的查询,比如用户输入查询词“Facebook”,搜索系统查找倒排索引,从中读取包含这个单词的文档,这些文档就是提供给用户的搜索结果。
  利用单词频率信息、文档频率信息可以对这些候选搜索结果进行排序,计算文档和查询的相似性,按照相似性的得分由高到低排序输出。
  综上所述,你懂的,废话不多说,下面进入实战环节。
  下图来自:https://db-engines.com/en/ranking
           
      
准备工作 
  
环境 
  
Elasticsearch:7.9.3 
Spring Boot:2.4.3 
JDK:11.0.7 
前端:仿京东商城模板 + Vue,文中配有详细代码 
IDE:IntelliJ IDEA 
 
  
Elasticsearch 
  
  安装流程这里不过多赘述,本文使用 Elasticsearch 集群环境,已安装好 IK 中文分词器。
  下图为 elasticsearch-head 插件(浏览器可以直接安装该插件进行连接)显示的集群信息。
           
      
Spring Boot 
  
创建项目 
  
  使用 Spring Initializr 初始化 Spring Boot 项目,添加 Spring Web,Spring Data Elasticsearch,MySQL,MyBatis,Lombok。
           
    
           
    
           
      
配置文件 
  
  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 即可 。
           
      在 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 效果如下:
           
    功能开发 
  
创建索引 
  
需求说明 
  
  该功能主要用于将 MySQL 商品信息导入 Elasticseach。
  如果贵司搭建了 ELK 系统,可以使用 Logstash 将 MySQL 商品信息导入 Elasticseach。这部分内容后期我也会更新,敬请期待,今天我们不聊这部分。
  既然没有 Logstash 那我们就通过代码将 MySQL 数据导入 Elasticseach。
  MySQL 商品表的字段是非常多的,而商城搜索页面所需要的数据无非就是商品ID、商品名称、商品价格、商品评论数和商品原始图等(根据自己实际开发情况而定,这里根据本文案例前端样式进行分析)。一般商品表还会添加商品关键词字段专门方便搜索引擎使用,所以一条单表 SQL 查询相信应该难不倒各位。
           
      
实体类 
  
  创建实体类 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 注入 GoodsMapper 和 GoodsRepository,实现将 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 插件页面,结果如下:
           
      查看一下 goods 索引库的映射信息。
           
      MySQL 的 goods 表:
           
      Elasticsearch 的 goods 索引库:
           
      看到以上结果,说明 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 >    
     < span > 
         < em > 共8页   到第< input  type = "text"  id = "num"  class = "page_num" /> 页</ em > 
         < a  href = ""  class = "skipsearch" > 确定</ a > 
     </ span > 
 </ div > 
<!-- 分页信息 end --> 
 
 
  
Vue and Axios 
  
  首先全局添加一个 div,设置 id="app"。
           
      然后初始化 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 >    
     < span > 
         < em > 共{{ page.totalPage }}页   到第< 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 随便输入什么测试一下吧。
           
    彩蛋 
  
  开发结束以后,试试搜索 程序员😂 有意外惊喜哦。
           
    结语 
  
  至此 Elasticsearch 的实战小项目《电商搜索系统》就完成啦,本文讲解了 Spring Boot 整合 Elasticsearch 的使用,顺便结合前端 Vue 实现了页面效果。作为一款非常热门的搜索引擎,大家非常有必要进行更深入的学习,最后祝大家加薪!加薪!加薪!
温馨提示:本案例图文并茂,代码齐全(包括前端)大家只需要跟着文章一步步学习即可实现。想要一步到位直接获取代码的同学,请关注微信公众号「哈喽沃德先生」回复 search 即可 。
 
  
参考 
  
           
    本文采用 知识共享「署名-非商业性使用-禁止演绎 4.0 国际」许可协议
大家可以通过 分类Elasticsearch
  
🤗 您的点赞和转发是对我最大的鼓励和支持。
📢 扫码关注 哈喽沃德先生「文档 + 视频」每篇文章都配有专门视频讲解,学习更轻松噢 ~