Elasticsearch集成项目实现全文检索及高亮展示(8.20)

一、 适配es8并实现数据初始化

​ 把 MySQL 中的攻略、游记、目的地等数据全量同步到 Elasticsearch 中,实现搜索数据的初始化。

1、创建travel-api-search模块,选择Java项目。

(1)添加domain.StrategyEs实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class StrategyEs {

    private Long id;
    private String title;
    private String subTitle;
    private String summary;
}

(2)添加domain.DestinationEs实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class DestinationEs {

    private Long id;
    private String name;
    private String info;
}

(3)添加domain.NoteEs实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class NoteEs {

    private Long id;
    private String title;
    private String summary;
}

(4)添加domain.UserInfoEs实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserInfoEs {

    private Long id;
    private String nickname;
    private String info;
    private String city;
}

2、创建travel-modules-search模块,选择Java项目。

(1)添加依赖

 <dependencies>
        <dependency>
            <groupId>org.elasticsearch.client</groupId>
            <artifactId>elasticsearch-rest-client</artifactId>
            <version>8.18.8</version>
        </dependency>
        <dependency>
            <groupId>co.elastic.clients</groupId>
            <artifactId>elasticsearch-java</artifactId>
            <version>8.18.8</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        <dependency>
            <groupId>jakarta.json</groupId>
            <artifactId>jakarta.json-api</artifactId>
            <version>2.0.1</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish</groupId>
            <artifactId>jakarta.json</artifactId>
            <version>2.0.1</version>
        </dependency>
     <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-common-log</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-common-security</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-api-search</artifactId>
            <version>3.6.2</version>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-api-strategy</artifactId>
            <version>3.6.2</version>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-api-destination</artifactId>
            <version>3.6.2</version>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-api-member</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.wolfcode.wolf2w</groupId>
            <artifactId>travel-api-note</artifactId>
            <version>3.6.2</version>
        </dependency>   
    </dependencies>

	<dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>2.13.4</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

(2)添加application.yml配置文件

elasticsearch:
  url: localhost
  port: 9200
spring:
  application:
    name: travel-search
  redis:
    password: 123456
    host: localhost
    port: 6379
server:
  port: 9091

(3)添加config.EsConfig配置类

@Bean
public ElasticsearchClient elasticsearchClient() {

    // 1. 创建底层的 REST 客户端,指定 ES 服务器的地址、端口和协议
    RestClient restClient = RestClient.builder(
            new HttpHost(url, port, "http")
    ).build();

    // 2. 将 REST 客户端包装成传输层对象,并指定使用 Jackson 作为 JSON 序列化/反序列化工具
    RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());

    // 3. 基于传输层对象创建 Elasticsearch 高级客户端并注册到 Spring 容器
    return new ElasticsearchClient(transport);
}

(4)添加service.IStrategyEsService接口

public interface IStrategyEsService {
    void initEs() throws IOException;
}

其余IDestinationEsService、INoteEsService、IUserinfoEsService也要创建,就不写出了。

(5)添加service.impl.StrategyEsServiceImpl实现类

// 标记为 Spring 服务组件,注册到容器中供注入使用
@Service
public class StrategyEsServiceImpl implements IStrategyEsService {

    // 注入远程攻略服务(Feign 客户端),用于从其他微服务获取 MySQL 中的攻略数据
    @Autowired
    private RemoteStrategyService remoteStrategyService;

    // 注入 Elasticsearch 高级客户端,用于操作 ES 索引和文档
    @Autowired
    public ElasticsearchClient client;

    // 定义攻略索引名称常量
    public final String INDEX_NAME = "strategy";

    @Override
    public void initEs() throws IOException {

        // ========== 第一步:删除已有索引(相当于"删库") ==========
        // 检查 strategy 索引是否已存在
        BooleanResponse exists = client.indices().exists(e -> e.index(INDEX_NAME));
        // 如果存在,则删除该索引,确保每次初始化都是干净的状态
        if (exists.value()) {
            client.indices().delete(e -> e.index(INDEX_NAME));
        }

        // ========== 第二步:创建索引并设置映射(相当于"建库建表") ==========
        CreateIndexRequest createReq = CreateIndexRequest.of(r -> r.index(INDEX_NAME)
                // 设置索引分片数为 1,副本数为 1
                .settings(s -> s.numberOfShards("1").numberOfReplicas("1"))
                // 设置字段映射:定义每个字段的类型和分词器
                .mappings(m -> m
                        // id 字段,类型为 long
                        .properties("id", p -> p.long_(l -> l))
                        // title 字段,类型为 text,使用 ik_max_word 分词器(最细粒度切分,适合搜索)
                        .properties("title", p -> p.text(t -> t.analyzer("ik_max_word")))
                        // subTitle 字段,同上
                        .properties("subTitle", p -> p.text(t -> t.analyzer("ik_max_word")))
                        // summary 字段,同上
                        .properties("summary", p -> p.text(t -> t.analyzer("ik_max_word")))));
        // 执行创建索引请求
        client.indices().create(createReq);
        System.out.println("建库成功");

        // ========== 第三步:从 MySQL 查询数据并同步到 ES(相当于"导数据") ==========
        // 通过 Feign 远程调用攻略微服务,获取所有攻略数据
        List<Strategy> list = remoteStrategyService.list("inner").getData();

        // 遍历每条攻略数据,逐条写入 ES
        for (Strategy strategy : list) {
            // 创建 ES 文档对象
            StrategyEs es = new StrategyEs();
            // 将 Strategy 中同名的属性值拷贝到 StrategyEs 中(如 id、title、subTitle 等)
            BeanUtils.copyProperties(strategy, es);
            // 将文档写入 ES 索引,指定索引名和文档 id
            client.index(i -> i.index(INDEX_NAME).id(es.getId().toString()).document(es));
        }
    }
}
initEs() 执行流程:

1. 删旧索引 → 如果 strategy 索引已存在,先删除
2. 建新索引 → 创建索引,设置分片、副本、字段类型和分词器
3. 查 MySQL → 通过 Feign 远程调用获取攻略数据
4. 写 ES    → 逐条将数据转换为 StrategyEs 并写入 ES 索引

其余DestinationEsServiceImpl、NoteEsServiceImpl、UserinfoEsServiceImpl也要创建,就不写出了。

(6)添加controller.admin.SearchAdminController类

// 标记为 REST 控制器,所有方法的返回值会自动序列化为 JSON(或空响应体)
@RestController
public class SearchAdminController {

    // 注入攻略 ES 服务,负责攻略索引的初始化和管理
    @Autowired
    private IStrategyEsService strategyEsService;

    // 注入目的地 ES 服务,负责目的地索引的初始化和管理
    @Autowired
    private IDestinationEsService destinationEsService;

    // 注入笔记 ES 服务,负责笔记索引的初始化和管理
    @Autowired
    private INoteEsService noteEsService;

    // 注入用户信息 ES 服务,负责用户信息索引的初始化和管理
    @Autowired
    private UserinfoEsServiceImpl userinfoEsServiceImpl;

    // 暴露 /esInit 接口,访问后一键初始化所有 ES 索引
    @RequestMapping("/esInit")
    public void esInit() throws IOException {
        // 创建攻略索引
        strategyEsService.initEs();
        // 创建目的地索引
        destinationEsService.initEs();
        // 创建笔记索引
        noteEsService.initEs();
        // 创建用户信息索引
        userinfoEsServiceImpl.initEs();
    }
}

二、全文检索和高亮展示

​ 发送请求带了两个参数,type指定查询的内容(攻略/目的地/游记/用户),keyword是输入的内容,用于高亮显示。而keyword是继承自父类QueryObject而来的。

1、创建query.SearchQuery类

@Data
public class SearchQuery extends QueryObject {
    private int type = -1;// -1全部、1攻略、2游记、3找人(0目的地、4问答)
}

2、创建controller.SearchController类

// 标记为 REST 控制器,处理搜索相关的 HTTP 请求
@RestController
public class SearchController {

    // 注入搜索服务,封装了 ES 高亮查询的通用逻辑
    @Autowired
    private ISearchService searchService;

    // 搜索入口接口,根据 type 参数决定搜索范围
    // type=1 搜攻略,type=2 搜游记,type=3 搜用户,type=-1 或不传则搜全部
    @GetMapping("/search")
    private R<?> search(SearchQuery qo) throws IOException, InvocationTargetException, IllegalAccessException {
        switch (qo.getType()) {
            case 1:
                // 只搜攻略
                return R.ok(searchStrategy(qo));
            case 2:
                // 只搜游记
                return R.ok(searchNote(qo));
            case 3:
                // 只搜用户
                return R.ok(searchUser(qo));
            default: // -1 或不传 type 时,搜索全部类型
                return R.ok(searchAll(qo));
        }
    }

    // 查询全部:同时查询用户、游记、攻略、目的地四个索引,汇总结果返回
    private Object searchAll(SearchQuery qo) throws IOException, InvocationTargetException, IllegalAccessException {

        // 综合搜索时限制每类最多返回 5 条,避免数据量过大
        qo.setPageSize(5);

        // 查询用户索引,高亮字段:昵称、简介、城市
        Page<UserInfo> UserPage = searchService.searchWithHighLight("userinfo", UserInfo.class, UserInfoEs.class, qo,
                "nickname", "info", "city");
        // 查询笔记索引,高亮字段:标题、摘要
        Page<Note> NotePage = searchService.searchWithHighLight("note", Note.class, NoteEs.class, qo,
                "title", "summary");
        // 查询攻略索引,高亮字段:标题、副标题、摘要
        Page<Strategy> StrategyPage = searchService.searchWithHighLight("strategy", Strategy.class, StrategyEs.class, qo,
                "title", "subTitle", "summary");
        // 查询目的地索引,高亮字段:名称、介绍
        Page<Destination> DestinationPage = searchService.searchWithHighLight("destination", Destination.class, DestinationEs.class, qo,
                "name", "info");

        // 将四类结果的总数相加,作为综合搜索的总命中数
        long total = UserPage.getTotalElements() + NotePage.getTotalElements() +
                StrategyPage.getTotalElements() + DestinationPage.getTotalElements();

        // 将四类搜索结果汇总到一个 Map 中返回给前端
        Map map = new HashMap();
        map.put("total", total);
        map.put("users", UserPage.getContent());
        map.put("notes", NotePage.getContent());
        map.put("strategies", StrategyPage.getContent());
        map.put("dests", DestinationPage.getContent());
        return map;
    }

    // 单独搜索用户:在 userinfo 索引中查询,高亮昵称、简介、城市字段
    private Object searchUser(SearchQuery qo) throws IOException, InvocationTargetException, IllegalAccessException {
        Page<UserInfo> page = searchService.searchWithHighLight("userinfo", UserInfo.class, UserInfoEs.class, qo,
                "nickname", "info", "city");
        return page;
    }

    // 单独搜索游记:在 note 索引中查询,高亮标题、摘要字段
    private Object searchNote(SearchQuery qo) throws IOException, InvocationTargetException, IllegalAccessException {
        Page<Note> page = searchService.searchWithHighLight("note", Note.class, NoteEs.class, qo,
                "title", "summary");
        return page;
    }

    // 单独搜索攻略:在 strategy 索引中查询,高亮标题、副标题、摘要字段
    private Object searchStrategy(SearchQuery qo) throws IOException, InvocationTargetException, IllegalAccessException {
        Page<Strategy> page = searchService.searchWithHighLight("strategy", Strategy.class, StrategyEs.class, qo,
                "title", "subTitle", "summary");
        return page;
    }
}

3、创建service.ISearchService接口

public interface ISearchService {

    /**
     * 通用高亮搜索方法
     *
     * @param indexName ES 索引名称(如 "strategy"、"note"、"userinfo" 等)
     * @param clazz     返回结果的实体类类型(如 Strategy.class),用于将搜索结果转换为业务对象
     * @param esClazz   ES 文档类类型(如 StrategyEs.class),对应 ES 索引中的字段结构
     * @param qo        搜索条件对象,包含关键词、页码、每页条数等查询参数
     * @param fields    需要高亮显示的字段名(可变参数),如 "title"、"summary" 等
     * @param <T>       返回结果的泛型,对应业务实体类
     * @param <K>       ES 文档类的泛型,对应 ES 索引中的文档结构
     * @return 分页结果,包含匹配的数据列表和总记录数
     * @throws IOException                  ES 请求异常
     * @throws InvocationTargetException    反射调用异常
     * @throws IllegalAccessException       反射访问异常
     */
    <T, K> Page<T> searchWithHighLight(String indexName, Class<T> clazz,
                                       Class<K> esClazz, SearchQuery qo, String... fields)
            throws IOException, InvocationTargetException, IllegalAccessException;
}

4、创建service.impl.SearchServiceImpl实现类

// 标记为 Spring 服务组件,实现通用的高亮搜索逻辑
@Service
public class SearchServiceImpl implements ISearchService {

    // 注入 ES 高级客户端,用于执行搜索请求
    @Autowired
    private ElasticsearchClient client;
    // 注入远程攻略服务(Feign 客户端),根据 ES 返回的 id 回查 MySQL 获取完整数据
    @Autowired
    private RemoteStrategyService remoteStrategyService;
    // 注入远程笔记服务
    @Autowired
    private RemoteNoteService remoteNoteService;
    // 注入远程用户信息服务
    @Autowired
    private RemoteUserInfoService remoteUserInfoService;
    // 注入远程目的地服务
    @Autowired
    private RemoteDestinationService remoteDestinationService;

    @Override
    public <T, K> Page<T> searchWithHighLight(String indexName, Class<T> clazz, Class<K> esClazz,
                                              SearchQuery qo, String... fields) throws IOException, InvocationTargetException, IllegalAccessException {

        // ========== 第一步:构建高亮配置 ==========
        // 将传入的字段名数组转换为 ES 高亮字段 Map,每个字段使用默认高亮配置
        Map<String, HighlightField> fieldMap = new HashMap<>();
        for (String field : fields) {
            fieldMap.put(field, HighlightField.of(h -> h));
        }
        // 设置高亮标签:匹配到的关键词用红色 <span> 包裹
        Highlight highlight = Highlight.of(h -> h.preTags("<span style='color:red'>")
                        .postTags("</span>")
                        .fields(fieldMap));

        // ========== 第二步:执行 ES 搜索请求 ==========
        SearchResponse<K> resp = client.search(sh -> sh.index(indexName)
                // 设置分页起始位置:(当前页 - 1) * 每页条数
                .from((qo.getCurrentPage() - 1) * qo.getPageSize())
                // 设置每页返回条数
                .size(qo.getPageSize())
                // 使用 multi_match 查询,在指定字段中搜索关键词,使用 ik_max_word 分词器
                .query(q -> q.multiMatch(m -> m.query(qo.getKeyword()).fields(Arrays.asList(fields)).analyzer("ik_max_word")))
                // 应用高亮配置
                .highlight(highlight), esClazz);

        // ========== 第三步:解析 ES 搜索结果 ==========
        // 获取命中的元数据(包含总数和具体命中列表)
        HitsMetadata<K> hits = resp.hits();
        // 获取命中的总记录数
        long total = hits.total().value();
        // 获取当前页的命中列表
        List<Hit<K>> hits2 = hits.hits();

        // 用于存放最终的业务实体列表
        List<T> musqlList = new ArrayList<>();

        // ========== 第四步:根据 ES 返回的 id 回查 MySQL 获取完整数据 ==========
        for (Hit<K> hit : hits2) {
            // 从 ES 命中结果中获取文档 id(即 MySQL 中的主键)
            long id = Long.valueOf(hit.id());

            // 根据索引名称,调用对应的远程服务获取 MySQL 中的完整数据
            T t = null;
            if ("strategy".equals(indexName)) {
                // 攻略索引 → 调用攻略服务获取完整攻略数据
                t = (T) remoteStrategyService.getOne(id, "inner").getData();
            } else if ("note".equals(indexName)) {
                // 笔记索引 → 调用笔记服务获取完整笔记数据
                t = (T) remoteNoteService.getOne(id, "inner").getData();
            } else if ("userinfo".equals(indexName)) {
                // 用户索引 → 调用用户服务获取完整用户数据
                t = (T) remoteUserInfoService.getOne(id, "inner").getData();
            } else if ("destination".equals(indexName)) {
                // 目的地索引 → 调用目的地服务获取完整目的地数据
                t = (T) remoteDestinationService.getOne(id, "inner").getData();
            }

            // ========== 第五步:将 ES 高亮结果覆盖到业务实体对应字段 ==========
            // 获取高亮字段 Map:key 是字段名,value 是高亮后的文本片段列表
            Map<String, List<String>> highLightMap = hit.highlight();
            for (String key : highLightMap.keySet()) {
                // 将同一字段的高亮片段拼接成完整字符串
                List<String> list = highLightMap.get(key);
                StringBuilder sb = new StringBuilder();
                for (String s : list) {
                    sb.append(s);
                }
                String value = sb.toString();
                // 通过反射将高亮后的值设置到业务实体对应的属性上,替换原始文本
                BeanUtils.setProperty(t, key, value);
            }

            // 将处理好的业务实体加入结果列表
            musqlList.add(t);
        }

        // ========== 第六步:封装分页结果返回 ==========
        // 构建分页对象(页码从 0 开始,所以 currentPage - 1)
        Pageable pageable = PageRequest.of(qo.getCurrentPage() - 1, qo.getPageSize());
        // 将结果列表、分页信息和总记录数封装为 Spring Data 的 Page 对象返回
        return new PageImpl<>(musqlList, pageable, total);
    }
}
searchWithHighLight() 执行流程:

1. 构建高亮配置 → 设置哪些字段需要高亮、高亮标签样式
2. 执行 ES 搜索 → multi_match 多字段匹配 + 分页 + 高亮
3. 解析命中结果 → 获取总数和当前页的文档列表
4. 回查 MySQL   → 根据 ES 返回的 id,通过 Feign 远程调用获取完整业务数据
5. 覆盖高亮文本 → 将 ES 返回的高亮片段拼接到业务实体对应字段上
6. 封装分页返回 → 组装为 Spring Data Page 对象返回给 Controller
posted on 2026-08-23 20:18  冬冬咚  阅读(11)  评论(0)    收藏  举报