【 搜索服务 】

0.学习目标

  • 独立编写数据导入功能
  • 独立实现基本搜索
  • 独立实现页面分页
  • 独立实现结果排序

1.索引库数据导入

昨天我们学习了Elasticsearch的基本应用。今天就学以致用,搭建搜索微服务,实现搜索功能。

1.1.创建搜索服务

创建module:

【 搜索服务 】_第1张图片

【 搜索服务 】_第2张图片

Pom文件:


<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>leyouartifactId>
        <groupId>com.leyou.parentgroupId>
        <version>1.0.0-SNAPSHOTversion>
    parent>
    <modelVersion>4.0.0modelVersion>

    <groupId>com.leyou.servicegroupId>
    <artifactId>ly-searchartifactId>
    <version>1.0.0-SNAPSHOTversion>

    <dependencies>
        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-netflix-eureka-clientartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-elasticsearchartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.cloudgroupId>
            <artifactId>spring-cloud-starter-openfeignartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
		dependency>
    dependencies>
project>

application.yml:

server:
  port: 8083
spring:
  application:
    name: search-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.206.66:9300
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
  instance:
    lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳
    lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
    prefer-ip-address: true
    ip-address: 127.0.0.1
    instance-id: ${
     spring.application.name}:${
     server.port}

启动类:

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class LySearchService {
     

    public static void main(String[] args) {
     
        SpringApplication.run(LySearchService.class, args);
    }
}

1.2.索引库数据格式分析

接下来,我们需要商品数据导入索引库,便于用户搜索。

那么问题来了,我们有SPU和SKU,到底如何保存到索引库?

1.2.1.以结果为导向

大家来看下搜索结果页:

【 搜索服务 】_第3张图片

可以看到,每一个搜索结果都有至少1个商品,当我们选择大图下方的小图,商品会跟着变化。

因此,搜索的结果是SPU,即多个SKU的集合

既然搜索的结果是SPU,那么我们索引库中存储的应该也是SPU,但是却需要包含SKU的信息。

1.2.2.需要什么数据

再来看看页面中有什么数据:

【 搜索服务 】_第4张图片

直观能看到的:图片、价格、标题、副标题

暗藏的数据:spu的id,sku的id

另外,页面还有过滤条件:

【 搜索服务 】_第5张图片

这些过滤条件也都需要存储到索引库中,包括:

商品分类、品牌、以及其他可用来搜索的规格参数等

综上所述,我们需要的数据格式有:

spuId、SkuId、商品分类id、品牌id、图片、价格、商品的创建时间、sku信息集、可搜索的规格参数

1.2.3.最终的数据结构

我们创建一个类,封装要保存到索引库的数据,并设置映射属性:

@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是参数名,值是参数值
    //TODO getter setter toString
}

一些特殊字段解释:

  • 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:红色

1.3.商品微服务提供接口

索引库中的数据来自于数据库,我们不能直接去查询商品的数据库,因为真实开发中,每个微服务都是相互独立的,包括数据库也是一样。所以我们只能调用商品微服务提供的接口服务。

先思考我们需要的数据:

  • SPU信息

  • SKU信息

  • SPU的详情

  • 商品分类名称(拼接all字段)

  • 规格参数

再思考我们需要哪些服务:

  • 第一:分批查询spu的服务,已经写过。
  • 第二:根据spuId查询sku的服务,已经写过
  • 第三:根据spuId查询SpuDetail的服务,已经写过
  • 第四:根据商品分类id,查询商品分类名称,没写过

因此我们需要额外提供一个查询商品分类名称的接口。

1.3.1.在item-service商品分类名称查询

CatergoryController:

/**
 * 根据商品分类id查询名称
 * @param ids 要查询的分类id集合
 * @return 多个名称的集合
 */
@GetMapping("names")
public ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids){
     
    List<String > list = this.categoryService.queryNameByIds(ids);
    if (list == null || list.size() < 1) {
     
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }
    return ResponseEntity.ok(list);
}

service:

public List<String> queryNameByIds(List<Long> ids) {
     
    return this.categoryMapper.selectByIdList(ids).stream()
        		.map(Category::getName).collect(Collectors.toList());
}

测试:

【 搜索服务 】_第6张图片

1.3.2.编写FeignClient

问题展现:

现在,我们要在搜索微服务调用商品微服务的接口。

第一步要引入商品微服务依赖:ly-item-interface


<dependency>
    <groupId>com.leyou.servicegroupId>
    <artifactId>ly-item-interfaceartifactId>
    <version>${leyou.latest.version}version>
dependency>

第二步,编写FeignClient

@FeignClient(value = "item-service")
@RequestMapping("/goods")
public interface GoodsClient {
     

    /**
     * 分页查询商品
     * @param page
     * @param rows
     * @param saleable
     * @param key
     * @return
     */
    @GetMapping("/spu/page")
    ResponseEntity<PageResult<SpuBo>> querySpuByPage(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "rows", defaultValue = "5") Integer rows,
            @RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
            @RequestParam(value = "key", required = false) String key);

    /**
     * 根据spu商品id查询详情
     * @param id
     * @return
     */
    @GetMapping("/spu/detail/{id}")
    ResponseEntity<SpuDetail> querySpuDetailById(@PathVariable("id") Long id);

    /**
     * 根据spu的id查询sku
     * @param id
     * @return
     */
    @GetMapping("sku/list")
    ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id") Long id);
}

以上的这些代码直接从商品微服务中拷贝而来,完全一致。差别就是没有方法的具体实现。大家觉得这样有没有问题?

而FeignClient代码遵循SpringMVC的风格,因此与商品微服务的Controller完全一致。这样就存在一定的问题:

  • 代码冗余。尽管不用写实现,只是写接口,但服务调用方要写与服务controller一致的代码,有几个消费者就要写几次。
  • 增加开发成本。调用方还得清楚知道接口的路径,才能编写正确的FeignClient。

解决方案:

因此,一种比较友好的实践是这样的:(服务提供方,不仅提供实体类,还要提供api接口申明)

  • 我们的服务提供方不仅提供实体类,还要提供api接口声明
  • 调用方不用字节编写接口方法声明,直接继承提供方给的Api接口即可,

需要在ly-item-interface中引入一些依赖:

<dependency>
    <groupId>org.springframeworkgroupId>
    <artifactId>spring-webmvcartifactId>
dependency>
<dependency>
    <groupId>com.leyou.commongroupId>
    <artifactId>ly-commonartifactId>
    <version>1.0.0-SNAPSHOTversion>
dependency>

第一步:服务的提供方在ly-item-interface中提供API接口,并编写接口声明:

【 搜索服务 】_第7张图片

商品分类服务接口:

@RequestMapping("category")
public interface CategoryApi {
     

    @GetMapping("names")
    List<String> queryNameByIds(@RequestParam("ids") List<Long> ids);
}

商品服务接口:

public interface GoodsApi {
     

    /**
     * 分页查询商品
     * @param page
     * @param rows
     * @param saleable
     * @param key
     * @return
     */
    @GetMapping("/spu/page")
    PageResult<SpuBO> querySpuByPage(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "rows", defaultValue = "5") Integer rows,
            @RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
            @RequestParam(value = "key", required = false) String key);

    /**
     * 根据spu商品id查询详情
     * @param id
     * @return
     */
    @GetMapping("/spu/detail/{id}")
    SpuDetail querySpuDetailById(@PathVariable("id") Long id);

    /**
     * 根据spu的id查询sku
     * @param id
     * @return
     */
    @GetMapping("sku/list")
    List<Sku> querySkuBySpuId(@RequestParam("id") Long id);
}

第二步:在调用方ly-search中编写FeignClient,但不要写方法声明了,直接继承ly-item-interface提供的api接口:

商品的FeignClient:

@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
     
}

商品分类的FeignClient:

@FeignClient(value = "item-service")
public interface CategoryClient extends CategoryApi {
     
}

是不是简单多了?

项目结构:

【 搜索服务 】_第8张图片

测试

创建测试类:

在接口上按快捷键:Ctrl + Shift + T

【 搜索服务 】_第9张图片

【 搜索服务 】_第10张图片

测试代码:


@RunWith(SpringRunner.class)
@SpringBootTest(classes = LySearchService.class)
public class CategoryClientTest {
     

    @Autowired
    private CategoryClient categoryClient;

    @Test
    public void testQueryCategories() {
     
        List<String> categoryList =
                this.categoryClient.queryNameByIds(Arrays.asList(1L, 2L, 3L));
        for (String category : categoryList) {
     
            System.out.println("category = " + category);
        }
    }
}

结果:

【 搜索服务 】_第11张图片

1.4.导入数据

1.4.1.创建GoodsRepository

java代码:

public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
     
}

1.4.2.创建索引

我们新建一个测试类,在里面进行数据的操作:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LySearchService.class)
public class ElasticsearchTest {
     

    @Autowired
    private GoodsRepository goodsRepository;

    @Autowired
    private ElasticsearchTemplate elasticsearchTemplate;

    @Test
    public void createIndex(){
     
        // 创建索引
        this.elasticsearchTemplate.createIndex(Goods.class);
        // 配置映射
        this.elasticsearchTemplate.putMapping(Goods.class);
    }
}

【 搜索服务 】_第12张图片

1.4.3.导入数据

导入数据其实就是查询数据,然后把查询到的Spu转变为Goods来保存,因此我们先编写一个IndexService,然后在里面定义一个方法, 把Spu转为Goods

添加SpecificationApi

@RequestMapping("spec")
public interface SpecificationApi {
     

    @GetMapping("/params")
    List<SpecParam> querySpecParam(
            @RequestParam(value="gid", required = false) Long gid,
            @RequestParam(value="cid", required = false) Long cid,
            @RequestParam(value="searching", required = false) Boolean searching,
            @RequestParam(value="generic", required = false) Boolean generic
    );
}

添加SpecificationClient

@FeignClient(value = "item-service")
public interface SpecificationClient extends SpecificationApi {
     
}

数据模板

【 搜索服务 】_第13张图片

@Service
public class IndexService {
     
    @Autowired
    private GoodsClient goodsClient;

    @Autowired
    private SpecificationClient specificationClient;

    @Autowired
    private CategoryClient categoryClient;


    public Goods buildGoods(SpuBo spu){
     
        //
        Long id = spu.getId();
        //准备数据
        //sku集合
        List<Sku> skus = this.goodsClient.querySkuBySpuId(id);

        //spuDetail
        SpuDetail spuDetail = this.goodsClient.querySpuDetailById(id);

        //商品分类名称
        List<String> names = this.categoryClient.queryNameByIds(Arrays.asList(spu.getCid1(),spu.getCid2(),spu.getCid3()));

        //查询通用规格参数
        List<SpecParam> genericParas = this.specificationClient.querySpecParam(null, spu.getCid3(), true, true);

        //处理sku
        //把商品价格取出单独存放,便于展示
        List<Long> prices = new ArrayList<>();

        List<Map<String,Object>> skuList = new ArrayList<>();

        for (Sku sku : skus) {
     
            prices.add(sku.getPrice());
            Map<String,Object> skuMap = new HashMap<>();
            skuMap.put("id",sku.getId());
            skuMap.put("title",sku.getPrice());
            skuMap.put("image", StringUtils.isBlank(sku.getImages()) ? "":sku.getImages().split(",")[0]);
            skuMap.put("price",sku.getPrice());
            skuList.add(skuMap);
        }

        //处理规格参数
        Map<Long, String> genericMap = JsonUtils.parseMap(spuDetail.getGenericSpec(), Long.class, String.class);


        Map<Long,List<String>> specialMap = JsonUtils.nativeRead(spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {
     
        });

        //处理规格参数显示问题,默认显示id+值,处理后显示id对应的名称+值
        Map<String, Object> specs = new HashMap<>();

        for (SpecParam param : genericParas) {
     

            //通用参数
            String value = genericMap.get(param.getId());
            if (param.getNumeric()){
     
                //数值类型需要加分段
                value = this.chooseSegment(value,param);
            }
            specs.put(param.getName(),value);
        }

        Goods goods = new Goods();
        goods.setId(spu.getId());
        //搜索条件拼接:这里如果要加品牌,可以再写个BrandClient,根据id查品牌
        goods.setAll(spu.getTitle()+" "+StringUtils.join(names," "));
        goods.setSubTitle(spu.getSubTitle());
        goods.setBrandId(spu.getBrandId());
        goods.setCid1(spu.getCid1());
        goods.setCid2(spu.getCid2());
        goods.setCid3(spu.getCid3());
        goods.setCreateTime(spu.getCreateTime());
        goods.setPrice(prices);
        goods.setSkus(JsonUtils.serialize(skuList));
        goods.setSpecs(specs);

        return goods;
    }

因为过滤参数中有一类比较特殊,就是数值区间:

【 搜索服务 】_第14张图片

所以我们在存入时要进行处理:

  private String chooseSegment(String value, SpecParam p) {
     
        double val = NumberUtils.toDouble(value);
        String result = "其它";
        // 保存数值段
        for (String segment : p.getSegments().split(",")) {
     
            String[] segs = segment.split("-");
            // 获取数值范围
            double begin = NumberUtils.toDouble(segs[0]);
            double end = Double.MAX_VALUE;
            if(segs.length == 2){
     
                end = NumberUtils.toDouble(segs[1]);
            }
            // 判断是否在范围内
            if(val >= begin && val < end){
     
                if(segs.length == 1){
     
                    result = segs[0] + p.getUnit() + "以上";
                }else if(begin == 0){
     
                    result = segs[1] + p.getUnit() + "以下";
                }else{
     
                    result = segment + p.getUnit();
                }
                break;
            }
        }
        return result;
    }

然后编写一个测试类,循环查询Spu,然后调用IndexService中的方法,把SPU变为Goods,然后写入索引库:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LySearchService.class)
public class ESLoadDataTest {
     

   @Autowired
   private IndexService indexService;

   @Autowired
   private GoodsClient goodsClient;

   @Autowired
   private GoodsRepository goodsRepository;
   @Test
   public void loadData(){
     
       int page = 1;
       int rows = 100;
       int size = 0;
       do {
     
           // 查询spu
           PageResult<SpuBO> result = this.goodsClient.querySpuByPage(page, rows, true, null);
           List<SpuBO> spus = result.getItems();

           // spu转为goods
           List<Goods> goods = spus.stream().map(spu -> this.indexService.buildGoods(spu))
                   .collect(Collectors.toList());

           // 把goods放入索引库
           this.goodsRepository.saveAll(goods);

           size = spus.size();
           page++;
       }while (size == 100);
   }
}

通过kibana查询, 可以看到数据成功导入:

【 搜索服务 】_第15张图片

2.实现基本搜索

2.1.页面分析

2.1.1.页面跳转

在首页的顶部,有一个输入框:

【 搜索服务 】_第16张图片

当我们输入任何文本,点击搜索,就会跳转到搜索页search.html了:

并且将搜索关键字以请求参数携带过来:

【 搜索服务 】_第17张图片

我们打开search.html,在最下面会有提前定义好的Vue实例:

<script type="text/javascript">
    var vm = new Vue({
      
        el: "#searchApp",
        data: {
      
        },
        components:{
      
            // 加载页面顶部组件
            lyTop: () => import("./js/pages/top.js")
        }
    });
script>

这个Vue实例中,通过import导入的方式,加载了另外一个js:top.js并作为一个局部组件。top其实是页面顶部导航组件,我们暂时不管

2.1.2.发起异步请求

要想在页面加载后,就展示出搜索结果。我们应该在页面加载时,获取地址栏请求参数,并发起异步请求,查询后台数据,然后在页面渲染。

我们在search.html的vue 中的data属性中定义一个对象,记录请求的参数:

data: {
     
    ly,
    search:{
     
        key:"", // 搜索页面的关键字
    }
}

我们通过钩子函数created,在页面加载时获取请求参数,并记录下来。

created(){
     
    // 判断是否有请求参数
    if(!location.search){
     
        return;
    }
    // 将请求参数转为对象
    const search = ly.parse(location.search.substring(1));
    // 记录在data的search对象中
    this.search = search;
    
    // 发起请求,根据条件搜索
    this.searchFromServer();
}

然后发起请求,搜索数据。

methods:{
     
    searchFromServer(){
     
        // 发起异步请求
        ly.http.post("/search/page",this.search)
            .then(resp => {
     
            console.log(resp.data);
        })
    }
}
  • 我们这里使用ly是common.js中定义的工具对象。
  • 这里使用的是post请求,这样可以携带更多参数,并且以json格式发送

刷新页面试试:

【 搜索服务 】_第18张图片

请求体:

在这里插入图片描述

因为后台没有提供接口,所以无法访问。没关系,接下来我们实现后台接口

2.2.后台提供搜索接口

2.2.1.controller

首先分析几个问题:

  • 请求方式:Post

  • 请求路径:/search/page,不过前面的/search应该是网关的映射路径,因此真实映射路径page,代表分页查询

  • 请求参数:json格式,目前只有一个属性:key,搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有page属性,因此我们可以用一个对象来接收请求的json数据:

    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;
        }
    }
    
  • 返回结果:作为分页结果,一般都两个属性:当前页数据、总条数信息,我们可以使用之前定义的PageResult类

代码:

@RestController
@RequestMapping
public class SearchController {
     

    @Autowired
    private IndexService indexService;

    /**
     * 搜索商品
     *
     * @param request
     * @return
     */
    @PostMapping("page")
    public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request) {
     
        PageResult<Goods> result = this.indexService.search(request);
        if (result == null) {
     
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.ok(result);
    }
}

2.2.2.service

public PageResult<Goods> search(SearchRequest request) {
     
    String key = request.getKey();
    if(StringUtils.isBlank(key)){
     
        // 如果用户没搜索条件,我们可以给默认的,或者返回null
        return null;
    }

    Integer page = request.getPage() - 1;// page 从0开始
    Integer size = request.getSize();

    // 1、创建查询构建器
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
    // 2、查询
    // 2.1、对结果进行筛选
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{
     "id","skus","subTitle"}, null));
    // 2.2、基本查询
    queryBuilder.withQuery(QueryBuilders.matchQuery("all", key));

    // 2.3、分页
    queryBuilder.withPageable(PageRequest.of(page, size));

    // 3、返回结果
    Page<Goods> result = this.repository.search(queryBuilder.build());

    // 4、解析结果
    long total = result.getTotalElements();
    long totalPage = (total + size - 1) / size;
    return new PageResult<>(total, totalPage, result.getContent());
}

注意点:我们要设置SourceFilter,来选择要返回的结果,否则返回一堆没用的数据,影响查询效率。

2.2.3.测试

在ly-api-gateway网关服务中加入路由支持

【 搜索服务 】_第19张图片

刷新页面测试:

【 搜索服务 】_第20张图片

【 搜索服务 】_第21张图片

数据是查到了,但是因为我们只查询部分字段,所以结果json 数据中有很多null,这很不优雅。

解决办法很简单,在application.yml中添加一行配置,json处理时忽略空值:

spring:
  jackson:
    default-property-inclusion: non_null # 配置json处理时忽略空值

结果:

【 搜索服务 】_第22张图片

2.3.页面渲染

页面已经拿到了结果,接下来就要渲染样式了。

2.3.1.保存搜索结果

首先,在search.html的data中定义属性,保存搜索的结果:

【 搜索服务 】_第23张图片

searchFromServer的异步查询中,将结果赋值给goodsList

【 搜索服务 】_第24张图片

2.3.2.循环展示商品

在search.html的中部,有一个div,用来展示所有搜索到的商品:

【 搜索服务 】_第25张图片

可以看到,div中有一个无序列表ul,内部的每一个li就是一个商品spu了。

我们删除多余的,只保留一个li,然后利用vue的循环来展示搜索到的结果:

【 搜索服务 】_第26张图片

2.3.3.多sku展示

分析

接下来展示具体的商品信息,来看图:

【 搜索服务 】_第27张图片

这里我们可以发现,一个商品位置,是多个sku的信息集合。当用户鼠标选择某个sku,对应的图片、价格、标题会随之改变!

我们先来实现sku的选择,才能去展示不同sku的数据。

【 搜索服务 】_第28张图片

可以看到,在列表中默认第一个是被选中的,那我们就需要做两件事情:

  • 记录当前被选中的是哪一个sku,记录在哪里比较合适呢?显然是遍历到的goods对象自己内部,因为每一个goods都会有自己的sku信息。
  • 在搜索到数据时,先默认把第一个sku作为被选中的,记录下来

初始化sku

我们在查询成功的回调函数中,对goods进行遍历,然后添加一个selected属性,保存被选中的sku:

【 搜索服务 】_第29张图片

【 搜索服务 】_第30张图片

多sku图片列表

接下来,我们看看多个sku的图片列表位置:

【 搜索服务 】_第31张图片

看到又是一个无序列表,这里我们也一样删掉多余的,保留一个li,需要注意选中的项有一个样式类:selected

我们的代码:


注意:

  • class样式通过 goods.selected的id是否与当前sku的id一致来判断
  • 绑定了鼠标事件,鼠标进入后把当前sku赋值到goods.selected

2.3.4.展示sku其它属性

现在,我们已经可以通过goods.selected获取用户选中的sku,那么我们就可以在页面展示了:

【 搜索服务 】_第32张图片

刷新页面:

【 搜索服务 】_第33张图片

看起来很完美是吧!

但其实有一些瑕疵

2.3.5.几个问题

1)价格显示的是分

首先价格显示就不正确,我们数据库中存放的是以分为单位,所以这里要格式化。

好在我们之前common.js中定义了工具类,可以帮我们转换。

改造:

【 搜索服务 】_第34张图片

结果报错:

【 搜索服务 】_第35张图片

为啥?

因为在Vue范围内使用任何变量,都会默认去Vue实例中寻找,我们使用ly,但是Vue实例中没有这个变量。所以解决办法就是把ly记录到Vue实例:

【 搜索服务 】_第36张图片

然后刷新页面:

【 搜索服务 】_第37张图片

2)标题过长

标题内容太长了,已经无法完全显示,怎么办?

截取一下:

【 搜索服务 】_第38张图片

最好在加个悬停展示所有内容的效果

3)sku点击不切换

还有一个错误比较隐蔽,不容易被发现。我们点击sku 的图片列表,发现没有任何变化。

这不科学啊,为什么?

通过控制台观察,发现数据其实是变化了,但是Vue却没有重新渲染视图。

这是因为Vue的自动渲染是基于对象的属性变化的。比如页面使用GoodsList进行渲染,如果GoodsList变化,或者其内部的任何子对象变化,都会Vue感知,从而从新渲染页面。

然而,这一切有一个前提,那就是当你第一次渲染时,对象中有哪些属性,Vue就只监视这些属性,后来添加的属性发生改变,是不会被监视到的。

而我们的goods对象中,本身是没有selected属性的,是我们后来才添加进去的:

【 搜索服务 】_第39张图片

这段代码稍微改造一下,即可:

【 搜索服务 】_第40张图片

也就是说,我们先把selected属性初始化完毕,然后才把整个对象赋值给goodsList,这样,goodsList已初始化时就有selected属性,以后就会被正常监控了。

【 搜索服务 】_第41张图片

3.页面分页效果

刚才的查询中,我们默认了查询的页码和每页大小,因此所有的分页功能都无法使用,接下来我们一起看看分页功能条该如何制作。

这里要分两步,

  • 第一步:如何生成分页条
  • 第二步:点击分页按钮,我们做什么

3.1.如何生成分页条

先看下页面关于分页部分的代码:

【 搜索服务 】_第42张图片

可以看到所有的分页栏内容都是写死的。

3.1.1.需要的数据

分页数据应该是根据总页数当前页总条数等信息来计算得出。

  • 当前页:肯定是由页面来决定的,点击按钮会生成不同的页
  • 总页数:需要后台传递给我们
  • 总条数:需要后台传递给我们

我们首先在data中记录下这几个值:

【 搜索服务 】_第43张图片

因为page是搜索条件之一,所以记录在search对象中。

要注意:我们在created钩子函数中,会读取url路径的参数,然后赋值给search。如果是第一次请求页面,page是不存在的。因此为了避免page被覆盖,我们应该这么做:

【 搜索服务 】_第44张图片

3.1.2.后台提供数据

后台返回的结果中,要包含total和totalPage,我们改造下刚才的接口:

在我们返回的PageResult对象中,其实是有totalPage字段的:

【 搜索服务 】_第45张图片

我们在返回时,把这个值填上:

【 搜索服务 】_第46张图片

页面测试一下:

【 搜索服务 】_第47张图片

OK

3.1.3.页面计算分页条

首先,把后台提供的数据保存在data中:

【 搜索服务 】_第48张图片

然后看下我们要实现的效果:

在这里插入图片描述

这里最复杂的是中间的1~5的分页按钮,它需要动态变化。

思路分析:

  • 最多有5个按钮,因此我们可以用v-for循环从1到5即可,假如遍历得到的值为i
  • 但是分页条不一定是从1开始:
    • 如果当前页值小于等于3的时候,分页条位置从1开始到5结束,页码就是遍历得到的i的值
    • 但是如果当前页大于3,则不应该是i,而是要比i大了(page-3),所以就是page-3 + i

所以,我们的页面这样来做:

【 搜索服务 】_第49张图片

a标签中的分页数字通过index函数来计算,需要把i传递过去:

index(i) {
     
    if (this.search.page <= 3 || this.totalPage <= 5) {
     
        // 当前页小于3,则页码就是从 i 开始
        return i;
    } else if(this.search.page <= this.totalPage - 2) {
     
        // 大于3,则从page-2开始,然后逐个加1
        return this.search.page - 3 + i;
    } else{
     
        return this.totalPage - 5 + i;
    }
}

需要注意的是,如果总页数不足5页,我们就不应该遍历15,而是1总页数,稍作改进:

在这里插入图片描述

分页条的其它部分就比较简单了:

共{ {totalPage}}页  到第

3.2.点击分页做什么

点击分页按钮后,自然是要修改page的值

所以,我们在上一页下一页按钮添加点击事件,对page进行修改,在数字按钮上绑定点击事件,点击直接修改page:

【 搜索服务 】_第50张图片

翻页事件的方法:

    prevPage(){
     
        if(this.search.page > 1){
     
            this.search.page--
        }
    },
    nextPage(){
     
        if(this.search.page < this.totalPage){
     
            this.search.page++
        }
    }

page发生变化,我们应该去后台重新查询数据。

不过,如果我们直接发起ajax请求,那么浏览器的地址栏中是不会有变化的,没有记录下分页信息。如果用户刷新页面,那么就会回到第一页。

这样不太友好,我们应该把搜索条件记录在地址栏的查询参数中

因此,我们监听search的变化,然后把search的过滤字段拼接在url路径后:

watch:{
     
    search:{
     
        deep:true,
            handler(val){
     
            // 把search对象变成请求参数,拼接在url路径
            window.location.href = "http://www.leyou.com/search.html?" + ly.stringify(val);
        }
    }
},

刷新页面测试,然后就出现重大bug:页面无限刷新!为什么?

因为Vue实例初始化的钩子函数中,我们读取请求参数,赋值给search的时候,也触发了watch监视!也就是说,每次页面创建完成,都会触发watch,然后就会去修改window.location路径,然后页面被刷新,再次触发created钩子,又触发watch,周而复始,无限循环。

所以,我们需要在watch中进行监控,如果发现是第一次初始化,则不继续向下执行。

那么问题是,如何判断是不是第一次?

第一次初始化时,search中的key值肯定是空的,所以,我们这么做:

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);
        }
    }
}

再次刷新,OK了!

3.3.页面顶部分页条

在页面商品列表的顶部,也有一个分页条:

【 搜索服务 】_第51张图片

我们把这一部分,也加上点击事件:

【 搜索服务 】_第52张图片

4.排序(作业)

4.1.页面搜索排序条件

在搜索商品列表的顶部,有这么一部分内容:

在这里插入图片描述
这是用来做排序的,默认按照综合排序。点击新品,应该按照商品创建时间排序,点击价格应该按照价格排序。因为我们没有统计销量和评价,这里咱们以新品价格为例,进行讲解,做法是相通的。

排序需要知道两个内容:

  • 排序的字段

  • 排序的方式

    因此,我们首先在search中记录这两个信息,因为created钩子函数会对search进行覆盖,因此我们在钩子函数中对这两个信息进行初始化即可:

【 搜索服务 】_第53张图片

然后,在页面上给按钮绑定点击事件,修改sortBydescending的值:



可以看到,页面请求参数中已经有了排序字段了:

【 搜索服务 】_第54张图片

4.2.后台添加排序逻辑

接下来,后台需要接收请求参数中的排序信息,然后在搜索中加入排序的逻辑。

现在,我们的请求参数对象SearchRequest中,只有page、key两个字段。需要进行扩展:

【 搜索服务 】_第55张图片

然后在搜索业务逻辑中,添加排序条件:

【 搜索服务 】_第56张图片

注意,因为我们存储在索引库中的的价格是一个数组,因此在按照价格排序时,会进行智能处理:

  • 如果是价格降序,则会把数组中的最大值拿来排序
  • 如果是价格升序,则会把数组中的最小值拿来排序

【 搜索服务 】_第57张图片

你可能感兴趣的:(【 搜索服务 】)