完成乐优商城搜索微服务的搭建和实现
服务搭建
创建工程
- GroupId:
com.leyou.service
- ArtifactId:
ly-search
编写pom.xml
1 |
|
编写application.yaml
1 | server: |
编写启动类
1 | package com.leyou; |
索引库数据结构
1 | package com.leyou.search.pojo; |
部分字段解释:
all:用来进行全文检索的字段,里面包含标题、商品分类信息
price:价格数组,是所有sku的价格集合。方便根据价格进行筛选过滤
skus:用于页面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段
specs:所有规格参数的集合。key是参数名,值是参数值。
例如:我们在specs中存储 内存:4G,6G,颜色为红色,转为json就是:
1
2
3
4
5
6{
"specs":{
"内存":[4G,6G],
"颜色":"红色"
}
}当存储到索引库时,elasticsearch会处理为两个字段:
- specs.内存:[4G,6G]
- specs.颜色:红色
另外, 对于字符串类型,还会额外存储一个字段,这个字段不会分词,用作聚合。
- specs.颜色.keyword:红色
数据导入索引库
商品微服务新增接口
按id集合查询分类数据
controller
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/**
* 根据商品分类id查询分类集合
*
* @param ids 要查询的分类id集合
* @return 多个名称的集合
*/
"/list/ids") (
public ResponseEntity<List<Category>> queryCategoryListByIds( ("ids") List<Long> ids) {
if (ids.isEmpty()) {
throw new LyException(LyExceptionEnum.PARAM_CANNOT_BE_NULL);
}
return ResponseEntity.ok(categoryService.queryByIds(ids));
}
/**
* 根据商品分类id查询名称
*
* @param ids 要查询的分类id集合
* @return 多个名称的集合
*/
"names") (
public ResponseEntity<List<String>> queryNameByIds( ("ids") List<Long> ids) {
if (ids.isEmpty()) {
throw new LyException(LyExceptionEnum.PARAM_CANNOT_BE_NULL);
}
List<String> list = this.categoryService.queryNameByIds(ids);
if (list == null || list.size() < 1) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(list);
}service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/**
* 按ID查询分类集合,id可以为多个
*
* @param ids id集合
* @return List<Category>
*/
public List<Category> queryByIds(List<Long> ids) {
return categoryMapper.selectByIdList(ids);
}
/**
* 根据商品分类id查询名称
*
* @param ids 要查询的分类id集合
* @return 多个名称的集合
*/
public List<String> queryNameByIds(List<Long> ids) {
return queryByIds(ids).stream().map(Category::getName).collect(Collectors.toList());
}
按品牌ID查询品牌
controller
1
2
3
4
5
6
7
8
9
10/**
* 查询指定ID的品牌
*
* @param brandId 品牌ID
* @return Brand
*/
"/{brandId}") (
public ResponseEntity<Brand> queryBrandById(@PathVariable("brandId") long brandId) {
return ResponseEntity.ok(brandService.queryById(brandId));
}service
1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 按品牌ID查询品牌
*
* @param brandId 品牌
* @return Brand
*/
public Brand queryById(long brandId) {
Brand brand = brandMapper.selectByPrimaryKey(brandId);
if (brand == null) {
throw new LyException(LyExceptionEnum.BRAND_NOT_FOUND);
}
return brand;
}
编写FeignClient
将部分商品微服务的api对外提供。对外的接口列表由服务的提供方来维护。
- 商品微服务对外提供api
以GoodsApi为例
在item-interface中编写对外接口
1 | package com.leyou.api; |
注意 方法的url mapping需要加上goods,因为没有在类上声明
@RequestMapping("goods")
,并且方法的返回值没有使用ResponseEntity
,这样更方便使用
其他接口定义与GoodsClient类似,接口类对外的方法可以在使用的时候再添加。
- 搜索微服务编写FeignClient
编写接口,继承商品微服务提供的api接口即可。
以GoodsClient为例
1 | package com.leyou.search.client; |
这里我将商品微服务定义在常量类中,这样方便同意管理。其他client与GoodsClient类似。
编写GoodsRepository
继承
ElasticsearchRepository
即可
1 | package com.leyou.search.repository; |
创建索引库及其映射Mapping
使用测试类即可完成该操作
1 | package com.leyou.search.repository; |
编写数据导入代码
由于我这份资源中的数据库结构中的规格数据结构和我视频中的规格数据结构不一致(数字型的规格参数没有分段信息),所以我这里就没有做数字类型分段搜索相关的操作。
SearchService
1 | package com.leyou.search.service; |
导入数据
1 |
|
执行loadData,前往kibana查看
完成基本搜索
准备工作:
- 允许www.leyou.com到api.leyou.com的跨域
- 在
ly-gateway
中配置search-service
的路由
后端
添加搜索请求对象
1 | package com.leyou.search.pojo; |
Controller
1 | package com.leyou.search.controller; |
Service
在
SearchService
中新增分页搜索方法
1 | /** |
这里我将所有的elasticsaerch中的映射字段定义在常量类中统一管理。
测试
这里我并没有指定查询第几页,所以默认是查询第一页的数据
成功查询到商品数据,但是可以在返回结果中看到很多为null
的数据,这里我们可以通过设置Spring mvc
的配置过滤掉为null
的字段。
重启搜索微服务,再次查询。
这样就清爽很多了,后端基本上就已经完成了
前端
发起搜索请求与返回结果接收
在
search.html
中添加js代码
1 | var vm = new Vue({ |
包括拿到返回结果的数据初始化工作
数据渲染
1 | <div class="goods-list"> |
主要工作:
- 遍历goodsList
- 展示被选中的sku的价格,图片,名称
- 通过缩略图选择不同的sku
- 处理价格
分页条
新增Js代码
在
methods
中添加方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21index(i){
if(this.search.page <= 3 || this.totalPage <= 5){
// 如果当前页小于等于3或者总页数小于等于5
return i;
} else if(this.search.page > 3) {
// 如果当前页大于3
return this.search.page - 3 + i;
} else {
return this.totalPage - 5 + i;
}
},
prevPage(){
if(this.search.page > 1){
this.search.page--
}
},
nextPage(){
if(this.search.page < this.totalPage){
this.search.page++
}
}深度监控
search.page
1
2
3
4
5
6
7
8
9
10
11
12
13watch:{
search:{
deep:true,
handler(val,old){
if(!old || !old.key){
// 如果旧的search值为空,或者search中的key为空,证明是第一次
return;
}
// 把search对象变成请求参数,拼接在url路径
window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val);
}
}
}页面渲染 - 底部分页条
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24<div class="fr">
<div class="sui-pagination pagination-large">
<ul style="width: 550px">
<li :class="{prev:true,disabled:search.page === 1}">
<a @click="prevPage">«上一页</a>
</li>
<li :class="{active: index(i) === search.page}" v-for="i in Math.min(5,totalPage)" :key="i">
<a href="#">{{index(i)}}</a>
</li>
<li class="dotted" v-show="totalPage > 5"><span>...</span></li>
<li :class="{next:true,disabled:search.page === totalPage}">
<a @click="nextPage">下一页»</a>
</li>
</ul>
<div>
<span>共{{totalPage}}页 </span>
<span>
到第
<input type="text" class="page-num" v-model="search.page">
页 <button class="page-confirm" :click="search">确定</button>
</span>
</div>
</div>
</div>页面渲染 - 导航处分页内容
1
2
3
4
5
6<div class="top-pagination">
<span>共 <i style="color: #222;">{{total}}</i> 商品</span>
<span><i style="color: red;">{{search.page}}</i>/{{totalPage}}</span>
<a class="btn-arrow" @click="prevPage" style="display: inline-block"><</a>
<a class="btn-arrow" @click="nextPage" style="display: inline-block">></a>
</div>
复杂过滤查询
过滤分析
整个过滤部分有3块:
- 顶部的导航,已经选择的过滤条件展示:
- 商品分类面包屑,根据用户选择的商品分类变化
- 其它已选择过滤参数
- 过滤条件展示,又包含3部分
- 商品分类展示
- 品牌展示
- 其它规格参数
- 展开或收起的过滤条件的按钮
顶部导航要展示的内容跟用户选择的过滤条件有关。
- 比如用户选择了某个商品分类,则面包屑中才会展示具体的分类
- 比如用户选择了某个品牌,列表中才会有品牌信息。
分类以及品牌过滤
准备工作
分类:页面显示了分类名称,但背后肯定要保存id信息。所以至少要有id和name
品牌:页面展示的有logo,有文字,当然肯定有id,基本上是品牌的完整数据
我们新建一个类,继承PageResult,然后扩展两个新的属性:分类集合和品牌集合
1 | package com.leyou.search.pojo; |
后端
改造
SearchService
并修改SearchController
中的search
方法的返回值为SearchResult
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/**
* 分页查询goods集合
*
* @param request 搜索参数
* @return goods分页对象
*/
public SearchResult queryByPage(SearchRequest request) {
// 获取分页参数,且elasticsearch页码从0开始,需要减1
int page = request.getPage() - 1;
int size = request.getSize();
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder();
// 查询方式
nativeSearchQueryBuilder.withQuery(QueryBuilders.matchQuery(SearchAppConstans.FIELD_ALL, request.getKey()));
// 结果过滤
nativeSearchQueryBuilder.withSourceFilter(
new FetchSourceFilter(new String[]{SearchAppConstans.FIELD_ID, SearchAppConstans.FIELD_SUB_TITLE, SearchAppConstans.FIELD_SKUS}, null));
// 分页查询
PageRequest pageRequest = PageRequest.of(page, size);
nativeSearchQueryBuilder.withPageable(PageRequest.of(page, size));
// 聚合分类以及品牌
nativeSearchQueryBuilder.addAggregation(
AggregationBuilders.terms(SearchAppConstans.AGGREGATION_CATEGORY).field(SearchAppConstans.FIELD_CID_3));
nativeSearchQueryBuilder.addAggregation(
AggregationBuilders.terms(SearchAppConstans.AGGREGATION_BRAND).field(SearchAppConstans.FIELD_BRAND_ID));
AggregatedPage<Goods> searchResult = template.queryForPage(nativeSearchQueryBuilder.build(), Goods.class);
// 分页结果
Page<Goods> pageResult = PageableExecutionUtils.getPage(searchResult.getContent(), pageRequest, searchResult::getTotalElements);
// 获取聚合结果
Aggregations aggregations = searchResult.getAggregations();
List<Category> categories = getCategoryAgg(aggregations.get(SearchAppConstans.AGGREGATION_CATEGORY));
List<Brand> brands = getBrandAgg(aggregations.get(SearchAppConstans.AGGREGATION_BRAND));
return new SearchResult(pageResult.getTotalElements(), (long) pageResult.getTotalPages(),
pageResult.getContent(), categories, brands);
}
/**
* 获取分类聚合结果
*
* @param aggregation 聚合结果
* @return 分类集合
*/
private List<Category> getCategoryAgg(LongTerms aggregation) {
try {
List<Long> cids = aggregation.getBuckets().stream()
.map(bucket -> bucket.getKeyAsNumber().longValue())
.collect(Collectors.toList());
return categoryClient.queryCategoryListByIds(cids);
} catch (Exception e) {
log.error("获取分类聚合结果出错! error message = [{}]", e.getMessage());
return null;
}
}
/**
* 获取品牌聚合结果
*
* @param aggregation 聚合结果
* @return 品牌集合
*/
private List<Brand> getBrandAgg(LongTerms aggregation) {
try {
List<Long> bids = aggregation.getBuckets().stream()
.map(bucket -> bucket.getKeyAsNumber().longValue())
.collect(Collectors.toList());
return brandClient.queryBrandByIds(bids);
} catch (Exception e) {
log.error("获取品牌聚合结果出错! error message = [{}]", e.getMessage());
return null;
}
}还需要在
BrandController
以及BrandApi
中提供查询多个id的api。在这里还遇到坑,我无法从查询出来的结果中获取到正确的总页数,每次拿出来都是1,查看了获取totalPages的出处,显示我的每页记录数是0,明明进行了正确设置,不知道怎么回事。最后只能在返回之前重新构造分页结果对象,才能正确获取到总页数。
- 获取总页数的方法:
PageImpl.getTotalPages
1
2
3
4
5> public int getTotalPages() {
> // 取出this.getSize 为 0,但是我在searchController中使用getSize为20,不知道为什么zzzzzz
> return this.getSize() == 0 ? 1 : (int)Math.ceil((double)this.total / (double)this.getSize());
> }
>- 获取总页数的方法:
前端
页面渲染
在vue的data中添加用于保存过滤信息的变量
1
2// 过滤参数集合
filters:[]获取到数据后初始化
filters
,在laadData
中新增代码1
2
3
4
5
6
7
8
9// 初始化分类以及品牌过滤
this.filters.push({
k:"cid3",
options: resp.data.categories
});
this.filters.push({
k:"brandId",
options: resp.data.brands
});html
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<div class="clearfix selector">
<div class="type-wrap" v-for="(f,i) in filters" :key="i" v-if="f.k !== 'brandId'">
<div class="fl key">{{f.k === 'cid3' ? '分类' : 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 === 'brandId' ? '品牌' : 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>
</div>
规格参数过滤
- 1)用户搜索得到商品,并聚合出商品分类
- 2)判断分类数量是否等于1,如果是则进行规格参数聚合
- 3)先根据分类,查找可以用来搜索的规格
- 4)对规格参数进行聚合
- 5)将规格参数聚合结果整理后返回
准备工作,扩展返回结果
在SearchResult
中新增属性,并添加该属性到构造方法中
1 | private List<Map<String, Object>> specs; |
后端
修改
SearchService
中的search
方法categories.remove(1)
是因为我的索引库中除了手机
分类还有手机贴膜
分类,不去掉,无法满足聚合规格参数的条件,所以这里在调试的时候加上了这行代码,调试完后,请注释掉或删除该行代码。getSpecAgg方法
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/**
* 聚合规格参数
*/
private List<Map<String, Object>> getSpecsAgg(QueryBuilder queryBuilder, Category category) {
List<Map<String, Object>> specs = new ArrayList<>();
// 查询规格参数
String specString = specificationClient.querySpecificationByCategoryId(category.getId());
JSONArray spec = JSON.parseArray(specString);
// 遍历需要过滤的key
List<String> searchableKeyList = new ArrayList<>();
for (int i = 0; i < spec.size(); i++) {
JSONArray specParams = spec.getJSONObject(i).getJSONArray("params");
specParams.forEach(sp -> {
JSONObject specParam = (JSONObject) sp;
// 查看是否需要索引,需要则添加到索引规格Map中
if (specParam.getBoolean("searchable")) {
searchableKeyList.add(specParam.getString("k"));
}
});
}
// 聚合需要过滤的规格参数
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder();
nativeSearchQueryBuilder.withQuery(queryBuilder);
searchableKeyList.forEach(key -> nativeSearchQueryBuilder.addAggregation(
AggregationBuilders.terms(key).field(SearchAppConstans.FIELD_SPECS + "." + key + ".keyword")));
// 获取聚合结果
Aggregations aggregations = template.queryForPage(nativeSearchQueryBuilder.build(), Goods.class).getAggregations();
// 解析聚合结果
searchableKeyList.forEach(key -> {
StringTerms aggregation = aggregations.get(key);
List<String> valueList = aggregation.getBuckets().stream()
.map(StringTerms.Bucket::getKeyAsString)
.filter(StringUtils::isNoneBlank)
.collect(Collectors.toList());
// 放入结果集
Map<String, Object> map = new HashMap<>();
map.put("k", key);
map.put("options", valueList);
specs.add(map);
});
return specs;
}
前端
修改
loadData
方法,始化规格过滤1
2
3
4
5// 初始化规格过滤
resp.data.specs.forEach(spec => {
spec.options = spec.options.map(o => ({name:o}));
this.filters.push(spec);
});展示更多和收起
在vue的
data
中定义记录展开和隐藏的状态值1
show: false
给按钮绑定点击事件,改变
show
的值1
2
3
4
5
6
7
8<div class="type-wrap" style="text-align: center">
<v-btn small flat @click="show = true">
更多<v-icon>arrow_drop_down</v-icon>
</v-btn>
<v-btn small="" flat @click="show = false">
收起<v-icon>arrow_drop_up</v-icon>
</v-btn>
</div>遍历
filters
时,判断是否展示更多
最终效果
过滤条件的筛选
后端
准备工作
在SearchRequest
对象中扩展用于接收过滤信息的对象,由于过滤信息是不确定的,所以使用Map接收。
1 | private Map<String, String> filter; // 注意添加get,set方法 |
修改生成查询条件的方式
buildBasicQuery(SearchRequest request)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/**
* 生成查询条件
*/
private QueryBuilder buildBasicQuery(SearchRequest request) {
BoolQueryBuilder queryBuilder = new BoolQueryBuilder();
// 基础查询条件
queryBuilder.must(QueryBuilders.matchQuery(SearchAppConstans.FIELD_ALL, request.getKey()));
// 过滤查询条件
Map<String, String> filter = request.getFilter();
filter.forEach((key, value) -> {
// 处理key
if (!key.equals(SearchAppConstans.FIELD_CID_3) && !key.equals(SearchAppConstans.FIELD_BRAND_ID)) {
key = SearchAppConstans.FIELD_SPECS + "." + key + ".keyword";
}
queryBuilder.filter(QueryBuilders.termQuery(key, value));
});
return queryBuilder;
}
前端
在初始化过程时,
search
中新增filter属性新增
selectFilter
方法,并绑定点击事件1
2
3
4
5
6
7
8
9selectFilter(key, option){
const obj = {};
Object.assign(obj, this.search);
if(key === 'cid3' || key === 'brandId'){
option = option.id;
}
obj.filter[key] = option.name;
this.search = obj;
}注意:还需要修改
common.js
中的allowDots
为true已选过滤项不再展示
我们可以编写一个计算属性,把filters中的 已经被选择的key过滤掉
1
2
3
4
5
6
7
8
9
10
11
12computed:{
remainFilters(){
const keys = Object.keys(this.search.filter);
if(this.search.filter.cid3){
keys.push("cid3")
}
if(this.search.filter.brandId){
keys.push("brandId")
}
return this.filters.filter(f => !keys.includes(f.k));
}
}修改遍历的
filter
为remainFilters
已选过滤项标签显示
基本有四类数据:
- 商品分类:这个不需要展示,分类展示在面包屑位置
- 品牌:这个要展示,但是其key和值不合适,我们不能显示一个id在页面。需要找到其name值
- 数值类型规格:这个展示的时候,需要把单位查询出来
- 非数值类型规格:这个直接展示其值即可
html
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">{{getFilterValue(k,v)}}</span></span>
<i class="sui-icon icon-tb-close"></i>
</li>
</ul>- 判断如果
k === 'cid3'
说明是商品分类,直接忽略 - 判断
k === 'brandId'
说明是品牌,页面显示品牌,其它规格则直接显示k
的值 - 值的处理比较复杂,我们用一个方法
getFilterValue(k,v)
来处理,调用时把k
和v
都传递
js
1
2
3
4
5
6
7
8
9
10
11
12
13getFilterValue(k,v){
// 如果没有过滤参数,我们跳过展示
if(!this.filters || this.filters.length === 0){
return null;
}
let filter = null;
// 判断是否是品牌
if(k === 'brandId'){
// 返回品牌名称
return this.filters.find(f => f.k === 'brandId').options[0].name;
}
return v;
}取消已选择过滤项
绑定单击事件到对应的标签上
1
2
3removeFilter(k){
this.search.filter[k] = null;
}