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
浙公网安备 33010602011771号