和检索的所有功能都放在search 服务下:
因为要对前端页面进行搭建:所以search 服务中需要引入thymeleaf
在Index 页面中,引入thymeleaf 的名称空间。
功能说明:
当点击搜索按钮时,就应该跳转到search 服务的index
同时浏览器中输入search.gulimall.com 也能进入来搜索页面。然后又要用nginx 的动静分离。所以所有的关于search 服务的静态资源都放在nginx 的html/static/search 的路径下。
搜索页面的html 文件就放在search 服务中
然后在gateway 服务中配置负责转发search 页面请求的路由。
既然要让gateway 发现并找到search 服务,那么该服务就必须在Nacos 中有注册。
search 配置Nacos 服务发现地址, 以及配置当前应用的名字
配置服务发现功能:
接下来进行项目开发:
准备:
加入热部署依赖
关闭thymeleaf 缓存:
这样修改完的前端页面就可以直接Ctrl + F9 进行重新编译。
项目中除里用域名进入搜索页面,也能可以通过三级菜单中的分类来进入搜索页面。
点击了三级菜单,因为它需要转到list.html ,所以就把原本search 服务的index.html 改名为list.html
编写一个专门处理三级菜单跳转到搜索页面的Controller
效果:
实现搜索栏输入关键字后点击搜索按钮进入搜索页面功能:
分析:
检索系统要检索的商品
应该是在经过search 页面的Controller 时获取到搜索的参数,然后再返回所需要的数据到搜索页面。
在进入搜索页面下面还有一大堆的检索条件,这些构成了复杂的检索条件。
约定:在点击综合排序时,只能选中一个进行排序搜索。在项目中我们修改成:销量,价格,热度
比如我点击了销量,按照升序进行排序,那么封装成参数就是:
过滤参数:
给商品筛选的参数制定规则:
把这里面的属性都统称为:attrs
比如我选择:“系统”的“其他”和“安卓” -> 参数形式:attrs=1_其他:安卓
我还选择了其他的属性:attrs=1_其他:安卓&attrs=…
因为页面也需要数据进行显示,所以也要封装一个VO 对象给它
参考京东商城,每个查询结果的属性中都有两个固定的属性:品牌和分类
下面的属性都是根据商品所具有的属性来进行显示的。
attrName 就是像“操作系统”,“分辨率”…,他们的值就是可以被点击的部分(蓝色部分)
关于分类和品牌在Vo 对象中的字段:
检索参数是这样的:
将这些参数都分为三部分:品牌,分类,其他参数。各自都是一个类。并封装来一个List 中。
Controller:
这些查询都是去ES 中查询。
根据SearchResult 的参数(分页数据除外),制定ES 的查询
@Data
public class SearchParam {
//从搜索框中输入的关键字
private String keyword;
//从三级菜单点击商品中传过来的三级分类id
private Long catalogId;
/**
* sort = saleCount_asc/desc
* sort = skuPrice_asc/desc
* sort = hotScore_asc/desc
*/
//排序条件
private String sort;
/**
* 好多的过滤条件
* hasStock(是否有货)、skuPrice价格区间、brandId、catalog3Id
* hasStock=0/1
* skuPrice=1_500/_500/500_
*/
private Integer hasStock; //是否只显示有货
private String skuPrice;//价格区间
private List<Long> brandId; //按照品牌进行查询,可以多选
private List<String> attrs; //按照属性进行筛选
private Integer pageNum; //页码
}
keyword 就是must 中的skuTitle;
为什么要用filter? 因为这里面的数据不用得分,查询出来会快一点
catalog3Id 就是filter 中的catalog3Id。
sort 就是filter 中的sort。
hasStock 就是filter 中的hasStcok。
brandId 就是filter 中的brandId .
attrs 就是filter 中nested 中的attrs(因为它是经过ES 的扁平化处理的,所以要用nested 才能拿出来)
如果要查询的attr 是多个那么就要写多个term,要为不同的attr 属性创建不同的attr
skuPrice 就是filter 中的range 的skuPrice(查询价格区间)
分页数据:
from…size…
因为在页面展示的时候,鼠标选中商品时,商品介绍会有一个高亮显示。
效果:
GET product/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "华为"
}
}
],
"filter": [
{
"term": {
"catalogId": "225"
}
},
{
"terms": {
"brandId": [
"1",
"4",
"9"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "9"
}
}
},
{
"terms": {
"attrs.attrValue": [
"海思(Hisillcon)",
"HUAWEI kirin970"
]
}
}
]
}
}
}
},
{
"term": {
"hasStock": {
"value": "true"
}
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 6000
}
}
}
]
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size": 1,
"highlight": {
"fields": {"skuTitle":{}},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
}
}
最难的是在搜索页面把搜索出的商品的数据进行动态变化(即展示出的属性都是下面商品中都要有的,而且还是动态改变的,毕竟人可能无时无刻都在搜索商品)。所以以下就是聚合区的数据内容
所以就要分析根据条件查询出的商品都有哪些属性,将这些属性进行聚合。
所以聚合数据就分为三部分:
品牌聚合,分类聚合(可能需要显示各种商品类型),属性聚合
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brand_name_agg": {
"terms": {
"field": "brandName",
"size": 10
}
},
"brand_img_agg":{
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalog_name_agg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attr_agg": {
"nested": {
"path": "attrs"
},
"aggs": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
}
下面将上面的dsl 语句转换成java 代码:
private SearchRequest buildSearchRequest(SearchParam param) {
//构建DSL 语句
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
/**
* 所有的查询条件:模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存)
*/
//1.构建bool - query
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//1.1 must - 模糊匹配
if (!StringUtils.isEmpty(param.getKeyword())){
boolQuery.must(QueryBuilders.matchQuery("skuTitle",param.getKeyword()));
}
//1.2. bool - filter 按照三级分类id 查询
if (param.getCatalog3Id() != null){
boolQuery.filter(QueryBuilders.termQuery("catalogId",param.getCatalog3Id()));
}
//1.2 bool -filter 按照品牌id 查询
if (param.getBrandId() != null && param.getBrandId().size() > 0){
boolQuery.filter(QueryBuilders.termsQuery("brandId",param.getBrandId()));
}
//1.2 bool -filter 按照所有指定的属性进行查询
if (param.getAttrs() != null && param.getAttrs().size() > 0){
//attrs=1_5寸:8寸&attrs=2_16G:8G
for (String attrStr: param.getAttrs()) {
BoolQueryBuilder nestedboolQuery = QueryBuilders.boolQuery();
//attrStr = 1_5寸:8寸
String[] s = attrStr.split("_");
String attrId = s[0]; //检索属性 id
//5寸:8寸
String[] attrValues = s[1].split(":"); //这个属性的检索用的值
nestedboolQuery.must(QueryBuilders.termQuery("attrs.attrId",attrId));
nestedboolQuery.must(QueryBuilders.termsQuery("attrs.attrValue",attrValues));
//每一个必须都得生成一个nested 查询
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestedboolQuery, ScoreMode.None);
boolQuery.filter(nestedQuery);
}
}
//1.2 bool -filter 按照库存是否有进行查询
boolQuery.filter(QueryBuilders.termQuery("hasStock",param.getHasStock() == 1));
//1.2 bool -filter 按照价格区间进行查询
if (!StringUtils.isEmpty(param.getSkuPrice())){
//1_500/_500/500_
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
String[] s = param.getSkuPrice().split("_");
if (s.length == 2){
//证明价格区间是一个双闭合区间
rangeQuery.gte(s[0]).lte(s[1]);
}else if(s.length == 1){
//证明价格区间是一个双闭合区间
if(param.getSkuPrice().startsWith("_")){
rangeQuery.lte(s[0]);
}
if(param.getSkuPrice().endsWith("_")){
rangeQuery.gte(s[0]);
}
}
boolQuery.filter(rangeQuery);
}
//把以前的所有条件都拿来进行封装
sourceBuilder.query(boolQuery);
/**
* 排序,分页,高亮
*/
//2.1 排序
if(!StringUtils.isEmpty(param.getSort())){
String sort = param.getSort();
//sort = hotSocre_asc/desc
String[] s = sort.split("_");
SortOrder order = s[1].equalsIgnoreCase("asc")?SortOrder.ASC : SortOrder.DESC;
sourceBuilder.sort(s[0],order);
}
//2.2 分页 pageSize = 5
// pageNum=1, from=0, size=5, [0,1,2,3,4]
// pageNum=2, from=5, size=5
// 计算from 的公式: from = (pageNum - 1)*size
sourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);
sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);
//2.3 高亮: 只有经过模糊查询的数据才需要高亮显示
if (!StringUtils.isEmpty(param.getKeyword())){
HighlightBuilder builder = new HighlightBuilder();
builder.field("skuTitle");
builder.preTags("<b style='color:red'>");
builder.postTags("</b>");
sourceBuilder.highlighter(builder);
}
/**
* 聚合分析
*/
//品牌聚合
TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg");
brand_agg.field("brandId").size(50);
//品牌聚合的子聚合
brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
sourceBuilder.aggregation(brand_agg);
//2. 分类聚合 catalog_agg
TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
sourceBuilder.aggregation(catalog_agg);
//3. 属性聚合 attr_agg
//声明是嵌入式聚合
NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
//聚合当前的attr_id
TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");
//根据父聚合得出的attr_id 得出他们的attr_name
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
//根据父聚合得出的attr_id 得出他们可能所有的属性值
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
attr_agg.subAggregation(attr_id_agg);
sourceBuilder.aggregation(attr_agg);
String s = sourceBuilder.toString();
System.out.println("构建的DSL:"+s);
SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, sourceBuilder);
return searchRequest;
}
这段对应的前端代码:
<div class="rig_tab">
<div th:each="product:${result.getProducts()}">
<div class="ico">
<i class="iconfont icon-weiguanzhu"></i>
<a href="/static/search/#">关注</a>
</div>
<p class="da">
<a href="/static/search/#">
<img th:src="${product.skuImg}" class="dim">
</a>
</p>
<ul class="tab_im">
<li><a href="/static/search/#" title="黑色">
<img th:src="${product.skuImg}"></a></li>
</ul>
<p class="tab_R">
<span th:text="'¥'+${product.skuPrice}">¥5199.00</span>
</p>
<p class="tab_JE">
<a href="/static/search/#" th:utext="${product.skuTitle}">
Apple iPhone 7 Plus (A1661) 32G 黑色 移动联通电信4G手机
</a>
</p>
<p class="tab_PI">已有<span>11万+</span>热门评价
<a href="/static/search/#">二手有售</a>
</p>
<p class="tab_CP"><a href="/static/search/#" title="谷粒商城Apple产品专营店">谷粒商城Apple产品...</a>
<a href='#' title="联系供应商进行咨询">
<img src="/static/search/img/xcxc.png">
</a>
</p>
<div class="tab_FO">
<div class="FO_one">
<p>自营
<span>谷粒商城自营,品质保证</span>
</p>
<p>满赠
<span>该商品参加满赠活动</span>
</p>
</div>
</div>
</div >
</div>
还有这段对应的前端:
前端代码:
<div class="JD_selector">
<!--手机商品筛选-->
<div class="title">
<h3><b>手机</b><em>商品筛选</em></h3>
<div class="st-ext">共 <span>10135</span>个商品 </div>
</div>
<div class="JD_nav_logo">
<!--品牌-->
<div class="JD_nav_wrap">
<div class="sl_key">
<span><b>品牌:</b></span>
</div>
<div class="sl_value">
<div class="sl_value_logo">
<ul>
<li th:each="brand:${result.brands}">
<a href="/static/search/#">
<img th:src="${brand.brandImg}" alt="">
<div th:text="${brand.brandName}">
华为(HUAWEI)
</div>
</a>
</li>
</ul>
</div>
</div>
<div class="sl_ext">
<a href="/static/search/#">
更多
<i style='background: url("image/search.ele.png")no-repeat 3px 7px'></i>
<b style='background: url("image/search.ele.png")no-repeat 3px -44px'></b>
</a>
<a href="/static/search/#">
多选
<i>+</i>
<span>+</span>
</a>
</div>
</div>
<!--分类-->
<div class="JD_pre">
<div class="sl_key">
<span><b>分类:</b></span>
</div>
<div class="sl_value">
<ul>
<li th:each="catalog:${result.catalogs}">
<a href="/static/search/#" th:text="${catalog.catalogName}">5.56英寸及以上</a>
</li>
</ul>
</div>
<div class="sl_ext">
<a href="/static/search/#">
更多
<i style='background: url("image/search.ele.png")no-repeat 3px 7px'></i>
<b style='background: url("image/search.ele.png")no-repeat 3px -44px'></b>
</a>
<a href="/static/search/#">
多选
<i>+</i>
<span>+</span>
</a>
</div>
</div>
<!--其他的所有需要展示的属性-->
<div class="JD_pre" th:each="attr:${result.attrs}">
<div class="sl_key">
<span th:text="${attr.attrName}">屏幕尺寸:</span>
</div>
<div class="sl_value">
<ul>
<li th:each="val:${attr.attrValue}"><a href="/static/search/#" th:text="${val}">5.56英寸及以上</a></li>
</ul>
</div>
</div>
</div>
实现效果:当点击“品牌” 和“分类” 中的数据时,在url 上就要加上对应的参数
为拼接url 写一个方法:
品牌的跳转代码:
分类的跳转代码:
其他属性的跳转代码:由于其他属性的参数是一个拼接出来的字符串,所以它的参数也要加上双引号:
完成功能:在输入框输入关键字,点击搜索,就能在下面查询出相关商品
对应代码:
其实还是调用商品属性的那个拼接url 的方法
完成功能:
分页条
这里一个巧妙的点:在a 标签中创建一个属性pn, 可以用它来操作并记录当前页码:pageNum。
这些a 标签的跳转函数。
确定按钮所绑定页数输入框的跳转函数:
完成功能:在搜索栏输入关键字进行搜索,并且回显输入的信息在搜索框
搜索功能:
图中的th:value="${param.keyword}",这里是的param 是url 中的参数,这是thymeleaf 的语法。所以这里就完成了回显
搜索函数:
完成功能:商品排序
比如:当我们点击销量,发送的请求参数是:sort=saleCount_asc/desc
而当点击对应排序按钮而显示的红色高亮显示的样式,因为它是在页面跳转以后才能显示的,所以在跳转前给它设置的样式都没用,所以就要用另一种方法
给它自定义一个style 样式:然后用三目运算算出它应该有的样式
<div class="filter_top">
<div class="filter_top_left" th:width="p = ${param.sort}">
<a class="sort_a"
th:attr="style=${(#strings.isEmpty(p) || #strings.startsWith(p,'hotScore'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="hostScore" href="/static/search/#">综合排序</a>
<a class="sort_a"
th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="saleCount" href="/static/search/#">销量</a>
<a class="sort_a"
th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="skuPrice" href="/static/search/#">价格</a>
<a class="sort_a" href="/static/search/#">评论分</a>
<a class="sort_a" href="/static/search/#">上架时间</a>
</div>
给a 标签的父标签定义一个变量:p,它所获取的就是参数中的sort 的值
因为页面进行了刷新,所以在方法中设置的desc 在刷新后的页面都没有了,所以class 属性中的值也要进行回显。可以根据发送的sort 参数的值来进行class 的回显。
<div class="filter_top_left" th:width="p = ${param.sort}">
<a th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc'))}?'sort_a desc':'sort_a'"
th:attr="style=${(#strings.isEmpty(p) || #strings.startsWith(p,'hotScore'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="hostScore" href="/static/search/#">综合排序</a>
<a th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc'))}?'sort_a desc':'sort_a'"
th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="saleCount" href="/static/search/#">销量</a>
<a th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc'))}?'sort_a desc':'sort_a'"
th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="skuPrice" href="/static/search/#">价格</a>
<a class="sort_a" href="/static/search/#">评论分</a>
<a class="sort_a" href="/static/search/#">上架时间</a>
</div>
给排序的文本内容加上:↑,↓
<div class="filter_top">
<div class="filter_top_left" th:width="p = ${param.sort}">
<a th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc'))?'sort_a desc':'sort_a'}"
th:attr="style=${(#strings.isEmpty(p) || #strings.startsWith(p,'hotScore'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="hostScore" href="/static/search/#">综合排序 [[${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc'))?'↓':'↑'}]]</a>
<a th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc'))?'sort_a desc':'sort_a'}"
th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="saleCount" href="/static/search/#">销量 [[${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc'))?'↓':'↑'}]]</a>
<a th:class="${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc'))?'sort_a desc':'sort_a'}"
th:attr="style=${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice'))?'color: #333;border-color: #CCC; background: #FFF':'color: #FFF;border-color: #4393c; background: #e4393c'}"
sort="skuPrice" href="/static/search/#">价格 [[${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc'))?'↓':'↑'}]]</a>
<a class="sort_a" href="/static/search/#">评论分</a>
<a class="sort_a" href="/static/search/#">上架时间</a>
</div>
完成功能:价格区间
组装参数函数:
页面跳转后回显参数:
完成功能:过滤查询条件
回显功能:
封装函数并跳转页面功能:
完成功能:面包屑导航
当点击一个属性进行查询时,这个属性就会增添到上面的一个导航块中,并且当删除一个导航块,页面就会回到上一个点击属性的页面中
远程调用需要的包:
1.spring-cloud
1.1 spring-cloud 版本
1.2 spring-cloud 依赖
2.open-feign 依赖
3.主程序中,配置注释开启远程调用
分析:面包屑中的数据,并构造出该Vo 类
编写远程调用接口
对应被远程调用的方法
可以看到返回的类型是AttrRespVo.
AttrRespVo
AttrVo
因为远程调用的方法的返回值是AttrRespVo,但是这个类型只属于product 类型,所以要么把AttrRespVo 放到common 类中,要么在search 类中再构建一个包含了AttrRespVo 类型包括它的父类数据的类。
从前端传来的数据param 中分析出面包屑所需要的参数并构建出来
当取消掉最新的面包屑之后,要跳转到上一个点击面包屑的参数页面中。
这里是这样设计的,每当点击一个属性之后,就生成一个面包屑数据,而这个面包屑数据就包含上一个属性作为参数的路径。作为前端:只需要把面包屑的数据都遍历出来,然后封装在一个a 标签中即可,当点击a 标签时,就触发面包屑数据的link 属性(上一个属性所在的url)
因为attr 属性是在url 中可以连续存在的,即url?attrs=xxxx&attrs=xxxx…
所以重写了替换和添加参数的方法:
前端构建面包屑效果:
当点击"x" ,就是触发了a 标签中的href。其中就包含了nav.link
完善面包屑导航功能:
当点击属性让它成为面包屑导航以后,它的属性所在的属性栏中消失。
1.其他的所有都能上面包屑导航,即品牌分类都可以
完善功能:
当点击了品牌,属性,分类成为了面包屑导航以后,对应在属性栏中的会消失。
因为属性栏在前端页面也要在成为面包屑后消失,所以要知道是哪个属性对应的id,所以就要在Result 中封装一个attrIds.
因为品牌等参数都要获取,所以获取url 的字符串。通过Servlet 的request 就可以就能够进行获取
前端:检测到参数中有没有brandId ,有则不显示品牌栏
属性栏:遍历到所有属性,然后将url 中出现的属性抹除