目录
前言
上一篇完成了elasticsearch、elasticsearch-head的搭建,这一章将带入spring-boot进行开发整合。spring-boot为java的数据交互提供了许多的便利。其中spring-data模块整合了市场上绝大部分的数据存储。
搭建环境
首先登录到spring官网找到spring data组件,可以看到其中的版本要求,可以参考表格选择对应的
这边之前安装的是elasticsearch的7.10.0版本。
然后再我们的工程pom文件中加上
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-elasticsearch</artifactId>
</dependency>
首先在我们的yml配置文件中,在spring目录下增加
elasticsearch:
rest:
username: elastic
password: elastic
uris: ['127.0.0.1:9200']
这里是配置了9200端口下连接的账号和密码,uris是一个数组项,意味着我们可以配置一个集群。
然后我们需要在spring boot工程下创建一个配置类
这个是本次实战中所使用的配置
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "spring.elasticsearch.rest")
public class ElasticSearchConfig extends AbstractElasticsearchConfiguration {
/**
* 用户名
**/
private String username;
/**
* 密码
**/
private String password;
/**
* 主机地址
**/
private List<String> uris;
@Override
@Bean("elasticsearchClient")
public RestHighLevelClient elasticsearchClient() {
if (CollectionUtils.isEmpty(uris)) {
log.error("elasticsearch 地址为空");
throw new RuntimeException("elasticsearch 配置地址为空");
}
HttpHost[] httpHosts = this.createHosts();
//凭证
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password));
RestHighLevelClient restHighLevelClient = new RestHighLevelClient(
RestClient.builder(httpHosts)
.setHttpClientConfigCallback(httpAsyncClientBuilder ->
httpAsyncClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
.setKeepAliveStrategy((response,context) -> TimeUnit.MINUTES.toMillis(3)))
.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder.setConnectTimeout(10000)
.setSocketTimeout(30000))
);
return restHighLevelClient;
}
private HttpHost[] createHosts() {
HttpHost[] httpHosts = new HttpHost[uris.size()];
for (int i = 0; i < uris.size(); i++) {
String hostStr = uris.get(i);
String[] split = hostStr.split(":");
httpHosts[i] = new HttpHost(split[0], Integer.parseInt(split[1]));
}
return httpHosts;
}
}
首先增加
@Data @Component @ConfigurationProperties(prefix = "spring.elasticsearch.rest")
这三个注解,将类声明为配置,然后声明几个成员变量,主要从前面的yml文件中读取,是账号、密码、服务地址。
类继承AbstractElasticsearchConfiguration,这个是spring data中推荐的rest高级客户端,我们需要实现里面的抽象方法elastisearchClient。同时将这个方法构建成一个bean,可以在其他地方使用。
官网上是另一种写法
如果需要增加自定义个配置可以参考下面的
上面配置类header,连接超时参数,授权访问,ssl连接等,
官网的通过构建了一个配置项ClientConfiguration来实现的。
实战中用了另一种使用RestClient.builder方法。(注意了一个是RestClient一个是RestClients,官网是使用了org.springframework.data.elasticsearch.client下面的RestClients,我这边另一种写法是org.elasticsearch.client下面的RestClient)。
为了兼容集群环境,我们需要创建一个方法可以输出一个数组参数,这里我设计了一个方法将原型配置文件中的uris做了一个转化。在配置类中设置了授权的账号密码,连接的超时时间,和客户端连接的保持时间。
这里一个简单可用的elasticsearchRestClient就完成了。
然后就是构建我们的实体对象,也就是我们orm层。
elasticsearch的多表结构设计
这次我们实战的主要业务环境是一个论坛,当中会出现的实体是帖子,评论,点赞
可以看到是一个多级的一对多结构。
elasticsearch属于非关系型数据库,它的搜索优势在于单表的查询,多表的关联不是它的强项。所以我们设计elasticsearch时需要注意尽量使用单表,哪怕是一定的数据冗余。但是在实际开发中往往有些不可控的因素,需要我们不得不设计一个多表的关联结构。
在elasticsearch中多表的关联结构一般是有两种解决方案:1 nested 2 父子文档。在我们设计多表结构之前我们先了解一下这几个elasticsearch的数据结构。首先是object,elasticsearch的存储结构是json的,本质上多表的扩展还是数据的一个冗余,将原来的单表结构横向扩展,丰富其单个数据类型的能力。
官网上是这样介绍object类型的
也就是在elasticsearch里面存储检索的时候,会把这个嵌套的object的key拼接起来。也就是搜索的时候需要带上完整的key路径。
而nested你可以理解是一个特殊的object类型,是为了解决array类型的object而产生的的。我们举例
在上图,两个内部字段first和last被解析成了数组,这个时候引发一个问题,当我们搜索一个first为John,last为White的人,理论上是搜索不到的,但是实际上却会有查询结果,这是因为这两个字段分别在两个数组中命中了信息却丢失了这两个字段相互之间的联系。这时将user类型改为nested,user就会作为一个隐藏的文档结构被存储,而不是像之前这样被解析成简单的key-value。
这个就是所谓的嵌套类型,适合数组类型,同时有需要独立维护这个数组数据的结构。
下面介绍的是parent-child类型,在elasticsearch新版本中也叫做join类型。通俗的说叫父子文档。
父子文档顾名思义就是有一个父文档和一个子文档
可以看到里面有一个特殊类型的字段类型为join,里面有一个键值对,分别是父级和子级的名称
这里join字段的值是父级文档question,说明这是一个父文档。
这里添加的是子文档,可以看到join类型字段上的值是answer。需要注意有个parent字段,这个字段的值就是我们该子文档对应的父文档。
这里归纳一些父子文档的规则。
- 每一个索引中只允许存在一个join类型的字段
- 父级文档和子级文档必须被存在同一个分片上,这个可以保证他们在子文档查询删除更新时可以被相同的路由。
有父子文档那就还有孙子路由,也就是多级的。
例如
在join类型字段里面可以在里面配置多级。需要明白的是不论是单层父子还是多层父子都需要在同一片分片上。这里简单的展开分片的概念。
在elasticsearch中存储数据的是索引等价于mysql中的table。在索引中存储了文档,而实际上索引内是有多个分片来组成的,分片它保存了一个索引的部分数据,是一个扩容的单元,一个最小的索引拥有一个分片。
这里要引入一个路由的概念,当存储文档的时候elasticsearch需要知道这个文档需要 存在哪个分片中。分片号的值有公式推倒
number_of_primarys_shards是主分片的数量,主分片可以理解就是我们集群中主从的主节点角色。这是一个固定值。routing是一个变量默认是_id,或者是一个自定义的值。最终的分片号在0到主分片数量减一。
那么如果父子文档的shard不同会发生什么呢?
当执行创建、更新、删除一个子文档时,elasticsearch需要指定父文档的id,也就是默认会限定分片。如果不是一致的分片那么在默认请求时会操作到另外的分片上,造成错误。
完成了嵌套文档、父子文档的解释后,我们开始spring data elasticsearch的实战。
spring data elasticsearch实战
用过spring data jpa的同学应该知道,spring data jpa可以通过正向工程在服务启动时创建实体对象映射到数据库中。那么spring data elasticsearch也可以。
这里我们举例
@Data
@Document(indexName = "forum_post")
public class ForumPostES {
/**
* 话题id
**/
@Id
@Field(type = FieldType.Long, name = "post_id")
private Long postId;
/**
* 标题
**/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String title;
/**
* 标题关键字 keyword类型可以做去重聚合
**/
@Field(type = FieldType.Keyword, name = "title_keyword")
private String titleKeyword;
/**
* 内容
**/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String content;
/**
* 内容不分词
**/
@Field(type = FieldType.Keyword, value = "content_keyword",ignoreAbove = 256)
private String contentKeyword;
/**
* 版块id
**/
@Field(type = FieldType.Integer, name = "plate_id")
private Integer plateId;
/**
* 版块中文名称
**/
@Field(type = FieldType.Keyword, name = "plate_name_cn")
private String plateNameCn;
/**
* 版块英文名称
**/
@Field(type = FieldType.Keyword, name = "plate_name_en")
private String plateNameEn;
/**
* 内容类型
**/
@Field(type = FieldType.Short, name = "content_type")
private Short contentType;
/**
* 发帖日期
**/
@Field(type = FieldType.Keyword, name = "gmt_created")
private String gmtCreated;
/**
* 回复日期
**/
@Field(type = FieldType.Keyword, name = "gmt_reply")
private String gmtReply;
/**
* 是否置顶
**/
@Field(type = FieldType.Boolean, name = "is_top")
private Boolean isTop;
/**
* 是否存档
**/
@Field(type = FieldType.Boolean, name = "is_archive")
private Boolean isArchive;
/**
* 置顶日期
**/
@Field(type = FieldType.Keyword, name = "gmt_top")
private String gmtTop;
/**
* 操作人
**/
@Field(type = FieldType.Keyword, name = "created_by")
private String createdBy;
/**
* 用户名
**/
@Field(type = FieldType.Text, analyzer = "ik_max_word", name = "username", searchAnalyzer = "ik_smart")
private String userName;
/**
* 用户名不分词
**/
@Field(type = FieldType.Keyword, name = "username_keyword")
private String userNameKeyword;
/**
* 头像
**/
@Field(type = FieldType.Keyword, name = "head_icon")
private String headIcon;
/**
* 浏览量
**/
@Field(type = FieldType.Integer, name = "read_count")
private Integer readCount;
/**
* 评论量
**/
@Field(type = FieldType.Integer, name = "reply_count")
private Integer replyCount;
/**
* 点赞量
**/
@Field(type = FieldType.Integer, name = "like_count")
private Integer likeCount;
/**
* 语言类型
**/
@Field(type = FieldType.Short)
private Short languageType;
@JoinTypeRelations(
relations = {
@JoinTypeRelation(parent = "forum_post", children = {"forum_post_like", "forum_reply", "forum_reply_like"})
}
)
private JoinField<Long> post_join;
/**
* 评论id
**/
@Field(type = FieldType.Long, name = "reply_id")
private Long replyId;
/**
* 话题点赞id
**/
@Field(type = FieldType.Long, name = "post_like_id")
private Long postLikeId;
/**
* 要搜索的类型
**/
@Field(type = FieldType.Short, name = "search_type")
private Short searchType;
/**
* 回复点赞id
**/
@Field(type = FieldType.Long, name = "reply_like_id")
private Long replyLikeId;
}
这里解释下所使用的注解
- @Document 声明一个index,定义在elasticsearch的索引名称,可以设置分片数,默认程序启动时可以创建索引到elasticsearch中。可以通过设置createIndex参数为flase,使启动时不创建
- @Id声明索引中的主键
- @Field声明映射的数据类型,别名,可以定义使用的分词器。例如text会默认使用分词而keyword不会
- @JoinRelationType定义一个join类型,里面可以设置父子的标记
定义完实体类之后可以创建一个repository,创建一个接口并继承ElasticsearchRepository。
这个类可以提供jpa的最基本方法。li
@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S var1);
<S extends T> Iterable<S> saveAll(Iterable<S> var1);
Optional<T> findById(ID var1);
boolean existsById(ID var1);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> var1);
long count();
void deleteById(ID var1);
void delete(T var1);
void deleteAll(Iterable<? extends T> var1);
void deleteAll();
}
例如这些简单的增删改查。
对于像mybatis这样的orm框架,在处理复杂的sql查询时提供了直接编写sql的方式。spring data
elasticsearch 的repository能提供的也只是简单的api。在一些复杂情况下我们需要使用RestHighLevelClient来执行一些复杂的操作
在这次的实战项目中我是这样设计的
首先我统一封装了一个请求elasticsearch的方法,通过传入searchrequest参数获取结果
@Autowired
private RestHighLevelClient elasticsearchClient;
/**
* @description 封装一个统一的查询返回
* @author zhou
* @create 2021/9/9 13:47
* @param searchRequest 查询请求
* @return org.elasticsearch.action.search.SearchResponse
**/
@Override
@Retryable(value = ServiceException.class,backoff = @Backoff(delay = 500),recover = "recover")
public SearchResponse getSearchResponse(SearchRequest searchRequest){
SearchResponse searchResponse;
log.info("尝试连接");
log.info("查询参数:[{}]",searchRequest.source().toString());
try {
searchResponse = elasticsearchClient.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("ES服务连接异常");
throw new ServiceException(ErrorEnum.ELASTIC_SEARCH);
}
return searchResponse;
}
由于elasticsearch的查询比较多样复杂,展开的话会有很多的点,这里就放上一份我在项目所使用的代码
@Override
public Page<ForumPostES> queryPostPage(ForumPostQuery forumPostQuery) {
SearchRequest searchRequest = new SearchRequest("forum_post");
//构造分页器
PageRequest pageRequest = PageRequest.of(forumPostQuery.getPageNum() - 1, forumPostQuery.getPageSize());
//构造查询条件
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
//字段过滤
String[] exclude = {"reply_id", "post_like_id", "reply_like_id"};
String[] include = {};
sourceBuilder.fetchSource(include, exclude);
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//只查询话题
boolQueryBuilder.must(QueryBuilders.termQuery("search_type", SearchTypeEnum.POST.getCode()));
//非存档
boolQueryBuilder.must(QueryBuilders.termQuery("is_archive", false));
if (null != forumPostQuery.getPlateId()) {
//版块id
boolQueryBuilder.must(QueryBuilders.termQuery("plate_id", forumPostQuery.getPlateId()));
}
if (!CollectionUtils.isEmpty(forumPostQuery.getLanguageTypeList())) {
//语言
boolQueryBuilder.must(QueryBuilders.termsQuery("language_type", forumPostQuery.getLanguageTypeList()));
}
if (StringUtils.isNotBlank(forumPostQuery.getKeyword())) {
String keyword = forumPostQuery.getKeyword().toLowerCase();
//分词搜索
BoolQueryBuilder keywordBuilder = QueryBuilders.boolQuery();
keywordBuilder.should(QueryBuilders.matchPhraseQuery("title", keyword).boost(6));
keywordBuilder.should(QueryBuilders.wildcardQuery("title.keyword", "*" + keyword + "*").boost(6));
keywordBuilder.should(QueryBuilders.matchPhraseQuery("username", keyword).boost(4));
keywordBuilder.should(QueryBuilders.wildcardQuery("username_keyword", "*" + keyword + "*").boost(4));
keywordBuilder.should(QueryBuilders.matchPhraseQuery("content", keyword).boost(6));
keywordBuilder.should(QueryBuilders.wildcardQuery("content_keyword", "*" + keyword + "*").boost(4));
boolQueryBuilder.must(keywordBuilder);
}
if (null != forumPostQuery.getWithMe()) {
//与我相关
QueryBuilder queryBuilder = this.getChild(forumPostQuery.getWithMe(), forumPostQuery.getAccount());
if (null != queryBuilder) {
boolQueryBuilder.must(queryBuilder);
}
}
//条件查询
sourceBuilder.query(boolQueryBuilder);
//高亮
//sourceBuilder.highlighter(highlightBuilder);
//分页
int from = pageRequest.getPageNumber() * pageRequest.getPageSize();
sourceBuilder.from(from).size(pageRequest.getPageSize());
//排序
sourceBuilder.sort("is_top", SortOrder.DESC);
sourceBuilder.sort("gmt_top",SortOrder.DESC);
sourceBuilder.sort("_score", SortOrder.DESC);
if (null != forumPostQuery.getSortType()) {
//动态排序
this.getOrder(forumPostQuery.getSortType(), sourceBuilder);
sourceBuilder.sort("gmt_created", SortOrder.DESC);
}
searchRequest.source(sourceBuilder);
sourceBuilder.collapse(new CollapseBuilder("post_id"));
SearchResponse searchResponse = elasticsearchDao.getSearchResponse(searchRequest);
List<ForumPostES> forumPostESList = new ArrayList<>(pageRequest.getPageSize());
//boolean hasKeyword = StringUtils.isNotBlank(forumPostQuery.getKeyword());
for (SearchHit hit : searchResponse.getHits().getHits()) {
JSONObject jsonObject = JSONObject.parseObject(hit.getSourceAsString());
ForumPostES forumPostES = JSON.toJavaObject(jsonObject, ForumPostES.class);
forumPostESList.add(forumPostES);
}
return new PageImpl<ForumPostES>(forumPostESList, pageRequest, searchResponse.getHits().getTotalHits().value);
}
- 首先通过SearchRequest指定我们要查询的索引
- 如果需要分页就构造PageRequest。(注意,elasticsearch有深分页问题需要通过scroll解决,分页数较少的不需要考虑)在后续的SearchSourceBuilder中设置from和size指定开始记录和每页大小。
- 构造一个SearchSourceBuilder作为查询器,使用builder模式
- 使用查询器的fetchSource可以实现mysql中的投影操作,过滤字段
- 通过QueryBuilders.boolQuery()创建一个布尔查询,可以使用当中的must,should,mustNot即我们在mysql中所使用的and 、or 、!=
- termQuery指的是精确查询即mysql中的=
- matchPhraseQuery指的是短语查询
- wildcardQuery指的是模糊查询,可以使用通配符*
- 父子查询需要构造JoinQueryBuilders,查询子文档使用hasChildQuery,查询父文档使用hasParentQuery
- 排序使用SearchSourceBuilder的sort方法指定排序的字段
- collapse类似于mysql的distinct实现字段的对应去重
这里基本简单的展示了elasticsearch的查询api,可以解决大部分的查询问题。
到此spring boot elasticsearch 整合部分就完成了。