过滤功能分析
整个过滤部分有3块:
- 顶部的导航,已经选择的过滤条件展示:
- 过滤条件展示,又包含3部分
- 展开或收起的过滤条件的按钮
顶部导航要展示的内容跟用户选择的过滤条件有关。这部分需要依赖第二部分:过滤条件的展示和选择。展开或收起的按钮是否显示,取决于过滤条件有多少,如果很少,那么就没必要展示。所以也是跟第二部分的过滤条件有关。
先做第二部分:过滤条件展示。
分类和品牌过滤条件 获取和展示
数据库中已经有所有的分类和品牌信息。无论是分类信息,还是品牌信息,都应该从搜索的结果商品中进行聚合得到。
扩展返回的结果
返回的结果PageResult对象,里面只有total、totalPage、items3个属性。现在要对商品分类和品牌进行聚合,添加分类和品牌的数据。
分类:页面显示了分类名称,但背后肯定要保存id信息。所以至少要有id和name
品牌:页面展示的有logo,有文字,当然肯定有id,基本上是品牌的完整数据
新建一个类,继承PageResult,然后扩展两个新的属性:分类集合和品牌集合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package com.deltaqin.search.pojo;
import com.deltaqin.common.vo.PageResult; import com.deltaqin.item.pojo.Brand; import com.deltaqin.item.pojo.Category; import lombok.Data; import lombok.NoArgsConstructor;
import java.util.List;
@Data @NoArgsConstructor public class SearchResult extends PageResult<Goods> {
private List<Category> categories; private List<Brand> brands;
public SearchResult(Long total, Integer totalPage, List<Goods> items, List<Category> categories, List<Brand> brands) { super(total, totalPage, items); this.categories = categories; this.brands = brands; } }
|
聚合商品分类和品牌
修改搜索的业务逻辑,对分类和品牌聚合。
因为索引库中只有id,所以根据id聚合,然后再根据id去查询完整数据。所以,商品微服务需要提供一个接口:根据品牌id集合,批量查询品牌。
修改SearchService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
|
public PageResult<Goods> search(SearchRequest request) { String key = request.getKey(); if (StringUtils.isBlank(key)) { return null; }
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
QueryBuilder basicQuery = QueryBuilders.matchQuery("all", key).operator(Operator.AND); queryBuilder.withQuery(basicQuery);
queryBuilder.withSourceFilter(new FetchSourceFilter( new String[]{"id", "skus", "subTitle"}, null));
int page = request.getPage(); int size = request.getSize(); queryBuilder.withPageable(PageRequest.of(page - 1, size));
String sortBy = request.getSortBy(); Boolean desc = request.getDescending(); if (StringUtils.isNotBlank(sortBy)) { queryBuilder.withSort(SortBuilders.fieldSort(sortBy).order(desc ? SortOrder.DESC : SortOrder.ASC)); }
String categoryAggName = "categories"; String brandAggName = "brands"; queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3")); queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId"));
AggregatedPage<Goods> result = elasticsearchTemplate.queryForPage(queryBuilder.build(), Goods.class);
Long totalElements = result.getTotalElements(); int totalPages = (totalElements.intValue() + size - 1) / size;
Aggregations aggs = result.getAggregations(); List<Category> categories = parseCategoryAgg(aggs.get(categoryAggName)); List<Brand> brands = parseBrandAgg(aggs.get(brandAggName));
return new SearchResult(totalElements, totalPages, result.getContent(), categories, brands); }
private List<Brand> parseBrandAgg(LongTerms aggregation) { try { List<Long> ids = aggregation.getBuckets() .stream().map(b -> b.getKeyAsNumber().longValue()) .collect(Collectors.toList()); List<Brand> brands = brandClient.queryBrandByIds(ids); return brands; } catch (Exception e) { log.error("[搜索服务]查询品牌异常:", e); return null; } }
private List<Category> parseCategoryAgg(LongTerms aggregation) { try { List<Long> ids = aggregation.getBuckets() .stream().map(b -> b.getKeyAsNumber().longValue()) .collect(Collectors.toList()); List<Category> category = categoryClient.queryNameByIds(ids); return category; } catch (Exception e) { log.error("[搜索服务]查询分类异常:", e); return null; } }
|

页面渲染数据
过滤参数数据结构
分类、品牌内容结构相似,都是key和value的结构。可以把所有的过滤条件放入一个数组中,然后在页面利用v-for遍历一次生成。
1 2 3 4 5 6
| [ { k:"过滤字段名", options:[{},{}] } ]
|
先在data中定义数组:filters,等待组装过滤参数:
1 2 3 4 5 6 7 8 9 10 11
| data: { ly, search:{ key: "", page: 1 }, goodsList:[], total: 0, totalPage: 0, filters:[] },
|
然后在查询搜索结果的回调函数中,对过滤参数进行封装,所有的过滤条件的数据结构都是这样的:key是过滤条件的名字,value是json对象组成的对象。

然后刷新页面,注意上面是在network里面查看后端返回的数据,这里应该在Vue工具里面查看实例里面已经添加的数据。通过浏览器工具,查看封装的结果:

页面渲染数据
虽然页面元素是一样的,但是品牌会比其它搜索条件多出一些样式,因为品牌是以图片展示。需要进行特殊处理。数据展示是一致的,采用v-for处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <div class="type-wrap" v-for="f in filters" :key="f.k" v-if="f.k !== 'brandId'"> <div class="fl key" v-text="f.k === 'cid3' ? '分类' : 'f.k'"></div> <div class="fl value"> <ul class="type-list"> <li v-for="(o,i) in f.options" :key="i"> </li> </ul> </div> <div class="fl ext"></div> </div>
<div class="type-wrap logo" v-else> <div class="fl key brand">品牌</div> <div class="value logos"> <ul class="logo-list" v-for="(o,i) in f.options" :key="i"> <li v-if="o.image"><img :src="o.image"/></li> <li v-else></li> </ul> </div> <div class="fl ext"> <a href="javascript:void(0);" class="sui-btn">多选</a> </div> </div>
|
规格参数过滤条件 获取和展示
如果用户尚未选择商品分类,或者聚合得到的分类数大于1,那么就没必要进行规格参数的聚合。因为不同分类的商品,其规格是不同的。因此,在后台需要对聚合得到的商品分类数量进行判断,如果等于1,才继续进行规格参数的聚合。不能把索引库中的所有规格参数都拿来过滤。因为并不是所有的规格参数都可以用来过滤,参数的值是不确定的。在设计规格参数时,已经标记了某些规格可搜索,某些不可搜索。
因此,一旦商品分类确定,就可以根据商品分类查询到其对应的规格,从而知道哪些规格要进行搜索。虽然数据库中有所有的规格参数,但是不能把一切数据都用来供用户选择。与商品分类和品牌一样,应该是从用户搜索得到的结果中聚合,得到与结果品牌的规格参数可选值。
- 1)用户搜索得到商品,并聚合出商品分类
- 2)判断分类数量是否等于1,如果是则进行规格参数聚合
- 3)先根据分类,查找可以用来搜索的规格
- 4)对规格参数进行聚合
- 5)将规格参数聚合结果整理后返回
修改返回结果SearchResult
前面的返回结果中需要增加新数据,用来保存规格参数过滤条件。
在Vue实例里面存放的是下面map格式的数组,所以希望可以得到相似的结构就不用处理直接放在实例里面使用,上面传递分类和品牌的时候没有使用这种而是直接返回数组,在前端转换为map格式的数据。下面的规格参数选择在后端返回之前就将数据封装好:
1 2 3 4 5 6
| [ { "k":"规格参数名", "options":["规格参数 值","规格参数值"] } ]
|
用List<Map<String, Object>>来表示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Data @NoArgsConstructor public class SearchResult extends PageResult<Goods> {
private List<Category> categories; private List<Brand> brands; private List<Map<String, Object>> specs;
public SearchResult(Long total, Integer totalPage, List<Goods> items, List<Category> categories, List<Brand> brands, List<Map<String, Object>> specs) { super(total, totalPage, items); this.categories = categories; this.brands = brands; this.specs = specs; } }
|
修改SearchService增加获取规格参数
首先,在聚合得到商品分类后,判断分类的个数,如果是1个则进行规格聚合。
将聚合的代码抽取到了一个getParamAggResult方法中。然后,根据商品分类,查询所有可用于搜索的规格参数。因为规格参数保存时不做分词,因此其名称会自动带上一个.keyword后缀:
注意第一句会报错:
// StringTerms terms = aggregations.get(name);
InternalTerms, ?> terms = aggregations.get(name);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
| public PageResult<Goods> search(SearchRequest request) { String key = request.getKey(); if (StringUtils.isBlank(key)) { return null; }
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
QueryBuilder basicQuery = QueryBuilders.matchQuery("all", key).operator(Operator.AND); queryBuilder.withQuery(basicQuery);
queryBuilder.withSourceFilter(new FetchSourceFilter( new String[]{"id", "skus", "subTitle"}, null));
int page = request.getPage(); int size = request.getSize(); queryBuilder.withPageable(PageRequest.of(page - 1, size));
String sortBy = request.getSortBy(); Boolean desc = request.getDescending(); if (StringUtils.isNotBlank(sortBy)) { queryBuilder.withSort(SortBuilders.fieldSort(sortBy).order(desc ? SortOrder.DESC : SortOrder.ASC)); }
String categoryAggName = "categories"; String brandAggName = "brands"; queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3")); queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId"));
AggregatedPage<Goods> result = elasticsearchTemplate.queryForPage(queryBuilder.build(), Goods.class);
Long totalElements = result.getTotalElements(); int totalPages = (totalElements.intValue() + size - 1) / size;
Aggregations aggs = result.getAggregations(); List<Category> categories = parseCategoryAgg(aggs.get(categoryAggName)); List<Brand> brands = parseBrandAgg(aggs.get(brandAggName));
List<Map<String, Object>> specs = null; if (categories != null && categories.size() == 1) { specs = buildSpecificationAgg((Long)categories.get(0).getId(), basicQuery); }
return new SearchResult(totalElements, totalPages, result.getContent(), categories, brands, specs); }
private List<Map<String,Object>> buildSpecificationAgg(Long cid, QueryBuilder basicQuery) { List<Map<String, Object>> specMapList = new ArrayList<>();
List<SpecParam> params = this.specificationClient.queryParams(null, cid, null, true); NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); queryBuilder.withQuery(basicQuery); params.forEach(param -> { String aggName = param.getName(); queryBuilder.addAggregation( AggregationBuilders.terms(aggName).field("specs." + aggName + ".keyword")); }); queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{}, null));
AggregatedPage<Goods> goodsPage = elasticsearchTemplate.queryForPage(queryBuilder.build(), Goods.class);
Aggregations aggregations = goodsPage.getAggregations(); for (SpecParam param : params) { String name = param.getName(); InternalTerms<?, ?> terms = aggregations.get(name); List<Object> options = new ArrayList<>(); options = terms.getBuckets() .stream().map(b -> b.getKeyAsString()).collect(Collectors.toList());
Map<String, Object> map = new HashMap<>();
map.put("k", name); map.put("options", options); specMapList.add(map); } return specMapList; }
|
页面渲染数据
渲染规格过滤条件
首先把后台传递过来的specs添加到filters数组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| loadData() { ly.http.post("/search/page", this.search).then(resp => { this.total = resp.data.total; this.totalPage = resp.data.totalPage; resp.data.items.forEach(goods => { goods.skus = JSON.parse(goods.skus); goods.selectedSku = goods.skus[0]; }) this.goodsList = resp.data.items; this.filters.push({ k: "cid3", options: resp.data.categories }); this.filters.push({ k: "brandId", options: resp.data.brands }); resp.data.specs.forEach(spec => this.filters.push(spec));
}).catch(error => {
}); },
|
要注意:分类、品牌的option选项是对象,里面有name属性,而specs中的option是简单的字符串,直接输出里面的字符串即可

展示或收起过滤条件
我们在data中定义变量,记录展开或隐藏的状态:

然后在按钮绑定点击事件,以改变show的取值:
1 2 3 4 5 6 7 8 9 10
| <div class="type-wrap" style="text-align: center"> <v-btn small flat v-show="!show" @click="show=true"> 更多 <v-icon>arrow_drop_down</v-icon> </v-btn> <v-btn small="" flat v-show="show" @click="show=false"> 收起 <v-icon>arrow_drop_up</v-icon> </v-btn> </div>
|
过滤条件的筛选
当我们点击页面的过滤项:
- 把过滤条件保存在search对象中(watch监控到search变化后就会发送到后台)
- 在页面顶部展示已选择的过滤项
- 把商品分类展示到顶部面包屑
前台–保存过滤项
定义属性
把已选择的过滤项保存在search中:要注意,在created构造函数中会对search进行初始化(search.filter)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| created() { if (!location.search) { return; } const search = ly.parse(location.search.substring(1)); search.page = search.page ? parseInt(search.page) : 1; search.filter = search.filter ? search.filter : {}; search.sortBy = search.sortBy || ""; search.descending = search.descending === "true" || false; this.search = search; this.loadData(); },
|
search.filter是一个对象,结构:
绑定点击事件
给所有的过滤项绑定点击事件,要注意,点击事件传2个参数:
在点击事件中,保存过滤项到selectedFilter:
1 2 3 4 5 6 7 8 9 10
| selectFilter(k, o){ const {... obj} = this.search.filter; obj[k] = o; this.search.filter = obj; }
|
search对象中嵌套了filter对象,请求参数格式化时需要进行特殊处理,修改common.js中的一段代码:

我们刷新页面,点击后通过浏览器功能查看search.filter的属性变化:

并且,此时浏览器地址也发生了变化:

网络请求也正常发出:

后台–添加过滤条件
拓展请求对象
我们需要在请求类:SearchRequest中添加属性,接收过滤属性。过滤属性都是键值对格式,但是key不确定,所以用一个map来接收即可。
1
| private Map<String, String> filter;
|
添加过滤条件
基本查询的基础之上,要把页面传递的过滤条件也加入进去。不能使用普通的查询,而是要用到BooleanQuery,基本结构是这样的:
1 2 3 4 5 6 7 8 9 10 11
| GET /deltaqin/_search { "query":{ "bool":{ "must":{ "match": { "title": "小米手机",operator:"and"}}, "filter":{ "range":{"price":{"gt":2000.00,"lt":3800.00}} } } } }
|
所以对原来的基本查询进行改造:(SearchService中的search方法)
1 2 3 4 5 6
|
BoolQueryBuilder boolQueryBuilder = buildBooleanQueryBuilder(request); queryBuilder.withQuery(boolQueryBuilder);
|
因为比较复杂,将其封装到一个方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
private BoolQueryBuilder buildBooleanQueryBuilder(SearchRequest request) { BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
boolQueryBuilder.must(QueryBuilders.matchQuery("all", request.getKey()).operator(Operator.AND));
if (CollectionUtils.isEmpty(request.getFilter())){ return boolQueryBuilder; } for (Map.Entry<String, String> entry : request.getFilter().entrySet()) { String key = entry.getKey(); if (!"cid3".equals(key) && !"brandId".equals(key)) { key = "specs." + key + ".keyword"; } boolQueryBuilder.filter(QueryBuilders.termQuery(key, entry.getValue())); } return boolQueryBuilder; }
|
页面展示选择的过滤项
隐藏已经选择的过滤项
用户选择的项保存在search.filter中,可以编写一个计算属性,把filters中的 已经被选择的key过滤掉:
1 2 3 4 5 6 7 8 9
| computed:{ remainFilters(){ const keys = Object.keys(this.search.filter);
return this.filters.filter(f => !keys.includes(f.k) && f.options.length > 1); } }
|
然后页面不再直接遍历filters,而是遍历remainFilters

展示已经选择的过滤项
需要在页面展示用户已选择的过滤项,所有已选择过滤项都保存在search.filter中,因此在页面遍历并展示即可。
- 商品分类:分类展示在面包屑位置
- 品牌:这个要展示,但是其key和值不合适,不能显示一个id在页面。需要找到其name值
- 数值类型规格:这个展示的时候,需要把单位查询出来
- 非数值类型规格:这个直接展示其值即可
1 2 3 4 5 6 7
| <ul class="tags-choose"> <li class="tag" v-for="(v,k) in search.filter" v-if="k !== 'cid3'" :key="k"> {{k === 'brandId' ? '品牌' : k}}:<span style="color: red" v-text="findValue(k,v)"></span> <i class="sui-icon icon-tb-close" @click="deleteFilter(k)"></i> </li> </ul>
|
- 判断如果
k === 'cid3'说明是商品分类,直接忽略
- 判断
k === 'brandId'说明是品牌,页面显示品牌,其它规格则直接显示k的值
- 值的处理比较复杂,我们用一个方法
getFilterValue(k,v)来处理,调用时把k和v都传递
1 2 3 4 5 6 7 8
| findValue(k,v){ if(!this.filters || this.filters.length === 0){ return null; } if (k != 'brandId') return v; return this.filters.find(f => f.k === 'brandId').options[0].name; },
|
然后刷新页面,即可
取消过滤项
绑定点击事件:
1 2 3 4
| <li class="tag" v-for="(v,k) in search.filter" :key="k"> {{k === 'brandId' ? '品牌' : k}}:<span style="color: red" v-text="findValue(k,v)"></span> <i class="sui-icon icon-tb-close" @click="deleteFilter(k)"></i> </li>
|
删除过滤项
1 2 3 4 5 6 7
| deleteFilter(k){ const {... obj} = this.search.filter; delete obj[k]; this.search.filter = obj; }
|
商品分类面包屑
当用户选择一个商品分类以后,应该在过滤模块的上方展示一个面包屑,把三级商品分类都显示出来。用户选择的商品分类就存放在search.filter中,但是里面只有第三级分类的id:cid3。需要根据它查询出所有三级分类的id及名称
提供查询分类接口
在商品微服务中提供一个根据三级分类id查询1~3级分类集合的方法:
Controller
1 2 3 4 5 6 7 8 9 10 11 12 13
|
@GetMapping("all/level") public ResponseEntity<List<Category>> queryAllByCid3(@RequestParam("id") Long id){ List<Category> list = this.categoryService.queryAllByCid3(id); if (list == null || list.size() < 1) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return ResponseEntity.ok(list); }
|
Service
1 2 3 4 5 6
| public List<Category> queryAllByCid3(Long id) { Category c3 = this.categoryMapper.selectByPrimaryKey(id); Category c2 = this.categoryMapper.selectByPrimaryKey(c3.getParentId()); Category c1 = this.categoryMapper.selectByPrimaryKey(c2.getParentId()); return Arrays.asList(c1,c2,c3); }
|

页面展示面包屑
判断商品分类是否只有1个,如果是,则查询三级商品分类,添加到面包屑即可。

渲染:
1 2 3 4 5 6 7 8
| <ul class="fl sui-breadcrumb"> <li><span>全部结果:</span></li> <li v-for="(c,i) in breads" :key="i"> <a href="#" v-if="i < 2">{{c.name}}</a> <span v-else>{{c.name}}</span> </li> </ul>
|
优化
搜索系统需要优化的点:
- 查询规格参数部分可以添加缓存
- 聚合计算interval变化频率极低,所以可以设计为定时任务计算(周期为天),然后缓存起来。
- elasticsearch本身有查询缓存,可以不进行优化
- 商品图片应该采用缩略图,减少流量,提高页面加载速度
- 图片采用延迟加载
- 图片还可以采用CDN服务器
- sku信息应该在页面异步加载,而不是放到索引库