目录
一、过滤功能分析
首先看下页面要实现的效果:
整个过滤部分有3块:
-
顶部的导航,已经选择的过滤条件展示:
-
商品分类面包屑,根据用户选择的商品分类变化
-
其它已选择过滤参数
-
-
过滤条件展示,又包含3部分
-
商品分类展示
-
品牌展示
-
其它规格参数
-
-
展开或收起的过滤条件的按钮
顶部导航要展示的内容跟用户选择的过滤条件有关。
-
比如用户选择了某个商品分类,则面包屑中才会展示具体的分类
-
比如用户选择了某个品牌,列表中才会有品牌信息。
所以,这部分需要依赖第二部分:过滤条件的展示和选择。
展开或收起的按钮是否显示,取决于过滤条件有多少,如果很少,那么就没必要展示。所以也是跟第二部分的过滤条件有关。
所以重中之重就是第二部分:过滤条件展示
二、生成品牌和分类过滤
在这个位置,不是把所有的分类和品牌信息都展示出来。因为用户搜索的条件会对商品进行过滤,而在搜索结果中,不一定包含所有的分类和品牌,直接展示出所有商品分类,让用户选择显然是不合适的。无论是分类信息,还是品牌信息,都应该从搜索的结果商品中进行聚合得到。
2.1 扩展返回的结果
原来,返回的结果是PageResult对象,里面只有total、totalPage、items3个属性。但是现在要对商品分类和品牌进行聚合,数据显然不够用,所以需要对返回的结果进行扩展,添加分类和品牌的数据。
那么问题来了:以什么格式返回呢?
看页面:
分类:页面显示了分类名称,但背后肯定要保存id信息。所以至少要有id和name
品牌:页面展示的有logo,有文字,当然肯定有id,基本上是品牌的完整数据
需要新建一个类,继承PageResult,然后扩展两个新的属性:分类集合和品牌集合。
package com.leyou.vo;
import com.leyou.common.pojo.PageResult;
import com.leyou.item.pojo.Brand;
import com.leyou.item.pojo.Category;
import java.util.List;
import java.util.Map;
/**
* @Author: 98050
* Time: 2018-10-12 21:06
* Feature: 搜索结果存储对象
*/
public class SearchResult<Goods> extends PageResult<Goods> {
/**
* 分类的集合
*/
private List<Category> categories;
/**
* 品牌的集合
*/
private List<Brand> brands;
/**
* 规格参数的过滤条件
*/
private List<Map<String,Object>> specs;
public List<Category> getCategories() {
return categories;
}
public void setCategories(List<Category> categories) {
this.categories = categories;
}
public List<Brand> getBrands() {
return brands;
}
public void setBrands(List<Brand> brands) {
this.brands = brands;
}
public List<Map<String, Object>> getSpecs() {
return specs;
}
public void setSpecs(List<Map<String, Object>> specs) {
this.specs = specs;
}
public SearchResult(List<Category> categories, List<Brand> brands, List<Map<String,Object>> specs){
this.categories = categories;
this.brands = brands;
this.specs = specs;
}
public SearchResult(Long total, List<Goods> item,List<Category> categories, List<Brand> brands, List<Map<String,Object>> specs){
super(total,item);
this.categories = categories;
this.brands = brands;
this.specs = specs;
}
public SearchResult(Long total,Long totalPage, List<Goods> item,List<Category> categories, List<Brand> brands, List<Map<String,Object>> specs){
super(total,totalPage,item);
this.categories = categories;
this.brands = brands;
this.specs = specs;
}
}
2.2 聚合商品分类和品牌
修改搜索的业务逻辑,对分类和品牌聚合。
因为索引库中只有id,所以根据id聚合,然后再根据id去查询完整数据。
所以,商品微服务需要提供一个接口:根据品牌id集合,批量查询品牌。
2.2.1 提供查询品牌接口
BrandApi
package com.leyou.item.api;
import com.leyou.item.pojo.Brand;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* @Author: 98050
* Time: 2018-10-11 20:04
* Feature:品牌服务接口
*/
@RequestMapping("brand")
public interface BrandApi {
/**
* 根据品牌id结合,查询品牌信息
* @param ids
* @return
*/
@GetMapping("list")
List<Brand> queryBrandByIds(@RequestParam("ids") List<Long> ids);
}
BrandController
/**
* 根据品牌id结合,查询品牌信息
* @param ids
* @return
*/
@GetMapping("list")
public ResponseEntity<List<Brand>> queryBrandByIds(@RequestParam("ids") List<Long> ids){
List<Brand> list = this.brandService.queryBrandByBrandIds(ids);
if (list == null){
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.ok(list);
}
BrandService
BrandServiceImpl
BrandMapper
需要继承通用mapper的 SelectByIdListMapper
2.2.2 搜索功能改造
添加BrandClient
修改SearchService:
public SearchResult<Goods> search(SearchRequest searchRequest) {
String key = searchRequest.getKey();
/**
* 判断是否有搜索条件,如果没有,直接返回null。不允许搜索全部商品
*/
if (StringUtils.isBlank(key)){
return null;
}
//1.构建查询条件
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
//1.1.对关键字进行全文检索查询
queryBuilder.withQuery(QueryBuilders.matchQuery("all",key).operator(Operator.AND));
//1.2.通过sourceFilter设置返回的结果字段,只需要id,skus,subTitle
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id","skus","subTitle"},null));
//1.3.分页和排序
searchWithPageAndSort(queryBuilder,searchRequest);
//1.4.聚合
/**
* 商品分类聚合名称
*/
String categoryAggName = "category";
/**
* 品牌聚合名称
*/
String brandAggName = "brand";
//1.4.1。对商品分类进行聚合
queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3"));
//1.4.2.对品牌进行聚合
queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId"));
//2.查询、获取结果
AggregatedPage<Goods> pageInfo = (AggregatedPage<Goods>) this.goodsRepository.search(queryBuilder.build());
//3.解析查询结果
//3.1 分页信息
Long total = pageInfo.getTotalElements();
int totalPage = pageInfo.getTotalPages();
//3.2 商品分类的聚合结果
List<Category> categories = getCategoryAggResult(pageInfo.getAggregation(categoryAggName));
//3.3 品牌的聚合结果
List<Brand> brands = getBrandAggResult(pageInfo.getAggregation(brandAggName));
//3.封装结果,返回
return new SearchResult<>(total, (long)totalPage,pageInfo.getContent(),categories,brands);
}
}
将分页和排序单独抽取为一个函数,这两个都是基本查询条件
/**
* 构建基本查询条件
* @param queryBuilder
* @param request
*/
private void searchWithPageAndSort(NativeSearchQueryBuilder queryBuilder, SearchRequest request) {
// 准备分页参数
int page = request.getPage();
int size = request.getDefaultSize();
// 1、分页
queryBuilder.withPageable(PageRequest.of(page - 1, size));
// 2、排序
String sortBy = request.getSortBy();
Boolean desc = request.getDescending();
if (StringUtils.isNotBlank(sortBy)) {
// 如果不为空,则进行排序
queryBuilder.withSort(SortBuilders.fieldSort(sortBy).order(desc ? SortOrder.DESC : SortOrder.ASC));
}
然后对商品分类和品牌的聚合结果的解析也是放在单独的函数中进行的
对商品分类聚合结果解析
private List<Category> getCategoryAggResult(Aggregation aggregation) {
LongTerms brandAgg = (LongTerms) aggregation;
List<Long> cids = new ArrayList<>();
for (LongTerms.Bucket bucket : brandAgg.getBuckets()){
cids.add(bucket.getKeyAsNumber().longValue());
}
//根据id查询分类名称
return this.categoryClient.queryCategoryByIds(cids).getBody();
}
对品牌聚合结果发解析
/**
* 解析品牌聚合结果
* @param aggregation
* @return
*/
private List<Brand> getBrandAggResult(Aggregation aggregation) {
LongTerms brandAgg = (LongTerms) aggregation;
List<Long> bids = new ArrayList<>();
for (LongTerms.Bucket bucket : brandAgg.getBuckets()){
bids.add(bucket.getKeyAsNumber().longValue());
}
//根据品牌id查询品牌
return this.brandClient.queryBrandByIds(bids);
}
2.2.3 SearchController
2.2.4 测试
2.3 页面渲染数据
2.3.1 过滤参数数据结构
页面展示效果:
虽然分类、品牌内容都不太一样,但是结构相似,都是key和value的结构。
而且页面结构也极为类似:
所以,可以把所有的过滤条件放入一个数组
中,然后在页面利用v-for
遍历一次生成。
其基本结构是这样的:
[
{
k:"过滤字段名",
options:[{/*过滤字段值对象*/},{/*过滤字段值对象*/}]
}
]
先在data中定义数组:filters,等待组装过滤参数:
然后在查询搜索结果的回调函数中,对过滤参数进行封装:
然后刷新页面,查看封装的结果:
2.3.2 页面渲染数据
虽然页面元素是一样的,但是品牌会比其它搜索条件多出一些样式,因为品牌是以图片展示。需要进行特殊处理。数据展示是一致的,采用v-for处理:
<div class="type-wrap" v-for="(f,i) in filters" :key="i" v-if="f.k !== '品牌'">
<div class="fl key">{{f.k}}</div>
<div class="fl value">
<ul class="type-list">
<li v-for="(option, j) in f.options" :key="j">
<a>{{option.name}}</a>
</li>
</ul>
</div>
<div class="fl ext"></div>
</div>
<div class="type-wrap logo" v-else>
<div class="fl key brand">{{f.k}}</div>
<div class="value logos">
<ul class="logo-list">
<li v-for="(option, j) in f.options" v-if="option.image">
<img :src="option.image" />
</li>
<li style="text-align: center" v-else>
<a style="line-height: 30px; font-size: 12px" href="#">{{option.name}}</a>
</li>
</ul>
</div>
<div class="fl ext">
<a href="javascript:void(0);" class="sui-btn">多选</a>
</div>
</div>
测试:
三、生成规格参数过滤
3.1 分析
有四个问题需要先思考清楚:
-
什么时候显示规格参数过滤?
-
如何知道哪些规格需要过滤?
-
要过滤的参数,其可选值是如何获取的?
-
规格过滤的可选值,其数据格式怎样的?
什么情况下显示有关规格参数的过滤?
如果用户尚未选择商品分类,或者聚合得到的分类数大于1,那么就没必要进行规格参数的聚合。因为不同分类的商品,其规格是不同的。
因此,在后台需要对聚合得到的商品分类数量进行判断,如果等于1,我们才继续进行规格参数的聚合。
如何知道哪些规格需要过滤?
不能把数据库中的所有规格参数都拿来过滤。因为并不是所有的规格参数都可以用来过滤,参数的值是不确定的。
在设计规格参数时,已经标记了某些规格可搜索,某些不可搜索。
因此,一旦商品分类确定,我们就可以根据商品分类查询到其对应的规格,从而知道哪些规格要进行搜索。
要过滤的参数,其可选值是如何获取的?
虽然数据库中有所有的规格参数,但是不能把一切数据都用来供用户选择。
与商品分类和品牌一样,应该是从用户搜索得到的结果中聚合,得到与结果品牌的规格参数可选值
规格过滤的可选值,其数据格式怎样的?
之前存储时已经将数据分段,恰好符合这里的需求 。
3.2 实现
3.2.1 扩展返回结果
返回结果中需要增加新数据,用来保存规格参数过滤条件。这里与前面的品牌和分类过滤的json结构类似:
[
{
"k":"规格参数名",
"options":["规格参数值","规格参数值"]
}
]
因此,在SerachResult中用List<Map<String, String>>来表示:
构造函数如下:
3.2.2 判断是否需要聚合
首先,在聚合得到商品分类后,判断分类的个数,如果是1个则进行规格聚合:
将聚合的代码抽取到了一个getSpecs
方法中。
3.2.3 获取需要聚合的规格参数
然后,根据商品分类,查询所有可用于搜索的规格参数:
/**
* 聚合规格参数
* @param id
* @param basicQuery
* @return
*/
private List<Map<String, Object>> getSpec(Long id, QueryBuilder basicQuery) {
//不管是全局参数还是sku参数,只要是搜索参数,都根据分类id查询出来
String specsJSONStr = this.specClient.querySpecificationByCategoryId(id).getBody();
System.out.println("json"+specsJSONStr);
//1.将规格反序列化为集合
List<Map<String,Object>> specs = null;
specs = JsonUtils.nativeRead(specsJSONStr, new TypeReference<List<Map<String, Object>>>() {
});
//2.过滤出可以搜索的规格参数名称,分成数值类型、字符串类型
Set<String> strSpec = new HashSet<>();
//准备map,用来保存数值规格参数名及单位
Map<String,String> numericalUnits = new HashMap<>();
//解析规格
String searchable = "searchable";
String numerical = "numerical";
String k = "k";
String unit = "unit";
for (Map<String,Object> spec :specs){
List<Map<String, Object>> params = (List<Map<String, Object>>) spec.get("params");
params.forEach(param ->{
if ((boolean)param.get(searchable)){
if (param.containsKey(numerical) && (boolean)param.get(numerical)){
numericalUnits.put(param.get(k).toString(),param.get(unit).toString());
}else {
strSpec.add(param.get(k).toString());
}
}
});
}
//3.聚合计算数值类型的interval
Map<String,Double> numericalInterval = getNumberInterval(id,numericalUnits.keySet());
return this.aggForSpec(strSpec,numericalInterval,numericalUnits,basicQuery);
}
具体分析一下函数的执行过程:
- 传入参数:id(商品所属第三级分类:cid3),basicQuery(基本查询构造器)
- 根据分类id查询出对应参数的参数格式,返回结果是JSON字符串
假设传入的cid3为76,即对应的分类为手机,那么将返回手机的全部规格参数,包括公共属性和sku特有属性
也就是返回数据库中tb_specification表中对应的数据
- 将返回的JSON字符串序列化为对象
序列化的结果:
打开后台规格参数管理,查看手机的规格参数:
与反序列化的结果一一对应。每一项都是Map<String,Object>
- 在反序列化的结果中过滤出可以搜索的规格参数名称(可搜索,则对应的searchable = true),并且将其分为数值类型和字符串类型
数值类型过滤结果:(参数及其单位)
字符型过滤结果:
- 然后聚合计算数值型参数的interval,聚合过程单独抽取一个函数getNumberInterval
- 将字符型参数(strSpec)、数值类型分别对应的间距(numericalInterval)、数值类型参数(numericalUnits)、基本查询构造器(basicQuery)传入到函数aggForSpec中,进行聚合,得到最终的过滤属性值。
3.2.4 聚合规格参数求得Interval
/**
* 聚合得到interval
* @param id
* @param keySet
* @return
*/
private Map<String, Double> getNumberInterval(Long id, Set<String> keySet) {
Map<String,Double> numbericalSpecs = new HashMap<>();
//准备查询条件
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
//不查询任何数据
queryBuilder.withQuery(QueryBuilders.termQuery("cid3",id.toString())).withSourceFilter(new FetchSourceFilter(new String[]{""},null)).withPageable(PageRequest.of(0,1));
//添加stats类型的聚合,同时返回avg、max、min、sum、count等
for (String key : keySet){
queryBuilder.addAggregation(AggregationBuilders.stats(key).field("specs." + key));
}
Map<String,Aggregation> aggregationMap = this.elasticsearchTemplate.query(queryBuilder.build(),
searchResponse -> searchResponse.getAggregations().asMap()
);
for (String key : keySet){
InternalStats stats = (InternalStats) aggregationMap.get(key);
double interval = this.getInterval(stats.getMin(),stats.getMax(),stats.getSum());
numbericalSpecs.put(key,interval);
}
return numbericalSpecs;
}
分析一下函数的执行过程:
- 传入参数:id(分类id,cid3)、keySet(数值类型参数的集合)
- 基本查询,不查询任何数据,因为过滤结果时用FetchSourceFilter(new String[]{""},null)来过滤
看一下FetchSourceFilter的构造函数
includes:来指定想要显示的字段
excludes:来指定不想要显示的字段
即不查询任何数据,因为要根据传入的数值类型参数进行过滤
- 遍历keySet集合,添加stats类型的聚合,返回avg、max、min、sum、count等。
因为参数全部在specs内,所以field字段为:field("specs." + key)
知识点:
聚合中两个概念:桶(bucket)、指标(metric)
桶(bucket): 满足特定条件的文档的集合
Filter
Range
Missing
Terms
Date Range
Global Aggregation
Histogram
Date Histogram
IPv4 range
Return only aggregation results指标(metric): 对桶内的文档进行聚合分析的操作
AVG
Cardinality
Stats
Extended Stats
Percentiles
Percentile Ranks(1)桶
a、简单来说桶就是满足特定条件的文档的集合。
b、当聚合开始被执行,每个文档里面的值通过计算来决定符合哪个桶的条件,如果匹配到,文档将放入相应的桶并接着开始聚合操作。
c、桶也可以被嵌套在其他桶里面。
(2)指标
a、桶能让我们划分文档到有意义的集合,但是最终我们需要的是对这些桶内的文档进行一些指标的计算。分桶是一种达到目的地的手段:它提供了一种给文档分组的方法来让我们可以计算感兴趣的指标。
b、大多数指标是简单的数学运算(如:最小值、平均值、最大值、汇总),这些是通过文档的值来计算的。
(3)桶和指标的组合
聚合是由桶和指标组成的。聚合可能只有一个桶,可能只有一个指标,或者可能两个都有。也有可能一些桶嵌套在其他桶里面。
- 构建好查询后,使用ElasticsearchTemplate进行查询,并且获取查询结果,保存在map当中
- 解析结果,获取最小值、最大值和sum,然后调用getInterval函数进行interval的计算
解析结果:
getInterval函数:
private double getInterval(double min, double max, Double sum) { //要显示6个区间 double interval = (max - min) /6.0d; //判断是否是小数 if (sum.intValue() == sum){ //不是小数,要取整十、整百 int length = StringUtils.substringBefore(String.valueOf(interval),".").length(); double factor = Math.pow(10.0,length - 1); return Math.round(interval / factor)*factor; }else { //是小数的话就保留一位小数 return NumberUtils.scale(interval,1); } }
- 返回构建好的过滤字段numbericalSpecs
3.2.5 聚合得到过滤属性值
/**
* 根据规格参数,聚合得到过滤属性值
* @param strSpec
* @param numericalInterval
* @param numericalUnits
* @param basicQuery
* @return
*/
private List<Map<String, Object>> aggForSpec(Set<String> strSpec, Map<String, Double> numericalInterval, Map<String, String> numericalUnits, QueryBuilder basicQuery) {
List<Map<String,Object>> specs = new ArrayList<>();
//准备查询条件
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
queryBuilder.withQuery(basicQuery);
//聚合数值类型
for (Map.Entry<String,Double> entry : numericalInterval.entrySet()) {
queryBuilder.addAggregation(AggregationBuilders.histogram(entry.getKey()).field("specs." + entry.getKey()).interval(entry.getValue()).minDocCount(1));
}
//聚合字符串
for (String key :strSpec){
queryBuilder.addAggregation(AggregationBuilders.terms(key).field("specs."+key+".keyword"));
}
//解析聚合结果
Map<String,Aggregation> aggregationMap = this.elasticsearchTemplate.query(queryBuilder.build(), SearchResponse :: getAggregations).asMap();
//解析数值类型
for (Map.Entry<String,Double> entry :numericalInterval.entrySet()){
Map<String,Object> spec = new HashMap<>();
String key = entry.getKey();
spec.put("k",key);
spec.put("unit",numericalUnits.get(key));
//获取聚合结果
InternalHistogram histogram = (InternalHistogram) aggregationMap.get(key);
spec.put("options",histogram.getBuckets().stream().map(bucket -> {
Double begin = (Double) bucket.getKey();
Double end = begin + numericalInterval.get(key);
//对begin和end取整
if (NumberUtils.isInt(begin) && NumberUtils.isInt(end)){
//确实是整数,直接取整
return begin.intValue() + "-" + end.intValue();
}else {
//小数,取2位小数
begin = NumberUtils.scale(begin,2);
end = NumberUtils.scale(end,2);
return begin + "-" + end;
}
}));
specs.add(spec);
}
//解析字符串类型
strSpec.forEach(key -> {
Map<String,Object> spec = new HashMap<>();
spec.put("k",key);
StringTerms terms = (StringTerms) aggregationMap.get(key);
spec.put("options",terms.getBuckets().stream().map((Function<StringTerms.Bucket, Object>) StringTerms.Bucket::getKeyAsString));
specs.add(spec);
});
return specs;
}
分析一下函数的执行过程:
- 传入参数:strSpec(字符型过滤参数)、numericalInterval(数值型参数对应的区间)、numericalUnits(数值型参数对应的单位)、basicQuery(基本查询构造器)
- 准备查询条件,先进行基本查询
- 聚合数值类型的参数(阶梯分桶)
- 聚合字符串类型的参数(词条分桶),因为规格参数保存时不做分词,因此其名称会自动带上一个.keyword后缀
- 解析数值型参数,放入specs中
解析结果:
- 解析字符型参数,放入specs中
解析结果:
解析过程中,options保存的是流对象(stream),也可以转换为列表list。
- 返回specs
3.2.6 测试
3.3 页面渲染
3.3.1 渲染规格过滤条件
首先把后台传递过来的specs添加到filters数组:
要注意:分类、品牌的option选项是对象,里面有name属性,而specs中的option是简单的字符串,所以需要进行封装,变为相同的结构:
渲染:
显示单位
最后的结果:
3.3.2 展示或收起过滤条件
可以通过按钮点击来展开和隐藏部分内容:
在data中定义变量,记录展开或隐藏的状态:
然后再按钮绑定点击事件,以改变show的取值:
在展示规格时,对show进行判断:
默认显示四个:分类、品牌、后置摄像头、CPU频率