乐优商城项目总结——08搜索功能(Elasticsearch等)

商城搜索功能

平时购物时习惯都是直接搜索要买的商品,可以发现商品很快的就整齐的列到面前。
但是只靠从数据库里查的话不仅数据库的压力大,有人买要减库存,商家上架商品要存商品,用户搜索商品也要搜索很多数据,只靠数据库不崩溃也会很慢。

为了解决这个问题,我们可以使用全文检索技术:Elasticsearch。

Elasticsearch简介

Elasticsearch甚至可以当做数据库使用,不过这里我们把商品数据存进去,实现快速搜索,快速找到商品。
Elasticsearch具备以下特点:

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

Elasticsearch安装到linux我发现就有很多bug,使用的时候有时候还会报错,这是前期解决完bug后有一次又出现了的一个错误,不过好在网上基本都可以找到解决办法。
在这里插入图片描述
在linux安装以后,我们可以在win平台的本机下安装Kibana。

Kibana是一个基于Node.js的Elasticsearch索引库数据统计工具,可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。

而且还提供了操作Elasticsearch索引数据的控制台,并且提供了一定的API提示,非常有利于我们学习Elasticsearch的语法。

安装以后启动就可以正常使用了。

Elasticsearch的字段

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

在这里插入图片描述

1,type

Elasticsearch中支持的数据类型有很多,举例一些常用的:

  • String类型,又分两种:
    • text:可分词,不可参与聚合
    • keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
    • Numerical:数值类型,分两类
    • 基本数据类型:long、interger、short、byte、double、float、half_float
    • 浮点数的高精度类型:scaled_float
      • 需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
    • Date:日期类型
      elasticsearch可以对日期格式化为字符串存储,但是建议存储为毫秒值,存储为long,节省空间。
2,index

index影响字段的索引情况。

  • true:字段会被索引,则可以用来进行搜索。默认值就是true
  • false:字段不会被索引,不能用来搜索

index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。

但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false。

3,store

是否将数据进行额外存储。

在学习lucene和solr时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。

但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。

原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source的属性中。而且我们可以通过过滤_source来选择哪些要显示,哪些不显示。

而如果设置store为true,就会在_source以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,store的默认值就是false。

也就是说,对于额外存储我们啥都不用做也行。

Elasticsearch的使用

首先注意Elasticsearch本身就是分布式的,因此即便你只有一个节点,Elasticsearch默认也会对你的数据进行分片和副本操作,当你向集群添加新数据时,数据也会在新加入的节点中进行平衡。

因为Elasticsearch本身也有自己的语法,不过这里我直接用Spring Data Elasticsearch,这个是Spring提供的套件,引入坐标,在yml中配置就好。

pom

  <!--elasticsearch-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

yml
我这里填写的虚拟机里的Elasticsearch地址

spring:
  application:
    name: search-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.209.128:9300
下面开始使用:

第一步

要使用Elasticsearch做搜索,首先要创建索引,就跟创建一个数据库一样。

创建数据库要准备一个商品(goods)的实体类

@Data
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 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;
    private Long cid1;
    private Long cid2;
    private Long cid3;

    private Date createTime;
    private Set<Long> price;  //是所有sku的价格集合。方便根据价格进行筛选过滤

    @Field(type = FieldType.Keyword, index = false)
    private String skus;  //sku信息的json结构数据
    private Map<String, Object> specs;  //可搜索的规格参数,key是参数名,值是参数值

}

其中的注解解释:
Spring Data通过注解来声明字段的映射属性,有下面的三个注解:

  • @Document 作用在类,标记实体类为文档对象,一般有两个属性
    • indexName:对应索引库名称
    • type:对应在索引库中的类型
    • shards:分片数量,默认5
    • replicas:副本数量,默认1
  • @Id 作用在成员变量,标记一个字段作为id主键
  • @Field 作用在成员变量,标记为文档的字段,并指定字段映射属性:
    • type:字段类型,是是枚举:FieldType
    • index:是否索引,布尔类型,默认是true
    • store:是否存储,布尔类型,默认是false
    • analyzer:分词器名称

第二步
创建索引库:
创建一个测试类比较好,因为就用一次。一会往索引库存数据也在这里。

 @Autowired
    private ElasticsearchTemplate template;
     /**
     * 映射配置(mappings)
     *
     * 字段的数据类型、属性、是否索引、是否存储等特性
     */
 @Test
    public void test() {
        template.createIndex(Goods.class);
        template.putMapping(Goods.class);
    }

第三步
现在索引库也弄好了,就需要向里面塞数据了,数据需要从数据库中查,这一步比较麻烦。

首先创建跟通用mapper差不多的接口,也需要填上实体类,和主键类型。


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

service:
代码比较多,解释一下:

  1. 像BrandClient这种的是调用商品微服务处理,远程调用需要用到fegin,简单的做法就是item微服务那里事先写好api接口,上面写好功能,像下面这样。然后本地的微服务继承这个借口,注解开启fegin,填写item的eureka服务名称(@FeignClient(“item-service”))
 @GetMapping("brand/{id}")
    Brand queryById(@PathVariable("id") Long id);
  1. 要存到索引库的东西有很多,要花点功夫查询的东西一共分为4个步骤,注释有S1,S2,S3,S4实现的步骤。
  2. JsonUtils是一个自己写的工具类。
@Service
public class SearchService {
    /**
     * 要把查询的结果封装成goods对象,因为搜索所需要的就是spu
     *
     * @param spu
     * @return
     */
    @Autowired
    private CategoryClient categoryClient;
    @Autowired
    private BrandClient brandClient;
    @Autowired
    private GoodsClient goodsClient;
    @Autowired
    private SpecClient specClient;
    @Autowired
    private GoodsRepository goodsRepository;
    @Autowired
    private ElasticsearchTemplate template;

    public Goods buildGoods(Spu spu) {
        /**
         * S1,下面到拼接搜索字段的注释那里全是为了拼接搜索字段
         */
//        查询分类
        Long spuId = spu.getId();
        List<Category> categoryList = categoryClient.queryCategoryByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
        if (CollectionUtils.isEmpty(categoryList)) {
            throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
        }
//        获取category的名字
        List<String> names = categoryList.stream().map(Category::getName).collect(Collectors.toList());
//        查询品牌
        Brand brand = brandClient.queryById(spu.getBrandId());
        if (brand == null) {
            throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
        }
//        拼接搜索字段
        String all = spu.getTitle() + StringUtils.join(names, " ") + brand.getName();

        /**
         * S2,查询价格
         */
        List<Sku> skus = goodsClient.querySkuByIds(spuId);
        if (CollectionUtils.isEmpty(skus)) {
            throw new LyException(ExceptionEnum.GOODS_NOT_FOUND);
        }
//        Set<Long> price = skus.stream().map(Sku::getPrice).collect(Collectors.toSet());
        //存储price的集合,这样比上面那个更省性能。
        TreeSet<Long> priceSet = new TreeSet<>();

        /**
         * S3,对skus进行处理
         */
        //设置存储skuf的json结构的集合,用map结果转化sku对象,转化为json之后与对象结构相似(或者重新定义一个对象,存储前台要展示的数据,并把sku对象转化成自己定义的对象)
        List<Map<String, Object>> skuf = new ArrayList<>();
        //从sku中取出要进行展示的字段,并将sku转换成json格式
        for (Sku sku : skus) {
            HashMap<String, Object> map = new HashMap<>();
            map.put("id", sku.getId());
            map.put("title", sku.getTitle());
            //sku中有多个图片,只展示第一张
            map.put("image", StringUtils.substringBefore(sku.getImages(), ","));
            map.put("price", sku.getPrice());
            skuf.add(map);
            priceSet.add(sku.getPrice());
        }

        /**
         *S4
         */
//        查询规格参数
        List<SpecParam> params = specClient.queryParamByList(null, spu.getCid3(), true, null);
        if (CollectionUtils.isEmpty(params)) {
            throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
        }
//        查询商品详情
        SpuDetail spuDetail = goodsClient.queryDetailById(spuId);

        //获取通用规格参数
        Map<Long, String> genericSpec = JsonUtils.toMap(spuDetail.getGenericSpec(), Long.class, String.class);
        //获取特有规格参数
        Map<Long, List<String>> specialSpec = JsonUtils.nativeRead(spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {
        });

        //  规格参数,key是规格参数的名字,value是规格参数的值
        HashMap<String, Object> map = new HashMap<>();
        //对规格进行遍历,并封装spec,其中spec的key是规格参数的名称,值是商品详情中的值
        for (SpecParam param : params) {
            //key是规格参数的名称
            String key = param.getName();
            Object value = "";

            if (param.getGeneric()) {
                //参数是通用属性,通过规格参数的ID从商品详情存储的规格参数中查出值
                value = genericSpec.get(param.getId());
//                判断是否是数值类型
                if (param.getNumeric()) {
                    //参数是数值类型,处理成段,方便后期对数值类型进行范围过滤
                    value = chooseSegment(value.toString(), param);
                }
            } else {
                //参数不是通用类型
                value = specialSpec.get(param.getId());
            }
            value = (value == null ? "其他" : value);
            //存入map
            map.put(key, value);
        }


//        构建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.setId(spu.getId());
        goods.setAll(all);   // S1,搜索字段,包含标题,分类,品牌,规格等
        goods.setPrice(priceSet);  //S2,所有sku的价格集合
        goods.setSkus(JsonUtils.toString(skuf));  //S3,所有sku集合json格式
        goods.setSpecs(null);  //S4,所有可搜索的规格参数,这里填map,我忘了,存索引库也忘了
        goods.setSubtitle(spu.getSubTitle());

        return goods;
    }


    /**
     * 将规格参数为数值型的参数划分为段
     *
     * @param value
     * @param p
     * @return
     */
    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;
    }
}

第四步
在刚才的测试类中存入


   	@Autowired
    private GoodsRepository goodsRepository;
    @Autowired
    private GoodsClient goodsClient;
    @Autowired
    private SearchService searchService;

 @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());
//        存入索引库
            goodsRepository.saveAll(goodsList);
//            翻页
            page++;
            size = spuList.size();
        } while (size == 100);
    }

第四步
最后一步,实现搜索功能的代码,也是一顿操作猛如虎
我的搜索过滤功能没有实现,报的错也搜不到解决办法,现在好像知道了,上面goods.setSpecs(null); 里面应该是map,结果没有传进去。忙了好久没解决就放弃了,现在总结的时候才发现,希望自己以后写代码认真点。。。

 /**
     * 下面开始是搜索的业务功能
     *
     * @param request
     * @return
     */
    public PageResult<Goods> search(SearchRequest request) {
//        因为es的page从0开始,永远有一页显示不出来,所以-1
//        page=第几页       size=每页显示多少条数据(已经在类中固定,不能改了)
        int page = request.getPage() - 1;
        int size = request.getSize();
//      创建查询构造器,可以分页和过滤
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
//        结果过滤,里面第一个参数是包含什么,这里用数组,第二个参数是不包含什么,这里是mull
        queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subtitle", "skus"}, null));
//        分页
        queryBuilder.withPageable(PageRequest.of(page, size));
//        过滤
        queryBuilder.withQuery(QueryBuilders.matchQuery("all", request.getKey()));
//        聚合分类和品牌(二次功能实现添加)
        String categoryAggName = "category_agg";
        queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3")); //根据cid进行聚合
//        聚合品牌
        String brandAggName = "brand_agg";
        queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId"));
//        查询(二次查询的结果不是分页结果,而是聚合结果,不能再用普通查询,需要template查询)
//        Page<Goods> search = goodsRepository.search(queryBuilder.build());
        AggregatedPage<Goods> search = template.queryForPage(queryBuilder.build(), Goods.class);
//        解析结果
//        解析分页结果
        long totalElements = search.getTotalElements();//总条数
        int totalPages = search.getTotalPages();//总页数
        List<Goods> goodsList = search.getContent(); //当前页面数据
//        解析聚合结果
        Aggregations aggs = search.getAggregations();
        List<Category> categories = parseCategoryAgg(aggs.get(categoryAggName));
        List<Brand> brands = parsebrandAgg(aggs.get(brandAggName));
        return new SearchResult<>(totalElements, totalPages, goodsList, categories, brands);
    }

    private List<Brand> parsebrandAgg(LongTerms terms) {
        try {
            List<Long> ids = terms.getBuckets().stream().map(b -> b.getKeyAsNumber().longValue()).collect(Collectors.toList());
            List<Brand> brands = brandClient.queryBrandByIds(ids);
            return brands;
        } catch (Exception e) {
            return null;
        }
    }

    private List<Category> parseCategoryAgg(LongTerms terms) {
        try {
            List<Long> ids = terms.getBuckets().stream().map(b -> b.getKeyAsNumber().longValue()).collect(Collectors.toList());
            List<Category> categories = categoryClient.queryCategoryByIds(ids);
            return categories;
        } catch (Exception e) {
            return null;
        }
    }

总结

到这里搜索功能结束了,有很多知识需要巩固,想着存个数据而已,其实要从很多表里面查,存数据然后再取出数据(查询的时候),都有要注意的点,代码的一个小失误没注意导致错误也找不到,这一点应该注意。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值