Elasticsearch 实战系列(二):SpringBoot 集成 Elasticsearch,从 0 到 1 实现商品搜索系统

Elasticsearch 实战系列(二):SpringBoot 集成 Elasticsearch,从 0 到 1 实现商品搜索系统

本文为 Elasticsearch 实战系列第二篇,承接上一篇《Elasticsearch 实战系列(一):基础入门》的核心知识点,从零实现 Spring Boot 与 Elasticsearch 的无缝集成,基于 Spring Data Elasticsearch 搭建一套可直接落地的商品搜索系统,覆盖环境搭建、分层开发、基础 CRUD、复杂全文检索、聚合统计、高亮搜索全流程。

前言

在上一篇中,我们已经掌握了 ES 的核心概念、DSL 语法、索引与文档的基础操作。但在实际的 Java 后端开发中,我们很少直接通过 curl 命令操作 ES,更多是在 Spring Boot 项目中集成 ES,实现业务化的检索能力。

读完本文,你将掌握以下核心内容:

  1. ES 主流 Java 客户端的选型逻辑,避开已废弃的过时方案
  2. Spring Boot 与 ES 的版本匹配规则,解决 90% 的集成启动报错
  3. 基于 Spring Data Elasticsearch 的分层开发规范
  4. 无需手写 DSL,通过方法命名实现基础查询
  5. 基于 ElasticsearchRestTemplate 实现复杂组合查询、聚合统计、高亮搜索
  6. 新手集成 ES 的高频踩坑点与完整解决方案

一、ES Java 客户端选型:选对工具少走弯路

ES 官方提供了多种 Java 客户端,不同版本的适配性、维护状态差异极大,选对客户端是避免后续返工的第一步。

1.1 主流客户端对比

客户端类型 核心说明 推荐状态 适配版本
TransportClient 基于 TCP 协议的传统客户端 ❌ 已废弃 ES 7.x 之前版本
RestHighLevelClient 基于 HTTP 的高级 REST 客户端 ⚠️ 即将废弃 ES 7.x 全系列
Elasticsearch Java API Client 官方新一代 Java 客户端 ✅ 官方推荐 ES 8.x+
Spring Data Elasticsearch Spring 生态封装的 ES 操作框架 ✅ 全场景推荐 兼容 7.x、8.x 全系列

1.2 本文选型说明

本文最终选用Spring Data Elasticsearch作为核心开发框架,核心原因如下:

  1. 与 Spring Boot 无缝集成,自动配置、开箱即用,无需手动管理客户端连接
  2. 提供 Repository 模式,无需手写 DSL 即可实现绝大多数基础查询,大幅简化开发
  3. 同时兼容 ES 7.x 和 8.x 版本,后续版本升级成本极低
  4. 底层封装了 RestHighLevelClient,既支持极简开发,也保留了原生 DSL 复杂查询的能力
  5. 与 Spring 生态的其他组件(Spring MVC、Spring Cloud 等)完美适配,符合 Java 后端开发规范

整体集成架构如下:

复制代码
Controller层(REST接口) → Service层(业务逻辑) → Repository层/ElasticsearchRestTemplate → Elasticsearch集群

全程通过 HTTP REST 协议与 ES 通信,无语言绑定,兼容性极强。


二、环境准备:版本匹配是第一要务

Spring Boot、Spring Data Elasticsearch、ES 三者之间有严格的版本对应关系,版本不匹配会出现类找不到、方法不兼容、启动报错等问题,这是新手集成 ES 的第一大踩坑点。

2.1 严格的版本对应关系

本文采用官方推荐的稳定兼容版本组合,适配绝大多数企业级生产环境:

表格

Spring Boot 版本 Spring Data Elasticsearch 版本 Elasticsearch 版本
2.7.x 4.4.x 7.17.x
3.0.x 5.0.x 8.x

本文最终使用版本:

  • Spring Boot:2.7.14
  • Spring Data Elasticsearch:4.4.14(随 Spring Boot 自动引入)
  • Elasticsearch:7.17.10(与上一篇环境保持一致)

2.2 项目核心依赖引入

在 Maven 的 pom.xml 中引入以下核心依赖,无需手动指定 Spring Data Elasticsearch 的版本,由 Spring Boot 父工程统一管理:

xml 复制代码
<dependencies>
    <!-- Spring Boot Web 核心依赖 -->
    <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>

    <!-- Lombok 简化实体类代码 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

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

2.3 配置文件编写

在 application.yml(或 application.properties)中添加 ES 相关配置,Spring Boot 会自动读取配置,完成客户端的自动初始化:

yaml 复制代码
spring:
  elasticsearch:
    # ES服务地址,集群环境填写多个节点,用逗号分隔
    uris: http://localhost:9200
    # 若ES开启了用户名密码认证,填写以下配置
    # username: elastic
    # password: 123456
    # 连接超时时间,默认5s,网络环境差可适当调大
    connection-timeout: 5s
    # socket通信超时时间,默认30s,大数据量查询可适当调大
    socket-timeout: 30s

# 日志配置:开启ES相关操作的DEBUG日志,方便调试排查问题
logging:
  level:
    org.springframework.data.elasticsearch: DEBUG

三、实体类映射:Java 对象与 ES 索引的绑定

实体类是 Java 业务对象与 ES 索引文档的映射桥梁,通过注解即可完成索引名称、分片配置、字段类型、分词规则的全定义,无需手动在 ES 中创建索引和 Mapping。

3.1 完整商品实体类

我们以电商商品搜索场景为例,创建完整的 Product 实体类,覆盖 ES 常用的字段类型与配置:

java 复制代码
package com.example.demo.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.math.BigDecimal;
import java.util.Date;
import java.util.List;

/**
 * 商品实体类,与ES的product_index索引绑定
 */
@Data
@Document(
        indexName = "product_index",  // 绑定的索引名称,必须全小写
        shards = 3,                    // 主分片数量,创建后不可修改
        replicas = 1,                  // 副本分片数量,可动态调整
        createIndex = true             // 项目启动时自动创建索引(若不存在)
)
public class Product {

    /**
     * 文档主键,对应ES中的_id字段
     */
    @Id
    private String id;

    /**
     * 商品标题:全文检索字段,使用ik_max_word分词器实现中文分词
     */
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String title;

    /**
     * 商品分类:精确匹配字段,不分词,用于分类过滤、聚合统计
     */
    @Field(type = FieldType.Keyword)
    private String category;

    /**
     * 商品价格:浮点类型,用于范围查询、排序
     */
    @Field(type = FieldType.Double)
    private BigDecimal price;

    /**
     * 商品库存:整数类型,用于库存过滤
     */
    @Field(type = FieldType.Integer)
    private Integer stock;

    /**
     * 商品销量:整数类型,用于排序、统计
     */
    @Field(type = FieldType.Integer)
    private Integer sales;

    /**
     * 商品品牌:精确匹配字段,用于品牌过滤、聚合
     */
    @Field(type = FieldType.Keyword)
    private String brand;

    /**
     * 商品标签:数组类型,Keyword类型,用于标签过滤
     */
    @Field(type = FieldType.Keyword)
    private List<String> tags;

    /**
     * 商品描述:全文检索字段,支持中文分词模糊搜索
     */
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String description;

    /**
     * 创建时间:日期类型,支持多种日期格式,用于时间范围查询、排序
     */
    @Field(type = FieldType.Date, pattern = "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis")
    private Date createTime;

}

3.2 核心注解详解

1. @Document 注解(类级别)

用于绑定 Java 类与 ES 索引,核心参数说明:

  • indexName:必填,绑定的索引名称,必须符合 ES 索引命名规则(全小写,不能以特殊字符开头)
  • shards:主分片数量,默认 1,创建索引后不可修改
  • replicas:副本分片数量,默认 1,可动态调整
  • createIndex:是否在项目启动时自动创建索引,默认 true,生产环境建议开启
2. @Id 注解(字段级别)

用于标记文档主键,对应 ES 中的_id字段,支持手动赋值,也可由 ES 自动生成。

3. @Field 注解(字段级别)

用于定义字段的存储与索引规则,核心参数说明:

  • type:必填,字段类型(Text、Keyword、Integer、Date 等),直接决定字段的检索能力
  • analyzer:text 类型字段的分词器,中文场景推荐使用 ik_max_word(细粒度分词)
  • searchAnalyzer:搜索时使用的分词器,默认与 analyzer 保持一致
  • index:是否开启索引,默认 true,关闭后该字段无法被检索
  • store:是否单独存储,默认 false,默认存储在_source 中

3.3 Java 与 ES 字段类型对应规则

Java 数据类型 ES 字段类型 核心适用场景
String Text 全文检索场景(商品标题、文章内容、描述等),会分词
String Keyword 精确匹配场景(分类、品牌、状态、ID、标签等),不分词
Integer/Long Integer/Long 整数数据(年龄、库存、销量、数量等)
Float/Double/BigDecimal Float/Double 浮点数据(价格、评分、折扣率等)
Boolean Boolean 二值状态(是否上架、是否删除等)
Date Date 时间数据(创建时间、更新时间、订单时间等)
List/Set Array 数组数据(标签列表、图片地址列表等)
自定义对象 Object 单嵌套对象(收货地址、规格信息等)

踩坑提示:Text 类型字段不支持排序和聚合,Keyword 类型字段不支持全文分词检索,一定要根据业务场景选择正确的字段类型,否则会出现查询不到结果、排序报错的问题。


四、Repository 数据访问层:极简实现基础 CRUD

Spring Data Elasticsearch 提供了 Repository 抽象,继承ElasticsearchRepository接口即可自动获得基础的 CRUD、分页、排序能力,无需手动编写实现类,甚至无需手写 DSL 语句,通过方法命名即可自动生成查询逻辑。

4.1 Repository 继承体系

复制代码
CrudRepository → PagingAndSortingRepository → ElasticsearchRepository → 自定义Repository接口
  • CrudRepository:提供基础的增删改查方法
  • PagingAndSortingRepository:扩展了分页和排序能力
  • ElasticsearchRepository:扩展了 ES 专属的搜索能力
  • 自定义接口:实现业务专属的查询方法

4.2 自定义 ProductRepository

创建 ProductRepository 接口,继承 ElasticsearchRepository,泛型参数为 <实体类类型,主键类型>:

java 复制代码
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;

/**
 * 商品数据访问层,自动获得基础CRUD能力
 */
@Repository
public interface ProductRepository extends ElasticsearchRepository<Product, String> {

    // ==================== 单条件精确查询 ====================
    /**
     * 根据商品标题查询(全文检索)
     */
    List<Product> findByTitle(String title);

    /**
     * 根据商品分类查询(精确匹配)
     */
    List<Product> findByCategory(String category);

    /**
     * 根据品牌查询(精确匹配)
     */
    List<Product> findByBrand(String brand);

    // ==================== 范围查询 ====================
    /**
     * 价格区间查询
     */
    List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);

    /**
     * 查询价格大于指定值的商品
     */
    List<Product> findByPriceGreaterThan(BigDecimal price);

    /**
     * 查询价格小于指定值的商品
     */
    List<Product> findByPriceLessThan(BigDecimal price);

    // ==================== 模糊查询 ====================
    /**
     * 标题模糊查询
     */
    List<Product> findByTitleLike(String title);

    // ==================== 组合条件查询 ====================
    /**
     * 组合查询:分类 + 价格区间
     */
    List<Product> findByCategoryAndPriceBetween(String category, BigDecimal minPrice, BigDecimal maxPrice);

    /**
     * 组合查询:品牌或分类
     */
    List<Product> findByBrandOrCategory(String brand, String category);

    // ==================== 分页与排序查询 ====================
    /**
     * 根据分类分页查询
     */
    Page<Product> findByCategory(String category, Pageable pageable);

    /**
     * 根据分类查询,按销量倒序排序
     */
    List<Product> findByCategoryOrderBySalesDesc(String category);

}

4.3 方法命名规则详解

Spring Data Elasticsearch 会根据方法名自动解析生成对应的 DSL 查询语句,核心关键字与 ES 查询的对应关系如下:

方法关键字 示例方法 对应 ES DSL 查询 逻辑说明
findBy findByTitle match/term 查询 根据字段查询
And findByTitleAndCategory bool.must 多个条件必须同时满足(AND)
Or findByBrandOrCategory bool.should 多个条件满足其一即可(OR)
Between findByPriceBetween range 范围查询,包含上下限
GreaterThan findByPriceGreaterThan range.gt 大于指定值
LessThan findByPriceLessThan range.lt 小于指定值
Like findByTitleLike wildcard 模糊查询
OrderBy findByCategoryOrderBySalesDesc sort 按指定字段排序

最佳实践:简单单条件、组合条件查询优先使用方法命名实现,开发效率极高;复杂的多条件动态查询、聚合查询、高亮搜索,使用 ElasticsearchRestTemplate 实现。


五、Service 业务层:覆盖基础操作与复杂查询

Service 层负责封装业务逻辑,分为两部分:基于 Repository 实现基础 CRUD,基于 ElasticsearchRestTemplate 实现复杂动态查询。

5.1 完整 Service 层实现

java 复制代码
package com.example.demo.service;

import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.Aggregations;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.sort.SortBuilders;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 商品业务层实现
 */
@Slf4j
@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private ElasticsearchRestTemplate elasticsearchTemplate;

    // ==================== 基础CRUD操作 ====================
    /**
     * 新增/更新商品
     * 若ID已存在则执行全量更新,不存在则执行新增
     */
    public Product save(Product product) {
        return productRepository.save(product);
    }

    /**
     * 批量保存商品
     */
    public void saveAll(List<Product> products) {
        productRepository.saveAll(products);
    }

    /**
     * 根据ID查询商品
     */
    public Product findById(String id) {
        Optional<Product> optional = productRepository.findById(id);
        return optional.orElse(null);
    }

    /**
     * 根据ID删除商品
     */
    public void deleteById(String id) {
        productRepository.deleteById(id);
    }

    /**
     * 查询所有商品
     */
    public List<Product> findAll() {
        Iterable<Product> iterable = productRepository.findAll();
        List<Product> productList = new ArrayList<>();
        iterable.forEach(productList::add);
        return productList;
    }

    // ==================== 基于Repository的业务查询 ====================
    /**
     * 根据分类分页查询商品
     * @param category 分类名称
     * @param page 页码,从0开始
     * @param size 每页条数
     */
    public Page<Product> findByCategory(String category, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return productRepository.findByCategory(category, pageable);
    }

    /**
     * 价格区间查询商品
     */
    public List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
        return productRepository.findByPriceBetween(minPrice, maxPrice);
    }

    // ==================== 基于RestTemplate的复杂查询 ====================
    /**
     * 全文检索:多字段匹配关键字(标题+描述)
     */
    public List<Product> fullTextSearch(String keyword) {
        // 构建多字段匹配查询:同时匹配title和description字段
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "description"))
                .build();

        // 执行查询
        SearchHits<Product> searchHits = elasticsearchTemplate.search(searchQuery, Product.class);

        // 解析结果,返回商品列表
        return searchHits.stream()
                .map(SearchHit::getContent)
                .collect(Collectors.toList());
    }

    /**
     * 动态组合查询:分类、价格区间、关键字、排序
     * 支持参数动态为空,自动忽略空条件
     */
    public List<Product> complexSearch(String category, BigDecimal minPrice, BigDecimal maxPrice, String keyword) {
        // 构建布尔查询
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // 分类过滤:参数不为空时才添加条件
        if (category != null && !category.isEmpty()) {
            boolQuery.filter(QueryBuilders.termQuery("category", category));
        }

        // 价格区间过滤:参数不为空时才添加条件
        if (minPrice != null || maxPrice != null) {
            boolQuery.filter(QueryBuilders.rangeQuery("price")
                    .gte(minPrice != null ? minPrice : 0)
                    .lte(maxPrice != null ? maxPrice : Integer.MAX_VALUE));
        }

        // 关键字全文检索:参数不为空时才添加条件
        if (keyword != null && !keyword.isEmpty()) {
            boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "title", "description"));
        }

        // 构建查询:按销量倒序排序
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(boolQuery)
                .withSort(SortBuilders.fieldSort("sales").order(SortOrder.DESC))
                .build();

        // 执行查询并解析结果
        SearchHits<Product> searchHits = elasticsearchTemplate.search(searchQuery, Product.class);
        return searchHits.stream()
                .map(SearchHit::getContent)
                .collect(Collectors.toList());
    }

    // ==================== 高级功能实现 ====================
    /**
     * 聚合查询:按商品分类统计商品数量
     */
    public Map<String, Long> countByCategory() {
        // 构建聚合查询:按category字段分桶统计文档数量
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .addAggregation(AggregationBuilders.terms("category_count").field("category"))
                .build();

        // 执行查询
        SearchHits<Product> searchHits = elasticsearchTemplate.search(searchQuery, Product.class);

        // 解析聚合结果
        Aggregations aggregations = searchHits.getAggregations();
        Terms categoryAgg = aggregations.get("category_count");

        // 封装结果:key=分类名称,value=商品数量
        Map<String, Long> result = new HashMap<>();
        for (Terms.Bucket bucket : categoryAgg.getBuckets()) {
            result.put(bucket.getKeyAsString(), bucket.getDocCount());
        }
        return result;
    }

    /**
     * 高亮搜索:匹配的关键字添加高亮标签,优化前端展示
     */
    public List<Product> searchWithHighlight(String keyword) {
        // 构建高亮配置:匹配title和description字段,用<em>标签包裹匹配的关键字
        HighlightBuilder highlightBuilder = new HighlightBuilder()
                .field("title")
                .field("description")
                .preTags("<em>")
                .postTags("</em>");

        // 构建查询
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "description"))
                .withHighlightBuilder(highlightBuilder)
                .build();

        // 执行查询
        SearchHits<Product> searchHits = elasticsearchTemplate.search(searchQuery, Product.class);

        // 解析结果:将高亮内容替换到商品实体中
        return searchHits.stream()
                .map(hit -> {
                    Product product = hit.getContent();
                    // 获取高亮字段
                    Map<String, List<String>> highlightFields = hit.getHighlightFields();
                    // 替换标题的高亮内容
                    if (highlightFields.containsKey("title")) {
                        product.setTitle(highlightFields.get("title").get(0));
                    }
                    // 替换描述的高亮内容
                    if (highlightFields.containsKey("description")) {
                        product.setDescription(highlightFields.get("description").get(0));
                    }
                    return product;
                })
                .collect(Collectors.toList());
    }

}

5.2 核心实现说明

  1. 基础 CRUD:直接调用 Repository 提供的内置方法,无需手动实现,极简开发
  2. 动态组合查询:使用 BoolQueryBuilder 构建动态条件,参数为空时自动忽略,适配前端多条件筛选的业务场景
  3. 过滤与查询分离:过滤条件统一放在 filter 子句中,不计算相关性分数,ES 会自动缓存过滤结果,查询性能远高于 must 子句
  4. 聚合查询:使用 Terms 聚合实现分桶统计,适用于分类统计、品牌统计等电商常见场景
  5. 高亮搜索:自定义高亮标签,将匹配的关键字替换到实体类中,前端可直接渲染高亮效果,无需额外处理

六、Controller 层:RESTful API 接口设计

基于 Spring MVC 设计 RESTful 风格的 API 接口,对外提供商品的增删改查、检索能力,所有接口均配套测试命令,可直接调用验证。

6.1 统一返回结果类

首先创建通用的返回结果类Result,统一接口返回格式:

java 复制代码
package com.example.demo.common;

import lombok.Data;

/**
 * 全局统一返回结果类
 */
@Data
public class Result<T> {

    /**
     * 响应码:200成功,其他失败
     */
    private Integer code;

    /**
     * 响应消息
     */
    private String message;

    /**
     * 响应数据
     */
    private T data;

    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage("操作成功");
        result.setData(data);
        return result;
    }

    public static <T> Result<T> success(String message, T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage(message);
        result.setData(data);
        return result;
    }

    public static <T> Result<T> error(String message) {
        Result<T> result = new Result<>();
        result.setCode(500);
        result.setMessage(message);
        result.setData(null);
        return result;
    }

}

6.2 完整 Controller 接口实现

java 复制代码
package com.example.demo.controller;

import com.example.demo.common.Result;
import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

/**
 * 商品管理REST接口
 */
@RestController
@RequestMapping("/api/products")
public class ProductController {

    @Autowired
    private ProductService productService;

    // ==================== 基础CRUD接口 ====================
    /**
     * 创建商品
     */
    @PostMapping
    public Result<Product> create(@RequestBody Product product) {
        Product savedProduct = productService.save(product);
        return Result.success(savedProduct);
    }

    /**
     * 批量创建商品
     */
    @PostMapping("/batch")
    public Result<Void> batchCreate(@RequestBody List<Product> products) {
        productService.saveAll(products);
        return Result.success("批量创建成功", null);
    }

    /**
     * 根据ID查询商品详情
     */
    @GetMapping("/{id}")
    public Result<Product> getById(@PathVariable String id) {
        Product product = productService.findById(id);
        return Result.success(product);
    }

    /**
     * 查询所有商品
     */
    @GetMapping
    public Result<List<Product>> getAll() {
        List<Product> products = productService.findAll();
        return Result.success(products);
    }

    /**
     * 根据ID删除商品
     */
    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable String id) {
        productService.deleteById(id);
        return Result.success("删除成功", null);
    }

    // ==================== 检索查询接口 ====================
    /**
     * 根据分类分页查询商品
     * @param category 分类名称
     * @param page 页码,默认0(第一页)
     * @param size 每页条数,默认10
     */
    @GetMapping("/category/{category}")
    public Result<Page<Product>> getByCategory(
            @PathVariable String category,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        Page<Product> productPage = productService.findByCategory(category, page, size);
        return Result.success(productPage);
    }

    /**
     * 价格区间查询商品
     */
    @GetMapping("/price-range")
    public Result<List<Product>> getByPriceRange(
            @RequestParam BigDecimal minPrice,
            @RequestParam BigDecimal maxPrice) {
        List<Product> products = productService.findByPriceRange(minPrice, maxPrice);
        return Result.success(products);
    }

    /**
     * 全文关键字搜索
     */
    @GetMapping("/search")
    public Result<List<Product>> fullTextSearch(@RequestParam String keyword) {
        List<Product> products = productService.fullTextSearch(keyword);
        return Result.success(products);
    }

    /**
     * 多条件复杂搜索
     * 所有参数均为非必填,支持动态组合
     */
    @GetMapping("/complex-search")
    public Result<List<Product>> complexSearch(
            @RequestParam(required = false) String category,
            @RequestParam(required = false) BigDecimal minPrice,
            @RequestParam(required = false) BigDecimal maxPrice,
            @RequestParam(required = false) String keyword) {
        List<Product> products = productService.complexSearch(category, minPrice, maxPrice, keyword);
        return Result.success(products);
    }

    /**
     * 带高亮的关键字搜索
     */
    @GetMapping("/search-highlight")
    public Result<List<Product>> searchWithHighlight(@RequestParam String keyword) {
        List<Product> products = productService.searchWithHighlight(keyword);
        return Result.success(products);
    }

    /**
     * 按分类统计商品数量
     */
    @GetMapping("/count-by-category")
    public Result<Map<String, Long>> countByCategory() {
        Map<String, Long> countResult = productService.countByCategory();
        return Result.success(countResult);
    }

}

6.3 接口测试 curl 命令

项目启动后,可直接通过以下 curl 命令测试所有接口,也可通过 Postman、Apifox 等工具调用:

shell 复制代码
# 1. 查询所有商品
curl http://localhost:8080/api/products

# 2. 根据ID查询商品详情
curl http://localhost:8080/api/products/1

# 3. 根据分类分页查询商品
curl http://localhost:8080/api/products/category/手机

# 4. 价格区间查询商品
curl "http://localhost:8080/api/products/price-range?minPrice=3000&maxPrice=8000"

# 5. 全文关键字搜索
curl "http://localhost:8080/api/products/search?keyword=华为"

# 6. 多条件复杂搜索
curl "http://localhost:8080/api/products/complex-search?category=手机&minPrice=4000&maxPrice=7000&keyword=5G"

# 7. 带高亮的关键字搜索
curl "http://localhost:8080/api/products/search-highlight?keyword=旗舰"

# 8. 按分类统计商品数量
curl http://localhost:8080/api/products/count-by-category

# 9. 创建单个商品
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{
    "title": "iPhone 15 Pro",
    "category": "手机",
    "price": 7999,
    "stock": 150,
    "sales": 300,
    "brand": "苹果",
    "tags": ["5G", "高端", "iOS"],
    "description": "苹果最新旗舰手机,搭载A17 Pro芯片"
  }'

# 10. 根据ID删除商品
curl -X DELETE http://localhost:8080/api/products/1

七、测试数据初始化:启动自动导入测试数据

为了方便测试,我们实现CommandLineRunner接口,在项目启动完成后自动初始化测试数据,无需手动调用接口造数:

java 复制代码
package com.example.demo;

import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Date;

/**
 * 项目启动时自动初始化测试数据
 */
@Component
public class DataInitializer implements CommandLineRunner {

    @Autowired
    private ProductService productService;

    @Override
    public void run(String... args) throws Exception {
        // 构建测试商品数据
        Product p1 = new Product();
        p1.setId("1");
        p1.setTitle("华为Mate 60 Pro 5G手机");
        p1.setCategory("手机");
        p1.setPrice(new BigDecimal("6999"));
        p1.setStock(100);
        p1.setSales(500);
        p1.setBrand("华为");
        p1.setTags(Arrays.asList("5G", "高端", "国产"));
        p1.setDescription("华为最新旗舰手机,搭载麒麟9000S芯片,支持卫星通信");
        p1.setCreateTime(new Date());

        Product p2 = new Product();
        p2.setId("2");
        p2.setTitle("小米14 Pro 智能手机");
        p2.setCategory("手机");
        p2.setPrice(new BigDecimal("4999"));
        p2.setStock(200);
        p2.setSales(800);
        p2.setBrand("小米");
        p2.setTags(Arrays.asList("5G", "性价比", "拍照"));
        p2.setDescription("小米旗舰手机,搭载骁龙8 Gen3芯片,徕卡影像系统");
        p2.setCreateTime(new Date());

        Product p3 = new Product();
        p3.setId("3");
        p3.setTitle("MacBook Pro 14英寸笔记本电脑");
        p3.setCategory("电脑");
        p3.setPrice(new BigDecimal("14999"));
        p3.setStock(50);
        p3.setSales(200);
        p3.setBrand("苹果");
        p3.setTags(Arrays.asList("M3芯片", "高性能", "轻薄"));
        p3.setDescription("苹果M3芯片笔记本,专业级性能,适合设计、开发场景");
        p3.setCreateTime(new Date());

        // 批量保存数据
        productService.saveAll(Arrays.asList(p1, p2, p3));
        System.out.println("===== 测试商品数据初始化完成 =====");
    }

}

八、新手高频踩坑与解决方案

8.1 报错:no such index [product_index]

问题原因 :ES 中不存在对应的索引,可能是自动创建索引失败,或者手动删除了索引。解决方案:手动创建索引与映射,添加以下方法,项目启动时执行即可:

java 复制代码
@Autowired
private ElasticsearchRestTemplate elasticsearchTemplate;

/**
 * 手动创建索引与映射
 */
public void createIndex() {
    IndexOperations indexOps = elasticsearchTemplate.indexOps(Product.class);
    // 若索引不存在,则创建索引并写入映射
    if (!indexOps.exists()) {
        indexOps.create();
        indexOps.putMapping(indexOps.createMapping());
    }
}

8.2 ES 服务连接超时

问题原因 :网络环境差、ES 服务不在本地、超时时间配置过短,导致连接超时。解决方案:调整配置文件中的超时时间,适当放大连接超时和 socket 超时:

yaml 复制代码
spring:
  elasticsearch:
    uris: http://localhost:9200
    connection-timeout: 10s
    socket-timeout: 60s

同时检查 ES 服务是否正常启动、端口是否开放、防火墙是否拦截了请求。

8.3 中文分词不生效

问题原因 :ES 中未安装 IK 中文分词器,导致 text 类型的中文字段无法正常分词,全文检索失效。解决方案:安装与 ES 版本完全一致的 IK 中文分词器,Docker 环境安装步骤如下:

shell 复制代码
# 1. 进入ES容器
docker exec -it elasticsearch bash

# 2. 安装对应版本的IK分词器(本文ES版本为7.17.10,必须对应)
./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.17.10/elasticsearch-analysis-ik-7.17.10.zip

# 3. 退出容器
exit

# 4. 重启ES容器,使分词器生效
docker restart elasticsearch

注意:IK 分词器的版本必须与 ES 版本完全一致,否则会导致 ES 启动失败。


九、总结与系列预告

本文总结

本文完整覆盖了 Spring Boot 集成 Elasticsearch 的全流程,从客户端选型、环境搭建、分层开发,到复杂查询、高级功能、踩坑解决方案,实现了一套可直接落地的商品搜索系统。核心内容如下:

  1. 明确了 ES Java 客户端的选型逻辑,避开了已废弃的 TransportClient 方案
  2. 梳理了 Spring Boot 与 ES 的版本对应规则,解决了集成的第一大踩坑点
  3. 掌握了基于注解的实体类与 ES 索引映射规则,明确了字段类型的选型逻辑
  4. 学会了基于 Repository 的极简开发模式,通过方法命名实现基础查询
  5. 掌握了基于 ElasticsearchRestTemplate 的复杂动态查询、聚合统计、高亮搜索实现
  6. 解决了新手集成 ES 的 3 个高频问题,提供了完整的解决方案

系列预告

在下一篇文章中,我们将深入讲解 Elasticsearch 的高级查询技巧与生产环境实战场景,包括:

  • 深度分页的 3 种解决方案与性能对比
  • 嵌套对象与 nested 类型的查询实战
  • 中文分词的高级配置与自定义词典
  • ES 集群的生产环境部署规范与性能优化
  • 海量数据的同步方案与最佳实践
相关推荐
Ynchen. ~1 小时前
快速复习笔记(随笔)
笔记
Amour恋空2 小时前
Nacos服务发现与配置
java·后端·服务发现
uzong2 小时前
为什么是你来做?面试中犀利问题的底层逻辑是什么和标准回答模版
后端·面试
chikaaa2 小时前
RabbitMQ 核心机制总结笔记
java·笔记·rabbitmq·java-rabbitmq
Sailing2 小时前
🚀AI 写代码越来越快,但我开始不敢上线了
前端·后端·面试
QQ24391972 小时前
spring boot医院挂号就诊系统信息管理系统源码-SpringBoot后端+Vue前端+MySQL【可直接运行】
前端·vue.js·spring boot
Coder-coco2 小时前
家政服务管理系统|基于springboot + vue家政服务管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·家政服务管理系统
程序员鱼皮2 小时前
万字干货 | OpenClaw 进阶玩法大全:技能 / 多 Agent / 省钱 / 安全,50+ 实战技巧一次学会
前端·后端·ai编程
张道宁2 小时前
基于Spring Boot与Docker的YOLOv8检测服务实战
spring boot·yolo·docker