【主流技术】详解 SpringBoot 集成 ElasticSearch7.x 全过程
前言
ElasticSearch 简称 es,是一个开源的高扩展的分布式全文检索引擎,目前最新版本已经到了8.11.x了。
它可以近乎实时的存储、检索数据,且其扩展性很好,是企业级应用中较为常见的检索技术。
下面主要记录学习 ElasticSearch7.x 的一些基本结构、在Spring Boot 项目里基本应用的过程,在这里与大家作分享交流。
一、添加依赖
这里引用的依赖是 starter-data-elasticsearch,版本应与 Spring Boot(我是2.7.2)的版本一致,并不是 Elasticsearch 的版本。
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
<version>2.7.2</version>
</dependency>
二、 yml 配置
spring:
elasticsearch:
uris: http://远程主机的公网IP:9200
username: 自己的用户名
password: 自己的密码
使用 Docker 安装 Elasticsearch 教程见我写的另一篇博客:https://www.cnblogs.com/CodeBlogMan/p/16392792.html
使用 Docker 安装的 Elasticsearch 设置账号/密码教程:https://blog.csdn.net/qq_38669698/article/details/130529829
因为 ES 设置了密码,所以 Kibana 的配置也需要修改:https://blog.csdn.net/weixin_45956631/article/details/130636880
三、注入依赖
-
(推荐)ElasticsearchRestTemplate 类来源于 org.springframework.data.elasticsearch.core 包,封装了 Elasticsearch 的 RESTful API,使用起来很便捷。
//直接引入即可,无需额外的 Bean 配置和序列化配置 @Resource private ElasticsearchRestTemplate elasticTemplate;
-
(推荐)ElasticsearchRepository 接口来源于 org.springframework.data.elasticsearch.repository 包, 该接口用于简化对 Elasticsearch 中数据的操作。
public interface ArticleRepository extends ElasticsearchRepository<ESArticle, String>{}
注:ESArticle 为实体类,String 表示唯一 Id 的数据类型。
-
(不推荐)在 Elasticsearch 7.15版本之后,官方已将它的高级客户端 RestHighLevelClient 标记为弃用状态,之后的版本会推荐新的 RestClient。
经过笔者对比实践,无论是新/旧客户端,在 Spring Boot 项目中都没有上面前两个使用起来便捷。但值得注意的是,很多企业以前的项目都会使用旧的 RestHighLevelClient 来写业务。
@Resource private RestHighLevelClient highLevelClient; @Resource private RestClient restClient;
四、CRUD 常用 API
-
ES 实体类
和 MySQL、MongoDB 在 Spring 中的实体类一样,需要将字段和类属性进行映射,同样还可以使用注解进行简单配置。
以下是文章 ESArticle 的实体类,@Document(indexName = "article")表示对应的索引名称,字段属性包含标题、内容、标签、点赞数/收藏数等:
@Data @Document(indexName = "article") @EqualsAndHashCode(callSuper = true) public class ESArticle extends BaseEntity implements Serializable { /** * 自定义时间格式 */ private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; /** * 唯一标识 id */ @Id @Field(type = FieldType.Text) private String id; /** * 标题,字段类型为 Text,没有 String 类型;分词类型为 ik 分词器的最细颗粒度划分法。 */ @Field(type = FieldType.Text, analyzer = "ik_max_word") private String title; /** * 内容 */ @Field(type = FieldType.Text, analyzer = "ik_max_word") private String content; /** * 标签列表 */ private List<String> tags; /** * 点赞数 */ private Integer thumbNum; /** * 收藏数 */ private Integer favourNum; /** * 创建用户 id */ @Field(type = FieldType.Text) private String userId; /** * 创建时间,单独存储,字段类型为 Date ,自定义格式 */ @Field(store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN) private Date createTime; /** * 更新时间,单独存储,字段类型为 Date ,自定义格式 */ @Field(store = true, type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN) private Date updateTime; /** * 是否删除 */ private Integer isDelete; }
-
documents 操作
documents 的概念和 MySQL 中的行类似,指的是一条条的记录,但是 ES 里所有的数据都是 JSON 格式的,所以看起来就像是一个个文档了。
以下简单的 CRUD 都由 ArticleRepository 来完成,下一小节复杂的查询交给 ElasticsearchRestTemplate 来完成。
-
新增(批量)
@Resource private ArticleMapper articleMapper; @Resource private ArticleRepository articleRepository; //todo: ES里的数据来源于数据库,需要做迁移,业务数据不会直接写进 ES 里 //todo: 有全量和增量两种方式做数据迁移,或者引入第三方框架处理 //todo: 此处暂不做数据迁移展示,就直接往 ES 里写,然后就当 ES 里已经有数据了,再做 CRUD 以及查询 @Override public Boolean addDocuments(){ LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); List<Article> articleList = articleMapper.selectList(wrapper); if (CollectionUtils.isNotEmpty(articleList)){ // 这里是两个实体的属性转换,这里不过多展开讲 List<ESArticle> esArticleList = articleList.stream().map(ESArticle::dbToEs).collect(Collectors.toList()); articleRepository.saveAll(esArticleList); return Boolean.TRUE; } return Boolean.FALSE; }
-
修改(更新)
//todo: 还可以使用 elasticTemplate 的 update() 来进行更新,不过一般没有单独针对 es 的数据更新需求 @Override public Boolean updateDocuments(){ ESArticle esArticle = articleRepository.findById("18094375634670546").orElse(null); if (Objects.nonNull(esArticle)){ esArticle.setTitle("测试修改标题更新操作"); articleRepository.save(esArticle); return Boolean.TRUE; } return Boolean.FALSE; }
-
获取
@Override public List<ESArticle> getESDocuments(){ List<ESArticle> list = Lists.newArrayList(); Iterable<ESArticle> esArticleList = this.articleRepository.findAll(Sort.by(Sort.Order.desc("id"))); esArticleList.forEach(list::add); return list; }
-
删除
@Override public Boolean deleteESDocuments(){ //如果存在该条 document 则继续删除 if (this.articleRepository.existsById("18094375634670546")){ this.articleRepository.deleteById("18094375634670546"); return Boolean.TRUE; } return Boolean.FALSE; }
-
-
常见条件查询(重点)
以下会详细地演示一下 BoolQueryBuilder 条件构造、常见 QueryBuilders 的方法等多条件复杂查询场景:
//todo: 企业项目中真正的复杂条件查询 @Override public PageInfo<ESArticle> testSearchFromES(ArticleSearchDTO articleSearchDTO){ //完整的合法 id String id = articleSearchDTO.getId(); //非法 id String notId = articleSearchDTO.getNotId(); //搜索框输入的内容(实际会从标签/内容/标题中查找) String searchText = articleSearchDTO.getSearchWord(); //单独在标题中查找 String title = articleSearchDTO.getTitle(); //单独在内容中查找 String content = articleSearchDTO.getContent(); //单独在标签中查找(全部标签) List<String> tagList = articleSearchDTO.getTags(); //任意标签 List<String> orTagList = articleSearchDTO.getOrTags(); //按照创建者的 userId 查找 String userId = articleSearchDTO.getUserId(); // 布尔查询初始化 BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); // 过滤,首先被删除的就不要了 boolQueryBuilder.filter(QueryBuilders.termQuery(this.fn.fnToFieldName(ESArticle::getIsDelete), NumberUtils.INTEGER_ZERO)); //如果输入的是 id 那么就不对 id 分词,然后过滤掉不符合该 id 的其它文档 if (StringUtils.isNotBlank(id)) { boolQueryBuilder.filter(QueryBuilders.termQuery("id", id)); } //如果输入的是非法 id 那么什么也查不到,取反(也就是所有)返回 if (StringUtils.isNotBlank(notId)) { boolQueryBuilder.mustNot(QueryBuilders.termQuery("id", notId)); } //创建者 userId 也不分词,过滤掉不匹配的 if (StringUtils.isNotBlank(userId)) { boolQueryBuilder.filter(QueryBuilders.termQuery("createId", userId)); } // 必须包含所有标签 if (CollectionUtils.isNotEmpty(tagList)) { for (String tag : tagList) { boolQueryBuilder.filter(QueryBuilders.termQuery("tags", tag)); } } // 包含任何一个标签即可 if (CollectionUtils.isNotEmpty(orTagList)) { BoolQueryBuilder orTagBoolQueryBuilder = QueryBuilders.boolQuery(); // DB 实体中 tag 字段为 String,而 ES 实体该字段的类型为 List,所以做循环遍历 for (String tag : orTagList) { orTagBoolQueryBuilder.should(QueryBuilders.termQuery("tags", tag)).minimumShouldMatch(1); } //filter 可以结合 bool 做更复杂的过滤 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)); } // 单独按内容检索 if (StringUtils.isNotBlank(content)) { boolQueryBuilder.should(QueryBuilders.matchQuery("content", content)); } }
-
分页查询
Spring Data 自带的分页方案,即 PageRequest 对象:
// 分页参数:起始页为 0 long current = articleSearchDTO.getCurrent() - 1; long pageSize = articleSearchDTO.getPageSize(); PageRequest pageRequest = PageRequest.of((int) current, (int) pageSize);
-
排序
设置了按条件排序则以排序字段为准来返回,没设置排序则默认按照分数,即匹配度返回:
// 排序字段,可以支持多个 String sortField = articleSearchDTO.getSortField(); SortBuilder<?> sortBuilder = SortBuilders.scoreSort(); if (StringUtils.isNotBlank(sortField)) { sortBuilder = SortBuilders.fieldSort(sortField).order(SortOrder.DESC); }
-
构造查询
将所有的条件放进 NativeSearchQueryBuilder 对象,并调用elasticTemplate.search()方法,最后放入PageInfo(这里引入的是com.github.pagehelper)对象返回:
// 构造查询 NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(boolQueryBuilder) .withSorts(sortBuilder) .withPageable(pageRequest).build(); // 获取查询对象的结果:放入所有条件,指定索引实体 SearchHits<ESArticle> searchHits = elasticTemplate.search(searchQuery, ESArticle.class); //todo: 因为 ES 的数据源自 MySQL,这里其实要保证 ES 里的数据与 MySQL 数据一致; //todo: 那么先以 ES 里的数据为准,后面讲数据迁移方案的时候,再去保证两者的强一致。 //初始化 page 对象 PageInfo<ESArticle> pageInfo = new PageInfo<>(); pageInfo.setList(searchHits.getSearchHits().stream().map(SearchHit::getContent).collect(Collectors.toList())); pageInfo.setTotal(searchHits.getTotalHits()); System.out.println(pageInfo); return pageInfo;
-
测试调用
@Test public void testSearchFromES(){ ArticleSearchDTO articleSearchDTO = new ArticleSearchDTO(); articleSearchDTO.setId("18094375634670546"); //articleSearchDTO.setSearchWord("是"); //articleSearchDTO.setTitle("标题"); //articleSearchDTO.setTags(Collections.singletonList("es")); //articleSearchDTO.setSortField("createTime"); esTestService.testSearchFromES(articleSearchDTO); }
测试数据如下图所示:
五、文章小结
使用 ElasticSearch 实现全文检索的过程并不复杂,只要在业务需要的地方创建 ElasticSearch 索引,将数据放入索引中,就可以使用 ElasticSearch 集成在 Spring Boot 中对搜索对象进行查询操作了。
无论是创建索引、精准匹配、还是字段高亮等操作,其本质上还是一个面向对象的过程。和 Java 中的其它“对象”一样,只要灵活运用这些“对象”的使用规则和特性,就可以满足业务上的需求。
关于 ElasticSearch7.x 的基本结构和在 Spring Boot 项目中的集成应用就和大家分享到这里。如有错误和不足,还期待大家的指正与交流。
参考文档:
- ElasticSearch 官方查询 API 文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/search.html
- Spring Data ElasticSearch 官方:https://docs.spring.io/spring-data/redis/docs/2.6.10/api/