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

2 阅读19分钟

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 ElasticsearchSpring 生态封装的 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.x4.4.x7.17.x
3.0.x5.0.x8.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 父工程统一管理:

<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 会自动读取配置,完成客户端的自动初始化:

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 常用的字段类型与配置:

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 字段类型核心适用场景
StringText全文检索场景(商品标题、文章内容、描述等),会分词
StringKeyword精确匹配场景(分类、品牌、状态、ID、标签等),不分词
Integer/LongInteger/Long整数数据(年龄、库存、销量、数量等)
Float/Double/BigDecimalFloat/Double浮点数据(价格、评分、折扣率等)
BooleanBoolean二值状态(是否上架、是否删除等)
DateDate时间数据(创建时间、更新时间、订单时间等)
List/SetArray数组数据(标签列表、图片地址列表等)
自定义对象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,泛型参数为 <实体类类型,主键类型>:

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 查询逻辑说明
findByfindByTitlematch/term 查询根据字段查询
AndfindByTitleAndCategorybool.must多个条件必须同时满足(AND)
OrfindByBrandOrCategorybool.should多个条件满足其一即可(OR)
BetweenfindByPriceBetweenrange范围查询,包含上下限
GreaterThanfindByPriceGreaterThanrange.gt大于指定值
LessThanfindByPriceLessThanrange.lt小于指定值
LikefindByTitleLikewildcard模糊查询
OrderByfindByCategoryOrderBySalesDescsort按指定字段排序

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


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

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

5.1 完整 Service 层实现

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,统一接口返回格式:

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 接口实现

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 等工具调用:

# 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接口,在项目启动完成后自动初始化测试数据,无需手动调用接口造数:

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 中不存在对应的索引,可能是自动创建索引失败,或者手动删除了索引。解决方案:手动创建索引与映射,添加以下方法,项目启动时执行即可:

@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 超时:

spring:
  elasticsearch:
    uris: http://localhost:9200
    connection-timeout: 10s
    socket-timeout: 60s

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

8.3 中文分词不生效

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

# 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 集群的生产环境部署规范与性能优化
  • 海量数据的同步方案与最佳实践