Skip to content

首页&全文检索 ​

![](首页 & 全文检索.assets/电商项目概览-首页.png)

1. 首页商品分类 ​

在电商页面首页,我们会展示电商网站中所有的一级商品类目,一级商品类目对应的二级商品类目,二级商品类目对应的三级商品类目。

![](首页 & 全文检索.assets/首页商品类目.png)

所以我们必须在访问首页时,查询到所有商品类目,一级对应的层级关系,具体的请求链路如下:

![](首页 & 全文检索.assets/商品详情.png)

首页返回的完整的三级类目及层级关系的json数据

json
[
  {
    "index": 1,
    "categoryId": 1,
    "categoryName": "图书、音像、电子书刊", # 一级分类的name
    "categoryChild": [
      {
       "categoryId": 1,
       "categoryName": "电子书刊", #二级分类的name
        "categoryChild": [
          {
            "categoryName": "电子书", # 三级分类的name
            "categoryId": 1
          },
          {
            "categoryName": "网络原创", # 三级分类的name
            "categoryId": 2
          },
          ...
        ],
      },
     ...
    ],
  },
  ...
"index": 2,
    "categoryChild": [
      {
        "categoryChild": [
          {
            "categoryName": "超薄电视", # 三级分类的name
            "categoryId": 1
          },
          {
            "categoryName": "全面屏电视", # 三级分类的name
            "categoryId": 2
          },
          ...

在service-product中,我们需要定义Controller,来接收处理获取首页分类数据请求的Controller

java
    /**
     * 获取全部分类信息
     * @return
     */
    @GetMapping("/index")
    public Result getBaseCategoryList(){
        List<FirstLevelCategoryNodeDTO> categoryTreeList = categoryService.getCategoryTreeList();
        return Result.ok(categoryTreeList);
    }

还要定义业务层接口,以及业务层实现类

java
    /*
          获取完整的三级类目信息
     */
    List<FirstLevelCategoryNodeDTO> getCategoryTreeList();
java
@Data
public class FirstLevelCategoryNodeDTO {
    // 一级目录id
    Long categoryId;

    // 一级目录名称
    String categoryName;

    // 一级目录的位序
    Integer index;

    // 一级目录所属的二级目录列表
    List<SecondLevelCategoryNodeDTO> categoryChild;

}
java
@Data
public class SecondLevelCategoryNodeDTO {

    // 二级目录id
    Long categoryId;
    
    // 二级目录名称
    String categoryName;

    // 二级目录所包含的三级目录列表
    List<ThirdLevelCategoryNodeDTO> categoryChild;
}
java
public class ThirdLevelCategoryNodeDTO {
	// 三级目录id
    private Long categoryId;
    // 三级目录名称
    private String categoryName;
}

在实现代码之前我们先梳理写思路:

  • 先查询出所有的一二三级类目的关联关系,得到 List<CategoryHierarchy>
sql
        select
        c1.id as first_level_category_id, c1.name as first_level_category_name,
        c2.id as second_level_category_id, c2.name as second_level_category_name,
        c3.id as third_level_category_id, c3.name as third_level_category_name
        from first_level_category c1
        inner join second_level_category c2 on c2.first_level_category_id = c1.id
        inner join third_level_category c3 on c3.second_level_category_id = c2.id
        <where>
          <if test="thirdLevelCategoryId != null">
              c3.id=#{thirdLevelCategoryId}
          </if>
        </where>

![](首页 & 全文检索.assets/商业所有类目关联数据表.png)

  • 将上述表示所有一二三级关联关系的List,根据一级类目Id分组得到Map<Long, List<CategoryHierarchy>,Map中的key表示一级类目Id,value表示一级类目所包含的二级类目信息集合。
  • 针对Map中的每一个key,以及Map的value,我们可以构造出表示一级类目的FirstLevelCategoryNodeDTO对象,以及构造出属于该一级类目的二级类目集合,即List<SecondLevelCategoryNodeDTO>
  • 同理,二级类目还包含多个三级类目,如何得到二级类目包含的三级类目集合呢?继续针对属于同一个一级类目的List<CategoryHierarchy>,继续根据二级类目Id分组,分组之后,又得到一个Map<Long, List<CategoryHierarchy>>,Map中的key表示二级类目Id,value表示二级目录所包含的三级类目信息集合
  • 针对该Map中的每一个Key,以及Map中的value,我们可以构造出表示二级类目的SecondLevelCategoryNodeDTO对象,以及二级类目所属的三级类目集合,即List<ThirdLevelCategoryNodeDTO>
  • 这样一来,一级类目FirstLevelCategoryNodeDTO,一级类目包含的二级类目集合List<SecondLevelCategoryNodeDTO>,以及每一个二级类目SecondLevelCategoryNodeDTO,二级类目包含的三级类目集合List<ThirdLevelCategoryNodeDTO>就都有了
java
    @Override
    @RedisCache(prefix = "category")
    public List<FirstLevelCategoryNodeDTO> getCategoryTreeList() {
        // 声明几个json 集合
        ArrayList<FirstLevelCategoryNodeDTO> firstLevelCategoryTreeNodes = new ArrayList<>();
        // 声明获取所有分类数据集合
        List<CategoryHierarchy> categoryHierarchyList = categoryHierarchyMapper.selectCategoryHierarchy(null);
        // 循环上面的集合并安一级分类Id 进行分组
        Map<Long, List<CategoryHierarchy>> firstLevelCategoryMap  = categoryHierarchyList.stream().collect(Collectors.groupingBy(CategoryHierarchy::getFirstLevelCategoryId));
        int index = 1;
        // 获取一级分类下所有数据
        for (Map.Entry<Long, List<CategoryHierarchy>> firstLevelEntry  : firstLevelCategoryMap.entrySet()) {
            // 获取一级分类Id
            Long firstLevelCategoryId  = firstLevelEntry.getKey();
            // 获取一级分类下面的所有集合
            List<CategoryHierarchy> firstLevelCategories  = firstLevelEntry.getValue();
            //
            FirstLevelCategoryNodeDTO firstLevelCategoryNode = new FirstLevelCategoryNodeDTO();
            firstLevelCategoryNode.setIndex(index);
            firstLevelCategoryNode.setCategoryId(firstLevelCategoryId);
            firstLevelCategoryNode.setCategoryName(firstLevelCategories.get(0).getFirstLevelCategoryName());
            // 变量迭代
            index++;
            List<SecondLevelCategoryNodeDTO> secondLevelCategoryNodes = buildSecondLevelCategoryNodeDTOs(firstLevelCategories);

            // 将二级数据放入一级里面
            firstLevelCategoryNode.setCategoryChild(secondLevelCategoryNodes);
            // 将一级类目放入最终结果集
            firstLevelCategoryTreeNodes.add(firstLevelCategoryNode);
        }
        return firstLevelCategoryTreeNodes;
    }
java
 private List<SecondLevelCategoryNodeDTO> buildSecondLevelCategoryNodeDTOs(List<CategoryHierarchy> firstLevelCategories) {

        // 声明二级分类对象集合
        List<SecondLevelCategoryNodeDTO> secondLevelCategoryNodes = new ArrayList<>();

        // 循环获取二级分类数据
        Map<Long, List<CategoryHierarchy>> firstLevelCategoryChildrenMap  = firstLevelCategories.stream()
                .collect(Collectors.groupingBy(CategoryHierarchy::getSecondLevelCategoryId));

        // 循环遍历
        for (Map.Entry<Long, List<CategoryHierarchy>> secondLevelEntry  : firstLevelCategoryChildrenMap.entrySet()) {
            // 获取二级分类Id
            Long secondLevelCategoryId  = secondLevelEntry.getKey();
            // 获取二级分类下的所有集合
            List<CategoryHierarchy> secondLevelCategories  = secondLevelEntry.getValue();
            // 声明二级分类对象
            SecondLevelCategoryNodeDTO secondLevelCategoryNode = new SecondLevelCategoryNodeDTO();
            secondLevelCategoryNode.setCategoryId(secondLevelCategoryId);
            secondLevelCategoryNode.setCategoryName(secondLevelCategories.get(0).getSecondLevelCategoryName());

            List<ThirdLevelCategoryNodeDTO> thirdLevelCategoryNodes = buildThirdLevelCategoryNodes(secondLevelCategories);
            // 将三级类目列表放入二级类目中
            secondLevelCategoryNode.setCategoryChild(thirdLevelCategoryNodes);

            // 添加到二级分类集合
            secondLevelCategoryNodes.add(secondLevelCategoryNode);
        }
        return secondLevelCategoryNodes;
    }
java
    private List<ThirdLevelCategoryNodeDTO> buildThirdLevelCategoryNodes(List<CategoryHierarchy> secondLevelCategories) {
        // 循环三级分类数据, 封装为ThirdLevelCategoryNodeDTO
        List<ThirdLevelCategoryNodeDTO> thirdLevelCategoryNodeDTOs = secondLevelCategories.stream().map(categoryHierarchy -> {
            ThirdLevelCategoryNodeDTO thirdLevelCategoryNode = new ThirdLevelCategoryNodeDTO();
            thirdLevelCategoryNode.setCategoryId(categoryHierarchy.getThirdLevelCategoryId());
            thirdLevelCategoryNode.setCategoryName(categoryHierarchy.getThirdLevelCategoryName());
            return thirdLevelCategoryNode;
        }).collect(Collectors.toList());

        return thirdLevelCategoryNodeDTOs;
    }

2. 全文检索 &#8203;

![](首页 & 全文检索.assets/电商详情概览-全文检索.png)

首页点击分类

![](首页 & 全文检索.assets/首页-点击商品类目.png)

在搜索框搜索关键字

![](首页 & 全文检索.assets/首页-搜索框.png)

搜索到的目标商品列表页

![](首页 & 全文检索.assets/首页-搜索列表标注版本.png)

2.1 准备搜索数据 &#8203;

当点击首页分类,或者在搜索框中搜索商品的时候,实际上都是在ES中完成搜索的,所以在搜索之前我们必须现将商品相关的数据,放入到ES的索引中,才能完成搜索。

2.1.1 创建索引和映射 &#8203;

我们首先梳理下,在搜索结果展示的搜索列表页中,需要展示哪些数据:

  • 所有搜索到的商品具有的品牌,以及品牌logo
  • 所有搜索到的商品具有的平台属性,以及对应的平台属性值
  • SKU商品的名称
  • SKU商品的价格
  • SKU商品的默认图片

很显然,所有这些数据都需要包含在,搜索结果中,因此ES的索引中也必须要有这些数据。但是在定义索引,以及映射之前,我们要先解决一个小问题:

  • 一个SKU商品有具有多个平台属性

  • 每一个平台属性又可能包含了平台属性id,平台属性名称,平台属性取值

那么一个SKU商品的平台属性值如何在JSON文档中表示呢?如果写出一个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商品的平台属性,我们就需要用到对象类型了。我们学习一个简单的例子,创建如下索引

json
PUT user
{
  "mappings": {
    "properties": {
      "name": {
        "properties": {
          "first": {"type": "text"},
          "last":  {"type": "text"}
        }
      }
    }
  }
}

向索引中添加一条文档数据如下:

json
POST user/_doc/1
{
  "name": [
     { 
      "first": "John",
      "last": "Smith"
     }, 
     {
      "first": "Alice",
      "last": "White"
     }
  ]
}

查询索引中的数据如下:

![](首页 & 全文检索.assets/全文检索-文档数据.png)

接下来,我们做一个简单的查询,查询下first name为Alice,last name为Smith的文档。

json
GET user/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name.first": "Alice" }},
        { "match": { "name.last":  "Smith" }}
      ]
    }
  }
}

查询结果如下:

![](首页 & 全文检索.assets/全文检索-对象查询.png)

看到这个结果,和我们的预期是不一样的,因为文档中并没有包含一个人的first name为Alice,last name为Smith。那么又为什么会有这样的结果呢?原因就在于,对于普通的对象数据,ES内部其实是这样来存储的:

json
{
   ...
   "_source" : {
      "name.first" : ["John", "Alice"],
      "name.last" : ["Smith", "White"]
    }
}

所以,再结合上面的查询条件,我们很容易就可以理解了,为什么命名没有一个人的first name叫Alice,last name叫White,但我们偏偏还是查询到了文档。

之所以会出现这种意料之外的情况,是因为使用对象类型可能会出现跨对象匹配,其实应该是,第一个人(对象)的last和“Smith”匹配,第二个人(对象)的first和“Alice匹配”,为了避免这种跨对象匹配的情况,我们需要使用nested类型,通过nested类型定义的对象,每个对象的数据是单独存储的,因此,在匹配查询条件时,是不会出现跨对象匹配的。

json
PUT user_nested
{
  "mappings": {
    "properties": {
      "name": {
        "type": "nested",
        "properties": {
          "first": {"type": "text"},
          "last":  {"type": "text"}
        }
      }
    }
  }
}
java
POST user_nested/_doc/1
{
  "name": [
     { 
      "first": "John",
      "last": "Smith"
     }, 
     {
      "first": "Alice",
      "last": "White"
     }
  ]
}

再次使用相同的查询,结果就是空集合,查询结果是符合预期的。

但是一旦使用了nested这种数据类型,查询方式,也要发生变化,我们下来测试下,继续使用普通查询方式,查询nested数据。

json
GET user_nested/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name.first": "Alice" }},
        { "match": { "name.last":  "White" }}
      ]
    }
  }
}

发现结果还是不对,因为索引中有一个人的名字first是Alice,last是White,查询到的仍然是空集合。

![](首页 & 全文检索.assets/全文检索-nested-非nested查询.png)

所以我们一定要注意,针对nested类型数据的条件查询,我们的查询也要使用nested查询

json
GET user_nested/_search
{
  "query": {
    "nested": {
      "path": "name",
      "query": {
        "bool": {
          "must": [
            { "match": { "name.first": "Alice" }},
            { "match": { "name.last":  "White" }}
          ]
        }
      }
    }
  }
}

![](首页 & 全文检索.assets/nested-nested查询.png)

关于nested查询,我们需要注意以下几点:

  • 针对nested数据类型的字段查询,应该使用nested类型的查询
  • 在nested查询中,path表示的是待查询的,数据类型为nested类型的字段名

所以很显然,在ES中,一条SKU商品对应的文档中,每个平台属性都应该使用nested类型来定义,同时针对平台属性的查询,也必须是nested查询。所以,定义映射的DSL如下:

JSON
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"
        }
      }
    }
}

2.1.2 添加测试商品数据 &#8203;

json
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" : "屏幕尺寸"
    }
  ]
}

2.2 商品查询DSL &#8203;

接下来,我们需要思考下,用户进入我们的电商网站后,会根据什么条件查询?

  • 商品名称关键字查询
  • 分类
  • 品牌
  • 平台属性值

除此之外,我们可能还需要,做高亮查询,排序查询,分页,聚合等

2.2.1 关键字查询 &#8203;

之所以把关键字查询放到bool查询的must部分,是因为我们认为,用户更关心的可能是目标商品和查询关键字的近似度,近似度越高可能越符合用户的预期。

json
#例如小米手机
GET my_goods/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
             "title": "小米手机"
          }
        }
      ]
    }
  }
}
json
# 如果查询小米手机,业务上要求查询出的手机必须得是小米的
POST my_goods/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "title": {
              "query": "小米手机",
              "operator": "and"
            }
          }
        }
      ]
    }
  }
}

2.2.2 过滤查询 &#8203;

对于品牌,平台属性,分类这些条件的查询,用户通常会希望商品和条件相同(所以就不关心,也不需要有近似度),所以这些查询条件,我们往往可以放到bool查询的filter部分。

json
#过滤查询-根据分类id
#term 查询条件不分词
GET my_goods/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "thirdLevelCategoryId": "61"
          }
        }
      ]
    }
  }
}
json
#过滤查询-根据品牌
GET my_goods/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "tmId": "1"
          }
        }
      ]
    }
  }
}
json
#过滤查询-平台属性
#查询 平台属性: 运行内存(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英寸的手机

json
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
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
  
}

2.2.3 分页查询 &#8203;

json
#分页查询
# from 从结果集的第几条开始返回 计算 开始的索引=(当前页-1)*每条数据
# size 每页条数
#位置 
POST my_goods/_search
{
  "query": {
    "match_all": {}
  },
  "from": 6,
  "size": 2
}

2.2.4 排序查询 &#8203;

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

2.2.5 聚合查询 &#8203;

json
#聚合查询--品牌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
          }
        }
      }
    }
  }
}
json
#聚合查询--平台属性聚合
#特点--类型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
              }
            }
          }
        }
      }
    }
  }
}

2.2.6 高亮查询 &#8203;

json
#高亮查询--title
#前提条件: 必须是关键字匹配查询才有高亮
POST my_goods/_search
{
  "query": {
    "match": {
      "title": "小米手机"
    }
  },
  "highlight": {
    "fields": {
      "title": {
        "pre_tags": "<span style=color:red>",
    	"post_tags": "</span>"
      }
    },

  }
  
}

2.2.7 指定查询结果中的字段 &#8203;

# 指定在包含查询结果文档数据的_source属性中,只包含id,defaultImg,title,price这几个字段
POST my_goods/_search
{
  "query": {
    "match_all": {}
  },
  "_source": [
    "id",
    "defaultImg",
    "title",
    "price"
  ]
}

2.2.8 最终的查询DSL &#8203;

json
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"
  ]
}

2.3 Spring Data Elasticsearch &#8203;

Spring Data Elasticsearch为文档的存储,查询,排序和统计提供了一个高度抽象的模板。使用Spring Data ElasticSearch来操作Elasticsearch,可以较大程度的减少我们的代码量,提高我们的开发效率。

要使用Elasticsearch我们需要引入如下依赖:

xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
            <version>2.1.7.RELEASE</version>
        </dependency>

还需要在配置文件中增加如下配置

yml
spring:
  elasticsearch:
    rest:
      # es server的地址
      uris: 192.168.0.102:9200
      # 连接超时时间
      connection-timeout: 6s
      # 访问超时时间
      read-timeout: 10s

2.3.1 定义文档映射实体类 &#8203;

类比于MyBatis-Plus可以定义实体类去映射数据库中的表中的数据,使用Spring Data Elasticsearch时,我们也可以通过定义一个实体类映射ES索引中的文档。

java
@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;

}
java
/*
   该类映射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属性,指定成员变量所映射的文档字段所使用的的分词器

2.3.2 Repository &#8203;

类比于Mybatis-Plus中定义BaseMaper子接口即可对单表做增删改查的操作,Spring Data Elastisearch中我们可以通过定义ElasticsearchRepository子接口,迅速实现对索引中的文档数据的增删改查,以及通过自定义方法,实现自定义查询。

java
public interface GoodsRepository extends ElasticsearchRepository<Goods,Long> {

}

ElasticsearchRepository接口需要接收两个泛型,第一个泛型即映射实体类,第二个泛型是在实体类中加了@Id注解的成员变量的数据类型,即映射到文档唯一标识_id字段的成员变量类型。

一旦我们定义好了ElasticsearchRepository的子接口,马上就可以实现对goods索引中文档的增删改查功能

java
    // 注入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注解自定义查询脚本
java
  /* 
     1. 通过Query注解定义具体的查询字符串(也可以替换为其他查询)
     2. 字符串中的?0是固定格式,表示第0个参数的占位符,在实际查询时会被方法的第一个参数值title的值替换,         如果有多个参数,依次类推即可
     3. List<Goods>
    */
    @Query(
                "{ " +
                        "\"match\": {\n" +
                    "      \"title\": \"?0\"\n" +
                            "}" +
                        "}"
    )
    List<Goods> matchSearch(String title);

这里的@Query注解中,只需要包含我们查询脚本中"query"{} 里面的内容即可,比如上面的@Query注解所表示的查询等价于

json
GET goods/_search
{
  "query": {
    "match": {
      "title": 具体待查询的参数值
    }
  }
}
java
    
	@Autowired
    ProductRepository productRepository;

	@Test
    public void testMatchSearch() {
        // 在调用的时候传递查询的参数值
        List<Goods> list = goodsRepository.matchSearch("荣耀手机");
        System.out.println(list);
    }
  • 利用@Query注解结合分页参数,实现分页查询
java
    /*
          1. 针对一个查询结果,返回对应的一页数据
          2. Pageable参数是当想要获取分页数据的时候,必须携带的参数,表示分页信息
             比如,查询第多少页数据,每页多少条数据等,该参数不会用来替换我们的@Query字符串中的参数
          3. 返回的结果是一个包含一页文档数据的Page对象
     */
    @Query(
            " {" +
                    "   \"match\": {\n" +
                    "      \"title\": \"?0\"\n" +
                    "    }" +
                    "}"
    )
    Page<Goods> testSearchPage(String title, Pageable pageable);
java
    // 注入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 + 分页参数实现高亮,分页自定义查询
java
    @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);
java
@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了。

2.3.3 ElasticsearchRestTemplate &#8203;

构造自定义分页,高亮,nested以及聚合查询,并发起请求

java
 
    @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);

        // 结果集过滤,只包含原始文档的id,defaultImg,title,price
        queryBuilder.withFields("id", "defaultImg", "title", "price");

        // 使用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 = search.getAggregations().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);
        
        // .....

    }

2.4 引入搜索服务 &#8203;

![](首页 & 全文检索.assets/search-2.png)

2.5 商品上下架 &#8203;

我们什么时候向ES中添加SKU商品数据呢?因为一个商品一旦上架,就应该是可以被搜索到的,所以当SKU商品上架的时候,我们需要向ES添加SKU商品数据。同理,当一个SKU商品下架的时候,该商品就不应该再被用户搜索到了,所以,当一个SKU商品下架的时候,我们应该从ES中删除该SKU商品的信息。

所以接下来在service-product中,我们需要修改上下架的SKU商品的代码

java
@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) {
    }
}
java
public interface SearchService {

    /**
     * 上架商品列表
     * @param skuId
     */
    void upperGoods(Long skuId);

    /**
     * 下架商品列表
     * @param skuId
     */
    void lowerGoods(Long skuId);
}
java
@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中的上下架。

2.6 商品热度排名 &#8203;

在ES中,我们希望在用户搜索到SKU商品后,我们可以按照SKU商品的热度排序,将最热门的商品排在前面。那么如何定义商品的热度呢?我们以商品的浏览量来定义商品的热度。因此每当一个SKU商品的详情数据被获取一次,我们就给它的热度加1。

java
    @GetMapping("api/list/inner/incrHotScore/{skuId}")
    public Result incrHotScore(@PathVariable("skuId") Long skuId) {
    }
java
/*
     该类用来ES根据条件查询到的每一个SKU商品信息
 */
@Data
public class GoodsDTO {

    // 商品Id skuId
    private Long id;
	
    // ...

    //  商品的热度! 我们将商品被用户点查看的次数越多,则说明热度就越高!
    private Long hotScore = 0L;
}
java
public interface SearchService {

    // ...

    /**
     * 更新热点
     * @param skuId
     */
    void incrHotScore(Long skuId);
}
java
@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用来调用搜索服务。

java
@FeignClient("service-search")
public interface SearchApiClient {

    /**
     * 更新商品incrHotScore
     * @param skuId
     * @return
     */
    @GetMapping("/api/list/inner/incrHotScore/{skuId}")
    Result incrHotScore(@PathVariable("skuId") Long skuId);
}

在商品服务中,需要修改获取商品详情页的代码,在增加SKU商品的热度

java
@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;
    }
}

2.7 条件查询 &#8203;

![](首页 & 全文检索.assets/搜索链路.png)

在搜索服务中,实现搜索接口

java
    @GetMapping("/list")
    public Result list(SearchParam searchParam) throws IOException {

    }
java
public interface SearchService {
    
    /**
     * 搜索列表
     * @param searchParam
     * @return
     * @throws IOException
     */
    SearchResponseDTO search(SearchParam searchParam) throws IOException;
}
java
// 封装查询条件
@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; // 每页默认显示的条数
}
java
    @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;
    }
java
 // 制作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);


        // 设置结果集过滤
        nativeSearchQueryBuilder.withFields("id", "defaultImg", "title", "price");
        return nativeSearchQueryBuilder;
    }

笔记 &#8203;

  • 类似redisson存json字符串之后,还要存json对应的java bean的全限定类名。spring data elasticsearch也是对象关系映射