Spring Boot 整合 Elasticsearch 详细教程
Spring Boot 整合 Elasticsearch 详细教程
1. 环境准备
1.1 版本兼容性
| Spring Boot 版本 | Elasticsearch 版本 | Spring Data Elasticsearch 版本 |
|---|---|---|
| 2.7.x | 7.17.x | 4.4.x |
| 3.0.x | 8.5.x+ | 5.0.x |
| 3.1.x | 8.7.x+ | 5.1.x |
本教程基于:
- Spring Boot 3.1.5
- Elasticsearch 8.10.0
- Java 17
2. 项目搭建
2.1 创建 Spring Boot 项目
使用 Spring Initializr 创建项目:
依赖选择:
- Spring Web
- Spring Data Elasticsearch
- Lombok
2.2 Maven 依赖配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>elasticsearch-demo</artifactId>
<version>1.0.0</version>
<name>elasticsearch-demo</name>
<properties>
<java.version>17</java.version>
<elasticsearch.version>8.10.0</elasticsearch.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data Elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- Elasticsearch Java Client (用于高级查询) -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>${elasticsearch.version}</version>
</dependency>
<!-- JSON 处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 开发工具 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2.3 配置文件
application.yml:
server:
port: 8080
spring:
elasticsearch:
uris: localhost:9200 # Elasticsearch 地址
username: elastic # 如果启用了安全认证
password: changeme # 如果启用了安全认证
connection-timeout: 5000ms
socket-timeout: 60000ms
data:
elasticsearch:
repositories:
enabled: true
# 自定义配置
elasticsearch:
index:
product: products
user: users
enable-ssl: false # 是否启用SSL
# 日志配置
logging:
level:
org.springframework.data.elasticsearch.client.WIRE: trace # 查看请求日志
com.example: debug
application.properties 版本:
# Elasticsearch 配置
spring.elasticsearch.uris=localhost:9200
spring.elasticsearch.username=elastic
spring.elasticsearch.password=changeme
spring.elasticsearch.connection-timeout=5000ms
spring.elasticsearch.socket-timeout=60000ms
# Spring Data Elasticsearch
spring.data.elasticsearch.repositories.enabled=true
# 自定义配置
elasticsearch.index.product=products
elasticsearch.index.user=users
elasticsearch.enable-ssl=false
# 日志
logging.level.org.springframework.data.elasticsearch.client.WIRE=trace
logging.level.com.example=debug
3. 实体类设计
3.1 产品实体类
package com.example.elasticsearchdemo.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 产品实体类
* @Document 注解用于指定索引名称
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "products", createIndex = true)
@Setting(settingPath = "/elasticsearch/settings/product-settings.json") // 可选:自定义设置
public class Product {
@Id
private String id;
/**
* 产品名称 - 支持全文搜索
* FieldType.Text 类型会被分词
* fields 定义多字段映射
*/
@MultiField(
mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart"),
otherFields = {
@InnerField(suffix = "keyword", type = FieldType.Keyword),
@InnerField(suffix = "pinyin", type = FieldType.Text, analyzer = "pinyin")
}
)
private String name;
/**
* 产品标题 - 简单文本字段
*/
@Field(type = FieldType.Text, analyzer = "standard")
private String title;
/**
* 产品描述 - 大文本字段
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String description;
/**
* 价格 - 浮点型
*/
@Field(type = FieldType.Double)
private BigDecimal price;
/**
* 库存 - 整数型
*/
@Field(type = FieldType.Integer)
private Integer stock;
/**
* 分类 - 关键字类型,用于精确匹配
*/
@Field(type = FieldType.Keyword)
private String category;
/**
* 标签列表 - 数组类型
*/
@Field(type = FieldType.Keyword)
private List<String> tags;
/**
* 品牌 - 关键字类型
*/
@Field(type = FieldType.Keyword)
private String brand;
/**
* 创建时间 - 日期类型
*/
@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
private LocalDateTime createTime;
/**
* 更新时间 - 日期类型
*/
@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
private LocalDateTime updateTime;
/**
* 是否上架
*/
@Field(type = FieldType.Boolean)
private Boolean isActive;
/**
* 评分 - 嵌套对象类型
*/
@Field(type = FieldType.Nested)
private List<Rating> ratings;
/**
* 规格参数 - 对象类型
*/
@Field(type = FieldType.Object)
private Map<String, Object> specifications;
/**
* 地理位置信息
*/
@GeoPointField
private GeoPoint location;
/**
* 评分嵌套类
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Rating {
@Field(type = FieldType.Text)
private String username;
@Field(type = FieldType.Integer)
private Integer score;
@Field(type = FieldType.Text)
private String comment;
@Field(type = FieldType.Date)
private LocalDateTime ratingTime;
}
}
3.2 地理坐标类
package com.example.elasticsearchdemo.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GeoPoint {
private Double lat;
private Double lon;
}
3.3 用户实体类(示例)
package com.example.elasticsearchdemo.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import java.time.LocalDate;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "users")
public class User {
@Id
private String id;
@MultiField(
mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word"),
otherFields = @InnerField(suffix = "keyword", type = FieldType.Keyword)
)
private String username;
@Field(type = FieldType.Keyword)
private String email;
@Field(type = FieldType.Integer)
private Integer age;
@Field(type = FieldType.Keyword)
private String gender;
@Field(type = FieldType.Date, format = DateFormat.year_month_day)
private LocalDate birthday;
@Field(type = FieldType.Text)
private String introduction;
@Field(type = FieldType.Keyword)
private List<String> interests;
@Field(type = FieldType.Object)
private Address address;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Address {
@Field(type = FieldType.Text)
private String province;
@Field(type = FieldType.Text)
private String city;
@Field(type = FieldType.Text)
private String detail;
@GeoPointField
private GeoPoint location;
}
}
4. Repository 接口
4.1 基础 Repository 接口
package com.example.elasticsearchdemo.repository;
import com.example.elasticsearchdemo.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
/**
* 产品Repository接口
* 继承ElasticsearchRepository,提供基础的CRUD操作
*/
@Repository
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
// ============ 基础查询方法 ============
/**
* 根据名称查询(精确匹配)
*/
List<Product> findByName(String name);
/**
* 根据名称模糊查询
*/
List<Product> findByNameContaining(String name);
/**
* 根据名称或描述查询
*/
List<Product> findByNameOrDescription(String name, String description);
/**
* 根据分类查询
*/
List<Product> findByCategory(String category);
/**
* 根据品牌查询
*/
List<Product> findByBrand(String brand);
/**
* 根据价格范围查询
*/
List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);
/**
* 根据库存查询
*/
List<Product> findByStockGreaterThan(Integer stock);
/**
* 查询上架商品
*/
List<Product> findByIsActiveTrue();
/**
* 根据多个分类查询
*/
List<Product> findByCategoryIn(List<String> categories);
/**
* 根据标签包含查询
*/
List<Product> findByTagsContains(String tag);
// ============ 分页查询 ============
/**
* 分页查询所有商品
*/
Page<Product> findAll(Pageable pageable);
/**
* 根据分类分页查询
*/
Page<Product> findByCategory(String category, Pageable pageable);
/**
* 根据品牌分页查询
*/
Page<Product> findByBrand(String brand, Pageable pageable);
/**
* 根据价格范围分页查询
*/
Page<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice, Pageable pageable);
// ============ 排序查询 ============
/**
* 根据价格升序查询
*/
List<Product> findByOrderByPriceAsc();
/**
* 根据价格降序查询
*/
List<Product> findByOrderByPriceDesc();
/**
* 根据创建时间降序查询
*/
List<Product> findByOrderByCreateTimeDesc();
// ============ 自定义查询 ============
/**
* 根据名称和分类查询
*/
List<Product> findByNameAndCategory(String name, String category);
/**
* 根据名称和价格范围查询
*/
List<Product> findByNameAndPriceBetween(String name, BigDecimal minPrice, BigDecimal maxPrice);
/**
* 统计某个分类的商品数量
*/
long countByCategory(String category);
/**
* 删除某个品牌的商品
*/
void deleteByBrand(String brand);
}
4.2 自定义 Repository
package com.example.elasticsearchdemo.repository.custom;
import com.example.elasticsearchdemo.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.math.BigDecimal;
import java.util.List;
/**
* 自定义Repository接口
*/
public interface CustomProductRepository {
/**
* 复杂搜索:多条件组合查询
*/
Page<Product> complexSearch(String keyword,
List<String> categories,
BigDecimal minPrice,
BigDecimal maxPrice,
List<String> brands,
Pageable pageable);
/**
* 全文搜索并高亮显示
*/
Page<Product> searchWithHighlight(String keyword, Pageable pageable);
/**
* 聚合查询:按品牌分组统计
*/
List<BrandStats> groupByBrand();
/**
* 聚合查询:价格范围分布
*/
List<PriceRangeStats> priceRangeDistribution();
/**
* 地理空间搜索:附近的产品
*/
List<Product> searchNearby(Double lat, Double lon, Double distance);
/**
* 自动补全建议
*/
List<String> getSuggestions(String prefix);
/**
* 批量更新库存
*/
void bulkUpdateStock(List<String> productIds, Integer stockDelta);
}
/**
* 品牌统计结果类
*/
class BrandStats {
private String brand;
private Long count;
private BigDecimal avgPrice;
// getters and setters
}
/**
* 价格范围统计结果类
*/
class PriceRangeStats {
private String range;
private Long count;
// getters and setters
}
4.3 自定义 Repository 实现
package com.example.elasticsearchdemo.repository.custom.impl;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.*;
import co.elastic.clients.elasticsearch._types.aggregations.*;
import co.elastic.clients.elasticsearch._types.query_dsl.*;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch.core.search.Highlight;
import co.elastic.clients.elasticsearch.core.search.HighlightField;
import co.elastic.clients.elasticsearch.core.search.SourceConfig;
import co.elastic.clients.json.JsonData;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.repository.custom.CustomProductRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.*;
@Slf4j
@Repository
@RequiredArgsConstructor
public class CustomProductRepositoryImpl implements CustomProductRepository {
private final ElasticsearchClient elasticsearchClient;
@Override
public Page<Product> complexSearch(String keyword,
List<String> categories,
BigDecimal minPrice,
BigDecimal maxPrice,
List<String> brands,
Pageable pageable) {
try {
// 构建布尔查询
BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();
// 关键字查询
if (keyword != null && !keyword.trim().isEmpty()) {
boolQueryBuilder.should(
Query.of(q -> q
.multiMatch(m -> m
.query(keyword)
.fields("name^3", "title^2", "description")
.type(TextQueryType.BestFields)
)
)
);
}
// 分类过滤
if (categories != null && !categories.isEmpty()) {
boolQueryBuilder.filter(
Query.of(q -> q
.terms(t -> t
.field("category")
.terms(t2 -> t2.value(categories.stream()
.map(FieldValue::of)
.toList()))
)
)
);
}
// 价格范围过滤
if (minPrice != null || maxPrice != null) {
RangeQuery.Builder rangeQueryBuilder = new RangeQuery.Builder()
.field("price");
if (minPrice != null) {
rangeQueryBuilder.gte(JsonData.of(minPrice.doubleValue()));
}
if (maxPrice != null) {
rangeQueryBuilder.lte(JsonData.of(maxPrice.doubleValue()));
}
boolQueryBuilder.filter(
Query.of(q -> q.range(rangeQueryBuilder.build()))
);
}
// 品牌过滤
if (brands != null && !brands.isEmpty()) {
boolQueryBuilder.filter(
Query.of(q -> q
.terms(t -> t
.field("brand")
.terms(t2 -> t2.value(brands.stream()
.map(FieldValue::of)
.toList()))
)
)
);
}
// 只查询上架商品
boolQueryBuilder.filter(
Query.of(q -> q
.term(t -> t
.field("isActive")
.value(true)
)
)
);
// 执行搜索
SearchResponse<Product> response = elasticsearchClient.search(
s -> s
.index("products")
.query(q -> q.bool(boolQueryBuilder.build()))
.from((int) pageable.getOffset())
.size(pageable.getPageSize())
.source(SourceConfig.of(sc -> sc
.filter(f -> f
.excludes("specifications") // 排除大字段
)
))
.sort(so -> so
.field(f -> f
.field("_score")
.order(SortOrder.Desc)
)
)
.sort(so -> so
.field(f -> f
.field("createTime")
.order(SortOrder.Desc)
)
),
Product.class
);
// 转换结果
List<Product> products = response.hits().hits().stream()
.map(Hit::source)
.filter(Objects::nonNull)
.toList();
long total = response.hits().total() != null ?
response.hits().total().value() : 0;
return new PageImpl<>(products, pageable, total);
} catch (IOException e) {
log.error("复杂搜索失败", e);
throw new RuntimeException("搜索失败", e);
}
}
@Override
public Page<Product> searchWithHighlight(String keyword, Pageable pageable) {
try {
// 构建高亮查询
Highlight highlight = Highlight.of(h -> h
.fields("name", HighlightField.of(hf -> hf))
.fields("description", HighlightField.of(hf -> hf))
.preTags("<em>")
.postTags("</em>")
);
SearchResponse<Product> response = elasticsearchClient.search(
s -> s
.index("products")
.query(q -> q
.multiMatch(m -> m
.query(keyword)
.fields("name", "description")
)
)
.highlight(highlight)
.from((int) pageable.getOffset())
.size(pageable.getPageSize()),
Product.class
);
// 处理高亮结果
List<Product> products = response.hits().hits().stream()
.map(hit -> {
Product product = hit.source();
if (product != null && hit.highlight() != null) {
// 处理高亮字段(这里简化处理)
Map<String, List<String>> highlights = hit.highlight();
// 可以将高亮结果设置到临时字段中
}
return product;
})
.filter(Objects::nonNull)
.toList();
long total = response.hits().total() != null ?
response.hits().total().value() : 0;
return new PageImpl<>(products, pageable, total);
} catch (IOException e) {
log.error("高亮搜索失败", e);
throw new RuntimeException("搜索失败", e);
}
}
@Override
public List<BrandStats> groupByBrand() {
try {
SearchResponse<Void> response = elasticsearchClient.search(
s -> s
.index("products")
.size(0) // 不需要返回文档
.aggregations("brand_stats", a -> a
.terms(t -> t
.field("brand")
.size(20)
)
.aggregations("avg_price", a2 -> a2
.avg(av -> av.field("price"))
)
.aggregations("count", a2 -> a2
.valueCount(vc -> vc.field("brand"))
)
),
Void.class
);
// 解析聚合结果
List<BrandStats> stats = new ArrayList<>();
StringTermsAggregate brandAgg = response.aggregations()
.get("brand_stats")
.sterms();
for (StringTermsBucket bucket : brandAgg.buckets().array()) {
BrandStats brandStat = new BrandStats();
brandStat.setBrand(bucket.key().stringValue());
brandStat.setCount(bucket.docCount());
// 获取平均价格
AvgAggregate avgPriceAgg = bucket.aggregations().get("avg_price").avg();
if (avgPriceAgg.value() != null) {
brandStat.setAvgPrice(BigDecimal.valueOf(avgPriceAgg.value()));
}
stats.add(brandStat);
}
return stats;
} catch (IOException e) {
log.error("品牌聚合查询失败", e);
throw new RuntimeException("聚合查询失败", e);
}
}
// 其他方法实现...
}
5. Service 层实现
5.1 基础 Service 接口
package com.example.elasticsearchdemo.service;
import com.example.elasticsearchdemo.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public interface ProductService {
// ============ CRUD 操作 ============
/**
* 保存或更新产品
*/
Product save(Product product);
/**
* 批量保存产品
*/
List<Product> saveAll(List<Product> products);
/**
* 根据ID查找产品
*/
Product findById(String id);
/**
* 查找所有产品
*/
List<Product> findAll();
/**
* 分页查找所有产品
*/
Page<Product> findAll(Pageable pageable);
/**
* 根据ID删除产品
*/
void deleteById(String id);
/**
* 删除所有产品
*/
void deleteAll();
// ============ 查询操作 ============
/**
* 根据名称搜索
*/
List<Product> searchByName(String name);
/**
* 根据分类查询
*/
List<Product> findByCategory(String category);
/**
* 根据价格范围查询
*/
List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice);
/**
* 根据品牌查询
*/
List<Product> findByBrand(String brand);
/**
* 根据标签查询
*/
List<Product> findByTag(String tag);
/**
* 搜索上架商品
*/
List<Product> findActiveProducts();
// ============ 高级搜索 ============
/**
* 复杂条件搜索
*/
Page<Product> complexSearch(String keyword,
List<String> categories,
BigDecimal minPrice,
BigDecimal maxPrice,
List<String> brands,
Pageable pageable);
/**
* 全文搜索
*/
Page<Product> fullTextSearch(String keyword, Pageable pageable);
/**
* 自动补全
*/
List<String> suggest(String prefix);
// ============ 聚合操作 ============
/**
* 按分类统计
*/
Map<String, Long> countByCategory();
/**
* 按品牌统计
*/
Map<String, Long> countByBrand();
/**
* 价格分布统计
*/
Map<String, Long> priceDistribution();
// ============ 批量操作 ============
/**
* 批量更新库存
*/
void bulkUpdateStock(Map<String, Integer> stockUpdates);
/**
* 批量更新价格
*/
void bulkUpdatePrice(Map<String, BigDecimal> priceUpdates);
}
5.2 Service 实现类
package com.example.elasticsearchdemo.service.impl;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.repository.ProductRepository;
import com.example.elasticsearchdemo.service.ProductService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductRepository productRepository;
@Override
@Transactional
public Product save(Product product) {
if (product.getId() == null) {
product.setCreateTime(LocalDateTime.now());
}
product.setUpdateTime(LocalDateTime.now());
return productRepository.save(product);
}
@Override
@Transactional
public List<Product> saveAll(List<Product> products) {
LocalDateTime now = LocalDateTime.now();
products.forEach(product -> {
if (product.getId() == null) {
product.setCreateTime(now);
}
product.setUpdateTime(now);
});
return (List<Product>) productRepository.saveAll(products);
}
@Override
public Product findById(String id) {
Optional<Product> product = productRepository.findById(id);
return product.orElseThrow(() ->
new RuntimeException("Product not found with id: " + id));
}
@Override
public List<Product> findAll() {
return (List<Product>) productRepository.findAll();
}
@Override
public Page<Product> findAll(Pageable pageable) {
return productRepository.findAll(pageable);
}
@Override
@Transactional
public void deleteById(String id) {
productRepository.deleteById(id);
}
@Override
@Transactional
public void deleteAll() {
productRepository.deleteAll();
}
@Override
public List<Product> searchByName(String name) {
return productRepository.findByNameContaining(name);
}
@Override
public List<Product> findByCategory(String category) {
return productRepository.findByCategory(category);
}
@Override
public List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
return productRepository.findByPriceBetween(minPrice, maxPrice);
}
@Override
public List<Product> findByBrand(String brand) {
return productRepository.findByBrand(brand);
}
@Override
public List<Product> findByTag(String tag) {
return productRepository.findByTagsContains(tag);
}
@Override
public List<Product> findActiveProducts() {
return productRepository.findByIsActiveTrue();
}
@Override
public Page<Product> complexSearch(String keyword,
List<String> categories,
BigDecimal minPrice,
BigDecimal maxPrice,
List<String> brands,
Pageable pageable) {
// 这里调用自定义Repository的方法
// 需要注入CustomProductRepository
throw new UnsupportedOperationException("需要实现自定义Repository");
}
@Override
public Page<Product> fullTextSearch(String keyword, Pageable pageable) {
// 使用简单的方式实现
return productRepository.findByNameContaining(keyword, pageable);
}
@Override
public List<String> suggest(String prefix) {
// 实现自动补全逻辑
throw new UnsupportedOperationException("需要实现自动补全");
}
@Override
public Map<String, Long> countByCategory() {
// 实现聚合查询
throw new UnsupportedOperationException("需要实现聚合查询");
}
@Override
public Map<String, Long> countByBrand() {
throw new UnsupportedOperationException("需要实现聚合查询");
}
@Override
public Map<String, Long> priceDistribution() {
throw new UnsupportedOperationException("需要实现聚合查询");
}
@Override
@Transactional
public void bulkUpdateStock(Map<String, Integer> stockUpdates) {
stockUpdates.forEach((productId, stockDelta) -> {
Optional<Product> productOpt = productRepository.findById(productId);
productOpt.ifPresent(product -> {
int newStock = product.getStock() + stockDelta;
product.setStock(newStock);
product.setUpdateTime(LocalDateTime.now());
productRepository.save(product);
});
});
}
@Override
@Transactional
public void bulkUpdatePrice(Map<String, BigDecimal> priceUpdates) {
priceUpdates.forEach((productId, newPrice) -> {
Optional<Product> productOpt = productRepository.findById(productId);
productOpt.ifPresent(product -> {
product.setPrice(newPrice);
product.setUpdateTime(LocalDateTime.now());
productRepository.save(product);
});
});
}
}
5.3 高级 Service(使用 ElasticsearchClient)
package com.example.elasticsearchdemo.service;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.aggregations.Aggregation;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.example.elasticsearchdemo.entity.Product;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductAdvancedService {
private final ElasticsearchClient elasticsearchClient;
/**
* 使用ElasticsearchClient进行搜索
*/
public List<Product> searchWithClient(String keyword) {
try {
SearchResponse<Product> response = elasticsearchClient.search(
s -> s
.index("products")
.query(q -> q
.bool(b -> b
.must(m -> m
.match(t -> t
.field("name")
.query(keyword)
)
)
.filter(f -> f
.term(t -> t
.field("isActive")
.value(true)
)
)
)
)
.size(100),
Product.class
);
return response.hits().hits().stream()
.map(Hit::source)
.filter(Objects::nonNull)
.collect(Collectors.toList());
} catch (IOException e) {
log.error("搜索失败", e);
throw new RuntimeException("搜索失败", e);
}
}
/**
* 批量索引文档
*/
public void bulkIndexProducts(List<Product> products) {
try {
BulkRequest.Builder br = new BulkRequest.Builder();
for (Product product : products) {
br.operations(op -> op
.index(idx -> idx
.index("products")
.id(product.getId())
.document(product)
)
);
}
BulkResponse response = elasticsearchClient.bulk(br.build());
if (response.errors()) {
log.error("批量索引存在错误");
response.items().forEach(item -> {
if (item.error() != null) {
log.error("文档 {} 索引失败: {}", item.id(), item.error().reason());
}
});
}
} catch (IOException e) {
log.error("批量索引失败", e);
throw new RuntimeException("批量索引失败", e);
}
}
/**
* 更新单个字段
*/
public void updateField(String id, String field, Object value) {
try {
UpdateResponse<Product> response = elasticsearchClient.update(
u -> u
.index("products")
.id(id)
.doc(Map.of(field, value)),
Product.class
);
log.info("更新成功,版本: {}", response.version());
} catch (IOException e) {
log.error("更新字段失败", e);
throw new RuntimeException("更新字段失败", e);
}
}
/**
* 获取索引统计信息
*/
public Map<String, Object> getIndexStats() {
try {
IndicesStatsResponse response = elasticsearchClient.indices()
.stats(s -> s.index("products"));
Map<String, Object> stats = new HashMap<>();
stats.put("文档总数", response.indices().get("products").primaries().docs().count());
stats.put("索引大小", response.indices().get("products").primaries().store().size());
stats.put("分片数", response.indices().get("products").shards().size());
return stats;
} catch (IOException e) {
log.error("获取索引统计失败", e);
throw new RuntimeException("获取统计失败", e);
}
}
}
6. Controller 层实现
6.1 RESTful API 控制器
package com.example.elasticsearchdemo.controller;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.service.ProductService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
// ============ CRUD 接口 ============
/**
* 创建产品
*/
@PostMapping
public ResponseEntity<Product> createProduct(@RequestBody Product product) {
log.info("创建产品: {}", product.getName());
Product savedProduct = productService.save(product);
return ResponseEntity.status(HttpStatus.CREATED).body(savedProduct);
}
/**
* 批量创建产品
*/
@PostMapping("/batch")
public ResponseEntity<List<Product>> batchCreateProducts(@RequestBody List<Product> products) {
log.info("批量创建产品,数量: {}", products.size());
List<Product> savedProducts = productService.saveAll(products);
return ResponseEntity.status(HttpStatus.CREATED).body(savedProducts);
}
/**
* 根据ID获取产品
*/
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable String id) {
log.info("获取产品,ID: {}", id);
Product product = productService.findById(id);
return ResponseEntity.ok(product);
}
/**
* 获取所有产品
*/
@GetMapping
public ResponseEntity<List<Product>> getAllProducts() {
log.info("获取所有产品");
List<Product> products = productService.findAll();
return ResponseEntity.ok(products);
}
/**
* 分页获取产品
*/
@GetMapping("/page")
public ResponseEntity<Page<Product>> getProductsByPage(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createTime") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
log.info("分页获取产品,第 {} 页,每页 {} 条", page, size);
Sort.Direction sortDirection = "desc".equalsIgnoreCase(direction)
? Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sortBy));
Page<Product> products = productService.findAll(pageable);
return ResponseEntity.ok(products);
}
/**
* 更新产品
*/
@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable String id,
@RequestBody Product product) {
log.info("更新产品,ID: {}", id);
// 确保ID一致
product.setId(id);
Product updatedProduct = productService.save(product);
return ResponseEntity.ok(updatedProduct);
}
/**
* 删除产品
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable String id) {
log.info("删除产品,ID: {}", id);
productService.deleteById(id);
return ResponseEntity.noContent().build();
}
// ============ 搜索接口 ============
/**
* 根据名称搜索
*/
@GetMapping("/search/name")
public ResponseEntity<List<Product>> searchByName(
@RequestParam String name) {
log.info("根据名称搜索: {}", name);
List<Product> products = productService.searchByName(name);
return ResponseEntity.ok(products);
}
/**
* 根据分类查询
*/
@GetMapping("/search/category")
public ResponseEntity<List<Product>> searchByCategory(
@RequestParam String category) {
log.info("根据分类查询: {}", category);
List<Product> products = productService.findByCategory(category);
return ResponseEntity.ok(products);
}
/**
* 根据价格范围查询
*/
@GetMapping("/search/price-range")
public ResponseEntity<List<Product>> searchByPriceRange(
@RequestParam BigDecimal minPrice,
@RequestParam BigDecimal maxPrice) {
log.info("根据价格范围查询: {} - {}", minPrice, maxPrice);
List<Product> products = productService.findByPriceRange(minPrice, maxPrice);
return ResponseEntity.ok(products);
}
/**
* 根据品牌查询
*/
@GetMapping("/search/brand")
public ResponseEntity<List<Product>> searchByBrand(
@RequestParam String brand) {
log.info("根据品牌查询: {}", brand);
List<Product> products = productService.findByBrand(brand);
return ResponseEntity.ok(products);
}
/**
* 根据标签查询
*/
@GetMapping("/search/tag")
public ResponseEntity<List<Product>> searchByTag(
@RequestParam String tag) {
log.info("根据标签查询: {}", tag);
List<Product> products = productService.findByTag(tag);
return ResponseEntity.ok(products);
}
/**
* 查询上架商品
*/
@GetMapping("/active")
public ResponseEntity<List<Product>> getActiveProducts() {
log.info("查询上架商品");
List<Product> products = productService.findActiveProducts();
return ResponseEntity.ok(products);
}
/**
* 复杂条件搜索
*/
@GetMapping("/search/complex")
public ResponseEntity<Page<Product>> complexSearch(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) List<String> categories,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) List<String> brands,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
log.info("复杂条件搜索,关键字: {}", keyword);
Pageable pageable = PageRequest.of(page, size);
Page<Product> products = productService.complexSearch(
keyword, categories, minPrice, maxPrice, brands, pageable);
return ResponseEntity.ok(products);
}
/**
* 全文搜索
*/
@GetMapping("/search/full-text")
public ResponseEntity<Page<Product>> fullTextSearch(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
log.info("全文搜索,关键字: {}", keyword);
Pageable pageable = PageRequest.of(page, size);
Page<Product> products = productService.fullTextSearch(keyword, pageable);
return ResponseEntity.ok(products);
}
// ============ 聚合统计接口 ============
/**
* 按分类统计
*/
@GetMapping("/stats/category")
public ResponseEntity<Map<String, Long>> statsByCategory() {
log.info("按分类统计");
Map<String, Long> stats = productService.countByCategory();
return ResponseEntity.ok(stats);
}
/**
* 按品牌统计
*/
@GetMapping("/stats/brand")
public ResponseEntity<Map<String, Long>> statsByBrand() {
log.info("按品牌统计");
Map<String, Long> stats = productService.countByBrand();
return ResponseEntity.ok(stats);
}
/**
* 价格分布统计
*/
@GetMapping("/stats/price-distribution")
public ResponseEntity<Map<String, Long>> priceDistribution() {
log.info("价格分布统计");
Map<String, Long> distribution = productService.priceDistribution();
return ResponseEntity.ok(distribution);
}
// ============ 批量操作接口 ============
/**
* 批量更新库存
*/
@PostMapping("/bulk/stock")
public ResponseEntity<Void> bulkUpdateStock(
@RequestBody Map<String, Integer> stockUpdates) {
log.info("批量更新库存,更新数量: {}", stockUpdates.size());
productService.bulkUpdateStock(stockUpdates);
return ResponseEntity.ok().build();
}
/**
* 批量更新价格
*/
@PostMapping("/bulk/price")
public ResponseEntity<Void> bulkUpdatePrice(
@RequestBody Map<String, BigDecimal> priceUpdates) {
log.info("批量更新价格,更新数量: {}", priceUpdates.size());
productService.bulkUpdatePrice(priceUpdates);
return ResponseEntity.ok().build();
}
}
6.2 高级搜索控制器
package com.example.elasticsearchdemo.controller;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.aggregations.Aggregation;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.service.ProductAdvancedService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/advanced")
@RequiredArgsConstructor
public class AdvancedSearchController {
private final ProductAdvancedService productAdvancedService;
private final ElasticsearchClient elasticsearchClient;
/**
* 高级搜索
*/
@GetMapping("/search")
public ResponseEntity<List<Product>> advancedSearch(@RequestParam String keyword) {
log.info("高级搜索,关键字: {}", keyword);
List<Product> products = productAdvancedService.searchWithClient(keyword);
return ResponseEntity.ok(products);
}
/**
* 获取索引统计
*/
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
log.info("获取索引统计");
Map<String, Object> stats = productAdvancedService.getIndexStats();
return ResponseEntity.ok(stats);
}
/**
* 聚合查询示例
*/
@GetMapping("/aggregations")
public ResponseEntity<?> getAggregations() {
try {
SearchResponse<Void> response = elasticsearchClient.search(
s -> s
.index("products")
.size(0)
.aggregations("category_agg", a -> a
.terms(t -> t.field("category"))
)
.aggregations("price_stats", a -> a
.stats(st -> st.field("price"))
),
Void.class
);
return ResponseEntity.ok(response.aggregations());
} catch (IOException e) {
log.error("聚合查询失败", e);
return ResponseEntity.internalServerError().body("聚合查询失败");
}
}
}
7. 配置类
7.1 Elasticsearch 配置
package com.example.elasticsearchdemo.config;
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.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
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.client.ClientConfiguration;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
@Configuration
@EnableElasticsearchRepositories(basePackages = "com.example.elasticsearchdemo.repository")
public class ElasticsearchConfig extends ElasticsearchConfiguration {
@Value("${spring.elasticsearch.uris}")
private String elasticsearchUris;
@Value("${spring.elasticsearch.username:}")
private String username;
@Value("${spring.elasticsearch.password:}")
private String password;
@Override
public ClientConfiguration clientConfiguration() {
// 构建客户端配置
ClientConfiguration.MaybeSecureClientConfigurationBuilder builder =
ClientConfiguration.builder()
.connectedTo(elasticsearchUris);
// 如果配置了用户名密码,添加认证
if (username != null && !username.isEmpty()) {
builder.withBasicAuth(username, password);
}
// 配置连接超时等参数
return builder
.withConnectTimeout(5000)
.withSocketTimeout(60000)
.build();
}
/**
* 配置 ElasticsearchClient Bean
* 用于高级操作
*/
@Bean
public ElasticsearchClient elasticsearchClient() {
// 创建低级客户端
RestClient restClient = RestClient.builder(
HttpHost.create(elasticsearchUris))
.setHttpClientConfigCallback(httpClientBuilder -> {
// 如果配置了用户名密码,添加认证
if (username != null && !username.isEmpty()) {
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials(username, password)
);
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
}
return httpClientBuilder;
})
.build();
// 使用Jackson作为JSON处理器
ElasticsearchTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
7.2 索引初始化配置
package com.example.elasticsearchdemo.config;
import co.elastic.clients.elasticsearch.indices.CreateIndexRequest;
import co.elastic.clients.elasticsearch.indices.ExistsRequest;
import com.example.elasticsearchdemo.entity.Product;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.IndexOperations;
@Slf4j
@Configuration
@RequiredArgsConstructor
public class IndexInitializer {
private final ElasticsearchOperations elasticsearchOperations;
private final co.elastic.clients.elasticsearch.ElasticsearchClient elasticsearchClient;
/**
* 应用启动时初始化索引
*/
@Bean
public CommandLineRunner initIndex() {
return args -> {
try {
// 检查并创建产品索引
IndexOperations indexOps = elasticsearchOperations.indexOps(Product.class);
if (!indexOps.exists()) {
log.info("创建产品索引...");
indexOps.create();
// 创建映射
indexOps.putMapping(indexOps.createMapping(Product.class));
log.info("产品索引创建完成");
} else {
log.info("产品索引已存在");
}
// 检查用户索引是否存在(使用ElasticsearchClient)
boolean userIndexExists = elasticsearchClient.indices()
.exists(ExistsRequest.of(e -> e.index("users")))
.value();
if (!userIndexExists) {
log.info("创建用户索引...");
elasticsearchClient.indices().create(CreateIndexRequest.of(c -> c.index("users")));
log.info("用户索引创建完成");
}
} catch (Exception e) {
log.error("初始化索引失败", e);
}
};
}
}
8. 测试数据初始化
8.1 数据初始化类
package com.example.elasticsearchdemo.data;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
@Slf4j
@Configuration
@RequiredArgsConstructor
@Profile("dev") // 只在开发环境运行
public class DataInitializer {
private final ProductRepository productRepository;
@Bean
public CommandLineRunner initData() {
return args -> {
// 清空现有数据
productRepository.deleteAll();
// 创建测试数据
List<Product> products = Arrays.asList(
Product.builder()
.name("iPhone 14 Pro")
.title("苹果最新旗舰手机")
.description("A16仿生芯片,4800万像素主摄,灵动岛设计")
.price(BigDecimal.valueOf(7999))
.stock(100)
.category("手机")
.brand("Apple")
.tags(Arrays.asList("苹果", "智能手机", "旗舰"))
.isActive(true)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build(),
Product.builder()
.name("小米13 Ultra")
.title("小米影像旗舰")
.description("徕卡四摄,2K AMOLED屏幕,骁龙8 Gen 2处理器")
.price(BigDecimal.valueOf(5999))
.stock(80)
.category("手机")
.brand("小米")
.tags(Arrays.asList("小米", "徕卡", "影像旗舰"))
.isActive(true)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build(),
Product.builder()
.name("华为MateBook 14")
.title("华为轻薄笔记本")
.description("2K全面屏,第11代酷睿处理器,多屏协同")
.price(BigDecimal.valueOf(6999))
.stock(50)
.category("笔记本电脑")
.brand("华为")
.tags(Arrays.asList("笔记本", "轻薄本", "办公"))
.isActive(true)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build(),
Product.builder()
.name("联想拯救者Y9000P")
.title("游戏本")
.description("RTX 4060显卡,i9处理器,2.5K 165Hz屏幕")
.price(BigDecimal.valueOf(9999))
.stock(30)
.category("游戏本")
.brand("联想")
.tags(Arrays.asList("游戏本", "电竞", "高性能"))
.isActive(true)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build(),
Product.builder()
.name("索尼PS5")
.title("游戏主机")
.description("8K游戏支持,光线追踪,超高速SSD")
.price(BigDecimal.valueOf(3899))
.stock(20)
.category("游戏主机")
.brand("索尼")
.tags(Arrays.asList("游戏机", "主机", "PS5"))
.isActive(true)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build()
);
// 保存数据
productRepository.saveAll(products);
log.info("初始化了 {} 条测试数据", products.size());
};
}
}
9. 测试类
9.1 集成测试
package com.example.elasticsearchdemo;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.repository.ProductRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class ElasticsearchDemoApplicationTests {
@Autowired
private ProductRepository productRepository;
@Test
void contextLoads() {
// 测试上下文加载
}
@Test
void testCRUDOperations() {
// 创建测试数据
Product product = Product.builder()
.name("测试商品")
.price(BigDecimal.valueOf(999.99))
.stock(100)
.category("测试分类")
.brand("测试品牌")
.isActive(true)
.build();
// 保存
Product savedProduct = productRepository.save(product);
assertThat(savedProduct.getId()).isNotNull();
// 查询
Product foundProduct = productRepository.findById(savedProduct.getId()).orElse(null);
assertThat(foundProduct).isNotNull();
assertThat(foundProduct.getName()).isEqualTo("测试商品");
// 更新
foundProduct.setPrice(BigDecimal.valueOf(888.88));
Product updatedProduct = productRepository.save(foundProduct);
assertThat(updatedProduct.getPrice()).isEqualByComparingTo("888.88");
// 删除
productRepository.deleteById(updatedProduct.getId());
boolean exists = productRepository.existsById(updatedProduct.getId());
assertThat(exists).isFalse();
}
@Test
void testSearchOperations() {
// 准备测试数据
List<Product> products = List.of(
Product.builder().name("苹果手机").category("手机").price(BigDecimal.valueOf(5999)).stock(10).build(),
Product.builder().name("苹果笔记本").category("笔记本").price(BigDecimal.valueOf(8999)).stock(5).build(),
Product.builder().name("安卓手机").category("手机").price(BigDecimal.valueOf(2999)).stock(20).build()
);
productRepository.saveAll(products);
// 测试按名称搜索
List<Product> appleProducts = productRepository.findByNameContaining("苹果");
assertThat(appleProducts).hasSize(2);
// 测试按分类搜索
List<Product> phones = productRepository.findByCategory("手机");
assertThat(phones).hasSize(2);
// 测试分页
Page<Product> page = productRepository.findAll(PageRequest.of(0, 2));
assertThat(page.getContent()).hasSize(2);
assertThat(page.getTotalElements()).isGreaterThanOrEqualTo(3);
}
}
9.2 Controller 测试
package com.example.elasticsearchdemo.controller;
import com.example.elasticsearchdemo.entity.Product;
import com.example.elasticsearchdemo.service.ProductService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ProductService productService;
@Test
void createProduct_ShouldReturnCreated() throws Exception {
// 准备测试数据
Product product = Product.builder()
.name("测试商品")
.price(BigDecimal.valueOf(999.99))
.build();
Product savedProduct = Product.builder()
.id("test-id")
.name("测试商品")
.price(BigDecimal.valueOf(999.99))
.build();
given(productService.save(any(Product.class))).willReturn(savedProduct);
// 执行测试
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(product)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("test-id"))
.andExpect(jsonPath("$.name").value("测试商品"));
}
@Test
void getProductById_ShouldReturnProduct() throws Exception {
// 准备测试数据
Product product = Product.builder()
.id("test-id")
.name("测试商品")
.price(BigDecimal.valueOf(999.99))
.build();
given(productService.findById("test-id")).willReturn(product);
// 执行测试
mockMvc.perform(get("/api/products/test-id"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("test-id"))
.andExpect(jsonPath("$.name").value("测试商品"));
}
@Test
void searchByName_ShouldReturnProducts() throws Exception {
// 准备测试数据
List<Product> products = Arrays.asList(
Product.builder().name("苹果手机").price(BigDecimal.valueOf(5999)).build(),
Product.builder().name("苹果笔记本").price(BigDecimal.valueOf(8999)).build()
);
given(productService.searchByName("苹果")).willReturn(products);
// 执行测试
mockMvc.perform(get("/api/products/search/name")
.param("name", "苹果"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].name").value("苹果手机"));
}
@Test
void getProductsByPage_ShouldReturnPage() throws Exception {
// 准备测试数据
List<Product> products = Arrays.asList(
Product.builder().name("商品1").build(),
Product.builder().name("商品2").build()
);
Page<Product> page = new PageImpl<>(products, PageRequest.of(0, 10), 2);
given(productService.findAll(any(PageRequest.class))).willReturn(page);
// 执行测试
mockMvc.perform(get("/api/products/page")
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.totalElements").value(2));
}
}
10. 异常处理
10.1 自定义异常
package com.example.elasticsearchdemo.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class BusinessException extends RuntimeException {
private final HttpStatus status;
private final String code;
public BusinessException(String message) {
super(message);
this.status = HttpStatus.BAD_REQUEST;
this.code = "400";
}
public BusinessException(String message, HttpStatus status) {
super(message);
this.status = status;
this.code = String.valueOf(status.value());
}
public BusinessException(String message, String code) {
super(message);
this.status = HttpStatus.BAD_REQUEST;
this.code = code;
}
}
@Getter
class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resourceName, String id) {
super(String.format("%s not found with id: %s", resourceName, id),
HttpStatus.NOT_FOUND);
}
}
10.2 全局异常处理器
package com.example.elasticsearchdemo.handler;
import com.example.elasticsearchdemo.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Map<String, Object>> handleBusinessException(BusinessException e) {
log.error("业务异常: {}", e.getMessage(), e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", e.getStatus().value());
errorResponse.put("error", e.getStatus().getReasonPhrase());
errorResponse.put("message", e.getMessage());
errorResponse.put("code", e.getCode());
return ResponseEntity.status(e.getStatus()).body(errorResponse);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGenericException(Exception e) {
log.error("系统异常: {}", e.getMessage(), e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", 500);
errorResponse.put("error", "Internal Server Error");
errorResponse.put("message", "系统内部错误,请稍后重试");
errorResponse.put("detail", e.getMessage());
return ResponseEntity.status(500).body(errorResponse);
}
}
11. 性能优化配置
11.1 连接池配置
package com.example.elasticsearchdemo.config;
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
import org.elasticsearch.client.RestClientBuilder;
import org.springframework.context.annotation.Configuration;
@Configuration
public class HttpClientConfig {
/**
* 自定义HTTP客户端配置
*/
public RestClientBuilder.HttpClientConfigCallback httpClientConfigCallback() {
return new RestClientBuilder.HttpClientConfigCallback() {
@Override
public HttpAsyncClientBuilder customizeHttpClient(
HttpAsyncClientBuilder httpClientBuilder) {
// 配置连接池
httpClientBuilder.setMaxConnTotal(100); // 最大连接数
httpClientBuilder.setMaxConnPerRoute(50); // 每个路由的最大连接数
// 启用TCP保持连接
httpClientBuilder.setKeepAliveStrategy((response, context) -> 60000);
return httpClientBuilder;
}
};
}
}
11.2 异步操作配置
package com.example.elasticsearchdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 核心线程数
executor.setMaxPoolSize(50); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setThreadNamePrefix("Async-");
executor.initialize();
return executor;
}
}
11.3 异步Service
package com.example.elasticsearchdemo.service;
import com.example.elasticsearchdemo.entity.Product;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncProductService {
private final ProductService productService;
/**
* 异步批量保存产品
*/
@Async("taskExecutor")
public CompletableFuture<List<Product>> saveAllAsync(List<Product> products) {
log.info("开始异步批量保存产品,数量: {}", products.size());
try {
List<Product> savedProducts = productService.saveAll(products);
log.info("异步批量保存产品完成");
return CompletableFuture.completedFuture(savedProducts);
} catch (Exception e) {
log.error("异步批量保存产品失败", e);
return CompletableFuture.failedFuture(e);
}
}
/**
* 异步搜索
*/
@Async("taskExecutor")
public CompletableFuture<List<Product>> searchAsync(String keyword) {
log.info("开始异步搜索: {}", keyword);
try {
List<Product> products = productService.searchByName(keyword);
log.info("异步搜索完成,结果数量: {}", products.size());
return CompletableFuture.completedFuture(products);
} catch (Exception e) {
log.error("异步搜索失败", e);
return CompletableFuture.failedFuture(e);
}
}
}
12. 监控和健康检查
12.1 健康检查端点
package com.example.elasticsearchdemo.health;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.cluster.HealthResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class ElasticsearchHealthIndicator implements HealthIndicator {
private final ElasticsearchClient elasticsearchClient;
@Override
public Health health() {
try {
HealthResponse healthResponse = elasticsearchClient.cluster().health();
String status = healthResponse.status().jsonValue();
int numberOfNodes = healthResponse.numberOfNodes();
int numberOfDataNodes = healthResponse.numberOfDataNodes();
// 根据状态判断健康程度
if ("green".equals(status)) {
return Health.up()
.withDetail("status", status)
.withDetail("numberOfNodes", numberOfNodes)
.withDetail("numberOfDataNodes", numberOfDataNodes)
.build();
} else if ("yellow".equals(status)) {
return Health.status("YELLOW")
.withDetail("status", status)
.withDetail("numberOfNodes", numberOfNodes)
.withDetail("numberOfDataNodes", numberOfDataNodes)
.withDetail("message", "集群处于警告状态,请检查副本分片")
.build();
} else {
return Health.down()
.withDetail("status", status)
.withDetail("numberOfNodes", numberOfNodes)
.withDetail("numberOfDataNodes", numberOfDataNodes)
.withDetail("message", "集群处于故障状态")
.build();
}
} catch (Exception e) {
log.error("Elasticsearch健康检查失败", e);
return Health.down(e).build();
}
}
}
12.2 监控配置
application.yml 添加监控配置:
# Actuator 监控端点配置
management:
endpoints:
web:
exposure:
include: health,metrics,info,elasticsearch
endpoint:
health:
show-details: always
metrics:
export:
elasticsearch:
enabled: true
index: metrics
13. 使用示例
13.1 主启动类
package com.example.elasticsearchdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
@SpringBootApplication
@EnableElasticsearchRepositories
public class ElasticsearchDemoApplication {
public static void main(String[] args) {
SpringApplication.run(ElasticsearchDemoApplication.class, args);
}
}
13.2 启动应用
- 确保 Elasticsearch 运行
# 启动 Elasticsearch
./elasticsearch
- 启动 Spring Boot 应用
mvn spring-boot:run
# 或
java -jar target/elasticsearch-demo-1.0.0.jar
- 测试 API
# 创建产品
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{
"name": "测试商品",
"price": 99.99,
"stock": 100,
"category": "电子产品",
"brand": "测试品牌"
}'
# 搜索产品
curl "http://localhost:8080/api/products/search/name?name=测试"
# 分页查询
curl "http://localhost:8080/api/products/page?page=0&size=10"
# 健康检查
curl http://localhost:8080/actuator/health
14. 常见问题和解决方案
14.1 连接问题
问题1: 连接超时
# 解决方案:增加超时时间
spring:
elasticsearch:
connection-timeout: 10000ms # 连接超时
socket-timeout: 60000ms # socket超时
问题2: SSL证书问题
// 解决方案:忽略SSL证书验证(仅开发环境)
@Bean
public RestClient restClient() {
return RestClient.builder(HttpHost.create(elasticsearchUris))
.setHttpClientConfigCallback(httpClientBuilder -> {
try {
SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null,
new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
public X509Certificate[] getAcceptedIssuers() { return null; }
}},
new SecureRandom());
return httpClientBuilder.setSSLContext(sslContext)
.setSSLHostnameVerifier((hostname, session) -> true);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.build();
}
14.2 性能问题
问题: 批量操作性能差
// 解决方案:使用ElasticsearchClient进行批量操作
public void bulkIndexWithClient(List<Product> products) {
BulkRequest.Builder br = new BulkRequest.Builder();
// 分批处理,每批1000条
for (int i = 0; i < products.size(); i += 1000) {
int end = Math.min(i + 1000, products.size());
List<Product> batch = products.subList(i, end);
for (Product product : batch) {
br.operations(op -> op
.index(idx -> idx
.index("products")
.id(product.getId())
.document(product)
)
);
}
try {
BulkResponse response = elasticsearchClient.bulk(br.build());
// 处理响应
} catch (IOException e) {
log.error("批量索引失败", e);
}
}
}
14.3 映射问题
问题: 字段类型映射错误
// 解决方案:使用@Field注解明确指定类型
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String description;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
private LocalDateTime createTime;
15. 最佳实践建议
- 索引设计
- 合理设置分片数量(根据数据量和节点数)
- 使用别名而不是直接操作索引
- 为时间序列数据使用索引模板
- 查询优化
- 使用filter context进行过滤,利用缓存
- 避免深度分页,使用search_after
- 合理使用_source字段,只返回需要的字段
- 代码优化
- 使用批量操作减少网络请求
- 合理使用异步操作提高吞吐量
- 实现重试机制处理临时故障
- 监控告警
- 监控集群健康状态
- 设置慢查询日志
- 监控JVM内存使用情况
- 安全实践
- 使用HTTPS连接
- 配置认证和授权
- 定期更新Elasticsearch版本

浙公网安备 33010602011771号