乐优商城笔记五:搜索模块

乐优商城搜索微服务的搭建与实现

服务搭建

创建工程
  • GroupId:com.leyou.service
  • ArtifactId:ly-search
编写pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>parent</artifactId>
        <groupId>com.leyou</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.leyou.service</groupId>
    <artifactId>ly-search</artifactId>

    <dependencies>
        <!-- Spring boot web starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- spring boot elasticsearch starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
        <!-- eureka -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- 服务调用 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

        <!-- test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!-- item service -->
        <dependency>
            <groupId>com.leyou.service</groupId>
            <artifactId>ly-item-interface</artifactId>
            <version>${leyou.latest.version}</version>
        </dependency>

    </dependencies>

</project>
编写application.yaml
server:
  port: 8002
spring:
  application:
    name: search-service
  data:
    elasticsearch:
      cluster-nodes: 192.168.136.101:9300
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:9999/eureka
  instance:
    ## 生产环境不建议开启
    lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳
    lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
编写启动类
package com.leyou;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LySearchApplication {
    public static void main(String[] args) {
        SpringApplication.run(LySearchApplication.class, args);
    }
}
索引库数据结构
package com.leyou.search.pojo;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.util.Date;
import java.util.List;
import java.util.Map;

@Data
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0)
public class Goods {
    @Id
    private Long id; // spuId
    
    @Field(type = FieldType.text, analyzer = "ik_max_word")
    private String all; // 所有需要被搜索的信息,包含标题,分类,甚至品牌
    
    @Field(type = FieldType.keyword, index = false)
    private String subTitle;// 卖点
    
    private Long brandId;// 品牌id
    
    private Long cid1;// 1级分类id
    
    private Long cid2;// 2级分类id
    
    private Long cid3;// 3级分类id
    
    private Date createTime;// 创建时间
    
    private List<Long> price;// 价格
    
    @Field(type = FieldType.keyword, index = false)
    private String skus;// sku信息的json结构
    
    private Map<String, Object> specs;// 可搜索的规格参数,key是参数名,值是参数值
}

部分字段解释:

  • all:用来进行全文检索的字段,里面包含标题、商品分类信息

  • price:价格数组,是所有sku的价格集合。方便根据价格进行筛选过滤

  • skus:用于页面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段

  • specs:所有规格参数的集合。key是参数名,值是参数值。

    例如:我们在specs中存储 内存:4G,6G,颜色为红色,转为json就是:

    {
        "specs":{
            "内存":[4G,6G],
            "颜色":"红色"
        }
    }
    

    当存储到索引库时,elasticsearch会处理为两个字段:

    • specs.内存:[4G,6G]
    • specs.颜色:红色

    另外, 对于字符串类型,还会额外存储一个字段,这个字段不会分词,用作聚合。

    • specs.颜色.keyword:红色

数据导入索引库

商品微服务新增接口
按id集合查询分类数据
  • controller

        /**
         * 根据商品分类id查询分类集合
         *
         * @param ids 要查询的分类id集合
         * @return 多个名称的集合
         */
        @GetMapping("/list/ids")
        public ResponseEntity<List<Category>> queryCategoryListByIds(@RequestParam("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 多个名称的集合
         */
        @GetMapping("names")
        public ResponseEntity<List<String>> queryNameByIds(@RequestParam("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

        /**
         * 按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

        /**
         * 查询指定ID的品牌
         *
         * @param brandId 品牌ID
         * @return Brand
         */
        @GetMapping("/{brandId}")
        public ResponseEntity<Brand> queryBrandById(@PathVariable("brandId") long brandId) {
            return ResponseEntity.ok(brandService.queryById(brandId));
        }
    
  • service

        /**
         * 按品牌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中编写对外接口

package com.leyou.api;

import com.leyou.pojo.Sku;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import java.util.List;

/**
 * 商品对外接口
 */
public interface GoodsApi {
    /**
     * 按商品ID查询该商品所有规格的商品列表
     *
     * @param spuId 商品ID
     * @return List<Sku>
     */
    @GetMapping("goods/sku/list/{spuId}")
    List<Sku> querySkuListById(@PathVariable("spuId") Long spuId);
}

注意 方法的url mapping需要加上goods,因为没有在类上声明@RequestMapping("goods"),并且方法的返回值没有使用ResponseEntity,这样更方便使用

其他接口定义与GoodsClient类似,接口类对外的方法可以在使用的时候再添加。

  • 搜索微服务编写FeignClient

编写接口,继承商品微服务提供的api接口即可。

以GoodsClient为例

package com.leyou.search.client;

import com.leyou.api.GoodsApi;
import com.leyou.common.util.LeyouConstans;
import org.springframework.cloud.openfeign.FeignClient;

/**
 * 商品微服务接口
 */
@FeignClient(LeyouConstans.SERVICE_ITEM)
public interface GoodsClient extends GoodsApi {

}

这里我将商品微服务定义在常量类中,这样方便同意管理。其他client与GoodsClient类似。

编写GoodsRepository

继承ElasticsearchRepository即可

package com.leyou.search.repository;

import com.leyou.search.pojo.Goods;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
创建索引库及其映射Mapping

使用测试类即可完成该操作

package com.leyou.search.repository;

import com.leyou.search.pojo.Goods;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {

    @Autowired
    private ElasticsearchTemplate esClient;
    @Autowired
    private GoodsRepository goodsRepository;

    @Test
    public void testCreateIndex() {
        // 创建索引库
        esClient.createIndex(Goods.class);
        // 创建映射Mapping
        esClient.putMapping(Goods.class);
    }
}
编写数据导入代码

由于我这份资源中的数据库结构中的规格数据结构和我视频中的规格数据结构不一致(数字型的规格参数没有分段信息),所以我这里就没有做数字类型分段搜索相关的操作。

SearchService
package com.leyou.search.service;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.leyou.common.enums.LyExceptionEnum;
import com.leyou.common.exception.LyException;
import com.leyou.common.util.JsonUtils;
import com.leyou.pojo.Brand;
import com.leyou.pojo.Sku;
import com.leyou.pojo.Spu;
import com.leyou.pojo.SpuDetail;
import com.leyou.search.client.item.BrandClient;
import com.leyou.search.client.item.CategoryClient;
import com.leyou.search.client.item.GoodsClient;
import com.leyou.search.pojo.Goods;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Service("appSearchService")
@Slf4j
public class SearchService {

    @Autowired
    private BrandClient brandClient;

    @Autowired
    private CategoryClient categoryClient;

    @Autowired
    private GoodsClient goodsClient;


    /**
     * 使用Spu数据构建索引库Goods结构
     *
     * @param spu 商品数据
     * @return Goods
     */
    public Goods buildGoods(Spu spu) {
        Goods goods = new Goods();
        // 基本数据处理
        goods.setBrandId(spu.getBrandId());
        goods.setCid1(spu.getCid1());
        goods.setCid2(spu.getCid2());
        goods.setCid3(spu.getCid3());
        goods.setCreateTime(spu.getCreateTime());
        goods.setSubTitle(spu.getSubTitle());
        goods.setId(spu.getId());

        try {
            // 处理搜索字段 all
            goods.setAll(buildAll(spu));

            // 处理SkuList
            List<Sku> skuList = goodsClient.querySkuListById(spu.getId());
            if (skuList == null || skuList.isEmpty()) {
                throw new LyException(LyExceptionEnum.SKU_LIST_NOT_FOUND);
            }
            goods.setSkus(buildSkus(skuList));

            // 处理price
            goods.setPrice(skuList.stream().map(Sku::getPrice).collect(Collectors.toList()));

            // 处理规格
            goods.setSpecs(buildSpecs(spu));
        } catch (Exception e) {
            log.error("error message = [{}], spuId = [{}]", e.getMessage(), spu.getId());
            return null;
        }

        return goods;
    }

    /**
     * 处理规格信息
     */
    private Map<String, Object> buildSpecs(Spu spu) {
        // 获取Spu的规格参数
        SpuDetail spuDetail = goodsClient.querySpuDetailById(spu.getId());
        if (spuDetail == null) {
            throw new LyException(LyExceptionEnum.GOODS_DETAIL_NOT_FOUND);
        }
        JSONArray spuSpec = JSON.parseArray(spuDetail.getSpecifications());

        // 封装规格索引信息
        Map<String, Object> result = new HashMap<>();
        // 遍历需要索引的key,并获取相应的值
        for (int i = 0; i < spuSpec.size(); i++) {
            JSONArray specParams = spuSpec.getJSONObject(i).getJSONArray("params");
            specParams.forEach(sp -> {
                JSONObject specParam = (JSONObject) sp;
                // 查看是否需要索引,需要则添加到索引规格Map中
                if (specParam.getBoolean("searchable") && specParam.containsKey("v")) {
                    result.put(specParam.getString("k"), specParam.getString("v"));
                } else if (specParam.getBoolean("searchable") && specParam.containsKey("options")) {
                    JSONArray options = specParam.getJSONArray("options");
                    result.put(specParam.getString("k"), options != null && options.isEmpty() ? "" : options.getString(0));
                }
            });
        }

        return result;
    }

    /**
     * 处理skus信息和价格信息
     */
    private String buildSkus(List<Sku> skuList) {
        List<Map<String, Object>> skus = new ArrayList<>();
        // 获取需要的字段并封装到map
        skuList.forEach(sku -> {
            HashMap<String, Object> map = new HashMap<>();
            map.put("id", sku.getId());
            map.put("title", sku.getTitle());
            map.put("price", sku.getPrice());
            map.put("image", StringUtils.substringBefore(sku.getImages(), ","));

            skus.add(map);
        });
        return JsonUtils.serialize(skus);
    }

    /**
     * 处理搜索字段
     */
    private String buildAll(Spu spu) {
        StringBuilder stringBuilder = new StringBuilder(spu.getTitle());

        // 分类信息
        List<String> categoryNames = categoryClient.queryNameByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
        stringBuilder.append(StringUtils.join(categoryNames, " "));

        // 品牌信息
        Brand brand = brandClient.queryBrandById(spu.getBrandId());
        if (brand == null) {
            throw new LyException(LyExceptionEnum.BRAND_NOT_FOUND);
        }
        stringBuilder.append(brand.getName());

        return stringBuilder.toString();
    }
}
导入数据
    @Test
    public void loadData() {
        int page = 1;
        int rows = 100;
        int size;
        do {
            // 查询spu集合
            PageResult<SpuBO> spuList = goodsClient.querySpuByPage(page, rows, null, null, null, true);
            if (spuList == null || spuList.getItems().isEmpty()) {
                break;
            }
            size = spuList.getItems().size();

            List<Goods> goodsList = spuList.getItems().stream()
                    .map(searchService::buildGoods).filter(Objects::nonNull).collect(Collectors.toList());

            // 保存至索引库
            goodsRepository.saveAll(goodsList);
            page++;
        } while (size == 100);
    }

执行loadData,前往kibana查看

完成基本搜索

准备工作:

  • 允许www.leyou.com到api.leyou.com的跨域
  • ly-gateway中配置search-service的路由
后端
添加搜索请求对象
package com.leyou.search.pojo;

/**
 * 搜索请求参数
 * 
 * 用于接收前端传递的搜索请求参数
 */
public class SearchRequest {

    private String key;// 搜索条件

    private Integer page;// 当前页

    private static final Integer DEFAULT_SIZE = 20;// 每页大小,不从页面接收,而是固定大小
    private static final Integer DEFAULT_PAGE = 1;// 默认页

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }

    public Integer getPage() {
        if(page == null){
            return DEFAULT_PAGE;
        }
        // 获取页码时做一些校验,不能小于1
        return Math.max(DEFAULT_PAGE, page);
    }

    public void setPage(Integer page) {
        this.page = page;
    }

    public Integer getSize() {
        return DEFAULT_SIZE;
    }
}
Controller
package com.leyou.search.controller;

import com.leyou.common.vo.PageResult;
import com.leyou.search.pojo.Goods;
import com.leyou.search.pojo.SearchRequest;
import com.leyou.search.service.SearchService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * 搜索模块 controller
 */
@RestController
public class SearchController {

    @Autowired
    private SearchService searchService;

    /**
     * 分页查询goods集合
     *
     * @param request 搜索参数
     * @return goods分页结果
     */
    @PostMapping("page")
    public ResponseEntity<PageResult<Goods>> queryGoodsByPage(@RequestBody SearchRequest request) {
        return ResponseEntity.ok(searchService.queryByPage(request));
    }

}
Service

SearchService中新增分页搜索方法

    /**
     * 分页查询goods集合
     *
     * @param request 搜索参数
     * @return goods分页对象
     */
    public PageResult<Goods> queryByPage(SearchRequest request) {
        // 获取分页参数,且elasticsearch页码从0开始,需要减1
        int page = request.getPage() - 1;
        int size = request.getSize();

        // 分页查询
        NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder();
        nativeSearchQueryBuilder.withPageable(PageRequest.of(page, size));
        // 结果过滤
        nativeSearchQueryBuilder.withSourceFilter(
                new FetchSourceFilter(new String[]{SearchAppConstans.FIELD_ID, SearchAppConstans.FIELD_SUB_TITLE, SearchAppConstans.FIELD_SKUS}, null));
        // 查询方式
        nativeSearchQueryBuilder.withQuery(QueryBuilders.matchQuery(SearchAppConstans.FIELD_ALL, request.getKey()));

        Page<Goods> goodsPage = goodsRepository.search(nativeSearchQueryBuilder.build());

        return new PageResult<>(goodsPage.getTotalElements(), (long) goodsPage.getTotalPages(), goodsPage.getContent());
    }

这里我将所有的elasticsaerch中的映射字段定义在常量类中统一管理。

测试

这里我并没有指定查询第几页,所以默认是查询第一页的数据

成功查询到商品数据,但是可以在返回结果中看到很多为null的数据,这里我们可以通过设置Spring mvc的配置过滤掉为null的字段。

重启搜索微服务,再次查询。

这样就清爽很多了,后端基本上就已经完成了

前端
发起搜索请求与返回结果接收

search.html中添加js代码

var vm = new Vue({
    el: "#searchApp",
    data: {
        ly,
        // 搜索参数
        search: {},
        // 商品集合
        goodsList: [],
        // 总记录数
        total: 0,
        // 总页数
        totalPage: 0
    },
    created() {
        // 判断是否有请求参数
        if(!location.search){
            return;
        }
        // 将请求参数转为对象
        const search = ly.parse(location.search.substring(1));
        // 记录在data的search对象中
        this.search = search;

        // 发起请求,根据条件搜索
        this.loadData();
    },
    methods: {
        loadData() {
            ly.http.post("/search/page", this.search).then(resp=>{
                // 处理goodsList
                resp.data.items.forEach(goods => {
                    // 序列化skus
                    goods.skus = JSON.parse(goods.skus);
                    // 添加默认选中的sku
                    goods.selected = goods.skus[0];
                });
                this.goodsList =resp.data.items;
                this.total = resp.data.total;
                this.totalPage = resp.data.totalPage;
            })
        }
    },
    components:{
        lyTop: () => import("./js/pages/top.js")
    }
});

包括拿到返回结果的数据初始化工作

数据渲染
<div class="goods-list">
    <ul class="yui3-g">
        <li class="yui3-u-1-5" v-for="goods in goodsList" :key="goods.id">
            <div class="list-wrap">
                <div class="p-img">
                    <a href="item.html" target="_blank"><img :src="goods.selected.image" height="200"/></a>
                    <ul class="skus">
                        <li :class="{selected : sku.id === goods.selected.id}" @mouseenter="goods.selected=sku" v-for="sku in goods.skus" :key="sku.id">
                            <img :src="sku.image">
                        </li>
                    </ul>
                </div>
                <div class="clearfix"></div>
                <div class="price">
                    <strong>
                        <em>¥</em>
                        <i v-text="ly.formatPrice(goods.selected.price)"></i>
                    </strong>
                </div>
                <div class="attr">
                    <em v-text="goods.selected.title.substring(0, 20) + '...'"></em>
                </div>
                <div class="cu">
                    <em><span></span>{{goods.subTitle.substring(0, 16) + '...'}}</em>
                </div>
                <div class="commit">
                    <i class="command">已有2000人评价</i>
                </div>
                <div class="operate">
                    <a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
                    <a href="javascript:void(0);" class="sui-btn btn-bordered">对比</a>
                    <a href="javascript:void(0);" class="sui-btn btn-bordered">关注</a>
                </div>
            </div>
        </li>
    </ul>
</div>

主要工作:

  • 遍历goodsList
  • 展示被选中的sku的价格,图片,名称
  • 通过缩略图选择不同的sku
  • 处理价格
分页条

新增Js代码

  • methods中添加方法

    index(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

    watch:{
        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);
            }
        }
    }
    
  • 页面渲染 - 底部分页条

    <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}}页&nbsp;</span>
                    <span>
                    到第
                    <input type="text" class="page-num" v-model="search.page"><button class="page-confirm" :click="search">确定</button>
                </span>
            </div>
        </div>
    </div>
    
  • 页面渲染 - 导航处分页内容

    <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">&lt;</a>
        <a class="btn-arrow" @click="nextPage" style="display: inline-block">&gt;</a>
    </div>
    

复杂过滤查询

过滤分析

整个过滤部分有3块:

  • 顶部的导航,已经选择的过滤条件展示:
    • 商品分类面包屑,根据用户选择的商品分类变化
    • 其它已选择过滤参数
  • 过滤条件展示,又包含3部分
    • 商品分类展示
    • 品牌展示
    • 其它规格参数
  • 展开或收起的过滤条件的按钮

顶部导航要展示的内容跟用户选择的过滤条件有关。

  • 比如用户选择了某个商品分类,则面包屑中才会展示具体的分类
  • 比如用户选择了某个品牌,列表中才会有品牌信息。
分类以及品牌过滤

准备工作

分类:页面显示了分类名称,但背后肯定要保存id信息。所以至少要有id和name

品牌:页面展示的有logo,有文字,当然肯定有id,基本上是品牌的完整数据

我们新建一个类,继承PageResult,然后扩展两个新的属性:分类集合和品牌集合

package com.leyou.search.pojo;

import com.leyou.common.vo.PageResult;
import com.leyou.pojo.Brand;
import com.leyou.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, Long totalPage, List<Goods> items, List<Category> categories, List<Brand> brands) {
        super(total, totalPage, items);
        this.categories = categories;
        this.brands = brands;
    }
}
后端
  • 改造SearchService并修改SearchController中的search方法的返回值为 SearchResult

    /**
     * 分页查询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
    public int getTotalPages() {
       // 取出this.getSize 为 0,但是我在searchController中使用getSize为20,不知道为什么zzzzzz
           return this.getSize() == 0 ? 1 : (int)Math.ceil((double)this.total / (double)this.getSize());
       }
    
前端
  • 页面渲染

    1. 在vue的data中添加用于保存过滤信息的变量

      // 过滤参数集合
      filters:[]
      
    2. 获取到数据后初始化filters,在laadData中新增代码

      // 初始化分类以及品牌过滤
      this.filters.push({
          k:"cid3",
          options: resp.data.categories
      });
      this.filters.push({
          k:"brandId",
          options: resp.data.brands
      });
      
    3. html

      <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中新增属性,并添加该属性到构造方法中

private List<Map<String, Object>> specs;
后端
  • 修改SearchService中的search方法

    categories.remove(1)是因为我的索引库中除了手机分类还有手机贴膜分类,不去掉,无法满足聚合规格参数的条件,所以这里在调试的时候加上了这行代码,调试完后,请注释掉或删除该行代码。

  • getSpecAgg方法

    /**
     * 聚合规格参数
     */
    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方法,始化规格过滤

    // 初始化规格过滤
    resp.data.specs.forEach(spec => {
       spec.options = spec.options.map(o => ({name:o}));
       this.filters.push(spec);
    });
    
  • 展示更多和收起

    1. 在vue的data中定义记录展开和隐藏的状态值

      show: false
      
    2. 给按钮绑定点击事件,改变show的值

      <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>
      
    3. 遍历filters时,判断是否展示更多

  • 最终效果

过滤条件的筛选
后端

准备工作

SearchRequest对象中扩展用于接收过滤信息的对象,由于过滤信息是不确定的,所以使用Map接收。

private Map<String, String> filter; // 注意添加get,set方法
  • 修改生成查询条件的方式

    buildBasicQuery(SearchRequest request)

    /**
     * 生成查询条件
     */
    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方法,并绑定点击事件

    selectFilter(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过滤掉

    computed:{
        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));
        }
    }
    

    修改遍历的filterremainFilters

  • 已选过滤项标签显示

    基本有四类数据:

    • 商品分类:这个不需要展示,分类展示在面包屑位置
    • 品牌:这个要展示,但是其key和值不合适,我们不能显示一个id在页面。需要找到其name值
    • 数值类型规格:这个展示的时候,需要把单位查询出来
    • 非数值类型规格:这个直接展示其值即可

    html

    <!--已选择过滤项-->
                <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)来处理,调用时把kv都传递

    js

    getFilterValue(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;
    }
    
  • 取消已选择过滤项

    绑定单击事件到对应的标签上

    removeFilter(k){
        this.search.filter[k] = null;
    }
    
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值