为SpringCloud项目集成ElasticSearch服务实现高效搜索功能

如何为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 # 基础配置(Nacos + ES)
├── 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 转换

  1. 索引实体 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,主键,自增
*/
@Id
private Integer drugId;

/**
* 药品名称,例如“阿莫西林胶囊”
*/
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String name;

// ........
}
  1. 请求模型 DrugPageRequest
  • 前端传过来的查询条件封装:关键字、排序、分页参数等;
1
2
3
4
5
6
7
8
@Getter
@Setter
public class DrugPageRequest extends BasePageRequest {

private String keyword; // 搜索关键词
private String sortField; // 排序字段

}
  1. 返回展示模型 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;
// ............
}
  1. 转换器 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);
}
  1. 高亮字段常量类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";
// ....................

/**
* 获取所有高亮字段
*
* @return 高亮字段数组
*/
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);
});

这几行是 这个类最核心、最有“技巧性”的代码

  1. 拿原始的 _source

    1
    DrugIndex content = hit.getContent();
  2. 用 Hutool 把 DrugIndex 转成 JSONObject,方便动态修改字段:

    1
    JSONObject obj = JSONUtil.parseObj(content);
  3. 遍历当前命中的高亮字段:

    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> 胶囊"

  4. 再把修改后的 JSONObject 转回 DrugIndex

  5. 最终返回的 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);
}
  1. 获取所有需要高亮的字段:
  2. 为每个字段创建一个 HighlightField
  3. 包装成 Highlight 对象,再构造 HighlightQuery 返回给 lightQuery 使用。

END