第4章 检索模块.md 83 KB

谷粒随享

第4章 专辑检索

学习目标:

  • 专辑索引库设计(字段、字段类型(Nested嵌套类型))
  • 专辑数据到索引库
    • 全量导入
    • 增量导入
  • 多条件专辑数据检索
  • 不同分类下热门的专辑列表
  • 搜索词自动补全

1、检索业务需求

根据用户输入的检索条件,查询出对用的商品

  • 业务数据:包含专辑ID、专辑名称、专辑封面、主播名称、专辑声音数量、付费类型、分类ID、统计信息、专辑标签等
  • 过滤条件:关键字、分类、专辑标签等
  • 排序方式:热门、时间、播放量

确定索引库中字段类型:

  • 字符串类型
    • keyword-不会进行分词,适用于精确等值查询字段,聚合,排序。不支持全文查询
    • text-会进行分词,适用于长本文用来进行全文检索字段。不能进行排序,聚合 ,例如:名称,简介
  • 嵌套类型(Nested修饰对象(List)

1.1 封装实体类

nested:类型是一种特殊的对象object数据类型(specialised version of the object datatype ),允许对象数组彼此独地进行索引查询

demo: 建立一个普通的index

如果linux 中有这个my_comment_index 先删除!DELETE /my_comment_index

步骤1:建立一个索引( 存储博客文章及其所有评论)

PUT my_comment_index/_doc/1
{
  "title": "狂人日记",
  "body": "《狂人日记》是一篇象征性和寓意很强的小说,当时,鲁迅对中国国民精神的麻木愚昧颇感痛切。",
  "comments": [
    {
      "name": "张三",
      "age": 34,
      "rating": 8,
      "comment": "非常棒的文章",
      "commented_on": "30 Nov 2023"
    },
    {
      "name": "李四",
      "age": 38,
      "rating": 9,
      "comment": "文章非常好",
      "commented_on": "25 Nov 2022"
    },
    {
      "name": "王五",
      "age": 33,
      "rating": 7,
      "comment": "手动点赞",
      "commented_on": "20 Nov 2021"
    }
  ]
}

如上所示,所以我们有一个文档描述了一个帖子和一个包含帖子上所有评论的内部对象评论。 但是Elasticsearch搜索中的内部对象并不像我们期望的那样工作。

步骤2 : 执行查询

GET /my_comment_index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "comments.name": "李四"
          }
        },
        {
          "match": {
            "comments.age": 34
          }
        }
      ]
    }
  }
}

查询结果:居然正常的响应结果了

image-20221205232357129

原因分析:comments字段默认的数据类型是Object,故我们的文档内部存储为:

{ "title": [ 狂人日记], "body": [ 《狂人日记》是一篇象征性和寓意很强的小说,当时... ], "comments.name": [ 张三, 李四, 王五 ], "comments.comment": [ 非常棒的文章,文章非常好,王五,... ], "comments.age": [ 33, 34, 38 ], "comments.rating": [ 7, 8, 9 ] }

我们可以清楚地看到,comments.name和comments.age之间的关系已丢失。这就是为什么我们的文档匹配李四和34的查询。

步骤3:删除当前索引

DELETE /my_comment_index

步骤4:建立一个nested 类型的(comments字段映射为nested类型,而不是默认的object类型)

PUT my_comment_index
{
  "mappings": {
      "properties": {
        "comments": {
          "type": "nested" 
        }
    }
  }
}


PUT my_comment_index/_doc/1
{
  "title": "狂人日记",
  "body": "《狂人日记》是一篇象征性和寓意很强的小说,当时,鲁迅对中国国民精神的麻木愚昧颇感痛切。",
  "comments": [
    {
      "name": "张三",
      "age": 34,
      "rating": 8,
      "comment": "非常棒的文章",
      "commented_on": "30 Nov 2023"
    },
    {
      "name": "李四",
      "age": 38,
      "rating": 9,
      "comment": "文章非常好",
      "commented_on": "25 Nov 2022"
    },
    {
      "name": "王五",
      "age": 33,
      "rating": 7,
      "comment": "手动点赞",
      "commented_on": "20 Nov 2021"
    }
  ]
}

重新执行步骤1,使用nested 查询

GET /my_comment_index/_search
{
  "query": {
    "nested": {
      "path": "comments",
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "comments.name": "李四"
              }
            },
            {
              "match": {
                "comments.age": 34
              }
            }
          ]
        }
      }
    }
  }
}

结果发现没有返回任何的文档,这是何故?

当将字段设置为nested 嵌套对象将数组中的每个对象索引为单独的隐藏文档,这意味着可以独立于其他对象查询每个嵌套对象。文档的内部表示:

{ { "comments.name": [ 张三], "comments.comment": [ 非常棒的文章 ], "comments.age": [ 34 ], "comments.rating": [ 9 ] }, { "comments.name": [ 李四], "comments.comment": [ 文章非常好 ], "comments.age": [ 38 ], "comments.rating": [ 8 ] }, { "comments.name": [ 王五], "comments.comment": [手动点赞], "comments.age": [ 33 ], "comments.rating": [ 7 ] }, { "title": [ 狂人日记 ], "body": [ 《狂人日记》是一篇象征性和寓意很强的小说,当时,鲁迅对中国... ] } }

每个内部对象都在内部存储为单独的隐藏文档。 这保持了他们的领域之间的关系。

商品上架,下架信息存在不同的数据库表中,所以我们将商品的上架-下架的字段统一封装到实体类中。

@Data
@Document(indexName = "albuminfo")
@JsonIgnoreProperties(ignoreUnknown = true)//目的:防止json字符串转成实体对象时因未识别字段报错
public class AlbumInfoIndex implements Serializable {

    private static final long serialVersionUID = 1L;

    // 专辑Id
    @Id
    private Long id;

    //  es 中能分词的字段,这个字段数据类型必须是 text!keyword 不分词! analyzer = "ik_max_word"
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String albumTitle;

    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String albumIntro;
    
    //  主播名称
    @Field(type = FieldType.Keyword)
    private String announcerName;

    //专辑封面
    @Field(type = FieldType.Keyword, index = false)
    private String coverUrl;

    //专辑包含声音总数
    @Field(type = FieldType.Long, index = false)
    private Integer includeTrackCount;

    //专辑是否完结:0-否;1-完结
    @Field(type = FieldType.Long, index = false)
    private String isFinished;

    //付费类型:免费、vip免费、付费
    @Field(type = FieldType.Keyword, index = false)
    private String payType;

    @Field(type = FieldType.Date,format = DateFormat.date_time, pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime; //

    @Field(type = FieldType.Long)
    private Long category1Id;

    @Field(type = FieldType.Long)
    private Long category2Id;

    @Field(type = FieldType.Long)
    private Long category3Id;

    //播放量
    @Field(type = FieldType.Integer)
    private Integer playStatNum = 0;

    //订阅量
    @Field(type = FieldType.Integer)
    private Integer subscribeStatNum = 0;

    //购买量
    @Field(type = FieldType.Integer)
    private Integer buyStatNum = 0;

    //评论数
    @Field(type = FieldType.Integer)
    private Integer commentStatNum = 0;

    //商品的热度!
    @Field(type = FieldType.Double)
    private Double hotScore = 0d;

    // 专辑属性值
    // Nested 支持嵌套查询
    @Field(type = FieldType.Nested)
    private List<AttributeValueIndex> attributeValueIndexList;

}

1.2 创建索引库

service-search模块中提供操作索引库持久层接口

package com.atguigu.tingshu.search.repository;

import com.atguigu.tingshu.model.search.AlbumInfoIndex;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface AlbumInfoIndexRepository extends ElasticsearchRepository<AlbumInfoIndex, Long> {
    //文档基本CRUD操作
}

启动ServiceSearchApplication当扫描到持久层接口会自动创建索引库

image-20231011095104003

2、专辑上下架

2.1 功能实现分析

专辑微服务中提供Feign接口:

  1. 获取专辑信息(包含属性列表)(有)但是需要提供feign接口
  2. 获取分类信息(无)
  3. 获取专辑统计信息(无)
    1. 数据库中统计信息所有文档通知值都为0,搜索结果要求按照热度,各种排行搜索。这里采用新增索引库文档手动生成随机值

用户微服务中提供Feign接口

  1. 获取用户信息(无)

2.1.1 查询专辑信息

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/23

service-album 中的AlbumInfoApiController控制器已有实现,只需要在service-album-client模块AlbumInfoFeignClien提供Feign远程调用方法即可。

image-20231024112923393

package com.atguigu.tingshu.album;

import com.atguigu.tingshu.album.impl.AlbumDegradeFeignClient;
import com.atguigu.tingshu.common.result.Result;
import com.atguigu.tingshu.model.album.AlbumInfo;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

/**
 * <p>
 * 专辑模块远程调用Feign接口
 * </p>
 *
 * @author atguigu
 */
@FeignClient(value = "service-album", path = "/api/album", fallback = AlbumDegradeFeignClient.class)
public interface AlbumFeignClient {


    /***
     * 根据专辑ID查询专辑信息包含专辑标签列表
     * @param id 专辑ID
     * @return
     */
    @GetMapping("/albumInfo/getAlbumInfo/{id}")
    public Result<AlbumInfo> getAlbumInfo(@PathVariable Long id);
    
}

AlbumDegradeFeignClient服务降级类

package com.atguigu.tingshu.album.impl;


import com.atguigu.tingshu.album.AlbumFeignClient;
import com.atguigu.tingshu.common.result.Result;
import com.atguigu.tingshu.model.album.AlbumInfo;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class AlbumDegradeFeignClient implements AlbumFeignClient {


    @Override
    public Result<AlbumInfo> getAlbumInfo(Long id) {
        log.error("[专辑模块]提供远程调用getAlbumInfo服务降级");
        return null;
    }
}

2.1.2 查询分类信息

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/49

根据三级分类Id查询分类数据,在BaseCategoryApiController 控制器中添加

/**
 * 根据三级分类ID(视图主键)查询分类视图对象
 * @param category3Id
 * @return
 */
@Operation(summary = "根据三级分类ID查询分类视图对象")
@GetMapping("/category/getCategoryView/{category3Id}")
public Result<BaseCategoryView> getCategoryView(@PathVariable Long category3Id){
    BaseCategoryView baseCategoryView = baseCategoryService.getCategoryView(category3Id);
    return Result.ok(baseCategoryView);
}

BaseCategoryService

/**
 * 根据三级分类ID查询分类视图对象
 * @param category3Id
 * @return
 */
BaseCategoryView getCategoryView(Long category3Id);

BaseCategoryServiceImpl

/**
 * 根据三级分ID(专辑视图主键)查询专辑信息
 *
 * @param category3Id
 * @return
 */
@Override
public BaseCategoryView getCategoryViewBy3Id(Long category3Id) {
    return baseCategoryViewMapper.selectById(category3Id);
}

service-album-client模块中AlbumFeignClient提供Feign远程调用接口及服务降级类

/**
 * 根据三级分类ID查询分类视图对象
 * @param category3Id
 * @return
 */
@GetMapping("/category/getCategoryView/{category3Id}")
public Result<BaseCategoryView> getCategoryView(@PathVariable Long category3Id);

AlbumDegradeFeignClient服务降级类

@Override
public Result<BaseCategoryView> getCategoryView(Long category3Id) {
    log.error("[专辑模块]提供远程调用getCategoryView服务降级");
    return null;
}

2.1.3 获取用户信息

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/51

  1. service-user模块中控制器UserInfoApiController中新增方法

    package com.atguigu.tingshu.user.api;
    
    import com.atguigu.tingshu.common.result.Result;
    import com.atguigu.tingshu.user.service.UserInfoService;
    import com.atguigu.tingshu.vo.user.UserInfoVo;
    import io.swagger.v3.oas.annotations.Operation;
    import io.swagger.v3.oas.annotations.tags.Tag;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @Tag(name = "用户管理接口")
    @RestController
    @RequestMapping("api/user")
    @SuppressWarnings({"all"})
    public class UserInfoApiController {
    
    @Autowired
    private UserInfoService userInfoService;
    
    
    /**
     * 根据用户ID获取用户(主播)基本信息
     *
     * @param userId
     * @return
     */
    @Operation(summary = "根据用户ID获取用户(主播)基本信息")
    @GetMapping("/userInfo/getUserInfoVo/{userId}")
    public Result<UserInfoVo> getUserInfoVoById(@PathVariable Long userId) {
        UserInfoVo userInfoVo = userInfoService.getUserInfoVoById(userId);
    		return Result.ok(userInfoVo);
    }
    }
    
  2. UserInfoService业务接口

    /**
    * 根据用户ID获取用户(主播)基本信息
    *
    * @param userId
    * @return
    */
    UserInfoVo getUserInfoVoById(Long userId);
    
  3. UserInfoServiceImpl业务实现类

    /**
    * 根据用户ID获取用户(主播)基本信息
    *
    * @param userId
    * @return
    */
    @Override
    public UserInfoVo getUserInfoVoById(Long userId) {
       //2.根据用户ID查询用户记录UserInfo
       UserInfo userInfo = userInfoMapper.selectById(userId);
       //3.转为UserInfoVo返回
       return BeanUtil.copyProperties(userInfo, UserInfoVo.class);
    }
    
  4. service-user-client模块中UserFeignClientFeign接口中新增远程调用接口与服务降级方法

    package com.atguigu.tingshu.user.client;
    
    import com.atguigu.tingshu.common.result.Result;
    import com.atguigu.tingshu.user.client.impl.UserDegradeFeignClient;
    import com.atguigu.tingshu.vo.user.UserInfoVo;
    import org.springframework.cloud.openfeign.FeignClient;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    
    /**
    * <p>
    * 用户模块远程调用API接口
    * </p>
    *
    * @author atguigu
    */
    @FeignClient(value = "service-user", path = "api/user", fallback = UserDegradeFeignClient.class)
    public interface UserFeignClient {
    
    
    /**
     * 根据用户ID查询用户信息
     * @param userId
     * @return
     */
    @GetMapping("/userInfo/getUserInfoVo/{userId}")
    public Result<UserInfoVo> getUserInfoVoByUserId(@PathVariable Long userId);
    }
    
    

UserDegradeFeignClient服务降级类

package com.atguigu.tingshu.user.client.impl;


import com.atguigu.tingshu.common.result.Result;
import com.atguigu.tingshu.user.client.UserFeignClient;
import com.atguigu.tingshu.vo.user.UserInfoVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class UserDegradeFeignClient implements UserFeignClient {

    @Override
    public Result<UserInfoVo> getUserInfoVoByUserId(Long userId) {
        log.error("远程调用[用户服务]getUserInfoVoByUserId方法服务降级");
        return null;
    }
}

2.2 专辑上架

2.2.1 专辑上架

该接口用于测试

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/53

service-search模块中SearchApiController 控制器

package com.atguigu.tingshu.search.api;

import com.atguigu.tingshu.common.result.Result;
import com.atguigu.tingshu.search.service.SearchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "搜索专辑管理")
@RestController
@RequestMapping("api/search")
@SuppressWarnings({"all"})
public class SearchApiController {

    @Autowired
    private SearchService searchService;


    /**
     * 仅用于测试-将指定已存在专辑上架保存到索引库
     *
     * @param albumId
     * @return
     */
    @Operation(summary = "仅用于测试-将指定已存在专辑上架保存到索引库")
    @GetMapping("/albumInfo/upperAlbum/{albumId}")
    public Result upperAlbum(@PathVariable Long albumId) {
        searchService.upperAlbum(albumId);
        return Result.ok();
    }

}

SearchService 接口

package com.atguigu.tingshu.search.service;

public interface SearchService {


    /**
     * 封装索引库文档对象,将指定已存在专辑上架保存到索引库
     * @param albumId
     */
    void upperAlbum(Long albumId);
}

SearchServiceImpl 实现类

package com.atguigu.tingshu.search.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.RandomUtil;
import com.atguigu.tingshu.album.AlbumFeignClient;
import com.atguigu.tingshu.model.album.AlbumAttributeValue;
import com.atguigu.tingshu.model.album.AlbumInfo;
import com.atguigu.tingshu.model.album.BaseCategoryView;
import com.atguigu.tingshu.model.search.AlbumInfoIndex;
import com.atguigu.tingshu.model.search.AttributeValueIndex;
import com.atguigu.tingshu.search.repository.AlbumInfoIndexRepository;
import com.atguigu.tingshu.search.service.SearchService;
import com.atguigu.tingshu.user.client.UserFeignClient;
import com.atguigu.tingshu.vo.user.UserInfoVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;


@Slf4j
@Service
@SuppressWarnings({"all"})
public class SearchServiceImpl implements SearchService {

    @Autowired
    private AlbumFeignClient albumFeignClient;


    @Autowired
    private UserFeignClient userFeignClient;

    @Autowired
    private AlbumInfoIndexRepository albumInfoIndexRepository;


    /**
     * 封装索引库文档对象,将指定已存在专辑上架保存到索引库
     *
     * @param albumId
     */
    @Override
    public void upperAlbum(Long albumId) {
        AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
        //1.远程调用专辑服务-根据专辑ID查询专辑信息(包含专辑标签列表)
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        if (albumInfo != null) {
            //1.1 将专辑基本信息拷贝到专辑索引库对象中
            BeanUtil.copyProperties(albumInfo, albumInfoIndex);
            //1.2 封装专辑标签列表
            List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
            if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
                //将集合泛型AlbumAttributeValue转为AttributeValueIndex类型
                List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList.stream().map(albumAttributeValue -> {
                    return BeanUtil.copyProperties(albumAttributeValue, AttributeValueIndex.class);
                }).collect(Collectors.toList());
                albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
            }
        }

        //2.远程调用专辑服务-获取专辑所属三级分类信息
        BaseCategoryView baseCategoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        if (baseCategoryView != null) {
            albumInfoIndex.setCategory1Id(baseCategoryView.getCategory1Id());
            albumInfoIndex.setCategory2Id(baseCategoryView.getCategory2Id());
            albumInfoIndex.setCategory3Id(baseCategoryView.getCategory3Id());
        }
        //3.远程调用用户服务-获取主播名称
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVoById(albumInfo.getUserId()).getData();
        if (userInfoVo != null) {
            albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
        }

        //4.专辑统计信息生成随机数-方便后续排序测试 TODO 远程调用专辑服务获取专辑实际统计数值
        //4.1 随机产生4个数值
        int num1 = RandomUtil.randomInt(500, 1000);
        int num2 = RandomUtil.randomInt(300, 500);
        int num3 = RandomUtil.randomInt(10, 100);
        int num4 = RandomUtil.randomInt(100, 300);
        albumInfoIndex.setPlayStatNum(num1);
        albumInfoIndex.setSubscribeStatNum(num2);
        albumInfoIndex.setBuyStatNum(num3);
        albumInfoIndex.setCommentStatNum(num4);
        //4.2 基于随机值计算得分
        BigDecimal bigDecimal1 = BigDecimal.valueOf(num1).multiply(new BigDecimal("0.4"));
        BigDecimal bigDecimal2 = BigDecimal.valueOf(num2).multiply(new BigDecimal("0.3"));
        BigDecimal bigDecimal3 = BigDecimal.valueOf(num3).multiply(new BigDecimal("0.2"));
        BigDecimal bigDecimal4 = BigDecimal.valueOf(num4).multiply(new BigDecimal("0.1"));
        BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
        //保留2位小数,四舍五入
        hotScore.setScale(2, RoundingMode.HALF_UP);
        albumInfoIndex.setHotScore(hotScore.doubleValue());


        //5.封装好索引库文档对象:AlbumInfoIndex
        //6.调用专辑索引库持久层对象保存文档
        albumInfoIndexRepository.save(albumInfoIndex);
    }

}

通过Knife4J接口地址 http://localhost:8502/doc.html 进行测试!

2.2.2 异步任务+线程池优化



    public static void main(String[] args) {
        //1.创建异步任务对象A 需要返回结果给其他异步任务使用
        CompletableFuture<String> completableFutureA = CompletableFuture.supplyAsync(() -> {
            System.out.println("任务A执行了:" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            return "resultA";
        });

        //2.基于异步任务A创建异步任务B-使用A的结果,本身不需要返回结果
        CompletableFuture<Void> completableFutureB = completableFutureA.thenAcceptAsync(resultA -> {
            Thread.sleep(2000);
            System.out.println("任务B执行了:" + Thread.currentThread().getName() + ",获取到A结果是:" + resultA);
        });

        //3.基于异步任务A创建异步任务C-使用A的结果,本身不需要返回结果
        CompletableFuture<Void> completableFutureC = completableFutureA.thenAcceptAsync(resultA -> {
            System.out.println("任务C执行了:" + Thread.currentThread().getName() + ",获取到A结果是:" + resultA);
        });

        //4.统一处理异步任务,所有任务必须执行完毕 主线程才继续
        CompletableFuture.allOf(completableFutureA, completableFutureB, completableFutureC).join();

        System.out.println(Thread.currentThread().getName() + "....end");
    }
  1. service-util模块中定义线程池对象

    package com.atguigu.tingshu.common.config.thread;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    import java.util.concurrent.ArrayBlockingQueue;
    import java.util.concurrent.Executors;
    import java.util.concurrent.ThreadPoolExecutor;
    import java.util.concurrent.TimeUnit;
    
    /**
    * @author: atguigu
    * @create: 2023-11-18 11:25
    */
    @Configuration
    public class ThreadPoolConfig {
    
    /**
     * 线程池配置 核心数建议等于最大线程数,避免线程创建销毁带来系统开销
     * 应用类型:
     * Java业务类型应用 IO 密集型 cpu核*2   一旦线程发生IO(文件IO,网络IO)线程释放掉CPU执行权
     * 视频转码,图像识别,大数据模型计算。计算密集型  cpu核数
     *
     * @return
     */
    @Bean
    public ThreadPoolExecutor threadPoolExecutor() {
        //JVM中Runtime(单例) 逻辑处理器数量
        int count = Runtime.getRuntime().availableProcessors() * 2;
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                count,//核心线程数
                count,//最大线程数
                0,//非核心线程空闲时间
                TimeUnit.SECONDS,//时间单位
                new ArrayBlockingQueue<>(200),//阻塞队列
                Executors.defaultThreadFactory(),//线程工厂
                (r, t) -> {
                    //被拒绝任务再次提交给线程池执行
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    t.submit(r);
                }//拒绝策略
        );
        //如果启动后立即有大量任务需要执行
        threadPoolExecutor.prestartCoreThread();
        return threadPoolExecutor;
    }
    }
    
  2. 使用异步任务+线程池优化

    package com.atguigu.tingshu.search.service.impl;
    
    import cn.hutool.core.bean.BeanUtil;
    import cn.hutool.core.collection.CollectionUtil;
    import cn.hutool.core.util.RandomUtil;
    import com.atguigu.tingshu.album.AlbumFeignClient;
    import com.atguigu.tingshu.model.album.AlbumAttributeValue;
    import com.atguigu.tingshu.model.album.AlbumInfo;
    import com.atguigu.tingshu.model.album.BaseCategoryView;
    import com.atguigu.tingshu.model.search.AlbumInfoIndex;
    import com.atguigu.tingshu.model.search.AttributeValueIndex;
    import com.atguigu.tingshu.search.repository.AlbumInfoIndexRepository;
    import com.atguigu.tingshu.search.service.SearchService;
    import com.atguigu.tingshu.user.client.UserFeignClient;
    import com.atguigu.tingshu.vo.user.UserInfoVo;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    import java.math.BigDecimal;
    import java.math.RoundingMode;
    import java.util.List;
    import java.util.concurrent.CompletableFuture;
    import java.util.concurrent.ThreadPoolExecutor;
    import java.util.stream.Collectors;
    
    
    @Slf4j
    @Service
    @SuppressWarnings({"all"})
    public class SearchServiceImpl implements SearchService {
    
    @Autowired
    private AlbumFeignClient albumFeignClient;
    
    
    @Autowired
    private UserFeignClient userFeignClient;
    
    @Autowired
    private AlbumInfoIndexRepository albumInfoIndexRepository;
    
    @Autowired
    private ThreadPoolExecutor threadPoolExecutor;
    
    
    /**
     * 封装索引库文档对象,将指定已存在专辑上架保存到索引库
     *
     * @param albumId
     */
    @Override
    public void upperAlbum(Long albumId) {
        AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
        //1.远程调用专辑服务-根据专辑ID查询专辑信息(包含专辑标签列表)
        CompletableFuture<AlbumInfo> albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> {
            AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
            if (albumInfo == null) {
                return null;
            }
            //1.1 将专辑基本信息拷贝到专辑索引库对象中
            BeanUtil.copyProperties(albumInfo, albumInfoIndex);
            //1.2 封装专辑标签列表
            List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
            if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
                //将集合泛型AlbumAttributeValue转为AttributeValueIndex类型
                List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList.stream().map(albumAttributeValue -> {
                    return BeanUtil.copyProperties(albumAttributeValue, AttributeValueIndex.class);
                }).collect(Collectors.toList());
                albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
            }
            return albumInfo;
        }, threadPoolExecutor);
    
        //2.远程调用专辑服务-获取专辑所属三级分类信息
        CompletableFuture<Void> categoryCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
            BaseCategoryView baseCategoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
            if (baseCategoryView != null) {
                albumInfoIndex.setCategory1Id(baseCategoryView.getCategory1Id());
                albumInfoIndex.setCategory2Id(baseCategoryView.getCategory2Id());
                albumInfoIndex.setCategory3Id(baseCategoryView.getCategory3Id());
            }
        }, threadPoolExecutor);
    
        //3.远程调用用户服务-获取主播名称
        CompletableFuture<Void> userInfoCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
            UserInfoVo userInfoVo = userFeignClient.getUserInfoVoById(albumInfo.getUserId()).getData();
            if (userInfoVo != null) {
                albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
            }
        }, threadPoolExecutor);
    
    
        //组合多个异步任务对象,要求所有异步任务必须执行完毕
        CompletableFuture.allOf(
                albumInfoCompletableFuture,
                categoryCompletableFuture,
                userInfoCompletableFuture
        ).join();
    
        //4.专辑统计信息生成随机数-方便后续排序测试 TODO 远程调用专辑服务获取专辑实际统计数值
        //4.1 随机产生4个数值
        int num1 = RandomUtil.randomInt(500, 1000);
        int num2 = RandomUtil.randomInt(300, 500);
        int num3 = RandomUtil.randomInt(10, 100);
        int num4 = RandomUtil.randomInt(100, 300);
        albumInfoIndex.setPlayStatNum(num1);
        albumInfoIndex.setSubscribeStatNum(num2);
        albumInfoIndex.setBuyStatNum(num3);
        albumInfoIndex.setCommentStatNum(num4);
        //4.2 基于随机值计算得分
        BigDecimal bigDecimal1 = BigDecimal.valueOf(num1).multiply(new BigDecimal("0.4"));
        BigDecimal bigDecimal2 = BigDecimal.valueOf(num2).multiply(new BigDecimal("0.3"));
        BigDecimal bigDecimal3 = BigDecimal.valueOf(num3).multiply(new BigDecimal("0.2"));
        BigDecimal bigDecimal4 = BigDecimal.valueOf(num4).multiply(new BigDecimal("0.1"));
        BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
        //保留2位小数,四舍五入
        hotScore.setScale(2, RoundingMode.HALF_UP);
        albumInfoIndex.setHotScore(hotScore.doubleValue());
    
    
        //5.封装好索引库文档对象:AlbumInfoIndex
        //6.调用专辑索引库持久层对象保存文档
        albumInfoIndexRepository.save(albumInfoIndex);
    }
    }
    

批量导入

package com.atguigu.tingshu;

import com.atguigu.tingshu.search.service.SearchService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CompletableFuture;

@SpringBootTest
class ServiceSearchApplicationTest {

    @Autowired
    private SearchService searchService;


    /**
     *   //TODO 每批次(采用多线程线程处理)新增200条文档; 每次逻辑:获取200个专辑索引库文档集合;调用批量操作API
     * 不严谨批量导入:
     * TODO:ES中批量新增API https://www.elastic.co/guide/en/elasticsearch/client/java-api-client/current/indexing-bulk.html
     */
    @Test
    public void test() {
        for (long i = 1; i <= 1607; i++) {
            searchService.upperAlbum(i);
        }
    }
}

2.3 专辑下架

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/55

SearchApiController控制器

/**
 * 下架专辑
 * @param albumId
 * @return
 */
@Operation(summary = "下架专辑")
@GetMapping("/albumInfo/lowerAlbum/{albumId}")
public Result lowerAlbum(@PathVariable Long albumId) {
    searchService.lowerAlbum(albumId);
    return Result.ok();
}

SearchService接口

/**
 * 下架专辑
 * @param albumId
 */
void lowerAlbum(Long albumId);

SearchServiceImpl实现类

/**
 * 下架专辑
 * @param albumId
 */
@Override
public void lowerAlbum(Long albumId) {
    albumInfoIndexRepository.deleteById(albumId);
}

通过Knife4J接口地址 http://localhost:8502/doc.html 进行测试!

2.4 专辑自动上下架

需求:基于Kafka消息队列实现专辑自动上下架;主播在APP端对自己专辑进行进行上架或者下架除了修改

2.4.1 生产者:专辑服务

service-album模块中改造:AlbumInfoServiceImpl中保存专辑的saveAlbumInfo方法

@Autowired
private KafkaService kafkaService;


/**
 * 保存专辑
 *
 * @param albumInfoVo
 * @param userId
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void saveAlbumInfo(AlbumInfoVo albumInfoVo, Long userId) {

    //....省略代码
    
    //同时将新上架状态公开的专辑存入ES 发送上架消息
    if ("1".equals(albumInfo.getIsOpen())){
        kafkaService.sendMessage(KafkaConstant.QUEUE_ALBUM_UPPER,String.valueOf(albumInfo.getId()  ));
    }
}

更新专辑方法添加

/**
 * 修改专辑信息
 *
 * @param id          专辑ID
 * @param albumInfoVo 变更后专辑信息
 * @return
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAlbumInfo(Long id, AlbumInfoVo albumInfoVo) {
    //....省略代码

    //TODO如果是开放专辑自动将专辑同步到ES索引库中
    if ("1".equals(albumInfo.getIsOpen())) {
        kafkaService.sendMessage(KafkaConstant.QUEUE_ALBUM_UPPER, albumInfo.getId().toString());
    } else {
        kafkaService.sendMessage(KafkaConstant.QUEUE_ALBUM_LOWER, albumInfo.getId().toString());
    }
}

删除专辑方法添加

/**
 * 删除专辑
 * @param id
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void removeAlbumInfoById(Long id) {
    //	删除专辑表的数据 album_info
    this.removeById(id);
    //	删除专辑属性信息
    albumAttributeValueMapper.delete(new LambdaQueryWrapper<AlbumAttributeValue>().eq(AlbumAttributeValue::getAlbumId, id));
    //	删除专辑对应的统计数据
    albumStatMapper.delete(new LambdaQueryWrapper<AlbumStat>().eq(AlbumStat::getAlbumId, id));

    //TODO 同步删除索引库中专辑文档
    kafkaService.sendMessage(KafkaConstant.QUEUE_ALBUM_LOWER, id.toString());
}

2.4.2 消费者:搜索服务

监听:在service-search微服务中添加监听方法

package com.atguigu.tingshu.search.receiver;

import com.atguigu.tingshu.common.constant.KafkaConstant;
import com.atguigu.tingshu.search.service.SearchService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

/**
 * @author: atguigu
 * @create: 2023-11-18 14:16
 */
@Slf4j
@Component
public class SearchReceiver {

    @Autowired
    private SearchService searchService;


    /**
     * 监听专辑上架消息,完成索引库导入
     * 考虑:1.要不要进行幂等性处理(不需要)  2.是否需要进行事务管理(不需要)
     *
     * @param record
     */
    @KafkaListener(topics = KafkaConstant.QUEUE_ALBUM_UPPER)
    public void albumUpper(ConsumerRecord<String, String> record) {
        String value = record.value();
        if (StringUtils.isNotBlank(value)) {
            log.info("[搜索服务]监听到专辑上架消息:{}", value);
            searchService.upperAlbum(Long.valueOf(value));
        }
    }


    /**
     * 监听专辑下架消息,完成索引库删除
     * 考虑:1.要不要进行幂等性处理  2.是否需要进行事务管理(不需要)
     *
     * @param record
     */
    @KafkaListener(topics = KafkaConstant.QUEUE_ALBUM_LOWER)
    public void albumLower(ConsumerRecord<String, String> record) {
        String value = record.value();
        if (StringUtils.isNotBlank(value)) {
            log.info("[搜索服务]监听到专辑下架消息:{}", value);
            searchService.lowerAlbum(Long.valueOf(value));
        }
    }
}

3、专辑关键字检索

业务需求:普通用户通过关键字检索专辑列表

  • 提交条件:关键字,通过专辑属性/属性值,通过专辑分类
  • 排序:支持根据专辑热门值排序;播放量;发布时间;
  • 分页:每页显示10条记录
  • 高亮:关键字高亮显示
  • 字段指定:指定查询响应业务字段

返回信息:响应符合要求专辑列表,关键字要高亮显示;分页信息;

# 模拟用户填写“财经”关键字、选择(人文国学- 国学-传统文化)三个分类、根据(是否付费、付费)专辑标签过滤
#、(是否完结、完结)等两个专辑标签
GET /albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典"
                }
              },
              {
                "match": {
                  "albumIntro": "古典"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        },
        {
          "term": {
            "category2Id": "168"
          }
        },
        {
          "term": {
            "category3Id": "1336"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "18"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "37"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "19"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "40"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "asc"
      }
    }
  ],
  "highlight": {
    "fields": {
      "albumTitle": {}
    },
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "_source": {
    "excludes": [
      "attributeValueIndexList.attributeId",
      "attributeValueIndexList.valueId",
      "category1Id",
      "category2Id",
      "category3Id",
      "hotScore"
    ]
  }
}

3.1 封装条件及响应对象

根据用户可能输入的检索条件,所有条件封装到一个实体对象中 AlbumIndexQuery

package com.atguigu.tingshu.query.search;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.util.List;

@Data
@Schema(description = "专辑信息搜索")
public class AlbumIndexQuery {

	@Schema(description = "关键字")
	private String keyword;

	@Schema(description = "一级分类")
	private Long category1Id;

	@Schema(description = "二级分类")
	private Long category2Id;

	@Schema(description = "三级分类")
	private Long category3Id;

	@Schema(description = "属性(属性id:属性值id)")
	private List<String> attributeList;

	// order=1:asc  排序规则   0:asc
	@Schema(description = "排序(综合排序[1:desc] 播放量[2:desc] 发布时间[3:desc];asc:升序 desc:降序)")
	private String order = "";// 1:综合排序 2:播放量 3:最近更新

	private Integer pageNo = 1;//分页信息
	private Integer pageSize = 10;
}

将检索的结果统一封装到一个实体类便于显示检索内容 AlbumSearchResponseVo

package com.atguigu.tingshu.vo.search;

import lombok.Data;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

@Data
public class AlbumSearchResponseVo implements Serializable {

    //检索出来的商品信息
    private List<AlbumInfoIndexVo> list = new ArrayList<>();
    private Long total;//总记录数
    private Integer pageSize;//每页显示的内容
    private Integer pageNo;//当前页面
    private Long totalPages;

}

3.2 检索实现

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/57

SearchApiController控制器

/**
 * 专辑站内检索:支持关键字、分类及标签过滤,分页,高亮
 *
 * @param queryVo
 * @return
 */
@Operation(summary = "专辑站内检索:支持关键字、分类及标签过滤,分页,高亮")
@PostMapping("/albumInfo")
public Result<AlbumSearchResponseVo> search(@RequestBody AlbumIndexQuery queryVo) {
    AlbumSearchResponseVo responseVo = searchService.search(queryVo);
    return Result.ok(responseVo);
}

SearchService接口

/**
 * 专辑站内检索:支持关键字、分类及标签过滤,分页,高亮
 *
 * @param queryVo
 * @return
 */
AlbumSearchResponseVo search(AlbumIndexQuery queryVo);


/**
 * 根据检索入参对象封装检索请求对象
 * @param queryVo 查询条件
 * @return
 */
SearchRequest buildDSL(AlbumIndexQuery queryVo);

/**
 * 解析ES检索结果
 * @param searchResponse ES检索结果对象
 * @return
 */
AlbumSearchResponseVo parseResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery queryVo);

SearchServiceImpl实现类

@Autowired
private ElasticsearchClient elasticsearchClient;


/**
 * 专辑站内检索:支持关键字、分类及标签过滤,分页,高亮
 *
 * @param queryVo
 * @return
 */
@Override
public AlbumSearchResponseVo search(AlbumIndexQuery queryVo) {
    try {
        //一、构建检索请求对象(构建完整建设请求路径,请求头参数)
        SearchRequest searchRequest = this.buildDSL(queryVo);
        System.err.println("本次检索DSL:");
        System.err.println(searchRequest.toString());

        //二、调用ES-Restful接口执行检索
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(searchRequest, AlbumInfoIndex.class);

        //三、解析ES响应结果
        return this.parseResult(searchResponse, queryVo);
    } catch (Exception e) {
        log.error("[搜索服务]关键字检索异常:{}", e);
        throw new RuntimeException(e);
    }
}
//专辑索引库名称
private static final String INDEX_NAME = "albuminfo";

/**
 * 根据检索入参对象封装检索请求对象
 *
 * @param queryVo 查询条件
 * @return
 */
@Override
public SearchRequest buildDSL(AlbumIndexQuery queryVo) {
    //1.构建检索请求对象构建器对象 封装检索索引库名称
    SearchRequest.Builder searchRequestBuilder = new SearchRequest.Builder();
    searchRequestBuilder.index(INDEX_NAME);

    //2.设置查询条件,设置请求体参数中"query" 关键字、分类ID、标签
    //2.0 创建最外层bool查询-设置关键字及过滤条件
    BoolQuery.Builder allBoolQueryBuilder = new BoolQuery.Builder();
    //2.1 设置关键字条件查询 要求:关键字任意匹配专辑标题,简介,主播名称
    String keyword = queryVo.getKeyword();
    if (StringUtils.isNotBlank(keyword)) {
        //2.1.1 创建封装关键字查询三个条件bool查询对象
        BoolQuery.Builder keywordBoolQueryBuilder = new BoolQuery.Builder();
        //2.1.2 分别设置查询专辑标题
        keywordBoolQueryBuilder.should(s -> s.match(m -> m.field("albumTitle").query(keyword)));
        //2.1.3 分别设置查询专辑简介
        keywordBoolQueryBuilder.should(s -> s.match(m -> m.field("albumIntro").query(keyword)));
        //2.1.4 分别设置查询专辑主播名称
        keywordBoolQueryBuilder.should(s -> s.term(t -> t.field("announcerName").value(keyword)));
        allBoolQueryBuilder.must(keywordBoolQueryBuilder.build()._toQuery());
    }
    //2.2 设置分类过滤(1、2、3级分类ID过滤)
    Long category1Id = queryVo.getCategory1Id();
    if (category1Id != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category1Id").value(category1Id)));
    }
    Long category2Id = queryVo.getCategory2Id();
    if (category2Id != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category2Id").value(category2Id)));
    }
    Long category3Id = queryVo.getCategory3Id();
    if (category3Id != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category3Id").value(category3Id)));
    }
    //2.3 设置专辑标签(标签ID,标签值ID)过滤
    List<String> attributeList = queryVo.getAttributeList();
    if (CollectionUtil.isNotEmpty(attributeList)) {
        //2.3.1 获取筛选标签ID,级标签值过滤条件集合,每遍历一组标签条件 构建一个nested查询对象
        for (String attribute : attributeList) {
            //标签条件形式  标签ID:标签值ID
            String[] split = attribute.split(":");
            if (split != null && split.length == 2) {
                //2.3.2 创建nested查询对象
                NestedQuery.Builder nestedQueryBuilder = new NestedQuery.Builder();
                nestedQueryBuilder.path("attributeValueIndexList");
                nestedQueryBuilder.query(q -> q.bool(
                        b -> b.must(m -> m.term(t -> t.field("attributeValueIndexList.attributeId").value(split[0])))
                                .must(m -> m.term(t -> t.field("attributeValueIndexList.valueId").value(split[1])))
                ));
                //2.3.3 在nested查询内部封装bool查询,包含标签ID,跟标签值ID
                allBoolQueryBuilder.filter(nestedQueryBuilder.build()._toQuery());
            }
        }
    }
    //2.4 将封装所有查询条件放入请求体对象
    searchRequestBuilder.query(allBoolQueryBuilder.build()._toQuery());

    //3.设置分页,设置请求体参数"from"=(页码-1)*pageSize,"size"
    int from = (queryVo.getPageNo() - 1) * queryVo.getPageSize();
    searchRequestBuilder.from(from).size(queryVo.getPageSize());

    //4.设置排序 设置请求体参数中"sort"
    String order = queryVo.getOrder();
    //排序条件:排序(综合排序[1:desc] 播放量[2:desc] 发布时间[3:desc];asc:升序 desc:降序)
    if (StringUtils.isNotBlank(order)) {
        String[] split = order.split(":");
        String orderField = "";
        if (split != null && split.length == 2) {
            //4.1 确定排序字段
            switch (split[0]) {
                case "1":
                    orderField = "hotScore";
                    break;
                case "2":
                    orderField = "playStatNum";
                    break;
                case "3":
                    orderField = "createTime";
                    break;
            }
            String finalOrderField = orderField;
            searchRequestBuilder.sort(s -> s.field(f -> f.field(finalOrderField)
                    .order("asc".equals(split[1]) ? SortOrder.Asc : SortOrder.Desc)));
        }
    }

    //5.设置高亮,设置请求体参数中"highlight"
    if (StringUtils.isNotBlank(queryVo.getKeyword())) {
        searchRequestBuilder.highlight(h -> h.fields("albumTitle", h1 -> h1.preTags("<font style='color:red'>").postTags("</font>")));
    }

    //6.设置字段响应,设置请求体参数中"_source"
    List<String> excludeFields = Arrays.asList("attributeValueIndexList.attributeId",
            "attributeValueIndexList.valueId",
            "category1Id",
            "category2Id",
            "category3Id",
            "hotScore");
    searchRequestBuilder.source(s -> s.filter(f -> f.excludes(excludeFields)));

    return searchRequestBuilder.build();
}

查询结果集封装

/**
 * 解析ES检索结果
 *
 * @param searchResponse ES检索结果对象
 * @param queryVo
 * @return
 */
@Override
public AlbumSearchResponseVo parseResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery queryVo) {
    //1.创建响应结果VO对象
    AlbumSearchResponseVo vo = new AlbumSearchResponseVo();
    //2.封装VO对象中分页属性
    //2.1 封装分页 页码 页大小
    Integer pageNo = queryVo.getPageNo();
    Integer pageSize = queryVo.getPageSize();
    vo.setPageNo(pageNo);
    vo.setPageSize(pageSize);
    //2.2 获取总记录数
    long total = searchResponse.hits().total().value();
    vo.setTotal(total);
    //2.3 计算总页数
    long totalPages = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
    vo.setTotalPages(totalPages);

    //3.封装VO对象中当前页数据集合属性
    List<Hit<AlbumInfoIndex>> hits = searchResponse.hits().hits();
    if (CollectionUtil.isNotEmpty(hits)) {
        List<AlbumInfoIndexVo> albumInfoIndexVoList = hits.stream().map(hit -> {
            //3.1 获取命中专辑文档
            AlbumInfoIndex albumInfoIndex = hit.source();
            //3.2 处理可能存在高亮片段
            Map<String, List<String>> highlight = hit.highlight();
            if (CollectionUtil.isNotEmpty(highlight) && highlight.containsKey("albumTitle")) {
                //3.3 获取专辑字段对应高亮片段
                String highlightAlbumTitle = highlight.get("albumTitle").get(0);
                albumInfoIndex.setAlbumTitle(highlightAlbumTitle);
            }
            //3.4 将查询到AlbumInfoIndex转为AlbumInfoIndexVo类型
            return BeanUtil.copyProperties(albumInfoIndex, AlbumInfoIndexVo.class);
        }).collect(Collectors.toList());
        vo.setList(albumInfoIndexVoList);
    }
    return vo;
}

4、热门专辑检索

当进入首页,会自动发出三个请求

  • 查询所有(1、2、3)分类列表 例如:音乐、有声书、娱乐等
  • 根据一级分类下包含所有三级分类列表。例如:音乐分类下的 催眠音乐、放松音乐
  • 根据一级分类查询所有三级分类下包含的热门专辑(热度前6)

4.1 查询一级分类下三级分类列表

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/59

service-album 模块 BaseCategoryApiController 控制器中添加

/**
 * 首页中展示指定1级分类下前7个三级分类集合
 *
 * @param category1Id:一级分类ID
 * @return
 */
@Operation(summary = "首页中展示指定1级分类下前7个三级分类集合")
@GetMapping("/category/findTopBaseCategory3/{category1Id}")
public Result getTopBaseCategory3(@PathVariable Long category1Id) {
    List<BaseCategory3> list = baseCategoryService.getTopBaseCategory3(category1Id);
    return Result.ok(list);
}

BaseCategoryService接口:

/**
 * 首页中展示指定1级分类下前7个三级分类集合
 *
 * @param category1Id:一级分类ID
 * @return
 */
List<BaseCategory3> getTopBaseCategory3(Long category1Id);

BaseCategoryServiceImpl实现类

/**
 * 首页中展示指定1级分类下前7个三级分类集合
 *
 * @param category1Id:一级分类ID
 * @return
 */
@Override
public List<BaseCategory3> getTopBaseCategory3(Long category1Id) {
    //1.根据一级分类ID查询二级分类集合
    LambdaQueryWrapper<BaseCategory2> baseCategory2LambdaQueryWrapper = new LambdaQueryWrapper<>();
    baseCategory2LambdaQueryWrapper.eq(BaseCategory2::getCategory1Id, category1Id);
    List<BaseCategory2> baseCategory2List = baseCategory2Mapper.selectList(baseCategory2LambdaQueryWrapper);

    //2.处理二级分类集合获取二级分类ID集合
    if (CollectionUtil.isNotEmpty(baseCategory2List)) {
        List<Long> baseCategory2IdList = baseCategory2List.stream().map(BaseCategory2::getId).collect(Collectors.toList());
        //3.根据二级分类ID集合查询三级分类列表,查询前7个
        LambdaQueryWrapper<BaseCategory3> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.in(BaseCategory3::getCategory2Id, baseCategory2IdList);
        queryWrapper.last("limit 7");
        return baseCategory3Mapper.selectList(queryWrapper);
    }

    return null;
}

4.2 根据一级分类Id获取全部分类信息

YAP接口地址:http://192.168.200.6:3000/project/11/interface/api/63

点击全部的时候,加载所有的一级分类下二级三级分类列表:

BaseCategoryApiController控制器

/**
 * 查询指定一级分类下包含二级分类列表(二级分类下三级分类列表
 * @param category1Id 1级分类ID
 * @return
 */
@Operation(summary = "查询指定一级分类下包含二级分类列表(二级分类下三级分类列表)")
@GetMapping("/category/getBaseCategoryList/{category1Id}")
public Result<JSONObject> getBaseCategoryList(@PathVariable Long category1Id){
    JSONObject jsonObject1 = baseCategoryService.getBaseCategoryListByCategory1Id(category1Id);
    return Result.ok(jsonObject1);
}

BaseCategoryService接口:

/**
 * 查询指定一级分类下包含二级分类列表(二级分类下三级分类列表
 * @param category1Id 1级分类ID
 * @return
 */
JSONObject getBaseCategoryListByCategory1Id(Long category1Id);

BaseCategoryServiceImpl实现类:

/**
 * 查询指定一级分类下包含二级分类列表(二级分类下三级分类列表
 *
 * @param category1Id 1级分类ID
 * @return
 */
@Override
public JSONObject getBaseCategoryListByCategory1Id(Long category1Id) {
    //1.根据1分类ID查询分类视图-得到一级分类列表
    LambdaQueryWrapper<BaseCategoryView> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(BaseCategoryView::getCategory1Id, category1Id);
    List<BaseCategoryView> category1List = baseCategoryViewMapper.selectList(queryWrapper);

    //2.处理一级分类下二级分类,根据一级分类集合中二级分类ID进行分组
    if (CollectionUtil.isNotEmpty(category1List)) {
        //2.1 构建一级分类对象
        JSONObject jsonObject1 = new JSONObject();
        jsonObject1.put("categoryId", category1List.get(0).getCategory1Id());
        jsonObject1.put("categoryName", category1List.get(0).getCategory1Name());

        Map<Long, List<BaseCategoryView>> category2ListMap = category1List.stream().collect(Collectors.groupingBy(BaseCategoryView::getCategory2Id));
        List<JSONObject> jsonObject2List = new ArrayList<>();
        for (Map.Entry<Long, List<BaseCategoryView>> entry2 : category2ListMap.entrySet()) {
            Long category2Id = entry2.getKey();
            String category2Name = entry2.getValue().get(0).getCategory2Name();
            //2.2 构建二级分类JSON对象
            JSONObject jsonObject2 = new JSONObject();
            jsonObject2.put("categoryId", category2Id);
            jsonObject2.put("categoryName", category2Name);
            jsonObject2List.add(jsonObject2);
            //3.处理二级分类下三级分类
            List<JSONObject> jsonObject3List = new ArrayList<>();
            for (BaseCategoryView baseCategoryView : entry2.getValue()) {
                JSONObject jsonObject3 = new JSONObject();
                jsonObject3.put("categoryId", baseCategoryView.getCategory3Id());
                jsonObject3.put("categoryName", baseCategoryView.getCategory3Name());
                jsonObject3List.add(jsonObject3);
            }
            //4.将三级分类集合放入二级分类对象中"categoryChild"属性中
            jsonObject2.put("categoryChild", jsonObject3List);
        }
        //2.3 将二级分类集合放入一级分类对象中"categoryChild"属性中
        jsonObject1.put("categoryChild", jsonObject2List);
        return jsonObject1;
    }
    return null;
}

4.3 查询分类下热门专辑

获取频道页数据时,页面需要将存储一个 List 集合包含所有三级分类下热门专辑,map 中 需要存储

该接口是获取一级分类下,置顶到频道页的三级分类(base_category3)的热门专辑数据

Map map = new HashMap<String, Object>(); //某个分类下热门专辑对象
map.put("baseCategory3", baseCategory3(三级分类对象));
map.put("list", albumInfoIndexList(当前三级分类下热门前6的专辑列表));

image-20230921152112908

首页分类下热门专辑从ES获取,相关DSL语句如下:

# 需求:查询指定(7个)三级分类热度前6的专辑列表
#第一步:根据"音乐"下7个三级分类ID查询专辑列表(1001->1007)

GET /albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1003",
        "1004",
        "1005",
        "1006",
        "1007"
      ]
    }
  }
}

#第二步:将第一步查询专辑结果根据专辑中三级分类ID进行分组
GET /albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1003",
        "1004",
        "1005",
        "1006",
        "1007"
      ]
    }
  },
  "aggs": {
    "category3Agg": {
      "terms": {
        "field": "category3Id",
        "size": 10
      }
    }
  }
}




#第三步:在分组后专辑列表中再次进行排序(小范围排序)查询热度前6的专辑

GET /albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1003",
        "1004",
        "1005",
        "1006",
        "1007"
      ]
    }
  },
  "size": 0, 
  "aggs": {
    "category3IdAgg": {
      "terms": {
        "field": "category3Id",
        "size": 10
      },
      "aggs": {
        "hotScoreTop6":{
          "top_hits": {
            "size": 6,
            "sort": [{"hotScore": {"order": "desc"}}]
          }
        }
        
      }
    }
  }
}

service-album-client模块中AlbumFeignClient提供Feign接口

/**
 * 首页中展示指定1级分类下前7个三级分类集合
 *
 * @param category1Id:一级分类ID
 * @return
 */
@GetMapping("/category/findTopBaseCategory3/{category1Id}")
public Result<List<BaseCategory3>> getTopBaseCategory3(@PathVariable Long category1Id);

AlbumDegradeFeignClient服务降级类

@Override
public Result<List<BaseCategory3>> getTopBaseCategory3(Long category1Id) {
    log.error("[专辑模块]提供远程调用getTopBaseCategory3服务降级");
    return null;
}

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/61

/**
 * 查询指定一级分类下前7个三级分类,得到每个三级分类下热度前6的热门专辑
 *
 * @param category1Id 1级分类ID
 * @return
 */
@Operation(summary = "查询指定一级分类下前7个三级分类,得到每个三级分类下热度前6的热门专辑")
@GetMapping("/albumInfo/channel/{category1Id}")
public Result<List<Map<String, Object>>> searchCategory3Top6(@PathVariable Long category1Id) {
    List<Map<String, Object>> list = searchService.searchCategory3Top6(category1Id);
    return Result.ok(list);
}

SearchService接口:

/**
 * 查询指定一级分类下前7个三级分类,得到每个三级分类下热度前6的热门专辑
 *
 * @param category1Id 1级分类ID
 * @return
 */
List<Map<String, Object>> searchCategory3Top6(Long category1Id);

SearchServiceImpl实现类:

/**
 * 查询指定一级分类下前7个三级分类,得到每个三级分类下热度前6的热门专辑
 *
 * @param category1Id 1级分类ID
 * @return
 */
@Override
public List<Map<String, Object>> searchCategory3Top6(Long category1Id) {
    try {
        //1. 处理及封装三级分类
        //1.1.远程调用专辑服务-得到1级分类下前7个三级分类集合
        List<BaseCategory3> baseCategory3List = albumFeignClient.getTopBaseCategory3(category1Id).getData();
        Assert.notNull(baseCategory3List, "三级分类为空");

        //1.2.获取三级分类集合中三级分类ID
        List<Long> category3IdList = baseCategory3List.stream().map(BaseCategory3::getId).collect(Collectors.toList());

        //1.4 DSL语句多关键字精确查询需要FiledValue集合->将ID集合转为FiledValue集合
        List<FieldValue> fieldValueList = category3IdList.stream().map(category3Id -> FieldValue.of(category3Id)).collect(Collectors.toList());

        //1.3 将三级分类集合转为Map 方便封装Map中分类对象 Key:三级分类ID Value:三级分类对象
        Map<Long, BaseCategory3> category3Map = baseCategory3List.stream().collect(Collectors.toMap(BaseCategory3::getId, baseCategory3 -> baseCategory3));


        //2.调用原生ElasticsearchClient进行检索-DSL参数较少采用lambda完成检索
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(
                s -> s.index(INDEX_NAME)
                        .query(q -> q.terms(t -> t.field("category3Id").terms(t1 -> t1.value(fieldValueList))))
                        .size(0)
                        .aggregations("category3IdAgg", a -> a.terms(
                                        t -> t.field("category3Id").size(10)
                                ).aggregations("hotScoreTop6", a1 -> a1.topHits(top -> top.size(6).sort(sort -> sort.field(f -> f.field("hotScore").order(SortOrder.Desc)))))
                        )
                , AlbumInfoIndex.class
        );

        //3.解析ES响应结果,从聚合中获取每个三级分类下热度前6的专辑
        //3.1 获取三级分类ID聚合对象
        LongTermsAggregate category3IdAgg = searchResponse.aggregations().get("category3IdAgg").lterms();
        //3.2 获取三级分类ID聚合结果桶数组
        List<LongTermsBucket> category3Buckets = category3IdAgg.buckets().array();
        if (CollectionUtil.isNotEmpty(category3Buckets)) {
            //3.3 每遍历一个三级分类Bucket 处理该三级分类下热门专辑
            List<Map<String, Object>> mapList = category3Buckets.stream().map(category3Bucket -> {
                //3.3.1 获取到三级分类ID
                long category3Id = category3Bucket.key();
                //3.3.2 通过三级分类集合对象获取子聚合
                List<Hit<JsonData>> hotScoreTop6 = category3Bucket.aggregations().get("hotScoreTop6").topHits().hits().hits();
                //3.3.3 当前分类下热门专辑
                List<AlbumInfoIndex> category3Top6List = hotScoreTop6.stream().map(hit -> {
                    String albumInfoJSON = hit.source().toJson().toString();
                    return JSON.parseObject(albumInfoJSON, AlbumInfoIndex.class);
                }).collect(Collectors.toList());
                //3.4 封装当前三级分类下 热门对象Map
                Map<String, Object> map = new HashMap<>();
                map.put("baseCategory3", category3Map.get(category3Id));
                map.put("list", category3Top6List);
                return map;
            }).collect(Collectors.toList());
            return mapList;
        }
        return null;
    } catch (Exception e) {
        log.error("[搜索服务]分类下热门专辑异常:{}", e);
        throw new RuntimeException(e);
    }
}

5、关键字自动补全功能

5.1 入门案例

completion suggester查询

Elasticsearch提供了**Completion Suggester**查询来实现自动补全功能。这个查询会匹配以用户输入内容**开头的词条**并返回。为了提高补全查询的效率,对于文档中字段的类型有一些约束:参与补全查询的字段必须是**completion**类型。根据用户输入的关键词,实现自动填充的效果

API: [Suggesters | Elasticsearch Guide 8.5] | Elastic

completion suggest 也叫自动完成,搜索推荐,搜索提示 ,一般多叫自动完成,即auto completion。

比如说我们在百度,搜索,你现在搜索“大话西游” —> image-20231022214021073

completion,es 实现的时候,是非常高性能的,会构建不是倒排索引,也不是正排索引,就是纯的用于进行前缀搜索的一种特殊的数据结构,而且会全部放在内存中,所以auto completion进行的前缀搜索提示,性能是非常高的。

  1. 初始化设置

要使用completion需要先将其做设置,注意此处suggest的type【注:suggest不只有completion这一种】,另外此处title未设置全词匹配即type非keyword,故会出现补充测试这一现象

  1. 存储数据

    #创建索引-存放用于搜索过的关键词或者初始化导入数据将相关词放入 “提词” 索引库
    PUT test
    {
     "mappings": {
       "properties": {
         "name": {
           "type": "keyword"
         },
         "suggestKeyword": {
           "type": "completion"
         }
       }
     }
    }
    # 添加数据
    POST test/_doc
    {
     "name": "Pitch Fork",
     "suggestKeyword": ["Pitch", "Fork"]
    }
    POST test/_doc
    {
     "name": "Spading Fork",
     "suggestKeyword": ["Spading", "Fork"]
    }
    POST test/_doc
    {
     "name": "Fountain",
     "suggestKeyword": ["Fountain"]
    }
    # 查询所有数据 会有三条数据出现
    GET test/_search
    
  2. 测试 suggest

    # 检索fo开的的人 会出现三条数据
    #需求:将用户已录入部分关键字获取到去查询提词库,用于自动补全选项
    GET /test/_search
    {
     "suggest": {
       "my-suggest": {
         "prefix": "fo",        
         "completion": {         
             "field": "suggestKeyword"  
         }
       }
     }
    }
    

结果:检索结果集为空,但是建议提词中有三条数据

   {
     "took": 16,
     "timed_out": false,
     "_shards": {
       "total": 1,
       "successful": 1,
       "skipped": 0,
       "failed": 0
     },
     "hits": {
       "total": {
         "value": 0,
         "relation": "eq"
       },
       "max_score": null,
       "hits": []
     },
     "suggest": {
       "my-suggest": [
         {
           "text": "fo",
           "offset": 0,
           "length": 2,
           "options": [
             {
               "text": "Fork",
               "_index": "test",
               "_id": "AVYrb4sBqAkoJbWz52TJ",
               "_score": 1,
               "_source": {
                 "name": "Pitch Fork",
                 "suggestKeyword": [
                   "Pitch",
                   "Fork"
                 ]
               }
             },
             {
               "text": "Fork",
               "_index": "test",
               "_id": "AlYrb4sBqAkoJbWz52T9",
               "_score": 1,
               "_source": {
                 "name": "Spading Fork",
                 "suggestKeyword": [
                   "Spading",
                   "Fork"
                 ]
               }
             },
             {
               "text": "Fountain",
               "_index": "test",
               "_id": "A1Yrb4sBqAkoJbWz6GQh",
               "_score": 1,
               "_source": {
                 "name": "Fountain",
                 "suggestKeyword": [
                   "Fountain"
                 ]
               }
             }
           ]
         }
       ]
     }
   }

如果检索fon开头的单词,则一条数据都不会出现.

   GET test/_search
   {
     "suggest": {
       "completer": {
         "prefix": "fon",
         "completion": {
           "field": "suggestKeyword"
         }
       }
     }
   }

结果:

   {
     "took": 1,
     "timed_out": false,
     "_shards": {
       "total": 1,
       "successful": 1,
       "skipped": 0,
       "failed": 0
     },
     "hits": {
       "total": {
         "value": 0,
         "relation": "eq"
       },
       "max_score": null,
       "hits": []
     },
     "suggest": {
       "completer": [
         {
           "text": "fon",
           "offset": 0,
           "length": 3,
           "options": []
         }
       ]
     }
   }
  1. 文档中包含两个fork。"skip_duplicates": true 作用

    # 去重显示
    GET test/_search
    {
     "suggest": {
       "completer": {
         "prefix": "fo",
         "completion": {
           "field": "suggestKeyword",
           "skip_duplicates": true
         }
       }
     }
    }
    

结果:

   {
     "took": 1,
     "timed_out": false,
     "_shards": {
       "total": 1,
       "successful": 1,
       "skipped": 0,
       "failed": 0
     },
     "hits": {
       "total": {
         "value": 0,
         "relation": "eq"
       },
       "max_score": null,
       "hits": []
     },
     "suggest": {
       "completer": [
         {
           "text": "fo",
           "offset": 0,
           "length": 2,
           "options": [
             {
               "text": "Fork",
               "_index": "test",
               "_id": "sA5tvIoBWq_kM3ND-lkp",
               "_score": 1,
               "_source": {
                 "name": "Pitch Fork",
                 "suggest": [
                   "Pitch",
                   "Fork"
                 ]
               }
             },
             {
               "text": "Fountain",
               "_index": "test",
               "_id": "sQ5uvIoBWq_kM3NDF1l7",
               "_score": 1,
               "_source": {
                 "name": "Fountain",
                 "suggest": [
                   "Fountain"
                 ]
               }
             }
           ]
         }
       ]
     }
   }
  1. 在实际使用中,有时我们输入时可能会出现错误。比如输入 for 时,输入了foe。此时可以使用 fuzzy, 在这里面的 fuzziness 我设置为 auto。 _source : false 含义是不看source数据.

    GET test/_search
    {
     "_source": false, 
     "suggest": {
       "completer": {
         "prefix": "foe",
         "completion": {
           "field": "suggestKeyword",
           "skip_duplicates": true,
           "fuzzy": {
             "fuzziness": "auto"
           }
         }
       }
     }
    }
    

结果集:

   {
     "took": 1,
     "timed_out": false,
     "_shards": {
       "total": 1,
       "successful": 1,
       "skipped": 0,
       "failed": 0
     },
     "hits": {
       "total": {
         "value": 0,
         "relation": "eq"
       },
       "max_score": null,
       "hits": []
     },
     "suggest": {
       "completer": [
         {
           "text": "foe",
           "offset": 0,
           "length": 3,
           "options": [
             {
               "text": "Fork",
               "_index": "test",
               "_id": "sA5tvIoBWq_kM3ND-lkp",
               "_score": 2
             },
             {
               "text": "Fountain",
               "_index": "test",
               "_id": "sQ5uvIoBWq_kM3NDF1l7",
               "_score": 2
             }
           ]
         }
       ]
     }
   }

5.2 功能实现

image-20231005103017179

5.2.1 提词索引库初始化

提词文档索引库对象

package com.atguigu.tingshu.model.search;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.CompletionField;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.core.suggest.Completion;

@Data
@Document(indexName = "suggestinfo")
@JsonIgnoreProperties(ignoreUnknown = true)//目的:防止json字符串转成实体对象时因未识别字段报错
public class SuggestIndex {

    /*悲惨世界*/

    @Id
    private String id;

    /*
       专辑名称,主播名称,用于给用户展示提词  悲惨世界
    * */
    @Field(type = FieldType.Text, analyzer = "standard")
    private String title;


    /**
     * 用与检索建议词查询字段 汉字 悲 惨 世 界
     */
    @CompletionField(analyzer = "standard", searchAnalyzer = "standard", maxInputLength = 20)
    private Completion keyword;

    /**
     * 用与检索建议词查询字段 完整汉语拼音 beicanshijie
     */
    @CompletionField(analyzer = "standard", searchAnalyzer = "standard", maxInputLength = 20)
    private Completion keywordPinyin;

    /**
     * 用与检索建议词查询字段 完整汉字拼音首字母 bcsj
     */
    @CompletionField(analyzer = "standard", searchAnalyzer = "standard", maxInputLength = 20)
    private Completion keywordSequence;

}

创建索引库:

package com.atguigu.tingshu.search.repository;

import com.atguigu.tingshu.model.search.SuggestIndex;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

public interface SuggestIndexRepository extends ElasticsearchRepository<SuggestIndex, String> {
}

service-search模块SearchServiceImpl.upperAlbum上架方法中追加内容:

@Autowired
private SuggestIndexRepository suggestIndexRepository;



/**
 * 上架专辑到索引库
 *
 * @param albumId
 */
@Override
public void upperAlbum(Long albumId) {
     
    //5....省略

    //6 TODO 将上架专辑中 专辑标题 专辑作者名称 存入提词索引库
    this.saveSuggestDoc(albumInfoIndex);
}

/**
 * 新增提词库索引文档
 *
 * @param albumInfoIndex
 */
@Override
public void saveSuggestDoc(AlbumInfoIndex albumInfoIndex) {
        //TODO 7.将新增专辑标题作为提词存入提词索引库 用于关键字自动补全
        //7.1 处理专辑标题
        SuggestIndex suggestIndex = new SuggestIndex();
        suggestIndex.setId(albumInfoIndex.getId());
        suggestIndex.setTitle(albumInfoIndex.getAlbumTitle());
        suggestIndex.setKeyword(new Completion(new String[]{albumInfoIndex.getAlbumTitle()}));
        suggestIndex.setKeywordPinyin(new Completion(new String[]{PinyinUtil.getPinyin(albumInfoIndex.getAlbumTitle(), "")}));
        suggestIndex.setKeywordSequence(new Completion(new String[]{PinyinUtil.getFirstLetter(albumInfoIndex.getAlbumTitle(), "")}));

        suggestIndexRepository.save(suggestIndex);

}

5.2.2 关键字自动提示

相关dsl 语句:

GET suggestinfo/_search
{
  "suggest": {
    "keywordSuggest": {
      "prefix": "gd",
      "completion": {
        "field": "keyword",
        "size": 10,
        "skip_duplicates": true
      }
    },
    "sequenceSuggest": {
      "prefix": "gd",
      "completion": {
        "field": "keywordSequence",
        "size": 10,
        "fuzzy": {
          "fuzziness": "2"
        },
        "skip_duplicates": true
      }
    },
    "pinYinSuggest": {
      "prefix": "gd",
      "completion": {
        "field": "keywordPinyin",
        "size": 10,
        "fuzzy": {
          "fuzziness": "2"
        },
        "skip_duplicates": true
      }
    }
  }
}

#如果建议词数据量不够10条,则执行全文查询
GET suggestinfo/_search
{
  "query": {
    "match": {
      "title": "古"
    }
  }
}

YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/65

SearchApiController控制器

/**
 * 根据已填写关键字返回自动补全内容
 * @param keyword
 * @return
 */
@Operation(summary = "根据已填写关键字返回自动补全内容")
@GetMapping("/albumInfo/completeSuggest/{keyword}")
public Result<List<String>> completeSuggest(@PathVariable String keyword){
    List<String> list = searchService.completeSuggest(keyword);
    return Result.ok(list);
}

SearchService接口

/**
 * 根据已填写关键字返回自动补全内容
 *
 * @param keyword
 * @return
 */
List<String> completeSuggest(String keyword);

/**
 * 解析建议响应结果
 * @param searchResponse 检索响应对象
 * @param suggestName 自定义建议名称
 * @return
 */
Collection<String> parseSugggestResult(SearchResponse<SuggestIndex> searchResponse, String suggestName);

SearchServiceImpl实现类:

/**
 * 根据已填写关键字返回自动补全内容
 *
 * @param keyword
 * @return
 */
@Override
public List<String> completeSuggest(String keyword) {
    try {
        //1.创建检索请求对象
        SearchRequest.Builder builder = new SearchRequest.Builder();
        builder.index("suggestinfo");
        builder.suggest(
                s -> s.suggesters("keywordSuggest", f -> f.prefix(keyword).completion(c -> c.field("keyword").skipDuplicates(true).size(10)))
                        .suggesters("pinYinSuggest", f -> f.prefix(keyword).completion(c -> c.field("keywordPinyin").skipDuplicates(true).size(10).fuzzy(fu -> fu.fuzziness("2"))))
                        .suggesters("sequenceSuggest", f -> f.prefix(keyword).completion(c -> c.field("keywordSequence").skipDuplicates(true).size(10).fuzzy(fu -> fu.fuzziness("2"))))
        );
        SearchRequest searchRequest = builder.build();
        System.out.println("本次建议词DSL:");
        System.err.println(searchRequest);
        //2.执行建议词检索
        SearchResponse<SuggestIndex> searchResponse = elasticsearchClient.search(searchRequest, SuggestIndex.class);

        //3.解析ES响应数据,处理建议结果
        //3.1 准备可以自动去重重复提词集合HashSet
        Set<String> hashSet = new HashSet<>();
        hashSet.addAll(this.parseSugggestResult(searchResponse, "keywordSuggest"));
        hashSet.addAll(this.parseSugggestResult(searchResponse, "pinYinSuggest"));
        hashSet.addAll(this.parseSugggestResult(searchResponse, "sequenceSuggest"));
        if (hashSet.size() > 10) {
            return new ArrayList<>(hashSet).subList(0, 10);
        }
        //3.2 判断解析完成后集合长度如果小于10 根据关键词进行全文匹配查询
        if (hashSet.size() < 10) {
            SearchResponse<SuggestIndex> response = elasticsearchClient.search(s -> s.index("suggestinfo").query(q -> q.match(m -> m.field("title").query(keyword))), SuggestIndex.class);
            List<Hit<SuggestIndex>> hits = response.hits().hits();
            if (CollectionUtil.isNotEmpty(hits)) {
                for (Hit<SuggestIndex> hit : hits) {
                    SuggestIndex suggestIndex = hit.source();
                    hashSet.add(suggestIndex.getTitle());
                    if (hashSet.size() >= 10) {
                        break;
                    }
                }
            }
        }
        return new ArrayList<>(hashSet);
    } catch (Exception e) {
        log.error("[搜索服务]关键字自动补全异常:{}", e);
        throw new RuntimeException(e);
    }
}


/**
 * 解析建议响应结果
 *
 * @param searchResponse 检索响应对象
 * @param suggestName    自定义建议名称
 * @return
 */
@Override
public Collection<String> parseSugggestResult(SearchResponse<SuggestIndex> searchResponse, String suggestName) {
    List<String> list = new ArrayList<>();
    //1.获取指定建议结果
    List<Suggestion<SuggestIndex>> suggestionList = searchResponse.suggest().get(suggestName);
    if (CollectionUtil.isNotEmpty(suggestionList)) {
        for (Suggestion<SuggestIndex> suggestIndexSuggestion : suggestionList) {
            List<CompletionSuggestOption<SuggestIndex>> options = suggestIndexSuggestion.completion().options();
            if (CollectionUtil.isNotEmpty(options)) {
                for (CompletionSuggestOption<SuggestIndex> option : options) {
                    SuggestIndex suggestIndex = option.source();
                    list.add(suggestIndex.getTitle());
                }
            }
        }
    }
    return list;
}

6、ELK日志解决方案

​ Logstash是具有实时流水线能力的开源的数据收集引擎。Logstash可以动态统一不同来源的数据,并将数据标准化到您选择的目标输出。它提供了大量插件,可帮助我们解析,丰富,转换和缓冲任何类型的数据。

6.1 Logstash环境问题

  1. 安装Logstash参考md文档

  2. 通过查看Logstash容器日志,401未授权异常 ES8.0后必须有授权许可

image-20231027155525805

  1. 修改宿主机Logstash配置文件添加授权配置信息即可:/mydata/logstash/logstash.conf

    image-20231027155816091

    user => "elastic"
    password => "111111"
    
  2. 重启Logstash容器

6.2 项目中整合

  1. 引入依赖

    <dependency>
       <groupId>net.logstash.logback</groupId>
       <artifactId>logstash-logback-encoder</artifactId>
       <version>5.1</version>
    </dependency>
    
  2. 日志配置文件logback-spring.xml增加,日志Logstash策略

    <!-- logstash日志 -->
    <appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
       <!-- logstash ip和暴露的端口,logback就是通过这个地址把日志发送给logstash -->
       <destination>192.168.200.6:5044</destination>
       <encoder charset="UTF-8" class="net.logstash.logback.encoder.LogstashEncoder" />
    </appender>
       
    <!-- 开发环境 -->
    <springProfile name="dev">
       <!-- com.atguigu日志记录器:业务程序INFO级别  -->
       <logger name="com.atguigu" level="INFO" />
       <!--<logger name="com.alibaba" level="WARN" />-->
       <!-- 根日志记录器:INFO级别  -->
       <root level="INFO">
           <appender-ref ref="CONSOLE" />
           <appender-ref ref="LOGSTASH" />
       </root>
    </springProfile>
    
  3. 启动项目测试Java进程启动会将日志发送到Logstash,Logstash会自动将数据存入ES

    image-20231121163655639