Elasticsearch整合及基本操作示例

  1. 1. Elasticsearch简介
    1. 1.1 基本介绍
    2. 1.2 关键概念
    3. 1.3 适用情形
  2. 2. Docker-ElasticSearch环境搭建
    1. 2.1 拉取镜像并运行容器
      1. 2.1.1 部署命令
      2. 2.1.2 进入容器进行配置
      3. 2.1.3 注意事项
      4. 2.1.4 数据挂载遇到的问题
    2. 2.2 可视化管理ES
      1. 2.2.1 使用Elasticvue浏览器插件
      2. 2.2.2 安装kibana可视化插件
    3. 2.3 安装ik分词器插件
    4. 2.4 使用curl操作ES
  3. 3. 与Springboot的整合
    1. 3.1 配置项目依赖
    2. 3.2 编写配置文件
  4. 4. 基本增删查改操作
    1. 4.1 索引操作
      1. 4.1.1 创建索引
      2. 4.1.2 查询索引
      3. 4.1.3 删除索引
      4. 4.1.4 检查索引是否存在
    2. 4.2 文档操作
      1. 4.2.1 新增文档
      2. 4.2.2 查询文档
      3. 4.2.3 修改文档
      4. 4.2.4 删除文档
      5. 4.2.5 批量操作文档
      6. 4.2.6 全量查询文档
      7. 4.2.7 查询结果过滤
      8. 4.2.8 分页查询文档
      9. 4.2.9 条件查询文档
      10. 4.2.10 范围查询文档
      11. 4.2.11 模糊查询文档
  5. 5. Elasticsearch通用封装
  6. 6. Elasticsearch常见问题
    1. 6.1 依赖版本不正确的问题
    2. 6.2 ES出错返回404状态码的问题
  7. 7. Python操作ElasticSearch
  8. 8. 参考资料

1. Elasticsearch简介

1.1 基本介绍

ElasticSearch(ES)是一个基于Lucene构建的开源、分布式、RESTful接口的全文搜索引擎。ES还是一个分布式文档数据库,其中每个字段均可被索引,而且每个字段的数据均可被搜索,ES能够横向扩展至数以百计的服务器存储以及处理PB级的数据。可以在极短的时间内存储、搜索和分析大量的数据。通常作为具有复杂搜索场景情况下的引擎。

Elasticsearch架构图

1.2 关键概念

为了快速了解ES的关键概念及与传统关系型数据库的不同,可以与MySQL从几个方面做个对比。

[1] 结构名称不同

ElasticSearch MySQL
字段(Field) 属性(列)
文档(Document) 记录(行)
类型(Type)
索引(Index) 数据库

注:ES 在7.0以及之后的版本中 Type 被废弃了。一个 index 中只有一个默认的 type,即 _doc。被废弃后,库表合一,Index 既可以被认为是对应 MySQL 的 Database,也可以认为是对应的 Table。

[2] ES分布式搜索,传统数据库遍历式搜索

ES支持分片和复制,从而方便水平分割和扩展,复制保证了ES的高可用与高吞吐。

在ES中,当你创建一个索引的时候,你可以指定你想要的分片的数量。每个分片本身也是一个功能完善并且独立的索引,索引可以被放置到集群中的任何节点上。分片优点:

  • 允许你水平分割/扩展你的内容容量。
  • 允许你在分片之上进行分布式的、并行的操作,进而提高性能/吞吐量。
  • 分片的分布,它的文档怎样聚合回搜索请求,完全由Elasticsearch管理。

[3] ES采用倒排索引,传统数据库采用B+树索引

假设一个文档(用id标识)是有许多的单词(用value标识)组成的,每个单词可能同一个文档中重复出现很多次,也可能出现在不同的文档中。

  • 正排索引:从文档角度看其中的单词,表示每个文档都含有哪些单词,以及每个单词出现了多少次(词频)及其出现位置(相对于文档首部的偏移量)。【即id —> value】

  • 倒排索引:从单词角度看文档,标识每个单词分别在那些文档中出现(文档ID),以及在各自的文档中每个单词分别出现了多少次(词频)- 及其出现位置(相对于该文档首部的偏移量)。【即value —> id】

ES中为所有字段默认都建了倒排索引。

1.3 适用情形

[1] 全文检索

  • Elasticsearch 靠全文检索起步,将 Lucene 开发包做成一个数据产品,屏蔽了 Lucene 各种复杂的设置,为开发人员提供了便利。

[2] 应用查询

  • Elasticsearch 最擅长的就是查询,基于倒排索引核心算法,查询性能强于 B-Tree 类型所有数据产品,尤其是关系型数据库方面。当数据量超过千万或者上亿时,数据检索的效率非常明显。

[3] 大数据领域

  • Elasticserach 已经成为大数据平台对外提供查询的重要组成部分之一。大数据平台将原始数据经过迭代计算,之后结果输出到一个数据库提供查询,特别是大批量的明细数据。

[4] 日志检索

  • 著名的 ELK 三件套,讲的就是 Elasticsearch,Logstash,Kibana,专门针对日志采集、存储、查询设计的产品组合。

[5] 监控领域

  • 指标监控,Elasticsearch 进入此领域比较晚,却赶上了好时代,Elasticsearch 由于其倒排索引核心算法,也是支持时序数据场景的,性能也是相当不错的,在功能性上完全压住时序数据库。

[6] 机器学习

  • 很多数据产品都集成了,Elasticsearch真正将机器学习落地成为一个产品 ,简化使用,所见即所得。而不像其它数据产品,仅仅集成算法包,使用者还必须开发很多应用支持。

2. Docker-ElasticSearch环境搭建

2.1 拉取镜像并运行容器

2.1.1 部署命令

1
2
3
4
5
6
7
8
9
$ docker pull elasticsearch:7.16.2
$ docker run -d --name es \
-p 9200:9200 -p 9300:9300 \
-v /root/docker/es/data:/usr/share/elasticsearch/data \
-v /root/docker/es/config:/usr/share/elasticsearch/config \
-v /root/docker/es/plugins:/usr/share/elasticsearch/plugins \
-e "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms1g -Xmx1g" \
elasticsearch:7.16.2
$ docker update es --restart=always

2.1.2 进入容器进行配置

这时使用docker ps命令查看虽然运行起来了,但还无法访问,需要进入容器内部修改配置解决跨域问题。

1
2
3
4
5
$ docker ps
$ docker exec -it es /bin/bash
$ cd config
$ chmod o+w elasticsearch.yml
$ vi elasticsearch.yml

其中,在 elasticsearch.yml 文件的末尾添加以下三行代码(前两行解决跨域问题,第三行开启xpack安全认证)

1
2
3
http.cors.enabled: true
http.cors.allow-origin: "*"
xpack.security.enabled: true

然后把权限修改回来,重启容器,设置账号密码,浏览器访问http://IP:9200地址即可(用 elastic账号 和自己设置的密码登录即可)

1
2
3
4
5
$ chmod o-w elasticsearch.yml
$ exit
$ docker restart es
$ docker exec -it es /bin/bash
$ ./bin/elasticsearch-setup-passwords interactive // 然后设置一大堆账号密码

2.1.3 注意事项

1)Elasticsearch请选择7.16.0之后的版本,之前的所有版本都使用了易受攻击的 Log4j2版本,存在严重安全漏洞。

2)ES_JAVA_OPTS="-Xms1g -Xmx1g"只是一个示例,内存设置的少了会导致数据查询速度变慢,具体设置多少要根据业务需求来定,一般而言公司的实际项目要设置8g内存以上。

2.1.4 数据挂载遇到的问题

[1] 数据锁定问题

  • 报错信息:java.lang.IllegalStateException: failed to obtain node locks, tried [[/usr/share/elasticsearch/data]] with lock id [0]; maybe these locations are not writable or multiple nodes were started without increasing

  • 产生原因:ES在运行时会在/data/nodes/具体分片目录里生成一个node.lock文件,由于我是在运行期scp过来的挂载数据,这个也被拷贝过来了,导致数据被锁定。

  • 解决办法:删掉/data/nodes/具体分片/node.lock文件即可

[2] data目录权限问题

  • 解决办法:进入容器内部,把data目录的权限设置为777即可

[3] 集群与单节点问题

  • 解决办法:修改config/elasticsearch.yml里的集群配置即可,如果原来是集群,现在要单节点,就把集群配置去掉。

[4] 堆内存配置问题

  • 报错信息:initial heap size [8589934592] not equal to maximum heap size [17179869184]; this can cause resize pauses

  • 解决办法:-Xms 与 -Xmx 设置成相同大小的内存。

2.2 可视化管理ES

2.2.1 使用Elasticvue浏览器插件

可借助 Elasticvue Chrome插件实现ES数据库的可视化管理。

elasticvue

2.2.2 安装kibana可视化插件

下载与ES版本相同的Kibana

1
2
3
4
5
6
$ mkdir -p /root/kibana
$ cd /root/kibana
$ wget https://artifacts.elastic.co/downloads/kibana/kibana-7.16.2-linux-x86_64.tar.gz
$ tar -zxvf kibana-7.16.2-linux-x86_64.tar.gz
$ cd /root/kibana/kibana-7.16.2-linux-x86_64
$ vi /config/kibana.yml

修改配置文件内容如下(用不到的我这里给删掉了,原配置文件有着很详尽的英文说明):

1
2
3
4
5
6
server.port: 5601
server.host: "ip"
elasticsearch.hosts: ["http://ip:9200"]
elasticsearch.username: "username"
elasticsearch.password: "password"
i18n.locale: "zh-CN"

启动kibana:

1
2
$ cd /root/kibana/kibana-7.16.2-linux-x86_64/bin # 进入可执行目录
$ nohup /root/kibana/kibana-7.16.2-linux-x86_64/bin/kibana & # 启动kibana

说明:如果是root用户,会报Kibana should not be run as root. Use --allow-root to continue.的错误,建议切换别的用户去执行,如果就是想用root用户启动,则使用nohup /root/docker/kibana/kibana-7.16.2-linux-x86_64/bin/kibana --allow-root &

启动成功后,浏览器打开http://ip:5601/地址,用es的用户名和密码进行登录,就可以使用了。

Kibana管理面板

关闭kibana:

1
2
$ ps -ef | grep kibana
$ kill -9 [PID]

2.3 安装ik分词器插件

项目简介:IK 分析插件将 Lucene IK 分析器集成到 elasticsearch 中,支持自定义字典。

项目地址:https://github.com/medcl/elasticsearch-analysis-ik

安装方式:去Releases下载对应ES版本的ik分词器插件,然后上传到Plugins目录将其挂载到容器内。

测试方式:ik分词器有2种算法:ik_smart和ik_max_word,下面我们通过postman工具来测试ik分词器的分词算法。

[1] 测试ik_smart分词

请求url:http://ip:9200/_analyze

请求方式:get

请求参数:

1
2
3
4
{
"analyzer":"ik_smart",
"text":"我爱你,特靠谱"
}

[2] 测试ik_max_word分词

请求url:http://ip:9200/_analyze

请求方式:get

请求参数:

1
2
3
4
{
"analyzer":"ik_max_word",
"text":"我爱你,特靠谱"
}

上面测试例子可以看到,不管是ik_smart还是ik_max_word算法,都不认为”特靠谱”是一个关键词(ik分词器的自带词库中没有有”特靠谱”这个词),所以将这个词拆成了三个词:特、靠、谱。

自定义词库:ik分词器会把分词库中没有的中文按每个字进行拆分。如果不想被拆分,那么就需要维护一套自己的分词库。

Step1:进入ik分词器路径/config目录,新建一个my.dic文件,添加一些关键词,如”特靠谱”、”靠谱”等,每一行就是一个关键词。

Step2:修改配置文件IKAnalyzer.cfg.xml,配置<entry key="ext_dict"></entry>

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict">my.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords"></entry>
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">words_location</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

Step3:重启ES,并再次使用Postman测试上述请求,发现”特靠谱”、”靠谱”等将其视为一个词了。

2.4 使用curl操作ES

1
2
3
4
5
6
7
8
// 查询所有索引
$ curl -u 用户名:密码 http://ip:port/_cat/indices

// 删除索引(包含结构)
$ curl -u 用户名:密码 -XDELETE http://ip:port/索引名

// 清空索引(不包含结构)
$ curl -u 用户名:密码 -XPOST 'http://ip:port/索引名/_delete_by_query?refresh&slices=5&pretty' -H 'Content-Type: application/json' -d'{"query": {"match_all": {}}}'

3. 与Springboot的整合

完整示例代码已在Github上开源:https://github.com/Logistic98/es-springboot-demo

3.1 配置项目依赖

使用Maven拉取项目依赖,注意服务端与高级客户端的版本要与你搭建的Elasticsearch服务版本一致。

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--统一管理全局变量-->
<properties>
<elasticsearch.version>7.16.2</elasticsearch.version>
<elasticsearch.rest.high.level.client.version>7.16.2</elasticsearch.rest.high.level.client.version>
</properties>

<!-- elasticsearch 服务端 -->
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>${elasticsearch.version}</version>
</dependency>
<!-- elasticsearch 高级客户端 -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>${elasticsearch.rest.high.level.client.version}</version>
</dependency>

注意事项:

1)Elasticsearch请选择7.16.0之后的版本,之前的所有版本都使用了易受攻击的 Log4j2版本,存在严重安全漏洞。

2)如果缺失 httpcore 和 httpclient 依赖,可手动进行添加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--统一管理全局变量-->
<properties>
<httpclient.version>4.5.5</httpclient.version>
<httpcore.version>4.4.9</httpcore.version>
</properties>

<!-- HttpClient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpclient.version}</version>
</dependency>
<!-- HttpCore -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>${httpcore.version}</version>
</dependency>

3.2 编写配置文件

我搭建ES服务时开启了xpack安全验证,所以是需要账号密码的,没开启的话就不需要。

application.properties

1
2
3
4
5
## elasticsearch配置
elasticsearch.host=127.0.0.1
elasticsearch.port=9200
elasticsearch.username=username
elasticsearch.password=password

/config/ElasticsearchConfiguration.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Configuration
public class ElasticsearchConfiguration {

@Value("${elasticsearch.host}")
private String host;

@Value("${elasticsearch.port}")
private int port;

@Value("${elasticsearch.username}")
private String username;

@Value("${elasticsearch.password}")
private String password;

@Bean(destroyMethod = "close", name = "client")
public RestHighLevelClient initRestClient() {
// 用户认证对象
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
// 设置账号密码
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password));
// 创建rest client对象
RestClientBuilder builder = RestClient.builder(
new HttpHost(host, port))
.setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback() {
@Override
public HttpAsyncClientBuilder customizeHttpClient(
HttpAsyncClientBuilder httpClientBuilder) {
return httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider);
}
});
return new RestHighLevelClient(builder);
}
}

4. 基本增删查改操作

本文只讲Java里如何操作ES,关于如何直接通过HTTP请求操作ES的部分略过。

4.1 索引操作

4.1.1 创建索引

1
2
3
4
5
6
@Override
public boolean createIndex(String index) throws IOException {
CreateIndexRequest createIndexRequest = new CreateIndexRequest(index);
CreateIndexResponse createIndexResponse = client.indices().create(createIndexRequest, RequestOptions.DEFAULT);
return createIndexResponse.isAcknowledged();
}

4.1.2 查询索引

1
2
3
4
5
6
@Override
public String[] queryIndex(String index) throws IOException {
GetIndexRequest queryIndexRequest = new GetIndexRequest(index);
GetIndexResponse getIndexResponse = client.indices().get(queryIndexRequest, RequestOptions.DEFAULT);
return getIndexResponse.getIndices();
}

4.1.3 删除索引

1
2
3
4
5
6
@Override
public boolean deleteIndex(String index) throws IOException {
DeleteIndexRequest deleteIndexRequest = new DeleteIndexRequest(index);
AcknowledgedResponse deleteIndexResponse = client.indices().delete(deleteIndexRequest, RequestOptions.DEFAULT);
return deleteIndexResponse.isAcknowledged();
}

4.1.4 检查索引是否存在

1
2
GetIndexRequest getIndexRequest = new GetIndexRequest (Constant.INDEX);
boolean exists = client.indices().exists(getIndexRequest, RequestOptions.DEFAULT);

4.2 文档操作

公共文件如下:

/constant/Constant.java

1
2
3
public interface Constant {
String INDEX = "user";
}

/pojo/UserDocument.java

1
2
3
4
5
6
7
8
@Data
public class UserDocument {
private String id;
private String name;
private String sex;
private Integer age;
private String city;
}

4.2.1 新增文档

1
2
3
4
5
6
7
8
9
@Override
public Boolean createDocument(UserDocument document) throws Exception {
String id = document.getId();
IndexRequest indexRequest = new IndexRequest(Constant.INDEX)
.id(id)
.source(JSON.toJSONString(document), XContentType.JSON);
IndexResponse indexResponse = client.index(indexRequest, RequestOptions.DEFAULT);
return indexResponse.status().equals(RestStatus.CREATED);
}

注:如果没加.id(id)部分,则会自动生成一个20位的 UUID 作为 _id 字段。

4.2.2 查询文档

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public UserDocument queryDocument(String id) throws IOException {
GetRequest getRequest = new GetRequest(Constant.INDEX, id);
GetResponse getResponse = client.get(getRequest, RequestOptions.DEFAULT);
UserDocument result = new UserDocument();
if (getResponse.isExists()) {
String sourceAsString = getResponse.getSourceAsString();
result = JSON.parseObject(sourceAsString, UserDocument.class);
} else {
logger.error("没有找到该 id 的文档");
}
return result;
}

4.2.3 修改文档

1
2
3
4
5
6
7
8
9
@Override
public Boolean updateDocument(UserDocument document) throws Exception {
String id = document.getId();
UpdateRequest updateRequest = new UpdateRequest();
updateRequest.index(Constant.INDEX).id(id);
updateRequest.doc(JSON.toJSONString(document), XContentType.JSON);
UpdateResponse updateResponse = client.update(updateRequest, RequestOptions.DEFAULT);
return updateResponse.status().equals(RestStatus.OK);
}

4.2.4 删除文档

1
2
3
4
5
6
@Override
public String deleteDocument(String id) throws Exception {
DeleteRequest deleteRequest = new DeleteRequest(Constant.INDEX, id);
DeleteResponse response = client.delete(deleteRequest, RequestOptions.DEFAULT);
return response.getResult().name();
}

4.2.5 批量操作文档

批量新增:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public Boolean bulkCreateDocument(List<UserDocument> documents) throws IOException {
BulkRequest bulkRequest = new BulkRequest();
for (UserDocument document : documents) {
String id = document.getId();
IndexRequest indexRequest = new IndexRequest(Constant.INDEX)
.id(id)
.source(JSON.toJSONString(document), XContentType.JSON);
bulkRequest.add(indexRequest);
}
BulkResponse bulkResponse = client.bulk(bulkRequest, RequestOptions.DEFAULT);
return bulkResponse.status().equals(RestStatus.OK);
}

批量删除:

1
2
3
4
5
6
7
8
9
10
@Override
public Boolean bulkDeleteDocument(List<UserDocument> documents) throws Exception {
BulkRequest bulkRequest = new BulkRequest();
for (UserDocument document : documents) {
String id = document.getId();
bulkRequest.add(new DeleteRequest().index(Constant.INDEX).id(id));
}
BulkResponse bulkResponse = client.bulk(bulkRequest, RequestOptions.DEFAULT);
return bulkResponse.status().equals(RestStatus.OK);
}

4.2.6 全量查询文档

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public List<UserDocument> queryAllDocument() throws IOException {
SearchRequest getAllRequest = new SearchRequest();
getAllRequest.indices(Constant.INDEX);
getAllRequest.source(new SearchSourceBuilder().query(QueryBuilders.matchAllQuery()));
SearchResponse getAllResponse = client.search(getAllRequest, RequestOptions.DEFAULT);
SearchHits hits = getAllResponse.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

4.2.7 查询结果过滤

全量查询文档结果处理(字段排序、过滤字段)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public List<UserDocument> queryFilterDocument() throws IOException {
SearchRequest request = new SearchRequest();
request.indices(Constant.INDEX);
SearchSourceBuilder builder = new SearchSourceBuilder().query(QueryBuilders.matchAllQuery());
builder.sort("age", SortOrder.DESC);
String[] excludes = {"id","city"};
String[] includes = {};
builder.fetchSource(includes, excludes);
request.source(builder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

4.2.8 分页查询文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public List<UserDocument> queryPageDocument(int from, int size) throws IOException {
SearchRequest getPartRequest = new SearchRequest();
getPartRequest.indices(Constant.INDEX);
SearchSourceBuilder builder = new SearchSourceBuilder().query(QueryBuilders.matchAllQuery());
builder.from(from); // 分页起始位置,(当前页码-1)*每页显示数据条数
builder.size(size); // 每页展示条数
getPartRequest.source(builder);
SearchResponse response = client.search(getPartRequest, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

4.2.9 条件查询文档

单条件查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public List<UserDocument> querySingleConditionDocument(String name) throws IOException {
SearchRequest request = new SearchRequest();
request.indices(Constant.INDEX);
request.source(new SearchSourceBuilder().query(QueryBuilders.termQuery("name", name)));
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

组合条件查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public List<UserDocument> queryCombinationConditionDocument(String name,String city) throws IOException {
SearchRequest request = new SearchRequest();
request.indices(Constant.INDEX);
SearchSourceBuilder builder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
boolQueryBuilder.must(QueryBuilders.matchQuery("name", name));
boolQueryBuilder.mustNot(QueryBuilders.matchQuery("city", city));
builder.query(boolQueryBuilder);
request.source(builder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

注意事项:matchQuery搜索的时候,首先会解析查询字符串,进行分词然后再查询;而termQuery,输入的查询内容是什么,就会按照什么去查询,并不会解析查询内容。

4.2.10 范围查询文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public List<UserDocument> queryRangeDocument(int minAge, int maxAge) throws IOException {
SearchRequest request = new SearchRequest();
request.indices(Constant.INDEX);
SearchSourceBuilder builder = new SearchSourceBuilder();
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("age");
// gt大于,gte大于等于,lt小于,lte小于等于
rangeQuery.gte(minAge);
rangeQuery.lt(maxAge);
builder.query(rangeQuery);
request.source(builder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

4.2.11 模糊查询文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public List<UserDocument> queryFuzzyDocument(String name) throws IOException {
SearchRequest request = new SearchRequest();
request.indices(Constant.INDEX);
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(QueryBuilders.fuzzyQuery("name", name).fuzziness(Fuzziness.ONE)); // 模糊字段偏移量
request.source(builder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHits hits = response.getHits();
List<UserDocument> result = new ArrayList<>();
for ( SearchHit hit : hits ) {
result.add(JSON.parseObject(hit.getSourceAsString(), UserDocument.class));
}
return result;
}

5. Elasticsearch通用封装

为了减少代码重复,实际开发中会将常用的ES方法进行封装,使用时直接传参调用即可。

其中 pom.xmlapplication.properties/config/ElasticsearchConfiguration.java 等文件的配置同上。

/service/ElasticSearchService.java的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.bulk.BulkResponse;
import org.elasticsearch.action.delete.DeleteRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.action.update.UpdateRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.client.core.CountRequest;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.aggregations.AggregationBuilder;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.elasticsearch.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.*;

/**
* 通用ElasticSearch工具接口
*/
@Slf4j
@Service
public class ElasticSearchService {

@Autowired
private RestHighLevelClient restHighLevelClient;

/**
* 共用方法:ElasticSearch通用查询
* @param searchSourceBuilder
* @param index
* @return
*/
private SearchResponse pageQuerySearchResponse(SearchSourceBuilder searchSourceBuilder, String index) {
SearchRequest searchRequest = new SearchRequest()
.source(searchSourceBuilder)
.indices(index);
SearchResponse searchResponse = null;
try {
searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
e.printStackTrace();
}
return searchResponse;
}

/**
* 共用方法:ElasticSearch批量操作(新增、删除)数据
*
* @param bulkRequest
*/
private void esBatch(BulkRequest bulkRequest) {
try {
BulkResponse bulkResponse = restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
log.error("bulk错误信息:{}", bulkResponse.buildFailureMessage());
}
} catch (Exception e) {
log.error("批量操作es数据错误", e);
}
}

/**
* 查询指定条件的数据
* @param indexName 索引名称
* @param sortField 排序字段
* @param sortType 排序类别
* @param page 页码
* @param rows 每页大小
* @param boolQueryBuilder 查询条件
* @param includeFields 返回字段
* @param excludeFields 排除字段
* @return
*/
public SearchResponse search(String indexName, String sortField, String sortType, Integer page, Integer rows, BoolQueryBuilder boolQueryBuilder,
String[] includeFields, String[] excludeFields) {

SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.from((page - 1) * rows)
.size(rows)
.fetchSource(includeFields, excludeFields)
.trackTotalHits(true);

SortOrder so = SortOrder.DESC;
if (ObjectUtil.isNotEmpty(sortType) && "asc".equals(sortType)) {
so = SortOrder.ASC;
}
searchSourceBuilder.sort(sortField, so);
searchSourceBuilder.query(boolQueryBuilder);

return pageQuerySearchResponse(searchSourceBuilder, indexName);
}

/**
* 查询单条数据
* @param indexName 索引名称
* @param boolQueryBuilder 查询条件
* @return
*/
public Map<String, Object> searchOne(String indexName, BoolQueryBuilder boolQueryBuilder) {
Map<String, Object> resultMap = new HashMap();
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().from(0).size(1);
searchSourceBuilder.query(boolQueryBuilder);
SearchResponse searchResponse = pageQuerySearchResponse(searchSourceBuilder, indexName);
if (searchResponse.getHits().getTotalHits().value > 0) {
SearchHit searchHit = searchResponse.getHits().getHits()[0];
resultMap = searchHit.getSourceAsMap();
}
return resultMap;
}

/**
* 获取指定索引下的数据量
*
* @param indexName
* @return
*/
public Long getCountByIndex(String indexName) {
Long totalHites = 0L;
if (!StrUtil.isEmpty(indexName)) {
try {
CountRequest countRequest = new CountRequest(indexName);
totalHites = restHighLevelClient.count(countRequest, RequestOptions.DEFAULT).getCount();
} catch (Exception e) {
e.printStackTrace();
}
}
return totalHites;
}

/**
* 查询聚合数据
* @param indexName 索引名称
* @param boolQueryBuilder 查询条件
* @param aggregationBuilder 聚合条件
* @param includeFields 返回字段
* @param excludeFields 排除字段
* @return
*/
public SearchResponse aggsSearch(String indexName, BoolQueryBuilder boolQueryBuilder, AggregationBuilder aggregationBuilder,
String[] includeFields, String[] excludeFields) {
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.size(0)
.fetchSource(includeFields, excludeFields)
.trackTotalHits(true);
searchSourceBuilder.aggregation(aggregationBuilder);
searchSourceBuilder.query(boolQueryBuilder);
return pageQuerySearchResponse(searchSourceBuilder, indexName);
}

/**
* 批量添加数据(自动生成id)
* @param indexName
* @param dataList
*/
public void addBatchDataAutoId(String indexName, List<Map> dataList) {
BulkRequest request = new BulkRequest();
try {
int count = 0;
for (Map map : dataList) {
request.add(new IndexRequest(indexName).source(map));
count++;
if (count == 1000) {
esBatch(request);
count = 0;
}
}
if (count != 0) {
esBatch(request);
}
} catch (Exception e) {
log.error("生成BulkRequest错误", e);
}
}

/**
* 批量添加数据(主动指定id)
* @param indexName
* @param dataList
*/
public void addBatchData(String indexName, List<Map> dataList) {
BulkRequest request = new BulkRequest();
try {
int count = 0;
for (Map map : dataList) {
String id = map.get("id").toString();
request.add(new IndexRequest(indexName).id(id).source(map));
count++;
if (count == 1000) {
esBatch(request);
count = 0;
}
}
if (count != 0) {
esBatch(request);
}
} catch (Exception e) {
log.error("生成BulkRequest错误", e);
}
}

/**
* 批量删除数据
* @param indexName
* @param idList
*/
public void deleteBatchData(String indexName, List<String> idList) {
BulkRequest request = new BulkRequest();
try {
int count = 0;
for (String id : idList) {
request.add(new DeleteRequest().index(indexName).id(id));
count++;
if (count == 1000) {
esBatch(request);
count = 0;
}
}
if (count != 0) {
esBatch(request);
}
} catch (Exception e) {
log.error("生成BulkRequest错误", e);
}
}

/**
* 修改数据
* @param indexName
* @param dataMap
*/
public void update(String indexName, Map dataMap) {
String contentId = dataMap.get("id").toString();
UpdateRequest updateRequest = new UpdateRequest(indexName, "_doc", contentId);
updateRequest.doc(JSONUtil.parse(dataMap.get("data")).toString(), XContentType.JSON);
try {
restHighLevelClient.update(updateRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
e.printStackTrace();
}
}

}

6. Elasticsearch常见问题

6.1 依赖版本不正确的问题

问题情景:使用上述通用封装的“修改数据”方法时,意外报错Handler dispatch failed; nested exception is java.lang.NoClassDefFoundError: org/elasticsearch/xcontent/XContentType,是依赖问题。

问题排查:首先我排查了引用的项目依赖,发现其中有 7.16.2 和 7.12.1 两个版本,实际用的是 7.12.1 版本,那个版本缺东西所以报错,但是我并没有配置过7.12.1版本的ES。查阅资料后发现,是 2.5.4 版本的 spring-boot-starter-parent 里默认指定了ES版本为7.12.1,所以导致了该问题。

  • Step1:找到项目总 pom.xml ,找到 spring-boot-starter-parent,选中后按 Ctrl + 单击进行跳转

    1
    2
    3
    4
    5
    6
    <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.5.4</version>
    <relativePath/> <!-- lookup parent from repository -->
    </parent>
  • Step2:在跳转文件 spring-boot-starter-parent-2.5.4.pom 里找到 spring-boot-dependencies,选中后按 Ctrl + 单击进行跳转

    1
    2
    3
    4
    5
    <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-dependencies</artifactId>
    <version>2.5.4</version>
    </parent>
  • Step3:在跳转文件 spring-boot-dependencies-2.5.4.pom 里我们可以看到默认使用了 7.12.1 版本的ES

    1
    2
    3
    <properties>
    <elasticsearch.version>7.12.1</elasticsearch.version>
    </properties>

问题解决:找到了问题所在,我们只需要在项目总 pom.xml 里指定一下我们需要的 ES 版本即可。

1
2
3
4
<properties>
<!--2.5.4版本的 spring-boot-starter-parent 默认使用的es依赖为 7.12.1,因此这里需要手动指定-->
<elasticsearch.version>7.16.2</elasticsearch.version>
</properties>

6.2 ES出错返回404状态码的问题

问题情景:前端在请求 业务API 时返回了404状态码,一开始以为是请求路径不对,后来发现是涉及ES的业务代码出错导致的。

问题原因:Java对ES的请求API进行了封装,ES代码出错实际对应的是调用API请求ES出错,所以就返回了404。

问题解决:查看日志找到开头的错误原因进行修复即可(ES出错的日志特别多,可能会超出默认控制台的显示上限,查看不到有效的报错信息,这里修改IDEA的/安装目录/bin/idea.properties配置文件,可以取消限制,具体操作以MacOS为例如下)

  • Step1:打开 Finder 通过 Application 找到 IDEA 应用,右键点击“显示包内容”。

  • Step2:修改/安装目录/bin/idea.properties配置文件,将 idea.cycle.buffer.size 设置为 disabled

    1
    2
    3
    4
    5
    6
    #---------------------------------------------------------------------
    # This option controls console cyclic buffer: keeps the console output size not higher than the specified buffer size (KiB).
    # Older lines are deleted. In order to disable cycle buffer use idea.cycle.buffer.size=disabled
    #---------------------------------------------------------------------
    #idea.cycle.buffer.size=1024
    idea.cycle.buffer.size=disabled
  • Step3:重启IDEA即可(如果还不好使的话,可能是插件影响了,比如 Grep console,卸载掉就好了)

7. Python操作ElasticSearch

以ElasticSearch的导入导出为例,代码已在Github上开源,项目地址为:https://github.com/Logistic98/es-data-transfer

Step1:安装依赖并编写配置文件

1
$ pip install elasticsearch==7.16.2   // 注意要和ES的版本保持一致

config.ini(把ES连接信息换成自己的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[TARGET_ES]
host = 192.168.1.1
port = 9200
user = elastic
password = elastic
timeout = 60

[SOURCE_ES]
host = 192.168.1.2
port = 9200
user = elastic
password = elastic
timeout = 60
index_list = test_index1, test_index2

注:多个索引之间用英文逗号分隔(逗号后面有没有空格都无所谓,读取配置时会进行处理)

Step2:编写ES导入导出脚本

export_es_data.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# -*- coding: utf-8 -*-

from elasticsearch import Elasticsearch
from datetime import timedelta
import datetime
import os
import json
import logging
from configparser import ConfigParser

# 生成日志文件
logging.basicConfig(filename='logging_es.log', level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def read_config():
cfg = ConfigParser()
cfg.read('./config.ini', encoding='utf-8')
host = cfg.get('SOURCE_ES', 'host')
port = cfg.get('SOURCE_ES', 'port')
user = cfg.get('SOURCE_ES', 'user')
password = cfg.get('SOURCE_ES', 'password')
timeout = cfg.get('SOURCE_ES', 'timeout')
index_list = cfg.get('SOURCE_ES', 'index_list')
es_dict = {}
es_dict['host'] = host
es_dict['port'] = port
es_dict['user'] = user
es_dict['password'] = password
es_dict['timeout'] = timeout
es_dict['index_list'] = index_list
return es_dict


def write_list_to_json(list, json_file_name, json_file_save_path):
"""
将list写入到json文件
:param list:
:param json_file_name: 写入的json文件名字
:param json_file_save_path: json文件存储路径
:return:
"""
if not os.path.exists(json_file_save_path):
os.makedirs(json_file_save_path)
os.chdir(json_file_save_path)
with open(json_file_name, 'w', encoding='utf-8') as f:
json.dump(list, f, ensure_ascii=False)


def es_json(es_dict, start_time, end_time):
str_separate = "==============================================================="
try:
BASE_DIR = os.getcwd()
Es = Elasticsearch(
hosts=[str(es_dict['host']) + ":" + str(es_dict['port'])],
http_auth=(str(es_dict['user']), str(es_dict['password'])),
timeout=int(es_dict['timeout'])
)

except Exception as e:
logging.error(e)

index_list = ''.join(es_dict['index_list'].split()).split(",")
for i in index_list:
print(f"保存索引{i}的数据\r")
print_info1 = "保存索引" + i + "的数据"
logging.info(print_info1)
query = {
"query": {
"range": {
"@timestamp": {
# 大于上一次读取结束时间,小于等于本次读取开始时间
"gt": start_time,
"lte": end_time
}
}
},
"size": 10000
}
try:
data = Es.search(index=i, body=query)
source_list = []
for hit in data['hits']['hits']:
source_data = hit['_source']
source_data['_id'] = hit['_id']
source_list.append(source_data)
print(f"保存的时间为{start_time}{end_time}\r")
print_info2 = "保存的时间为" + start_time + "到" + end_time + ""
logging.info(print_info2)
file_path = BASE_DIR + "/json_file"
file_name = str(i) + ".json"
if len(source_list) != 0:
write_list_to_json(source_list, file_name, file_path)
else:
print('无更新')
logging.info(str(i) + '无更新')
print(str_separate)
logging.info(str_separate)
except Exception as e:
print(e)
logging.info("es数据库到json文件的读写error" % e)
logging.info(str_separate)


if __name__ == '__main__':
start_date_time = datetime.datetime.now() + timedelta(days=-1)
end_date_time = datetime.datetime.now()
start_time = start_date_time.strftime("%Y-%m-%dT%H:00:00.000Z")
end_time = end_date_time.strftime("%Y-%m-%dT%H:00:00.000Z")
# 读取配置信息
es_dict = read_config()
# 获取当前的目录地址
BASE_DIR = os.getcwd()
# 读取es数据库中的数据,写成json文件
es_json(es_dict, start_time, end_time)

import_es_data.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# -*- coding: utf-8 -*-

import os
import logging
import time

from elasticsearch import Elasticsearch, helpers
from configparser import ConfigParser


# 生成日志文件
logging.basicConfig(filename='logging_es.log', level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# 查找解密后的json文件
def json_es(BASE_DIR):
json_path = BASE_DIR + '/json_file/'
filelist = []
for file in os.listdir(json_path):
if '.json' == file[-5:]:
filelist.append(json_path + file)
for i in filelist:
head, sep, tail = i.partition('json_file/')
indexname = tail
head, sep, tail = indexname.partition('.json')
index_name = head
read_json(i, index_name)
os.remove(i)

# 读json文件
def read_json(file_path, index_name):
with open(file_path, 'r', encoding='utf-8') as file:
json_str = file.read()
# json_str中会存在一个null字符串表示空值,但是python里面没有null这个关键字,需要将null定义为变量名,赋值python里面的None
null = None
# 将字符串形式的列表数据转成列表数据
json_list = eval(json_str)
batch_data(json_list, index_name)

# 将构造好的列表写入ES数据库
def batch_data(json_list, index_name):
""" 批量写入数据 """
# 按照步长分批插入数据库,缓解插入数据库时的压力
length = len(json_list)
# 步长为1000,缓解批量写入的压力
step = 1000
for i in range(0, length, step):
# 要写入的数据长度大于步长,那么久分批写入
if i + step < length:
actions = []
for j in range(i, i + step):
# 先把导入时添加的"_id"的值取出来
new_id = json_list[j]['_id']
del json_list[j]["_id"] # 要删除导入时添加的"_id"
action = {
"_index": str(index_name),
"_id": str(new_id),
"_source": json_list[j]
}
actions.append(action)
helpers.bulk(Es, actions, request_timeout=120)
# 要写入的数据小于步长,那么久一次性写入
else:
actions = []
for j in range(i, length):
# 先把导入时添加的"_id"的值取出来
new_id = json_list[j]['_id']
del json_list[j]["_id"] # 要删除导入时添加的"_id"
action = {
"_index": str(index_name),
"_id": str(new_id),
"_source": json_list[j]
}
actions.append(action)
helpers.bulk(Es, actions, request_timeout=120)
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
insert_es_info = str(index_name) + "索引插入了" + str(length) + "条数据,时间是" + str(now_time)
logging.info(insert_es_info)

def read_config():
cfg = ConfigParser()
cfg.read('./config.ini', encoding='utf-8')
host = cfg.get('TARGET_ES', 'host')
port = cfg.get('TARGET_ES', 'port')
user = cfg.get('TARGET_ES', 'user')
password = cfg.get('TARGET_ES', 'password')
timeout = cfg.get('TARGET_ES', 'timeout')
es_dict = {}
es_dict['host'] = host
es_dict['port'] = port
es_dict['user'] = user
es_dict['password'] = password
es_dict['timeout'] = timeout
return es_dict


if __name__ == '__main__':
# 获取当前的目录地址
BASE_DIR = os.getcwd()
# 读取配置文件
es_dict = read_config()
# 构造连接
Es = Elasticsearch(
hosts=[str(es_dict['host']) + ":" + str(es_dict['port'])],
http_auth=(str(es_dict['user']), str(es_dict['password'])),
timeout=int(es_dict['timeout'])
)
# 将解密的json文件写入ES数据库
json_es(BASE_DIR)

Step3:执行脚本导入导出

执行 export_es_data.py 会读取 [SOURCE_ES] 里的 ES 配置,对指定索引进行导出,注意单次仅能导出10000条数据

执行 import_es_data.py 会读取 [TARGET_ES] 里的 ES 配置,json_file文件夹内的json文件进行导入,导入成功后会删除这些json文件。

8. 参考资料

[1] 安装elasticsearch-analysis-ik分词器插件 from CSDN

[2] Elasticsearch可视化工具 from CSDN

[3] Elasticsearch:权威指南 from 官方文档

[4] Java高级REST客户端使用指南 from 官方文档

[5] 尚硅谷-ElasticSearch教程入门到精通 from Bilibili

[6] “Exception in thread “I/O dispatcher 1” java.lang.AssertionError“报错的解决方案 from Github issue

[7] Elasticsearch客户端基本身份验证 from 官方文档

[8] ES 既是搜索引擎又是数据库?真的有那么全能吗?from InfoQ

[9] Elasticsearch学习笔记 from CSDN

[10] ES termQuery和matchQuery区别浅析 from CSDN

[11] SpringBoot查看和修改依赖的版本 from CSDN