✨基于Spring-Data-Elasticsearch 优雅的实现 多字段搜索 + 高亮 + 分页 + 数据同步✨

标签: spring data elasticsearch | 发表时间:2022-10-16 15:13 | 作者:阿杆
出处:https://juejin.cn/backend

theme: devui-blue highlight: a11y-dark

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第17天, 点击查看活动详情

系列说明

本系列文章基于我的开源微服务项目【校园博客】进行分析和讲解,所有源码均可在GitHub仓库上找到。 系列文章地址请见我的 校园博客专栏

目前项目还有很大改进和完善的空间,欢迎各位有意愿的同学参与项目贡献(尤其前端),一起学习一起进步。

项目的技术栈主要是:
后端 Java + SpringBoot + SpringCloud + Nacos + Getaway + Fegin + MybatisPlus + MySQL + Redis + ES + RabbitMQ + Minio + 七牛云OSS + Jenkins + Docker
前端 Vue + ElementUI + Axios(说实话前端我不太清楚)

前言

本篇文章主要是一些对Spring-Data-Elasticsearch使用上的记录和讲解,对原理和基础知识并没有介绍,适合有一定ES基础的朋友阅读。

为了给项目添加一个好的搜索功能,我去学习了一下elasticsearch。

在学习elasticsearch-client的期间,发现它提供的api不太优雅,用起来也不太舒服,而且我觉得有些操作完全是可以封装在内部的,比如获取数据后,对数据转化为bean的操作;还有属性高亮,不仅设置比较麻烦,而且设置完成的高亮居然是单独在一个字段里的,需要开发者去手动的替换才行,这些操作我觉得其实都可以封装在内部的,害,个人感慨,请勿介意。

然后我就去看了一下spring-data里面提供的 es 操作库,发现有很多操作都封装的比较完善,使用起来也比较优雅,于是我便使用spring-data-elasticsearch完成了这个功能,查阅了很多资料、博客、官方文档,有些地方我觉得官方文档讲的也不够详细,导致走了很多弯路,也可能是我没有找到详细的文档。

为了方便大家学习和少走弯路,也便于本人日后回顾,故记录于此。

本篇文章讲的内容是在项目的 /blog-service/blog-content-server 路径下,感兴趣的同学欢迎随时查看,觉得不错的话也欢迎点点star噢。

技术要点

  1. 使用 copyToElasticsearchRepository 完成的多字段搜索。
  2. 使用注解 @Highlight@HighlightField 完成的高亮显示。
  3. 使用 PageableSearchPage 实现分页和高亮两不误的接口。
  4. 使用 RabbitMQ 完成 MySQLelasticsearch 的数据同步。

依赖项

我当前的环境:

  • springboot 2.6.6

  • elasticsearch 7.12

  • kibana 7.12(这个不是必须的)

  • 然后当前版本的spring默认是用的 7.15.2 的我担心和我的es不兼容,就加了个标签给它改了一下版本:

        <elasticsearch.version>7.12.1</elasticsearch.version>
    

核心依赖其实就这一个,这里面已经依赖了elasticsearch需要的一些依赖,例如 elasticsearch-rest-high=level-client

  <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

然后如果跟我一样使用 RabbitMQ 做数据同步的话,还需要引用mq的依赖:

  <!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- json序列化依赖,需要手动配置bean -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

配置文件

这里需要配置elasticsearch的账号密码

  spring:
  elasticsearch:
    uris: "http://localhost:9200"
    username: 12345
    password: 12345

核心代码

实体类BlogDoc

下面是我代码当中跟 es 进行交互的实体类,代码上有相关的注释,我将一些多余的、意义不大的属性删掉了,方便大家查看。

  package cn.sticki.blog.content.pojo;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
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;

import java.util.Date;

/**
 * Blog ES文档类型
 *
 * @author 阿杆
 * @version 1.0
 * @date 2022/7/8 15:24
 */
@Data
@Document(indexName = "blog")
public class BlogDoc {

/**
 * 博客id
 */
@Id
Integer id;

/**
 * 封面图链接
 */
@Field(type = FieldType.Keyword, index = false)
String coverImage;

/**
 * 标题
 */
@Field(type = FieldType.Text, analyzer = "ik_max_word", copyTo = "descriptiveContent")
String title;

/**
 * 描述
 */
@Field(type = FieldType.Text, analyzer = "ik_max_word", copyTo = "descriptiveContent")
String description;

/**
 * 创建时间
 */
@Field(type = FieldType.Date, pattern = "uuuu-MM-dd HH:mm:ss")
Date createTime;

/**
 * 发表状态(1表示已发表、2表示未发表、3为仅自己可见、4为回收站、5为审核中)
 */
@Field(type = FieldType.Integer)
Integer status;

/**
 * 由其他属性copy而来,主要用于搜索功能,不需要储存数据
 */
@JsonIgnore
@Field(type = FieldType.Text, analyzer = "ik_max_word", ignoreFields = "descriptiveContent", excludeFromSource = true)
String descriptiveContent;

}

注解说明:

  • @Document(indexName = "blog"):声明该实体类对应es中的哪个 索引库

  • @Id:声明该字段对应索引库当中的id。

  • @JsonIgnore:这个应该很熟悉吧,就是 在json序列化时将对象中的一些属性忽略掉,使返回的json数据不包含该属性

  • @Field(...) ,这些其实都 对应es的api调用时传入的字段,有一点es基础会很容易看懂,也可以看看我写的elasticsearch专栏下的其他文章,前几篇是我学基础的时候记录的。

    • type = FieldType.Integer :声明字段属性,如果不写,默认为auto,就是es会帮你自动匹配成最合适的字段类型,建议还是写一下。

    • index = false :声明该字段不需要建立索引,一般用于不会被拿来搜索、排序、统计的字段,比如我这里写的封面图链接。

    • analyzer = "ik_max_word" : 声明该text字段需要使用的分词器,我这里是用的ik分词器,需要开发者去手动安装,但对中文分词比较友好。

    • excludeFromSource = true:翻译出来意思是“从源中排除”,应该是指这个字段的属性不会插入到es索引库当中吧,这个字段是我用来``copy_to`的,主要是搜索的时候使用,本身并不会直接存入数据,所以这个字段如果有数据,我希望插入的时候把它忽略。

    • copyTo = "descriptiveContent":这个就是跟es的copy_to一样,就是说把当前属性拷贝到“descriptiveContent”当中,可以拷贝多个属性到同一个字段中,便于搜索、查询。

    • pattern = "uuuu-MM-dd HH:mm:ss" : 声明该自定义的格式字符串,一般在type = FieldType.Date时使用。

    • format:跟pattern差不多,官方解释是用于定义至少一种预定义格式。如果未定义,则使用默认值*_date_optional_time epoch_millis*。也就是只能使用给定的枚举值,不能自定义,自定义的话得用pattern。下图是谷歌翻译的官方解释:

      image-20221016134857918

实体类属性copy_to

大家都知道,在es当中如果有多个字段需要被同时查询(比如我的博客业务,要搜索内容的时候,我会把用户输入的关键字同时拿来匹配标题和文章描述),那可以用 multi_matchquery_string进行多字段查询,也可以用 copy_to将多个字段复制到一个新属性上再去查新属性,这几种方法都是可以的,但是copy_to它的性能会高一些,尤其是在同时要查的属性非常多的时候,这属于是一种储存换取速度的方式。

copy_to的属性在上面已经讲过了,跟es的api用来起来差不多的,但是我上面的代码还写了一个 descriptiveContent

  /**
 * 由其他属性copy而来,主要用于搜索功能,不需要储存数据
 */
@JsonIgnore
@Field(type = FieldType.Text, analyzer = "ik_max_word", ignoreFields = "descriptiveContent", excludeFromSource = true)
String descriptiveContent;

这个属性就是被cope_to到的那个属性,但实际上我们在写代码的时候并不会给它赋值或者取值或者别的怎么样,总是就是希望他尽可能透明,仅在对es时有效,因为es里是已经提前定义好这个索引库了的,es创建索引库的代码我会贴在文章最后。

这是因为,后面我们要使用 ElasticsearchRepository的时候,被查询的字段如果不存在于这个实体类,idea会有一个很碍眼的提示, 作为强迫症患者,这就引发了我的思考,是不是我们在定义实体类的时候,要和定义索引库的时候一样给出全部的字段呢?尽管这个字段只是一个“隐身”的字段。 为了把这个碍眼的提示去掉 为了让代码变得更可读一点,所以我加上了这个字段,并加了一些忽略的属性使它尽可能隐身。

Mapper层(Repository)

核心代码如下,具体解释和分析在下面:

  package cn.sticki.blog.content.mapper;

import cn.sticki.blog.content.pojo.BlogDoc;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Highlight;
import org.springframework.data.elasticsearch.annotations.HighlightField;
import org.springframework.data.elasticsearch.annotations.HighlightParameters;
import org.springframework.data.elasticsearch.core.SearchPage;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

/**
 * BlogRepository操作类
 * 提供save、findById、findAll、count、delete、exists等接口
 *
 * @author 阿杆
 * @version 1.0
 * @date 2022/7/9 10:53
 */
public interface BlogRepository extends ElasticsearchRepository<BlogDoc, Long> {

/**
 * 通过描述内容来搜索博客
 *
 * @param descriptiveContent 描述语句
 * @param pageable           分页
 * @return 博客列表
 */
@SuppressWarnings("SpringDataRepositoryMethodReturnTypeInspection")
@Highlight(fields = {
@HighlightField(name = "title", parameters = @HighlightParameters(requireFieldMatch = false)),
@HighlightField(name = "description", parameters = @HighlightParameters(requireFieldMatch = false)),
})
SearchPage<BlogDoc> findByDescriptiveContent(String descriptiveContent, Pageable pageable);

}

继承ElasticsearchRepository

  1. 这个其实就有点像继承 BaseMapper,它会给你提供一些基础的CRUD方法,方便你直接使用,比如save、delete、find之类的。

    image-20221016142548009

  2. 它是个泛型类,两个参数分别是 <实体类,id的类型>

  3. 在该接口下(BlogRepository)按照特殊的命名规则声明的方法,可以直接调用,不需要开发者实现接口,且它返回的内容是已经封装好的, 你需要的数据会被封装在你提供的实体类里面(不用手动解析数据)。

    大概就是 findByXxxAndXxxOrXxx() 这个类型,具体的可以参考官网: https://docs.spring.io/spring-data/elasticsearch/docs/4.3.5/reference/html/#elasticsearch.query-methods.criterions,这里也截一点给大家看看(谷歌浏览器翻译的):

    image-20221016142312365

    image-20221016142406280

  4. 也可以使用 @Query 注解写原生的 api 请求接口,不太优雅,个人不推荐使用。

然后这里我只添加了一个方法:

  SearchPage<BlogDoc> findByDescriptiveContent(String descriptiveContent, Pageable pageable);

这个意思就是所通过 DescriptiveContent 属性来查询数据,后面的两个参数一个是搜索的内容,一个是分页的参数(分页需要配合支持分页的返回值才行)。

这个findByXxx的Xxx属性必须是实体类里面存在的属性才可以,不然会提示错误:

image-20221016142649418

高亮显示

  @SuppressWarnings("SpringDataRepositoryMethodReturnTypeInspection")
@Highlight(fields = {
@HighlightField(name = "title", parameters = @HighlightParameters(requireFieldMatch = false)),
@HighlightField(name = "description", parameters = @HighlightParameters(requireFieldMatch = false)),
})

使用注解 @Highlight@HighlightField,来设置高亮的字段,使用 @HighlightParameters 来添加高亮的参数。

我这里设置了requireFieldMatch = false,这个参数是 取消只有字段匹配才给高亮的规则,这是因为我搜索的字段是由另外两个字符copyTo而来的,高亮的内容肯定是在另外两个字段里面,设置该参数可以让其他字段的高亮也展示出来。

这里还有一篇高亮显示的教程文章,我讲的比较粗糙,他这个写的比较详细,贴给大家学习: https://blog.csdn.net/qq_45794678/article/details/111188548

官方文档给的说明就这么点。。。 怕我学会了然后教别人吗。。。

image-20221016142838476

分页功能

通过 Pageable 做参数和 SearchPage 做返回值来完成了对分页的需求,传参的时候使用 PageRequest.of(page, size) 来创建分页参数即可。

得到结果后仅需将分页的内容替换掉实体类的内容即可,并且数据里面包含有获取页码的信息的接口:

image-20221016143118800

Service层

核心代码如下:

  @Service
public class BlogContentServiceImpl implements BlogContentService {

@Resource
private BlogRepository blogRepository;

/**
 * 搜索博客
 *
 * @param key 搜索内容
 * @param page 页码
 * @param size 页大小
 * @return 搜索到的结果列表
 */
@Override
public List<BlogDoc> searchBlog(String key, int page, int size) {
// 1. 获取数据
SearchPage<BlogDoc> searchPage = blogRepository.findByDescriptiveContent(
// 1.1 设置key和分页,这里是从第0页开始的,所以要-1
key,PageRequest.of(page - 1, size));
// 2. 高亮数据替换
List<SearchHit<BlogDoc>> searchHitList = searchPage.getContent();
ArrayList<BlogDoc> blogDocList = new ArrayList<>(searchHitList.size());
for (SearchHit<BlogDoc> blogHit : searchHitList) {
// 2.1 获取博客数据
BlogDoc blogDoc = blogHit.getContent();
// 2.2 获取高亮数据
Map<String, List<String>> fields = blogHit.getHighlightFields();
if (fields.size() > 0) {
// 2.3 通过反射,将高亮数据替换到原来的博客数据中
BeanMap beanMap = BeanMap.create(blogDoc);
for (String name : fields.keySet()) {
beanMap.put(name, fields.get(name).get(0));
}
}
// 2.4 博客数据插入列表
blogDocList.add(blogDoc);
}
return blogDocList;
}

}

替换高亮数据

到这里其实就只要做一件事了,因为Repository返回的数据已经帮你封装好实体类了,不需要再去json转bean了,它唯一的缺点就是,高亮数据还是得自己去做替换,所以我上面这些代码也就是做了这一件事,就是把高亮的数据替换掉原来的数据。

这里我用到了 BeanMap,代码里不用写死属性名称,相对来说更优雅一点,如果有需要的话,也可以把中间这一段分离成一个单独的方法,可以提供给不同的类使用。

数据同步

数据同步指的是 elasticsearchMySQL 的数据同步,由于我的项目做的是微服务架构,我的 博客服务博客内容服务是两个微服务(本文讲的是博客内容服务),博客服务提供文章的 增删改查功能,并连接MySQL,博客内容服务提供 搜索功能,并连接ES,故两者的数据需要同步。

这里我使用的是RabbitMQ,主要逻辑如下:

  1. 用户新建修改或删除博客时,博客服务发送消息到MQ中,发到自己的交换机里,并指定key。
  2. 内容服务提前创建队列并绑定到博客服务的交换机中。
  3. 当内容服务接收到消息时,做出对应的操作。

核心代码如下:

  /**
 * 内容服务对博客服务的消息队列监听器
 *
 * @author 阿杆
 * @version 1.0
 * @date 2022/7/10 9:32
 */
@Slf4j
@Component
public class BlogServerListener {

@Resource
private BlogRepository blogRepository;

@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(name = BLOG_EXCHANGE),
value = @Queue(name = BLOG_SAVE_QUEUE),
key = {BLOG_INSERT_KEY, BLOG_UPDATE_KEY}
))
public void saveListener(BlogDoc blogDoc) {
log.debug("save blogDoc,{}", blogDoc);
blogRepository.save(blogDoc);
}

@RabbitListener(bindings = @QueueBinding(
exchange = @Exchange(name = BLOG_EXCHANGE),
value = @Queue(name = BLOG_DELETE_QUEUE),
key = BLOG_DELETE_KEY
))
public void deleteListener(Long blogId) {
log.debug("delete blog ,id->{}", blogId);
blogRepository.deleteById(blogId);
}

}

其实可以看出,通过Repository来实现这些操作都是很简单的。

需要注意的是,这里的save操作,是 ES的全量更新,所以发送过来的数据,一定要是完整的数据,否则会导致部分字段丢失。

然后发送消息的大概就是代码是:

  rabbitTemplate.convertAndSend(BLOG_EXCHANGE, BLOG_UPDATE_KEY, blog);

MQ序列化配置

这里RabbitMQ的序列化配置我也贴一下,这个可以让MQ消息变成json格式的。

  package cn.sticki.common.amqp.autoconfig;

import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author 阿杆
 * @version 1.0
 * @date 2022/6/25 18:01
 */
@Configuration
public class AmqpMessageConverterConfig {

@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}

}

后记

本篇文章主要使用了 ElasticsearchRepository 和相关注解来完成了一些常有的需求,比较优雅(个人认为)的实现了查询分页和高亮的功能(网上找到的教程都没有把分页和高亮一起适配的)。但如果有更为复杂的需求,可能还是需要使用 ElasticsearchRestTemplate来完成。 官网: https://docs.spring.io/spring-data/elasticsearch/docs/4.3.5/reference/html/#elasticsearch.operations.resttemplate

相关 [spring data elasticsearch] 推荐:

✨基于Spring-Data-Elasticsearch 优雅的实现 多字段搜索 + 高亮 + 分页 + 数据同步✨

- - 掘金 后端
这是我参与「掘金日新计划 · 10 月更文挑战」的第17天, 点击查看活动详情. 本系列文章基于我的开源微服务项目【校园博客】进行分析和讲解,所有源码均可在GitHub仓库上找到. 系列文章地址请见我的 校园博客专栏. GitHub地址: https://github.com/stick-i/scblogs.

Spring Data JPA 简单介绍

- tangfl - BlogJava-首页技术区
考虑到公司应用中数据库访问的多样性和复杂性,目前正在开发UDSL(统一数据访问层),开发到一半的时候,偶遇SpringData工程. 于是就花了点时间了解SpringData,可能UDSL II期会基于SpringData做扩展. 介绍:针对关系型数据库,KV数据库,Document数据库,Graph数据库,Map-Reduce等一些主流数据库,采用统一技术进行访问,并且尽可能简化访问手段.

SpringSource发布Spring Data Redis 1.0.0

- - InfoQ cn
近日, SpringSource 发布了用于将Redis轻松集成到Java应用中的开源 库的首个稳定版. Redis是个由VMWare/SpringSource资助的键值存储,为一些高性能网站如GitHub与StackOverflow等所用. Redis是新近涌现的NoSQL数据存储之一,它关注于简单性与性能(整个数据集放在内存中).

spring data jpa简单实例

- - 编程语言 - ITeye博客
我们都知道Spring是一个非常优秀的JavaEE整合框架,它尽可能的减少我们开发的工作量和难度.   在持久层的业务逻辑方面,Spring开源组织又给我们带来了同样优秀的Spring Data JPA.   通常我们写持久层,都是先写一个接口,再写接口对应的实现类,在实现类中进行持久层的业务逻辑处理.

了解 Spring Data JPA - hungerW

- - 博客园_首页
自 JPA 伴随 Java EE 5 发布以来,受到了各大厂商及开源社区的追捧,各种商用的和开源的 JPA 框架如雨后春笋般出现,为开发者提供了丰富的选择. 它一改之前 EJB 2.x 中实体 Bean 笨重且难以使用的形象,充分吸收了在开源社区已经相对成熟的 ORM 思想. 另外,它并不依赖于 EJB 容器,可以作为一个独立的持久层技术而存在.

Spring Data JPA,基础学习笔记.

- - ITeye博客
最好先学习 JPA 方面的知识....在这里使用的是 Hibernate. 也已经使用了一段时间,看什么都不如看官方文档,我这里也只是写个笔记记录一下,如果能帮助到其他人,很开心 .算是个 demoshow 吧.这里也只写了一些我觉得比较有用的地方.其他一些使用知识,请参见官方文档:http://static.springsource.org/spring-data/data-jpa/docs/current/reference/html/.

文章: Spring Data —— 完全统一的API?

- - InfoQ cn
Spring Data 作为SpringSource的其中一个父项目, 旨在统一和简化对各类型持久化存储, 而不拘泥于是关系型数据库还是NoSQL 数据存储. 白皮书下载:JBoss Enterprise Application Platform 6迁移指南. 白皮书下载:从虚拟化到云:在云中优化和自动化IT.

使用 Spring Data JPA 简化 JPA 开发

- -
Spring Data JPA 让一切近乎完美. 通过前面的分析可以看出,Spring 对 JPA 的支持已经非常强大,开发者只需关心核心业务逻辑的实现代码,无需过多关注 EntityManager 的创建、事务处理等 JPA 相关的处理,这基本上也是作为一个开发框架而言所能做到的极限了. 然而,Spring 开发小组并没有止步,他们再接再厉,于最近推出了 Spring Data JPA 框架,主要针对的就是 Spring 唯一没有简化到的业务逻辑代码,至此,开发者连仅剩的实现持久层业务逻辑的工作都省了,唯一要做的,就只是声明持久层的接口,其他都交给 Spring Data JPA 来帮你完成.

spring boot与spring batch、postgres及elasticsearch整合

- - 互联网 - ITeye博客
当系统有大量数据需要从数据库导入elasticsearch时,使用sping batch可以提高导入的效率. 这篇文章使用spring batch将数据从postgres导入elasticsearch. 本文使用spring data jest连接ES(也可以使用spring data elasticsearch连接ES),ES版本为5.5.3.

使用Java 8 Streams和Spring Data JPA流式传输MySQL结果

- -
2015年10月19日|  KrešimirNesek. 从1.8版开始,Spring数据项目包含一个有趣的功能 - 通过一个简单的API调用,开发人员可以请求将数据库查询结果作为Java 8流返回. 在技​​术上可行并且由底层数据库技术支持的情况下,结果将逐个流式传输,并且可以使用流操作进行处理.