利用SpringBoot整合ElasticSearch实现搜索高亮

1,273 阅读6分钟

前言: 在用户搜索指定的数据的时候,为了提升用户的使用体验,让用户更有区分度的选择合适的内容,可以将搜索内容中关键字的高亮显示。

过程比较简单
注意: 博主这里选择使用spring-boot-starter-data-elasticsearch SpringBoot框架帮我们自动配置了一套合适的,简易使用的elasticSearch的相关Api;这样可以减少我们的学习成本 (因为ElasticSearch版本迭代速度很快,之前主流的 High level client JavaApi被淘汰了,7.15版本后是一套新的javaApi,又要学新的东西,比较耗费时间)

1.引入es相关依赖
<!-- elasticsearch-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

2.创建ES实体类:封装通过ES查询返回的数据,同时也可以作为添加至ES中的对象;类似于MySQL,可以通过实体类对MySQL查询出来的数据做处理,也可以将前端传递过来的数据封装成实体类,实现MySQL中数据的新增;

import cn.hutool.extra.pinyin.PinyinUtil;
import com.google.common.reflect.TypeToken;
import com.google.gson.Gson;
import com.yupi.springbootinit.model.entity.Post;

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

import lombok.Data;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

/**
 * 帖子 ES 包装类
 **/
@Document(indexName = "post")
@Data
public class PostEsDTO implements Serializable {

    private static final String DATE_TIME_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";

    /**
     * id
     */
    @Id   //文档id
    private Long id;

    /**
     * 标题
     */
    private String title;

    /**
     * 内容
     */
    private String content;

    /**
     * 标签列表
     */
    private List<String> tags;

    /* *
     * 搜索建议
     */
    private List<String> titleSuggestion;

    /**
     * 点赞数
     */
//    private Integer thumbNum;

    /**
     * 收藏数
     */
//    private Integer favourNum;

    /**
     * 创建用户 id
     */
    private Long userId;

    /**
     * 创建时间
     */
    @Field(index = false, store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN)
    private Date createTime;

    /**
     * 更新时间
     */
    @Field(index = false, store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN)
    private Date updateTime;

    /**
     * 是否删除
     */
    private Integer isDelete;

    private static final long serialVersionUID = 1L;

    private static final Gson GSON = new Gson();

    /**
     * 对象转包装类
     *
     * @param post
     * @return
     */
    public static PostEsDTO objToDto(Post post) {
        if (post == null) {
            return null;
        }
        PostEsDTO postEsDTO = new PostEsDTO();
        BeanUtils.copyProperties(post, postEsDTO);
        String title = postEsDTO.getTitle();
        List<String> titleSuggestion = new ArrayList<>() {{
            add(title);
            add(PinyinUtil.getPinyin(title).replace(" ", ""));
        }};
        postEsDTO.setTitleSuggestion(titleSuggestion);
        String tagsStr = post.getTags();
        if (StringUtils.isNotBlank(tagsStr)) {
            postEsDTO.setTags(GSON.fromJson(tagsStr, new TypeToken<List<String>>() {
            }.getType()));
        }
        return postEsDTO;
    }

    /**
     * 包装类转对象
     *
     * @param postEsDTO
     * @return
     */
    public static Post dtoToObj(PostEsDTO postEsDTO) {
        if (postEsDTO == null) {
            return null;
        }
        Post post = new Post();
        BeanUtils.copyProperties(postEsDTO, post);
        List<String> tagList = postEsDTO.getTags();
        if (CollectionUtils.isNotEmpty(tagList)) {
            post.setTags(GSON.toJson(tagList));
        }
        return post;
    }
}

3.创建工具类,用于操作ES; 类似于操作MySQL的Mapper接口;可以对ES文档中指定索引实现增删改查操作(如果ES中已经有数据可忽略,没有数据需要使用该工具类向es中添加数据)

import com.yupi.springbootinit.model.dto.post.PostEsDTO;
import java.util.List;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

/**
 * 帖子 ES 操作
 *
 */
public interface PostEsDao extends ElasticsearchRepository<PostEsDTO, Long> {
}

4.在业务层中引入ElasticsearchRestTemplate类的对象,这是spring-boot-data-starter-elasticsearch这个依赖中已经封装好的;直接在业务层中通过 @Resource 自动装填即可; 由于我的业务层中有很多代码,太冗余了,就直接展示通过es查询数据并将数据高亮处理的方法

@Service
@Slf4j
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements PostService {

@Resource
private ElasticSearchRestTemplate  elasticsearchRestTemplate;


@Override
public Page<Post> searchFromEs(PostQueryRequest postQueryRequest) {
    Long id = postQueryRequest.getId();
    Long notId = postQueryRequest.getNotId();
    String searchText = postQueryRequest.getSearchText();
    String title = postQueryRequest.getTitle();
    String content = postQueryRequest.getContent();
    List<String> tagList = postQueryRequest.getTags();
    List<String> orTagList = postQueryRequest.getOrTags();
    Long userId = postQueryRequest.getUserId();
    // es 起始页为 0
    long current = postQueryRequest.getCurrent() - 1;
    long pageSize = postQueryRequest.getPageSize();
    // 指定排序字段
    String sortField = postQueryRequest.getSortField();
    // 指定排序规则(降序或升序)
    String sortOrder = postQueryRequest.getSortOrder();

    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();

    // 过滤
    boolQueryBuilder.filter(QueryBuilders.termQuery("isDelete", 0));
    if (id != null) {
        boolQueryBuilder.filter(QueryBuilders.termQuery("id", id)); // term 在es中表示精确查询
    }
    if (notId != null) {
        boolQueryBuilder.mustNot(QueryBuilders.termQuery("id", notId));
    }
    if (userId != null) {
        boolQueryBuilder.filter(QueryBuilders.termQuery("userId", userId));
    }
    // 必须包含所有标签
    if (CollectionUtils.isNotEmpty(tagList)) {
        for (String tag : tagList) {
            boolQueryBuilder.filter(QueryBuilders.termQuery("tags", tag));
        }
    }
    // 包含任何一个标签即可
    if (CollectionUtils.isNotEmpty(orTagList)) {
        BoolQueryBuilder orTagBoolQueryBuilder = QueryBuilders.boolQuery();
        for (String tag : orTagList) {
            orTagBoolQueryBuilder.should(QueryBuilders.termQuery("tags", tag));
        }
        // 至少匹配到一个标签即可
        orTagBoolQueryBuilder.minimumShouldMatch(1);
        boolQueryBuilder.filter(orTagBoolQueryBuilder);
    }
    // 按关键词检索
    if (StringUtils.isNotBlank(searchText)) {
        boolQueryBuilder.should(QueryBuilders.matchQuery("title", searchText));
        boolQueryBuilder.should(QueryBuilders.matchQuery("content", searchText));
        boolQueryBuilder.minimumShouldMatch(1);
    }
    // 按标题检索
    if (StringUtils.isNotBlank(title)) {
        boolQueryBuilder.should(QueryBuilders.matchQuery("title", title));
        boolQueryBuilder.minimumShouldMatch(1);
    }
    // 按内容检索
    if (StringUtils.isNotBlank(content)) {
        boolQueryBuilder.should(QueryBuilders.matchQuery("content", content));
        boolQueryBuilder.minimumShouldMatch(1);
    }
    // 排序
    SortBuilder<?> sortBuilder = SortBuilders.scoreSort();
    if (StringUtils.isNotBlank(sortField)) {
        sortBuilder = SortBuilders.fieldSort(sortField);
        sortBuilder.order(CommonConstant.SORT_ORDER_ASC.equals(sortOrder) ? SortOrder.ASC : SortOrder.DESC);
    }
    // 分页
    PageRequest pageRequest = PageRequest.of((int) current, (int) pageSize);
    // 构造查询
    NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
            .withQuery(boolQueryBuilder)
            .withPageable(pageRequest)
            .withSorts(sortBuilder)
            .withHighlightFields(  // 高亮字段显示
                    new HighlightBuilder.Field("content").preTags("<em style='color:red'>").postTags("</em>"),
                    new HighlightBuilder.Field("title").preTags("<em style='color:red'>").postTags("</em>")
            ).build();
    SearchHits<PostEsDTO> searchHits = elasticsearchRestTemplate.search(searchQuery, PostEsDTO.class);
    Page<Post> page = new Page<>();
    page.setTotal(searchHits.getTotalHits());
    List<Post> resourceList = new ArrayList<>();
    // 对es查询出来的结果进行封装处理,作用是返回给前端
    if (searchHits.hasSearchHits()) {
        List<SearchHit<PostEsDTO>> searchHitList = searchHits.getSearchHits();
       // 取出需要高亮的字段,封装到map中,key为id,value 为高亮字段值;
        Map<Long, String> postIdTitleHighlightMap = new HashMap<>();
        Map<Long, String> postIdContentHighlightMap = new HashMap<>();
        searchHitList.stream().forEach(searchHit -> {
            Long postId = searchHit.getContent().getId();
            List<String> titleHighlightList = searchHit.getHighlightField("title");
            if (titleHighlightList.size() != 0) {
                String titleHighlight = titleHighlightList.get(0);
                if (StringUtils.isNotBlank(titleHighlight)) {
                    postIdTitleHighlightMap.put(postId, titleHighlight);
                }
            }
            List<String> contentHighlightList = searchHit.getHighlightField("content");
            if (contentHighlightList.size() != 0) {
                String contentHighlight = contentHighlightList.get(0);
                if (StringUtils.isNotBlank(contentHighlight)) {
                    postIdContentHighlightMap.put(postId, contentHighlight);
                }
            }
        });
        List<Long> postIdList = searchHitList.stream().map(searchHit -> searchHit.getContent().getId())
                .collect(Collectors.toList());
        List<Post> postList = baseMapper.selectBatchIds(postIdList);
        if (postList != null) {
            Map<Long, List<Post>> idPostMap = postList.stream().collect(Collectors.groupingBy(Post::getId));
            postIdList.forEach(postId -> {
                if (idPostMap.containsKey(postId)) {
                    Post post = idPostMap.get(postId).get(0);
                    String titleField = postIdTitleHighlightMap.get(postId);
                    if (StringUtils.isNotBlank(titleField)) {
                        post.setTitle(titleField);
                    }
                    String contentField =postIdContentHighlightMap.get(postId);
                    if(StringUtils.isNotBlank(contentField)){
                        post.setContent(contentField);
                    }
                    resourceList.add(post);
                } else {
                    // 从 es 清空 db 已物理删除的数据
                    String delete = elasticsearchRestTemplate.delete(String.valueOf(postId), PostEsDTO.class);
                    log.info("delete post {}", delete);
                }
            });
        }
    }
    page.setRecords(resourceList);
    return page;
 }

}

是不是很懵😵,其实只有几个字段是需要我们关注的!!!

  1. 需要高亮处理,就需要在查询条件中指定高亮字段
// 在查询条件中添加高亮字段
 NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
            .withQuery(boolQueryBuilder)
            .withPageable(pageRequest)
            .withSorts(sortBuilder)
            .withHighlightFields(  // 高亮字段显示
                    new HighlightBuilder.Field("content").preTags("<em style='color:red'>").postTags("</em>"),
                    new HighlightBuilder.Field("title").preTags("<em style='color:red'>").postTags("</em>")
            ).build();
// 将ES中的查询结果封装成 PostEsDTO 实体类;            
SearchHits<PostEsDTO> searchHits = elasticsearchRestTemplate.search(searchQuery, PostEsDTO.class);    
            
  1. 然后就是对查询结果做处理
// 取出需要高亮的字段,封装到map中,key为id,value 为高亮字段值;
        Map<Long, String> postIdTitleHighlightMap = new HashMap<>();
        Map<Long, String> postIdContentHighlightMap = new HashMap<>();
        searchHitList.stream().forEach(searchHit -> {
            // 取id
            Long postId = searchHit.getContent().getId();
            // 取高亮字段title
            List<String> titleHighlightList = searchHit.getHighlightField("title");
            if (titleHighlightList.size() != 0) {
                // get(0)的目的是每段话 取第一个关键字作为高亮字段即可,
                String titleHighlight = titleHighlightList.get(0);
                // 判断是否为空
                if (StringUtils.isNotBlank(titleHighlight)) {
                    postIdTitleHighlightMap.put(postId, titleHighlight);
                }
            }
            // 去高亮字段content
            List<String> contentHighlightList = searchHit.getHighlightField("content");
            if (contentHighlightList.size() != 0) {
                String contentHighlight = contentHighlightList.get(0);
                if (StringUtils.isNotBlank(contentHighlight)) {
                    postIdContentHighlightMap.put(postId, contentHighlight);
                }
            }
        });
  1. 最后将存放高亮字段的map中对应的值设置到返回的实体类中(对于ES查询的数据其实并不是数据库中的完整数据,比如用户id字段,在MySQL的帖子表 post 中存在,但不一定要上传到es上去,因为作为搜索方压根不知道你的用户id是啥)所以我们必须收集到es查询出来的所有数据id; 再通过这些id在MySQL中将所对应的全部字段找到,这时我们只要通过比对map中的key和实体类的id是否一致,然后将高亮字段值设置到实体类中进行返回即可。
            // 这是从ES中查询到符合条件的数据的id集合
            List<Long> postIdList = searchHitList.stream().map(searchHit -> searchHit.getContent().getId())
                .collect(Collectors.toList());
        // 利用集合存放从MySQL数据库根据id查询出来的数据
        List<Post> postList = baseMapper.selectBatchIds(postIdList);
        if (postList != null) {
            // 通过Collectors.groupingBy方法对id进行分组,得到的结果类似{key:id,value:postList}但postList中只对存在一个post
            Map<Long, List<Post>> idPostMap = postList.stream().collect(Collectors.groupingBy(Post::getId));
            // 遍历id集合
            postIdList.forEach(postId -> {
                // 数据库中包含这条id的数据,同时es也查出来了,就可以设置高亮字段了
                if (idPostMap.containsKey(postId)) {
                    Post post = idPostMap.get(postId).get(0);
                    String titleField = postIdTitleHighlightMap.get(postId);
                    if (StringUtils.isNotBlank(titleField)) {
                        // 设置title字段为高亮
                        post.setTitle(titleField);
                    }
                    String contentField =postIdContentHighlightMap.get(postId);
                    if(StringUtils.isNotBlank(contentField)){
                        // 设置content字段为高亮
                        post.setContent(contentField);
                    }
                    // resouceList是返回结果的集合
                    resourceList.add(post);
                }

大致过程完毕!!!

debug一下:检查返回给前端的结果,发现title,content字段中都多了 <em color="red"> </em>标签 image.png

在前端中利用一个p标签,带上 v-html=item.title即可 image.png

最后前端高亮字段展示 image.png

如果有小伙伴还是不懂的话,可以私信我,或者评论区留下评论,我会及时解答疑问; 最后希望我们能多多沟通,共同进步!!!