我想大家都知道软件技术的更新迭代速度非常快,Spring技术栈的迭代步伐更是让我们这些Java程序猿有些望而却步,像Springboot、Springdata、Springcloud这些技术,我们这些做Java的,我想大家无论多忙也会抽时间去跟一下。无论热爱技术也好,为了工作也罢,就是那句古话,学无止境。
言归正传,因为最近要升级Elasticsearch, 之前延用的是6.x版本,众所周知,7.x较6.x版本改动还是有点大的,个人觉得Elasticsearch技术栈的学习资料较其他常用的技术栈要少一些,自己在研究整合时候参考较多的都是官网和JavaDoc, 强烈建议首先要系统的学习一下Elasticsearch, 起码能在kibana中对Elasticsearch操作自如,有助于在整合时了解API的含义,其实SpringData也就是把你在kibana中对Elasticsearch的JSON操作基于RestHighLevelClient再封装成了Template, Springboot的一贯风格。
关于Elasticsearch7.x 与 SpringData的技术我在这里就不介绍了,大家可以参考官网或其他资料,下面贴上官网地址
说明一下,整合以Demo代码案例驱动,主要针对于包含了父子文档,Java如何操纵Elasticsearch,自己对Elasticsearch 7.6.2操作进行了封装,如果单文档、基本的repository如果能解决你的问题,可以没有必用我封装的这一套。Elasticsearch 的数据建模设计原则是:能单文档解决,坚决不用关系文档,You know, for search.
1. 前置准备
- 搭建ELK环境,Elasticsearch 版本为
7.6.2
,并且安装ik中文分词器(其它中文分词器自便),Kibana版本为7.6.2
与 Elasticsearch版本一致
2. 整合从pom开始
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
<relativePath/>
</parent>
<groupId>cn.apelx</groupId>
<artifactId>elasticsearch</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>elasticsearch</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<hutool-core.version>5.3.8</hutool-core.version>
<fastjson.version>1.2.62</fastjson.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
<version>${hutool-core.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3. application.properties
# 集群多个逗号隔开
spring.elasticsearch.rest.uris=http://localhost:9200
spring.elasticsearch.rest.connection-timeout=1
spring.elasticsearch.rest.read-timeout=30
这里我具体研究的是spring-data-elasticsearch 封装的 ElasticsearchRestTemplate
API, 只需要如上配置,而单文档你用repository的话,如上配置也足矣
4. Mapping操作的封装
直接使用 Spring-Data-Elasticsearch 的 @Document
和 @Field
注解,用repository.save(S entity)方法, SpringData 会根据你@Field注解的属性自动创建mapping。 而实际生产环境中,自动生成的mapping往往会有很多问题,达不到预期的业务需求,所以还是建议大家自己去定义mapping。可以先在Kibana中插入数据,查看自动生成的mapping, 自己对mapping json 做好业务相关调整后,再放入代码中。
因为mapping设置都是json, 基于Java的面向对象思想,我将mapping映射封装成了JavaBean, 先贴一段mapping
{
"users" : {
"mappings" : {
"properties" : {
"_class" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"analyzer" : "ik_max_word",
"search_analyzer": "ik_max_word"
},
"age" : {
"type" : "integer"
},
"birth" : {
"type" : "date",
"format" : "uuuu-MM-dd'T'HH:mm:ss"
},
"firstName" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"analyzer" : "ik_max_word",
"search_analyzer": "ik_max_word"
},
"lastName" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"analyzer" : "ik_max_word",
"search_analyzer": "ik_max_word"
},
"money" : {
"type" : "double"
},
"pmsContent" : {
"type" : "keyword"
},
"pmsId" : {
"type" : "long"
},
"userGuid" : {
"type" : "keyword"
},
"userId" : {
"type" : "keyword"
},
"userPermissionRelation" : {
"type" : "join",
"eager_global_ordinals" : true,
"relations" : {
"users" : "permission"
}
}
}
}
}
}
对比如上mapping, 给大家描述一下封装的JavaBean
PropertiesMapping
/**
* Properties Mapping
*
* @author lx
* @since 2020/7/17 17:32
*/
@Data
@NoArgsConstructor
public class PropertiesMapping {
/**
* 字段名称
*/
private @NonNull String fieldName;
/**
* 字段映射(与关系映射互斥)
*/
private FieldMapping fieldMapping;
/**
* 关系映射(与字段映射互斥)
*/
private RelationshipMapping relationshipMapping;
public PropertiesMapping(@NonNull String fieldName, FieldMapping fieldMapping) {
this.fieldName = fieldName;
this.fieldMapping = fieldMapping;
}
public PropertiesMapping(@NonNull String fieldName, RelationshipMapping relationshipMapping) {
this.fieldName = fieldName;
this.relationshipMapping = relationshipMapping;
}
}
PropertiesMapping 类对应如上json中key为 properties
下的每一个字段的设置, fieldName
对应每个字段名称; fieldMapping
对应每个字段的具体mapping设置; relationshipMapping
对应关系文档的设置(join)
FieldMapping
/**
* 字段映射
*
* @author lx
* @since 2020/7/17 17:33
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class FieldMapping {
/**
* 字段类型
*/
private String type;
/**
* 是否索引字段
*/
private Boolean index;
/**
* 格式化样式(data字段)
*/
private String format;
/**
* 分词器
*/
private String analyzer;
/**
* 搜索分词器
*/
@JsonProperty(value = "search_analyzer")
private String searchAnalyzer;
/**
* 字段长度限制
*/
@JsonProperty(value = "ignore_above")
private Integer ignoreAbove;
/**
* fields 子字段
*/
private SubfieldMapping fields;
public FieldMapping(String type) {
this.type = type;
}
public FieldMapping(String type, Boolean index) {
this.type = type;
this.index = index;
}
public FieldMapping(String type, Boolean index, String format) {
this.type = type;
this.index = index;
this.format = format;
}
public FieldMapping(String type, String analyzer, String searchAnalyzer) {
this.type = type;
this.analyzer = analyzer;
this.searchAnalyzer = searchAnalyzer;
}
public FieldMapping(String type, Boolean index, String analyzer, String searchAnalyzer, SubfieldMapping fields) {
this.type = type;
this.index = index;
this.analyzer = analyzer;
this.searchAnalyzer = searchAnalyzer;
this.fields = fields;
}
}
FieldMapping类中具体对应mapping映射JavaDoc都有标注, SubfieldMapping
对应的是当前字段的子字段,例如你需要再当前text类型字段下加上一个keyword子字段不分词索引,可以加添加此属性
SubfieldMapping
/**
* 子字段Mapping
*
* @author lx
* @since 2020/7/17 17:33
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SubfieldMapping {
/**
* keyword 映射
*/
private KeywordMapping keyword;
}
SubfieldMapping 类中我只封装了一个keyword, 对应你所需要添加的子字段映射。若一个子字段无法满足你的业务需求,可以添加多个子字段,根据业务需求基于此类再封装
KeywordMapping
/**
* Keyword Mapping
*
* @author lx
* @since 2020/7/17 17:39
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class KeywordMapping {
/**
* 子字段类型
*/
private String type;
/**
* 字段长度限制
*/
@JsonProperty(value = "ignore_above")
private Integer ignoreAbove;
public KeywordMapping(String type) {
this.type = type;
}
}
RelationshipMapping
/**
* 关系映射
*
* @author lx
* @since 2020/7/20 22:50
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RelationshipMapping {
/**
* 类型; 推荐join
*/
private String type;
/**
* 是否开启全局预加载,加快查询;
* 此参数只支持text和keyword,keyword默认可用,而text需要设置fielddata属性
*/
@JsonProperty(value = "eager_global_ordinals")
private Boolean eagerGlobalOrdinals;
/**
* 关系描述("parentDocName": ["sonDocName" ...])
*/
private JSONObject relations;
}
RelationshipMapping 类封装了mapping中关系的映射, relations
字段对应你的关系描述, 例如父文档名称为 user
, 子文档名称为 permission
, 那么relastions的key应该为 "user"
, value为 Collections.singletonList("permission")
, 如果父子文档不满足你的业务,还要涉及到子孙文档,那么推荐你分两个Index 查询多次,Elasticsearch 不是关系型数据库,它擅长的不是关系的处理
。
基于如上JSON,如下贴出如何通过JavaBean封装得到mapping
public List<PropertiesMapping> testGetMappingJavaBean() {
List<PropertiesMapping> propertiesMappingList = new ArrayList<>();
// -------------------父文档 User Mapping -------------------------------
FieldMapping classFieldMapping = new FieldMapping("text", Boolean.TRUE, "ik_max_word", "ik_max_word",
new SubfieldMapping(new KeywordMapping("keyword", 256)));
PropertiesMapping classPropertiesMapping = new PropertiesMapping("_class", classFieldMapping);
propertiesMappingList.add(classPropertiesMapping);
FieldMapping userIdFieldMapping = new FieldMapping("keyword", Boolean.TRUE);
PropertiesMapping userIdPropertiesMapping = new PropertiesMapping("userId", userIdFieldMapping);
propertiesMappingList.add(userIdPropertiesMapping);
FieldMapping ageFieldMapping = new FieldMapping("integer", Boolean.TRUE);
PropertiesMapping agePropertiesMapping = new PropertiesMapping("age", ageFieldMapping);
propertiesMappingList.add(agePropertiesMapping);
FieldMapping birthFieldMapping = new FieldMapping("date", Boolean.TRUE, "uuuu-MM-dd'T'HH:mm:ss");
PropertiesMapping birthPropertiesMapping = new PropertiesMapping("birth", birthFieldMapping);
propertiesMappingList.add(birthPropertiesMapping);
FieldMapping firstNameFieldMapping = new FieldMapping("text", Boolean.TRUE, "ik_max_word", "ik_max_word", new SubfieldMapping(new KeywordMapping("keyword", 256)));
PropertiesMapping firstNamePropertiesMapping = new PropertiesMapping("firstName", firstNameFieldMapping);
propertiesMappingList.add(firstNamePropertiesMapping);
FieldMapping lastNameFieldMapping = new FieldMapping("text", Boolean.TRUE, "ik_max_word", "ik_max_word", new SubfieldMapping(new KeywordMapping("keyword", 256)));
PropertiesMapping lastNamePropertiesMapping = new PropertiesMapping("lastName", lastNameFieldMapping);
propertiesMappingList.add(lastNamePropertiesMapping);
FieldMapping moneyFieldMapping = new FieldMapping("double", Boolean.TRUE);
PropertiesMapping moneyPropertiesMapping = new PropertiesMapping("money", moneyFieldMapping);
propertiesMappingList.add(moneyPropertiesMapping);
FieldMapping userGuidFieldMapping = new FieldMapping("keyword", Boolean.TRUE);
PropertiesMapping userGuidPropertiesMapping = new PropertiesMapping("userGuid", userGuidFieldMapping);
propertiesMappingList.add(userGuidPropertiesMapping);
// ---------------关联关系; permission---------------
JSONObject relations = new JSONObject();
relations.put("users", Collections.singletonList("permission"));
RelationshipMapping relationshipMapping = new RelationshipMapping("join", Boolean.TRUE, relations);
PropertiesMapping uprPropertiesMapping = new PropertiesMapping("userPermissionRelation", relationshipMapping);
propertiesMappingList.add(uprPropertiesMapping);
// -----------------子文档 Permission Mapping --------------------------
FieldMapping pmsIdFieldMapping = new FieldMapping("long", Boolean.TRUE);
PropertiesMapping pmsIdPropertiesMapping = new PropertiesMapping("pmsId", pmsIdFieldMapping);
propertiesMappingList.add(pmsIdPropertiesMapping);
FieldMapping pmsContentFieldMapping = new FieldMapping("keyword", Boolean.TRUE);
PropertiesMapping pmsContentPropertiesMapping = new PropertiesMapping("pmsContent", pmsContentFieldMapping);
propertiesMappingList.add(pmsContentPropertiesMapping);
return propertiesMappingList;
}
如何将如上Mapping put 到 Elasticsearch中,调用下面封装的 ElasticsearchUtils
中的工具方法 putMapping
即可
5. 业务 Entity
案例实体为一对多,分别是用户 User
及权限 Permission
,一个User有多条Permission
父子关系的文档在Elasticsearch 中必须保存在一个Index中,而在Java中,往往一对多关系是多个Entity, 毕竟我们都有面向对象的思想,那么一些列的问题就来了,且细听我分说
首先贴上entity
User
/**
* 用户实体类
*
* @author lx
* @since 2020/7/17 13:55
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "users", replicas = 1, shards = 1, createIndex = true)
public class User implements Serializable {
@Id
private String userId;
@Field(type = FieldType.Keyword)
private String userGuid;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
private String firstName;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
private String lastName;
@Field(type = FieldType.Integer)
private Integer age;
@Field(type = FieldType.Double)
private Double money;
/**
* 1. Jackson日期时间序列化问题:
* Cannot deserialize value of type `java.time.LocalDateTime` from String "2020-06-04 15:07:54": Failed to deserialize java.time.LocalDateTime: (java.time.format.DateTimeParseException) Text '2020-06-04 15:07:54' could not be parsed at index 10
* 解决:@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
* 2. 日期在ES存为long类型
* 解决:需要加format = DateFormat.custom
* 3. java.time.DateTimeException: Unable to obtain LocalDate from TemporalAccessor: {DayOfMonth=5, YearOfEra=2020, MonthOfYear=6},ISO of type java.time.format.Parsed
* 解决:pattern = "uuuu-MM-dd HH:mm:ss" 即将yyyy改为uuuu,或8uuuu: pattern = "8uuuu-MM-dd HH:mm:ss"
* 参考:https://www.elastic.co/guide/en/elasticsearch/reference/current/migrate-to-java-time.html#java-time-migration-incompatible-date-formats
*/
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
@Field(type = FieldType.Date, format = DateFormat.custom, pattern = "uuuu-MM-dd'T'HH:mm:ss")
private LocalDateTime birth;
private RelationModel userPermissionRelation;
}
这里有特别注意的三点
- @Document注解 since 4.0 已经过期了type属性
- @Id 注解标注的字段,无法再用@Filed注解去标注字段的Elasticsearch 类型,否则用repository save 的时候会报错
- Java中的字段难免会和日期打交道,但是Elasticsearch 对应的无法使用 java.util.Date, 它默认的序列化为long型的时间戳,不仅不符合我们的需求,并且反序列化时会报错,包括其他的时间类,都做了尝试,最后在官网和博客中找到了解决方案,JavaDoc中贴了资料地址,有兴趣可以自行研究
这里的 userPermissionRelation
是join关系映射的自定义名称, RelationModel
是我封装的保存父子文档关系join的映射,如下贴出
RelationModel
/**
* 关系Model
*
* @author lx
* @since 2020/7/20 23:45
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RelationModel {
/**
* 关系名称
*/
private @NonNull
String name;
/**
* 父文档ID
*/
private @Nullable
String parent;
public RelationModel(String name) {
this.name = name;
}
}
父文档relationModel.parent为空即可,子文档需填写父文档ID,为了添加Join关系与索引时指定routing
Permission
/**
* 权限
*
* @author lx
* @since 2020/7/21 10:17
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "users", replicas = 1, shards = 1, createIndex = true)
public class Permission implements Serializable {
@Id
private Long pmsId;
@Field(type = FieldType.Keyword)
private String pmsContent;
private RelationModel userPermissionRelation;
}
注意:这里User 与 Permission 所对应的indexName 是相同的,即保存在同一索引中,否则就无父子文档的概念了,你会发现你搜索不到你想要的结果
那么问题来了,在Elasticsearch中,一个Index只有一个_id字段,我在JavaBean中对一个Index分了多个Entity, 那么这个时候,再用spring-data-elasticsearch 的repository去操作我们的entity 就会有问题了,那么怎么解决呢?说一下我自己的解决思路与过程
- 首先第一步肯定是put mapping 到Elasticsearch 中,基于自己封装的JavaBean 与 ElascticsearchRestTemplate, 自己封装了工具类方法
- 第二步索引父/子文档入Elasctisearch。父文档没什么问题,如普通的单文档索引一样,子文档索引时需要拿到父文档ID,也就是要获取到我封装的
RelationModel .parent
字段,怎么办?原本我是定义了接口,侵入业务entity, 也就是你的entity必须实现我的接口,接口中必须实现两个方法,1是返回文档ID,2是返回父文档ID,但是这样的侵入有些不太合理,怎么解决呢?最后还是选择了反射,因为使用spring-data-elasticsearch, 你必须要用到@Dcument与@Id注解,那么我就反射获取@Id注解的字段,获取文档ID值,再反射获取RelationModel .parent
父文档ID字段值,那么问题就迎刃而解了!
有个小细节先再这里说一下,因为操作json推荐使用jackson的ObjectMapper
, 在我们写mapping入Elasticsearch的时候,JavaBean中为null的字段不能序列化,其二索引父子文档的时候,因为都在一个Index中,我们又分开了业务Entity, 所以索引入Elasticsearch时空字段也无需序列化,所以ObjectMapper要设置null值不参与序列化。还有个细节是ObjectMapper无法正确正/反序列化LocalDateTime
,所以我们要自定义Jackson的LocalDateTime正/反序列化类,设置入ObjectMapper中 - 第三步,也是比较难的痛点,你分开了Entity, 那么你查询的时候出来的数据就会非预期了,因为现在User、Permission 是存在同一个名为
users
的索引中的,在Elasticsearch中你查一个索引, 索引下数据都会出来,但实际业务场景下,虽然User、Permission是存在一个Index中,但是我只想查询User或只想查询Permission怎么办呢? 一个macthAll()方法,会将你Index下所有的文档都返回了,那不是我想要的结果。刚开始我看到了官网的ElasticsearchCustomConversions
, 转换你的类,我希望在返回之后,如果查询的User,那么我在conversions中剔除掉Permission,反之亦然,但是一番尝试之后我发现,它只是把中间转换的部分交给你处理,查询出的还是Index下所有的文档,我尝试在conversions
方法中返回null, 但人家不允许你返回null,会报错,跟了源码发现,人家是查询好了,不为null了,怎么转成Map, 你可以自定义,Map怎么转成实体类你可以自定义,但是你不能改人结果集的多少,此路不通。
而后,我就想看能不能看看它是怎么查询的,能不能改它的查询策略,想法固然美好,跟了源码后,底层的RestHighLevelClient
,想改人源码纯属空闲时间太多,应变一下最后想到了解决方法:
封装一个查询方法,默认查询包含existQuery("标注@Id的字段")
,也就是说你想查询User, 你给我User.class, 我会默认给你加上 existQuery(“userId”), 一次只会在一个Index中查询一个类型的文档,如果你想查询Permission, 那么我会加上 existQuery(“pmsId”), 这样就会限制你的查询返回单类型(父或子)文档。 那么如果你不想限制,就想一个findAll查出全部的文档,那么就比较简单了,别分开Entity, 一个Index下的业务类,放到一个Entity中,你就可以直接使用Spring-Data-Elasticsearch 封装的Repository了, save, findAll 等一系列方法,不够还能用spring-data那一套,写个方法名还不用写实现,看大家的业务场景需求,和个人选择吧
下面贴上思路实现的封装
6. 封装的工具类
ElasticsearchUtils
/**
* Elasticsearch工具类
*
* @author lx
* @since 2020/7/17 10:28
*/
@Component
public class ElasticsearchUtils {
private ObjectMapper objectMapper;
private ElasticsearchRestTemplate elasticsearchRestTemplate;
private static final String PROPERTIES_KEY = "properties";
@PostConstruct
public void init(){
JavaTimeModule timeModule = new JavaTimeModule();
timeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
timeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer());
// 设置NULL值不参与序列化
objectMapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL).registerModule(timeModule);
}
/**
* 判断索引是否存在
*
* @param indexName 索引名称
* @return 是否存在索引
*/
public boolean existIndex(String indexName) {
if (StringUtils.isNotEmpty(indexName)) {
return elasticsearchRestTemplate.indexOps(IndexCoordinates.of(indexName)).exists();
}
return Boolean.FALSE;
}
/**
* 索引不存在时创建索引
*
* @param indexName 索引名称
* @return 是否创建成功
*/
public boolean createIndexIfNotExist(String indexName) {
if (!existIndex(indexName)) {
return elasticsearchRestTemplate.indexOps(IndexCoordinates.of(indexName)).create();
}
return Boolean.FALSE;
}
/**
* 索引存在删除索引
*
* @param indexName 索引名称
* @return 是否删除成功
*/
public boolean deleteIndexIfExist(String indexName) {
if (existIndex(indexName)) {
return elasticsearchRestTemplate.indexOps(IndexCoordinates.of(indexName)).delete();
}
return Boolean.FALSE;
}
/**
* 设置索引Mapping
*
* @param indexName 索引名称
* @param propertiesMappingList properties字段映射封装类集合
* @return 是否设置Mapping成功
*/
public boolean putMapping(String indexName, List<PropertiesMapping> propertiesMappingList) {
if (StringUtils.isNotEmpty(indexName)) {
createIndexIfNotExist(indexName);
return elasticsearchRestTemplate.indexOps(IndexCoordinates.of(indexName)).putMapping(Document.parse(getJsonMapping(propertiesMappingList)));
}
return Boolean.FALSE;
}
/**
* 获取Mapping映射JSON字符串
*
* @param propertiesMappingList properties封装类集合
* @return Mapping映射JSON字符串
*/
private String getJsonMapping(List<PropertiesMapping> propertiesMappingList) {
JSONObject fieldsJson = new JSONObject();
if (propertiesMappingList != null && !propertiesMappingList.isEmpty()) {
propertiesMappingList.forEach(propertiesMapping ->
// 关系映射优先于字段映射
fieldsJson.put(propertiesMapping.getFieldName(), propertiesMapping.getRelationshipMapping() != null ?
propertiesMapping.getRelationshipMapping() : propertiesMapping.getFieldMapping()));
}
JSONObject propertiesJson = new JSONObject();
propertiesJson.put(PROPERTIES_KEY, fieldsJson);
try {
return objectMapper.writeValueAsString(propertiesJson);
} catch (JsonProcessingException e) {
e.printStackTrace();
return propertiesJson.toJSONString();
}
}
/**
* 新增文档
*
* @param indexName 索引名称
* @param elasticsearchModel elasticsearch文档; 文档需标注@Document注解、包含@Id注解字段, 且@Id注解标注的文档ID字段值不能为空
* @return 文档ID
*/
public <T> String indexDoc(String indexName, T elasticsearchModel) {
if (existIndex(indexName)) {
return elasticsearchRestTemplate.index(new IndexQueryBuilder().withId(getDocumentIdValue(elasticsearchModel))
.withObject(elasticsearchModel).build(), IndexCoordinates.of(indexName));
}
return null;
}
/**
* 批量新增文档
*
* @param indexName 索引名称
* @param docList elasticsearch文档集合; 文档需标注@Document注解、包含@Id注解字段, 且@Id注解标注的文档ID字段值不能为空
* @return 文档ID
*/
public <T> List<String> bulkIndexDoc(String indexName, List<T> docList) {
if (existIndex(indexName) && docList != null && !docList.isEmpty()) {
List<IndexQuery> indexQueries = new ArrayList<>();
docList.forEach(doc ->
indexQueries.add(new IndexQueryBuilder().withId(getDocumentIdValue(doc)).withObject(doc).build()));
return elasticsearchRestTemplate.bulkIndex(indexQueries, IndexCoordinates.of(indexName));
}
return null;
}
/**
* 批量索引子文档
* 数量由外部控制; 单次bulk操作控制50条
*
* @param indexName 索引名称
* @param subDocList elasticsearch子文档集合;
* 子文档需标注@Document注解、包含@Id注解字段, 且@Id注解标注的文档ID字段值不能为空
* 子文档需包含RelationModel.class字段、且字段RelationModel.parent值不为null
* @param subRelationName 子文档关系名; 匹配subDocList中RelationModel.name值
* @return 索引文档ID集合
*/
public <T> List<String> bulkIndexSubDoc(String indexName, List<T> subDocList, String subRelationName) {
if (existIndex(indexName) && subDocList != null && !subDocList.isEmpty()) {
Map<String, List<T>> groupMap = groupByParentId(subDocList, subRelationName);
List<String> result = new ArrayList<>();
// 根据父文档ID分组循环
groupMap.forEach((parentId, subDocGroupList) -> {
List<IndexQuery> queries = new ArrayList<>();
subDocGroupList.forEach(subGroupDoc ->
queries.add(new IndexQueryBuilder()
.withId(getDocumentIdValue(subGroupDoc))
.withObject(subGroupDoc)
.build()));
result.addAll(elasticsearchRestTemplate.bulkIndex(queries, BulkOptions.builder().withRoutingId(parentId).build(), IndexCoordinates.of(indexName)));
});
return result;
}
return null;
}
/**
* 索引子文档
*
* @param indexName 索引名称
* @param elasticsearchSubModel Elasticsearch子文档;
* 子文档需标注@Document注解、包含@Id注解字段, 且@Id注解标注的文档ID字段值不能为空
* 子文档需包含RelationModel.class字段、且字段RelationModel.parent值不为null
* @param subRelationName 子文档关系名; 匹配elasticsearchSubModel中RelationModel.name值
* @return 子文档ID
*/
public <T> String indexSubDoc(String indexName, T elasticsearchSubModel, String subRelationName) {
if (existIndex(indexName) && elasticsearchSubModel != null) {
List<IndexQuery> queries = Collections.singletonList(new IndexQueryBuilder().withId(getDocumentIdValue(elasticsearchSubModel))
.withObject(elasticsearchSubModel).build());
List<String> result = elasticsearchRestTemplate.bulkIndex(queries, BulkOptions.builder()
.withRoutingId(getDocumentParentIdValue(elasticsearchSubModel, subRelationName)).build(), IndexCoordinates.of(indexName));
return result != null && !result.isEmpty() ? result.get(0) : null;
}
return null;
}
/**
* 根据父文档ID分组子文档
*
* @param subDocList elasticsearch 子文档集合;
* 子文档需标注@Document注解、包含@Id注解字段, 且@Id注解标注的文档ID字段值不能为空
* 子文档需包含RelationModel.class字段、且RelationModel.parent值不为null
* @param subRelationName 子文档关系名; 匹配subDocList中RelationModel.name值
* @return key: 父文档ID; value: 父文档下所有子文档集合
*/
private <T> Map<String, List<T>> groupByParentId(List<T> subDocList, String subRelationName) {
Map<String, List<T>> result = new HashMap<>();
if (subDocList != null && !subDocList.isEmpty()) {
subDocList.forEach(subDoc -> {
// 不存在key创建空ArrayList并push key-value; 存在key返回其value
result.computeIfAbsent(getDocumentParentIdValue(subDoc, subRelationName), k -> new ArrayList<>()).add(subDoc);
});
}
return result;
}
/**
* 根据ID查询文档
*
* @param indexName 索引名称
* @param docId 文档ID
* @param tClass 映射类Class
* @param <T>
* @return Elasticsearch 文档
*/
public <T> T findById(String indexName, String docId, Class<T> tClass) {
if (existIndex(indexName) && StringUtils.isNotEmpty(docId) && tClass != null) {
return elasticsearchRestTemplate.get(docId, tClass, IndexCoordinates.of(indexName));
}
return null;
}
/**
* 根据ID判断文档是否存在
*
* @param indexName 索引名称
* @param docId 文档ID
* @return 存在与否
*/
public boolean existDocById(String indexName, String docId) {
if (existIndex(indexName) && StringUtils.isNotEmpty(docId)) {
return elasticsearchRestTemplate.exists(docId, IndexCoordinates.of(indexName));
}
return Boolean.FALSE;
}
/**
* 更新文档
*
* @param indexName 索引名称
* @param elasticsearchModel elasticsearch文档; 文档需标注@Document注解、包含@Id注解字段, 且@Id标注的文档ID值不能为空
* @return UpdateResponse.Result
* @throws JsonProcessingException JsonProcessingException
*/
public <T> UpdateResponse.Result updateDoc(String indexName, T elasticsearchModel) throws JsonProcessingException {
return updateDoc(indexName, elasticsearchModel, this.objectMapper);
}
/**
* 更新文档
*
* @param indexName 索引名称
* @param elasticsearchModel elasticsearch文档; 文档需标注@Document注解、包含@Id注解字段, 且@Id标注的文档ID值不能为空
* @param objectMapper objectMapper
* @return UpdateResponse.Result
* @throws JsonProcessingException JsonProcessingException
*/
public <T> UpdateResponse.Result updateDoc(String indexName, T elasticsearchModel, ObjectMapper objectMapper) throws JsonProcessingException {
if (StringUtils.isNotEmpty(indexName) && elasticsearchModel != null) {
Assert.isTrue(existDocById(indexName, getDocumentIdValue(elasticsearchModel)), "elasticsearch document miss.");
objectMapper = objectMapper == null ? this.objectMapper : objectMapper;
String json = objectMapper.writeValueAsString(elasticsearchModel);
UpdateQuery updateQuery = UpdateQuery.builder(getDocumentIdValue(elasticsearchModel)).withDocument(Document.parse(json)).build();
return elasticsearchRestTemplate.update(updateQuery, IndexCoordinates.of(indexName)).getResult();
}
return UpdateResponse.Result.NOOP;
}
/**
* 查询文档
*
* @param indexName 索引名称
* @param tClass 映射文档类 文档需标注@Document注解、包含@Id注解字段
* @param queryBuilder 非结构化数据 QueryBuilder; queryBuilder与filterBuilder必须二者存在其一
* @param <T>
* @return
*/
public <T> SearchHits<T> search(String indexName, Class<T> tClass, QueryBuilder queryBuilder) {
return search(indexName, tClass, queryBuilder, null, null, null, null);
}
/**
* 查询文档
*
* @param indexName 索引名称
* @param tClass 映射文档类 文档需标注@Document注解、包含@Id注解字段
* @param queryBuilder 非结构化数据 QueryBuilder; queryBuilder与filterBuilder必须二者存在其一
* @param filterBuilder 结构化数据 QueryBuilder; filterBuilder与queryBuilder必须二者存在其一
* @param <T>
* @return
*/
public <T> SearchHits<T> search(String indexName, Class<T> tClass, QueryBuilder queryBuilder, QueryBuilder filterBuilder) {
return search(indexName, tClass, queryBuilder, filterBuilder, null, null, null);
}
/**
* 查询文档
*
* @param indexName 索引名称
* @param tClass 映射文档类 文档需标注@Document注解、包含@Id注解字段
* @param queryBuilder 非结构化数据 QueryBuilder
* @param pageable FilterQueryBuilder
* @param <T>
* @return
*/
public <T> SearchHits<T> search(String indexName, Class<T> tClass, QueryBuilder queryBuilder, Pageable pageable) {
return search(indexName, tClass, queryBuilder, null, null, pageable, null);
}
/**
* 查询文档
* 查询QueryBuilder默认包含existQuery(tClass.@Id标注的字段)
* <p>
* 查询的文档必须包含映射@Document的@Id字段(父子文档关系索引中, 因父子文档保存在一个索引中,
* 而JavaBean对应父子文档是分开的,业务查询的时候不希望查询到无关业务属性的文档数据映射出来,
* 故通过包含单个父/子文档的@Id字段避免无关数据问题)
* </p>
*
* @param indexName 索引名称
* @param tClass 映射文档类 文档需标注@Document注解、包含@Id注解字段
* @param queryBuilder 非结构化数据 QueryBuilder; queryBuilder与filterBuilder必须二者存在其一
* @param filterBuilder 结构化数据 QueryBuilder; filterBuilder与queryBuilder必须二者存在其一
* @param abstractAggregationBuilder 聚合查询Builder
* @param pageable 分页/排序; 分页从0开始
* @param fields 包含字段
* @param <T>
* @return
*/
public <T> SearchHits<T> search(String indexName, Class<T> tClass, @Nullable QueryBuilder queryBuilder,
@Nullable QueryBuilder filterBuilder, @Nullable AbstractAggregationBuilder abstractAggregationBuilder,
@Nullable Pageable pageable, @Nullable String[] fields) {
if (existIndex(indexName)) {
// 查询的文档必须包含映射@Document的@Id字段(父子文档关系索引中, 因父子文档保存在一个索引中,
// 而JavaBean对应父子文档是分开的,业务查询的时候不希望查询到无关业务属性的文档数据映射出来,故通过包含单个父/子文档的@Id字段避免无关数据问题)
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery().must(QueryBuilders.existsQuery(getDocumentIdFieldName(tClass)));
if (queryBuilder != null) {
boolQueryBuilder.must(queryBuilder);
}
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder().withQuery(boolQueryBuilder);
if (filterBuilder != null) {
nativeSearchQueryBuilder.withFilter(filterBuilder);
}
if (abstractAggregationBuilder != null) {
nativeSearchQueryBuilder.addAggregation(abstractAggregationBuilder);
nativeSearchQueryBuilder.withSourceFilter(new FetchSourceFilterBuilder().build());
}
if (pageable != null) {
nativeSearchQueryBuilder.withPageable(pageable);
}
if (fields != null && fields.length > 0) {
nativeSearchQueryBuilder.withFields(fields);
}
return elasticsearchRestTemplate.search(nativeSearchQueryBuilder.build(), tClass, IndexCoordinates.of(indexName));
}
return null;
}
/**
* 聚合查询
* 查询QueryBuilder默认包含existQuery(tClass.@Id标注的字段)
* 若涉及到父子文档,需要在一个index的聚合中包含父子文档时,请使用 {@link ElasticsearchUtils#aggSearch(String, Class, AbstractAggregationBuilder)}
*
* @param indexName 索引名称
* @param tClass 映射文档类 文档需标注@Document注解、包含@Id注解字段
* @param abstractAggregationBuilder 聚合查询Builder
* @param <T>
* @return
*/
public <T> SearchHits<T> aggLimitSearch(String indexName, Class<T> tClass, AbstractAggregationBuilder abstractAggregationBuilder) {
return search(indexName, tClass, null, null, abstractAggregationBuilder, null, null);
}
/**
* 聚合查询
* 无父子文档限制, 无查询,仅做Index的聚合
*
* @param indexName 索引名称
* @param tClass 映射文档类
* @param abstractAggregationBuilder 聚合查询Builder
* @param <T>
* @return
*/
public <T> SearchHits<T> aggSearch(String indexName, Class<T> tClass, AbstractAggregationBuilder abstractAggregationBuilder) {
if (existIndex(indexName) && abstractAggregationBuilder != null) {
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder().addAggregation(abstractAggregationBuilder)
.withSourceFilter(new FetchSourceFilterBuilder().build());
return elasticsearchRestTemplate.search(nativeSearchQueryBuilder.build(), tClass, IndexCoordinates.of(indexName));
}
return null;
}
/**
* 校验JavaBean是否实现了@Document注解
*
* @param elasticsearchModel elasticsearch bean
* @param <T>
*/
private <T> void validDocument(T elasticsearchModel) {
Assert.notNull(elasticsearchModel, elasticsearchModel.getClass().getSimpleName() + " must not be null.");
validDocument(elasticsearchModel.getClass());
}
/**
* 校验JavaBean是否实现了@Document注解
*
* @param tClass elasticsearch bean class
* @param <T>
*/
private <T> void validDocument(Class<T> tClass) {
Assert.notNull(tClass, tClass.getSimpleName() + " must not be null.");
org.springframework.data.elasticsearch.annotations.Document document = tClass
.getAnnotation(org.springframework.data.elasticsearch.annotations.Document.class);
Assert.notNull(document, tClass.getSimpleName() + " must have @"
+ org.springframework.data.elasticsearch.annotations.Document.class.getName() + " annotation.");
}
/**
* 获取elasticsearch bean 标注@Id注解的文档Id值
* 校验 elasticsearch bean 是否实现了@Document注解
* 获取标注了@Id注解的字段(存在多个取first)
*
* @param elasticsearchModel
* @param <T>
* @return 文档Id值; not null
*/
@NonNull
private <T> String getDocumentIdValue(T elasticsearchModel) {
validDocument(elasticsearchModel);
List<Field> fields = ReflectUtils.getClassFieldsByAnnotation(elasticsearchModel.getClass(), Id.class);
// notEmpty 已校验notNull, 但是编译器无法检测NPE; 添加此句抑制编译器
Assert.notNull(fields, elasticsearchModel.getClass().getSimpleName()
+ " no fields marked with @" + Id.class.getName() + " annotation.");
Assert.notEmpty(fields, elasticsearchModel.getClass().getSimpleName()
+ " no fields marked with @" + Id.class.getName() + " annotation.");
Object fieldValue = ReflectUtils.getFieldValue(elasticsearchModel, fields.get(0));
Assert.isTrue(fieldValue != null && StringUtils.isNotEmpty(fieldValue.toString()),
elasticsearchModel.getClass().getSimpleName() + " @Id value must not be null.");
return String.valueOf(fieldValue);
}
/**
* 获取elasticsearch bean属性为`RelationModel.class`字段的parent父文档ID字段值
* 校验 elasticsearch bean 是否实现了@Document注解
* 获取属性为`RelationModel.class`的字段, 多个根据指定的子文档类型名称获取其对应
* 获取 RelationModel.parent 父文档ID字段值返回
*
* @param elasticsearchModel
* @param subRelationName 子文档关系名
* @param <T>
* @return 父文档ID
*/
@NonNull
private <T> String getDocumentParentIdValue(T elasticsearchModel, String subRelationName) {
validDocument(elasticsearchModel);
Assert.isTrue(StringUtils.isNotEmpty(subRelationName), "parameter `subRelationName` must not be null");
List<Field> fields = ReflectUtils.getClassFieldsByType(elasticsearchModel.getClass(), RelationModel.class);
// notEmpty 已校验notNull, 但是编译器无法检测NPE; 添加此句抑制编译器
Assert.notNull(fields, elasticsearchModel.getClass().getSimpleName() + " must has " + RelationModel.class.getName() + " fields.");
Assert.notEmpty(fields, elasticsearchModel.getClass().getSimpleName() + " must has " + RelationModel.class.getName() + " fields.");
for (Field field : fields) {
Method getMethod = ReflectUtils.getMethodByName(elasticsearchModel.getClass(), StringUtils.upperFirstAndAddPre(field.getName(), "get"));
Assert.notNull(getMethod, elasticsearchModel.getClass().getSimpleName() + " must has " + field.getName() + " field getter method.");
try {
RelationModel relationModel = (RelationModel) getMethod.invoke(elasticsearchModel);
Assert.notNull(relationModel, elasticsearchModel.getClass().getSimpleName() + "." + field.getName() + " field value must not be null.");
if (relationModel.getName().equals(subRelationName)) {
Assert.isTrue(StringUtils.isNotEmpty(relationModel.getParent()), elasticsearchModel.getClass().getSimpleName() + "." + field.getName() + ".parent value must not be null.");
return relationModel.getParent();
}
} catch (IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
throw new IllegalArgumentException(elasticsearchModel.getClass().getSimpleName() + " has no sub relation model filed with name eq '" + subRelationName + "'");
}
/**
* 获取elasticsearch bean 标注@Id注解的文档Id字段名称
* 校验 elasticsearch bean 是否实现了@Document注解
* 获取标注了@Id注解的字段(存在多个取first)
*
* @param tClass
* @param <T>
* @return 文档Id字段名称 not null
*/
@NonNull
private <T> String getDocumentIdFieldName(Class<T> tClass) {
validDocument(tClass);
List<Field> fields = ReflectUtils.getClassFieldsByAnnotation(tClass, Id.class);
// notEmpty 已校验notNull, 但是编译器无法检测NPE; 添加此句抑制编译器
Assert.notNull(fields, tClass.getSimpleName() + " no fields marked with @" + Id.class.getName() + " annotation.");
Assert.notEmpty(fields, tClass.getSimpleName() + " no fields marked with @" + Id.class.getName() + " annotation.");
return fields.get(0).getName();
}
@Autowired
public void setElasticsearchRestTemplate(ElasticsearchRestTemplate elasticsearchRestTemplate) {
this.elasticsearchRestTemplate = elasticsearchRestTemplate;
}
}
ElasticsearchConvertUtils
/**
* Elasticsearch 数据转换工具类
*
* @author lx
* @since 2020/7/24 15:02
*/
@Component
public class ElasticsearchConvertUtils {
/**
* searchHits 转换 List<T> 数据
*
* @param searchHits searchHits查询结果集
* @param <T>
* @return
*/
public static <T> List<T> searchHitsConvertDataList(SearchHits<T> searchHits) {
if (searchHits != null && searchHits.hasSearchHits()) {
List<T> response = new ArrayList<>();
searchHits.forEach(searchHit ->
response.add(searchHit.getContent()));
return response;
}
return null;
}
/**
* searchHits 转换 List<? extend Terms.Bucket> 数据
*
* @param searchHits searchHits查询结果集
* @param termAggregationName term聚合名称
* @param <T>
* @return
*/
public static <T> List<? extends Terms.Bucket> searchHitsConvertTermsBuckets(SearchHits<T> searchHits, String termAggregationName) {
if (searchHits != null && searchHits.hasAggregations() && StringUtils.isNotEmpty(termAggregationName)) {
Aggregations aggregations = searchHits.getAggregations();
if (aggregations != null) {
ParsedTerms parsedTerms = aggregations.get(termAggregationName);
if (parsedTerms != null) {
return parsedTerms.getBuckets();
}
}
}
return null;
}
/**
* {@link Terms.Bucket} 转换获取子聚合 {@link Range.Bucket}
*
* @param bucket {@link Terms.Bucket} 桶
* @param rangAggregationName range聚合名称
* @return
*/
public static List<? extends Range.Bucket> termsBucketConvertRangeBucket(Terms.Bucket bucket, String rangAggregationName) {
if (bucket != null && StringUtils.isNotEmpty(rangAggregationName)) {
Aggregations aggregations = bucket.getAggregations();
if (aggregations != null) {
ParsedRange parsedRange = aggregations.get(rangAggregationName);
if (parsedRange != null) {
return parsedRange.getBuckets();
}
}
}
return null;
}
}
ReflectUtils
/**
* 反射工具类
*
* @author lx
* @since 2020/7/21 10:56
*/
public class ReflectUtils extends cn.hutool.core.util.ReflectUtil {
/**
* 根据指定的注解获取标注了注解的字段
*
* @param targetClass 目标对象Class
* @param annotationClass 注解Class
* @return
*/
public static List<Field> getClassFieldsByAnnotation(Class<?> targetClass, Class<? extends Annotation> annotationClass) {
if (Objects.nonNull(targetClass) && Objects.nonNull(annotationClass)) {
Field[] fields = getFields(targetClass);
if (Objects.nonNull(fields) && fields.length > 0) {
List<Field> response = new ArrayList<>();
for (Field field : fields) {
Annotation annotation = field.getAnnotation(annotationClass);
if (Objects.nonNull(annotation)) {
response.add(field);
}
}
return response.isEmpty() ? null : response;
}
}
return null;
}
/**
* 根据指定Type获取字段
*
* @param targetClass 目标对象Class
* @param type 类型
* @return
*/
public static List<Field> getClassFieldsByType(Class<?> targetClass, Type type) {
if (Objects.nonNull(targetClass) && Objects.nonNull(type)) {
Field[] fields = getFields(targetClass);
if (Objects.nonNull(fields) && fields.length > 0) {
List<Field> response = new ArrayList<>();
for (Field field : fields) {
if (field.getType().equals(type)) {
response.add(field);
}
}
return response.isEmpty() ? null : response;
}
}
return null;
}
}
StringUtils
/**
* 字符串工具类
*
* @author lx
* @since 2020/7/17 11:20
*/
public class StringUtils extends StrUtil {
}
LocalDateTimeSerializer
/**
* LocalDateTime Jackson 序列化
*
* @author lx
* @since 2020/7/23 8:48
*/
public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime localDateTime, JsonGenerator jsonGenerator, SerializerProvider serializers) throws IOException {
jsonGenerator.writeString(localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")));
}
}
LocalDateTimeDeserializer
/**
* LocalDateTime Jackson 反序列化
*
* @author lx
* @since 2020/7/23 8:50
*/
public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
return LocalDateTime.parse(jsonParser.getValueAsString(), DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"));
}
}
工具类实现的细节我想我就不再赘述了,工具类的使用与测试内容也比较多,大家可以自己在github上宕下来,有我测试的一些方法,下面贴出github地址,有什么疑问欢迎交流
原文: 小七_Ape
作者:spring-data-elasticsearch 4.0.1 整合 springboot 2.3.1 elsaticsearch 7.6.2 包含父子文档(join)整合_es 7.6 父子文档 bulk-CSDN博客