第4章 检索模块.md 94 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 = "album_info")
@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远程调用方法即可。

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 com.atguigu.tingshu.model.album.BaseCategoryView;
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 {

    //baseUrl=http://service-album = http://192.168.31.21:8501
    // 客户端从Nacos拉取到目标服务可用实例列表进行负载均衡(默认策略轮询)

    /**
     * 根据专辑ID查询专辑信息(包含标签列表)
     *
     * @param 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 com.atguigu.tingshu.model.album.BaseCategoryView;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@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 getCategoryView(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.common.util.ResponseUtil;
    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> getUserInfoVo(@PathVariable Long userId){
    		UserInfoVo userInfoVo = userInfoService.getUserInfo(userId);
    		return Result.ok(userInfoVo);
    	}
       
       
    }
    
  2. 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> getUserInfoVo(@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> getUserInfoVo(Long userId) {
        log.error("[用户服务]提供远程调用getUserInfoVo执行服务降级");
        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 专辑ID
     * @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 专辑ID
     * @return
     */
    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.lang.Assert;
import cn.hutool.core.util.RandomUtil;
import com.atguigu.tingshu.album.AlbumFeignClient;
import com.atguigu.tingshu.common.execption.GuiguException;
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.util.List;
import java.util.stream.Collectors;


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

    @Autowired
    private AlbumInfoIndexRepository albumInfoIndexRepository;

    @Autowired
    private AlbumFeignClient albumFeignClient;

    @Autowired
    private UserFeignClient userFeignClient;

    /**
     * 将指定专辑上架到索引库
     *
     * @param albumId 专辑ID
     * @return
     */
    @Override
    public void upperAlbum(Long albumId) {
        //1.创建索引库文档对象 AlbumInfoIndex
        AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
        //2.封装文档对象中专辑相关信息及标签列表
        //2.1 远程调用专辑服务获取专辑信息及标签列表
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        Assert.notNull(albumInfo, "专辑:{}不存在", albumId);
        //2.2 封装专辑信息到索引库文档对象中
        BeanUtil.copyProperties(albumInfo, albumInfoIndex);
        //2.3 封装专辑标签列表到索引库文档对象中
        List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
        if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
            List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList
                    .stream()
                    .map(albumAttributeValue -> BeanUtil.copyProperties(albumAttributeValue, AttributeValueIndex.class))
                    .collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }

        //3.封装文档对象中分类相关信息
        //3.1 远程调用专辑服务-根据专辑所属3级分类ID查询分类信息
        BaseCategoryView baseCategoryView = albumFeignClient.getCategoryView(albumInfoIndex.getCategory3Id()).getData();
        Assert.notNull(baseCategoryView, "分类:{}不存在", albumInfoIndex.getCategory3Id());
        //3.2 封装分类ID到索引库文档对象中
        albumInfoIndex.setCategory1Id(baseCategoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(baseCategoryView.getCategory2Id());

        //4.封装文档对象中主播相关信息
        //4.1 远程调用用户服务-根据专辑所属用户ID查询主播信息
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "用户:{}信息为空", albumInfo.getUserId());
        //4.2 封装主播名称到索引库文档对象中
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());

        //5.封装文档对象中统计相关信息 TODO 采用随机生成方式
        //5.1 封装播放量数值
        int playStatNum = RandomUtil.randomInt(1000, 2000);
        albumInfoIndex.setPlayStatNum(playStatNum);
        //5.2 封装订阅量数值
        int subscribeStatNum = RandomUtil.randomInt(800, 1000);
        albumInfoIndex.setSubscribeStatNum(subscribeStatNum);
        //5.3 封装购买量数值
        int buyStatNum = RandomUtil.randomInt(100, 500);
        albumInfoIndex.setBuyStatNum(buyStatNum);
        //5.4 封装评论量数值
        int commentStatNum = RandomUtil.randomInt(500, 1000);
        albumInfoIndex.setCommentStatNum(commentStatNum);
        //5.5 基于以上生成统计数值计算出当前文档热度分值  热度=累加(不同统计数值*权重)
        BigDecimal bigDecimal1 = new BigDecimal("0.1").multiply(BigDecimal.valueOf(playStatNum));
        BigDecimal bigDecimal2 = new BigDecimal("0.2").multiply(BigDecimal.valueOf(subscribeStatNum));
        BigDecimal bigDecimal3 = new BigDecimal("0.3").multiply(BigDecimal.valueOf(buyStatNum));
        BigDecimal bigDecimal4 = new BigDecimal("0.4").multiply(BigDecimal.valueOf(commentStatNum));
        BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
        albumInfoIndex.setHotScore(hotScore.doubleValue());

        //6.保存专辑索引库文档对象
        albumInfoIndexRepository.save(albumInfoIndex);
    }
}

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

2.2.2 异步任务+线程池优化

package com.atguigu.tingshu;

import com.atguigu.tingshu.common.constant.SystemConstant;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CompletableFuture;

import static org.junit.jupiter.api.Assertions.*;


@SpringBootTest
class ServiceSearchApplicationTest {


    //public static void main(String[] args) throws InterruptedException {
    //    long timeMillis = System.currentTimeMillis();
    //
    //    System.out.println("--开始处理专辑-远程调用");
    //    Thread.sleep(100);
    //    System.out.println("==结束处理");
    //
    //    System.out.println("--开始处理分类-远程调用");
    //    Thread.sleep(100);
    //    System.out.println("==结束处理");
    //
    //    System.out.println("--开始处理用户-远程调用");
    //    Thread.sleep(100);
    //    System.out.println("==结束处理");
    //
    //    System.out.println("--开始处理统计-远程调用");
    //    Thread.sleep(100);
    //    System.out.println("==结束处理");
    //    long cost = System.currentTimeMillis() - timeMillis;
    //    System.err.println(cost);
    //}


    /**
     * 创建异步任务
     * CompletableFuture内部包含内置线程池forkJoin不使用
     * CompletableFuture.runAsync() 异步任务不依赖其他任务,当前任务不需要返回结果
     * CompletableFuture.supplyAsync() 异步任务不依赖其他任务,当前任务有返回结果
     * <p>
     * 异步任务对象调用方法
     * 其他异步任务对象.thenApplyAsync()  获取被依赖任务执行结果,当前任务有返回结果
     * 其他异步任务对象.thenAcceptAsync()  获取被依赖任务执行结果,当前任务无返回结果
     *
     * 组合异步任务
     *  anyOf().join() 只要任意任务执行完毕主线程继续
     *  allOf().join() 只要所有任务执行完毕主线程继续
     *
     *
     * @param args
     */
    public static void main(String[] args) {
        //1. 创建异步任务对象-任务A 不需要依赖其他任务,需要返回结果给B,C任务使用
        CompletableFuture<String> completableFutureA = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + ",异步任务A执行了");
            return "resultA"; //异步任务A执行结果
        });

        //2. 基于异步任务A创建异步任务B,依赖A任务执行结果,当前任务不需要返回值
        CompletableFuture<Void> completableFutureB = completableFutureA.thenAcceptAsync(aResult -> {
            System.out.println(Thread.currentThread().getName() + ",异步任务B执行了,获取到A的结果:" + aResult);
        });

        //3. 基于异步任务A创建异步任务C
        CompletableFuture<Void> completableFutureC = completableFutureA.thenAcceptAsync(aResult -> {
            System.out.println(Thread.currentThread().getName() + ",异步任务C执行了,获取到A的结果:" + aResult);
        });


        //新增异步任务D 不依赖任何异步任务 不需要有返回值
        CompletableFuture<Void> completableFutureD = CompletableFuture.runAsync(() -> {
            System.out.println(Thread.currentThread().getName() + ",异步任务D执行了");
        });


        //4. 组合异步任务,主线程阻塞等待所有异步任务执行完毕,主线程才继续
        CompletableFuture.allOf(
                completableFutureA,
                completableFutureB,
                completableFutureC,
                completableFutureD
        ).join();


        System.out.println(Thread.currentThread().getName()+"主线程执行");
    }

}
  1. service-util模块中定义线程池对象

    package com.atguigu.tingshu.common.thread;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
    import org.springframework.stereotype.Component;
    
    import java.util.concurrent.*;
    
    /**
    * @author: atguigu
    * @create: 2024-08-09 11:28
    */
    @Slf4j
    @Configuration
    public class ThreadPoolConfig {
    
    /**
     * 基于JDK(JUC)提供线程池Class
     */
    @Bean
    public Executor threadPoolExecutor() {
        //1.获取当前服务器核心数确定核心线程数
        int cpuCoreCount = Runtime.getRuntime().availableProcessors();
        int threadCount = cpuCoreCount * 2;
        //2.通过构造方法创建线程池对象
        ThreadPoolExecutor threadPoolExecutor =
                new ThreadPoolExecutor(
                        threadCount,
                        threadCount,
                        0,
                        TimeUnit.SECONDS,
                        new ArrayBlockingQueue<>(200),
                        Executors.defaultThreadFactory(),
                        new ThreadPoolExecutor.CallerRunsPolicy()
                );
        //3.可选:提交创建核心线程
        threadPoolExecutor.prestartCoreThread();
        return threadPoolExecutor;
    }
    
    
    /**
     * 基于Spring提供线程池Class-threadPoolTaskExecutor 功能更强
     */
    @Bean
    public Executor threadPoolTaskExecutor() {
        int count = Runtime.getRuntime().availableProcessors();
        int threadCount = count*2+1;
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        // 核心池大小
        taskExecutor.setCorePoolSize(threadCount);
        // 最大线程数
        taskExecutor.setMaxPoolSize(threadCount);
        // 队列程度
        taskExecutor.setQueueCapacity(300);
        // 线程空闲时间
        taskExecutor.setKeepAliveSeconds(0);
        // 线程前缀名称
        taskExecutor.setThreadNamePrefix("sync-tingshu-Executor--");
        // 该方法用来设置 线程池关闭 的时候 等待 所有任务都完成后,再继续 销毁 其他的 Bean,
        // 这样这些 异步任务 的 销毁 就会先于 数据库连接池对象 的销毁。
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        // 任务的等待时间 如果超过这个时间还没有销毁就 强制销毁,以确保应用最后能够被关闭,而不是阻塞住。
        taskExecutor.setAwaitTerminationSeconds(300);
        // 线程不够用时由调用的线程处理该任务
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return taskExecutor;
    }
    }
    
  2. SearchServiceImpl 使用异步任务+线程池优化

    @Autowired //先根据类型找Bean结果找到三个Bean 继续根据属性名称executor找对应Bean对象找不到报错;
    //@Qualifier("threadPoolTaskExecutor")
    //@Resource(name = "threadPoolTaskExecutor")
    //private Executor executor;
    private Executor threadPoolTaskExecutor;
    
    /**
    * 将指定专辑上架到索引库
    *
    * @param albumId 专辑ID
    * @return
    */
    @Override
    public void upperAlbum(Long albumId) {
    //1.创建索引库文档对象 AlbumInfoIndex
    AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
    //2.封装文档对象中专辑相关信息及标签列表
    CompletableFuture<AlbumInfo> albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> {
        //2.1 远程调用专辑服务获取专辑信息及标签列表
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        Assert.notNull(albumInfo, "专辑:{}不存在", albumId);
        //2.2 封装专辑信息到索引库文档对象中
        BeanUtil.copyProperties(albumInfo, albumInfoIndex);
        //2.3 封装专辑标签列表到索引库文档对象中
        List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
        if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
            List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList
                    .stream()
                    .map(albumAttributeValue -> BeanUtil.copyProperties(albumAttributeValue, AttributeValueIndex.class))
                    .collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }
        return albumInfo;
    }, threadPoolTaskExecutor);
    
    //3.封装文档对象中分类相关信息
    CompletableFuture<Void> categoryCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
        //3.1 远程调用专辑服务-根据专辑所属3级分类ID查询分类信息
        BaseCategoryView baseCategoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        Assert.notNull(baseCategoryView, "分类:{}不存在", albumInfo.getCategory3Id());
        //3.2 封装分类ID到索引库文档对象中
        albumInfoIndex.setCategory1Id(baseCategoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(baseCategoryView.getCategory2Id());
    }, threadPoolTaskExecutor);
    
    //4.封装文档对象中主播相关信息
    CompletableFuture<Void> announcerCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
        //4.1 远程调用用户服务-根据专辑所属用户ID查询主播信息
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "用户:{}信息为空", albumInfo.getUserId());
        //4.2 封装主播名称到索引库文档对象中
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
    }, threadPoolTaskExecutor);
    
    //5.封装文档对象中统计相关信息 TODO 采用随机生成方式
    CompletableFuture<Void> statCompletableFuture = CompletableFuture.runAsync(() -> {
        //5.1 封装播放量数值
        int playStatNum = RandomUtil.randomInt(1000, 2000);
        albumInfoIndex.setPlayStatNum(playStatNum);
        //5.2 封装订阅量数值
        int subscribeStatNum = RandomUtil.randomInt(800, 1000);
        albumInfoIndex.setSubscribeStatNum(subscribeStatNum);
        //5.3 封装购买量数值
        int buyStatNum = RandomUtil.randomInt(100, 500);
        albumInfoIndex.setBuyStatNum(buyStatNum);
        //5.4 封装评论量数值
        int commentStatNum = RandomUtil.randomInt(500, 1000);
        albumInfoIndex.setCommentStatNum(commentStatNum);
        //5.5 基于以上生成统计数值计算出当前文档热度分值  热度=累加(不同统计数值*权重)
        BigDecimal bigDecimal1 = new BigDecimal("0.1").multiply(BigDecimal.valueOf(playStatNum));
        BigDecimal bigDecimal2 = new BigDecimal("0.2").multiply(BigDecimal.valueOf(subscribeStatNum));
        BigDecimal bigDecimal3 = new BigDecimal("0.3").multiply(BigDecimal.valueOf(buyStatNum));
        BigDecimal bigDecimal4 = new BigDecimal("0.4").multiply(BigDecimal.valueOf(commentStatNum));
        BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
        albumInfoIndex.setHotScore(hotScore.doubleValue());
    }, threadPoolTaskExecutor);
    
    
    //6.组合以上四个异步任务
    CompletableFuture.allOf(
            albumInfoCompletableFuture,
            statCompletableFuture,
            categoryCompletableFuture,
            announcerCompletableFuture
    ).join();
    
    //6.保存专辑索引库文档对象
    albumInfoIndexRepository.save(albumInfoIndex);
    }
    

批量导入

package com.atguigu;

import com.atguigu.tingshu.ServiceSearchApplication;
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;

/**
 * @author: atguigu
 * @create: 2023-12-13 14:17
 */
@SpringBootTest(classes = ServiceSearchApplication.class)
public class BatchImportTest {

    @Autowired
    private SearchService searchService;


    /**
     * 采用for循环导入专辑,不严谨导入,专辑ID如果存在“断层”查询专辑
     */
    @Test
    public void test() {
        for (long i = 0; i < 1608; i++) {
            try {
                searchService.upperAlbum(i);
            } catch (Exception e) {
                continue;
            }
        }
    }
}

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
 * @return
 */
void lowerAlbum(Long albumId);

SearchServiceImpl实现类

/**
 * 将指定专辑下架,从索引库删除文档
 * @param albumId
 * @return
 */
@Override
public void lowerAlbum(Long albumId) {
    albumInfoIndexRepository.deleteById(albumId);
}

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

2.4 专辑自动上下架

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

2.4.1 生产者:专辑服务

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

/**
 * 内容创作者/运营人员保存专辑
 * * TODO 业务校验-验证内容安全
 * * 1.封装专辑相关信息,保存一条记录到专辑信息表
 * * 2.封装专辑标签关系集合,保存若干条记录到专辑标签关系表
 * * 3.封装专辑统计信息,保存4条记录到专辑统计表
 *
 * @param albuminfo 新增专辑信息
 * @param userId    用户ID
 */
@Override
@Transactional(rollbackFor = Exception.class) //默认RuntimeException跟Error回滚事务
public void saveAlbumInfo(AlbumInfo albuminfo, Long userId) {
    //1.封装专辑相关信息,保存一条记录到专辑信息表
    //2.封装专辑标签关系集合,保存若干条记录到专辑标签关系表
    //3.封装专辑统计信息,保存4条记录到专辑统计表
    //4.业务校验 验证专辑内填写相关文本信息是否合法 根据审核结果设置审核状态
    String suggestTitle = vodService.scanText(albuminfo.getAlbumTitle());
    String suggestIntro = vodService.scanText(albuminfo.getAlbumIntro());
    if ("pass".equals(suggestTitle) && "pass".equals(suggestIntro)) {
        //专辑标题内容审核无误修改为审核通过
        albuminfo.setStatus(SystemConstant.ALBUM_STATUS_PASS);
        albumInfoMapper.updateById(albuminfo);
        //5.发送专辑上架MQ消息
        rabbitService.sendMessage(MqConst.EXCHANGE_ALBUM, MqConst.ROUTING_ALBUM_UPPER, albuminfo.getId());
        return;
    }
    //6.发送专辑下架消息
    rabbitService.sendMessage(MqConst.EXCHANGE_ALBUM, MqConst.ROUTING_ALBUM_LOWER, albuminfo.getId());
    throw new GuiguException(500, "专辑标题或内容存在违规!");
}

更新专辑方法添加

/**
 * 修改专辑信息
 *
 * @param albumInfo
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void updateAlbumInfo(AlbumInfo albumInfo) {
    //1.修改专辑相关信息
    //2.可能需要-修改专辑标签关系
    //TODO 对修改后内容进行再次内容审核
    String suggestTitle = vodService.scanText(albumInfo.getAlbumTitle());
    String suggestIntro = vodService.scanText(albumInfo.getAlbumIntro());
    if ("pass".equals(suggestTitle) && "pass".equals(suggestIntro)) {
        //专辑标题内容审核无误修改为审核通过
        albumInfo.setStatus(SystemConstant.ALBUM_STATUS_PASS);
        albumInfoMapper.updateById(albumInfo);
        //4. TODO 发送上架消息
        rabbitService.sendMessage(MqConst.EXCHANGE_ALBUM, MqConst.ROUTING_ALBUM_UPPER, albumInfo.getId());
        return;
    }
    //5.发送下架消息
    rabbitService.sendMessage(MqConst.EXCHANGE_ALBUM, MqConst.ROUTING_ALBUM_LOWER, albumInfo.getId());
    throw new GuiguException(500, "专辑标题或内容存在违规!");
}

删除专辑方法添加

/**
 * 根据专辑ID删除专辑
 * 1.判断该专辑是否关联声音
 * 2.删除专辑记录
 * 3.删除统计记录
 * 4.删除专辑标签记录
 *
 * @param id
 * @return
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void removeAlbumInfo(Long id) {
    //1.判断该专辑是否关联声音-根据专辑ID查询声音表数量进行判断
    //2.删除专辑记录
    //3.删除统计记录
    //4.删除专辑标签记录
    //5.TODO 发送下架消息
    rabbitService.sendMessage(MqConst.EXCHANGE_ALBUM, MqConst.ROUTING_ALBUM_LOWER, id);
}

2.4.2 消费者:搜索服务

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

package com.atguigu.tingshu.search.receiver;

import com.atguigu.tingshu.common.rabbit.constant.MqConst;
import com.atguigu.tingshu.search.service.SearchService;
import com.rabbitmq.client.Channel;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

/**
 * @author: atguigu
 * @create: 2024-08-09 14:14
 */
@Slf4j
@Component
public class SearchReceiver {

    @Autowired
    private SearchService searchService;



    /**
     * @Exchange(autoDelete="true当最后一个绑定到Exchange上的队列删除后,自动删除该Exchange")
     * @Queue(autoDelete="true没有消费者队列就会自动删除")
     *
     * 监听专辑上架队列,完成专辑上架
     * @param albumId 专辑ID
     * @param message
     * @param channel
     */
    @SneakyThrows
    @RabbitListener(bindings = @QueueBinding(
            exchange = @Exchange(value = MqConst.EXCHANGE_ALBUM, durable = "true", autoDelete = "true"),
            value = @Queue(value = MqConst.QUEUE_ALBUM_UPPER, durable = "true", autoDelete = "true"),
            key = MqConst.ROUTING_ALBUM_UPPER
    ))
    public void albumUpper(Long albumId, Message message, Channel channel) {
        if (albumId != null) {
            log.info("[搜索服务]监听到专辑上架消息:{}", albumId);
            searchService.upperAlbum(albumId);
        }
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }

    /**
     *
     * 监听专辑下架队列,完成专辑下架
     * @param albumId 专辑ID
     * @param message
     * @param channel
     */
    @SneakyThrows
    @RabbitListener(bindings = @QueueBinding(
            exchange = @Exchange(value = MqConst.EXCHANGE_ALBUM, durable = "true", autoDelete = "true"),
            value = @Queue(value = MqConst.QUEUE_ALBUM_LOWER, durable = "true", autoDelete = "true"),
            key = MqConst.ROUTING_ALBUM_LOWER
    ))
    public void albumLower(Long albumId, Message message, Channel channel) {
        if (albumId != null) {
            log.info("[搜索服务]监听到专辑下架消息:{}", albumId);
            searchService.lowerAlbum(albumId);
        }
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

3、专辑关键字检索

检索-关键字搜索

3.0 DSL分析

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

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

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

#需求:分页根据“关键字”搜索专辑列表,支持(热度、播放量、发布时间)排序,支持根据分类(1,2,3)筛选;支持根据标签条件进行筛选。结果中文档包含查询关键字高亮显示

#1.存在多个条件筛选请求体参数有query选择bool查询封装多个条件,要进行分页请求体参数:from,size ;对结果关键字高亮显示,请求体参数:highlight;对查询字段指定,请求参数:_source;存在排序,请求体参数:sort
POST album_info/_search
{
  "query": {},
  "from": 0,
  "size": 10,
  "sort": [],
  "_source": [],
  "highlight": {}
}

#2.简单参数设置:分页、排序、高亮
POST album_info/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "highlight": {
    "fields": {"albumTitle": {}},
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  }
}

#3.设置参数设置:查询条件(1、关键字(放入must)  2、分类(放入filter中)  3、标签(放入filter中))
POST album_info/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "highlight": {
    "fields": {
      "albumTitle": {}
    },
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "query": {
    "bool": {
      "must": [
        {
          #设置关键字查询条件
        }
      ],
      "filter": [
        {}, //设置分类过滤
        {}  //设置标签过滤
      ]
    }
  }
}
#3.1 关键字查询条件,包含三个子条件(查询标题or查询简介or查询主播名称) 关键字查询需要按照相关度返回列表,选择must
POST album_info/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "highlight": {
    "fields": {
      "albumTitle": {}
    },
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "albumTitle": "古典音乐"
          }
        }
      ]
    }
  }
}


#3.2 分类查询,可以通过1,2,3进行查询 分类数据由后台决定让用户可以选哪些,当前用户仅选择分类,适合放入缓存(缓存命中高)
POST album_info/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "highlight": {
    "fields": {
      "albumTitle": {}
    },
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "albumTitle": "古典音乐"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "5"
          }
        }
      ]
    }
  }
}
#3.3 标签查询,可以通过N组标签条件查询 标签数据由后台决定让用户可以选哪些,当前用户仅选择标签条件,适合放入缓存(缓存命中高)
POST album_info/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "highlight": {
    "fields": {
      "albumTitle": {}
    },
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "albumTitle": "古典音乐"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "5"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "15"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "32"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "16"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "33"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "17"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "36"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}

#4.设置过滤返回字段列表
POST album_info/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "highlight": {
    "fields": {
      "albumTitle": {}
    },
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "albumTitle": "古典音乐"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "5"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "15"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "32"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "16"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "33"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "17"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "36"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "_source": {
    "excludes": [
      "attributeValueIndexList",
      "hotScore",
      "commentStatNum",
      "buyStatNum",
      "subscribeStatNum",
      "announcerName"
    ]
  }
}

3.1 封装条件及响应对象

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

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

3.2 检索实现

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

SearchApiController控制器

/**
 * 站内条件检索专辑接口
 *
 * @param albumIndexQuery
 * @return
 */
@Operation(summary = "站内条件检索专辑接口")
@PostMapping("/albumInfo")
public Result<AlbumSearchResponseVo> search(@RequestBody AlbumIndexQuery albumIndexQuery) {
    AlbumSearchResponseVo vo = searchService.search(albumIndexQuery);
    return Result.ok(vo);
}

SearchService接口

/**
 * 根据条件检索ElasticSearch专辑索引库
 * @param albumIndexQuery 查询条件
 * @return
 */
AlbumSearchResponseVo search(AlbumIndexQuery albumIndexQuery);


/**
 * 基于检索条件对象封装检索请求对象(封装完整DSL语句)
 * @param albumIndexQuery
 * @return
 */
SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery);

/**
 * 解析ES检索结果
 * @param searchResponse
 * @param albumIndexQuery
 * @return
 */
AlbumSearchResponseVo parseSearchResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery albumIndexQuery);

SearchServiceImpl实现类

@Autowired
private ElasticsearchClient elasticsearchClient;


//专辑索引库名称
private static final String INDE_NAME = "album_info";

/**
 * 整体采用传统写法,局部采用Lambda表达方式
 * 根据条件检索ElasticSearch专辑索引库
 *
 * @param albumIndexQuery 查询条件
 * @return
 */
@Override
public AlbumSearchResponseVo search(AlbumIndexQuery albumIndexQuery) {
    try {
        //1.基于查询条件封装检索请求对象
        SearchRequest searchRequest = this.buildDSL(albumIndexQuery);
        System.err.println("本次检索DSL:");
        System.err.println(searchRequest);
        //2.执行索引库检索
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(searchRequest, AlbumInfoIndex.class);
        //3.解析解析ES检索结果
        return this.parseSearchResult(searchResponse, albumIndexQuery);
    } catch (Exception e) {
        log.error("[检索服务]站内搜索异常:{}", e);
        throw new RuntimeException(e);
    }
}

封装站内专辑检索DSL请求

/**
 * 基于检索条件对象封装检索请求对象(封装完整DSL语句)
 *
 * @param albumIndexQuery
 * @return
 */
@Override
public SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery) {
    //1.创建检索请求构建器对象
    SearchRequest.Builder builder = new SearchRequest.Builder();
    //2.设置DSL中各项请求体参数信息:分页、高亮、排序、条件、过滤
    //2.1 设置请求路径中检索:索引库名称
    builder.index(INDE_NAME);
    //2.2 设置请求体参数中"query"查询条件
    String keyword = albumIndexQuery.getKeyword();
    //2.2.1 创建组合三大查询条件bool查询对象
    BoolQuery.Builder allConditionBoolQueryBuilder = new BoolQuery.Builder();
    //2.2.2 设置查询条件-关键字(全文查询专辑标题)
    if (StringUtils.isNotBlank(keyword)) {
        allConditionBoolQueryBuilder.must(m->m.match(m1 -> m1.field("albumTitle").query(keyword)));
    }
    //2.2.3 设置过滤条件-分类
    Long category1Id = albumIndexQuery.getCategory1Id();
    if (category1Id != null) {
        allConditionBoolQueryBuilder.filter(f -> f.term(t -> t.field("category1Id").value(category1Id)));
    }
    Long category2Id = albumIndexQuery.getCategory2Id();
    if (category2Id != null) {
        allConditionBoolQueryBuilder.filter(f -> f.term(t -> t.field("category2Id").value(category2Id)));
    }
    Long category3Id = albumIndexQuery.getCategory3Id();
    if (category3Id != null) {
        allConditionBoolQueryBuilder.filter(f -> f.term(t -> t.field("category3Id").value(category3Id)));
    }
    //2.2.4 设置过滤条件-标签 可能提交多组标签过滤条件 一组标签条件形式=标签ID:标签值ID
    List<String> attributeList = albumIndexQuery.getAttributeList();
    if (CollectionUtil.isNotEmpty(attributeList)) {
        for (String attribute : attributeList) {
            //2.2.4.1 每循环一次设置一组标签过滤条件 标签ID:标签值ID
            String[] split = attribute.split(":");
            if (split != null && split.length == 2) {
                allConditionBoolQueryBuilder.filter(f -> f.nested(
                        n -> n.path("attributeValueIndexList")
                                .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])))
                                ))
                ));
            }
        }
    }
    builder.query(allConditionBoolQueryBuilder.build()._toQuery());
    //2.3 设置请求体参数中"from,size"分页信息
    int from = (albumIndexQuery.getPageNo() - 1) * albumIndexQuery.getPageSize();
    builder.from(from).size(albumIndexQuery.getPageSize());
    //2.4 设置请求体参数中"sort"排序
    String order = albumIndexQuery.getOrder();
    //2.4.1 获取前端提交排序参数取值 形式=1:desc
    if (StringUtils.isNotBlank(order)) {
        String[] split = order.split(":");
        if (split != null && split.length == 2) {
            //2.4.2 获取排序字段
            String orderField = "";
            switch (split[0]) {
                case "1":
                    orderField = "hotScore";
                    break;
                case "2":
                    orderField = "playStatNum";
                    break;
                case "3":
                    orderField = "createTime";
                    break;
            }
            //2.4.3 获取排序方式
            String orderDes = split[1];
            String finalOrderField = orderField;
            builder.sort(s -> s.field(f -> f.field(finalOrderField).order("asc".equals(orderDes) ? SortOrder.Asc : SortOrder.Desc)));
        }
    }
    //2.5 设置请求体参数中"highlight"高亮
    if (StringUtils.isNotBlank(keyword)) {
        builder.highlight(h -> h.fields("albumTitle", f -> f.preTags("<font style='color:red'>").postTags("</font>")));
    }
    //2.6 设置请求体参数中"_source"指定查询字段
    builder.source(s -> s.filter(f -> f.includes("id", "albumTitle", "albumIntro", "coverUrl", "includeTrackCount", "playStatNum", "createTime", "payType")));

    //3.基于构建器对象返回检索请求对象
    return builder.build();
}

封装检索结果

/**
 * 解析ES检索结果
 *
 * @param searchResponse
 * @param albumIndexQuery
 * @return
 */
@Override
public AlbumSearchResponseVo parseSearchResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery albumIndexQuery) {
    //1.创建响应VO对象
    AlbumSearchResponseVo vo = new AlbumSearchResponseVo();

    //2.封装VO中四个分页相关信息
    Integer pageNo = albumIndexQuery.getPageNo();
    Integer pageSize = albumIndexQuery.getPageSize();
    vo.setPageNo(pageNo);
    vo.setPageSize(pageSize);
    //2.1 解析ES获取命中记录数
    long total = searchResponse.hits().total().value();
    vo.setTotal(total);
    //2.2 根据总记录数及页大小计算总页码
    long totalPages = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
    vo.setTotalPages(totalPages);

    //3.封装Vo中检索到文档数据(记得处理高亮)
    List<Hit<AlbumInfoIndex>> hitList = searchResponse.hits().hits();
    if (CollectionUtil.isNotEmpty(hitList)) {
        //3.1 遍历命中记录对象得到Hit对象中中_source(专辑对象)
        List<AlbumInfoIndexVo> list = hitList.stream()
                .map(hit -> {
                    AlbumInfoIndex albumInfoIndex = hit.source();
                    //3.2 处理高亮
                    Map<String, List<String>> highlightMap = hit.highlight();
                    if (CollectionUtil.isNotEmpty(highlightMap)) {
                        String highlightText = highlightMap.get("albumTitle").get(0);
                        albumInfoIndex.setAlbumTitle(highlightText);
                    }
                    return BeanUtil.copyProperties(albumInfoIndex, AlbumInfoIndexVo.class);
                }).collect(Collectors.toList());
        vo.setList(list);
    }
    //4.响应VO
    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级分类ID查询置顶3级分类列表
 * @param category1Id
 * @return
 */
@Operation(summary = "根据1级分类ID查询置顶3级分类列表")
@GetMapping("/category/findTopBaseCategory3/{category1Id}")
public Result<List<BaseCategory3>> getTopBaseCategory3(@PathVariable Long category1Id){
    List<BaseCategory3> list = baseCategoryService.getTopBaseCategory3(category1Id);
    return Result.ok(list);
}

BaseCategoryService接口:

/**
 * 根据1级分类ID查询置顶3级分类列表
 * @param category1Id
 * @return
 */
List<BaseCategory3> getTopBaseCategory3(Long category1Id);

BaseCategoryServiceImpl实现类

/**
 * 根据1级分类ID查询置顶3级分类列表
 *
 * @param category1Id
 * @return
 */
@Override
public List<BaseCategory3> getTopBaseCategory3(Long category1Id) {
    //1.根据1级分类ID得到二级分类列表(获取二级分类ID列表)
    LambdaQueryWrapper<BaseCategory2> category2LambdaQueryWrapper = new LambdaQueryWrapper<>();
    category2LambdaQueryWrapper.eq(BaseCategory2::getCategory1Id, category1Id);
    category2LambdaQueryWrapper.select(BaseCategory2::getId);
    List<BaseCategory2> baseCategory2List = baseCategory2Mapper.selectList(category2LambdaQueryWrapper);
    if (CollectionUtil.isNotEmpty(baseCategory2List)) {
        //2.根据二级分类ID列表得到置顶三级分类
        //2.1 获取所有2级分类ID
        List<Long> category2IdList = baseCategory2List
                .stream()
                .map(BaseCategory2::getId)
                .collect(Collectors.toList());
        //2.2 构建查询三级分类条件
        LambdaQueryWrapper<BaseCategory3> baseCategory3LambdaQueryWrapper = new LambdaQueryWrapper<>();
        baseCategory3LambdaQueryWrapper.eq(BaseCategory3::getIsTop, 1);
        baseCategory3LambdaQueryWrapper.in(BaseCategory3::getCategory2Id, category2IdList);
        baseCategory3LambdaQueryWrapper.orderByAsc(BaseCategory3::getOrderNum);
        baseCategory3LambdaQueryWrapper.last("LIMIT 7");
        baseCategory3LambdaQueryWrapper.select(BaseCategory3::getId, BaseCategory3::getName, BaseCategory3::getCategory2Id);
        //2.3 查询得到7个置顶三级分类
        List<BaseCategory3> list = baseCategory3Mapper.selectList(baseCategory3LambdaQueryWrapper);
        return list;
    }
    return null;
}

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

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

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

BaseCategoryApiController控制器

/**
 * 查询当前1级分类下包含子分类(二三级分类)
 * @param category1Id
 * @return {"categoryId":1,"categoryName":"音乐",categoryChild:[{"categoryId":101,"categoryName":"音乐音效",categoryChild:[{..}]}]}
 */
@Operation(summary = "查询当前1级分类下包含子分类(二三级分类)")
@GetMapping("/category/getBaseCategoryList/{category1Id}")
public Result<JSONObject> getBaseCategoryListByCategory1Id(@PathVariable Long category1Id){
    JSONObject jsonObject = baseCategoryService.getBaseCategoryListByCategory1Id(category1Id);
    return Result.ok(jsonObject);
}

BaseCategoryService接口:

/**
 * 查询当前1级分类下包含子分类(二三级分类)
 * @param category1Id
 * @return {"categoryId":1,"categoryName":"音乐",categoryChild:[{"categoryId":101,"categoryName":"音乐音效",categoryChild:[{..}]}]}
 */
JSONObject getBaseCategoryListByCategory1Id(Long category1Id);

BaseCategoryServiceImpl实现类:

/**
 * 查询当前1级分类下包含子分类(二三级分类)
 *
 * @param category1Id
 * @return {"categoryId":1,"categoryName":"音乐",categoryChild:[{"categoryId":101,"categoryName":"音乐音效",categoryChild:[{..}]}]}
 */
@Override
public JSONObject getBaseCategoryListByCategory1Id(Long category1Id) {
    //1.根据1级分类ID查询分类视图得到“1级”分类列表  封装1级分类对象
    LambdaQueryWrapper<BaseCategoryView> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(BaseCategoryView::getCategory1Id, category1Id);
    List<BaseCategoryView> category1List = baseCategoryViewMapper.selectList(queryWrapper);
    if (CollectionUtil.isNotEmpty(category1List)) {
        String category1Name = category1List.get(0).getCategory1Name();
        //1.1 封装1级分类对象
        JSONObject jsonObject1 = new JSONObject();
        jsonObject1.put("categoryId", category1Id);
        jsonObject1.put("categoryName", category1Name);
        //2.处理二级分类
        List<JSONObject> jsonObject2List = new ArrayList<>();
        //2.1 对"1级"分类列表进行按2级分类ID分组
        Map<Long, List<BaseCategoryView>> map2 = category1List
                .stream()
                .collect(Collectors.groupingBy(BaseCategoryView::getCategory2Id));
        //2.2 遍历"2级"分类Map
        for (Map.Entry<Long, List<BaseCategoryView>> entry2 : map2.entrySet()) {
            Long category2Id = entry2.getKey();
            String category2Name = entry2.getValue().get(0).getCategory2Name();
            //2.3 封装2级分类JSON对象
            JSONObject jsonObject2 = new JSONObject();
            jsonObject2.put("categoryId", category2Id);
            jsonObject2.put("categoryName", category2Name);
            //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);
            }
            jsonObject2.put("categoryChild", jsonObject3List);
            jsonObject2List.add(jsonObject2);
        }
        //2.4 将2级分类集合存入1级分类categoryChild中
        jsonObject1.put("categoryChild", jsonObject2List);
        return jsonObject1;
    }
    return null;
}

4.3 查询分类下热门专辑

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

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

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

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

image-20230921152112908

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

#需求:查询北方省份(置顶三级分类)中身高最高(热度最高专辑)前6个同学


#1.根据置顶7个三级分类ID检索得到属于7个分类下-->专辑列表
# 例如:以音乐(Id=1)为例得到该分类置顶三级分类
POST album_info/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1007",
        "1012",
        "1013",
        "1008",
        "1002",
        "1003"
      ]
    }
  }
}

#2.对第一步中检索到专辑列表按3级分类ID进行聚合(分组)
POST album_info/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1007",
        "1012",
        "1013",
        "1008",
        "1002",
        "1003"
      ]
    }
  },
  "aggs": {
    "category3_agg": {
      "terms": {
        "field": "category3Id",
        "size": 10
      }
    }
  }
}


#3.在分组后,对组内专辑按照热度进行排序,获取热度前6的专辑
POST album_info/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1007",
        "1012",
        "1013",
        "1008",
        "1002",
        "1003"
      ]
    }
  },
  "aggs": {
    "category3_agg": {
      "terms": {
        "field": "category3Id",
        "size": 10
      },
      "aggs": {
        "top6": {
          "top_hits": {
            "size": 6,
            "sort": [
              {
                "hotScore": {
                  "order": "desc"
                }
              }
            ]
          }
        }
      }
    }
  },
  "size": 0
}

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

/**
 * 根据1级分类ID查询置顶3级分类列表
 * @param category1Id
 * @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

SearchApiController

/**
 * 查询置顶3级分类下热度TOP6专辑列表
 * @param category1Id
 * @return
 */
@Operation(summary = "查询置顶3级分类下热度TOP6专辑列表")
@GetMapping("/albumInfo/channel/{category1Id}")
public Result<List<Map<String, Object>>> searchTopCategoryHotAlbum(@PathVariable Long category1Id){
    List<Map<String, Object>> list = searchService.searchTopCategoryHotAlbum(category1Id);
    return Result.ok(list);
}

SearchService接口:

/**
 * 查询置顶3级分类下热度TOP6专辑列表
 * @param category1Id
 * @return
 */
List<Map<String, Object>> searchTopCategoryHotAlbum(Long category1Id);

SearchServiceImpl实现类:

/**
 * 查询置顶3级分类下热度TOP6专辑列表
 *
 * @param category1Id
 * @return
 */
@Override
public List<Map<String, Object>> searchTopCategoryHotAlbum(Long category1Id) {
    try {
        //1.根据1级分类ID远程调用专辑服务获取6个置顶三级分类 用于检索ES条件,及封装Map中分类对象
        List<BaseCategory3> category3List = albumFeignClient.getTopBaseCategory3(category1Id).getData();
        Assert.notNull(category3List, "未查询到{}置顶分类", category1Id);
        //1.1 检索多关键字精确查询查询条件FiledValue类型 将集合泛型从BaseCategory3转为FiledValue(封装3级分类ID)
        List<FieldValue> fieldValueList = category3List
                .stream()
                .map(category3 -> FieldValue.of(category3.getId()))
                .collect(Collectors.toList());

        //1.2 将三级分类List转为Map<Long, BaseCategory3> Map中key:三级分类ID Value:三级分类对象
        Map<Long, BaseCategory3> category3Map = category3List
                .stream()
                .collect(Collectors.toMap(BaseCategory3::getId, c3 -> c3));


        //2.根据查询条件:7个三级分类ID 聚合:根据三级分类ID聚合(子聚合:按照热度进行排序)检索ES
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(
                s -> s.index(INDE_NAME)
                        .query(q -> q.terms(t -> t.field("category3Id").terms(t1 -> t1.value(fieldValueList))))
                        .aggregations("category3_agg",
                                a -> a.terms(t -> t.field("category3Id").size(10))
                                        .aggregations("top6", a1 -> a1.topHits(t -> t.size(6).sort(
                                                sort -> sort.field(f -> f.field("hotScore").order(SortOrder.Desc))
                                        )))
                        )
                        .size(0),
                AlbumInfoIndex.class);

        //2.解析ES检索结果封装置顶分类热门专辑Map对象
        //2.1 根据分类ID聚合名称从ES检索响应对象获取聚合结果
        LongTermsAggregate category3Agg = searchResponse.aggregations().get("category3_agg").lterms();
        //2.2 遍历三级分类桶(Bucket)数组,每遍历一个Bucket封装置顶分类热门专辑Map对象
        List<LongTermsBucket> bucketList = category3Agg.buckets().array();
        if (CollectionUtil.isNotEmpty(bucketList)) {
            List<Map<String, Object>> list = bucketList
                    .stream()
                    .map(c3Bucket -> {
                        //2.2.1 获取外层聚合结果中key(三级分类ID)
                        long category3Id = c3Bucket.key();
                        //2.2.2 获取当前聚合内包含子聚合(按照组内专辑热度排序)
                        TopHitsAggregate top6Aggregate = c3Bucket.aggregations().get("top6").topHits();
                        //2.2.3 遍历子聚合Bucket得到热度TOP6专辑列表
                        List<Hit<JsonData>> hitsList = top6Aggregate.hits().hits();
                        if (CollectionUtil.isNotEmpty(hitsList)) {
                            List<AlbumInfoIndex> top6List = hitsList.stream().map(hit -> {
                                //获取热门专辑JSON字符串
                                String sourceStr = hit.source().toString();
                                //将JSON转为专辑文档对象
                                AlbumInfoIndex albumInfoIndex = JSON.parseObject(sourceStr, AlbumInfoIndex.class);
                                return albumInfoIndex;
                            }).collect(Collectors.toList());
                            //2.3 封装当前置顶分类热门专辑Map对象
                            Map<String, Object> map = new HashMap<>();
                            map.put("baseCategory3", category3Map.get(category3Id));
                            map.put("list", top6List);
                            return map;
                        }
                        return null;
                    }).collect(Collectors.toList());
            //3.返回检索结果集合对象
            return list;
        }
    } catch (IOException e) {
        log.error("[检索服务]检索置顶分类热门专辑异常:{}", e);
        throw new RuntimeException(e);
    }
    return null;
}

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. 存储数据

    #关键字自动补全:
    #创建索引-存放用于搜索过的关键词或者初始化导入数据将相关词放入 “提词” 索引库
    #######例如:
    # 字段name:原始内容 经典留声机,给用户展示自动补全选项
    # suggestKeyword:经 典 留 声 机
    # suggestPinyin:jingdianliushengji
    # suggestLetter:jdlsj
       
    #可以进行建议自动补全字段
    PUT test
    {
     "mappings": {
       "properties": {
         "name": {
           "type": "keyword"
         },
         "suggestKeyword": {
           "type": "completion"
         },
         "suggestPinyin": {
           "type": "completion"
         },
         "suggestLetter": {
           "type": "completion"
         }
       }
     }
    }
    # 添加数据
    POST test/_doc
    {
     "name": "Pitch Fork",
     "suggestKeyword": ["Pitch", "Fork"]
    }
    POST test/_doc
    {
     "name": "Spading Fork",
     "suggestKeyword": ["Spading", "Fork"]
    }
       
    POST test/_doc
    {
     "name": "Spring",
     "suggestKeyword": ["spring", "Fork"]
    }
    POST test/_doc
    {
     "name": "Fountain",
     "suggestKeyword": ["Fountain"]
    }
       
       
    POST test/_doc
    {
     "name": "经典留声机",
     "suggestKeyword": ["经典留声机"],
     "suggestPinyin":["jingdianliushengji"],
     "suggestLetter":["jdlsj"]
    }
       
    POST test/_doc
    {
     "name": "京东商城",
     "suggestKeyword": ["京东商城"],
     "suggestPinyin":["jingdongshangcheng"],
     "suggestLetter":["jdsc"]
    }
       
    POST test/_doc
    {
     "name": "8分钟,3D环境减压冥想|音乐疗愈",
     "suggestKeyword": ["8分钟,3D环境减压冥想|音乐疗愈"],
     "suggestPinyin":["8fenzhong,3Dhuanjingjieyamingxiang|yinyueliaoyu"],
     "suggestLetter":["8fz,3Dhjjymx|yyly"]
    }
       
       
    # 查询所有数据 会有数据出现
    GET test/_search
    
  2. 测试 suggest

    #模拟用户录入部分关键字内容后,完成自动补全
       
    #单独查询 拼音建议词
    GET /test/_search
    {
     "suggest": {
       "mySuggestPinyin": {
         "prefix": "8fen",
         "completion": {
           "field": "suggestPinyin"
         }
       }
     }
    }
       
    #单独查询 拼音字母建议词
    GET /test/_search
    {
     "suggest": {
       "mySuggestLetter": {
         "prefix": "8fz",
         "completion": {
           "field": "suggestLetter"
         }
       }
     }
    }
       
    #单独查询 汉字建议词
    GET /test/_search
    {
     "suggest": {
       "mySuggestKeyword": {
         "prefix": "经典",
         "completion": {
           "field": "suggestKeyword"
         }
       }
     }
    }
       
    #一个建议中包含多个自定义建议参数,分别查询多个自动补全字段
    GET /test/_search
    {
     "suggest": {
       "mySuggestKeyword": {
         "prefix": "8f",
         "completion": {
           "field": "suggestKeyword",
           "size": 10,
           "skip_duplicates": true
         }
       },
       "mySuggestPinyin": {
         "prefix": "8f",
         "completion": {
           "field": "suggestPinyin",
            "skip_duplicates": true
         }
       },
       "mySuggestLetter": {
         "prefix": "8f",
         "completion": {
           "field": "suggestLetter",
            "skip_duplicates": true
         }
       }
     }
    }
    

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

   {
     "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 提词索引库初始化

注意:在service-search中引入依赖 ,可以选择任意一个引入项目中,如果引入多个,Hutool会按照以上顺序选择第一个使用。

<dependency>
	<groupId>io.github.biezhi</groupId>
	<artifactId>TinyPinyin</artifactId>
	<version>2.0.3.RELEASE</version>
</dependency>
<dependency>
	<groupId>com.belerweb</groupId>
	<artifactId>pinyin4j</artifactId>
	<version>2.5.1</version>
</dependency>
<dependency>
	<groupId>com.github.stuxuhai</groupId>
	<artifactId>jpinyin</artifactId>
	<version>1.1.8</version>
</dependency>

启动项目扫描持久层接口,创建提词索引库:

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上架方法中追加内容:

/**
 * 将指定专辑ID构建索引库文档对象,将文档保存到索引库
 *
 * @param albumId
 */
@Override
public void upperAlbum(Long albumId) {
    //1.封装索引库文档对象..省略

    //2.保存专辑索引库文档
    albumInfoIndexRepository.save(albumInfoIndex);

    //3.将专辑标题存入提词索引库
    this.saveSuggestIndex(albumInfoIndex);
}

保存提词文档

@Autowired
private SuggestIndexRepository suggestIndexRepository;

/**
 * 将专辑标题存入提词索引库
 *
 * @param albumInfoIndex
 */
@Override
public void saveSuggestIndex(AlbumInfoIndex albumInfoIndex) {
    //1.构建索引库文档对象
    SuggestIndex suggestIndex = new SuggestIndex();
    suggestIndex.setId(albumInfoIndex.getId().toString());
    String albumTitle = albumInfoIndex.getAlbumTitle();
    suggestIndex.setTitle(albumTitle);
    suggestIndex.setKeyword(new Completion(new String[]{albumTitle}));
    //1.1 将汉字转为汉语拼音 jing dian liu sheng ji
    String albumTitlePinyin = PinyinUtil.getPinyin(albumTitle, "");
    suggestIndex.setKeywordPinyin(new Completion(new String[]{albumTitlePinyin}));
    //1.1 将汉字转为汉语拼音首字母
    String albumTitleFirstLetter = PinyinUtil.getFirstLetter(albumTitle, "");
    suggestIndex.setKeywordSequence(new Completion(new String[]{albumTitleFirstLetter}));

    //2.存入提词文档记录到提词索引库
    suggestIndexRepository.save(suggestIndex);
}

5.2.2 关键字自动提示

相关dsl 语句:

# 模拟用户录入经典文本:可以汉字(经典) 拼音  拼音首字母
POST suggestinfo/_search
{
  "suggest": {
    "letter-suggest": {
      "prefix": "gdg",
      "completion": {
        "field": "keywordSequence",
        "size":10,
        "skip_duplicates": true
      }
    },
    "pinyin-suggest": {
      "prefix": "gdg",
      "completion": {
        "field": "keywordPinyin",
        "size":10,
        "skip_duplicates": true,
        "fuzzy": {
          "fuzziness": 1
        }
      }
    },
    "keyword-suggest": {
      "prefix": "gdg",
      "completion": {
        "field": "keyword",
         "size":10,
        "skip_duplicates": true
      }
    }
  }
}

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
 */
List<String> parseSuggestResult(SearchResponse<SuggestIndex> searchResponse, String suggestName);

SearchServiceImpl实现类:

private static final String SUGGEST_INDEX = "suggestinfo";

/**
 * 根据用户已录入字符查询提词索引库进行自动补全关键字
 *
 * @param keyword
 * @return
 */
@Override
public List<String> completeSuggest(String keyword) {
    try {
        //1.发起自动补全请求
        SearchResponse<SuggestIndex> searchResponse = elasticsearchClient.search(s ->
                        s.index(SUGGEST_INDEX)
                                .suggest(
                                        s1 -> s1.suggesters("letter-suggest", fs -> fs.prefix(keyword).completion(c -> c.field("keywordSequence").size(10).skipDuplicates(true)))
                                                .suggesters("pinyin-suggest", s2 -> s2.prefix(keyword).completion(c -> c.field("keywordPinyin").size(10).skipDuplicates(true)))
                                                .suggesters("keyword-suggest", s2 -> s2.prefix(keyword).completion(c -> c.field("keyword").size(10).skipDuplicates(true)))
                                )
                , SuggestIndex.class);
        //2.解析ES自动补全结果
        Set<String> hashSet = new HashSet<>();
        //2.1 解析建议结果-通过不同建议参数名获取汉字、拼音等提示词结果
        hashSet.addAll(this.parseSuggestResult(searchResponse, "letter-suggest"));
        hashSet.addAll(this.parseSuggestResult(searchResponse, "pinyin-suggest"));
        hashSet.addAll(this.parseSuggestResult(searchResponse, "keyword-suggest"));
        //2.2 如果解析建议提示词列表长度小于10,采用全文查询尝试补全到10个
        if (hashSet.size() < 10) {
            //2.2.1 根据用户录入字符进行全文检索
            SearchResponse<AlbumInfoIndex> matchSearchResponse = elasticsearchClient.search(
                    s -> s.index(INDE_NAME)
                            .query(q -> q.match(m -> m.field("albumTitle").query(keyword)))
                            .size(10)
                    , AlbumInfoIndex.class
            );
            HitsMetadata<AlbumInfoIndex> hits = matchSearchResponse.hits();
            List<Hit<AlbumInfoIndex>> hitList = hits.hits();
            if (CollectionUtil.isNotEmpty(hitList)) {
                for (Hit<AlbumInfoIndex> hit : hitList) {
                    AlbumInfoIndex source = hit.source();
                    //2.2.2 将检索到专辑标题内容加入到提词结果列表中
                    hashSet.add(source.getAlbumTitle());
                    if (hashSet.size() >= 10) {
                        break;
                    }
                }
            }
        }
        if (hashSet.size() >= 10) {
            //如果提词结果列表中大于10截取前10个
            return new ArrayList<>(hashSet).subList(0, 10);
        } else {
            return new ArrayList<>(hashSet);
        }
    } catch (IOException e) {
        log.error("[搜索服务]关键字自动补全异常:{}", e);
        throw new RuntimeException(e);
    }
}

/**
 * 解析建议词结果
 *
 * @param searchResponse ES检索结果对象
 * @param suggestName    自定义建议词参数名称
 * @return
 */
@Override
public List<String> parseSuggestResult(SearchResponse<SuggestIndex> searchResponse, String suggestName) {
    //根据自定义建议词参数名称获取结果列表
    List<String> list = new ArrayList<>();
    List<Suggestion<SuggestIndex>> suggestionList = searchResponse.suggest().get(suggestName);
    if (CollectionUtil.isNotEmpty(suggestionList)) {
        //遍历得到建议对象
        for (Suggestion<SuggestIndex> suggestIndexSuggestion : suggestionList) {
            for (CompletionSuggestOption<SuggestIndex> option : suggestIndexSuggestion.completion().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. service微服务父工程中引入依赖

    <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