全文检索
功能介绍

首页点击分类

在搜索框搜索关键字

搜索到的目标商品列表页

准备搜索数据
当点击首页分类,或者在搜索框中搜索商品的时候,实际上都是在ES中完成搜索的,所以在搜索之前我们必须现将商品相关的数据,放入到ES的索引中,才能完成搜索。
创建索引和映射
我们首先梳理下,在搜索结果展示的搜索列表页中,需要展示哪些数据:
- 所有搜索到的商品具有的品牌,以及品牌logo
- 所有搜索到的商品具有的平台属性,以及对应的平台属性值
- SKU商品的名称
- SKU商品的价格
- SKU商品的默认图片
很显然,所有这些数据都需要包含在,搜索结果中,因此ES的索引中也必须要有这些数据。但是在定义索引,以及映射之前,我们要先解决一个小问题:
-
一个SKU商品有具有多个平台属性
-
每一个平台属性又可能包含了平台属性id,平台属性名称,平台属性取值
那么一个SKU商品的平台属性值如何在JSON文档中表示呢?如果写出一个JSON字符串的话,应该如下所示:
{ .... "_source" : { "id" : 57, "title" : "荣耀(HONOR) 荣耀V30 pro 5G手机 麒麟990芯片 v40轻奢版【幻夜星河】 全网通(8+256G)", ..... "attrs" : [ { "attrId" : 23, "attrValue" : "8G", "attrName" : "运行内存" }, { "attrId" : 24, "attrValue" : "256G", "attrName" : "机身内存" }, { "attrId" : 111, "attrValue" : "4000-4499mAh", "attrName" : "电池容量" }, { "attrId" : 112, "attrValue" : "6.3-6.59英寸", "attrName" : "屏幕尺寸" } ] } } ...}所以,要在索引中存储SKU商品的平台属性,我们就需要用到对象类型了。我们学习一个简单的例子,创建如下索引
PUT user{ "mappings": { "properties": { "name": { "properties": { "first": {"type": "text"}, "last": {"type": "text"} } } } }}向索引中添加一条文档数据如下:
POST user/_doc/1{ "name": [ { "first": "John", "last": "Smith" }, { "first": "Alice", "last": "White" } ]}查询索引中的数据如下:

接下来,我们做一个简单的查询,查询下first name为Alice,last name为Smith的文档。
GET user/_search{ "query": { "bool": { "must": [ { "match": { "name.first": "Alice" }}, { "match": { "name.last": "Smith" }} ] } }}查询结果如下:

看到这个结果,和我们的预期是不一样的,因为文档中并没有包含一个人的first name为Alice,last name为Smith。那么又为什么会有这样的结果呢?原因就在于,对于普通的对象数据,ES内部其实是这样来存储的:
{ ... "_source" : { "name.first" : ["John", "Alice"], "name.last" : ["Smith", "White"] }}所以,再结合上面的查询条件,我们很容易就可以理解了,为什么命名没有一个人的first name叫Alice,last name叫White,但我们偏偏还是查询到了文档。
之所以会出现这种意料之外的情况,是因为使用对象类型可能会出现跨对象匹配,其实应该是,第一个人(对象)的last和“Smith”匹配,第二个人(对象)的first和“Alice匹配”,为了避免这种跨对象匹配的情况,我们需要使用nested类型,通过nested类型定义的对象,每个对象的数据是单独存储的,因此,在匹配查询条件时,是不会出现跨对象匹配的。
PUT user_nested{ "mappings": { "properties": { "name": { "type": "nested", "properties": { "first": {"type": "text"}, "last": {"type": "text"} } } } }}POST user_nested/_doc/1{ "name": [ { "first": "John", "last": "Smith" }, { "first": "Alice", "last": "White" } ]}再次使用相同的查询,结果就是空集合,查询结果是符合预期的。
但是一旦使用了nested这种数据类型,查询方式,也要发生变化,我们下来测试下,继续使用普通查询方式,查询nested数据。
GET user_nested/_search{ "query": { "bool": { "must": [ { "match": { "name.first": "Alice" }}, { "match": { "name.last": "White" }} ] } }}发现结果还是不对,因为索引中有一个人的名字first是Alice,last是White,查询到的仍然是空集合。

所以我们一定要注意,针对nested类型数据的条件查询,我们的查询也要使用nested查询
GET user_nested/_search{ "query": { "nested": { "path": "name", "query": { "bool": { "must": [ { "match": { "name.first": "Alice" }}, { "match": { "name.last": "White" }} ] } } } }}
关于nested查询,我们需要注意以下几点:
- 针对nested数据类型的字段查询,应该使用nested类型的查询
- 在nested查询中,path表示的是待查询的,数据类型为nested类型的字段名
所以很显然,在ES中,一条SKU商品对应的文档中,每个平台属性都应该使用nested类型来定义,同时针对平台属性的查询,也必须是nested查询。所以,定义映射的DSL如下:
PUT my_goods{ "mappings" : { "properties" : { "attrs" : { "type" : "nested", "properties" : { "attrId" : { "type" : "long" }, "attrName" : { "type" : "keyword" }, "attrValue" : { "type" : "keyword" } } }, "defaultImg" : { "type" : "keyword" }, "firstLevelCategoryId" : { "type" : "long" }, "firstLevelCategoryName" : { "type" : "keyword" }, "hotScore" : { "type" : "long" }, "id" : { "type" : "long" }, "price" : { "type" : "double" }, "secondLevelCategoryId" : { "type" : "long" }, "secondLevelCategoryName" : { "type" : "keyword" }, "thirdLevelCategoryId" : { "type" : "long" }, "thirdLevelCategoryName" : { "type" : "keyword" }, "title" : { "type" : "text", "analyzer" : "ik_max_word" }, "tmId" : { "type" : "long" }, "tmLogoUrl" : { "type" : "keyword" }, "tmName" : { "type" : "keyword" } } }}添加测试商品数据
POST my_goods/_doc/57{ "id" : 57, "title" : "荣耀(HONOR) 荣耀V30 pro 5G手机 麒麟990芯片 v40轻奢版【幻夜星河】 全网通(8+256G)", "price" : 3999.0, "tmId" : 3, "tmName" : "华为", "tmLogoUrl":"http://47.93.148.192:8080/group1/M00/01/71/rBHu8mEQpUuAVioLAAGXnmYhX7M923.jpg", "defaultImg": "http://57", "firstLevelCategoryId" : 2, "firstLevelCategoryName" : "手机", "secondLevelCategoryId" : 13, "secondLevelCategoryName": "手机通讯", "thirdLevelCategoryId" : 61, "thirdLevelCategoryName" : "手机", "attrs" : [ { "attrId" : 23, "attrValue" : "8G", "attrName" : "运行内存" }, { "attrId" : 24, "attrValue" : "256G", "attrName" : "机身内存" }, { "attrId" : 111, "attrValue" : "4000-4499mAh", "attrName" : "电池容量" }, { "attrId" : 112, "attrValue" : "6.3-6.59英寸", "attrName" : "屏幕尺寸" } ]}
POST my_goods/_doc/58{ "id" : 58, "title" : "荣耀(HONOR)荣耀V30 pro 5G手机麒麟990芯片 v40轻奢版【冰岛幻境】全网通(8+128G)", "price" : 1999.0, "tmId" : 3, "tmName" : "华为", "tmLogoUrl":"http://47.93.148.192:8080/group1/M00/01/71/rBHu8mEQpUuAVioLAAGXnmYhX7M923.jpg", "defaultImg": "http://58", "firstLevelCategoryId" : 2, "firstLevelCategoryName" : "手机", "secondLevelCategoryId" : 13, "secondLevelCategoryName": "手机通讯", "thirdLevelCategoryId" : 61, "thirdLevelCategoryName" : "手机", "attrs" : [ { "attrId" : 23, "attrValue" : "8G", "attrName" : "运行内存" }, { "attrId" : 24, "attrValue" : "128G", "attrName" : "机身内存" }, { "attrId" : 111, "attrValue" : "3000-3999mAh", "attrName" : "电池容量" }, { "attrId" : 112, "attrValue" : "6.0-6.29英寸", "attrName" : "屏幕尺寸" } ]}
POST my_goods/_doc/59{ "id" : 59, "title" : "小米 CC9 手机 骁龙710 屏幕指纹3200万美颜自拍 4800万广角三摄美颜拍照 蓝色星球", "price" : 799.0, "tmId" : 1, "tmName" : "小米", "tmLogoUrl":"http://47.93.148.192:8080/group1/M00/01/71/rBHu8mEQpUuAVioLAAGXnmYhX7M923.jpg", "defaultImg": "http://59", "firstLevelCategoryId" : 2, "firstLevelCategoryName" : "手机", "secondLevelCategoryId" : 13, "secondLevelCategoryName": "手机通讯", "thirdLevelCategoryId" : 61, "thirdLevelCategoryName" : "手机", "attrs" : [ { "attrId" : 23, "attrValue" : "8G", "attrName" : "运行内存" }, { "attrId" : 24, "attrValue" : "128G", "attrName" : "机身内存" }, { "attrId" : 111, "attrValue" : "4000-4499mAh", "attrName" : "电池容量" }, { "attrId" : 112, "attrValue" : "6.0-6.29英寸", "attrName" : "屏幕尺寸" } ]}
POST my_goods/_doc/60{ "id" : 60, "title" : "小米 CC9 手机 骁龙710 屏幕指纹3200万美颜自拍 4800万广角三摄美颜拍照 蓝色星球", "price" : 899.0, "tmId" : 1, "tmName" : "小米", "tmLogoUrl":"http://47.93.148.192:8080/group1/M00/01/71/rBHu8mEQpUuAVioLAAGXnmYhX7M923.jpg", "defaultImg": "http://60", "firstLevelCategoryId" : 2, "firstLevelCategoryName" : "手机", "secondLevelCategoryId" : 13, "secondLevelCategoryName": "手机通讯", "thirdLevelCategoryId" : 61, "thirdLevelCategoryName" : "手机", "attrs" : [ { "attrId" : 23, "attrValue" : "8G", "attrName" : "运行内存" }, { "attrId" : 24, "attrValue" : "256G", "attrName" : "机身内存" }, { "attrId" : 111, "attrValue" : "4000-4499mAh", "attrName" : "电池容量" }, { "attrId" : 112, "attrValue" : "6.0-6.29英寸", "attrName" : "屏幕尺寸" } ]}商品查询DSL
接下来,我们需要思考下,用户进入我们的电商网站后,会根据什么条件查询?
- 商品名称关键字查询
- 分类
- 品牌
- 平台属性值
除此之外,我们可能还需要,做高亮查询,排序查询等
关键字查询
之所以把关键字查询放到bool查询的must部分,是因为我们认为,用户更关心的可能是目标商品和查询关键字的近似度,近似度越高可能月符合用户的预期。
#例如小米手机GET my_goods/_search{ "query": { "bool": { "must": [ { "match": { "title": "小米手机" } } ] } }}# 如果查询小米手机,业务上要求查询出的手机必须得是小米的POST my_goods/_search{ "query": { "bool": { "must": [ { "match": { "title": { "query": "小米手机", "operator": "and" } } } ] } }}过滤查询
对于品牌,平台属性,分类这些条件的查询,用户通常会希望商品和条件相同(所以就不关心,也不需要有近似度),所以这些查询条件,我们往往可以放到bool查询的filter部分。
#过滤查询-根据分类id#term 查询条件不分词GET my_goods/_search{ "query": { "bool": { "filter": [ { "term": { "thirdLevelCategoryId": "61" } } ] } }}#过滤查询-根据品牌GET my_goods/_search{ "query": { "bool": { "filter": [ { "term": { "tmId": "1" } } ] } }}#过滤查询-平台属性#查询 平台属性: 运行内存(id为23)为8g的商品GET my_goods/_search{ "query": { "bool": { "filter": [ { "nested": { "path": "attrs", "query": { "bool": { "filter": [ { "term": { "attrs.attrValue": "8G" } }, { "term": { "attrs.attrId": 23 } } ] } } } } ] } }}我们如果想要同时查询多个平台属性值,怎么办呢?比如同时查询运行内存8G,屏幕尺寸为6.3-6.59英寸的手机
GET my_goods/_search{ "query": { "bool": { "filter": [ { "nested": { "path": "attrs", "query": { "bool": { "must": [ { "term": { "attrs.attrValue": "8G" } }, { "term": { "attrs.attrId": 23 } } ] } } } }, { "nested": { "path": "attrs", "query": { "bool": { "must": [ { "term": { "attrs.attrValue": "6.3-6.59英寸" } }, { "term": { "attrs.attrId": 112 } } ] } } } } ] } }
}分页查询
#分页查询# from 从结果集的第几条开始返回 计算 开始的索引=(当前页-1)*每条数据# size 每页条数#位置POST my_goods/_search{ "query": { "match_all": {} }, "from": 6, "size": 2}排序查询
#排序查询-价格 ,热点#sortPOST my_goods/_search{ "query": { "match_all": {} }, "sort": [ { "price": { "order": "asc" } } ]}聚合查询
#聚合查询--品牌id#aggs#field 聚合的字段#size 显示聚合后的数据个数#注意在聚合的内部只能有一个aggs属性# 该聚合查询的目的是获取每一个品牌对应的品牌名称和品牌logo urlGET my_goods/_search{ "query": { "match_all": {} }, "aggs": { "tmIdAgg": { "terms": { "field": "tmId", "size": 10 }, "aggs": { "tmNameAgg": { "terms": { "field": "tmName", "size": 10 } }, "tmLogoUrlAgg":{ "terms": { "field": "tmLogoUrl", "size": 10 } } } } }}#聚合查询--平台属性聚合#特点--类型nested# 对nested对象做nested聚合,主要获取每一个平台属性的属性名和属性值POST my_goods/_search{ "query": { "match_all": {} }, "aggs": { "attrAgg": { "nested": { "path": "attrs" }, "aggs": { "attrIdAgg": { "terms": { "field": "attrs.attrId", "size": 10 }, "aggs": { "attrNameAgg": { "terms": { "field": "attrs.attrName", "size": 10 } }, "attrValueAgg":{ "terms": { "field": "attrs.attrValue", "size": 10 } } } } } } }}高亮查询
#高亮查询--title#前提条件: 必须是关键字匹配查询才有高亮POST my_goods/_search{ "query": { "match": { "title": "小米手机" } }, "highlight": { "fields": { "title": { "pre_tags": "<span style=color:red>", "post_tags": "</span>" } },
}
}指定查询结果中的字段
# 指定在包含查询结果文档数据的_source属性中,只包含id,defaultImg,title,price这几个字段POST my_goods/_search{ "query": { "match_all": {} }, "_source": [ "id", "defaultImg", "title", "price" ]}最终的查询DSL
GET my_goods/_search{ "query": { "bool": { "must": [ { "match": { "title": "荣耀手机" } } ], "filter": [ { "term": { "thirdLevelCategoryId": "61" } }, { "term": { "tmId": 3 } }, { "nested": { "path": "attrs", "query": { "bool": { "must": [ { "term": { "attrs.attrValue": "8G" } }, { "term": { "attrs.attrId": 23 } } ] } } } }, { "nested": { "path": "attrs", "query": { "bool": { "must": [ { "term": { "attrs.attrValue": "6.3-6.59英寸" } }, { "term": { "attrs.attrId": 112 } } ] } } } } ] } }, "from": 0, "size": 4, "sort": [ { "hotScore": { "order": "asc" } } ], "aggs": { "tmIdAgg": { "terms": { "field": "tmId", "size": 10 }, "aggs": { "tmNameAgg": { "terms": { "field": "tmName", "size": 10 } }, "tmLogoUrlAgg": { "terms": { "field": "tmLogoUrl", "size": 10 } } } }, "attrAgg": { "nested": { "path": "attrs" }, "aggs": { "attrIdAgg": { "terms": { "field": "attrs.attrId", "size": 10 }, "aggs": { "attrNameAgg": { "terms": { "field": "attrs.attrName", "size": 10 } }, "attrValueAgg": { "terms": { "field": "attrs.attrValue", "size": 10 } } } } } } }, "highlight": { "fields": { "title": { "pre_tags": "<span style=color:red>", "post_tags": "</span>" } } }, "_source": [ "id", "defaultImg", "title", "price" ]}Spring Data Elasticsearch
Spring Data Elasticsearch为文档的存储,查询,排序和统计提供了一个高度抽象的模板。使用Spring Data ElasticSearch来操作Elasticsearch,可以较大程度的减少我们的代码量,提高我们的开发效率。
要使用Elasticsearch我们需要引入如下依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> <version>2.7.17</version></dependency>还需要在配置文件中增加如下配置
spring: elasticsearch: rest: # es server的地址 uris: 192.168.0.102:9200 # 连接超时时间 connection-timeout: 6s # 访问超时时间 read-timeout: 10s定义文档映射实体类
类比于MyBatis-Plus可以定义实体类去映射数据库中的表中的数据,使用Spring Data Elasticsearch时,我们也可以通过定义一个实体类映射ES索引中的文档。
@Data@Document(indexName = "goods" , shards = 1,replicas = 0)public class Goods { // 商品Id skuId _id @Id private Long id;
@Field(type = FieldType.Keyword, index = false) private String defaultImg;
// es 中能分词的字段,这个字段数据类型必须是 text!keyword 不分词! @Field(type = FieldType.Text, analyzer = "ik_max_word") private String title;
@Field(type = FieldType.Double) private Double price;
@Field(type = FieldType.Long) private Long tmId;
@Field(type = FieldType.Keyword) private String tmName;
@Field(type = FieldType.Keyword) private String tmLogoUrl;
@Field(type = FieldType.Long) private Long firstLevelCategoryId;
@Field(type = FieldType.Keyword) private String firstLevelCategoryName;
@Field(type = FieldType.Long) private Long secondLevelCategoryId;
@Field(type = FieldType.Keyword) private String secondLevelCategoryName;
@Field(type = FieldType.Long) private Long thirdLevelCategoryId;
@Field(type = FieldType.Keyword) private String thirdLevelCategoryName;
// 商品的热度! 我们将商品被用户点查看的次数越多,则说明热度就越高! @Field(type = FieldType.Long) private Long hotScore = 0L;
// 平台属性集合对象 // Nested 支持嵌套查询 @Field(type = FieldType.Nested) private List<SearchAttr> attrs;
}/* 该类映射nested平台属性*/@Datapublic class SearchAttr { // 平台属性Id @Field(type = FieldType.Long) private Long attrId; // 平台属性值名称 @Field(type = FieldType.Keyword) private String attrValue; // 平台属性名 @Field(type = FieldType.Keyword) private String attrName;}在Goods类上,通过添加@Document注解,我们将Goods类映射的文档所属的索引:
- @Document注解的index属性,用来定义实体类所映射的文档所属的目标索引名称
- @Document的shards属性,表示目标索引的住分片数量
- @Document的replicas属性,表示每个主分片所拥有的副本分片的数量
在Goods类的成员变量Id上通过添加@Id注解指定,Id成员变量映射到Goods索引中文档的id字段,同时也映射到文档的唯一表示_id字段。
在Goods类的其他成员变量上,通过添加@Field注解,定义成员变量和文档字段的映射关系:
- 默认同名成员变量,映射到文档中的同名字段(也可以由@Field注解的name属性显示指定)
- 通过@Field注解的type属性指定文档中同名字段的数据类型
- 通过@Field注解的analyzer属性,指定成员变量所映射的文档字段所使用的的分词器
Repository
类比于Mybatis-Plus中定义BaseMaper子接口即可对单表做增删改查的操作,Spring Data Elastisearch中我们可以通过定义ElasticsearchRepository子接口,迅速实现对索引中的文档数据的增删改查,以及通过自定义方法,实现自定义查询。
public interface GoodsRepository extends ElasticsearchRepository<Goods,Long> {
}ElasticsearchRepository接口需要接收两个泛型,第一个泛型即映射实体类,第二个泛型是在实体类中加了@Id注解的成员变量的数据类型,即映射到文档唯一标识_id字段的成员变量类型。
一旦我们定义好了ElasticsearchRepository的子接口,马上就可以实现对goods索引中文档的增删改查功能
// 注入repository对象 @Autowired private GoodsRepository goodsRepository;
// 保存单个文档对象 Goods good = .... goodsRepository.save(good);
// 批量保存多个文档对象 List<Goods> goods = ... goodsRepository.save(goods);
// 根据id查询 goodsRepository.findById(id);
// 根据id删除 goodsRepository.deleteById(id);同时,我们还需要注意一点,一旦我们定义好了ElasticsearchRepository接口,而且被SpringBoot启动类扫描到,那么在应用启动的时候,如果ElasticsearchRepository子接口所访问的索引在ES中不存在,Spring Data Elasticsearch会在ES中自动创建索引,并根据映射实体类定义索引的映射。
但是,大多数时候,我们可能需要对索引中的文档数据做自定义查询,此时仅仅使用ElasticsearchRepository接口中继承的方法无法满足我们的需求。此时我们就需要在自己的Repository接口中,通过自定义方法来实现各种自定义查询。
- 利用@Query注解自定义查询脚本
/* 1. 通过Query注解定义具体的查询字符串(也可以替换为其他查询) 2. 字符串中的?0是固定格式,表示第0个参数的占位符,在实际查询时会被方法的第一个参数值title的值替换, 如果有多个参数,依次类推即可 3. List<Goods> */ @Query( "{ " + "\"match\": {\n" + " \"title\": \"?0\"\n" + "}" + "}" ) List<Goods> matchSearch(String title);这里的@Query注解中,只需要包含我们查询脚本中”query”{} 里面的内容即可,比如上面的@Query注解所表示的查询等价于
GET goods/_search{ "query": { "match": { "title": 具体待查询的参数值 } }} @Autowired ProductRepository productRepository;
@Test public void testMatchSearch() { // 在调用的时候传递查询的参数值 List<Goods> list = goodsRepository.matchSearch("荣耀手机"); System.out.println(list); }- 利用@Query注解结合分页参数,实现分页查询
/* 1. 针对一个查询结果,返回对应的一页数据 2. Pageable参数是当想要获取分页数据的时候,必须携带的参数,表示分页信息 比如,查询第多少页数据,每页多少条数据等,该参数不会用来替换我们的@Query字符串中的参数 3. 返回的结果是一个包含一页文档数据的Page对象 */ @Query( " {" + " \"match\": {\n" + " \"title\": \"?0\"\n" + " }" + "}" ) Page<Goods> testSearchPage(String title, Pageable pageable); // 注入repository对象 @Autowired private GoodsRepository goodsRepository; /* 测试分页查询 */ @Test public void testSearchPage() { // 创建表示分页信息的Pageable对象 // 表示查询第几页数据,这里一定要注意,页数是从0开始算的 int page = 0; // 每页假设10个文档 int pageCount = 10;
// 调用Sort方法得到Sort对象,一个Sort对象表示 Sort sort = Sort.by(Sort.Direction.ASC, "price"); // PageRequest 是 Pageable接口子类对象 PageRequest pageInfo = PageRequest.of(page, pageCount,sort);
// 这里的Page对象可以被看做是List Page<Goods> pageResult = goodsRepository.testSearchPage("小米手机", pageInfo);
// 遍历集合,从每个SearchHit对象中取出文档对象, // 如果需要返回可以在遍历的时候将其,放入一个List中返回 pageResult.forEach( goods -> { // 访问查询到的一条文档
// ... });
// 获取满足条件的总的文档数量 long totalElements = pageResult.getTotalElements();
}- @Query注解 + @Highlight + 分页参数实现高亮,分页自定义查询
@Query( " { \"match\": {\n" + " \"title\": \"?0\"\n" + " }" + "}" ) @Highlight( fields = { @HighlightField(name = "title", parameters = @HighlightParameters( preTags = "<font color='red'>", postTags = "</font>")) } ) List<SearchHit<Goods>> testHighlight(String title, Pageable pageable);@Test public void testHighlight() { // 分页参数 PageRequest of = PageRequest.of(0, 10);
// 调用Repository方法获取搜索结果 List<SearchHit<Goods>> result = goodsRepository.testHighlight("小米手机", of);
// 结果集 List<Goods> itemDocuments = new ArrayList<>();
result.forEach(hit -> { // 获取目标文档 Goods content = hit.getContent(); // 获取高亮字段title对应的高亮字符串 List<String> title = hit.getHighlightField("title"); // 在文档对象中,用高亮字符串替换掉原来的值 content.setTitle(title.get(0)); // 加入结果集 itemDocuments.add(content); });
System.out.println(itemDocuments.size());
}虽然,testHighlight方法既实现了分页查询,又实现了高亮查询,但是有一个缺陷就是,该方法无法获取到满足查询条件的总的文档数量,它只会返回满足条件的一页文档数据。不知道满足条件的文档总数,前端就无法完成分页。
所以,很明显Repository好用,但是具有一定的局限性,如果面对比较复杂的查询,此时就只能使用Spring Data Elasticsearch提供的另外一个工具ElasticsearchRestTemplate了。
ElasticsearchRestTemplate
构造自定义分页,高亮,nested以及聚合查询,并发起请求
@Autowired ElasticsearchRestTemplate restTemplate;
@Autowired GoodsConverter goodsConverter;
@Test public void testRestTemplate() { // 该Builder包含所有搜索请求的参数 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 获取bool查询Builder BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 构造bool查询中match查询 MatchQueryBuilder matchQuery = QueryBuilders.matchQuery("title", "小米手机"); // 将该查询加入bool查询must中 boolQueryBuilder.must(matchQuery);
TermQueryBuilder subQueryForAttrNested = QueryBuilders.termQuery("attrs.attrValue", "8G"); // 构造nested查询 NestedQueryBuilder attrsNestedQuery = QueryBuilders.nestedQuery("attrs", subQueryForAttrNested, ScoreMode.None); // 将nested查询作为一个过滤条件 boolQueryBuilder.filter(attrsNestedQuery);
// 将整个bool查询添加到NativeSearchQueryBuilder queryBuilder.withQuery(boolQueryBuilder);
// 构造分页参数 //PageRequest price = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("price"))); PageRequest price = PageRequest.of(0, 10); // 向NativeSearchQueryBuilder添加分页参数 queryBuilder.withPageable(price);
// 按照指定字段值排序 FieldSortBuilder priceSortBuilder = SortBuilders.fieldSort("price").order(SortOrder.ASC); queryBuilder.withSort(priceSortBuilder);
// 构造高亮参数 HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("title").preTags("<font color='red'>").postTags("</font>"); // 向NativeSearchQueryBuilder添加高亮参数 queryBuilder.withHighlightBuilder(highlightBuilder);
// 设置品牌聚合(平台属性等的聚合也是相同的方式) TermsAggregationBuilder termsAggregationBuilder = AggregationBuilders.terms("tmIdAgg").field("tmId") .subAggregation(AggregationBuilders.terms("tmNameAgg").field("tmName")) .subAggregation(AggregationBuilders.terms("tmLogoUrlAgg").field("tmLogUrl"));
// 向NativeSearchQueryBuilder添加聚合参数 queryBuilder.addAggregation(termsAggregationBuilder);
// 设置文档中返回的字段 FetchSourceFilter fetchSourceFilter = new FetchSourceFilter(new String[]{"id", "defaultImg", "title", "price"}, null); queryBuilder.withSourceFilter(fetchSourceFilter);
// 使用ElasticsearchRestTemplate发起搜索请求 NativeSearchQuery build = queryBuilder.build(); SearchHits<Goods> search = restTemplate.search(build, Goods.class);
//封装所有的查询数据 SearchResponseDTO searchResponseDTO = new SearchResponseDTO();
// 获取满足条件的总文档数量 long totalHits = search.getTotalHits(); // 设置查询到的总文档条数 searchResponseDTO.setTotal(totalHits);
// 获取包含所有命中文档的SearchHit对象 List<SearchHit<Goods>> searchHits = search.getSearchHits();
// 处理搜索到的结果集即SearchHit<Goods>集合, 并使用高亮字符串替换 List<GoodsDTO> goodsList = searchHits.stream().map(hit -> { // 获取命中的文档 Goods content = hit.getContent();
//获取高亮字段 List<String> title = hit.getHighlightField("title");
// 用高亮字段替换 content.setTitle(title.get(0)); // 将Goods对象转化为GoodsDTO对象 GoodsDTO goodsDTO = goodsConverter.goodsPO2DTO(content); return goodsDTO; }).collect(Collectors.toList());
// 设置查询到的结果列表 searchResponseDTO.setGoodsList(goodsList);
// 从品牌聚合中获取品牌集合
// 根据id获取品牌id terms聚合结果 Terms terms = (Terms) searchResponse.getAggregations().aggregations().get("tmIdAgg"); List<SearchResponseTmDTO> trademarkList = terms.getBuckets().stream().map(tmIdBucket -> { // 封装品牌数据 SearchResponseTmDTO searchResponseTmDTO = new SearchResponseTmDTO();
String tmIdStr = tmIdBucket.getKeyAsString(); // 获取品牌id Long tmId = Long.parseLong(tmIdStr); // 设置品牌id searchResponseTmDTO.setTmId(tmId);
// 获取品牌名称聚合(子聚合) Terms tmNameAgg = tmIdBucket.getAggregations().get("tmNameAgg"); // 通过聚合桶的名称获取品牌名称 String tmName = tmNameAgg.getBuckets().get(0).getKeyAsString(); // 设置品牌名称 searchResponseTmDTO.setTmName(tmName);
// 获取品牌logo聚合(子聚合) Terms tmLogoUrlAgg = tmIdBucket.getAggregations().get("tmLogoUrlAgg"); // 通过聚合桶的名称获取品牌名称 String tmLogoUrl = tmLogoUrlAgg.getBuckets().get(0).getKeyAsString(); // 设置品牌名称 searchResponseTmDTO.setTmLogoUrl(tmLogoUrl);
return searchResponseTmDTO; }).collect(Collectors.toList());
// 设置聚合品牌数据 searchResponseDTO.setTrademarkList(trademarkList);
// .....
}引入搜索服务

商品上下架
我们什么时候向ES中添加SKU商品数据呢?因为一个商品一旦上架,就应该是可以被搜索到的,所以当SKU商品上架的时候,我们需要向ES添加SKU商品数据。同理,当一个SKU商品下架的时候,该商品就不应该再被用户搜索到了,所以,当一个SKU商品下架的时候,我们应该从ES中删除该SKU商品的信息。
所以接下来在service-product中,我们需要修改上下架的SKU商品的代码
@RestController@RequestMapping("api/list")public class ListApiController {
@Autowired private SearchService searchService;
@Autowired private ElasticsearchRestTemplate restTemplate;
/** * 上架商品 * @param skuId * @return */ @GetMapping("inner/upperGoods/{skuId}") public Result upperGoods(@PathVariable("skuId") Long skuId) { }
/** * 下架商品 * @param skuId * @return */ @GetMapping("inner/lowerGoods/{skuId}") public Result lowerGoods(@PathVariable("skuId") Long skuId) { }}public interface SearchService {
/** * 上架商品列表 * @param skuId */ void upperGoods(Long skuId);
/** * 下架商品列表 * @param skuId */ void lowerGoods(Long skuId);}@Servicepublic class SearchServiceImpl implements SearchService {
@Autowired private ProductApiClient productFeignClient;
@Autowired private GoodsRepository goodsRepository;
@Autowired GoodsConverter goodsConverter;
/** * 上架商品 * * @param skuId */ @Override public void upperGoods(Long skuId) { Goods goods = new Goods(); //查询sku对应的平台属性 List<PlatformAttributeInfoDTO> platformAttrInfoList = productFeignClient.getAttrList(skuId); if (null != platformAttrInfoList) { List<SearchAttr> searchAttrList = platformAttrInfoList.stream().map(baseAttrInfo -> { SearchAttr searchAttr = new SearchAttr(); searchAttr.setAttrId(baseAttrInfo.getId()); searchAttr.setAttrName(baseAttrInfo.getAttrName()); //一个sku的一个平台属性,只对应一个属性值 List<PlatformAttributeValueDTO> attrValueList = baseAttrInfo.getAttrValueList(); searchAttr.setAttrValue(attrValueList.get(0).getValueName()); return searchAttr; }).collect(Collectors.toList());
goods.setAttrs(searchAttrList); }
//查询sku信息 SkuInfoDTO skuInfoDTO = productFeignClient.getSkuInfo(skuId); // 查询品牌 TrademarkDTO baseTrademark = productFeignClient.getTrademark(skuInfoDTO.getTmId()); if (baseTrademark != null) { goods.setTmId(skuInfoDTO.getTmId()); goods.setTmName(baseTrademark.getTmName()); goods.setTmLogoUrl(baseTrademark.getLogoUrl());
}
// 查询分类 CategoryHierarchyDTO categoryView = productFeignClient.getCategoryView(skuInfoDTO.getThirdLevelCategoryId()); if (categoryView != null) { goods.setFirstLevelCategoryId(categoryView.getFirstLevelCategoryId()); goods.setFirstLevelCategoryName(categoryView.getFirstLevelCategoryName()); goods.setSecondLevelCategoryId(categoryView.getSecondLevelCategoryId()); goods.setSecondLevelCategoryId(categoryView.getSecondLevelCategoryName()); goods.setThirdLevelCategoryId(categoryView.getThirdLevelCategoryId()); goods.setThirdLevelCategoryName(categoryView.getThirdLevelCategoryName()); } goods.setDefaultImg(skuInfoDTO.getSkuDefaultImg()); goods.setPrice(skuInfoDTO.getPrice().doubleValue()); goods.setId(skuInfoDTO.getId()); goods.setTitle(skuInfoDTO.getSkuName());
goodsRepository.save(goods); }
/** * 下架商品 * * @param skuId */ @Override public void lowerGoods(Long skuId) { // 删除文档 this.goodsRepository.deleteById(skuId);
// 删除热度 redissonClient.getScoredSortedSet("hotScore").remove("skuId:" + skuId); }}这里需要注意一点的是,我们并没有在商品服务中定义FeignClient来调用商品服务,而是在后面学习消息中间件之后,利用发送消息的方式,通知搜索服务,实现SKU商品在ES中的上下架。
更新商品热度
在ES中,我们希望在用户搜索到SKU商品后,我们可以按照SKU商品的热度排序,将最热门的商品排在前面。那么如何定义商品的热度呢?我们以商品的浏览量来定义商品的热度。因此每当一个SKU商品的详情数据被获取一次,我们就给它的热度加1。
@GetMapping("api/list/inner/incrHotScore/{skuId}") public Result incrHotScore(@PathVariable("skuId") Long skuId) { }/* 该类用来ES根据条件查询到的每一个SKU商品信息 */@Datapublic class GoodsDTO {
// 商品Id skuId private Long id;
// ...
// 商品的热度! 我们将商品被用户点查看的次数越多,则说明热度就越高! private Long hotScore = 0L;}public interface SearchService {
// ...
/** * 更新热点 * @param skuId */ void incrHotScore(Long skuId);}@Servicepublic class SearchServiceImpl implements SearchService {
@Autowired private ProductApiClient productFeignClient;
@Autowired private GoodsRepository goodsRepository;
@Autowired GoodsConverter goodsConverter;
//...
@Autowired private RedissonClient redissonClient;
@Override public void incrHotScore(Long skuId) { // 定义key String hotKey = "hotScore"; // 保存数据 Double hotScore = redissonClient.getScoredSortedSet(hotKey).addScore("skuId:" + skuId, 1); if (hotScore.longValue() % 10 == 0) { // 更新es Optional<Goods> optional = goodsRepository.findById(skuId); Goods goods = optional.get(); goods.setHotScore(Math.round(hotScore)); goodsRepository.save(goods); } }}我们需要在每次获取商品详情数据的时候,增加该商品的热度(该SKU商品对应的文档的hotScore),所以在商品服务中我们需要定义如下FeignClient用来调用搜索服务。
@FeignClient("service-search")public interface SearchApiClient {
/** * 更新商品incrHotScore * @param skuId * @return */ @GetMapping("/api/list/inner/incrHotScore/{skuId}") Result incrHotScore(@PathVariable("skuId") Long skuId);}在商品服务中,需要修改获取商品详情页的代码,在增加SKU商品的热度
@Servicepublic class ItemServiceImpl implements ProductDetailService {
@Autowired SpuService spuService;
@Autowired SkuService skuService;
@Autowired CategoryService categoryService; @Autowired private ThreadPoolExecutor threadPoolExecutor;
@Autowired SearchApiClient searchApiClient;
@Autowired RedissonClient redissonClient;
@Override public ProductDetailDTO getItemBySkuId(Long skuId) { // ..., 增加热度 searchApiClient.incrHotScore(skuId); return productDetailDTO; }}条件查询

在搜索服务中,实现搜索接口
@GetMapping("/list") public Result list(SearchParam searchParam) throws IOException {
}public interface SearchService {
/** * 搜索列表 * @param searchParam * @return * @throws IOException */ SearchResponseDTO search(SearchParam searchParam) throws IOException;}// 封装查询条件@Datapublic class SearchParam {/*前端携带的请求参数:?thirdLevelCategoryId=61&trademark=2:华为&props=23:4G:运行内存&order=1:desc1. 三级类目:thirdLevelCategoryId=612. 品牌参数(可能有多个):trademark=2:华为 2是品牌id,华为是品牌名称3. 规格参数(平台属性,可能有多个): props=23:4G:运行内存,23是属性id,4G是平台属性值,运行内存是属性名4. 排序参数:order=1:desc,1表示按照热度排序,2表示按照价格排序*/ private Long firstLevelCategoryId; private Long secondLevelCategoryId; private Long thirdLevelCategoryId; // trademark=2:华为 private String trademark;//品牌
private String keyword;//检索的关键字
// 排序规则 // 1:hotScore 2:price private String order = ""; // 1:综合排序/热度 2:价格
// props=23:4G:运行内存 //平台属性Id 平台属性值名称 平台属性名 private String[] props;//页面提交的数组
private Integer pageNo = 1;//分页信息 private Integer pageSize = 3; // 每页默认显示的条数} @Override public SearchResponseDTO search(SearchParam searchParam) throws IOException { // 构建dsl语句 NativeSearchQueryBuilder nativeSearchQueryBuilder = buildQueryDsl(searchParam); NativeSearchQuery searchQuery = nativeSearchQueryBuilder.build(); SearchHits<Goods> searchResults = restTemplate.search(searchQuery, Goods.class);
//解析查询结果 SearchResponseDTO responseDTO = parseSearchResult(searchResults);
//设置满足条件的总记录数 responseDTO.setTotal(searchResults.getTotalHits()); // 响应中设置一页的文档数量 responseDTO.setPageSize(searchParam.getPageSize()); // 响应中设置当前页数 responseDTO.setPageNo(searchParam.getPageNo()); // 计算总页数 long totalPages = (responseDTO.getTotal() + searchParam.getPageSize() - 1) / searchParam.getPageSize(); if (totalPages == 0) { // 前端的页数是从1开始的 totalPages = 1; } responseDTO.setTotalPages(totalPages); return responseDTO; } // 制作dsl 语句 private NativeSearchQueryBuilder buildQueryDsl(SearchParam searchParam) { // 构建查询器 NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder();
// 构建boolQueryBuilder BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); // 构造关键字查询参数 buildKeyQuery(searchParam, boolQueryBuilder); // 构建品牌查询 trademark=2:华为 buildTrademarkQuery(searchParam, boolQueryBuilder);
// 构造类目查询 buildCategoryQuery(searchParam, boolQueryBuilder);
// 构建平台属性查询 23:4G:运行内存(可能有多个查询,这个例子只是一个平台属性或者叫规格参数) BuildSpecificQuery(searchParam, boolQueryBuilder); // 设置整个复合查询 nativeSearchQueryBuilder.withQuery(boolQueryBuilder); // 构建分页(es中的页数从0开始) PageRequest pageRequest = PageRequest.of(searchParam.getPageNo() - 1, searchParam.getPageSize()); nativeSearchQueryBuilder.withPageable(pageRequest);
// 构造排序参数 order=1:desc 1为按热度排序,2为按照价格排序 buildSort(searchParam, nativeSearchQueryBuilder);
// 构建高亮 HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("title").postTags("</span>").preTags("<span style=color:red>"); nativeSearchQueryBuilder.withHighlightBuilder(highlightBuilder);
// 构造品牌聚合 TermsAggregationBuilder termsAggregationBuilder = AggregationBuilders.terms("tmIdAgg").field("tmId") .subAggregation(AggregationBuilders.terms("tmNameAgg").field("tmName")) .subAggregation(AggregationBuilders.terms("tmLogoUrlAgg").field("tmLogoUrl")); // 设置品牌聚合 nativeSearchQueryBuilder.addAggregation(termsAggregationBuilder);
// 构造平台属性聚合 NestedAggregationBuilder nestedAggregationBuilder = AggregationBuilders.nested("attrAgg", "attrs") .subAggregation(AggregationBuilders.terms("attrIdAgg").field("attrs.attrId") .subAggregation(AggregationBuilders.terms("attrNameAgg").field("attrs.attrName")) .subAggregation(AggregationBuilders.terms("attrValueAgg").field("attrs.attrValue"))); // 设置平台属性聚合 nativeSearchQueryBuilder.addAggregation(nestedAggregationBuilder);
// 设置结果集过滤 String[] fields = {"id","title","price","defaultImg"}; FetchSourceFilter fetchSourceFilter = new FetchSourceFilter(fields,null); nativeSearchQueryBuilder.withSourceFilter(fetchSourceFilter);
return nativeSearchQueryBuilder; }文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!