一、前言
- 常用的全局搜索引擎有很多,有兴趣的童靴戳这里常见的Java全局搜索引擎,目前更常用的除了solr,还有就是我们今天要说的分布式搜索引擎 Elasticsearch(简称ES)
- spring boot整合ES的方式目前常见的有两种,一种是使用
spring data elasticsearch
,一种就是使用elasticsearchTemplate
进行整合。如对搜索没有高亮需求,用前者即可,如有高亮需求,则必须使用后者。
(ps:当然了,可能还有其它好用的方式是我不清楚的,若有的话承蒙不弃,请留言告知,thx。) - 事实上Elasticsearch只是我们常讲的ELK技术栈中的其中一项,emm,现在最新的叫法为Elastic Stack,即在ELK的基础上再加上用于数据收集的Beats。ELK,即:
1)Elasticsearch(简称ES):用于深度搜索和数据分析,它是基于Apache Lucene的分布式开源搜索引擎,无须预先定义数据结构就能动态地对数据进行索引;
2)Logstash:用于日志集中管理,包括从多台服务器上传输和转发日志,并对日志进行丰富和解析,是一个数据管道,提供了大量插件来支持数据的输入和输出处理;
3)Kibana:提供了强大而美观的数据可视化,Kibana完全使用HTML和Javascript编写,它利用Elasticsearch
的RESTful API来实现其强大的搜索能力,将结果显示位各种震撼的图形提供给最终的用户。
ps:该篇文章只展示如何实现高亮及权重分页搜索
,其它前置操作如索引创建/删除、数据保存删除等由于相关文章较多就不再赘述,可参考SpringBoot整合Elasticsearch
Here we go…
二、下载安装
在开始整合之前你需得先把需要用到的东西下载准备好,下面是常用插件的官方下载地址:
ps:如果只是想实现搜索效果的话你只需下载ES。至于分词器,可以使你的搜索效果更加友好。安装基本上都是开箱即用,如有不清楚的百度即可。
- Elasticsearch
- Logstash
- Kibana
- IK中文分词器
- 拼音分词器
三、主要配置
1. maven依赖
<!-- ElasticSearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
2. application.properties
## ElasticSearch - start
# 开启 Elasticsearch 仓库(默认值:true)
spring.data.elasticsearch.repositories.enabled=true
# 默认 9300 是 Java 客户端的端口。9200 是支持 Restful HTTP 的接口
# spring.data.elasticsearch.cluster-nodes=127.0.0.1:9300
spring.data.elasticsearch.cluster-nodes=192.168.5.58:9300
# 集群名(默认值: elasticsearch)
spring.data.elasticsearch.cluster-name=realestate
# 集群节点地址列表,用逗号分隔。如果没有指定,就启动一个客户端节点
#spring.data.elasticsearch.propertie 用来配置客户端的额外属性
# 存储索引的位置
spring.data.elasticsearch.properties.path.home=/data/project/target/elastic
# 连接超时的时间
spring.data.elasticsearch.properties.transport.tcp.connect_timeout=120s
## ElasticSearch - end
三、代码实现
1. 实体注解
注:Spring Data通过注解来声明字段的映射属性,有下面的三个注解:
@Document
:作用在类,标记实体类为文档对象,一般有两个属性
- indexName:对应索引库名称
- type:对应在索引库中的类型
- shards:分片数量,默认5
- replicas:副本数量,默认1
@Id
:作用在成员变量,标记一个字段作为id主键
@Field
:作用在成员变量,标记为文档的字段,并指定字段映射属性:
- type:字段类型,是枚举:FieldType,可以是text、long、short、date、integer、object等
- text:存储数据时候,会自动分词,并生成索引
- keyword:存储数据时候,不会分词建立索引
- Numerical:数值类型,分两类
- 基本数据类型:long、interger、short、byte、double、float、half_float
- 浮点数的高精度类型:scaled_float。
需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
- Date:日期类型。elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间
- index:是否索引,布尔类型,默认是true
- store:是否存储,布尔类型,默认是false
- analyzer:分词器名称,这里的ik_max_word即使用ik分词器
2. 创建实体
- 此处的实体为提供搜索用的实体,应与业务实体区分开,如果有相同实体,为避免混淆应写两份。正常情况下全局搜索接口可单独部署服务器。
- 实体的配置方式有多种,除了下方每个字段使用注解外,spring boot还为我们提供了@Setting及@Mapping注解方式。如对字段设置要求较高的建议使用后者,后者可在配置文件处写原生DDL语句,虽然麻烦些,但是较为清晰。
package com.yby.es.po;
import java.io.Serializable;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import com.yby.es.po.constant.EsConstant;
/**
* 游记
*/
@Document(indexName = EsConstant.INDEX_NAME.TRAVEL, type = "doc")
public class Travel extends BaseEntity implements Serializable {
private static final long serialVersionUID = -1838668690328733289L;
/**
* 关键词
*/
@Field(type = FieldType.Keyword)
private String keyword;
/**
* 途经城市
*/
@Field(type = FieldType.Keyword)
private String passCity;
public Travel() {
super();
}
public String getKeyword() {
return keyword;
}
public String getPassCity() {
return passCity;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
public void setPassCity(String passCity) {
this.passCity = passCity;
}
}
package com.yby.es.po;
import java.io.Serializable;
import java.util.Date;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
/**
* 基础实体
*
* @author lwx
* @date 2019/02/28
*/
public class BaseEntity implements Serializable {
private static final long serialVersionUID = 5695568297523302402L;
/**
* ID
*/
@Id
private Long id;
/**
* 类型
*/
@Field(type = FieldType.Keyword)
private String type;
/**
* 状态
*/
@Field(type = FieldType.Keyword)
private String status;
/**
* 标题
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String title;
/**
* 内容
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String content;
/**
* 忽略检索内容
*/
private String ignoreContent;
/**
* 描述
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String description;
/**
* 创建时间
*/
@Field(type = FieldType.Keyword)
private Date createTime;
/**
* 更新时间
*/
@Field(type = FieldType.Keyword)
private Date updateTime;
public BaseEntity() {
super();
}
public Long getId() {
return id;
}
public String getType() {
return type;
}
public String getStatus() {
return status;
}
public String getTitle() {
return title;
}
public String getContent() {
return content;
}
public String getIgnoreContent() {
return ignoreContent;
}
public String getDescription() {
return description;
}
public Date getCreateTime() {
return createTime;
}
public Date getUpdateTime() {
return updateTime;
}
public void setId(Long id) {
this.id = id;
}
public void setType(String type) {
this.type = type;
}
public void setStatus(String status) {
this.status = status;
}
public void setTitle(String title) {
this.title = title;
}
public void setContent(String content) {
this.content = content;
}
public void setIgnoreContent(String ignoreContent) {
this.ignoreContent = ignoreContent;
}
public void setDescription(String description) {
this.description = description;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
public void setUpdateTime(Date updateTime) {
this.updateTime = updateTime;
}
}
3. 实现方法
package com.yby.es.service.impl;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder.Field;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;
import org.springframework.data.elasticsearch.core.SearchResultMapper;
import org.springframework.data.elasticsearch.core.aggregation.AggregatedPage;
import org.springframework.data.elasticsearch.core.aggregation.impl.AggregatedPageImpl;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
/**
* 游记
*/
@Override
public Page<Travel> searchTravel(String keyword, Integer pageNumber, Integer pageSize) {
// 页码
if (pageNumber == null || pageNumber < 0) {
pageNumber = 0;
}
// 页数
if (pageSize == null || pageSize < 1) {
pageSize = 10;
}
Page<Travel> page = null;
try {
// 构建查询
NativeSearchQueryBuilder searchQuery = new NativeSearchQueryBuilder();
// 多索引查询
searchQuery.withIndices(EsConstant.INDEX_NAME.TRAVEL);
// 组合查询,boost即为权重,数值越大,权重越大
QueryBuilder queryBuilder = QueryBuilders.boolQuery()
.should(QueryBuilders.multiMatchQuery(keyword, "title").boost(3))
.should(QueryBuilders.multiMatchQuery(keyword, "passCity", "description").boost(2))
.should(QueryBuilders.multiMatchQuery(keyword, "content", "keyword").boost(1));
searchQuery.withQuery(queryBuilder);
// 高亮设置
List<String> highlightFields = new ArrayList<String>();
highlightFields.add("title");
highlightFields.add("passCity");
highlightFields.add("description");
highlightFields.add("content");
highlightFields.add("keyword");
Field[] fields = new Field[highlightFields.size()];
for (int x = 0; x < highlightFields.size(); x++) {
fields[x] = new HighlightBuilder.Field(highlightFields.get(x)).preTags(EsConstant.HIGH_LIGHT_START_TAG)
.postTags(EsConstant.HIGH_LIGHT_END_TAG);
}
searchQuery.withHighlightFields(fields);
// 分页设置
searchQuery.withPageable(PageRequest.of(pageNumber, pageSize));
page = elasticsearchTemplate.queryForPage(searchQuery.build(), Travel.class, new SearchResultMapper() {
@Override
@SuppressWarnings("unchecked")
public <T> AggregatedPage<T> mapResults(SearchResponse response, Class<T> clazz, Pageable pageable) {
// 获取高亮搜索数据
List<Travel> list = new ArrayList<Travel>();
SearchHits hits = response.getHits();
for (SearchHit searchHit : hits) {
if (hits.getHits().length <= 0) {
return null;
}
Travel data = new Travel();
// 公共字段
data.setId(new Double(searchHit.getId()).longValue());
data.setType(String.valueOf(searchHit.getSourceAsMap().get("type")));
data.setStatus(String.valueOf(searchHit.getSourceAsMap().get("status")));
data.setTitle(String.valueOf(searchHit.getSourceAsMap().get("title")));
data.setContent(String.valueOf(searchHit.getSourceAsMap().get("content")));
data.setIgnoreContent(String.valueOf(searchHit.getSourceAsMap().get("ignoreContent")));
data.setDescription(String.valueOf(searchHit.getSourceAsMap().get("description")));
Object createTime = searchHit.getSourceAsMap().get("createTime");
Object updateTime = searchHit.getSourceAsMap().get("updateTime");
if (createTime != null) {
data.setCreateTime(new Date(Long.valueOf(createTime.toString())));
}
if (updateTime != null) {
data.setUpdateTime(new Date(Long.valueOf(updateTime.toString())));
}
// 个性字段
data.setKeyword(String.valueOf(searchHit.getSourceAsMap().get("keyword")));
data.setPassCity(String.valueOf(searchHit.getSourceAsMap().get("passCity")));
// 反射调用set方法将高亮内容设置进去
try {
for (String field : highlightFields) {
HighlightField highlightField = searchHit.getHighlightFields().get(field);
if (highlightField != null) {
String setMethodName = parSetName(field);
Class<? extends Travel> poemClazz = data.getClass();
Method setMethod = poemClazz.getMethod(setMethodName, String.class);
String highlightStr = highlightField.fragments()[0].toString();
// 截取字符串
if ("content".equals(field) && highlightStr.length() > 50) {
highlightStr = StringUtil.truncated(highlightStr,
EsConstant.HIGH_LIGHT_START_TAG, EsConstant.HIGH_LIGHT_END_TAG);
}
setMethod.invoke(data, highlightStr);
}
}
} catch (Exception e) {
e.printStackTrace();
}
list.add(data);
}
if (list.size() > 0) {
AggregatedPage<T> result = new AggregatedPageImpl<T>((List<T>) list, pageable,
response.getHits().getTotalHits());
return result;
}
return null;
}
});
} catch (Exception e) {
e.printStackTrace();
}
return page;
}
parSetName方法:
private static String parSetName(String fieldName) {
if (StringUtils.isBlank(fieldName)) {
return null;
}
int startIndex = 0;
if (fieldName.charAt(0) == '_')
startIndex = 1;
return "set" + fieldName.substring(startIndex, startIndex + 1).toUpperCase()
+ fieldName.substring(startIndex + 1);
}
相关常量:
package com.yby.es.po.constant;
public interface EsConstant {
/**
* 高亮显示 - 开始标签
*/
String HIGH_LIGHT_START_TAG = "<em>";
/**
* 高亮显示 - 结束标签
*/
String HIGH_LIGHT_END_TAG = "</em>";
/**
* 索引名称
*/
class INDEX_NAME {
/**
* 游记
*/
public static final String TRAVEL = "travel";
}
}
4. 返回示例
当发现搜索返回结果中出现<em>
即表示高亮搜索成功,如下:
四、数据同步
实际开发中,我们需要将自己的数据库导入以及同步到ES,数据同步的方式多种多样,这里展示其中两种:
方式一:logstash多数据源导入
注:logstash具体部署这里不展开讲,不清楚的搜索下即可。
以mysql为例,jdbc.conf配置如下:
input {
stdin {}
jdbc {
# type => "activity"
add_field => { "[@metadata][type]" => "activity" }
jdbc_connection_string => "jdbc:mysql://192.168.5.58:3306/181_realestate?characterEncoding=UTF-8&autoReconnect=true"
jdbc_user => "root"
jdbc_password => "root"
# mysql依赖包路径;
jdbc_driver_library => "mysql/mysql-connector-java-8.0.13.jar"
# mysql驱动
jdbc_driver_class => "com.mysql.jdbc.Driver"
# 数据库重连尝试次数
connection_retry_attempts => "3"
# 判断数据库连接是否可用,默认false不开启
jdbc_validate_connection => "true"
# 数据库连接可用校验超时时间,默认3600S
jdbc_validation_timeout => "3600"
# 开启分页查询(默认false不开启);
# jdbc_paging_enabled => "true"
# 单次分页查询条数(默认100000,若字段较多且更新频率较高,建议调低此值);
# jdbc_page_size => "10000"
# statement为查询数据sql,如果sql较复杂,建议配通过statement_filepath配置sql文件的存放路径;
# sql_last_value为内置的变量,存放上次查询结果中最后一条数据tracking_column的值,此处即为ModifyTime;
# statement => "SELECT KeyId,TradeTime,OrderUserName,ModifyTime FROM `DetailTab` WHERE ModifyTime>= :sql_last_value order by ModifyTime asc"
statement_filepath => "mysql/realestate/sql/activity.sql"
# 是否将字段名转换为小写,默认true(如果有数据序列化、反序列化需求,建议改为false);
lowercase_column_names => false
# Value can be any of: fatal,error,warn,info,debug,默认info;
sql_log_level => warn
# 是否记录上次执行结果,true表示会将上次执行结果的tracking_column字段的值保存到last_run_metadata_path指定的文件中;
record_last_run => true
# 需要记录查询结果某字段的值时,此字段为true,否则默认tracking_column为timestamp的值;
use_column_value => true
# 需要记录的字段,用于增量同步,需是数据库字段
tracking_column => "id"
# Value can be any of: numeric,timestamp,Default value is "numeric"
tracking_column_type => numeric
# record_last_run上次数据存放位置;
last_run_metadata_path => "mysql/realestate/last_id/activity.txt"
# 是否清除last_run_metadata_path的记录,需要增量同步时此字段必须为false;
clean_run => false
# 同步频率(分 时 天 月 年),默认每分钟同步一次;
# 每天凌晨3点
# schedule => "* * * * *"
schedule => "0 3 * * *"
}
jdbc {
# type => "travel"
add_field => { "[@metadata][type]" => "travel" }
jdbc_connection_string => "jdbc:mysql://192.168.5.58:3306/181_realestate?characterEncoding=UTF-8&autoReconnect=true"
jdbc_user => "root"
jdbc_password => "root"
jdbc_driver_library => "mysql/mysql-connector-java-8.0.13.jar"
jdbc_driver_class => "com.mysql.jdbc.Driver"
connection_retry_attempts => "3"
jdbc_validate_connection => "true"
jdbc_validation_timeout => "3600"
# jdbc_paging_enabled => "true"
# jdbc_page_size => "10000"
statement_filepath => "mysql/realestate/sql/travel.sql"
lowercase_column_names => false
sql_log_level => warn
record_last_run => true
use_column_value => true
# 需要记录的字段,用于增量同步,需是数据库字段
tracking_column => "id"
# 跟踪数据类型: numeric,timestamp,Default value is "numeric"
tracking_column_type => numeric
# record_last_run上次数据存放位置;
last_run_metadata_path => "mysql/realestate/last_id/travel.txt"
# 是否清除last_run_metadata_path的记录,需要增量同步时此字段必须为false;
clean_run => false
# 同步频率(分 时 天 月 年),默认每分钟同步一次;
# 每天凌晨3点
# schedule => "* * * * *"
schedule => "0 3 * * *"
}
}
filter {
json {
source => "message"
remove_field => ["message"]
}
}
output {
# output模块的type需和jdbc模块的type一致
# if [type] == "activity" {
if [@metadata][type] == "activity" {
elasticsearch {
# host => "192.168.1.1"
# port => "9200"
# 配置ES集群地址
# hosts => ["192.168.1.1:9200", "192.168.1.2:9200", "192.168.1.3:9200"]
hosts => ["127.0.0.1:9200"]
# 索引名字,必须小写
index => "activity"
# 数据唯一索引(建议使用数据库KeyID)
document_id => "%{id}"
}
}
if [@metadata][type] == "travel" {
elasticsearch {
hosts => ["127.0.0.1:9200"]
index => "travel"
document_id => "%{id}"
}
}
stdout {
codec => json_lines
}
}
方式二:定时任务
注:至于需要同步单独的某行数据的可以以提供接口的方式进行同步,此处不展开
package com.yby.es.task;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;
import com.yby.es.po.Travel;
/**
* <pre>
* 数据导入定时任务
* </pre>
*
* @author lwx
*/
@Component
// 开启定时任务
@EnableScheduling
// 开启多线程
@EnableAsync
public class ImportTask {
protected Log logger = LogFactory.getLog(this.getClass());
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
@Autowired
private TravelEsRepository travelEsRepository;
@Autowired
private TravelMapper travelMapper;
/**
* 游记
*/
@Async
@Scheduled(cron = "0 16 3 ? * *") // 每天 03:16
public void importTravel() throws InterruptedException {
logger.info("【信息】执行定时任务...");
logger.info("【信息】正在清除游记数据...");
try {
boolean delete = elasticsearchTemplate.deleteIndex(Travel.class);
if (delete) {
logger.info("【信息】游记数据清除完毕,正在导入游记数据...");
Iterable<Travel> data = travelEsRepository.saveAll(travelMapper.list());
if (data != null) {
logger.info("【success】游记数据导入完毕!");
}
}
} catch (Exception e) {
logger.debug("【error】游记数据导入失败!");
e.printStackTrace();
}
}
}
五、注意事项
1. 新版不支持多type
- 为什么?
1、index、type的初衷:
之前es将index、type类比于关系型数据库(例如mysql)中database、table,这么考虑的目的是“方便管理数据之间的关系”。
2、为什么现在要移除type?
2.1 在关系型数据库中table是独立的(独立存储),但es中同一个index中不同type是存储在同一个索引中的(lucene的索引文件),因此不同type中相同名字的字段的定义(mapping)必须一致。
2.2 不同类型的“记录”存储在同一个index中,会影响lucene的压缩性能。
- 替换策略
3.1 一个index只存储一种类型的“记录”
这种方案的优点:
a)lucene索引中数据比较整齐(相对于稀疏),利于lucene进行压缩。
b)文本相关性打分更加精确(tf、idf,考虑idf中命中文档总数)
3.2 用一个字段来存储type
如果有很多规模比较小的数据表需要建立索引,可以考虑放到同一个index中,每条记录添加一个type字段进行区分。
这种方案的优点:
a)es集群对分片数量有限制,这种方案可以减少index的数量。
- 迁移方案
之前一个index上有多个type,如何迁移到3.1、3.2方案?
4.1 先针对实际情况创建新的index,[3.1方案]有多少个type就需要创建多少个新的index,[3.2方案]只需要创建一个新的index。
4.2 调用_reindex将之前index上的数据同步到新的索引上。
此处参考:
2. Index命名规范
必须小写,如果出现大写的名称,将报如下异常:
Caused by: org.elasticsearch.hadoop.EsHadoopIllegalArgumentException: Cannot determine write shards for [CC-2017.01.24/compliance]; likely its format is incorrect (maybe it contains illegal characters?)
at org.elasticsearch.hadoop.util.Assert.isTrue(Assert.java:50)
at org.elasticsearch.hadoop.rest.RestService.initSingleIndex(RestService.java:439)
at org.elasticsearch.hadoop.rest.RestService.createWriter(RestService.java:400)
at org.elasticsearch.spark.rdd.EsRDDWriter.write(EsRDDWriter.scala:40)
at org.elasticsearch.spark.rdd.EsSpark$$anonfun$saveToEs$1.apply(EsSpark.scala:67)
at org.elasticsearch.spark.rdd.EsSpark$$anonfun$saveToEs$1.apply(EsSpark.scala:67)
at org.apache.spark.scheduler.ResultTask.runTask(ResultTask.scala:66)
at org.apache.spark.scheduler.Task.run(Task.scala:89)
at org.apache.spark.executor.Executor$TaskRunner.run(Executor.scala:213
The end.