第4章 检索模块.md 98 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 {


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

AlbumDegradeFeignClient服务降级类

package com.atguigu.tingshu.album.impl;


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

@Slf4j
@Component
public class AlbumDegradeFeignClient implements AlbumFeignClient {


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

2.1.2 查询分类信息

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

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

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

BaseCategoryService

/**
 * 根据三级分类ID查询分类信息
 * @param category3Id
 * @return
 */
BaseCategoryView getCategoryView(Long category3Id);

BaseCategoryServiceImpl

/**
 * 根据三级分ID(专辑视图主键)查询专辑信息
 *
 * @param category3Id
 * @return
 */
@Override
public BaseCategoryView 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 {


    /**
     * 将指定专辑封装专辑索引库文档对象,完成文档信息
     * @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.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 AlbumFeignClient albumFeignClient;

    @Autowired
    private UserFeignClient userFeignClient;

    @Autowired
    private AlbumInfoIndexRepository albumInfoIndexRepository;


    /**
     * 将指定专辑封装专辑索引库文档对象,完成文档信息
     *
     * @param albumId
     */
    @Override
    public void upperAlbum(Long albumId) {
        //1.远程调用专辑服务,获取专辑信息(包含专辑标签列表),属性拷贝到索引库文档对象
        //1.1 远程调用专辑服务
        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(albumAttributeValue -> BeanUtil.copyProperties(albumAttributeValue, AttributeValueIndex.class))
                    .collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }

        //2.远程调用专辑服务,获取分类信息,封装三级分类ID
        BaseCategoryView categoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        Assert.notNull(categoryView, "分类{}信息为空", albumInfo.getCategory3Id());
        albumInfoIndex.setCategory1Id(categoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(categoryView.getCategory2Id());

        //3.远程调用用户服务,获取主播信息,封装主播名称
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "主播{}信息为空", albumInfo.getUserId());
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());

        //4.TODO 为了方便进行检索测试,随机产生专辑统计数值 封装专辑统计信息
        //4.1 随机产生四个数值作为统计值
        int num1 = RandomUtil.randomInt(500, 2000);
        int num2 = RandomUtil.randomInt(500, 1500);
        int num3 = RandomUtil.randomInt(500, 1000);
        int num4 = RandomUtil.randomInt(500, 1000);
        albumInfoIndex.setPlayStatNum(num1);
        albumInfoIndex.setSubscribeStatNum(num2);
        albumInfoIndex.setBuyStatNum(num3);
        albumInfoIndex.setCommentStatNum(num4);

        //4.2 基于统计值计算当前文档热度 统计量*动态权重
        BigDecimal bigDecimal1 = new BigDecimal("0.1").multiply(new BigDecimal(num1));
        BigDecimal bigDecimal2 = new BigDecimal("0.2").multiply(new BigDecimal(num2));
        BigDecimal bigDecimal3 = new BigDecimal("0.3").multiply(new BigDecimal(num3));
        BigDecimal bigDecimal4 = new BigDecimal("0.4").multiply(new BigDecimal(num4));
        BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
        albumInfoIndex.setHotScore(hotScore.doubleValue());

        //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. 使用异步任务+线程池优化

    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.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.concurrent.CompletableFuture;
    import java.util.concurrent.ThreadPoolExecutor;
    import java.util.stream.Collectors;
    
    
    @Slf4j
    @Service
    @SuppressWarnings({"all"})
    public class SearchServiceImpl implements SearchService {
    
    @Autowired
    private AlbumFeignClient albumFeignClient;
    
    @Autowired
    private UserFeignClient userFeignClient;
    
    @Autowired
    private AlbumInfoIndexRepository albumInfoIndexRepository;
    
    @Autowired
    private ThreadPoolExecutor threadPoolExecutor;
    
    
    /**
     * 将指定专辑封装专辑索引库文档对象,完成文档信息
     *
     * @param albumId
     */
    @Override
    public void upperAlbum(Long albumId) {
        AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
        //1.远程调用专辑服务,获取专辑信息(包含专辑标签列表),属性拷贝到索引库文档对象
        //1.1 远程调用专辑服务-专辑信息  分析:该任务不需要获取其他任务结果,当前任务需要结果给其他任务调用
        CompletableFuture<AlbumInfo> albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> {
            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(albumAttributeValue -> BeanUtil.copyProperties(albumAttributeValue, AttributeValueIndex.class))
                        .collect(Collectors.toList());
                albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
            }
            return albumInfo;
        }, threadPoolExecutor);
    
        //2.远程调用专辑服务,获取分类信息,封装三级分类ID 分析:该任务需要依赖专辑信息异步任务结果,当前任务无返回结果
        CompletableFuture<Void> categoryCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
            BaseCategoryView categoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
            Assert.notNull(categoryView, "分类{}信息为空", albumInfo.getCategory3Id());
            albumInfoIndex.setCategory1Id(categoryView.getCategory1Id());
            albumInfoIndex.setCategory2Id(categoryView.getCategory2Id());
        }, threadPoolExecutor);
    
        //3.远程调用用户服务,获取主播信息,封装主播名称 分析:该任务需要依赖专辑信息异步任务结果,当前任务无返回结果
        CompletableFuture<Void> userInfoCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
            UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
            Assert.notNull(userInfoVo, "主播{}信息为空", albumInfo.getUserId());
            albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
        }, threadPoolExecutor);
    
    
        //4.TODO 为了方便进行检索测试,随机产生专辑统计数值 封装专辑统计信息 将来改为远程调用
        CompletableFuture<Void> statCompletableFuture = CompletableFuture.runAsync(() -> {
            //4.1 随机产生四个数值作为统计值
            int num1 = RandomUtil.randomInt(500, 2000);
            int num2 = RandomUtil.randomInt(500, 1500);
            int num3 = RandomUtil.randomInt(500, 1000);
            int num4 = RandomUtil.randomInt(500, 1000);
            albumInfoIndex.setPlayStatNum(num1);
            albumInfoIndex.setSubscribeStatNum(num2);
            albumInfoIndex.setBuyStatNum(num3);
            albumInfoIndex.setCommentStatNum(num4);
    
            //4.2 基于统计值计算当前文档热度 统计量*动态权重
            BigDecimal bigDecimal1 = new BigDecimal("0.1").multiply(new BigDecimal(num1));
            BigDecimal bigDecimal2 = new BigDecimal("0.2").multiply(new BigDecimal(num2));
            BigDecimal bigDecimal3 = new BigDecimal("0.3").multiply(new BigDecimal(num3));
            BigDecimal bigDecimal4 = new BigDecimal("0.4").multiply(new BigDecimal(num4));
            BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
            albumInfoIndex.setHotScore(hotScore.doubleValue());
        }, threadPoolExecutor);
    
    
        //组合四个异步任务
        CompletableFuture.allOf(
                albumInfoCompletableFuture,
                statCompletableFuture,
                categoryCompletableFuture,
                userInfoCompletableFuture
        ).join();
    
        //5.调用文档持久层接口保存专辑
        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条记录
  • 高亮:关键字高亮显示
  • 字段指定:指定查询响应业务字段

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

学习目标:至少能看懂,先拷贝(尝试自己写)
#站内专辑检索

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

- 提交条件:关键字,通过专辑标签/标签值,通过专辑分类
- 排序:支持根据专辑热门值排序;播放量;发布时间;
- 分页:每页显示10条记录
- 高亮:关键字高亮显示
- 字段指定:指定查询响应业务字段
**/
#模拟用户通过关键字进行检索 需求:关键字可以在标题中包含,简介中包含,是主播名称
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ]
    }
  }
}


#上面基础上模拟用户选择分类
# 分类,标签条件由系统提供让用户选择,如果用户没有写关键字情况下,如果直接选择分类,标签条件形成缓存
#分类条件:一级分类ID 12  人文国学
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        }
      ]
    }
  }
}



#上面基础上模拟用户根据标签条件进行刷选
# 分析:标签集合采用Nested数据类型 固采用Nested查询,一个Nested查询中包含两个条件:标签ID查询,标签值ID 两个条件是must
# 将Nested查询放在filter中提高查询效率
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "18"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "37"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "19"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "40"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}


#在上面基础上加入排序
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "18"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "37"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "19"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "40"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ]
}

#在上面基础上加入分页
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "18"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "37"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "19"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "40"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "from": 0,
  "size": 10
}



#在上面基础上加入高亮
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "18"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "37"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "19"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "40"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "from": 0,
  "size": 10,
  "highlight": {
    "fields": {"albumTitle": {}, "albumIntro": {}},
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  }
}


#在上面基础上选择查询返回字段列表
POST albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
              {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
          "term": {
            "category1Id": "12"
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "18"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "37"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "19"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "40"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "from": 0,
  "size": 10,
  "highlight": {
    "fields": {"albumTitle": {}, "albumIntro": {}},
    "pre_tags": "<font style='color:red'>",
    "post_tags": "</font>"
  },
  "_source": ["id", "albumTitle", "albumIntro", "coverUrl", "payType", "includeTrackCount", "playStatNum"]
}

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);

/**
 * 基于入参查询条件构建完整检索请求对象
 * @param albumIndexQuery 查询条件
 * @return 检索请求对象
 */
SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery);

/**
 * 解析ES响应结果,将ES响应JSON转为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 {
        //一、创建检索请求对象
        SearchRequest searchRequest = this.buildDSL(albumIndexQuery);
        System.err.println("本次检索DSL:");
        System.err.println(searchRequest.toString());
        //二、调用执行检索
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(searchRequest, AlbumInfoIndex.class);
        //三、解析ES响应结果
        return this.parseResult(searchResponse);
    } catch (IOException e) {
        log.error("[搜索服务]站内检索异常:{}", e);
        throw new RuntimeException(e);
    }
}

封装站内专辑检索DSL请求

private static final String INDEX_NAME = "albuminfo";

/**
 * 基于入参查询条件构建完整检索请求对象
 *
 * @param albumIndexQuery 查询条件
 * @return 检索请求对象
 */
@Override
public SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery) {
    //1.创建检索请求构建器对象-封装检索索引库
    SearchRequest.Builder builder = new SearchRequest.Builder();
    builder.index(INDEX_NAME);
    //2.设置查询条件对应请求体参数中"query" 封装关键字、分类过滤、标签过滤
    String keyword = albumIndexQuery.getKeyword();
    //2.1 创建最外层组合多条件查询对象-封装所有查询条件
    BoolQuery.Builder allBoolQueryBuilder = new BoolQuery.Builder();
    //2.2 处理关键字检索 关键字全文检索专辑标题,简介。等值精确查询作者名称
    if (StringUtils.isNotBlank(keyword)) {
        allBoolQueryBuilder.must(
                m -> m.bool(
                        b -> b.should(s -> s.match(ma -> ma.field("albumTitle").query(keyword)))
                                .should(s -> s.match(ma -> ma.field("albumIntro").query(keyword)))
                                .should(s -> s.term(t -> t.field("announcerName").value(keyword)))
                )
        );
    }
    //2.3 处理分类过滤
    //2.3.1 判断1级分类是否提交,设置1级分类过滤
    if (albumIndexQuery.getCategory1Id() != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category1Id").value(albumIndexQuery.getCategory1Id())));
    }
    //2.3.2 判断2级分类是否提交,设置2级分类过滤
    if (albumIndexQuery.getCategory2Id() != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category2Id").value(albumIndexQuery.getCategory2Id())));
    }
    //2.3.3 判断3级分类是否提交,设置3级分类过滤
    if (albumIndexQuery.getCategory3Id() != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category3Id").value(albumIndexQuery.getCategory3Id())));
    }
    //2.4 处理标签过滤
    List<String> attributeList = albumIndexQuery.getAttributeList();
    if (CollectionUtil.isNotEmpty(attributeList)) {
        //2.4.1 前端可能提交多个标签过滤条件 提交标签形式  标签id:标签值Id
        for (String s : attributeList) {
            //每循环一次,封装标签Nested查询
            String[] split = s.split(":");
            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());
    //3.设置分页对应请求体参数中"from" "size" 封装起始位置,页大小
    Integer pageSize = albumIndexQuery.getPageSize();
    int from = (albumIndexQuery.getPageNo() - 1) * pageSize;
    builder.from(from);
    builder.size(pageSize);
    //4.设置排序对应请求体参数中"sort" 前段提交参数=排序字段:排序方式
    String order = albumIndexQuery.getOrder();
    if (StringUtils.isNotBlank(order)) {
        //4.1 对排序字符按照:进行切割
        String[] split = order.split(":");
        //4.2 得到排序字段及排序方式 综合排序[1:desc] 播放量[2:desc] 发布时间[3:desc]
        String orderFiled = "";
        switch (split[0]) {
            case "1":
                orderFiled = "hotScore";
                break;
            case "2":
                orderFiled = "playStatNum";
                break;
            case "3":
                orderFiled = "createTime";
                break;
        }
        String finalOrderFiled = orderFiled;
        builder.sort(s -> s.field(f -> f.field(finalOrderFiled).order("asc".equals(split[1]) ? SortOrder.Asc : SortOrder.Desc)));
    }
    //5.设置高亮显示对应请求体参数中"highlight"
    if (StringUtils.isNotBlank(keyword)) {
        builder.highlight(h -> h.fields("albumTitle", f -> f.preTags("<font style='color:red'>").postTags("</font>")));
    }
    //6.设置查询及返回字段列表
    builder.source(s -> s.filter(f -> f.excludes("category1Id", "category2Id", "category3Id", "hotScore", "attributeValueIndexList.attributeId", "attributeValueIndexList.valueId")));
    //7.基于构建器对象得到检索请求对象
    return builder.build();
}

封装检索结果

/**
 * 解析ES响应结果,将ES响应JSON转为AlbumSearchResponseVo对象
 *
 * @param searchResponse  ES响应结果
 * @param albumIndexQuery
 * @return
 */
@Override
public AlbumSearchResponseVo parseResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery albumIndexQuery) {
    AlbumSearchResponseVo vo = new AlbumSearchResponseVo();
    //1.封装分页相关信息 页码、页大小、总记录数、总页数
    //1.1 从入参中获取页码、页大小
    vo.setPageNo(albumIndexQuery.getPageNo());
    Integer pageSize = albumIndexQuery.getPageSize();
    vo.setPageSize(pageSize);
    //1.2 从ES检索结果中获取命中记录数
    long total = searchResponse.hits().total().value();
    //1.3 计算总页数
    long totalPages = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
    vo.setTotalPages(totalPages);

    //2.封装检索到专辑列表数据(处理高亮)
    //2.1 获取命中hits集合,遍历得到Hit对象中source(AlbumInfoIndex) 转为 AlbumInfoIndexVo
    List<Hit<AlbumInfoIndex>> hits = searchResponse.hits().hits();
    if (CollectionUtil.isNotEmpty(hits)) {
        List<AlbumInfoIndexVo> albumInfoIndexVoList = hits.stream().map(hit -> {
            //2.1.1 将得到AlbumInfoIndex类型转为AlbumInfoIndexVo
            AlbumInfoIndex albumInfoIndex = hit.source();
            AlbumInfoIndexVo albumInfoIndexVo = BeanUtil.copyProperties(albumInfoIndex, AlbumInfoIndexVo.class);
            //2.1.2 处理高亮片段
            Map<String, List<String>> highlightMap = hit.highlight();
            if (CollectionUtil.isNotEmpty(highlightMap)) {
                String albumTitleHighlight = highlightMap.get("albumTitle").get(0);
                albumInfoIndexVo.setAlbumTitle(albumTitleHighlight);
            }
            return albumInfoIndexVo;
        }).collect(Collectors.toList());
        vo.setList(albumInfoIndexVoList);
    }
    return vo;
}

4、热门专辑检索

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

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

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

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

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

/**
 * 根据1级分类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实现类

/**
 * 根据1级分类ID查询该分类下前7个置顶3级分类列表
 *
 * @param category1Id
 * @return
 */
@Override
public List<BaseCategory3> getTopBaseCategory3(Long category1Id) {
    //1.根据1级分类ID获取二级分类列表
    LambdaQueryWrapper<BaseCategory2> category2LambdaQueryWrapper = new LambdaQueryWrapper<>();
    category2LambdaQueryWrapper.eq(BaseCategory2::getCategory1Id, category1Id);
    category2LambdaQueryWrapper.select(BaseCategory2::getId);
    List<BaseCategory2> baseCategory2List = baseCategory2Mapper.selectList(category2LambdaQueryWrapper);
    //2.获取二级分类ID列表
    if (CollectionUtil.isNotEmpty(baseCategory2List)) {
        List<Long> baseCategory2IdList = baseCategory2List.stream().map(BaseCategory2::getId).collect(Collectors.toList());
        //3.根据二级分类ID列表查询三级分类
        LambdaQueryWrapper<BaseCategory3> queryWrapper = new LambdaQueryWrapper<>();
        //3.1 where category2_id in (101, 102, 103)
        queryWrapper.in(BaseCategory3::getCategory2Id, baseCategory2IdList);
        //3.2 置顶3级分类
        queryWrapper.eq(BaseCategory3::getIsTop, 1);
        //3.3 排序
        queryWrapper.orderByAsc(BaseCategory3::getOrderNum);
        //3.4 top7
        queryWrapper.last("limit 7");
        return baseCategory3Mapper.selectList(queryWrapper);
    }
    return null;
}

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

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

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

BaseCategoryApiController控制器

/**
 * 根据1级分类ID查询包含二级分类以及三级分类
 * @param category1Id
 * @return
 */
@Operation(summary = "根据1级分类ID查询包含二级分类以及三级分类")
@GetMapping("/category/getBaseCategoryList/{category1Id}")
public Result<JSONObject> getBaseCategoryListByCategory1Id(@PathVariable Long category1Id){
    JSONObject jsonObject = baseCategoryService.getBaseCategoryListByCategory1Id(category1Id);
    return Result.ok(jsonObject);
}

BaseCategoryService接口:

/**
 * 根据1级分类ID查询包含二级分类以及三级分类
 * @param category1Id
 * @return
 */
JSONObject getBaseCategoryListByCategory1Id(Long category1Id);

BaseCategoryServiceImpl实现类:

/**
 * 根据1级分类ID查询包含二级分类以及三级分类
 *
 * @param category1Id
 * @return
 */
@Override
public JSONObject getBaseCategoryListByCategory1Id(Long category1Id) {
    //1.封装一级分类对象
    LambdaQueryWrapper<BaseCategoryView> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(BaseCategoryView::getCategory1Id, category1Id);
    List<BaseCategoryView> category1List = baseCategoryViewMapper.selectList(queryWrapper);
    BaseCategoryView baseCategoryView = category1List.get(0);
    //1.1 封装一级分类对象
    JSONObject jsonObject1 = new JSONObject();
    jsonObject1.put("categoryId", baseCategoryView.getCategory1Id());
    jsonObject1.put("categoryName", baseCategoryView.getCategory1Name());
    //2.处理一级分类下包含二级分类列表
    //2.1 对“一级分类集合”中二级分类ID进行分组
    Map<Long, List<BaseCategoryView>> category2Map =
            category1List.stream().collect(Collectors.groupingBy(BaseCategoryView::getCategory2Id));
    if (CollectionUtil.isNotEmpty(category2Map)) {
        List<JSONObject> jsonObjects2List = new ArrayList<>();
        for (Map.Entry<Long, List<BaseCategoryView>> entry2 : category2Map.entrySet()) {
            //2.2 封装二级分类JSON对象
            Long category2Id = entry2.getKey();
            String category2Name = entry2.getValue().get(0).getCategory2Name();
            JSONObject jsonObject2 = new JSONObject();
            jsonObject2.put("categoryId", category2Id);
            jsonObject2.put("categoryName", category2Name);
            jsonObjects2List.add(jsonObject2);
            //3.处理二级分类下包含三级分类列表
            List<JSONObject> jsonObjects3List = new ArrayList<>();
            for (BaseCategoryView categoryView : entry2.getValue()) {
                //3.1 构建三级分类JSON对象
                JSONObject jsonObject3 = new JSONObject();
                jsonObject3.put("categoryId", categoryView.getCategory3Id());
                jsonObject3.put("categoryName", categoryView.getCategory3Name());
                jsonObjects3List.add(jsonObject3);
            }
            //3.2 将三级分类JSON对象集合将入二级分类对象"categoryChild"属性中
            jsonObject2.put("categoryChild", jsonObjects3List);
        }
        //2.3 将二级分类JSON对象集合加入到一级分类对象"categoryChild"属性中
        jsonObject1.put("categoryChild", jsonObjects2List);
    }
    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语句如下:

#置顶分类热门专辑

# 需求:查询某个分类下七个置顶3级分类热门专辑(热度较高)


# 第一步:根据七个置顶3级分类ID查询到专辑列表(所有置顶三级分类下包含的)
## 查询“音乐”一级分类下置顶7个三级分类 实现多关键字(7个分类)精确查询(3级分类ID)
POST albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1007",
        "1012",
        "1008",
        "1013",
        "1002",
        "1009"
      ]
    }
  }
}

#第二步 根据3级分类ID,对专辑列表进行分组(聚合)
POST albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1007",
        "1012",
        "1008",
        "1013",
        "1002",
        "1009"
      ]
    }
  },
  "aggs": {
    "category3Agg": {
      "terms": {
        "field": "category3Id",
        "size": 10
      }
    }
  }
}

#第三步:在三级分类ID集合内部,增加子聚合(按照专辑中热度进行排序)获取前6条记录
##使用子聚合top_hits 聚合后,每一个聚合Bucket里面仅返回指定顺序的前N条数据

POST albuminfo/_search
{
  "size": 0, 
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1007",
        "1012",
        "1008",
        "1013",
        "1002",
        "1009"
      ]
    }
  },
  "aggs": {
    "category3Agg": {
      "terms": {
        "field": "category3Id",
        "size": 10
      },
      "aggs": {
        "top6": {
          "top_hits": {
            "sort": [
              {
                "hotScore": {
                  "order": "desc"
                }
              }
            ],
            "size": 6
          }
        }
      }
    }
  }
}

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

/**
 * 根据1级分类ID查询该分类下前7个置顶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

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

SearchService接口:

/**
 * 查询指定1级分类下置顶7个三级分类下热门专辑前6
 * @param category1Id 一级分类ID
 * @return [{"baseCategory3":"分类信息","list":[{热门专辑}]}]
 */
List<Map<String, Object>> getTopCategoryAlbumList(Long category1Id);

SearchServiceImpl实现类:

/**
 * 查询指定1级分类下置顶7个三级分类下热门专辑前6
 *
 * @param category1Id 一级分类ID
 * @return [{"baseCategory3":"分类信息","list":[{热门专辑}]}]
 */
@Override
public List<Map<String, Object>> getTopCategoryAlbumList(Long category1Id) {
    try {
        //1.远程调用专辑服务获取置顶三级分类列表(7个)
        List<BaseCategory3> baseCategory3List = albumFeignClient.getTopBaseCategory3(category1Id).getData();
        //1.1 遍历分类集合得到三级分类ID
        List<Long> category3IdList = baseCategory3List.stream().map(BaseCategory3::getId).collect(Collectors.toList());
        //1.1.1 检索需要将三级分类ID集合泛型转为FieldValue
        List<FieldValue> fieldValueList =
                category3IdList.stream().map(category3Id -> FieldValue.of(category3Id)).collect(Collectors.toList());

        //1.2 后续封装响应结果中分类信息 将分类集合转为Map key:三级分类ID val:三级分类对象
        Map<Long, BaseCategory3> category3Map = baseCategory3List.stream()
                .collect(Collectors.toMap(BaseCategory3::getId, baseCategory3 -> baseCategory3));

        //2.调用原生ES客户端进行检索
        SearchResponse<AlbumInfoIndex> searchResponse =
                elasticsearchClient.search(
                        s -> s.index(INDEX_NAME).size(0)
                                .query(q -> q.terms(t -> t.field("category3Id").terms(t1 -> t1.value(fieldValueList))))
                                .aggregations(
                                        "category3Agg", 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)))))
                                )
                        , AlbumInfoIndex.class);

        //3.解析ES聚合结果
        Map<String, Aggregate> aggregations = searchResponse.aggregations();
        if (CollectionUtil.isNotEmpty(aggregations)) {
            //3.1 获取到三级分类ID聚合桶集合
            Buckets<LongTermsBucket> category3AggBuckets = aggregations.get("category3Agg").lterms().buckets();
            //3.2 遍历三级分类ID聚合桶,每遍历一个得到某个置顶三级分类下热门专辑
            if (CollectionUtil.isNotEmpty(category3AggBuckets.array())) {
                List<Map<String, Object>> list = category3AggBuckets.array().stream().map(category3Bucket -> {
                    //3.2.1 获取三级分类ID
                    long category3Id = category3Bucket.key();
                    BaseCategory3 baseCategory3 = category3Map.get(category3Id);
                    //3.3 基于当前分类桶继续下钻获取子聚合
                    List<AlbumInfoIndex> hotAlbumList = category3Bucket.aggregations().get("top6").topHits().hits().hits().stream()
                            .map(hotAlbumHit -> {
                                String hotAlbumHitJsonStr = hotAlbumHit.source().toString();
                                AlbumInfoIndex albumInfoIndex = JSON.parseObject(hotAlbumHitJsonStr, AlbumInfoIndex.class);
                                return albumInfoIndex;
                            }).collect(Collectors.toList());
                    //3.4 构建置顶三级分类热门专辑Map对象
                    Map<String, Object> map = new HashMap<>();
                    map.put("baseCategory3", baseCategory3);
                    map.put("list", hotAlbumList);
                    return map;
                }).collect(Collectors.toList());
                return list;
            }
        }
        return null;
    } 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上架方法中追加内容:

/**
 * 新增提词记录到提词索引库
 *
 * @param albumInfoIndex
 */
@Override
public void saveSuggetIndex(AlbumInfoIndex albumInfoIndex) {
    //1.将专辑标题内容作为提词原始记录存入提词库
    SuggestIndex suggestIndex = new SuggestIndex();
    //1.1 封装提词记录主键 - 跟专辑文档主键一致
    suggestIndex.setId(albumInfoIndex.getId().toString());
    //1.2 封装提词原始内容 给用户展示提词内容(专辑名称)
    String albumTitle = albumInfoIndex.getAlbumTitle();
    suggestIndex.setTitle(albumTitle);
    //1.3 用于提词字段:汉字提词
    suggestIndex.setKeyword(new Completion(new String[]{suggestIndex.getTitle()}));
    //1.4 用于提词字段:拼音提词 将中文转为拼音 采用""
    suggestIndex.setKeywordPinyin(new Completion(new String[]{PinyinUtil.getPinyin(albumTitle, "")}));
    //1.4 用于提词字段:首字母提词 将中文转为拼音首字母 采用""
    suggestIndex.setKeywordSequence(new Completion(new String[]{PinyinUtil.getFirstLetter(albumTitle, "")}));
    //2.执行保存
    suggestIndexRepository.save(suggestIndex);
}

5.2.2 关键字自动提示

相关dsl 语句:

GET /suggestinfo/_search
{
  "suggest": {
    "mySuggestKeyword": {
      "prefix": "jingdian",
      "completion": {
        "field": "keyword",
        "size": 10,
        "skip_duplicates": true
      }
    },
    "mySuggestPinyin": {
      "prefix": "jingdian",
      "completion": {
        "field": "keywordPinyin",
         "skip_duplicates": true
      }
    },
    "mySuggestSequence": {
      "prefix": "jingdian",
      "completion": {
        "field": "keywordSequence",
         "skip_duplicates": true
      }
    }
  }
}


#如果经过提词后记录不满足10个,采用分词查询
GET albuminfo/_search
{
  "query": {
    "match": {
      "albumTitle": "经典"
    }
  }
}

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 searchResponse ES响应结果
 * @return
 */
Collection<String> parseSuggestResult(String suggestName, SearchResponse<SuggestIndex> searchResponse);

SearchServiceImpl实现类:

private static final String SUGGEST_INDEX = "suggestinfo";

/**
 * 根据用户正在录入文本进行自动联想
 *
 * @param keyword 任意字符
 * @return 用于自动补全文本列表
 */
@Override
public List<String> completeSuggest(String keyword) {
    try {
        //1.发起建议词自动补全请求-采用Lambda表达式构建
        SearchResponse<SuggestIndex> searchResponse = elasticsearchClient
                .search(
                        s -> s.index(SUGGEST_INDEX)
                                .suggest(
                                        s1 -> s1.suggesters("suggestKeyword", f -> f.prefix(keyword).completion(c -> c.field("keyword").skipDuplicates(true).size(10)))
                                                .suggesters("suggestPinyin", f -> f.prefix(keyword).completion(c -> c.field("keywordPinyin").skipDuplicates(true).size(10)))
                                                .suggesters("suggestLetter", f -> f.prefix(keyword).completion(c -> c.field("keywordSequence").skipDuplicates(true).size(10)))
                                )
                        , SuggestIndex.class);
        //2.解析建议词结果,注意封装 自动补全文本列表长度最多显示10条
        //2.1 根据三个自定义建议词解析ES建议结果,将结果放入集合中(去重)
        HashSet<String> suggestSet = new HashSet<>();
        suggestSet.addAll(this.parseSuggestResult("suggestKeyword", searchResponse));
        suggestSet.addAll(this.parseSuggestResult("suggestPinyin", searchResponse));
        suggestSet.addAll(this.parseSuggestResult("suggestLetter", searchResponse));
        //2.2 判断结果长度如果小于10,采用全文查询-查询专辑索引库 补全到10个
        if (suggestSet.size() < 10) {
            SearchResponse<AlbumInfoIndex> response = elasticsearchClient.search(
                    s -> s.index(INDEX_NAME).query(q -> q.match(m -> m.field("albumTitle").query(keyword))),
                    AlbumInfoIndex.class);
            List<Hit<AlbumInfoIndex>> hits = response.hits().hits();
            if (CollectionUtil.isNotEmpty(hits)) {
                for (Hit<AlbumInfoIndex> hit : hits) {
                    String albumTitle = hit.source().getAlbumTitle();
                    suggestSet.add(albumTitle);
                    if (suggestSet.size() >= 10) {
                        break;
                    }
                }
            }
        }
        List<String> list = new ArrayList<>(suggestSet);
        if (suggestSet.size() > 10) {
            return new ArrayList<>(suggestSet).subList(0, 10);
        }
        return list;
    } catch (IOException e) {
        log.error("[搜索服务]关键词自动补全异常:{}", e);
        throw new RuntimeException(e);
    }
}

/**
 * 解析自定义建议词结果
 *
 * @param suggestName    自定义建议词参数名称
 * @param searchResponse ES响应结果
 * @return
 */
@Override
public Collection<String> parseSuggestResult(String suggestName, SearchResponse<SuggestIndex> searchResponse) {
    List<String> list = new ArrayList<>();
    List<Suggestion<SuggestIndex>> suggestions = searchResponse.suggest().get(suggestName);
    if (CollectionUtil.isNotEmpty(suggestions)) {
        for (Suggestion<SuggestIndex> suggestion : suggestions) {
            List<CompletionSuggestOption<SuggestIndex>> options = suggestion.completion().options();
            for (CompletionSuggestOption<SuggestIndex> option : options) {
                //得到提词索引库文档对象
                SuggestIndex suggestIndex = option.source();
                //将提词文档中原始内容
                list.add(suggestIndex.getTitle());
            }
        }
    }
    return list;
}

6、ELK日志解决方案

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

6.1 Logstash环境问题

  1. 安装Logstash参考md文档

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

image-20231027155525805

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

    image-20231027155816091

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

6.2 项目中整合

  1. 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