全文检索

7277 字
36 分钟
全文检索

功能介绍#

首页点击分类

在搜索框搜索关键字

搜索到的目标商品列表页

准备搜索数据#

当点击首页分类,或者在搜索框中搜索商品的时候,实际上都是在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
}

排序查询#

#排序查询-价格 ,热点
#sort
POST my_goods/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"price": {
"order": "asc"
}
}
]
}

聚合查询#

#聚合查询--品牌id
#aggs
#field 聚合的字段
#size 显示聚合后的数据个数
#注意在聚合的内部只能有一个aggs属性
# 该聚合查询的目的是获取每一个品牌对应的品牌名称和品牌logo url
GET 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平台属性
*/
@Data
public 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);
}
@Service
public 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商品信息
*/
@Data
public class GoodsDTO {
// 商品Id skuId
private Long id;
// ...
// 商品的热度! 我们将商品被用户点查看的次数越多,则说明热度就越高!
private Long hotScore = 0L;
}
public interface SearchService {
// ...
/**
* 更新热点
* @param skuId
*/
void incrHotScore(Long skuId);
}
@Service
public 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商品的热度

@Service
public 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;
}
// 封装查询条件
@Data
public class SearchParam {
/*
前端携带的请求参数:?thirdLevelCategoryId=61&trademark=2:华为&props=23:4G:运行内存&order=1:desc
1. 三级类目:thirdLevelCategoryId=61
2. 品牌参数(可能有多个):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;
}

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

全文检索
https://firefly-mu-weld.vercel.app/posts/microservice-19-full-text-search/
作者
Daisy
发布于
2026-06-14
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录