第4章 检索模块.md 92 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 getCategoryViewBy3Id(Long category3Id) {
    return baseCategoryViewMapper.selectById(category3Id);
}

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

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

AlbumDegradeFeignClient服务降级类

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

2.1.3 获取用户信息

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

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

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

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

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

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

UserDegradeFeignClient服务降级类

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


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

@Slf4j
@Component
public class UserDegradeFeignClient implements UserFeignClient {

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

2.2 专辑上架

2.2.1 专辑上架

该接口用于测试

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

service-search模块中SearchApiController 控制器

package com.atguigu.tingshu.search.api;

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

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

    @Autowired
    private SearchService searchService;


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

}

SearchService 接口

package com.atguigu.tingshu.search.service;

public interface SearchService {


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

SearchServiceImpl 实现类

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

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.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 AlbumInfoIndexRepository albumInfoIndexRepository;

    @Autowired
    private AlbumFeignClient albumFeignClient;

    @Autowired
    private UserFeignClient userFeignClient;

    /**
     * 将指定专辑ID构建索引库文档对象,将其存入索引库
     *
     * @param albumId 专辑ID
     */
    @Override
    public void upperAlbum(Long albumId) {
        //1.构建索引库文档对象
        AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();

        //2.封装专辑及专辑标签属性-远程调用专辑服务获取专辑信息(包含专辑标签列表)
        //2.1 处理专辑基本信息
        AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
        Assert.notNull(albumInfo, "专辑不存在,专辑ID{}", albumId);
        BeanUtil.copyProperties(albumInfo, albumInfoIndex);
        //2.2 处理专辑标签列表
        List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
        if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
            List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList.stream().map(albumAttributeValue -> {
                //将专辑标签集合泛型从AlbumAttributeValue转为AttributeValueIndex
                AttributeValueIndex attributeValueIndex = new AttributeValueIndex();
                attributeValueIndex.setAttributeId(albumAttributeValue.getAttributeId());
                attributeValueIndex.setValueId(albumAttributeValue.getValueId());
                return attributeValueIndex;
            }).collect(Collectors.toList());
            albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
        }

        //3.封装分类信息-远程调用专辑服务获取分类视图对象
        BaseCategoryView baseCategoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
        Assert.notNull(baseCategoryView, "分类不存在,分类ID:{}", albumInfo.getCategory3Id());
        albumInfoIndex.setCategory1Id(baseCategoryView.getCategory1Id());
        albumInfoIndex.setCategory2Id(baseCategoryView.getCategory2Id());

        //4.封装主播名称-远程调用用户服务获取用户信息
        UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
        Assert.notNull(userInfoVo, "主播信息不存在,主播ID:{}", albumInfo.getUserId());
        albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());

        //5.TODO 封装统计信息,采用产生随机值 以及专辑热度
        //5.1 随机为专辑产生播放量,订阅量,购买量,评论量 、
        int num1 = RandomUtil.randomInt(1000, 2000);
        int num2 = RandomUtil.randomInt(500, 1000);
        int num3 = RandomUtil.randomInt(200, 400);
        int num4 = RandomUtil.randomInt(100, 200);
        albumInfoIndex.setPlayStatNum(num1);
        albumInfoIndex.setSubscribeStatNum(num2);
        albumInfoIndex.setBuyStatNum(num3);
        albumInfoIndex.setCommentStatNum(num4);

        //5.2 基于统计值计算出专辑得分 为不同统计类型设置不同权重
        BigDecimal bigDecimal1 = new BigDecimal(num4).multiply(new BigDecimal("0.4"));
        BigDecimal bigDecimal2 = new BigDecimal(num3).multiply(new BigDecimal("0.3"));
        BigDecimal bigDecimal3 = new BigDecimal(num2).multiply(new BigDecimal("0.2"));
        BigDecimal bigDecimal4 = new BigDecimal(num1).multiply(new BigDecimal("0.1"));
        BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
        albumInfoIndex.setHotScore(hotScore.doubleValue());

        //6.将索引库文档对象存入索引库
        albumInfoIndexRepository.save(albumInfoIndex);
    }
}

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

2.2.2 异步任务+线程池优化

package com.atguigu;

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

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;

/**
 * @author: atguigu
 * @create: 2023-12-13 11:46
 */
@SpringBootTest(classes = ServiceSearchApplication.class)
public class CompletableFutureTest {

    @Autowired
    private ThreadPoolExecutor threadPoolExecutor;


    @Test
    public void test() throws ExecutionException, InterruptedException {
        //1.构建任务任务A对象-需要返回结果
        CompletableFuture<String> completableFutureA = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + ",任务A执行");
            return "resultA";
        }, threadPoolExecutor);

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

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

        //3.x 基于异步任务A构建异步任务D,D需要获取A任务执行结果,D返回结果
        CompletableFuture<String> completableFutureD = completableFutureA.thenApplyAsync(resultA -> {
            System.out.println(Thread.currentThread().getName() + ",任务D执行,获取到A结果:" + resultA);
            return "resultD";
        }, threadPoolExecutor);

        //4.组合所有异步任务 A,B,C三个任务全部执行完毕主线程继续
        CompletableFuture
                .allOf(completableFutureA, completableFutureB, completableFutureC, completableFutureD)
                .join();

        String d = completableFutureD.get();
        String a = completableFutureA.get();
        System.out.println(Thread.currentThread().getName()+",结束"+",A的结果:"+a+",D的结果:"+d);
    }


    public static void main(String[] args) {
        //1.构建任务任务A对象-需要返回结果
        CompletableFuture<String> completableFutureA = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName() + ",任务A执行");
            return "resultA";
        });

        //2.基于异步任务A构建异步任务B,B需要获取A任务执行结果,B不返回结果
        CompletableFuture<Void> completableFutureB = completableFutureA.thenAcceptAsync(resultA -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + ",任务B执行,获取到A结果:" + resultA);
        });

        //3.基于异步任务A构建异步任务C,C需要获取A任务执行结果,C不返回结果
        CompletableFuture<Void> completableFutureC = completableFutureA.thenAcceptAsync(resultA -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + ",任务C执行,获取到A结果:" + resultA);
        });

        //4.组合所有异步任务 A,B,C三个任务全部执行完毕主线程继续
        CompletableFuture
                .allOf(completableFutureA, completableFutureB, completableFutureC)
                .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 AlbumInfoIndexRepository albumInfoIndexRepository;
    
    @Autowired
    private AlbumFeignClient albumFeignClient;
    
    @Autowired
    private UserFeignClient userFeignClient;
    
    
    @Autowired
    private ThreadPoolExecutor threadPoolExecutor;
    
    /**
     * 将指定专辑ID构建索引库文档对象,将其存入索引库
     *
     * @param albumId 专辑ID
     */
    @Override
    public void upperAlbum(Long albumId) {
        //1.构建索引库文档对象
        AlbumInfoIndex albumInfoIndex = new AlbumInfoIndex();
    
        //2.封装专辑及专辑标签属性-远程调用专辑服务获取专辑信息(包含专辑标签列表)
        //2.1 处理专辑基本信息 不依赖其他任务,当前任务得有返回值
        CompletableFuture<AlbumInfo> albumInfoCompletableFuture = CompletableFuture.supplyAsync(() -> {
            AlbumInfo albumInfo = albumFeignClient.getAlbumInfo(albumId).getData();
            Assert.notNull(albumInfo, "专辑不存在,专辑ID{}", albumId);
            BeanUtil.copyProperties(albumInfo, albumInfoIndex);
            //2.2 处理专辑标签列表 A
            List<AlbumAttributeValue> albumAttributeValueVoList = albumInfo.getAlbumAttributeValueVoList();
            if (CollectionUtil.isNotEmpty(albumAttributeValueVoList)) {
                List<AttributeValueIndex> attributeValueIndexList = albumAttributeValueVoList.stream().map(albumAttributeValue -> {
                    //将专辑标签集合泛型从AlbumAttributeValue转为AttributeValueIndex
                    AttributeValueIndex attributeValueIndex = new AttributeValueIndex();
                    attributeValueIndex.setAttributeId(albumAttributeValue.getAttributeId());
                    attributeValueIndex.setValueId(albumAttributeValue.getValueId());
                    return attributeValueIndex;
                }).collect(Collectors.toList());
                albumInfoIndex.setAttributeValueIndexList(attributeValueIndexList);
            }
            return albumInfo;
        }, threadPoolExecutor);
    
        //3.封装分类信息-远程调用专辑服务获取分类视图对象 依赖专辑异步任务,当前任务不需要返回值
        CompletableFuture<Void> basecategoryViewCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
            BaseCategoryView baseCategoryView = albumFeignClient.getCategoryView(albumInfo.getCategory3Id()).getData();
            Assert.notNull(baseCategoryView, "分类不存在,分类ID:{}", albumInfo.getCategory3Id());
            albumInfoIndex.setCategory1Id(baseCategoryView.getCategory1Id());
            albumInfoIndex.setCategory2Id(baseCategoryView.getCategory2Id());
        }, threadPoolExecutor);
    
        //4.封装主播名称-远程调用用户服务获取用户信息
        CompletableFuture<Void> userInfoCompletableFuture = albumInfoCompletableFuture.thenAcceptAsync(albumInfo -> {
            UserInfoVo userInfoVo = userFeignClient.getUserInfoVo(albumInfo.getUserId()).getData();
            Assert.notNull(userInfoVo, "主播信息不存在,主播ID:{}", albumInfo.getUserId());
            albumInfoIndex.setAnnouncerName(userInfoVo.getNickname());
        }, threadPoolExecutor);
    
        //5.TODO 封装统计信息,采用产生随机值 以及专辑热度
        CompletableFuture<Void> statCompletableFuture = CompletableFuture.runAsync(() -> {
            //5.1 随机为专辑产生播放量,订阅量,购买量,评论量
            int num1 = RandomUtil.randomInt(1000, 2000);
            int num2 = RandomUtil.randomInt(500, 1000);
            int num3 = RandomUtil.randomInt(200, 400);
            int num4 = RandomUtil.randomInt(100, 200);
            albumInfoIndex.setPlayStatNum(num1);
            albumInfoIndex.setSubscribeStatNum(num2);
            albumInfoIndex.setBuyStatNum(num3);
            albumInfoIndex.setCommentStatNum(num4);
    
            //5.2 基于统计值计算出专辑得分 为不同统计类型设置不同权重
            BigDecimal bigDecimal1 = new BigDecimal(num4).multiply(new BigDecimal("0.4"));
            BigDecimal bigDecimal2 = new BigDecimal(num3).multiply(new BigDecimal("0.3"));
            BigDecimal bigDecimal3 = new BigDecimal(num2).multiply(new BigDecimal("0.2"));
            BigDecimal bigDecimal4 = new BigDecimal(num1).multiply(new BigDecimal("0.1"));
            BigDecimal hotScore = bigDecimal1.add(bigDecimal2).add(bigDecimal3).add(bigDecimal4);
            albumInfoIndex.setHotScore(hotScore.doubleValue());
        }, threadPoolExecutor);
    
    
        //6.组合异步任务对象-需求以上四个异步任务必须全部执行完毕,主线程继续
        CompletableFuture.allOf(
                albumInfoCompletableFuture,
                statCompletableFuture,
                basecategoryViewCompletableFuture,
                userInfoCompletableFuture
        ).join();
    
        //7.将索引库文档对象存入索引库
        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: 2023-11-18 14:16
 */
@Slf4j
@Component
public class SearchReceiver {

    @Autowired
    private SearchService searchService;


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


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

3、专辑关键字检索

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

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

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

学习目标:至少能看懂,先拷贝(尝试自己写)
业务需求:普通用户通过关键字检索专辑列表

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



#有条件请求体参数中提交:query
## 查询条件多个(bool) 三个条件必须满足 must/filter
### 关键字 match 查询
### (1,2,3)分类 term 查询
### 标签条件 Nested查询
#分页查询请求体中中提交:from ,size
#排序:sort
#高亮:highlight
#字段返回:_source

#模拟用户没有录入任何查询查询所有专辑
GET albuminfo/_search
{
  "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "_source": {"excludes": ["category1Id", "category2Id", "category3Id"]}
}

#模拟用户录入任意关键词查询专辑
# 关键词查询条件:采用must默认按照相关性得分进行排序。不同用户录入关键词不同,不适合使用Filter
## 用户输入关键词,可能匹配查询专辑标题或者简介。可能等值专辑作者(三个条件or)
GET /albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "古典文学"
                }
              },
               {
                "match": {
                  "albumIntro": "古典文学"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "古典文学"
                  }
                }
              }
            ]
          }
        }
      ]
    }
  },
  "size": 40
}



#模拟用户录入任意关键词查询专辑
# 关键词查询条件:采用must默认按照相关性得分进行排序。不同用户录入关键词不同,不适合使用Filter
## 用户输入关键词,可能匹配查询专辑标题或者简介。可能等值专辑作者(三个条件or)
#模拟用户选择分类进行筛选,可以在首页直接选择分类(1、2、3)分类查询结果可以放入缓存(提高检索效率)采用Filter来实现
#模拟用户选择专辑标签(该1级分类下关联标签)根据选择一组或多组标签条件进行筛选。同分类该数据适合放入缓存采用Filter。专辑标签是Nested类型查询使用Nested查询,一组条件内标签跟标签值必须满足bool must实现
GET /albuminfo/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              {
                "match": {
                  "albumTitle": "经典留声机"
                }
              },
               {
                "match": {
                  "albumIntro": "经典留声机"
                }
              },
              {
                "term": {
                  "announcerName": {
                    "value": "经典留声机"
                  }
                }
              }
            ]
          }
        }
      ],
      "filter": [
        {
         "term": {
           "category1Id": "8"
         } 
        },
         {
         "term": {
           "category2Id": "148"
         } 
        },
         {
         "term": {
           "category3Id": "1261"
         } 
        },
        {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "10"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "21"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
         {
          "nested": {
            "path": "attributeValueIndexList",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attributeValueIndexList.attributeId": {
                        "value": "11"
                      }
                    }
                  },
                  {
                    "term": {
                      "attributeValueIndexList.valueId": {
                        "value": "23"
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
   "from": 0,
  "size": 10,
  "sort": [
    {
      "hotScore": {
        "order": "desc"
      }
    }
  ],
  "_source": {"excludes": ["category1Id", "category2Id", "category3Id"]},
  "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 查询条件对象:包含关键字、分类id、标签列表、排序、分页信息
 * @return
 */
AlbumSearchResponseVo search(AlbumIndexQuery albumIndexQuery);

/**
 * 封装检索请求对象
 * @param albumIndexQuery 查询条件
 * @return 检索请求对象
 */
SearchRequest buildDSL(AlbumIndexQuery albumIndexQuery);

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

SearchServiceImpl实现类

@Autowired
private ElasticsearchClient elasticsearchClient;


private static final String INDEX_NAME = "albuminfo";


/**
 * 站内检索,支持关键字、分类、标签条件分页检索,结果高亮
 *
 * @param albumIndexQuery 查询条件对象:包含关键字、分类id、标签列表、排序、分页信息
 * @return 自定义VO对象:包含当前页专辑列表、分页信息
 */
@Override
public AlbumSearchResponseVo search(AlbumIndexQuery albumIndexQuery) {
    try {
        //一、构建检索请求对象SearchReqeust对象
        SearchRequest searchReqeust = this.buildDSL(albumIndexQuery);
        System.err.println("本次检索DSL:复制到Kibana中验证");
        System.err.println(searchReqeust.toString());

        //二、调用原生ES客户端对象进行检索
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(searchReqeust, AlbumInfoIndex.class);

        //三、解析ES响应结果
        return this.parseResult(searchResponse);
    } catch (Exception e) {
        log.error("[搜索服务]查询条件:{},站内检索异常:{}", albumIndexQuery, e);
        throw new RuntimeException(e);
    }
}

封装站内专辑检索DSL请求

/**
 * 封装检索请求对象:封装站内检索DSL请求体所有参数,包含查询条件query,分页from size,排序 sort, 高亮 highlight, 字段指定 _source
 * 查询条件跟排序高亮存在动态判断,整体上检索请求对象采用传统方式,局部采用Lambda简化
 *
 * @param queryVo 查询条件
 * @return 检索请求对象
 */
@Override
public SearchRequest buildDSL(AlbumIndexQuery queryVo) {
    //1.创建检索请求构建器对象-封装检索索引库 及 所有检索DSL语句
    SearchRequest.Builder builder = new SearchRequest.Builder();
    builder.index(INDEX_NAME);

    //2.设置请求体参数"query",处理查询条件(关键字、分类、标签)
    //2.1 创建最外层bool组合条件对象
    BoolQuery.Builder allBoolQueryBuilder = new BoolQuery.Builder();

    //2.2 处理关键字查询条件 采用must必须满足,包含bool组合三个子条件,三个子条件或者关系
    String keyword = queryVo.getKeyword();
    if (StringUtils.isNotBlank(keyword)) {
        BoolQuery.Builder keyWordBoolQueryBuilder = new BoolQuery.Builder();
        //2.2.1 should 设置匹配查询专辑标题
        keyWordBoolQueryBuilder.should(s -> s.match(m -> m.field("albumTitle").query(keyword)));
        //2.2.2 should 设置匹配查询专辑简介
        keyWordBoolQueryBuilder.should(s -> s.match(m -> m.field("albumIntro").query(keyword)));
        //2.2.3 should 设置精确查询作者名称
        keyWordBoolQueryBuilder.should(s -> s.term(t -> t.field("announcerName").value(keyword)));
        allBoolQueryBuilder.must(keyWordBoolQueryBuilder.build()._toQuery());
    }
    //2.3 处理分类ID查询条件
    if (queryVo.getCategory1Id() != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category1Id").value(queryVo.getCategory1Id())));
    }
    if (queryVo.getCategory2Id() != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category2Id").value(queryVo.getCategory2Id())));
    }
    if (queryVo.getCategory3Id() != null) {
        allBoolQueryBuilder.filter(f -> f.term(t -> t.field("category3Id").value(queryVo.getCategory3Id())));
    }

    //2.4 处理标签查询条件(可能有多个)
    List<String> attributeList = queryVo.getAttributeList();
    //2.4.1 判断是否提交标签过滤条件
    if (CollectionUtil.isNotEmpty(attributeList)) {
        //2.4.2 每遍历一个标签,设置Nested查询
        for (String attribute : attributeList) {
            String[] split = attribute.split(":");
            if (split != null && split.length == 2) {
                //2.4.3 在当前Nested查询中包含bool组合条件查询  - 采用传统方式
               /* NestedQuery.Builder nestedQueryBuilder = new NestedQuery.Builder();
                nestedQueryBuilder.path("attributeValueIndexList");

                BoolQuery.Builder nestedBoolQueryBuilder = new BoolQuery.Builder();
                //2.4.4 每个bool查询条件must精确查询标签ID
                nestedBoolQueryBuilder.must(m->m.term(t->t.field("attributeValueIndexList.attributeId").value(split[0])));
                //2.4.5 每个bool查询条件must精确查询标签值ID
                nestedBoolQueryBuilder.must(m->m.term(t->t.field("attributeValueIndexList.valueId").value(split[1])));
                nestedQueryBuilder.query(nestedBoolQueryBuilder.build()._toQuery());
                //2.4.6 将构建好Nested查询封装到最外层bool查询filter中
                allBoolQueryBuilder.filter(nestedQueryBuilder.build()._toQuery());*/
                //2.4.7 采用Lambda表达式简化
                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])))
                                ))
                        ));
            }
        }
    }

    //2.5 将最外层bool组合条件对象设置到请求体参数"query"中
    builder.query(allBoolQueryBuilder.build()._toQuery());

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

    //4.设置请求体参数"sort" 处理排序(动态 综合、播放量、发布时间)
    //4.1 判断参数排序是否提交 提交形式: 排序字段(1:综合 2:播放量 3:发布时间):排序方式
    String order = queryVo.getOrder();
    if (StringUtils.isNotBlank(order)) {
        //4.2 按照冒号对查询条件进行分割得到数组
        String[] split = order.split(":");
        if (split != null && split.length == 2) {
            //4.3 判断得到排序字段
            String orderField = "";
            switch (split[0]) {
                case "1":
                    orderField = "hotScore";
                    break;
                case "2":
                    orderField = "playStatNum";
                    break;
                case "3":
                    orderField = "createTime";
                    break;
            }
            //4.4 设置排序
            String finalOrderField = orderField;
            builder.sort(s -> s.field(f -> f.field(finalOrderField).order("asc".equals(split[1]) ? SortOrder.Asc : SortOrder.Desc)));
        }
    }

    //5.设置请求体参数"highlight" 处理高亮,前提:用户录入关键字
    if (StringUtils.isNotBlank(keyword)) {
        builder.highlight(h -> h.fields("albumTitle", hf -> hf.preTags("<font style='color:red'>").postTags("</font>")));
    }

    //6.设置请求体参数"_source" 处理字段指定
    builder.source(s -> s.filter(f -> f.excludes("category1Id",
            "category2Id",
            "category3Id",
            "attributeValueIndexList.attributeId",
            "attributeValueIndexList.valueId")));

    //7.调用构建器builder返回检索请求对象
    return builder.build();
}

封装检索结果

/**
 * 解析ES响应结果将结果封装为自定义VO对象
 *
 * @param searchResponse ES检索结果对象
 * @param queryVo
 * @return
 */
@Override
public AlbumSearchResponseVo parseResult(SearchResponse<AlbumInfoIndex> searchResponse, AlbumIndexQuery queryVo) {
    //1.构建响应VO对象
    AlbumSearchResponseVo vo = new AlbumSearchResponseVo();
    //2.封装分页信息(总记录数、总页数、页码、页大小)
    vo.setPageNo(queryVo.getPageNo());
    Integer pageSize = queryVo.getPageSize();
    vo.setPageSize(pageSize);
    //1.1 从ES响应结果中得到总记录数
    long total = searchResponse.hits().total().value();
    vo.setTotal(total);
    //1.2 动态计算总页数
    long totalPages = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
    vo.setTotalPages(totalPages);
    //3.封装检索到业务数据(专辑搜索Vo集合)
    List<Hit<AlbumInfoIndex>> hitList = searchResponse.hits().hits();
    if (CollectionUtil.isNotEmpty(hitList)) {
        List<AlbumInfoIndexVo> infoIndexVoList = hitList.stream().map(hit -> {
            //将获取到的文档对象AlbumInfoIndex类型转为AlbumInfoIndexVo类型
            AlbumInfoIndexVo albumInfoIndexVo = BeanUtil.copyProperties(hit.source(), AlbumInfoIndexVo.class);
            //处理高亮片段
            Map<String, List<String>> highlightMap = hit.highlight();
            if(CollectionUtil.isNotEmpty(highlightMap) && highlightMap.containsKey("albumTitle")){
                String highlightAlbumTitle = highlightMap.get("albumTitle").get(0);
                albumInfoIndexVo.setAlbumTitle(highlightAlbumTitle);
            }
            return albumInfoIndexVo;
        }).collect(Collectors.toList());
        vo.setList(infoIndexVoList);
    }
    //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级分类下置顶前7个三级分类集
 * @param category1Id
 * @return
 */
@Operation(summary = "查询指定1级分类下置顶前7个三级分类集合")
@GetMapping("/category/findTopBaseCategory3/{category1Id}")
public Result<List<BaseCategory3>> getTop7BaseCategory3(@PathVariable Long category1Id){
    List<BaseCategory3> list = baseCategoryService.getTop7BaseCategory3(category1Id);
    return Result.ok(list);
}

BaseCategoryService接口:

/**
 * 查询指定1级分类下置顶前7个三级分类集
 * @param category1Id 1级分类ID
 * @return
 */
List<BaseCategory3> getTop7BaseCategory3(Long category1Id);

BaseCategoryServiceImpl实现类

/**
 * 查询指定1级分类下置顶前7个三级分类集
 *
 * @param category1Id 1级分类ID
 * @return
 */
@Override
public List<BaseCategory3> getTop7BaseCategory3(Long category1Id) {
    //1.根据一级分类ID查询二级分类集合,得到二级分类ID集合
    LambdaQueryWrapper<BaseCategory2> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(BaseCategory2::getCategory1Id, category1Id);
    queryWrapper.select(BaseCategory2::getId);
    List<BaseCategory2> baseCategory2List = baseCategory2Mapper.selectList(queryWrapper);
    if (CollectionUtil.isNotEmpty(baseCategory2List)) {
        List<Long> baseCategory2IdList = baseCategory2List.stream().map(baseCategory2 -> baseCategory2.getId()).collect(Collectors.toList());
        //2.根据二级分类ID集合查询三级分类列表获取置顶前7个三级分类集合
        LambdaQueryWrapper<BaseCategory3> baseCategory3LambdaQueryWrapper = new LambdaQueryWrapper<>();
        //2.1 查询二级分类下包含三级分类
        baseCategory3LambdaQueryWrapper.in(BaseCategory3::getCategory2Id, baseCategory2IdList);
        //2.2 查询需要置顶的三级分类
        baseCategory3LambdaQueryWrapper.eq(BaseCategory3::getIsTop, 1);
        //2.3 根据序号排序 升序排
        baseCategory3LambdaQueryWrapper.orderByAsc(BaseCategory3::getOrderNum);
        //2.4 截取前7个 SQL+limit 7
        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
 * @return
 */
@Operation(summary = "根据1级分类对象查询包含二级分类(包含三级分类)")
@GetMapping("/category/getBaseCategoryList/{category1Id}")
public Result<JSONObject> getBaseCategoryListByCategory1Id(@PathVariable Long category1Id){
    JSONObject jsonObject = baseCategoryService.getBaseCategoryListByCategory1Id(category1Id);
    return Result.ok(jsonObject);
}

BaseCategoryService接口:

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

BaseCategoryServiceImpl实现类:

/**
 * 根据1级分类对象查询包含二级分类(包含三级分类)
 *
 * @param category1Id
 * @return
 */
@Override
public JSONObject getBaseCategoryListByCategory1Id(Long category1Id) {
    //1.根据1级分类ID查询分类视图得到一级分类列表
    LambdaQueryWrapper<BaseCategoryView> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(BaseCategoryView::getCategory1Id, category1Id);
    List<BaseCategoryView> baseCategory1List = baseCategoryViewMapper.selectList(queryWrapper);
    //2.处理一级分类对象 封装一级分类对象包含ID,分类名称
    if (CollectionUtil.isNotEmpty(baseCategory1List)) {
        //2.1 构建一级分类对象 封装ID,名称
        JSONObject jsonObject1 = new JSONObject();
        jsonObject1.put("categoryId", baseCategory1List.get(0).getCategory1Id());
        jsonObject1.put("categoryName", baseCategory1List.get(0).getCategory1Name());
        //3.处理一级分类下二级分类
        //3.1 将一级分类集合再按照二级分类ID分组得到Map Map中key:二级分类ID,Map中Value二级分类集合
        Map<Long, List<BaseCategoryView>> category2Map = baseCategory1List.stream().collect(Collectors.groupingBy(BaseCategoryView::getCategory2Id));
        //3.2 遍历Map每遍历一次Map封装二级分类JSON对象
        List<JSONObject> jsonObject2List = new ArrayList<>();
        for (Map.Entry<Long, List<BaseCategoryView>> entry2 : category2Map.entrySet()) {
            //3.3 构建二级分类对象,封装二级分类ID及名称
            JSONObject jsonObject2 = new JSONObject();
            jsonObject2.put("categoryId", entry2.getKey());
            jsonObject2.put("categoryName", entry2.getValue().get(0).getCategory2Name());
            jsonObject2List.add(jsonObject2);
            //4.处理二级分类下三级分类
            //4.1 遍历二级分类列表,没遍历一条记录构建三级分类对象
            List<JSONObject> jsonObject3List = new ArrayList<>();
            for (BaseCategoryView baseCategoryView : entry2.getValue()) {
                //4.2 构建三级分类对象,封装三级分类ID名称
                JSONObject jsonObject3 = new JSONObject();
                jsonObject3.put("categoryId", baseCategoryView.getCategory3Id());
                jsonObject3.put("categoryName", baseCategoryView.getCategory3Name());
                jsonObject3List.add(jsonObject3);
            }
            //4.3 将三级分类集合放入二级分类对象中
            jsonObject2.put("categoryChild", jsonObject3List);
        }
        //3.3 将二级分类集合放入一级分类对象中
        jsonObject1.put("categoryChild", jsonObject2List);
        return jsonObject1;
    }
    return null;
}

4.3 查询分类下热门专辑

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

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

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

image-20230921152112908

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

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

GET /albuminfo/_search
{
  "query": {
    "terms": {
      "category3Id": [
        "1001",
        "1002",
        "1007",
        "1008",
        "1009",
        "1012",
        "1013"
      ]
    }
  }
}

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

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

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

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

/**
 * 查询指定1级分类下置顶前7个三级分类集
 * @param category1Id
 * @return
 */
@GetMapping("/category/findTopBaseCategory3/{category1Id}")
public Result<List<BaseCategory3>> getTop7BaseCategory3(@PathVariable Long category1Id);

AlbumDegradeFeignClient服务降级类

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

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

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

SearchService接口:

/**
 * 查询1级分类下置顶3级分类下包含分类热门专辑
 * @param category1Id
 * @return
 */
List<Map<String, Object>> getTopCategory3HotAlbumList(Long category1Id);

SearchServiceImpl实现类:

/**
 * 查询1级分类下置顶3级分类下包含分类热门专辑
 *
 * @param category1Id
 * @return
 */
@Override
public List<Map<String, Object>> getTopCategory3HotAlbumList(Long category1Id) {
    try {
        //1.根据1级分类ID远程调用专辑服务获取置顶前7个三级分类集合
        //1.1 远程调用专辑服务获取置顶三级分类集合
        List<BaseCategory3> baseCategory3List = albumFeignClient.getTop7BaseCategory3(category1Id).getData();
        Assert.notNull(baseCategory3List, "一级分类{}未包含置顶三级分类", category1Id);
        //1.2 获取所有置顶三级分类ID集合
        List<Long> baseCategory3IdList = baseCategory3List.stream().map(BaseCategory3::getId).collect(Collectors.toList());
        //1.3 将三级分类集合转为Map<三级分类ID,三级分类对象> 方便解析结果封装三级分类对象
        //对BaseCategory3集合处理,转为Map Map中Key:ID,Map中val:三级分类对象BaseCategory3
        Map<Long, BaseCategory3> category3Map = baseCategory3List.stream()
                .collect(Collectors.toMap(BaseCategory3::getId, baseCategory3 -> baseCategory3));
        //1.4 将置顶三级分类ID转为FieldValue类型
        List<FieldValue> fieldValueList = baseCategory3IdList.stream()
                .map(baseCategory3Id -> FieldValue.of(baseCategory3Id))
                .collect(Collectors.toList());
        //2.检索ES获取置顶三级分类(7个)不同置顶三级分类下热度前6个的专辑列表
        //2.1 采用ES检索方法,通过lambda构建请求参数:query,size,aggs
        SearchResponse<AlbumInfoIndex> searchResponse = elasticsearchClient.search(
                s -> s.index(INDEX_NAME).size(0)
                        .query(q -> q.terms(t -> t.field("category3Id").terms(tf -> tf.value(fieldValueList))))
                        .aggregations("category3Agg", a -> a.terms(
                                t -> t.field("category3Id").size(10)
                        ).aggregations("top6Agg", a1 -> a1.topHits(t -> t.size(6).sort(sort -> sort.field(f -> f.field("hotScore").order(SortOrder.Desc)))))),
                AlbumInfoIndex.class);
        //3.解析ES响应聚合
        System.out.println(searchResponse);
        //3.1 获取三级分类聚合结果对象
        Aggregate category3Agg = searchResponse.aggregations().get("category3Agg");
        //3.2 获取三级分类聚合“桶”集合 由于三级分类ID字段类型为Long调用lterms方法
        Buckets<LongTermsBucket> buckets = category3Agg.lterms().buckets();
        List<LongTermsBucket> bucketList = buckets.array();
        if (CollectionUtil.isNotEmpty(bucketList)) {
            //3.3 遍历“桶”集合,每遍历一个“桶”处理某个置顶三级分类热门专辑
            List<Map<String, Object>> listMap = bucketList.stream().map(bucket -> {
                Map<String, Object> map = new HashMap<>();
                //3.3.1 处理热门专辑->分类
                long category3Id = bucket.key();
                BaseCategory3 baseCategory3 = category3Map.get(category3Id);
                map.put("baseCategory3", baseCategory3);
                //3.3.2 处理热门专辑->专辑列表
                //3.3.2.1 继续下钻获取子聚合得到当前分类下热门专辑
                Aggregate top6Agg = bucket.aggregations().get("top6Agg");
                List<Hit<JsonData>> hits = top6Agg.topHits().hits().hits();
                if (CollectionUtil.isNotEmpty(hits)) {
                    List<AlbumInfoIndex> hotAlbumList = hits.stream().map(hit -> {
                        //获取专辑JSON对象类型
                        JsonData source = hit.source();
                        return JSON.parseObject(source.toString(), AlbumInfoIndex.class);
                    }).collect(Collectors.toList());
                    map.put("list", hotAlbumList);
                }
                return map;
            }).collect(Collectors.toList());
            return listMap;
        }
    } catch (Exception e) {
        log.error("[检索服务]首页热门专辑异常:{}", e);
        throw new RuntimeException(e);
    }
    return null;
}

5、关键字自动补全功能

5.1 入门案例

completion suggester查询

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

API: [Suggesters | Elasticsearch Guide 8.5] | Elastic

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

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

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

  1. 初始化设置

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

  1. 存储数据

    #关键字自动补全:
    #创建索引-存放用于搜索过的关键词或者初始化导入数据将相关词放入 “提词” 索引库
    #######例如:
    # 字段name:原始内容 经典留声机
    # suggestKeyword:经 典 留 声 机
    # suggestPinyin:jingdianliushengji
    # suggestLetter:jdlsj
    #可以进行建议自动补全字段
    PUT test
    {
     "mappings": {
       "properties": {
         "name": {
           "type": "keyword"
         },
         "suggestKeyword": {
           "type": "completion"
         },
         "suggestPinyin": {
           "type": "completion"
         },
         "suggestLetter": {
           "type": "completion"
         }
       }
     }
    }
    # 添加数据
    POST test/_doc
    {
     "name": "Pitch Fork",
     "suggestKeyword": ["Pitch", "Fork"]
    }
    POST test/_doc
    {
     "name": "Spading Fork",
     "suggestKeyword": ["Spading", "Fork"]
    }
       
    POST test/_doc
    {
     "name": "Spring",
     "suggestKeyword": ["spring", "Fork"]
    }
    POST test/_doc
    {
     "name": "Fountain",
     "suggestKeyword": ["Fountain"]
    }
       
       
    POST test/_doc
    {
     "name": "经典留声机",
     "suggestKeyword": ["经典留声机"],
     "suggestPinyin":["jingdianliushengji"],
     "suggestLetter":["jdlsj"]
    }
       
       
    POST test/_doc
    {
     "name": "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 提词索引库初始化

提词文档索引库对象

package com.atguigu.tingshu.model.search;

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

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

    /*悲惨世界*/

    @Id
    private String id;

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


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

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

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

}

创建索引库:

package com.atguigu.tingshu.search.repository;

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

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

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

/**
 * 新增提词记录到提词索引库
 *
 * @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 SUCCEST_INDEX_NAME = "suggestinfo";


/**
 * 根据用户录入部分关键字进行自动补全
 *
 * @param keyword
 * @return
 */
@Override
public List<String> completeSuggest(String keyword) {
    try {
        //1.根据用户录入关键字进行建议提词请求发起
        SearchResponse<SuggestIndex> searchResponse = elasticsearchClient.search(
                s -> s.index(SUCCEST_INDEX_NAME)
                        .suggest(s1 -> s1.suggesters("mySuggestKeyword", fs -> fs.prefix(keyword).completion(c -> c.field("keyword").size(10).skipDuplicates(true)))
                                .suggesters("mySuggestPinyin", fs -> fs.prefix(keyword).completion(c -> c.field("keywordPinyin").size(10).skipDuplicates(true)))
                                .suggesters("mySuggestSequence", fs -> fs.prefix(keyword).completion(c -> c.field("keywordSequence").size(10).skipDuplicates(true)))
                        )
                , SuggestIndex.class
        );
        //2.解析建议词响应结果,将结果进行去重
        Set<String> hashSet = new HashSet<>();
        hashSet.addAll(this.parseSuggestResult("mySuggestKeyword", searchResponse));
        hashSet.addAll(this.parseSuggestResult("mySuggestPinyin", searchResponse));
        hashSet.addAll(this.parseSuggestResult("mySuggestSequence", searchResponse));
        if (hashSet.size() >= 10) {
            return new ArrayList<>(hashSet).subList(0, 10);
        }
        //3.如果建议词记录数小于10,采用全文查询专辑索引库尝试补全
        SearchResponse<AlbumInfoIndex> response = elasticsearchClient.search(
                s -> s.index(INDEX_NAME).query(q -> q.match(m -> m.field("albumTitle").query(keyword))),
                AlbumInfoIndex.class

        );
        //解析检索结果,将结果放入HashSet
        List<Hit<AlbumInfoIndex>> hits = response.hits().hits();
        if (CollectionUtil.isNotEmpty(hits)) {
            for (Hit<AlbumInfoIndex> hit : hits) {
                AlbumInfoIndex albumInfoIndex = hit.source();
                hashSet.add(albumInfoIndex.getAlbumTitle());
                if (hashSet.size() >= 10) {
                    break;
                }
            }
        }
        //4.最多返回10个自动补全提示词
        return new ArrayList<>(hashSet);
    } catch (Exception e) {
        log.error("[搜索服务]建议词自动补全异常:{}", e);
        throw new RuntimeException(e);
    }
}


/**
 * 解析建议词结果
 *
 * @param suggestName    自定义建议名称
 * @param searchResponse ES响应结果对象
 * @return
 */
@Override
public Collection<String> parseSuggestResult(String suggestName, SearchResponse<SuggestIndex> searchResponse) {
    //1.获取指定自定义建议词名称获取建议结果
    List<Suggestion<SuggestIndex>> suggestionList = searchResponse.suggest().get(suggestName);
    //2.获取建议自动补全对象
    List<String> list = new ArrayList<>();
    suggestionList.forEach(suggestIndexSuggestion -> {
        //3.获取options中自动补全结果
        for (CompletionSuggestOption<SuggestIndex> suggestOption : suggestIndexSuggestion.completion().options()) {
            SuggestIndex suggestIndex = suggestOption.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