第4章 检索模块.md 99 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远程调用方法即可。

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 {


    //拼接请求地址 baseUrl http://service-album 根据服务名称获取目标服务实例 进行负载均衡

    /**
     * 根据专辑ID查询专辑信息-包含专辑标签列表
     * 最终接口地址:http://service-album/api/album/albumInfo/getAlbumInfo/{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 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 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.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
     * @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 {


    /**
     * 将指定专辑ID,封装为索引库文档对象,存入索引库
     * @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.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.Random;
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;


    /**
     * 将指定专辑ID,封装为索引库文档对象,存入索引库
     *
     * @param albumId
     */
    @Override
    public void upperAlbum(Long albumId) {
        //1.封装专辑索引库文档对象专辑相关信息
        //1.1 远程调用"专辑服务"根据专辑ID查询专辑基本信息
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        Assert.notNull(albumInfo, "专辑:{},信息不存在", albumId);
        //1.2 将专辑基本信息拷贝创建专辑索引库文档对象
        AlbumInfoIndex albumInfoIndex = BeanUtil.copyProperties(albumInfo, AlbumInfoIndex.class);

        //1.3 封装专辑标签列表
        List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
        if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
            List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList
                    .stream()
                    .map(a -> BeanUtil.copyProperties(a, AttributeValueIndex.class))
                    .collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }


        //2.封装专辑索引库文档对象分类信息
        //2.1 远程调用"专辑服务"根据三级分类ID查询分类视图
        BaseCategoryView categoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        Assert.notNull(categoryView, "专辑:{},分类为空", albumId);
        //2.2 封装1,2级分类ID
        albumInfoIndex.setCategory1Id(categoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(categoryView.getCategory2Id());


        //3.封装专辑索引库文档对象统计信息(热度)(随机产生)
        //3.1 随机生成统计数值
        int num1 = RandomUtil.randomInt(0, 3000);
        int num2 = RandomUtil.randomInt(0, 2000);
        int num3 = RandomUtil.randomInt(0, 1000);
        int num4 = RandomUtil.randomInt(0, 500);
        albumInfoIndex.setPlayStatNum(num1);
        albumInfoIndex.setSubscribeStatNum(num2);
        albumInfoIndex.setBuyStatNum(num3);
        albumInfoIndex.setCommentStatNum(num4);

        //3.2 基于随机统计值计算热度 不同行为设置不同权重
        BigDecimal bigDecimal1 = new BigDecimal(num1).multiply(new BigDecimal("0.1"));
        BigDecimal bigDecimal2 = new BigDecimal(num2).multiply(new BigDecimal("0.2"));
        BigDecimal bigDecimal3 = new BigDecimal(num3).multiply(new BigDecimal("0.3"));
        BigDecimal bigDecimal4 = new BigDecimal(num4).multiply(new BigDecimal("0.4"));
        double hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4).doubleValue();
        albumInfoIndex.setHotScore(hotScore);

        //4.封装专辑索引库文档对象主播信息
        //4.1 远程调用"用户服务"根据用户ID查询用户基本信息
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "专辑:{},作者:{}不存在", albumId, albumInfo.getUserId());
        //4.2 封装主播名称
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());

        //5.保存专辑索引库文档到索引库
        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 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-12-13 11:18
    */
    @Configuration
    public class ThreadPoolConfig {
    
    
    /**
     * 自定义线程池对象
     * @return
     */
    @Bean
    public ThreadPoolExecutor threadPoolExecutor(){
        //1.动态得到线程数 IO密集型:CPU逻辑处理器个数*2
        int processorsCount = Runtime.getRuntime().availableProcessors();
        int coreCount = processorsCount * 2;
    
        //2.通过线程池构造创建线程池对象
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                coreCount,
                coreCount,
                0,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(200),
                Executors.defaultThreadFactory(),
                (r, e) -> {
                    //r:被拒绝任务  e:线程池对象
                    //自定义拒绝策略:重试-将任务再次提交给线程执行
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException ex) {
                        throw new RuntimeException(ex);
                    }
                    e.submit(r);
                }
        );
        //3.线程池核心线程第一个任务提交才创建
        threadPoolExecutor.prestartCoreThread();
        return threadPoolExecutor;
    }
    }
    
  2. SearchServiceImpl 使用异步任务+线程池优化

    @Autowired
    private ThreadPoolExecutor threadPoolExecutor;
    
    /**
    * 将指定专辑ID,封装为索引库文档对象,存入索引库
    *
    * @param albumId
    */
    @Override
    public void upperAlbum(Long albumId) {
    AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
    //1.封装专辑索引库文档对象专辑相关信息
    CompletableFuture<AlbumInfo> albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> {
        //1.1 远程调用"专辑服务"根据专辑ID查询专辑基本信息
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        Assert.notNull(albumInfo, "专辑:{},信息不存在", albumId);
        //1.2 将专辑基本信息拷贝创建专辑索引库文档对象
        BeanUtil.copyProperties(albumInfo, albumInfoIndex);
    
        //1.3 封装专辑标签列表
        List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
        if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
            List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList
                    .stream()
                    .map(a -> BeanUtil.copyProperties(a, AttributeValueIndex.class))
                    .collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }
        return albumInfo;
    }, threadPoolExecutor);
    
    
    //2.封装专辑索引库文档对象分类信息
    CompletableFuture<Void> categoryCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
        //2.1 远程调用"专辑服务"根据三级分类ID查询分类视图
        BaseCategoryView categoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        Assert.notNull(categoryView, "专辑:{},分类为空", albumId);
        //2.2 封装1,2级分类ID
        albumInfoIndex.setCategory1Id(categoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(categoryView.getCategory2Id());
    }, threadPoolExecutor);
    
    
    //3.封装专辑索引库文档对象统计信息(热度)(随机产生)
    //3.1 随机生成统计数值
    int num1 = RandomUtil.randomInt(0, 3000);
    int num2 = RandomUtil.randomInt(0, 2000);
    int num3 = RandomUtil.randomInt(0, 1000);
    int num4 = RandomUtil.randomInt(0, 500);
    albumInfoIndex.setPlayStatNum(num1);
    albumInfoIndex.setSubscribeStatNum(num2);
    albumInfoIndex.setBuyStatNum(num3);
    albumInfoIndex.setCommentStatNum(num4);
    
    //3.2 基于随机统计值计算热度 不同行为设置不同权重
    BigDecimal bigDecimal1 = new BigDecimal(num1).multiply(new BigDecimal("0.1"));
    BigDecimal bigDecimal2 = new BigDecimal(num2).multiply(new BigDecimal("0.2"));
    BigDecimal bigDecimal3 = new BigDecimal(num3).multiply(new BigDecimal("0.3"));
    BigDecimal bigDecimal4 = new BigDecimal(num4).multiply(new BigDecimal("0.4"));
    double hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4).doubleValue();
    albumInfoIndex.setHotScore(hotScore);
    
    //4.封装专辑索引库文档对象主播信息
    CompletableFuture<Void> userInfoCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
        //4.1 远程调用"用户服务"根据用户ID查询用户基本信息
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "专辑:{},作者:{}不存在", albumId, albumInfo.getUserId());
        //4.2 封装主播名称
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
    }, threadPoolExecutor);
    
    
    //5.组合所有异步任务,要求必须全部执行完毕
    CompletableFuture.allOf(
            albumInfoCompletableFuture,
            categoryCompletableFuture,
            userInfoCompletableFuture
    ).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
 */
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) {

    //....省略代码
    
     //4.TODO 调用内容审核接口(第三方阿里云)对专辑内容(封面、文字)
    
    //5.审核通过后发送上架专辑消息到Kafka
    if (true && "1".equals(albumInfo.getIsOpen())) {
        kafkaService.sendMessage(KafkaConstant.QUEUE_ALBUM_UPPER, albumId.toString());
    }
}

更新专辑方法添加

/**
 * 修改专辑信息
 *
 * @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: 2024-02-25 15:44
 */
@Slf4j
@Component
public class SearchReciever {


    @Autowired
    private SearchService searchService;

    /**
     * 监听到专辑上架消息
     * 该消费者保存专辑相当于新增或修改,具备幂等性
     *
     * @param consumerRecord
     */
    @KafkaListener(topics = KafkaConstant.QUEUE_ALBUM_UPPER)
    public void albumUpper(ConsumerRecord<String, String> consumerRecord) {
        String value = consumerRecord.value();
        if (StringUtils.isNotBlank(value)) {
            log.info("[搜索服务]监听到专辑{}上架", value);
            searchService.upperAlbum(Long.valueOf(value));
        }
    }



    /**
     * 监听到专辑下架消息
     *  删除专辑也具备幂等性
     *
     * @param consumerRecord
     */
    @KafkaListener(topics = KafkaConstant.QUEUE_ALBUM_LOWER)
    public void albumLower(ConsumerRecord<String, String> consumerRecord) {
        String value = consumerRecord.value();
        if (StringUtils.isNotBlank(value)) {
            log.info("[搜索服务]监听到专辑{}下架", value);
            searchService.lowerAlbum(Long.valueOf(value));
        }
    }
}

3、专辑关键字检索

3.0 DSL分析

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

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

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

# 学习目标:至少能看懂,先拷贝(尝试自己写) 站内专辑检索
/*
  知道每项参数作用,应用场景。
  1.存在提交查询 通过"query"指定查询条件
  2.存在分页,通过"from","size"实现分页
  3.存在排序,通过"sort"进行不同字段排序
  4.关键字查询,对词条进行高亮显示 通过""highlight"进行高亮显示
  5.对返回字段进行指定 通过"_source"指定返回字段
*/
POST albuminfo/_search
{
  "query": {},
  "from": 0,
  "size": 20,
  "sort": [
    {
      "FIELD": {
        "order": "desc"
      }
    }
  ],
  "highlight": {},
  "_source": []
}


#1.模拟分页查询 默认页大小:10条
# 页大小:10
POST albuminfo/_search
{
  "from": 0,
  "size": 10
}

#2.加入排序 模拟:使用热度进行排序
# 页大小:10
POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ]
}


#2.对响应业务数据结果精简
# 页大小:10
POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "_source": ["id", "payType", "coverUrl", "albumTitle", "albumIntro", "includeTrackCount", "playStatNum"]
}


#3.分析基于需求分析得到:查询条件至少三个 1.关键字  2.分类  3.标签 故采用bool查询(封装多个查询条件) 三个大条件都必须同时满足。其中关键字大条件:用户录入关键字可不相同,采用must ;分类与标签,程序限定选择项,适合形成缓存,采用filter进行过滤

#3.1 分析关键字查询:既可以查询标题,也可以查询简介,或者主播名称。在must中封装关键字查询条件,三个子条件是或者关系,同样采用boolean组合三个子条件
POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典"
                }
              },
              {
                "match": {
                  "albumIntro": "古典"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典"
                  }
                }
              }
            ]
          }
        }
      ]
    }
  }
}

#3.2 分析分类过滤 在filter中采用精确查询,分类是系统提供用户选择,适合将检索结果缓存,缓存命中高
POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "_source": [
    "id",
    "payType",
    "coverUrl",
    "albumTitle",
    "albumIntro",
    "includeTrackCount",
    "playStatNum"
  ],
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典"
                }
              },
              {
                "match": {
                  "albumIntro": "古典"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "7"
          }
        },
        {
          "term": {
            "category2Id": "137"
          }
        },
        {
          "term": {
            "category3Id": "1207"
          }
        }
      ]
    }
  }
}


#3.3 分析标签过滤 索引库中标签集合数据类型:Nested 用户选择某个标签在filter中增加Nested查询,一个Nested查询包含两个子条件,同时满足标签id,标签值ID(采用bool包装)
#模拟:用户选择 "有声书分类"-男频小说

POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
   "_source": [
    "id",
    "payType",
    "coverUrl",
    "albumTitle",
    "albumIntro",
    "includeTrackCount",
    "playStatNum"
  ],
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "经典"
                }
              },
              {
                "match": {
                  "albumIntro": "经典"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "经典"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "2"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {"term": {
                    "attributeValueIndexList.attributeId": {
                      "value": "1"
                    }
                  }},
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "1"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}


#模拟:用户选择 "有声书分类"-男频小说 跟 "讲播形式"-双人 一个标签筛选对应封装一个netsted查询

POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
   "_source": [
    "id",
    "payType",
    "coverUrl",
    "albumTitle",
    "albumIntro",
    "includeTrackCount",
    "playStatNum"
  ],
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "经典"
                }
              },
              {
                "match": {
                  "albumIntro": "经典"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "经典"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "2"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "1"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "1"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "2"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "4"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}

#4.只要有关键字检索可以对用户录入关键字 在返回结果中进行高亮展示
POST albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
   "_source": [
    "id",
    "payType",
    "coverUrl",
    "albumTitle",
    "albumIntro",
    "includeTrackCount",
    "playStatNum"
  ],
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "经典"
                }
              },
              {
                "match": {
                  "albumIntro": "经典"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "经典"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "2"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "1"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "1"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "2"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "4"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "highlight": {
    "fields": {"albumTitle": {}},
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  }
}

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 albumIndexQuery
 * @return
 */
@Operation(summary = "站内检索专辑")
@PostMapping("/albumInfo")
public Result<AlbumSearchResponseVo> search(@RequestBody AlbumIndexQuery albumIndexQuery) {
    AlbumSearchResponseVo vo = searchService.search(albumIndexQuery);
    return Result.ok(vo);
}

SearchService接口

/**
 * 站内检索专辑
 * @param albumIndexQuery 查询条件
 * @return
 */
AlbumSearchResponseVo search(AlbumIndexQuery albumIndexQuery);

/**
 * 基于提交请求参数封装检索ES所需检索请求对象
 * @param albumIndexQuery 条件
 * @return 检索请求对象
 */
SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery);

/**
 * 对ES检索返回结果进行解析,封装AlbumSearchResponseVo响应客户端
 *
 * @param searchResponse ES响应结果对象
 * @param albumIndexQuery 检索条件对象
 * @return
 */
AlbumSearchResponseVo parseResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery albumIndexQuery);

SearchServiceImpl实现类

@Autowired
private ElasticsearchClient elasticsearchClient;


private static final String INDEX_NAME = "albuminfo";


/**
 * 站内检索专辑
 *
 * @param albumIndexQuery 查询条件
 * @return
 */
@Override
public AlbumSearchResponseVo search(AlbumIndexQuery albumIndexQuery) {
    try {
        //1.基于入参中条件对象封装检索请求对象SearchReqeust
        SearchRequest searchRequest = this.buildDSL(albumIndexQuery);
        System.err.println("本次检索DSL:");
        System.err.println(searchRequest);
        //2.调用ES执行检索
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(searchRequest, AlbumInfoIndex.class);

        //3.解析ES响应结果封装自定义结果对象AlbumSearchResponseVo
        return this.parseResult(searchResponse);
    } catch (Exception e) {
        log.error("[搜索服务]站内搜索异常:", e);
        throw new RuntimeException(e);
    }
}

封装站内专辑检索DSL请求

private static final String INDEX_NAME = "albuminfo";

/**
 * 基于提交请求参数封装检索ES所需检索请求对象
 *
 * @param albumIndexQuery 条件
 * @return 检索请求对象
 */
@Override
public SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery) {
    //1.创建检索请求构建器Builder对象
    SearchRequest.Builder builder = new SearchRequest.Builder();
    builder.index(INDEX_NAME);
    //2. 在Builder对象中封装相关请求路径参数 "query","from","size","sort","highlight","_source“
    //2.1 设置查询条件 请求体参数"query"部分参数 创建最大条件对象
    BoolQuery.Builder allBoolQueryBuilder = new BoolQuery.Builder();
    String keyword = albumIndexQuery.getKeyword();
    if (StringUtils.isNotBlank(keyword)) {
        //2.1.1 设置大条件一:关键字 must  嵌套一个bool 三个子条件:should 匹配查询标题/简介;精确查询主播名称
        allBoolQueryBuilder.must(m -> m.bool(
                b -> b.should(s -> s.match(m1 -> m1.field("albumTitle").query(keyword)))
                        .should(s -> s.match(m2 -> m2.field("albumIntro").query(keyword)))
                        .should(s -> s.term(t -> t.field("announcerName").value(keyword)))
        ));
    }
    //2.1.2 设置大条件二:分类   filter
    //2.1.2.1 设置1级分类
    Long category1Id = albumIndexQuery.getCategory1Id();
    if (category1Id != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category1Id").value(category1Id)));
    }
    //2.1.2.2 设置2级分类
    Long category2Id = albumIndexQuery.getCategory2Id();
    if (category2Id != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category2Id").value(category2Id)));
    }
    //2.1.2.3 设置3级分类
    Long category3Id = albumIndexQuery.getCategory3Id();
    if (category3Id != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category3Id").value(category3Id)));
    }
    //2.1.3 设置大条件三:标签   filter 前端提交形式:List<String> 字符串=标签id:标签值id
    //关键点:每个标签条件构建对应Nested查询对象放入filter过滤
    List<String> attributeList = albumIndexQuery.getAttributeList();
    if (CollectionUtil.isNotEmpty(attributeList)) {
        //2.1.3.1 遍历标签条件,每遍历一次设置Nested查询
        for (String s : attributeList) {
            String[] split = s.split(":");
            if(split!=null && split.length==2){
                // 动态在Nested查询中设置标签ID,标签值的ID
                allBoolQueryBuilder.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(allBoolQueryBuilder.build()._toQuery());
    //2.2 设置分页 请求体参数"from"-起始位置,"size"
    Integer pageNo = albumIndexQuery.getPageNo();
    Integer pageSize = albumIndexQuery.getPageSize();
    int from = (pageNo - 1) * pageSize;
    builder.from(from).size(pageSize);
    //2.3 满足条件才设置排序 请求体参数中参数"sort" 前后端阅读参数格式:综合排序[1:desc] 播放量[2:desc] 发布时间[3:desc]
    String order = albumIndexQuery.getOrder();
    if (StringUtils.isNotBlank(order)) {
        //2.3.1 先根据冒号进行分隔
        String[] split = order.split(":");
        if (split != null && split.length == 2) {
            //2.3.2 确定排序字段
            String orderFiled = "";
            switch (split[0]) {
                case "1":
                    orderFiled = "hotScore";
                    break;
                case "2":
                    orderFiled = "playStatNum";
                    break;
                case "3":
                    orderFiled = "createTime";
                    break;
            }
            //2.3.3 确定排序方向
            SortOrder sortOrder = "asc".equals(split[1]) ? SortOrder.Asc : SortOrder.Desc;
            String finalOrderFiled = orderFiled;
            //2.3.4 设置动态排序
            builder.sort(s -> s.field(f -> f.field(finalOrderFiled).order(sortOrder)));
        }
    }
    //2.4 设置高亮 请求体参数中"highlight"
    if (StringUtils.isNotBlank(albumIndexQuery.getKeyword())) {
        builder.highlight(h -> h.fields("albumTitle", f -> f.preTags("<font style='color:red'>").postTags("</font>")));
    }
    //2.5 设置字段过滤 请求体参数中"_source"
    builder.source(s -> s.filter(f -> f.excludes(Arrays.asList("isFinished", "category1Id", "category2Id", "category3Id", "hotScore", "attributeValueIndexList.attributeId", "attributeValueIndexList.valueId"))));

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

封装检索结果

/**
 * 对ES检索返回结果进行解析,封装AlbumSearchResponseVo响应客户端
 *
 * @param searchResponse  ES响应结果对象
 * @param albumIndexQuery 检索条件对象
 * @return
 */
@Override
public AlbumSearchResponseVo parseResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery albumIndexQuery) {
    //1.创建响应结果VO对象
    AlbumSearchResponseVo vo = new AlbumSearchResponseVo();
    //2.封装分页信息
    //2.1 解析ES结果获取总记录数
    HitsMetadata<AlbumInfoIndex> hits = searchResponse.hits();
    long total = hits.total().value();
    //2.2 获取入参条件中页大小
    Integer pageSize = albumIndexQuery.getPageSize();
    //2.3 计算总页数
    long totalPages = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
    //2.4 封装分页参数
    vo.setTotal(total);
    vo.setTotalPages(totalPages);
    vo.setPageNo(albumIndexQuery.getPageNo());
    vo.setPageSize(pageSize);
    //3.封装检索到业务数据列表
    List<Hit<AlbumInfoIndex>> hitsList = hits.hits();
    if (CollectionUtil.isNotEmpty(hitsList)) {
        List<AlbumInfoIndexVo> list = hitsList.stream()
                .map(hit -> {
                    //3.1 将检索得到Hit对象转为要求AlbumInfoIndexVo对象
                    AlbumInfoIndexVo albumInfoIndexVo = BeanUtil.copyProperties(hit.source(), AlbumInfoIndexVo.class);
                    //3.2 判断Hit对象是否存在高亮文本片段
                    Map<String, List<String>> highlight = hit.highlight();
                    if (CollectionUtil.isNotEmpty(highlight)) {
                        //3.3 获取高亮文本片段
                        String albumTitleHighlight = highlight.get("albumTitle").get(0);
                        albumInfoIndexVo.setAlbumTitle(albumTitleHighlight);
                    }
                    return albumInfoIndexVo;
                }).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查询该分类下前7个置顶3级分类列表
 * @param category1Id
 * @return
 */
@Operation(summary = "根据1级分类ID查询该分类下前7个置顶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查询该分类下前7个置顶3级分类列表
 * @param category1Id
 * @return
 */
List<BaseCategory3> getTopBaseCategory3(Long category1Id);

BaseCategoryServiceImpl实现类

/**
 * 查询一级分类下置顶7个三级分类列表
 *
 * @param category1Id 1级分类ID
 * @return
 */
@Override
public List<BaseCategory3> getTop7Category3(Long category1Id) {
    //1.根据1级分类ID查询二级分类列表 得到二级分类ID集合
    LambdaQueryWrapper<BaseCategory2> baseCategory2LambdaQueryWrapper = new LambdaQueryWrapper<>();
    baseCategory2LambdaQueryWrapper.eq(BaseCategory2::getCategory1Id, category1Id);
    //索引覆盖,减少回表
    baseCategory2LambdaQueryWrapper.select(BaseCategory2::getId);
    List<BaseCategory2> baseCategory2List = baseCategory2Mapper.selectList(baseCategory2LambdaQueryWrapper);
    if (CollectionUtil.isNotEmpty(baseCategory2List)) {
        List<Long> category2IdList = baseCategory2List
                .stream()
                .map(BaseCategory2::getId)
                .collect(Collectors.toList());
        //2.根据二级分类ID集合查询三级分类表,得到置顶三级分类
        LambdaQueryWrapper<BaseCategory3> baseCategory3LambdaQueryWrapper = new LambdaQueryWrapper<>();
        baseCategory3LambdaQueryWrapper.in(BaseCategory3::getCategory2Id, category2IdList);
        baseCategory3LambdaQueryWrapper.select(BaseCategory3::getId, BaseCategory3::getName);
        baseCategory3LambdaQueryWrapper.eq(BaseCategory3::getIsTop, 1);
        baseCategory3LambdaQueryWrapper.orderByAsc(BaseCategory3::getOrderNum);
        baseCategory3LambdaQueryWrapper.last("limit 7");
        return baseCategory3Mapper.selectList(baseCategory3LambdaQueryWrapper);
    }
    return null;
}

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

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

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

BaseCategoryApiController控制器

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

BaseCategoryService接口:

/**
 * 查询1级分类下所有二级级三级分类
 * @param category1Id 1级分类ID
 * @return {categoryId:1,categoryName:"音乐",categoryChild:[{},{}]}
 */
JSONObject getBaseCategoryListByCategory1Id(Long category1Id);

BaseCategoryServiceImpl实现类:

/**
 * 查询1级分类下所有二级级三级分类
 *
 * @param category1Id 1级分类ID
 * @return {categoryId:1,categoryName:"音乐",categoryChild:[{},{}]}
 */
@Override
public JSONObject getBaseCategoryListByCategory1Id(Long category1Id) {
    //1.处理一级分类
    //1.1 根据1级分类ID查询“一级”分类列表
    LambdaQueryWrapper<BaseCategoryView> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(BaseCategoryView::getCategory1Id, category1Id);
    List<BaseCategoryView> baseCategory1List = baseCategoryViewMapper.selectList(queryWrapper);
    Assert.notNull(baseCategory1List, "分类下为空");
    //1.2 构建一级分类JSON对象 封装1级分类信息
    Long category1Id1 = baseCategory1List.get(0).getCategory1Id();
    String category1Name = baseCategory1List.get(0).getCategory1Name();

    JSONObject jsonObject1 = new JSONObject();
    jsonObject1.put("categoryId", category1Id1);
    jsonObject1.put("categoryName", category1Name);

    //2.处理二级分类
    //2.1 对列表按照2级分类ID进行分组 得到 “二级”分类Map
    Map<Long, List<BaseCategoryView>> map2 = baseCategory1List.stream().collect(Collectors.groupingBy(BaseCategoryView::getCategory2Id));
    //2.2 遍历Map 封装二级分类JSON对象
    List<JSONObject> jsonObject2List = new ArrayList<>();
    for (Map.Entry<Long, List<BaseCategoryView>> entry2 : map2.entrySet()) {
        Long category2Id = entry2.getKey();
        String category2Name = entry2.getValue().get(0).getCategory2Name();
        //2.2.1 创建二级分类JSON对象
        JSONObject jsonObject2 = new JSONObject();
        //2.2.2 封装2级分类ID,名称
        jsonObject2.put("categoryId", category2Id);
        jsonObject2.put("categoryName", category2Name);
        jsonObject2List.add(jsonObject2);
        //3.处理三级分类
        //3.1 遍历"二级分类"列表得到三级分类信息
        List<JSONObject> jsonObject3List = new ArrayList<>();
        for (BaseCategoryView baseCategoryView : entry2.getValue()) {
            //3.1.1创建3级分类JSON对象
            JSONObject jsonObject3 = new JSONObject();
            //3.1.2封装3级分类ID,名称
            jsonObject3.put("categoryId", baseCategoryView.getCategory3Id());
            jsonObject3.put("categoryName", baseCategoryView.getCategory3Name());
            jsonObject3List.add(jsonObject3);
        }
        //3.2 将3级分类JSON对象集合存入2级分类对象"categoryChild"中
        jsonObject2.put("categoryChild", jsonObject3List);

    }
    //2.3 将二级分类JSON对象集合存入1级分类对象"categoryChild"中
    jsonObject1.put("categoryChild", jsonObject2List);
    return jsonObject1;
}

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语句如下:

#需求:检索某个一级分下所有置顶三级分类(7个)下热度最高前6专辑列表

#1.已知得到一级分类下置顶7个三级分类ID 1001,1002,1007,1012,1008.1013,1002,1009 检索出所有三级分类下包含专辑列表
# 选择ES哪种查询,选择多关键字精确查询 terms查询
POST albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1007",
        "1008",
        "1012",
        "1013",
        "1009"
      ]
    }
  }
}
 
#2.对所有置顶三级分类专辑列表进行聚合(分组)按照热度进行降序排列,得到每个分类下热度前6的专辑列表
#2.1 选择聚合方式:terms聚合(将值相同放在一组)
POST albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1007",
        "1008",
        "1012",
        "1013",
        "1009"
      ]
    }
  },
  "aggs": {
    "category3_agg": {
      "terms": {
        "field": "category3Id",
        "size": 20
      }
    }
  }
}

#2.2 在三级分类ID分组后,对这一组内所有专辑进行热度排序,截取前6个专辑
#2.3 分类下热门专辑在聚合结果中,不需要从hits中解析结果,故size设置返回业务数据空即可
POST albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1007",
        "1008",
        "1012",
        "1013",
        "1009"
      ]
    }
  },
  "size": 0,
  "aggs": {
    "category3_agg": {
      "terms": {
        "field": "category3Id",
        "size": 20
      },
      "aggs": {
        "top6": {
          "top_hits": {
            "size": 6,
            "sort": [
              {
                "hotScore": {
                  "order": "desc"
                }
              }
            ]
          }
        }
      }
    }
  }
}

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

/**
 * 查询一级分类下置顶7个三级分类列表
 *
 * @param category1Id 1级分类ID
 * @return
 */
@GetMapping("/category/findTopBaseCategory3/{category1Id}")
public Result<List<BaseCategory3>> getTop7Category3(@PathVariable Long category1Id);

AlbumDegradeFeignClient服务降级类

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

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

/**
 * 查询1级分类下置顶三级分类热门前6专辑
 *
 * @param category1Id
 * @return
 */
@Operation(summary = "查询1级下置顶三级分类热门前6专辑")
@GetMapping("/albumInfo/channel/{category1Id}")
public Result<List<Map<String, Object>>> getTop6AlbumByCategory1(@PathVariable Long category1Id) {
    List<Map<String, Object>> list = searchService.getTop6AlbumByCategory1(category1Id);
    return Result.ok(list);
}

SearchService接口:

/**
 * 查询1级下置顶三级分类热门前6专辑
 *
 * @param category1Id
 * @return
 */
List<Map<String, Object>> getTop6AlbumByCategory1(Long category1Id);

SearchServiceImpl实现类:

/**
 * 查询1级下置顶三级分类热门前6专辑
 *
 * @param category1Id
 * @return
 */
@Override
public List<Map<String, Object>> getTop6AlbumByCategory1(Long category1Id) {
    try {
        //1.远程调用"专辑服务"获取1级分类下置顶7个三级分类列表
        List<BaseCategory3> baseCategory3List = albumFeignClient.getTop7Category3(category1Id).getData();
        Assert.notNull(baseCategory3List, "该分类{}下无三级分类", category1Id);
        //1.1 遍历得到所有三级分类ID集合
        List<Long> baseCategory3IdList = baseCategory3List.stream().map(BaseCategory3::getId).collect(Collectors.toList());
        //1.2 执行多关键字检索需要List<FieldValue> 故 转换类型
        List<FieldValue> fieldValueList = baseCategory3IdList.stream()
                .map(category3Id -> FieldValue.of(category3Id)).collect(Collectors.toList());

        //1.3 后续解析结果方便获取三级分类对象 将三级集合转为Map Key:三级分类ID Value:三级分类对象
        Map<Long, BaseCategory3> baseCategory3Map = baseCategory3List
                .stream()
                .collect(Collectors.toMap(BaseCategory3::getId, baseCategory3-> baseCategory3));

        //2.执行ES检索
        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(
                                        "category3_agg", a -> a.terms(t -> t.field("category3Id").size(20))
                                                .aggregations("top6", a1 -> a1.topHits(t -> t.size(6).sort(sort -> sort.field(f -> f.field("hotScore").order(SortOrder.Desc)))))
                                ),
                        AlbumInfoIndex.class
                );
        //3.解析ES响应结果,封装自定义结果List<Map<String, Object>>代表所有置顶三级分类热门专辑列表
        //3.1 获取ES响应中聚合结果对象,根据请求体参数聚合名称,获取三级分类聚合对象
        Aggregate category3_agg = searchResponse.aggregations().get("category3_agg");
        LongTermsAggregate category3Lterms = category3_agg.lterms();
        //3.2 获取三级分类聚合结果Buckets桶数组 遍历Bucket数组 每遍历一个Bucket处理一个置顶三级分类 Map
        List<LongTermsBucket> category3Buckets = category3Lterms.buckets().array();
        List<Map<String, Object>> list = category3Buckets.stream().map(category3Bucket -> {
            //3.3 获取三级分类ID
            long topCategory3Id = category3Bucket.key();
            //3.4 遍历当前三级分类聚合中子聚合得到热门专辑前6个列表
            Aggregate top6 = category3Bucket.aggregations().get("top6");
            List<AlbumInfoIndex> hotAlbumIndexList = top6.topHits().hits().hits()
                    .stream()
                    .map(hit -> {
                        String albumIndexStr = hit.source().toJson().toString();
                        AlbumInfoIndex hotAlbumInfoIndex = JSON.parseObject(albumIndexStr, AlbumInfoIndex.class);
                        return hotAlbumInfoIndex;
                    }).collect(Collectors.toList());
            //3.5 构建当前置顶三级分类热门专辑Map对象
            Map<String, Object> map = new HashMap<>();
            map.put("baseCategory3", baseCategory3Map.get(topCategory3Id));
            map.put("list", hotAlbumIndexList);
            return map;
        }).collect(Collectors.toList());
        return list;
    } catch (IOException 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. 存储数据

    #关键字自动补全:
    #创建索引-存放用于搜索过的关键词或者初始化导入数据将相关词放入 “提词” 索引库
    #######例如:
    # 字段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": "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.model.search;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.DateFormat;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

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

@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, 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.Keyword)
    private String announcerName;

    //播放量
    @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;

}

创建索引库:

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;

/**
 * 将指定专辑ID,封装为索引库文档对象,存入索引库
 *
 * @param albumId
 */
@Override
public void upperAlbum(Long albumId) {
    AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
    //1.封装专辑索引库文档对象专辑相关信息
    CompletableFuture<AlbumInfo> albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> {
        //1.1 远程调用"专辑服务"根据专辑ID查询专辑基本信息
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        Assert.notNull(albumInfo, "专辑:{},信息不存在", albumId);
        //1.2 将专辑基本信息拷贝创建专辑索引库文档对象
        BeanUtil.copyProperties(albumInfo, albumInfoIndex);

        //1.3 封装专辑标签列表
        List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
        if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
            List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList
                    .stream()
                    .map(a -> BeanUtil.copyProperties(a, AttributeValueIndex.class))
                    .collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }
        return albumInfo;
    }, threadPoolExecutor);


    //2.封装专辑索引库文档对象分类信息
    CompletableFuture<Void> categoryCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
        //2.1 远程调用"专辑服务"根据三级分类ID查询分类视图
        BaseCategoryView categoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        Assert.notNull(categoryView, "专辑:{},分类为空", albumId);
        //2.2 封装1,2级分类ID
        albumInfoIndex.setCategory1Id(categoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(categoryView.getCategory2Id());
    }, threadPoolExecutor);


    //3.封装专辑索引库文档对象统计信息(热度)(随机产生) TODO:后续改为动态查询专辑服务获取专辑统计信息
    //3.1 随机生成统计数值
    int num1 = RandomUtil.randomInt(0, 3000);
    int num2 = RandomUtil.randomInt(0, 2000);
    int num3 = RandomUtil.randomInt(0, 1000);
    int num4 = RandomUtil.randomInt(0, 500);
    albumInfoIndex.setPlayStatNum(num1);
    albumInfoIndex.setSubscribeStatNum(num2);
    albumInfoIndex.setBuyStatNum(num3);
    albumInfoIndex.setCommentStatNum(num4);

    //3.2 基于随机统计值计算热度 不同行为设置不同权重
    BigDecimal bigDecimal1 = new BigDecimal(num1).multiply(new BigDecimal("0.1"));
    BigDecimal bigDecimal2 = new BigDecimal(num2).multiply(new BigDecimal("0.2"));
    BigDecimal bigDecimal3 = new BigDecimal(num3).multiply(new BigDecimal("0.3"));
    BigDecimal bigDecimal4 = new BigDecimal(num4).multiply(new BigDecimal("0.4"));
    double hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4).doubleValue();
    albumInfoIndex.setHotScore(hotScore);

    //4.封装专辑索引库文档对象主播信息
    CompletableFuture<Void> userInfoCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
        //4.1 远程调用"用户服务"根据用户ID查询用户基本信息
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "专辑:{},作者:{}不存在", albumId, albumInfo.getUserId());
        //4.2 封装主播名称
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
    }, threadPoolExecutor);


    //5.组合所有异步任务,要求必须全部执行完毕
    CompletableFuture.allOf(
            albumInfoCompletableFuture,
            categoryCompletableFuture,
            userInfoCompletableFuture
    ).join();


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

    //7.将发布专辑名称存入提词索引库中
    this.saveSuggestDoc(albumInfoIndex);
}

保存提词文档

/**
 * 将专辑标题构建提词索引库文档对象,存入索引库
 *
 * @param albumInfoIndex
 */
@Override
public void saveSuggestDoc(AlbumInfoIndex albumInfoIndex) {
    //1.创建提词索引库文档对象
    SuggestIndex suggestIndex = new SuggestIndex();
    //2.给提词文档中属性赋值
    suggestIndex.setId(albumInfoIndex.getId().toString());
    //2.1 原始内容 用于给用户展示提示词选型
    String albumTitle = albumInfoIndex.getAlbumTitle();
    suggestIndex.setTitle(albumTitle);
    //2.2 建议词字段 汉字
    suggestIndex.setKeyword(new Completion(new String[]{albumTitle}));
    //2.3 建议词字段 汉语拼音
    String pinyin = PinyinUtil.getPinyin(albumTitle, "");
    suggestIndex.setKeywordPinyin(new Completion(new String[]{pinyin}));
    //2.4 建议词字段 汉语拼音首字母
    String firstLetter = PinyinUtil.getFirstLetter(albumTitle, "");
    suggestIndex.setKeywordSequence(new Completion(new String[]{firstLetter}));
    //3.保存提词文档
    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 suggestName 建议词参数名称
 * @param suggestMap ES响应结果
 * @return
 */
Collection<String> parseSuggestResult(String suggestName, Map<String, List<Suggestion<SuggestIndex>>> suggestMap);

SearchServiceImpl实现类:

private static final String SUGGEST_INDEX = "suggestinfo";

/**
 * 根据用户已录入文本进行展示相关提示词列表,实现自动补全
 * 需求:提示下拉框联想词最多展示10个,如果自动补全结果不足10个,尝试采用全文查询进行补全
 *
 * @param keyword
 * @return
 */
@Override
public List<String> completeSuggest(String keyword) {
    try {
        //1.发起自动补全请求-设置建议请求参数
        SearchResponse<SuggestIndex> searchResponse = elasticsearchClient
                .search(
                        s -> s.index(SUGGEST_INDEX_NAME)
                                .suggest(
                                        s1 -> s1.suggesters("letter-suggest", sf -> sf.prefix(keyword).completion(c -> c.field("keywordSequence").size(10).skipDuplicates(true)))
                                                .suggesters("pinyin-suggest", sf -> sf.prefix(keyword).completion(c -> c.field("keywordPinyin").size(10).skipDuplicates(true).fuzzy(f -> f.fuzziness("1"))))
                                                .suggesters("keyword-suggest", sf -> sf.prefix(keyword).completion(c -> c.field("keyword").size(10).skipDuplicates(true)))
                                ),
                        SuggestIndex.class
                );
        //2.解析ES响应建议词结果
        Map<String, List<Suggestion<SuggestIndex>>> suggestMap = searchResponse.suggest();
        //2.1 获取建议结果对象,分别从不同建议词结果中获取提示词列表
        Set<String> suggestResultSet = new HashSet<>();
        //2.2 将解析三个建议词结果提示词存入集合中(去重)
        suggestResultSet.addAll(this.parseSuggestResult("letter-suggest", suggestMap));
        suggestResultSet.addAll(this.parseSuggestResult("pinyin-suggest", suggestMap));suggestResultSet.addAll(this.parseSuggestResult("keyword-suggest", suggestMap));
        //2.3 如果得到建议词结果长度小于10采用全文检索补全
        if (suggestResultSet.size() < 10) {
            //2.3.1 全文检索专辑索引库
            SearchResponse<AlbumInfoIndex> response = elasticsearchClient.search(
                    s -> s.index(INDEX_NAME)
                            .query(q -> q.match(m -> m.query(keyword).field("albumTitle")))
                            .size(10),
                    AlbumInfoIndex.class
            );
            //2.3.2 解析命中索引库中专辑标题
            List<Hit<AlbumInfoIndex>> hits = response.hits().hits();
            if (CollectionUtil.isNotEmpty(hits)) {
                for (Hit<AlbumInfoIndex> hit : hits) {
                    AlbumInfoIndex albumInfoIndex = hit.source();
                    String albumTitle = albumInfoIndex.getAlbumTitle();
                    suggestResultSet.add(albumTitle);
                    if (suggestResultSet.size() >= 10) {
                        break;
                    }
                }
            }
        }
        //2.4 最终提示词列表最多是10个
        List<String> list = new ArrayList<>(suggestResultSet);
        if (list.size() < 10) {
            return list;
        } else {
            return list.subList(0, 10);
        }
    } catch (IOException e) {
        log.error("[搜索服务],关键字自动补全异常:", e);
        throw new RuntimeException(e);
    }
}

/**
 * 解析不用建议词参数对应提词结果
 *
 * @param suggestName 建议词参数名称
 * @param suggestMap  ES响应结果
 * @return
 */
@Override
public Collection<String> parseSuggestResult(String suggestName, Map<String, List<Suggestion<SuggestIndex>>> suggestMap) {
    List<String> list = new ArrayList<>();
    List<Suggestion<SuggestIndex>> suggestionList = suggestMap.get(suggestName);
    if (CollectionUtil.isNotEmpty(suggestionList)) {
        for (Suggestion<SuggestIndex> suggestIndexSuggestion : suggestionList) {
            //获取建议结果中options数组
            List<CompletionSuggestOption<SuggestIndex>> options = suggestIndexSuggestion.completion().options();
            if (CollectionUtil.isNotEmpty(options)) {
                for (CompletionSuggestOption<SuggestIndex> option : options) {
                    SuggestIndex suggestIndex = option.source();
                    //将提词文档中原始中文返回
                    String title = suggestIndex.getTitle();
                    list.add(title);
                }
            }
        }
    }
    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