开始前准备
springdata elastic接入方法
对应版本
elasticsearch 6.8
spring-boot 2.2.2.RELEASE
spring-boot-starter-data-elasticsearch 2.2.2.RELEASE
本文创作时,es最新版为7.2,可以兼容,6.0以下版本需要根据版本改部分代码,主要是低版本的几个Hits类不同,其他差别不大,可以自己查,这里不提供具体哪几个类。未来更高版本也不确定能否可用,自己去判断。
原理分析
实现关键词高亮在本质上是利用es的自定义ResultMapper功能,将匹配到的结果通过反射替换为加入高亮标识的片段的过程,对于这一点来说,网上相关文档数不胜数,并不是说完全不能用,但是对于聚合字段的处理基本是选择性忽略,而且对于使用的es client也停留在较老的版本,如果毫不思考直接使用,通常会遇到很多问题,别问我怎么知道的😓。
第一步:ES数据结构设计
如果你的es映射类含聚合信息:如数组字段、对象字段、对象数组字段,如
/**
* es公司信息聚合映射实体类
* @author tino
* @date 2020/7/27
*/
@org.springframework.data.elasticsearch.annotations.Document(indexName = "tino", type = "company_info", shards = 1, replicas = 0)
public class EsCompanyInfoModel extends BaseModel {
/**
* 公司名称
*/
private String companyName;
/**
* 数组
*/
private List<BizProductModel> products;
/**
* 公司分类
*/
private List<String> categories;
/**
* 公司案例
*/
private CompanyCaseModel companyCaseModel;
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public List<BizProductModel> getProducts() {
return products;
}
public void setProducts(List<BizProductModel> products) {
this.products = products;
}
public List<String> getCategories() {
return categories;
}
public void setCategories(List<String> categories) {
this.categories = categories;
}
public CompanyCaseModel getCompanyCaseModel() {
return companyCaseModel;
}
public void setCompanyCaseModel(CompanyCaseModel companyCaseModel) {
this.companyCaseModel = companyCaseModel;
}
}
那么你可能需要将聚合的对象数组、对象更改数据结构为:仅包含基本类型数组、字符串类型数组。
原因时对象数组的在反射时,想通过反射替换对象数组中对应索引的对象的字段操作起来比较困难,所以我们需要尽量简化数据结构,只将需要匹配的字段单独拿出来放到字符串数组中(应该没有业务会全字段匹配吧,如果有建议从设计层面去杜绝),更改数据结构后,需要清理ES中的旧数据
## 删除所有
index/type/_delete_by_query
{
"query": {
"match_all": {}
}
}
调整后的数据结构
/**
* es公司信息聚合映射实体类
* @author tino
* @date 2020/7/27
*/
@org.springframework.data.elasticsearch.annotations.Document(indexName = "tino", type = "company_info", shards = 1, replicas = 0)
public class EsCompanyInfoModel extends BaseModel {
/**
* 产品名称
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> productNames;
/**
* 产品线路
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> productRoutes;
/**
* 产品分类
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> productCategories;
/**
* 标签
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> productLabels;
/**
*
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> productIntros;
/**
* 公司案例标题
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> caseTiles;
/**
* 公司案例内容
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private List<String> casesValueContents;
public List<String> getProductNames() {
return productNames;
}
public void setProductNames(List<String> productNames) {
this.productNames = productNames;
}
public List<String> getProductRoutes() {
return productRoutes;
}
public void setProductRoutes(List<String> productRoutes) {
this.productRoutes = productRoutes;
}
public List<String> getProductCategories() {
return productCategories;
}
public void setProductCategories(List<String> productCategories) {
this.productCategories = productCategories;
}
public List<String> getProductLabels() {
return productLabels;
}
public void setProductLabels(List<String> productLabels) {
this.productLabels = productLabels;
}
public List<String> getProductIntros() {
return productIntros;
}
public void setProductIntros(List<String> productIntros) {
this.productIntros = productIntros;
}
public List<String> getCaseTiles() {
return caseTiles;
}
public void setCaseTiles(List<String> caseTiles) {
this.caseTiles = caseTiles;
}
public List<String> getCasesValueContents() {
return casesValueContents;
}
public void setCasesValueContents(List<String> casesValueContents) {
this.casesValueContents = casesValueContents;
}
}
因为我用的是ik分词器,这里我的分词规则配置的时ik_smart,这里需要注意的是,需要分词查询的字段必须设为Text类型,否则无法分词匹配,如果部分词只配置字段类型就好。
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
第二步:自定义结果映射类
- 从这里开始,低版本es的用户需要将无法导入的类替换为低版本的替代类,差别不大,可能只是命名和方法的调用问题。
- 我这里为了使数组能够成功被处理,做了如下操作:将数组字段名称通过 arrayFields传入mapper,当判断是数组时,拿出元数组,将匹配后的元素替换进原数组中。
- "span"标签是我在查询时定义的高亮规则的前后缀,如果匹配到后,命中的关键词会被我定义的span标签包裹,我也可以根据该特征获取到原值是什么,然后用List的replaceAll方法直接替换数组中原来的元素。
- 非数组字段直接反射set方法替换值即可。
高亮处理的核心代码:
for (SearchHit searchHit : hits) {
Map<String, HighlightField> highlightFields = searchHit.getHighlightFields();
T item = JSON.parseObject(searchHit.getSourceAsString(), clazz);
Field[] fields = clazz.getDeclaredFields();
List<Field> fieldList = new ArrayList<>(Arrays.asList(fields));
// 获取父类对象的所有字段,如果多层级继承,建议用while递归
Class<? super T> superclass = clazz.getSuperclass();
if (null != superclass) {
Field[] supperFields = superclass.getDeclaredFields();
if (null != supperFields) {
fieldList.addAll(new ArrayList<>(Arrays.asList(supperFields)));
}
}
for (Field field : fieldList) {
field.setAccessible(true);
try {
if (highlightFields.containsKey(field.getName())) {
if (arrayFields.contains(field.getName())) {
for (Text fragment : highlightFields.get(field.getName()).getFragments()) {
List<String> values = (List<String>) field.get(item);
values.replaceAll(s -> {
String originValue =
fragment.toString()
.replace("<span class=\"hlt\">", "").replace("</span>", "");
if (Objects.equals(s, originValue)) {
return fragment.toString();
}
return s;
});
field.set(item, values);
}
} else {
field.set(item, highlightFields.get(field.getName()).fragments()[0].toString());
}
}
} catch (Exception e) {
logger.error("es反射设值时发生错误", e);
}
}
list.add(item);
}
完整的ResultMapper:
/**
* es自定义结果映射类
*
* @author tino
* @date 2020/7/6
*/
@Component
public class EsResultMapper extends AbstractResultMapper {
private static Logger logger = LoggerFactory.getLogger(EsResultMapper.class);
private static Set<String> arrayFields = new HashSet<>();
public static Set<String> getArrayFields() {
return arrayFields;
}
public static void setArrayFields(Set<String> arrayFields) {
EsResultMapper.arrayFields = arrayFields;
}
/**
* 添加数字字段,对数组字段进行特殊处理
*
* @param fileds
*/
public void addArrayFields(String... fileds) {
if (null != fileds
&& fileds.length > 0)
for (String filed : fileds) {
EsResultMapper.arrayFields.add(filed);
}
}
private final MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext;
public EsResultMapper() {
this(new SimpleElasticsearchMappingContext());
}
public EsResultMapper(MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext) {
super(new DefaultEntityMapper(mappingContext));
Assert.notNull(mappingContext, "MappingContext must not be null!");
this.mappingContext = mappingContext;
}
public EsResultMapper(EntityMapper entityMapper) {
this(new SimpleElasticsearchMappingContext(), entityMapper);
}
public EsResultMapper(
MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext,
EntityMapper entityMapper) {
super(entityMapper);
Assert.notNull(mappingContext, "MappingContext must not be null!");
this.mappingContext = mappingContext;
}
@Override
public <T> AggregatedPage<T> mapResults(SearchResponse response, Class<T> clazz, Pageable pageable) {
long totalHits = response.getHits().getTotalHits();
List<T> list = new ArrayList<>();
SearchHits hits = response.getHits();
if (hits.getHits().length > 0) {
for (SearchHit searchHit : hits) {
Map<String, HighlightField> highlightFields = searchHit.getHighlightFields();
T item = JSON.parseObject(searchHit.getSourceAsString(), clazz);
Field[] fields = clazz.getDeclaredFields();
List<Field> fieldList = new ArrayList<>(Arrays.asList(fields));
// 获取父类对象的所有字段,如果多层级继承,建议用while递归
Class<? super T> superclass = clazz.getSuperclass();
if (null != superclass) {
Field[] supperFields = superclass.getDeclaredFields();
if (null != supperFields) {
fieldList.addAll(new ArrayList<>(Arrays.asList(supperFields)));
}
}
for (Field field : fieldList) {
field.setAccessible(true);
try {
if (highlightFields.containsKey(field.getName())) {
if (arrayFields.contains(field.getName())) {
for (Text fragment : highlightFields.get(field.getName()).getFragments()) {
List<String> values = (List<String>) field.get(item);
values.replaceAll(s -> {
String originValue =
fragment.toString()
.replace("<span class=\"hlt\">", "").replace("</span>", "");
if (Objects.equals(s, originValue)) {
return fragment.toString();
}
return s;
});
field.set(item, values);
}
} else {
field.set(item, highlightFields.get(field.getName()).fragments()[0].toString());
}
}
} catch (Exception e) {
logger.error("es反射设值时发生错误", e);
}
}
list.add(item);
}
}
return new AggregatedPageImpl<>(list, pageable, totalHits);
}
@Override
public <T> T mapResult(GetResponse response, Class<T> clazz) {
T result = mapEntity(response.getSourceAsString(), clazz);
if (result != null) {
setPersistentEntityId(result, response.getId(), clazz);
setPersistentEntityVersion(result, response.getVersion(), clazz);
}
return result;
}
@Override
public <T> LinkedList<T> mapResults(MultiGetResponse responses, Class<T> clazz) {
LinkedList<T> list = new LinkedList<>();
for (MultiGetItemResponse response : responses.getResponses()) {
if (!response.isFailed() && response.getResponse().isExists()) {
T result = mapEntity(response.getResponse().getSourceAsString(), clazz);
setPersistentEntityId(result, response.getResponse().getId(), clazz);
setPersistentEntityVersion(result, response.getResponse().getVersion(), clazz);
list.add(result);
}
}
return list;
}
private <T> void setPersistentEntityId(T result, String id, Class<T> clazz) {
if (clazz.isAnnotationPresent(Document.class)) {
ElasticsearchPersistentEntity<?> persistentEntity = mappingContext.getRequiredPersistentEntity(clazz);
ElasticsearchPersistentProperty idProperty = persistentEntity.getIdProperty();
// Only deal with String because ES generated Ids are strings !
if (idProperty != null && idProperty.getType().isAssignableFrom(String.class)) {
persistentEntity.getPropertyAccessor(result).setProperty(idProperty, id);
}
}
}
private <T> void setPersistentEntityVersion(T result, long version, Class<T> clazz) {
if (clazz.isAnnotationPresent(Document.class)) {
ElasticsearchPersistentEntity<?> persistentEntity = mappingContext.getPersistentEntity(clazz);
ElasticsearchPersistentProperty versionProperty = persistentEntity.getVersionProperty();
// Only deal with Long because ES versions are longs !
if (versionProperty.getType().isAssignableFrom(Long.class)) {
// check that a version was actually returned in the response, -1 would indicate that
// a search didn't request the version ids in the response, which would be an issue
Assert.isTrue(version != -1, "Version in response is -1");
persistentEntity.getPropertyAccessor(result).setProperty(versionProperty, version);
}
}
}
}
第三步:查询
dao不需要多说,有疑问的可以看上一篇文章
service:
- 这里使用ElasticsearchRestTemplate的查询方式,对应的时springdata的 high level client配置方法,如果是low level的客户端,只能使用ElasticsearchTemplate,否则注入时就会报错,特别注意一下。
完整service查询方法:
/**
1. es公司信息查询
2. 3. @author tino
4. @date 2020/7/27
*/
public class EsCompanyInfoServiceImpl extends BaseServiceImpl implements EsCompanyService {
private static final String TAG_PREFIX = "<span class=\"hlt\">";
private static final String TAG_SUFFIX = "</span>";
@Autowired
private ElasticsearchRestTemplate elasticsearchTemplate;
@Autowired
private EsResultMapper esResultMapper;
@Override
public Page<EsCompanyModel> search(String content, PageInfo pageInfo) {
// 构建查询条件
if (StringUtils.isBlank(content.trim())) {
return null;
}
// 多条件聚合并设置查询优先级
BoolQueryBuilder boolQueryBuilder
= QueryBuilders.boolQuery()
.should(
QueryBuilders.boolQuery().must(QueryBuilders.matchQuery(F_PRODUCT_ROUTES, content)).must(QueryBuilders.matchQuery(F_DELETE_FLAG, 0))
).boost(5)
.should(
QueryBuilders.boolQuery().must(QueryBuilders.matchQuery(F_CASE_VALUES, content)).must(QueryBuilders.matchQuery(F_DELETE_FLAG, 0))
).boost(4)
.should(
QueryBuilders.boolQuery().must(QueryBuilders.matchQuery(F_PRODUCTS_LABELS, content)).must(QueryBuilders.matchQuery(F_DELETE_FLAG, 0))
).boost(3)
.should(
QueryBuilders.boolQuery().must(QueryBuilders.matchQuery(F_PRODUCTS_LABELS, content)).must(QueryBuilders.matchQuery(F_DELETE_FLAG, 0))
).boost(2)
.should(
QueryBuilders.boolQuery()
.must(QueryBuilders.multiMatchQuery(content,
F_COMPANY_NAME, F_INTRO, F_PROVINCE, F_CITY, F_AREA, F_ADDRESS, F_PRODUCTS_INTROS, F_PRODUCTS_CATEGORIES)
)
.must(QueryBuilders.matchQuery(F_DELETE_FLAG, 0))
).boost(1)
// 必须是未删除的
;
// 设置排序规则
Sort sort = Sort.by(Sort.Direction.DESC, CompanyTimesModel.F_VIEW_TIMES, CompanyTimesModel.F_SHARE_TIMES, CompanyTimesModel.F_SUBSCRIBE_TIMES, "createDate.keyword");
// 组织分页参数
Pageable pageable = PageRequest.of(pageInfo.getPageNum() <= 1 ? 0 : pageInfo.getPageNum() - 1, pageInfo.getPageSize(), sort);
// 构建本地查询方法,对搜索关键词结果进行预处理
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(boolQueryBuilder)
.withHighlightFields(
new HighlightBuilder.Field(F_COMPANY_NAME).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_PRODUCTS_CATEGORIES).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_PRODUCT_ROUTES).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_PRODUCTS_LABELS).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_INTRO).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_CASE_VALUES).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX)
)
.withPageable(pageable)
.build();
// 搜索,获取结果
Page page = new Page(new com.github.pagehelper.Page());
try {
// 单独标记数组字段,方便result mapper进行处理
esResultMapper.addArrayFields(F_PRODUCTS_CATEGORIES, F_PRODUCT_ROUTES, F_PRODUCTS_LABELS, F_CASE_VALUES);
elasticsearchTemplate.queryForPage(searchQuery, EsCompanyModel.class, esResultMapper);
org.springframework.data.domain.Page<EsCompanyModel> result = elasticsearchTemplate.queryForPage(searchQuery, EsCompanyModel.class, esResultMapper);
// 将分页对象重构为与其他模块相同的分页数据结构
page.setList(result.getContent());
page.setTotal(result.getTotalElements());
page.setPageNum(result.getPageable().getPageNumber());
page.setPageSize(result.getPageable().getPageSize());
} catch (Exception e) {
// 当es中索引为空时,可能会出现错误
logger.error("es查询出现错误", e);
}
return page;
}
}
注意:
- 分页处理是因为springdata es的分页参数与其他模块的分页参数不同,我这里做一下统一处理,防止因为返回给前端的分页不统计一造成困扰。
- 为了避免魔法值的出现,我将查询的字段定义到了实体类中,可根据自己的选择定义到哪。
- 排序规则根据需要设置,不设置会根据查询优先级排,注意按时间排序,必须使用 .keyword,如: "createDate.keyword" 。
- 更改高亮规则时别忘了修改EsResultMapper中的规则,建议放到一个静态类中去统一维护。
前端设置
设置class=hlt的样式
.hlt{
color:red;
}
效果
PS
QueryBuilders详解:
- termQuery(“key”, obj) 完全匹配
- termsQuery(“key”, obj1, obj2…) 一次匹配多个值
- matchQuery(“key”, Obj) 单个匹配, field不支持通配符, 前缀具高级特性
- multiMatchQuery(“text”, “field1”, “field2”…) 匹配多个字段, field有通配符特性
- matchAllQuery(); 匹配所有文件
BoolQueryBuilder详解:
QueryBuilders.boolQuery()
.should(
QueryBuilders.boolQuery().must(QueryBuilders.matchQuery(F_PRODUCT_ROUTES, content)).must(QueryBuilders.matchQuery(F_DELETE_FLAG, 0))
).boost(5)
- 这条查询的含义是:查询未删除的公司线路中包含查询关键词的内容
- should方法相当于mysql的or,那么or后面如果有多条件需要()来包裹,所以我这里需要查询多个or的复杂条件,那么就需要使用嵌套的多个BoolQueryBuilder的方式去实现查询未删除公司信息。
- must方法相当于mysql里的=
- boost是查询优先级,数值越大查询结果越靠前。
NativeSearchQuery详解:
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(boolQueryBuilder)
.withHighlightFields(
new HighlightBuilder.Field(F_COMPANY_NAME).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_PRODUCTS_CATEGORIES).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_PRODUCT_ROUTES).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_PRODUCTS_LABELS).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_INTRO).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX),
new HighlightBuilder.Field(F_CASE_VALUES).numOfFragments(0).preTags(TAG_PREFIX).postTags(TAG_SUFFIX)
)
.withPageable(pageable)
.build();
- withHighlightFields(Field… highlightFields) 设置高亮字段,**numOfFragments(0)**高亮查询结果的片段长度,设置0标识返回所有内容,如果有类似百度快照的需求,可以根据业务场景展示前端需要的结果长度
- withPageable(Pageable pageable) 设置分页规则
- withFilter(QueryBuilder filterBuilder)设置过滤条件,与BoolQueryBuilder查询条件设置一样。
- 其他参数可以查看源码, 这里不做赘述。