RestAPI

在前面我们讲解了手动发送的对 ES 索引库和文档的操作

本质上这些都是组装请求,携带JSON,进行我们想要的操作,通过http请求发送给ES。

REST(Representational State Transfer,表述性状态转移)是一种软件架构风格,用于构建网络应用程序。而基于 REST 风格设计的 API 就称为 RestAPI。

RestAPI 具备以下特点:

  • 资源抽象:将网络中的一切都视为资源,比如一篇文章、一个用户、一条订单记录等,每个资源都有对应的唯一 URI(Uniform Resource Identifier,统一资源标识符)来标识,像https://example.com/api/users/1 就可以表示 ID 为 1 的用户资源。
  • 无状态通信:客户端和服务器之间的通信是无状态的,即每次请求都包含了处理该请求所需的全部信息,服务器不会依赖之前请求的状态来处理当前请求。这使得请求可以被独立处理,提高了系统的可扩展性和可靠性。
  • 统一接口:使用标准的 HTTP 方法(GET、POST、PUT、DELETE 等)来操作资源。例如,GET 用于获取资源,POST 用于创建资源,PUT 用于更新资源,DELETE 用于删除资源 。
  • 分层系统:可以通过中间层(如代理服务器、网关等)来处理请求,这有助于提高系统的安全性、可维护性和性能,同时也能隐藏内部系统的复杂性。

ES官方提供了各种不同语言的客户端,用来操作ES。

官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html、

在 Spring Boot 中,我们通过RestHighLevelClient(Elasticsearch 官方提供的 Java 高级客户端) 来使用 RestAPI 操作 ES

pom.xml文件中添加 Elasticsearch 客户端依赖以及 Spring Data Elasticsearch 依赖,就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependencies>
<!-- Elasticsearch 客户端依赖 -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.17.0</version> <!-- 根据实际的ES版本调整 -->
</dependency>
<!-- Spring Data Elasticsearch 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
</dependencies>

但是我是9.1.4,所以这个依赖的内容要进行修改,因为当 Elasticsearch 版本为 9.1.4 时,RestHighLevelClient 已被官方标记为废弃(deprecated),并推荐使用 ElasticsearchClient(基于 Java Client 8.x+ 重构的新客户端,完全支持 ES 8+ 及 9.x 版本)。

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
<dependencies>
<!-- Elasticsearch 核心客户端(替代 RestHighLevelClient) -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>9.1.4</version>
</dependency>

<!-- JSON 处理器(客户端依赖 Jackson 处理 JSON) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<!-- Spring Boot 自动配置支持(可选,简化客户端注入) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
<!-- 排除旧的 RestHighLevelClient 相关依赖(若有冲突) -->
<exclusions>
<exclusion>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

然后在application.yml文件中配置 Elasticsearch 的连接信息:

1
2
3
4
5
6
7
spring:
data:
elasticsearch:
cluster-name: elasticsearch # ES集群名称,根据实际情况修改
cluster-nodes: 127.0.0.1:9300 # ES节点地址和端口,9300是传输端口,若使用HTTP端口(默认9200),后续配置客户端时需调整
rest:
uris: http://127.0.0.1:9200 # ES的HTTP访问地址

Spring Boot框架中如何使用 API 操作索引库

新版本9.x 和 7.x,8.x 的ES的API操作不太一样,我这里以我的版本 9.1.4 的内容来了

Elasticsearch 9.x 版本(以 9.1.4 为例)在 Java API 层面延续了 8.x 的 co.elastic.clients.elasticsearch 客户端体系,但在部分 API 细节(如索引映射构建、分页参数)上有微调。

前提

在编写操作方法前,需完成依赖引入和客户端初始化,这是后续所有操作的基础。

通过 @Configuration 类创建 ElasticsearchClient 实例,并注入 Spring 容器,供 Service 层调用。9.x 版本客户端默认使用 HTTP/2 协议,配置与 8.x 一致:

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
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class EsConfig {

@Bean
public ElasticsearchClient elasticsearchClient() {
// 1. 配置 ES 服务器地址(若为集群,可添加多个 HttpHost)
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200, "http") // 9.x 默认端口仍为 9200
).build();

// 2. 创建传输层(依赖 Jackson 解析 JSON)
RestClientTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper()
);

// 3. 创建并返回客户端实例
return new ElasticsearchClient(transport);
}
}

关于索引库的以下所有操作,均封装在 EsService 类中,通过 @Autowired 注入 ElasticsearchClient

创建索引 + 定义映射

9.x 版本创建索引时,映射(Mapping)的构建方式与 8.x 一致,需通过 mappings() 方法定义字段类型(如 keywordtextlong 等)。

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
/**
* 创建索引
*/
public boolean createUserIndex() {
try {
// 构建创建索引请求,定义映射
CreateIndexResponse response = elasticsearchClient.indices()
.create(c -> c // 调用 create方法
.index("user_index")
.mappings(m -> m // 通过 `mappings()` 方法定义字段类型
.properties("id", p -> p.keyword(k -> k)) // 每个字段名及其类型
.properties("name", p -> p.text(t -> t.analyzer("standard")))
.properties("age", p -> p.integer(i -> i))
.properties("email", p -> p.keyword(k -> k))
.properties("createTime", p -> p.date(d -> d.format("yyyy-MM-dd HH:mm:ss")))
)
.settings(s -> s
.numberOfShards("3")
.numberOfReplicas("1")
)
);
return response.acknowledged();
} catch (IOException e) {
throw new RuntimeException("创建索引失败", e);
}
}

一般情况下创建索引和判断索引是否存在会一起使用

1
2
3
4
5
6
7
8
// 1. 先判断索引是否已存在,避免重复创建
ExistsResponse existsResponse = esClient.indices().exists(
ExistsRequest.of(e -> e.index(INDEX_NAME))
);
if (existsResponse.value()) {
System.out.println("索引 " + INDEX_NAME + " 已存在,无需重复创建");
return false;
}

修改索引/更新索引

Elasticsearch 索引创建后,大部分配置(如字段类型)不可修改,仅支持添加新字段、修改索引别名等操作。9.x 版本修改映射的 API 与 8.x 一致。

galgame_images 索引新增 description 字段(text 类型,用于图片描述)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import co.elastic.clients.elasticsearch.indices.UpdateMappingRequest;
import co.elastic.clients.elasticsearch.indices.UpdateMappingResponse;

/**
* 修改索引:新增字段(仅支持新增,不支持修改已有字段类型)
*/
public boolean addIndexField() throws Exception {
// 构建修改映射请求:新增 "description" 字段
UpdateMappingRequest updateRequest = UpdateMappingRequest.of(u -> u
.index(INDEX_NAME)
.mappings(m -> m
.properties("description", p -> p.text(t -> t.analyzer("standard")))
)
);

UpdateMappingResponse response = esClient.indices().updateMapping(updateRequest);
return response.acknowledged(); // true:修改成功
}

但是可变字段肯定还是可以修改的

删除索引/文档

9.x 版本 API 与 8.x 一致,通过 delete() 方法执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest;
import co.elastic.clients.elasticsearch.indices.DeleteIndexResponse;

/**
* 删除索引
*/
public boolean deleteIndex() throws Exception {
// 判断索引是否存在,避免删除不存在的索引
if (!isIndexExists()) {
System.out.println("索引 " + INDEX_NAME + " 不存在,无需删除");
return false;
}

// 构建删除请求
DeleteIndexRequest deleteRequest = DeleteIndexRequest.of(d -> d.index(INDEX_NAME));
DeleteIndexResponse response = esClient.indices().delete(deleteRequest);
return response.acknowledged(); // true:删除成功
}

根据文档 ID 删除,与删除索引逻辑类似,可以先判断文档是否存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import co.elastic.clients.elasticsearch.core.DeleteRequest;
import co.elastic.clients.elasticsearch.core.DeleteResponse;

/**
* 根据 ID 删除文档
* @param docId 文档 ID
* @return 操作结果(DELETED:删除成功,NOT_FOUND:文档不存在)
*/
public IndexResult deleteDocById(String docId) throws Exception {
// 构建删除请求
DeleteRequest deleteRequest = DeleteRequest.of(d -> d
.index(INDEX_NAME)
.id(docId)
);

// 执行删除
DeleteResponse response = esClient.delete(deleteRequest);
return response.result(); // 返回结果(DELETED/NOT_FOUND)
}

查询索引

流程也差不多,判断存在 + 获取索引信息

获取索引信息:查询索引的映射、设置等详情(9.x 版本 API 无变化)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 获取索引的映射信息(示例:打印游戏名字段的映射)
*/
public void getIndexMapping() throws Exception {
// 9.x 版本通过 getMapping() 方法获取索引映射
var mappingResponse = esClient.indices().getMapping(g -> g.index(INDEX_NAME));
// 解析映射:获取 "gameName" 字段的类型
String gameNameFieldType = mappingResponse.get(INDEX_NAME)
.mappings()
.properties()
.get("gameName")
.type();
System.out.println("gameName 字段类型:" + gameNameFieldType); // 输出 "text"
}

新增/修改 文档

ES 中「新增」和「修改」文档共用 index() 方法:

  • 若文档 ID 不存在:执行「新增」操作;
  • 若文档 ID 已存在:执行「全量覆盖修改」操作(9.x 版本无变化)。

前提要定义实体类,字段需与索引映射对应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import co.elastic.clients.elasticsearch.core.IndexRequest;
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.elasticsearch.core.indexing.IndexResult;

/**
* 新增/修改文档(根据 ID 自动判断)
* @param docId 文档唯一 ID(建议用 fileName,与 MinIO 关联)
* @param imageInfo 文档内容(ImageInfo 对象)
* @return 操作结果(CREATED:新增,UPDATED:修改)
*/
public IndexResult addOrUpdateDoc(String docId, ImageInfo imageInfo) throws Exception {
// 构建索引请求:指定索引名、文档 ID、文档内容
IndexRequest<ImageInfo> indexRequest = IndexRequest.of(i -> i
.index(INDEX_NAME)
.id(docId) // 文档 ID(唯一标识,若为空则 ES 自动生成)
.document(imageInfo) // 文档内容(自动序列化为 JSON)
);

// 执行请求
IndexResponse response = esClient.index(indexRequest);
return response.result(); // 返回操作结果(CREATED/UPDATED)
}

查询文档

文档查询是 ES 的核心能力,9.x 版本支持 match_all(全量查询)、term(精确查询)、match(模糊查询)、bool(组合查询)等,分页参数通过 from(偏移量)和 size(每页条数)控制。

根据id查询
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
import co.elastic.clients.elasticsearch.core.GetRequest;
import co.elastic.clients.elasticsearch.core.GetResponse;

/**
* 根据 ID 查询单条文档
* @param docId 文档 ID
* @return 文档内容(ImageInfo 对象,若不存在则为 null)
*/
public ImageInfo getDocById(String docId) throws Exception {
// 构建查询请求
GetRequest getRequest = GetRequest.of(g -> g
.index(INDEX_NAME)
.id(docId)
);

// 执行请求
GetResponse<ImageInfo> response = esClient.get(getRequest, ImageInfo.class);
// 判断文档是否存在
if (response.found()) {
return response.source(); // 返回文档内容(自动反序列化为 ImageInfo)
} else {
System.out.println("文档 " + docId + " 不存在");
return null;
}
}
全量查询(匹配所有文档,带分页)
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
import co.elastic.clients.elasticsearch.core.SearchRequest;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import java.util.ArrayList;
import java.util.List;

/**
* 全量查询文档(match_all),支持分页
* @param from 偏移量(从第几条开始,默认 0)
* @param size 每页条数(默认 10,最大不超过 index.max_result_window,默认 10000)
* @return 文档列表
*/
public List<ImageInfo> searchAllDocs(int from, int size) throws Exception {
// 构建搜索请求:match_all + 分页
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(INDEX_NAME)
.query(q -> q.matchAll(m -> m)) // 匹配所有文档
.from(from) // 偏移量(如 from=10,从第 11 条开始)
.size(size) // 每页返回条数(如 size=20,每页 20 条)
);

// 执行搜索
SearchResponse<ImageInfo> response = esClient.search(searchRequest, ImageInfo.class);

// 解析结果:提取命中的文档
List<ImageInfo> docList = new ArrayList<>();
for (Hit<ImageInfo> hit : response.hits().hits()) {
if (hit.source() != null) {
docList.add(hit.source());
}
}

System.out.println("全量查询命中总数:" + response.hits().total().value());
return docList;
}
条件查询(组合查询:游戏名模糊 + 标签精确)

结合 match(模糊查询游戏名)和 term(精确查询标签),用 bool 组合条件

这种和上面也差不多,就是 Match\Term\search + Query,然后构建字段

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
import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery;
import co.elastic.clients.elasticsearch._types.query_dsl.MatchQuery;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch._types.query_dsl.TermQuery;

/**
* 条件查询:游戏名模糊匹配 + 标签精确匹配(组合查询)
* @param gameName 游戏名(模糊匹配,如 "原神" 可匹配 "原神 角色图")
* @param tag 标签(精确匹配,如 "角色" 仅匹配 tags 含 "角色" 的文档)
* @param from 偏移量
* @param size 每页条数
* @return 匹配的文档列表
*/
public List<ImageInfo> searchDocsByCondition(String gameName, String tag, int from, int size) throws Exception {
// 1. 构建组合查询条件(bool + must)
BoolQuery.Builder boolQuery = new BoolQuery.Builder();

// 若游戏名不为空,添加 match 模糊查询
if (gameName != null && !gameName.trim().isEmpty()) {
MatchQuery matchQuery = MatchQuery.of(m -> m
.field("gameName") // 匹配的字段
.query(gameName.trim()) // 查询关键词
.fuzziness("AUTO") // 允许模糊匹配(如 "原神" 匹配 "原神")
);
boolQuery.must(Query.of(q -> q.match(matchQuery)));
}

// 若标签不为空,添加 term 精确查询
if (tag != null && !tag.trim().isEmpty()) {
TermQuery termQuery = TermQuery.of(t -> t
.field("tags") // 匹配的字段(数组类型,自动匹配其中一个元素)
.value(tag.trim()) // 精确匹配的值
);
boolQuery.must(Query.of(q -> q.term(termQuery)));
}

// 2. 构建搜索请求
SearchRequest searchRequest = SearchRequest.of(s -> s
.index(INDEX_NAME)
.query(Query.of(q -> q.bool(boolQuery.build()))) // 组合查询条件
.from(from)
.size(size)
);

// 3. 执行搜索并解析结果(与全量查询一致)
SearchResponse<ImageInfo> response = esClient.search(searchRequest, ImageInfo.class);
List<ImageInfo> docList = new ArrayList<>();
for (Hit<ImageInfo> hit : response.hits().hits()) {
if (hit.source() != null) {
docList.add(hit.source());
}
}

System.out.println("条件查询命中总数:" + response.hits().total().value());
return docList;
}

实际项目中集成ES

我就以我上次minio那个项目继续扩展操作了

添加依赖和配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- Elasticsearch 9.1.4 客户端依赖 -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>9.1.4</version>
</dependency>

<!-- Elasticsearch JSON 序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<!-- Spring Data Elasticsearch 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

<!-- Elasticsearch Transport 客户端 -->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
# Elasticsearch 配置
spring.elasticsearch.uris=http://localhost:9200
spring.elasticsearch.username=
spring.elasticsearch.password=
spring.elasticsearch.connection-timeout=10s
spring.elasticsearch.socket-timeout=30s

# Elasticsearch 索引配置
elasticsearch.index.name=galgame-images
elasticsearch.index.settings.number_of_shards=1
elasticsearch.index.settings.number_of_replicas=0

添加对应的配置类

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
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;

/**
* Elasticsearch 配置类
* 配置ES客户端连接
*/
@Configuration
@EnableElasticsearchRepositories(basePackages = "hbnu.project.ergoutreegalemjstore.repository")
public class ElasticsearchConfig {

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

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

@Value("${elasticsearch.scheme:http}")
private String elasticsearchScheme;

/**
* 创建Elasticsearch客户端
*/
@Bean
public ElasticsearchClient elasticsearchClient() {
// 创建低级REST客户端
RestClient restClient = RestClient.builder(
new HttpHost(elasticsearchHost, elasticsearchPort, elasticsearchScheme)
).build();

// 创建传输层
ElasticsearchTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper()
);

// 创建并返回Elasticsearch客户端
return new ElasticsearchClient(transport);
}
}

ES 配置类的核心目标是创建并配置一个可被 Spring 容器管理的 ElasticsearchClient 实例,使其能够正确连接到 ES 集群,并被业务代码(如 Service 层)注入使用。

在类注解部分,需要开启如下内容

1
2
3
4
5
@Configuration  // 标记为 Spring 配置类,会被 Spring 扫描并加载
@EnableElasticsearchRepositories(
basePackages = "hbnu.project.ergoutreegalemjstore.repository" // 扫描 ES 仓库接口的路径
)
public class ElasticsearchConfig { ... }
  • @Configuration:标记这是 Spring 这是一个配置类,用于创建和管理 Bean。
  • @EnableElasticsearchRepositories:可选但常用,用于开启 Spring Data Elasticsearch 的仓库功能(类似 JPA 的 @Repository),basePackages 指定仓库接口所在的包路径(若不指定,默认扫描当前配置类所在包及其子包)。

然后在这通过 @Value("${配置项键名:默认值}") 从配置文件读取参数

1
2
3
4
5
6
7
8
@Value("${elasticsearch.host:localhost}")
private String elasticsearchHost; // ES 主机地址,默认 localhost

@Value("${elasticsearch.port:9200}")
private int elasticsearchPort; // ES 端口,默认 9200(HTTP 端口)

@Value("${elasticsearch.scheme:http}")
private String elasticsearchScheme; // 协议,默认 http(生产环境可能用 https)

创建es实例的核心方法,首先构建 RestClient(低级客户端),负责与 ES 服务器建立连接,然后构建 ElasticsearchTransport(传输层) Java 对象与 ES 的 JSON 格式相互转换,最后构建 ElasticsearchClient(高级客户端),这是业务代码直接使用的客户端,封装了所有 ES 操作 API(如索引创建、文档 CRUD、查询等),通过 transport 与底层通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public ElasticsearchClient elasticsearchClient() {
// 1. 创建低级 REST 客户端(RestClient):负责与 ES 服务器建立 HTTP 连接
RestClient restClient = RestClient.builder(
new HttpHost(elasticsearchHost, elasticsearchPort, elasticsearchScheme)
).build();

// 2. 创建传输层(ElasticsearchTransport):处理 JSON 序列化/反序列化和请求传输
ElasticsearchTransport transport = new RestClientTransport(
restClient, // 底层 REST 客户端
new JacksonJsonpMapper() // JSON 处理器(使用 Jackson 库)
);

// 3. 创建高级客户端(ElasticsearchClient):提供面向业务的 API
return new ElasticsearchClient(transport);
}

RestClient.builder() 支持配置多个 ES 节点(集群环境),示例中是单节点,集群环境可传入多个 HttpHost

1
2
3
4
5
6
// 集群环境配置(多节点)
RestClient restClient = RestClient.builder(
new HttpHost("node1", 9200, "http"),
new HttpHost("node2", 9200, "http"),
new HttpHost("node3", 9200, "http")
).build();

当然你可以继续补充一些高级配置,我就懒得写了

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
@Bean
public ElasticsearchClient elasticsearchClient() {
// 配置连接超时、重试等参数
RestClientBuilder builder = RestClient.builder(
new HttpHost(elasticsearchHost, elasticsearchPort, elasticsearchScheme)
);

// 配置请求超时(连接超时、读取超时)
builder.setRequestConfigCallback(requestConfigBuilder -> {
return requestConfigBuilder
.setConnectTimeout(5000) // 连接超时:5秒
.setSocketTimeout(10000); // 读取超时:10秒
});

// 配置重试策略(当节点不可用时重试)
builder.setFailureListener(new RestClient.FailureListener() {
@Override
public void onFailure(HttpHost host) {
// 节点失败时的回调(如日志记录)
log.error("ES 节点连接失败:{}", host);
}
});

// 构建 RestClient
RestClient restClient = builder.build();

// 后续步骤同上(创建 transport 和 client)
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}

修改对应的实体类

扩展 ImageInfo 实体,添加 tags 和 gameName 字段,作为 ES 文档

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
package hbnu.project.ergoutreegalemjstore.entity;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

/**
* 图片信息实体类
* 用于存储到 Elasticsearch 的文档
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ImageInfo {
/**
* 文档ID(使用文件名作为唯一标识)
*/
private String id;

/**
* 文件名
*/
private String fileName;

/**
* MinIO 访问URL
*/
private String url;

/**
* 文件大小(字节)
*/
private Long size;

/**
* 上传时间
*/
private String uploadTime;

/**
* 游戏名称
*/
private String gameName;

/**
* 标签(多个标签用逗号分隔)
*/
private List<String> tags;
}


这个倒是没什么好说的,你希望你的es文档有什么额外的字段要存,在这里多出来一些就可以了

添加对应的组件

我们需要修改我们的服务类,需要很大程度的修改

注入 ElasticsearchClient 用于操作 ES

1
2
@Autowired
private ElasticsearchClient esClient;

在初始化的过程中,确认 ES 的索引存在

1
2
3
4
// 判断 "galgame_images" 索引是否存在
boolean indexExists = esClient.indices().exists(
ExistsRequest.of(e -> e.index(INDEX_NAME)) // 构建请求:指定索引名
).value(); // 返回布尔值结果

创建索引,使用 indices().create() 方法创建索引,同时可以定义映射(Mapping,类似数据库表结构):

1
2
3
4
5
6
7
8
9
10
11
12
// 代码片段:创建索引并定义映射
esClient.indices().create(
CreateIndexRequest.of(c -> c
.index(INDEX_NAME) // 指定索引名
.mappings(m -> m // 定义映射(字段类型)
.properties("fileName", p -> p.keyword(k -> k)) // fileName 为 keyword 类型(精确匹配)
.properties("url", p -> p.keyword(k -> k)) // url 为 keyword 类型
.properties("gameName", p -> p.text(t -> t.analyzer("standard"))) // gameName 为 text 类型(分词匹配)
.properties("tags", p -> p.keyword(k -> k)) // tags 为 keyword 类型(数组,精确匹配)
)
)
);
  • keyword 类型:适用于精确匹配(如文件名、URL、标签),不进行分词。
  • text 类型:适用于模糊搜索(如游戏名),会通过指定的分词器(如 standard)拆分文本。

新增 / 修改文档(Index),使用 index() 方法向索引中添加文档,若文档 ID 已存在则会更新:

1
2
3
4
5
6
7
8
// 代码片段:将 ImageInfo 对象索引到 ES
IndexResponse response = esClient.index(i -> i
.index(INDEX_NAME) // 指定索引名
.id(fileName) // 文档 ID(此处用文件名作为唯一 ID)
.document(imageInfo) // 要存储的文档对象(ImageInfo)
);
// 响应结果:result() 可返回操作类型(如 CREATED 或 UPDATED)
log.info("索引结果: {}", response.result());

文档对象(如 ImageInfo)需要与索引映射的字段对应,否则会导致字段类型不匹配错误。

所以整个的上传图片到 MinIO 并且索引到 ES 的代码就如下

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
/**
* 上传图片到 MinIO 并索引到 ES
*/
@Override
public String uploadImage(MultipartFile file, String gameName, String tags) throws Exception {
initializeStorage();

// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extension = originalFilename != null && originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf("."))
: "";
String fileName = UUID.randomUUID().toString() + extension;

// 1. 上传到 MinIO
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(fileName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
log.info("文件上传到 MinIO 成功: {}", fileName);

// 2. 构建图片信息
String url = String.format("%s/%s/%s",
minioConfig.getEndpoint(),
minioConfig.getBucketName(),
fileName);

ImageInfo imageInfo = new ImageInfo();
imageInfo.setId(fileName);
imageInfo.setFileName(fileName);
imageInfo.setUrl(url);
imageInfo.setSize(file.getSize());
imageInfo.setUploadTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
imageInfo.setGameName(gameName != null ? gameName.trim() : "");

// 将逗号分隔的标签字符串转换为列表
List<String> tagList = tags != null && !tags.trim().isEmpty()
? Arrays.asList(tags.trim().split("\\s*,\\s*"))
: new ArrayList<>();

imageInfo.setTags(tagList);

// 3. 索引到 Elasticsearch
IndexResponse response = esClient.index(i -> i
.index(INDEX_NAME)
.id(fileName)
.document(imageInfo)
);
log.info("文件索引到 ES 成功: {}, result: {}", fileName, response.result());

return fileName;
}

使用 search() 方法查询文档,支持复杂的查询条件(如 match_allmatchtermbool 等)。

匹配所有文档(match_all),当没有查询条件时,返回索引中所有文档:

1
2
3
4
5
6
7
// 代码片段:查询所有文档
SearchResponse<ImageInfo> response = esClient.search(
s -> s.index(INDEX_NAME) // 指定索引
.query(q -> q.matchAll(m -> m)) // 匹配所有文档
.size(1000), // 最多返回 1000 条结果
ImageInfo.class // 文档类型(用于结果反序列化)
);

match 查询,用于文本字段的模糊匹配(会分词),代码中对 gameName 的搜索就是这个:

1
2
3
4
5
6
Query.of(q -> q
.match(m -> m
.field("gameName") // 字段名
.query(gameName.trim()) // 查询文本
)
)

term 查询:用于精确匹配(适用于 keyword 类型),如代码中对 tags 的搜索:

1
2
3
4
5
6
Query.of(q -> q
.term(t -> t
.field("tags") // 字段名
.value(tags.trim()) // 精确匹配的值
)
)

bool 查询:组合多个条件(如 must 表示 “且” 关系),代码中组合了游戏名和标签的查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 构建 bool 查询
BoolQuery boolQuery = BoolQuery.of(b -> {
mustQueries.forEach(b::must); // 向 bool 查询中添加多个 must 条件
return b;
});

// 执行查询
SearchResponse<ImageInfo> response = esClient.search(
s -> s.index(INDEX_NAME)
.query(q -> q.bool(boolQuery)) // 使用 bool 查询
.size(1000),
ImageInfo.class
);

SearchResponse 包含命中的文档列表,通过 hits().hits() 获取,再提取文档源数据:

1
2
3
4
5
6
List<ImageInfo> images = new ArrayList<>();
for (Hit<ImageInfo> hit : response.hits().hits()) {
if (hit.source() != null) { // hit.source() 即为文档对象
images.add(hit.source());
}
}

删除文档就更常规,直接构建请求然后发送,使用 delete() 方法根据文档 ID 删除文档:

1
2
3
4
5
6
// 代码片段:删除指定 ID 的文档
DeleteResponse response = esClient.delete(d -> d
.index(INDEX_NAME) // 指定索引
.id(fileName) // 文档 ID
);
log.info("删除结果: {}", response.result()); // 结果如 DELETED 或 NOT_FOUND
操作类型 核心方法 / 类 说明
索引管理 indices().exists() 判断索引是否存在
indices().create() 创建索引并定义映射
文档操作 index() 新增 / 修改文档
search() 查询文档(支持多种查询类型)
delete() 删除文档
查询条件构建 Query.of() 构建查询条件
matchAll() 匹配所有文档
match() 文本模糊匹配(分词)
term() 精确匹配(适用于 keyword 类型)
BoolQuery 组合多个查询条件(must/should/mustNot)
响应处理 SearchResponse.hits().hits() 获取查询命中的文档列表
Hit.source() 获取文档的源数据对象

所以说,不需要 ImageRepository,它不需要额外的 Spring Data 仓库。当然你可以写

1
2
3
public interface ImageRepository {
// 这个文件可以保持空接口,因为你的实现直接使用了 MinIO 和 Elasticsearch 的客户端,不需要 Spring Data 仓库。
}

修改控制器添加接口

在 Controller 层,ES CURD 这些功能需要通过 HTTP 接口暴露,供前端或其他服务调用。

接口功能 HTTP 方法 路径示例 对应 ES 操作
搜索 / 查询图片 GET / 执行 match/term/match_all 查询
上传图片(索引) POST /upload 新增文档(index 操作)
删除图片 POST /delete/{fileName} 删除文档(delete 操作)
下载图片(辅助) GET /download/{fileName} 从 MinIO 读取文件(非 ES 操作,但依赖 ES 存储的元数据)

查询接口能灵活处理搜索条件,ES 的核心价值是 “搜索”,因此查询接口需要支持多种条件组合,同时兼容 “无条件查询(返回全部)”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/")
public String index(
@RequestParam(required = false) String gameName, // 游戏名(模糊搜索条件)
@RequestParam(required = false) String tags, // 标签(精确搜索条件)
Model model
) {
try {
// 调用 Service 层执行 ES 查询(支持条件组合或无条件)
List<ImageInfo> images = imageService.searchImages(gameName, tags);
model.addAttribute("images", images); // 传递查询结果到视图
// ... 处理提示信息
} catch (Exception e) {
// 异常处理
}
return "index";
}
  • 参数可选:通过 required = false 允许参数为空,实现 “无条件查询”(对应 ES 的 match_all)。
  • 条件组合:将多个参数(如 gameNametags)传递给 Service 层,由 Service 构建 bool 组合查询(如 must 关系)。
  • 结果传递:查询结果通常需要附带额外信息(如命中数量、搜索条件回显),方便前端展示。

向 ES 中新增文档时,通常需要先处理原始数据(如文件上传),再将元数据(如文件路径、名称、标签)存入 ES。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@PostMapping("/upload")
public String uploadImage(
@RequestParam("file") MultipartFile file, // 上传的图片文件(原始数据)
@RequestParam(required = false) String gameName, // 游戏名(元数据)
@RequestParam(required = false) String tags, // 标签(元数据)
RedirectAttributes redirectAttributes
) {
try {
// 1. 校验文件(非空、类型)
if (file.isEmpty()) { /* 提示错误 */ }
if (!contentType.startsWith("image/")) { /* 提示错误 */ }

// 2. 调用 Service 层:上传文件到 MinIO + 元数据存入 ES
String fileName = imageService.uploadImage(file, gameName, tags);

// 3. 反馈成功信息
redirectAttributes.addFlashAttribute("success", "上传成功: " + fileName);
} catch (Exception e) {
// 异常处理
}
return "redirect:/";
}
  • 数据分离:原始文件(如图片)通常存储在对象存储(如 MinIO),ES 仅存储元数据(路径、标签等),接口需同时处理两者。
  • 参数校验:对输入数据(如文件类型、大小)进行校验,避免无效数据进入 ES。
  • 事务性(伪):若文件上传成功但 ES 索引失败,需考虑回滚(如删除已上传的文件),保证数据一致性。

从 ES 中删除文档时,需同步删除关联的原始数据(如 MinIO 中的文件),避免 “孤儿数据”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping("/delete/{fileName}")
public String deleteImage(
@PathVariable String fileName, // 文档 ID(与 MinIO 文件名关联)
RedirectAttributes redirectAttributes
) {
try {
// 调用 Service 层:删除 MinIO 文件 + 删除 ES 文档
imageService.deleteImage(fileName);
redirectAttributes.addFlashAttribute("success", "删除成功: " + fileName);
} catch (Exception e) {
// 异常处理
}
return "redirect:/";
}
  • 唯一标识:用同一个 ID(如 fileName)关联 ES 文档和原始文件,确保删除时能准确定位。
  • 异常处理:若 ES 删除失败,需考虑是否回滚文件删除操作(视业务需求而定)。

涉及到分页需要注意,ES 默认返回前 10 条结果,若查询数据量较大,必须实现分页,否则可能导致性能问题或超出 ES 最大返回限制(默认 index.max_result_window = 10000)。

查询接口中添加 pagesize 参数,通过 ES 的 fromsize 实现分页:

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/")
public String index(
@RequestParam(required = false) String gameName,
@RequestParam(required = false) String tags,
@RequestParam(defaultValue = "1") int page, // 页码(从 1 开始)
@RequestParam(defaultValue = "20") int size, // 每页条数
Model model
) {
int from = (page - 1) * size; // 计算偏移量
List<ImageInfo> images = imageService.searchImages(gameName, tags, from, size);
// ...
}
  • 对于超大量数据(如 >10000 条),建议使用 ES 的 search_afterscroll API 替代 from/size,避免深度分页性能问题。我没写过,我不知道))

测试

我们上传一张,并且输入一些对应的文档信息

image-20251019173827213

搜索的测试也是没有任何问题的

image-20251019173838174

这样就为我们的项目添加了 ES 全文索引