乐优商城--服务(五) : 搜索微服务(LySearchApplication)

本文介绍了乐优商城的搜索微服务实现,主要涉及Elasticsearch的使用,包括Elasticsearch的特点、Kibana的介绍、操作索引库的步骤,以及如何测试查询和聚合功能。文章还详细讲述了搭建项目的过程,商品微服务提供接口的设计,索引库数据格式的分析,以及商品分类和品牌查询接口的实现。此外,还讨论了数据导入、基本搜索功能、结果过滤以及如何优化搜索服务。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. 引言

1.1 Elasticsearch

Elasticsearch:全文检索技术
在这里插入图片描述
如上所述,Elasticsearch具备以下特点:

  • 分布式,无需人工搭建集群(solr就需要人为配置,使用Zookeeper作为注册中心)
  • Restful风格,一切API都遵循Rest原则,容易上手
  • 近实时搜索,数据更新在Elasticsearch中几乎是完全同步的。

1.2 kibana

在这里插入图片描述
Kibana是一个基于Node.js的Elasticsearch索引库数据统计工具(发请求),可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。

而且还提供了操作Elasticsearch索引数据的控制台,并且提供了一定的API提示

1.3 操作索引

Elasticsearch也是基于Lucene的全文检索库,本质也是存储数据,很多概念与MySQL类似的。

对比关系:
索引(indices)--------------------------------Databases 数据库
类型(type)-----------------------------Table 数据表

     索引(indices)--------------------------------Databases 数据库
     类型(type)-----------------------------Table 数据表
     文档(Document)----------------Row 行
	 字段(Field)-------------------Columns 列 

Elasticsearch采用Rest风格API(http请求接口),因此其API就是一次http请求,可以用任何工具发起http请求

索引的请求格式:

  • 请求方式:PUT(创建,修改合二为一)/ GET(查看)/ DELETE(删除)/ POST(可以向一个已经存在的索引库中添加数据)
  • 请求路径:/索引库名
  • 请求参数:json格式:
     {
          "settings": {
              "number_of_shards": 3,
              "number_of_replicas": 2
            }
      }
  • settings:索引库的设置
  • number_of_shards:分片数量
  • number_of_replicas:副本数量

1.4 测试

1.4.1 查询

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {
	public void testQuery{
		// 1 创建查询构建器(spring提供的)
		NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
		// 2 结果过滤
		queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));
		// 3 添加查询条件
		queryBuilder.withQuery(QueryBuilders.matchQuery(name:"title",text:"小米手机"));
		// 4 排序
		queryBuilder.withSort(SortBuilders.fieldSort("price"").order(SortOrder.DESC));
		// 5 分页
		queryBuilder.withPageable(PageRequest.of(page,size));
		// 6 查询
		Page<Goods> result = repository.search(queryBuilder.build());
		long total = result.getTotalElements();
		........
	}
}

采用类的字节码信息创建索引并映射:
Spring Data通过注解来声明字段的映射属性,有下面的三个注解:

  • @Document 作用在类(Goods),标记实体类为文档对象,一般有两个属性
    • indexName:对应索引库名称
    • type:对应在索引库中的类型
    • shards:分片数量,默认5
    • replicas:副本数量,默认1
  • @Id 作用在成员变量,标记一个字段作为id主键
  • @Field 作用在成员变量,标记为文档的字段,并指定字段映射属性:
    • type:字段类型,是是枚举:FieldType
    • index:是否索引,布尔类型,默认是true
    • store:是否存储,布尔类型,默认是false
    • analyzer:分词器名称
  • 增删改不用ElasticsearchTemplate,ElasticsearchTemplate一般会用来做原生的复杂查询,比如聚合,我们一般的普通增删改查用不到,而spring给我们提供了ElasticsearchRepository( Spring Data 的强大之处,就在于你不用写任何DAO处理,自动根据方法名或类的信息进行CRUD操作。只要你定义一个接口,然后继承Repository提供的一些子接口,就能具备各种基本的CRUD功能。)
  • 因此我们应该写个GoodsRepository 接口继承ElasticsearchRepository,第一个泛型是实体类,第二个是id类型,接下来就可以直接用了
  • Spring Data 的另一个强大功能,是根据方法名称自动实现功能。
    比如:你的方法名叫做:findByTitle,那么它就知道你是根据title查询,然后自动帮你完成,无需写实现类。当然,方法名称要符合一定的约定
public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}

QueryBuilder(spring提供的)可整合Elasticsearch原生的结果过滤、查询、排序、分页等,还有结果过滤,整合完之后利用spring data做一个搜索,它会帮我们封装成一个结果

1.4.2 聚合

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {
	public void testAgg{
		// 1 创建查询构建器(spring提供的)
		NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
		String aggName = "popularBrand";
		// 2 聚合
		queryBuilder.addAggregation(AggregationBuilders.terms(CategoryAggName).field("brand"));
		// 3 查询并返回带聚合结果
	    AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);
		// 4 解析聚合
		Aggregations aggs = result.getAggregations();
		// 5 获取指定名称的聚合
		StringTerms terms  = aggs.getName(aggName);
		// 6 获取桶
		List<StringTerms.Bucket> buckets = terms.getBuckets();
		for(StringTerms.Bucket bucket:buckets){
			bucket.getKeyAsString();
			...
		}
		........
	}
}

2. 搭建项目

用户访问我们的首页,一般都会直接搜索来寻找自己想要购买的商品。
而商品的数量非常多,而且分类繁杂。如果能正确的显示出用户想要的商品,并进行合理的过滤,尽快促成交易,是搜索系统要研究的核心。

面对这样复杂的搜索业务和数据量,一般我们都会使用全文检索技术: Elasticsearch。

2.1 引入依赖

 <?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>leyou</artifactId>
        <groupId>com.leyou.parent</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

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

    <dependencies>
        <!--eureka-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!--web-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--elasticsearch-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
        <!--feign 服务间调用-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <!--springboot启动器的测试功能-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!--商品实体类的接口-->
        <dependency>
            <groupId>com.leyou.service</groupId>
            <artifactId>ly-item-interface</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2.2 配置

server:
  port: 8083
spring:
  application:
    name: search-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.184.130:9300
  jackson:
    default-property-inclusion: non_null  #排除返回结构中字段值为null的属性
  rabbitmq:
    host: 192.168.184.130
    username: leyou
    password: leyou
    virtual-host: /leyou
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
      registry-fetch-interval-seconds: 10
  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

2.3 启动类

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableDiscoveryClient
@EnableFeignClients
public class LySearchApplication {

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

3. 索引库数据格式分析

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

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

3.1 以结果为导向

我们来看下搜索结果页:

在这里插入图片描述

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

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

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

3.2 需要什么数据

由上图可以直观能看到的:图片、价格、标题、副标题(属于SKU数据,用来展示的);暗藏的数据:spu的id,sku的id

另外,页面还有过滤条件:
在这里插入图片描述
这些过滤条件也都需要存储到索引库中,包括:商品分类、品牌、可用来搜索的规格参数等

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

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

3.3 最终的数据结构

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

@Data
@Document(indexName = "goods", type = "docs", shards = 1)
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 Set<Long> price;// 价格,对应到elasticsearch/json中是数组,一个spu有多个sku,就有多个价格

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

4. 商品微服务提供接口

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

先思考我们需要的数据:

  • SPU信息
  • SKU信息
  • SPU的详情
  • 商品分类名称(拼接all字段)
  • 规格参数
  • 品牌

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

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

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

4.1 商品分类名称查询

4.1.1 web

@RestController
@RequestMapping("category")
public class CategoryController {
	// 根据商品分类cid列表查询分类集合
    @GetMapping("list/ids")
    public ResponseEntity<List<Category>> queryCategoryByIds(@RequestParam("ids")List<Long> ids){
        return ResponseEntity.ok(categoryService.queryByIds(ids));
    }
}

4.1.2 service

service之前写过该方法~

@Service
public class CategoryService {
    // 根据商品分类cid列表查询分类集合
    public List<Category> queryByIds(List<Long> cids){
       List<Category> idList = categoryMapper.selectByIdList(cids);
        if(CollectionUtils.isEmpty(idList)){
            throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
        }
        return idList;
    }
}

4.2 商品品牌名称查询

4.2.1 web

@RestController
@RequestMapping("brand")
public class BrandController {
    // 根据品牌brandid查询品牌名称
    @GetMapping("{id}")
    public ResponseEntity<Brand> queryBrandById(@PathVariable("id")Long id){
        return ResponseEntity.ok(brandService.queryById(id));
    }
} 

4.2.2 service

@Service
public class BrandService {
	// 根据品牌brandid查询品牌名称
    public Brand queryById(Long id){
        Brand brand = brandMapper.selectByPrimaryKey(id);
        if(brand == null){
            throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
        }
        return brand;
    }
}

5. 调用接口

第一步:服务的提供方在ly-item-interface中提供API接口,并编写接口声明:
在这里插入图片描述
品牌服务接口:

public interface BrandApi {
    // 根据品牌id查询品牌
    @GetMapping("brand/{id}")
    Brand queryBrandById(@PathVariable("id")Long id);

    // 根据bid的集合查询品牌信息
    @GetMapping("brand/list")
    List<Brand> queryBrandsByIds(@RequestParam("ids") List<Long> ids);
}

商品分类服务接口:

public interface CategoryApi {
	//根据sku的id集合查询所有的sku
    @GetMapping("category/list/ids")
    List<Category> queryCategoryByIds(@RequestParam("ids") List<Long> ids);
}

商品服务接口:

public interface GoodsApi {

    //根据spu的id查询详情detail
    @GetMapping("/spu/detail/{id}")
    SpuDetail querySpuDetailById(@PathVariable("id")Long id);

    //根据spu查询下面所有的sku
    @GetMapping("/sku/list")
    List<Sku> querySkuBySpuId(@RequestParam("id") Long spuId);

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

    // 根据spu的id查询spu
    @GetMapping("spu/{id}")
    Spu querySpuById(@PathVariable("id") Long spuId);

	//根据sku的id集合查询所有的sku
    @GetMapping("/sku/list/ids")
    List<Sku> querySkuByIds(@RequestParam("ids") List<Long> ids);

    // 减库存
    @PostMapping("stock/decrease")
    void decreaseStock(@RequestBody List<CartDTO> cartDTOS);
}

规格服务接口:

public interface SpecificationApi {

    // 查询规格参数集合
    @GetMapping("spec/params")
    List<SpecParam> querySpecParams(@RequestParam(value = "gid",required = false)Long gid,
                                    @RequestParam(value = "cid",required = false)Long cid,
                                    @RequestParam(value = "searching",required = false)Boolean searching);

    //根据cid查询规格组及其规格参数
    @GetMapping("spec/group")
    List<SpecGroup> queryGroupByCid(@RequestParam("cid") Long cid);
}

有的方法我们现在还没有写或者我们暂时用不到,但以后会用到,因此这里一并给出。

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

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>ly-common</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

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

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

品牌的FeignClient:

@FeignClient("item-service")
public interface BrandClient extends BrandApi{
}

商品分类的FeignClient:

@FeignClient("item-service")
public interface BrandClient extends BrandApi{
}

商品的FeignClient:

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

规格的FeignClient:

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

6. 导入数据

6.1 创建GoodsRepository

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

6.2 创建索引

我们新建一个测试类,在里面进行数据的操作:(创建索引只需一次就好,因此没有写到正式代码里,而是放在测试类里运行一下就好)

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

    @Autowired
    private GoodsRepository goodsRepository;

    @Autowired
    private ElasticsearchTemplate template;

    @Test
    public void testCreateIndex(){
        // 创建索引库 会根据Item类的@Document注解信息来创建
        template.createIndex(Goods.class);
        // 创建映射关系 会根据Item类中的id、Field等字段来自动完成映射
        template.putMapping(Goods.class);
    }
}

6.3 导入数据

导入数据其实就是查询数据库中的数据,然后把查询到的信息封装成Goods类型的对象放到索引库里,因此我们先编写一个SearchService ,然后在里面定义一个buildGoods方法, 把Spu封装为Goods

@Slf4j
@Service
public class SearchService {
    // 把spu封装为Goods
    public Goods buildGoods(Spu spu){
        // 构建goods对象
        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());

        // all --- 搜索字段:标题、分类、品牌、规格
        // 标题 spu.getTitle()

        // 查询分类
        List<String> names = categoryClient.queryCategoryByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()))
                .stream()
                .map(Category::getName)
                .collect(Collectors.toList());
        if(CollectionUtils.isEmpty(names)){
            throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
        }
        // 查询品牌
        Brand brand = brandClient.queryBrandById(spu.getBrandId());
        if(brand == null){
            throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
        }
        // all
        String all = spu.getTitle() + StringUtils.join(names," ") + brand.getName();


        // sku --- 所有sku的集合的json格式
        List<Sku> skuList = goodsClient.querySkuBySpuId(spu.getId());
        if(CollectionUtils.isEmpty(skuList)){
            throw new LyException(ExceptionEnum.GOODS_SKU_NOT_FOUND);
        }
        // 搜索字段只需要部分数据(id,title,price,image) 所以要对sku进行处理
        ArrayList<Map<String,Object>> skus = new ArrayList<>();
        // price
        Set<Long> priceList = new HashSet<>();
        for (Sku sku : skuList) {
            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(),","));//sku中有多个图片,只展示第一张

            skus.add(map);

            //处理价格
            priceList.add(sku.getPrice());

        }

        // 查询规格参数  结果是一个map
        // 规格参数表
        List<SpecParam> params = specificationClient.querySpecParams(null, spu.getCid3(), true);
        if(CollectionUtils.isEmpty(params)){
            throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
        }
        // 规格详情表
        SpuDetail spuDetail = goodsClient.querySpuDetailById(spu.getId());
        // 获取通用规格参数
        Map<Long, String> genericSpec = JsonUtils.parseMap(spuDetail.getGenericSpec(), Long.class, String.class);
        //获取特有规格参数
        Map<Long, List<String>> specialSpec = JsonUtils.nativeRead(
                spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {});

        //将参数填入map
        Map<String,Object> specs = new HashMap<>();
        for (SpecParam param : params) {
            // 规格名字 key
            String key = param.getName();
            Object value = "";

            //规格参数 value
            if(param.getGeneric()){
                // 通用属性
                value = genericSpec.get(param.getId());// 通用参数的数值类型有分段的情况存在,要做一个处理,不能按上面那种方法获得value
                //判断是否为数值类型 处理成段,覆盖之前的value
                if(param.getNumeric()){
                    value = chooseSegment(value.toString(),param);
                }
            }else {
                // 特殊属性
                value = specialSpec.get(param.getId());
            }
            value = (value == null ? "其他" : value);

            specs.put(key,value);
        }


        goods.setAll(all); // 搜索字段,包含标题、分类、品牌、规格
        goods.setSkus(JsonUtils.serialize(skus)); // 所有sku的集合的json格式
        goods.setPrice(priceList); // 所有sku的价格集合
        goods.setSpecs(specs); // 所有可搜索的规格参数

        return goods;
    }
}

因为过滤参数中有一类比较特殊,就是数值区间:
在这里插入图片描述
所以我们在存入时要进行处理:

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,然后调用SearchService中的方法,把SPU变为Goods,然后写入索引库:

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {
	@Test
	public void loadData(){
	    int page = 1;
	    int rows = 100;
	    int size=0;
	    do {
	        //查询spu信息
	        PageResult<Spu> result = goodsClient.querySpuByPage(page, rows, true, null);
	
	        List<Spu> spuList = result.getItems();//当前页
	        if(CollectionUtils.isEmpty(spuList)){
	            break;
	        }
	        //构建成goods
	        List<Goods> goodsList = spuList.stream().map(searchService::buildGoods).collect(Collectors.toList());
	        //存入索引库
	        Iterable<Goods> goods = goodsRepository.saveAll(goodsList);
	        //翻页
	        page++;
	        size=spuList.size();
	    }while(size==100);
	}
}

7. 实现基本搜索

7.1 web

7.1.1 页面分析

在这里插入图片描述

  • 请求方式:Post
  • 请求路径:/search/page,不过前面的/search应该是网关的映射路径,因此真实映射路径page,代表分页查询
  • 请求参数:json格式,目前只有一个属性:key,搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有page属性,因此我们可以用一个对象来接收请求的json数据:
public class SearchRequest {
    private static final Integer DEFAULT_PAGE = 1;
    private static final Integer DEFAULT_SIZE = 20;
    private String key;//搜索条件
    private Integer page;//当前页
    private Integer size=DEFAULT_SIZE;//页面大小

    public void setSize(Integer size) {
        this.size=DEFAULT_SIZE;
    }

    //排序字段
    private String sortBy;
    //是否降序
    private Boolean descending;

    //过滤字段
    private Map<String, String> filter;

    public String getSortBy() {
        return sortBy;
    }

    public void setSortBy(String sortBy) {
        this.sortBy = sortBy;
    }

    public Boolean getDescending() {
        return descending;
    }

    public void setDescending(Boolean descending) {
        this.descending = descending;
    }

    public String getKey() {
        return key;
    }

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

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

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

    public Integer getSize() {
        return size;
    }

    public Map<String, String> getFilter() {
        return filter;
    }

    public void setFilter(Map<String, String> filter) {
        this.filter = filter;
    }
}
  • 返回结果:作为分页结果,一般都两个属性:当前页数据、总条数信息,我们可以使用之前定义的PageResult类

: 由于前台门户系统采用www.leyou.com进行访问,因此应在GlobalCorsConfig配置config.addAllowedOrigin("http://www.leyou.com");
在这里插入图片描述

7.1.2 实现业务

@RestController
public class SearchController {
    // 搜索功能
    @PostMapping("page")
    public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request){
        return ResponseEntity.ok(searchService.search(request));
    }
}

7.2 service

@Slf4j
@Service
public class SearchService {
    // 搜索功能
    public SearchResult search(SearchRequest request) {
        int page = request.getPage() - 1;// page,elasticSearch默认从0开始,要进行减一操作否则一直查询不到第一页
        int size = request.getSize();

        // 创建查询构建器(spring提供的)
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        // 0 结果过滤
		queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));
        // 1 分页
        queryBuilder.withPageable(PageRequest.of(page,size));
        // 2 过滤
        QueryBuilder.withQuery(QueryBuilders.matchQuery(name:"all"),request.getKey());
        // 3 查询
        Page<Goods> result = repository.search(queryBuilder.build(), Goods.class);

        // 4 解析结果
        long total = result.getTotalElements();
        long totalPage = result.getTotalPages(); //int totalPage = ((int) total + size -1)/size;
		List<Goods> goodsList = result.getContent();
		
        return new SearchResult(total, totalPage, goodsList);
    }
}

7.3 测试

在这里插入图片描述
数据是查到了,但是因为我们只查询部分字段,所以结果json 数据中有很多null,解决办法很简单,在application.yml中添加一行配置,json处理时忽略空值即可。

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

:所有页面渲染操作全在前端,这里就不写啦

8. 结果过滤

8.1 过滤功能分析

首先看下页面要实现的效果:
在这里插入图片描述
整个过滤部分有3块:

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

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

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

所以,这部分需要依赖第二部分:过滤条件的展示和选择。因此我们先不着急去做。

展开或收起的按钮是否显示,取决于过滤条件现在有多少,如果有很多,那么就没必要展示。所以也是跟第二部分的过滤条件有关。

这样分析来看,我们必须先做第二部分:过滤条件展示。

8.2 生成分类和品牌过滤

先来看分类和品牌。在我们的数据库中已经有所有的分类和品牌信息。在这个位置,是不是把所有的分类和品牌信息都展示出来呢?

显然不是,用户搜索的条件会对商品进行过滤,而在搜索结果中,不一定包含所有的分类和品牌,直接展示出所有商品分类,让用户选择显然是不合适的。

无论是分类信息,还是品牌信息,都应该从搜索的结果商品中进行聚合得到。

8.2.1 扩展返回的结果

原来,我们返回的结果是PageResult对象,里面只有total、totalPage、items3个属性。但是现在要对商品分类和品牌进行聚合,数据显然不够用,我们需要对返回的结果进行扩展,添加分类和品牌的数据。

那么问题来了:以什么格式返回呢?

看页面:
在这里插入图片描述

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

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

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

@Data
public class SearchResult extends PageResult<Goods> {

    private List<Category> categories;// 分类过滤条件
    private List<Brand> brands; // 品牌过滤条件
    private List<Map<String,Object>> specs; // 规格参数过滤条件
    public SearchResult(Long total,
                        Long 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;
    }
}

8.2.2 聚合商品分类和品牌

我们修改搜索的业务逻辑,对分类和品牌聚合。

因为索引库中只有id,所以我们根据id聚合,然后再根据id去查询完整数据。

所以,商品微服务需要提供一个接口:根据品牌id集合,批量查询品牌。(之前已提前谢写过)

	// 搜索功能
    public SearchResult search(SearchRequest request) {

        String key = request.getKey(); // 搜索条件 eg:手机
        if (StringUtils.isBlank(key)) {
            throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
        }

        int page = request.getPage() - 1;// page,elasticSearch默认从0开始,要进行减一操作否则一直查询不到第一页
        int size = request.getSize();

        // 创建查询构建器(spring提供的)
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        // 0 结果过滤
		queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));
        // 1 分页
        queryBuilder.withPageable(PageRequest.of(page,size));
        // 2 过滤
        QueryBuilder.withQuery(QueryBuilders.matchQuery(name:"all"),request.getKey());
        // 3 聚合
        // 3.1 聚合分类
        String CategoryAggName = "categoryAgg";
        queryBuilder.addAggregation(AggregationBuilders.terms(CategoryAggName).field("cid3"))
        // 3.2 聚合品牌
        String BrandAggName = "brandAgg";
        queryBuilder.addAggregation(AggregationBuilders.terms(BrandAggName).field("brandId"));

        // 4 查询
        AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);

        // 5 解析结果
        // 5.1 解析分页结果
        long total = result.getTotalElements();
        long totalPage = result.getTotalPages();
        List<Goods> goodsList = result.getContent();

        // 5.2 解析聚合结果
        Aggregations aggs = result.getAggregations();
        // 分类聚合
        List<Category> categories = parseCategoryAgg(aggs.get(CategoryAggName));
        // 品牌集合
        List<Brand> brands = parseBrandAgg(aggs.get(BrandAggName));
        
        return new SearchResult(total, totalPage, goodsList,categories,brands);
    }

    // 解析商品分类聚合结果
    private List<Category> parseCategoryAgg(LongTerms terms) {

        try {
            List<Long> ids = terms.getBuckets().stream()
                    .map(bucket -> bucket.getKeyAsNumber().longValue())
                    .collect(Collectors.toList());
            List<Category> categories = categoryClient.queryCategoryByIds(ids);

            return categories;
        }catch (Exception e){
            return null;
        }
    }

    // 解析品牌聚合结果
    private List<Brand> parseBrandAgg(LongTerms terms) {
        try {
            List<Long> ids = terms.getBuckets().stream()
                    .map(bucket -> bucket.getKeyAsNumber().longValue())
                    .collect(Collectors.toList());

            List<Brand> brands = brandClient.queryBrandsByIds(ids);
            return brands;
        }catch (Exception e){
            return null;
        }
    }

8.3 生成规格参数过滤

8.3.1 分析

有四个问题需要先思考清楚:

  • 什么时候显示规格参数过滤?
  • 如何知道哪些规格需要过滤?
  • 要过滤的参数,其可选值是如何获取的?
  • 规格过滤的可选值,其数据格式怎样的?

| 什么情况下显示有关规格参数的过滤?

如果用户尚未选择商品分类,或者聚合得到的分类数大于1,那么就没必要进行规格参数的聚合。因为不同分类的商品,其规格是不同的。

因此,我们在后台需要对聚合得到的商品分类数量进行判断,如果等于1,我们才继续进行规格参数的聚合。

| 如何知道哪些规格需要过滤?

我们不能把数据库中的所有规格参数都拿来过滤。因为并不是所有的规格参数都可以用来过滤,参数的值是不确定的。

值的庆幸的是,我们在设计规格参数时,已经标记了某些规格可搜索,某些不可搜索。

因此,一旦商品分类确定,我们就可以根据商品分类查询到其对应的规格,从而知道哪些规格要进行搜索。

| 要过滤的参数,其可选值是如何获取的?

虽然数据库中有所有的规格参数,但是不能把一切数据都用来供用户选择。

与商品分类和品牌一样,应该是从用户搜索得到的结果中聚合,得到与结果品牌的规格参数可选值。

| 规格过滤的可选值,其数据格式怎样的?

我们直接看页面效果:
在这里插入图片描述

我们之前存储时已经将数据分段,恰好符合这里的需求

8.3.2 实现

总结一下,应该是以下几步:

  • 1)用户搜索得到商品,并聚合出商品分类
  • 2)判断分类数量是否等于1,如果是则进行规格参数聚合
  • 3)先根据分类,查找可以用来搜索的规格
  • 4)对规格参数进行聚合
  • 5)将规格参数聚合结果整理后返回
8.3.2.1 扩展返回结果

返回结果中需要增加新数据,用来保存规格参数过滤条件。这里与前面的品牌和分类过滤的json结构类似,因此,在java中我们用List<Map<String,Object>>来表示。

public class SearchResult extends PageResult<Goods>{

    private List<Category> categories;// 分类过滤条件
    private List<Brand> brands; // 品牌过滤条件
    private List<Map<String,String>> specs;
    public SearchResult(Long total, Integer totalPage, List<Goods> items,
                        List<Category> categories, List<Brand> brands,
                        List<Map<String,String>> specs) {
        super(total, totalPage, items);
        this.categories = categories;
        this.brands = brands;
        this.specs = specs;
    }
}
8.3.2.2 完整代码
// 搜索功能
public SearchResult search(SearchRequest request) {

    String key = request.getKey(); // 搜索条件 eg:手机
    if (StringUtils.isBlank(key)) {
        throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
    }

    int page = request.getPage() - 1;// page,elasticSearch默认从0开始,要进行减一操作否则一直查询不到第一页
    int size = request.getSize();

    // 1 创建查询构建器(spring提供的)
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();

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

    // 3 过滤
    // 3.1 结果过滤
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));

    // 3.2 过滤
    QueryBuilder baseQuery = QueryBuilders.matchQuery(name:"all"),request.getKey();
    queryBuilder.withQuery(baseQuery);

    // 4 聚合
    // 4.1 聚合分类
    String CategoryAggName = "categoryAgg";
    queryBuilder.addAggregation(AggregationBuilders.terms(CategoryAggName).field("cid3"));

    // 4.2 聚合品牌
    String BrandAggName = "brandAgg";
    queryBuilder.addAggregation(AggregationBuilders.terms(BrandAggName).field("brandId"));

    // 5 查询
    AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);

    // 6 解析结果
    // 6.1 解析分页结果
    long total = result.getTotalElements();
    long totalPage = result.getTotalPages(); //int totalPage = ((int) total + size -1)/size;

    // 6.2 解析聚合结果
    Aggregations aggs = result.getAggregations();
    // 分类聚合
    List<Category> categories = parseCategoryAgg(aggs.get(CategoryAggName));
    // 品牌集合
    List<Brand> brands = parseBrandAgg(aggs.get(BrandAggName));

    // 规格参数的聚合
    List<Map<String, Object>> specs = null;
    // 商品分类存在且值为1,才可以进行规格参数的聚合
    if(categories != null && categories.size() == 1){
        specs = buildSpecificationAgg(categories.get(0).getId(),baseQuery);
    }

    List<Goods> goodsList = result.getContent();
    return new SearchResult(total, totalPage, goodsList,categories,brands,specs);
}

// 解析商品分类聚合结果
private List<Category> parseCategoryAgg(LongTerms terms) {

    try {
        List<Long> ids = terms.getBuckets().stream()
                .map(bucket -> bucket.getKeyAsNumber().longValue())
                .collect(Collectors.toList());
        List<Category> categories = categoryClient.queryCategoryByIds(ids);

        return categories;
    }catch (Exception e){
        return null;
    }
}

// 解析品牌聚合结果
private List<Brand> parseBrandAgg(LongTerms terms) {
    try {
        List<Long> ids = terms.getBuckets().stream()
                .map(bucket -> bucket.getKeyAsNumber().longValue())
                .collect(Collectors.toList());

        List<Brand> brands = brandClient.queryBrandsByIds(ids);
        return brands;
    }catch (Exception e){
        return null;
    }
}

// 聚合规格参数
private List<Map<String,Object>> buildSpecificationAgg(Long cid, QueryBuilder baseQuery) {

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

    // 查询需要聚合的规格参数
    List<SpecParam> params = specificationClient.querySpecParams(null, cid, true);
    // 聚合
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
    // 1.1 带上基础查询条件
    queryBuilder.withQuery(baseQuery);
    // 1.2 遍历params 聚合名字 字段
    for (SpecParam param : params) {
        String name = param.getName();//规格参数的名字的不会重复 作为聚合的name
        queryBuilder.addAggregation(
                AggregationBuilders.terms(name).field("specs." + name + ".keyword"));
    }

    // 获取结果
    AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);

    // 解析结果
    Aggregations aggs = result.getAggregations();

    // 有几个param就要做几个聚合
    for (SpecParam param : params) {
        // 规格参数名称
        String name = param.getName();
        Terms terms = aggs.get(name);
        // 待选项
        List<Object> options = terms.getBuckets().stream()
                .map(b -> b.getKeyAsString()).collect(Collectors.toList());
        // 准备map
        Map<String, Object> map = new HashMap<>();
        map.put("k",name);
        map.put("options",options);

        specs.add(map);
    }
    return specs;
}

8.4 过滤条件的筛选

当我们点击页面的过滤项,要做哪些事情?

  • 把过滤条件保存在search对象中(watch监控到search变化后就会发送到后台)
  • 在页面顶部展示已选择的过滤项
  • 把商品分类展示到顶部面包屑

8.4.1 拓展请求对象

我们需要在请求类:SearchRequest中添加属性,接收过滤属性。过滤属性都是键值对格式,但是key不确定,所以用一个map来接收即可。
在这里插入图片描述

8.4.2 添加过滤条件

目前,我们的基本查询是这样的:在这里插入图片描述
现在,我们要把页面传递的过滤条件也进入进去。

因此不能在使用普通的查询(搜索条件与过滤条件不能放在一块),而是要用到BooleanQuery,基本结构是这样的:

GET /goods/_search
{
    "query":{
        "bool":{
        	"must":{ "match": { "title": "小米手机",operator:"and"}},
        	"filter":{
                "range":{"price":{"gt":2000.00,"lt":3800.00}}
        	}
        }
    }
}

所以,我们对原来的基本查询进行改造:
在这里插入图片描述
因为比较复杂,我们将其封装到一个方法中:

// 构建基本查询条件
private QueryBuilder buildBaseQuery(SearchRequest request) {

    // 创建布尔查询
    BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery();

    // 查询条件
    queryBuilder.must(QueryBuilders.matchQuery("all", request.getKey()));
    // 过滤条件 (有n个过滤条件因此要遍历map)
    Map<String, String> map = request.getFilter();
    for (Map.Entry<String, String> entry : map.entrySet()) {
        String key = entry.getKey();
        // 处理key
        if(!"cid3".equals(key) && !"brandId".equals(key)){
            key = "specs." + key + ".keyword";
        }
        String value = entry.getValue();
        queryBuilder.filter(QueryBuilders.termQuery(key,value));
    }

    return queryBuilder;
}

其它不变。

9. 优化

优化搜索微服务

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值