如何为SpringCloud项目集成ElasticSearch服务实现高效搜索功能
项目地址
0. 前提
- 你的项目基于SpringCloud微服务结构构建
- 你的项目中已构建网关服务
- 你的项目中有写入ES的数据,ES只做搜索功能,读取数据的功能需要你已实现
- 你已经在docker中成功运行ES的容器
1. 整体结构与架构思想
搜索作为独立微服务
- 在aimin项目中,搜索能力被单独拆成一个微服务:
aimin-search
数据写入侧:
- MySQL中存放药品的结构化数据(在
aimin-drug微服务维护);
- Canal 监听 MySQL binlog,把药品变更同步到Elasticsearch索引(
aimin-canal 微服务负责);
数据查询侧:
aimin-search 微服务对外提供药品搜索接口;
- 通过Elasticsearch按关键字、多字段进行检索,并做高亮、分页等处理;
- 前端(管理后台、小程序等)只需要调用
aimin-search 的REST接口即可,不直接接触ES。
请求链路:
1 2 3 4 5
| Client->>Gateway: /aimin-search/drug/search?keyword=阿莫西林 Gateway->>Search: 转发 HTTP 请求 Search->>ES: 组合查询条件,执行搜索 ES-->>Search: 返回搜索结果 + 高亮 Search-->>Client: 统一封装后的药品列表 VO
|
项目整体目录结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| aimin-search ├── AiminSearchApplication.java ├── bootstrap.yml ├── pom.xml ├── drug │ ├── controller │ │ └── DrugController.java │ ├── service │ │ └── DrugService.java │ ├── model │ │ ├── request │ │ │ └── DrugPageRequest.java │ │ ├── vo │ │ │ └── DrugVO.java │ │ └── convertor │ │ └── DrugConvertor.java │ └── constants │ └── DrugHighlightFields.java
|
2. 引入依赖
1 2 3 4
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>
|
3. 配置层:bootstrap.yml与Nacos配置
记得配置ES的地址就行了
1 2 3 4 5 6 7 8 9 10 11 12 13
| server: port: 8085 servlet: context-path: /aimin-search
spring: application: name: aimin-search profiles: active: dev
elasticsearch: uris: http://localhost:9200
|
4. 领域模型:索引实体 & VO 转换
- 索引实体
DrugIndex
- 对应 Elasticsearch 中的文档结构;
- 字段包括:
id / drugId / name / genericName / description / manufacturer / categoryId / price / prescription / status 等;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Data @Document(indexName = "drug_index") public class DrugIndex implements Serializable {
@Id private Integer drugId;
@Field(type = FieldType.Text, analyzer = "ik_max_word") private String name;
}
|
- 请求模型
DrugPageRequest
- 前端传过来的查询条件封装:关键字、排序、分页参数等;
1 2 3 4 5 6 7 8
| @Getter @Setter public class DrugPageRequest extends BasePageRequest {
private String keyword; private String sortField;
}
|
- 返回展示模型
DrugVO
- 返回给前端的视图对象,既包含基础信息,也可以包含高亮后的字段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Data public class DrugVO implements Serializable {
@Serial private static final long serialVersionUID = 1L; private Integer drugId;
private String name; }
|
- 转换器
DrugConvertor
- 把ES中查询到的 DrugIndex 列表,自动转换成你项目里返回给前端的DrugVO列表
1 2 3 4 5
| @Mapper(componentModel = MappingConstants.ComponentModel.SPRING, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS) public interface DrugConvertor { List<DrugVO> index2VO(List<DrugIndex> drugIndices); }
|
- 高亮字段常量类
DrugHighlightFields
- 统一管理“哪些字段需要高亮显示”的常量,用于 ES 搜索结果的高亮处理。
- 提供统一方法获取全部高亮字段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public class DrugHighlightFields { public static final String NAME = "name";
public static String[] getAllFieldArray() { return new String[]{NAME, DESCRIPTION, GENERIC_NAME, BRAND, MANUFACTURER, DOSAGE_FORM}; } public static List<String> getAllFieldList() { return Arrays.asList(getAllFieldArray()); } }
|
5. Controller 层:对外提供药品搜索接口
DrugController提供两个接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @RestController @RequestMapping("/search/drug") @RequiredArgsConstructor @Tag(name = "药品查询", description = "基于ES的药品查询") public class DrugController {
private final DrugService drugService;
@Operation(summary = "药品分页查询", description = "分页获取药品列表,支持关键字查询") @PostMapping public Result<?> searchPage(@RequestBody @Valid DrugPageRequest request) { PageResp<DrugIndex> pageResp = drugService.pageQuery(request); return Result.success(pageResp); }
@Operation(summary = "药品分页高亮查询", description = "分页获取药品列表并高亮展示,支持关键字查询") @PostMapping("/light") public Result<?> light(@RequestBody @Valid DrugPageRequest request) { PageResp<DrugIndex> pageResp = drugService.lightQuery(request); return Result.success(pageResp); } }
|
6. Service 层 DrugService
6.1 启动时自动创建索引:@PostConstruct init()
使用 @PostConstruct,在服务启动时自动检测药品索引是否存在,不存在则基于DrugIndex实体注解自动创建索引和映射。
1 2 3 4 5 6 7
| @PostConstruct public void init(){ IndexOperations indexOperations = elasticsearchTemplate.indexOps(DrugIndex.class); if(!indexOperations.exists()){ indexOperations.createWithMapping(); } }
|
6.2 普通分页查询:pageQuery
基于ES的 NativeQueryBuilder 构建 multi_match 查询,在药品名称、描述、通用名、品牌、厂家、剂型等多个字段上进行模糊匹配。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public PageResp<DrugIndex> pageQuery(DrugPageRequest request) { NativeQueryBuilder nativeQueryBuilder = new NativeQueryBuilder();
if (StrUtil.isNotBlank(request.getKeyword())) { nativeQueryBuilder.withQuery(queryBuilder -> queryBuilder.multiMatch(matchQueryBuilder -> matchQueryBuilder.query(request.getKeyword()) .fields(DrugHighlightFields.getAllFieldList()) )); }
if (null != request.getPageSize() && null != request.getCurrentPage()) { nativeQueryBuilder.withPageable(PageRequest.of(request.getCurrentPage(), request.getPageSize())).build(); }
NativeQuery nativeQuery = nativeQueryBuilder.build(); SearchHits<DrugIndex> searchHits = elasticsearchTemplate.search(nativeQuery, DrugIndex.class); List<DrugIndex> list = searchHits.stream().map(SearchHit::getContent).toList();
return PageResp.of(request.getCurrentPage(), request.getPageSize(), list, (int)searchHits.getTotalHits()); }
|
1)构建查询(multi_match)
1 2 3 4 5 6 7
| if (StrUtil.isNotBlank(request.getKeyword())) { nativeQueryBuilder.withQuery(queryBuilder -> queryBuilder.multiMatch(matchQueryBuilder -> matchQueryBuilder.query(request.getKeyword()) .fields(DrugHighlightFields.getAllFieldList()) )); }
|
2)分页参数
1 2 3
| if (null != request.getPageSize() && null != request.getCurrentPage()) { nativeQueryBuilder.withPageable(PageRequest.of(request.getCurrentPage(), request.getPageSize())).build(); }
|
3)执行查询 & 提取结果
1 2 3
| NativeQuery nativeQuery = nativeQueryBuilder.build(); SearchHits<DrugIndex> searchHits = elasticsearchTemplate.search(nativeQuery, DrugIndex.class); List<DrugIndex> list = searchHits.stream().map(SearchHit::getContent).toList();
|
4)封装成统一分页响应
1
| return PageResp.of(request.getCurrentPage(), request.getPageSize(), list, (int)searchHits.getTotalHits());
|
6.3 带高亮的分页搜索:lightQuery
- 在
multi_match 的基础上增加高亮配置,对多个字段同时开启高亮。
- 使用
Hutool JSONObject 对命中的 DrugIndex 对象进行字段覆盖,把 ES返回的高亮片段替换到实体字段中。
- 最终返回的
DrugIndex 列表中的字段已带高亮 HTML标签,方便前端直接展示搜索高亮效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public PageResp<DrugIndex> lightQuery(@Valid DrugPageRequest request) { HighlightQuery highlightQuery = buildHighlightQuery(); NativeQuery nativeQuery = new NativeQueryBuilder() .withQuery(query -> query.multiMatch(multiMatch -> multiMatch.query(request.getKeyword()) .fields(DrugHighlightFields.getAllFieldList()))) .withHighlightQuery(highlightQuery) .withPageable(PageRequest.of(request.getCurrentPage(), request.getPageSize())) .build();
SearchHits<DrugIndex> searchResult = elasticsearchTemplate.search(nativeQuery, DrugIndex.class);
ArrayList<DrugIndex> indices = new ArrayList<>(); searchResult.forEach(hit -> { DrugIndex content = hit.getContent(); JSONObject obj = JSONUtil.parseObj(content); hit.getHighlightFields().forEach((field, highlights) -> { obj.set(field,highlights.getFirst()); }); DrugIndex bean = JSONUtil.toBean(obj, DrugIndex.class); indices.add(bean); });
return PageResp.of(request.getCurrentPage(), request.getPageSize(), indices, (int)searchResult.getTotalHits()); }
|
1)构建高亮查询:buildHighlightQuery()
1
| HighlightQuery highlightQuery = buildHighlightQuery();
|
2)构建 NativeQuery(带高亮)
1 2 3 4 5 6 7 8
| NativeQuery nativeQuery = new NativeQueryBuilder() .withQuery(query -> query.multiMatch(multiMatch -> multiMatch.query(request.getKeyword()) .fields(DrugHighlightFields.getAllFieldList()))) .withHighlightQuery(highlightQuery) .withPageable(PageRequest.of(request.getCurrentPage(), request.getPageSize())) .build();
|
对比 pageQuery:
- 查询条件:一样,都是 multi_match 搜多个字段
- 差别:多了
.withHighlightQuery(highlightQuery),告诉 ES:这些字段命中后给我做高亮
3)执行查询
1
| SearchHits<DrugIndex> searchResult = elasticsearchTemplate.search(nativeQuery, DrugIndex.class);
|
这一步返回的 SearchHit<DrugIndex> 中不仅有 _source,还有高亮字段内容。
4)处理高亮结果(这块就是核心逻辑)
1 2 3 4 5 6 7 8 9 10 11
| ArrayList<DrugIndex> indices = new ArrayList<>();
searchResult.forEach(hit -> { DrugIndex content = hit.getContent(); JSONObject obj = JSONUtil.parseObj(content); hit.getHighlightFields().forEach((field, highlights) -> { obj.set(field,highlights.getFirst()); }); DrugIndex bean = JSONUtil.toBean(obj, DrugIndex.class); indices.add(bean); });
|
这几行是 这个类最核心、最有“技巧性”的代码:
拿原始的 _source:
1
| DrugIndex content = hit.getContent();
|
用 Hutool 把 DrugIndex 转成 JSONObject,方便动态修改字段:
1
| JSONObject obj = JSONUtil.parseObj(content);
|
遍历当前命中的高亮字段:
1 2 3
| hit.getHighlightFields().forEach((field, highlights) -> { obj.set(field,highlights.getFirst()); });
|
field 是字段名:比如 name / description
highlights.getFirst() 是 ES 返回的高亮片段字符串,比如:"<em>阿莫西林</em> 胶囊"
这里做的是:
直接用高亮字符串覆盖原来字段的值
相当于把 DrugIndex.name 从 阿莫西林胶囊 替换为 "<em>阿莫西林</em> 胶囊"
再把修改后的 JSONObject 转回 DrugIndex:
最终返回的 DrugIndex 列表里的字段,已经是带高亮 HTML 的字符串,可以直接给前端展示。
5)封装分页结果
1
| return PageResp.of(request.getCurrentPage(), request.getPageSize(), indices, (int)searchResult.getTotalHits());
|
和普通查询一样,只不过 list 换成了带高亮后的 indices。
6.4 构建高亮字段配置buildHighlightQuery
高亮字段统一管理(buildHighlightQuery + DrugHighlightFields)
buildHighlightQuery
1 2 3 4 5 6 7 8 9
| private HighlightQuery buildHighlightQuery() { List<String> allFieldList = DrugHighlightFields.getAllFieldList(); List<HighlightField> highlightFieldList = new ArrayList<>(); allFieldList.forEach(field -> { highlightFieldList.add(new HighlightField(field)); }); Highlight highlight = new Highlight(highlightFieldList); return new HighlightQuery(highlight, DrugIndex.class); }
|
- 获取所有需要高亮的字段:
- 为每个字段创建一个
HighlightField:
- 包装成
Highlight 对象,再构造 HighlightQuery 返回给 lightQuery 使用。
END