Elasticsearch+MQ搜索服务实战解析
在项目的独立搜索服务中,为了提高搜索性能,常常使用ES来提高搜索效率
但是,常常数据在数据库,这时我们需要把数据库中(这里以mysql为例)的数据先读取到ES中,然后再通过ES去搜索内容。
同时如果通过content-service服务(短剧管理服务)更新或删除数据,使用MQ消息队列去发送消息给搜索服务,让搜索服务去将数据同步到ES中
本篇文章只讲解ES服务工作原理,不做细节配置说明
如果还不会安装和基础使用的可以参考以下
Elasticsearch的安装与使用 #掘金文章# https://juejin.cn/post/7667778542090649609
1.1项目开始
第一步:首先配置yml文件
第二步:配置依赖
1.data-elasticsearch依赖,这是ES核心依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
2.这个是负责将json和java对象相互转换的依赖
xml
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
3.中文转换成拼音,然后把拼音也存进es库中,方便后面拼音查询
xml
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
4.将Drama对象转换成DramaDocument对象,用到的工具hutool来创建自己的工具类实现对象之间的转换
xml
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.47</version>
</dependency>
5.openfeign用于请求远程接口,来获取数据库中的数据
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
6.LoadBalancer依赖,有openfeign就必须有负载均衡(而有复杂均衡不一定有openfeign)
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
7.MQ依赖,通过contrent-service服务来更新或删除数据时,发送MQ消息队列到搜索服务,让搜索服务将更新或删除的数据同步到ES中
xml
<!-- RocketMQ Spring Boot Starter:消息队列,Spring Boot 3.x 对应 2.3.x -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
8.其他非必要依赖,这里不做介绍
第三步:写工具类
1.为了存储必须将Drama存储为DramaDocument,因为Drama是支持mybatis查询的注解,而DramaDocument是支持ES查询的注解
typescript
package com.javasm.bunnyflix.utils;
import cn.hutool.core.bean.BeanUtil;
import com.javasm.bunnyflix.document.DramaDocument;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import java.util.List;
public class ConvertUtil {
/**
* 批量转换 Drama 列表为 DramaDocument 列表
*/
public static List<DramaDocument> covertToDocument(List<DramaInfo> dramaList) {
if (dramaList != null) {
return dramaList.stream()
.map(ConvertUtil::convertSingle)
.toList();
}
return null;
}
/**
* 单个 Drama 转换为 DramaDocument(含拼音处理)
*/
public static DramaDocument convertSingle(DramaInfo drama) {
DramaDocument document = new DramaDocument();
BeanUtil.copyProperties(drama, document);
// ===== 标题拼音:生成分词拼音组合 =====
document.setTitlePinyin(PinyinUtils.toPinyinCombinations(document.getTitle()));
document.setTitlePinyinFist(PinyinUtils.toFirstLetters(document.getTitle()));
// ===== 简介拼音:生成分词拼音组合 =====
// document.setDescriptionPinyin(PinyinUtils.toPinyinCombinations(document.getDescription()));
// ===== 导演拼音:生成分词拼音组合 =====
// document.setDirectorPinyin(PinyinUtils.toPinyinCombinations(document.getDirector()));
// ===== 演员拼音:生成分词拼音组合 =====
// document.setMainActorsPinyin(PinyinUtils.toPinyinCombinations(document.getMainActors()));
return document;
}
}
首先通过convertSingle这个方法将一个一个的Drama转换为DramaDocument(DramaDocument比Drama多了几个拼音字段,用于在初始话的时候利用pinyin工具类将需要的字段存入DramaDocument然后添加到ES中),方便后面添加调用,同时第一次初始话的时候增加了covertToDocument方法,是将每个Drama排队执行convertSingle,可以用于第一次初始化操作,将所有的短剧信息导入到ES中
2.用于存入ES中时,对具体的搜索内容进行排序,高亮等处理。
typescript
package com.javasm.bunnyflix.utils;
import co.elastic.clients.elasticsearch._types.query_dsl.*;
import com.javasm.bunnyflix.document.DramaDocument;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.HighlightQuery;
import org.springframework.data.elasticsearch.core.query.highlight.Highlight;
import org.springframework.data.elasticsearch.core.query.highlight.HighlightField;
import org.springframework.data.elasticsearch.core.query.highlight.HighlightParameters;
import java.util.ArrayList;
import java.util.List;
public class DramaSearchUtil {
//这两个方法是对查询到的那些字段进行高亮处理的工具方法
public static HighlightQuery createDramaHighlight(){
return createHighlight("title","description","mainActors","director");
}
public static HighlightQuery createHighlight(String... fields){
if (fields != null){
List<HighlightField> highlightFieldList = new ArrayList<>();
for (String fieldName : fields){
highlightFieldList.add(new HighlightField(fieldName));
}
Highlight highlight = new Highlight(
HighlightParameters.builder()
.withPreTags("<em>")
.withPostTags("</em>")
.build(),
highlightFieldList);
return new HighlightQuery(highlight, DramaDocument.class);
}
return null;
}
// 创建基本查询,舍弃
public static Query createDramaBaseQuery(String keyword){
return createBaseQuery(keyword,"title","description","mainActors","director");
}
// 创建拼音和中文基本查询
public static Query createDramaPinyinBaseQuery(String keyword){
return createBaseQuery(keyword,"title","description","mainActors","director" ,"titlePinyin","titlePinyinFist","descriptionPinyin","mainActorsPinyin","directorPinyin");
}
//是上面方法的工具方法,用于处理创建拼音中文基本查询
public static Query createBaseQuery(String keyword,String... fields){
List<String> fieldsList = List.of(fields);
return Query.of(q->q.bool(b->{
b.must(m->m.multiMatch(mm->mm
.fields(fieldsList)
.query(keyword)
.operator(Operator.And)
));
//只保留 status== 1的文档,其余的会被过滤掉,不显示
b.filter(f->f.term(t->t.field("status").value(1)));
//如果有其他属性要过滤,再继续写
//b.filter(f->f.term(t->t.field("isDel").value(0)));
return b;
}));
}
//用于处理排序和高亮
public static Query createDramaScoreQuery(Query baseQuery){
return Query.of(q->
q.functionScore(fs->fs
.query(baseQuery)
.functions(fn->fn
.fieldValueFactor(fvf->fvf.field("playCount")
.factor(1.5)//加权系数
.modifier(FieldValueFactorModifier.Log1p)//平滑函数
.missing(0.0) //属性缺失的时候,给默认值
)
)
.functions(fn->fn
.fieldValueFactor(fvf->fvf.field("rating")
.factor(10.0)//加权系数
.modifier(FieldValueFactorModifier.Log1p)
.missing(0.0)
)
)
.boostMode(FunctionBoostMode.Multiply)//评分相乘
.scoreMode(FunctionScoreMode.Multiply)//多函数组合模式
));
}
//用于处理高亮字段
public static List<DramaDocument> convertHits2Document(SearchHits<DramaDocument> searchHits){
return searchHits.getSearchHits().stream()
.map(hit -> {
//获取文档对象--查询结果
DramaDocument document = hit.getContent();
//循环处理高亮的文字
hit.getHighlightFields().forEach((field, values) -> {
//获取高亮的文字
String highLightText = values.get(0);
switch (field) {
case "title" -> document.setTitle(highLightText);
// case "description" -> document.setDescription(highLightText);
// case "mainActors" -> document.setMainActors(highLightText);
// case "director" -> document.setDirector(highLightText);
}
});
return document;
}).toList();
}
}
3.拼音工具类,将分词器分词后的结构,进行拼音加工,然后存入DramaDocument,每个属性对应一个拼音属性,除此之外还对部分网络词汇进行拓展。
arduino
package com.javasm.bunnyflix.utils;
import com.alibaba.druid.util.StringUtils;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
import net.sourceforge.pinyin4j.format.exception.BadHanyuPinyinOutputFormatCombination;
import java.util.*;
/**
* 拼音转换工具类
* <p>
* 核心能力:
* - 中文 → 全拼(连续字符串)
* - 中文 → 拼音首字母
* - 中文 → 分词拼音组合(用于弹性拼音搜索)
* </p>
*/
public class PinyinUtils {
private static final HanyuPinyinOutputFormat FORMAT;
/**
* 短剧常用词汇词典(用于正向最大匹配分词)
* 覆盖短剧标题、简介、演员中的常见组合词
*/
private static final Set<String> DRAMA_DICT = Set.of(
// ---- 短剧名热词 ----
"总裁", "替身", "前妻", "闪婚", "老公", "大佬", "穿越", "驸马",
"重生", "卧底", "霸总", "离婚", "追妻", "真相", "隐秘",
"外星人", "错嫁", "千金", "归来", "将军", "女扮男装",
"时光", "旅馆", "官宣", "甜心", "烘焙", "徒弟", "魔尊",
"整容", "女王", "深宫", "保安", "队长", "兵王", "年下",
"弟弟", "庶女", "攻略", "锦绣", "说谎", "同桌", "妖怪",
"复仇", "逆袭", "豪门", "契约", "先婚后爱", "替身文学",
"废柴", "翻身", "特种兵", "保镖", "热血", "爆笑",
"啼笑皆非", "火葬场", "白月光",
// ---- 演员名拆解 ----
"王一博", "赵丽颖", "肖战", "杨幂", "陈伟霆", "迪丽热巴",
"龚俊", "刘亦菲", "成毅", "杨洋", "唐嫣", "罗晋",
"张译", "王凯", "谭松韵", "白敬亭", "赵露思", "吴磊",
"孙俪", "陈晓", "袁姗姗", "任嘉伦", "许凯", "刘昊然",
"张子枫", "彭昱畅", "杨紫", "李现", "雷佳音", "易烊千玺",
"周冬雨", "林更新", "张新成", "袁冰妍", "刘学义", "吴京",
"张涵予", "李晨", "宋威龙", "王安宇", "景甜", "冯绍峰",
"陈钰琪", "周迅", "段奕宏", "王传君", "虞书欣", "丁禹兮",
"周也", "朱一龙", "张彬彬",
// ---- 停用词(分词时单独成词,组合时会跳过) ----
"的", "了", "在", "是", "我", "他", "她", "你",
"不", "也", "就", "都", "和", "与", "之", "上",
"下", "被", "把", "让", "给", "到", "有", "看",
"大", "小", "老", "第"
);
/**
* 正向最大匹配的最大词长
*/
private static final int MAX_WORD_LENGTH = 5;
static {
FORMAT = new HanyuPinyinOutputFormat();
FORMAT.setCaseType(HanyuPinyinCaseType.LOWERCASE);//固定把所有的汉语拼音都转成小写字母
FORMAT.setToneType(HanyuPinyinToneType.WITHOUT_TONE);//取消声调
}
/**
* 获取中文文本的拼音全拼 外星人 = waixingren
*/
public static String toFullPinyin(String chinese) {
if (StringUtils.isEmpty(chinese)) {
return "";
}
StringBuilder stringBuilder = new StringBuilder();
for (char c : chinese.toCharArray()) {
try {
String[] pinyinStringArray = PinyinHelper.toHanyuPinyinStringArray(c, FORMAT);
if (pinyinStringArray != null && pinyinStringArray.length > 0) {
stringBuilder.append(pinyinStringArray[0]);
} else {
//如果转换失败了,可能对方输入的是英文
stringBuilder.append(c);
}
} catch (BadHanyuPinyinOutputFormatCombination e) {
stringBuilder.append(c);
}
}
return stringBuilder.toString().toLowerCase();
}
/**
* 获取中文文本的拼音首字母 外星人 = wxy
*/
public static String toFirstLetters(String chinese) {
if (StringUtils.isEmpty(chinese)) {
return "";
}
StringBuilder result = new StringBuilder();
for (char c : chinese.toCharArray()) {
try {
String[] pinyinStringArray = PinyinHelper.toHanyuPinyinStringArray(c, FORMAT);
if (pinyinStringArray != null && pinyinStringArray.length > 0) {
result.append(pinyinStringArray[0].charAt(0));
} else {
result.append(c);
}
} catch (BadHanyuPinyinOutputFormatCombination e) {
result.append(c);
}
}
return result.toString().toLowerCase();
}
// ======================== 拼音组合搜索 ========================
/**
* 对中文文本进行分词,再将分词后的拼音片段生成所有连续子序列组合,用空格分隔。
* <p>
* 示例输入: "总裁的替身前妻"
* 分词: [总裁, 的, 替身, 前妻]
* 拼音: [zongcai, de, tishen, qianqi]
* 输出: "zongcaidetishenqianqi tishenqianqi zongcaiqianqi zongcaide zongcai de tishen qianqi"
* <p>
* 这样 ES 使用 whitespace 分词后,搜索 tishenqianqi / zongcai / tishen 等都能命中。
*
* @param chinese 中文文本
* @return 空格分隔的拼音组合字符串
*/
public static String toPinyinCombinations(String chinese) {
if (StringUtils.isEmpty(chinese)) {
return "";
}
// 1. 对中文进行正向最大匹配分词
List<String> words = segmentByDict(chinese);
// 如果只有一个词,无需组合,直接返回全拼
if (words.size() <= 1) {
return toFullPinyin(chinese);
}
// 2. 每个词转拼音
List<String> pinyinWords = new ArrayList<>(words.size());
for (String word : words) {
pinyinWords.add(toFullPinyin(word));
}
// 3. 收集所有连续子序列组合(用 LinkedHashSet 去重且保持顺序)
Set<String> combinations = new LinkedHashSet<>();
// 3a. 完整拼音(去掉停用词保留实词的空格版,让用户输"总裁前妻"也能匹配)
String fullWithoutStop = buildFullWithoutStop(words, pinyinWords);
if (!fullWithoutStop.isEmpty()) {
combinations.add(fullWithoutStop);
}
// 3b. 所有连续子序列
for (int i = 0; i < pinyinWords.size(); i++) {
for (int j = i; j < pinyinWords.size(); j++) {
String subSeq = String.join("", pinyinWords.subList(i, j + 1));
combinations.add(subSeq);
}
}
// 3c. 去掉停用词后的连续子序列(用户输入"替身前妻"而非"的替身前妻"也能匹配)
List<String> contentPinyinWords = new ArrayList<>();
List<String> contentWords = new ArrayList<>();
for (int k = 0; k < words.size(); k++) {
if (!isStopWord(words.get(k))) {
contentWords.add(words.get(k));
contentPinyinWords.add(pinyinWords.get(k));
}
}
if (contentPinyinWords.size() < pinyinWords.size()) {
for (int i = 0; i < contentPinyinWords.size(); i++) {
for (int j = i; j < contentPinyinWords.size(); j++) {
String subSeq = String.join("", contentPinyinWords.subList(i, j + 1));
combinations.add(subSeq);
}
}
}
return String.join(" ", combinations);
}
/**
* 构建去掉停用词之后的完整拼音串
* 例如 "总裁的替身前妻" → "zongcaitishenqianqi"
*/
private static String buildFullWithoutStop(List<String> words, List<String> pinyinWords) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < words.size(); i++) {
if (!isStopWord(words.get(i))) {
sb.append(pinyinWords.get(i));
}
}
return sb.toString();
}
/**
* 判断是否为停用词
*/
private static boolean isStopWord(String word) {
return "的".equals(word) || "了".equals(word) || "在".equals(word)
|| "是".equals(word) || "我".equals(word) || "他".equals(word)
|| "她".equals(word) || "你".equals(word) || "不".equals(word)
|| "也".equals(word) || "就".equals(word) || "都".equals(word)
|| "和".equals(word) || "与".equals(word) || "之".equals(word)
|| "上".equals(word) || "下".equals(word) || "被".equals(word)
|| "把".equals(word) || "让".equals(word) || "给".equals(word)
|| "到".equals(word) || "有".equals(word) || "看".equals(word)
|| "大".equals(word) || "小".equals(word) || "老".equals(word)
|| "第".equals(word);
}
/**
* 基于自定义词典的正向最大匹配分词
* <p>
* 算法说明:
* 1. 从文本起始位置开始,取最长 MAX_WORD_LENGTH 个字符查词典
* 2. 匹配到则作为词切割,否则回退长度继续匹配
* 3. 如果完全匹配不到,则单字成词
* 4. 连续的非中文字符(英文/数字)作为整体保留
* </p>
*
* @param text 待分词的文本
* @return 分词结果列表
*/
private static List<String> segmentByDict(String text) {
List<String> result = new ArrayList<>();
int i = 0;
int len = text.length();
while (i < len) {
char c = text.charAt(i);
// 非中文字符:连续收集(英文、数字、标点)
if (isNonChinese(c)) {
StringBuilder nonChinese = new StringBuilder();
while (i < len && isNonChinese(text.charAt(i))) {
nonChinese.append(text.charAt(i));
i++;
}
result.add(nonChinese.toString());
continue;
}
// 中文字符:正向最大匹配
boolean matched = false;
int maxLookup = Math.min(MAX_WORD_LENGTH, len - i);
for (int wordLen = maxLookup; wordLen >= 1; wordLen--) {
String candidate = text.substring(i, i + wordLen);
if (DRAMA_DICT.contains(candidate)) {
result.add(candidate);
i += wordLen;
matched = true;
break;
}
}
// 词典未匹配到,单字成词
if (!matched) {
result.add(String.valueOf(text.charAt(i)));
i++;
}
}
return result;
}
/**
* 判断字符是否为非中文字符(英文、数字、标点、空格等)
*/
private static boolean isNonChinese(char c) {
return c < 0x4E00 || c > 0x9FFF;
}
}
数据库对应的实体类,这个是事先就应该存在的,用于将数据库的数据,放进jvm中
kotlin
package com.javasm.bunnyflix.entity.search;
import java.util.Date;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import java.io.Serializable;
/**
* 剧集信息表(DramaInfo)表实体类
*
* @author makejava
* @since 2026-07-29 16:35:14
*/
@Data
@SuppressWarnings("serial")
public class DramaInfo extends Model<DramaInfo> {
//剧集ID,雪花算法生成
@TableId(type = IdType.AUTO)
private Long id;
//剧集标题
private String title;
//剧集简介
private String description;
//封面图片URL
private String coverUrl;
//分类:都市甜宠/古装仙侠/悬疑推理/逆袭爽文/家庭伦理/科幻未来/青春校园
private String category;
//作者/编剧
private String author;
//导演
private String director;
//主演(逗号分隔)
private String mainActors;
//总集数
private Integer totalEpisodes;
//剧集状态:0连载中 1已完结
private Integer status;
//总播放量
private Long playCount;
//点赞数
private Long likeCount;
//VIP专属:0免费 1VIP可看
private Integer isVip;
//评分(1.0-10.0)
private Double rating;
//创建时间
private Date createdAt;
//更新时间
private Date updatedAt;
/**
* 获取主键值
*/
@Override
public Serializable pkVal() {
return this.id;
}
}
ES数据库对应的实体类DramaDocument,相较于Drama实体类,这个增加了部分字段的拼音字段,用于存储拼音List,用于ES拼音查询
kotlin
package com.javasm.bunnyflix.document;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.DateFormat;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.util.Date;
/**
* 剧集 ES 文档实体,映射 drama 索引
*
* @author makejava
* @since 2026-07-29 17:00:00
*/
@Data
@Document(indexName = "drama")
public class DramaDocument {
// 剧集ID,雪花算法生成
@Id
private Long id;
// 剧集标题,ik 分词全文检索
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String title;
// 剧集简介,ik 分词全文检索
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String description;
// 标题拼音分词组合(空格分隔),whitespace 分词
@Field(type = FieldType.Text, analyzer = "whitespace")
private String titlePinyin;
// 标题拼音首字母
@Field(type = FieldType.Keyword)
private String titlePinyinFist;
// 简介拼音分词组合(空格分隔)
@Field(type = FieldType.Text, analyzer = "whitespace")
private String descriptionPinyin;
// 封面图片URL,无需索引
@Field(type = FieldType.Keyword, index = false)
private String coverUrl;
// 分类:都市甜宠/古装仙侠/悬疑推理/逆袭爽文/家庭伦理/科幻未来/青春校园
@Field(type = FieldType.Keyword)
private String category;
// 作者/编剧
@Field(type = FieldType.Keyword)
private String author;
// 导演
@Field(type = FieldType.Keyword)
private String director;
// 导演拼音分词组合(空格分隔)
@Field(type = FieldType.Text, analyzer = "whitespace")
private String directorPinyin;
// 主演(逗号分隔),ik 分词全文检索
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String mainActors;
// 主演拼音分词组合(空格分隔)
@Field(type = FieldType.Text, analyzer = "whitespace")
private String mainActorsPinyin;
// 总集数
@Field(type = FieldType.Integer)
private Integer totalEpisodes;
// 剧集状态:0连载中 1已完结
@Field(type = FieldType.Integer)
private Integer status;
// 总播放量
@Field(type = FieldType.Long)
private Long playCount;
// 点赞数
@Field(type = FieldType.Long)
private Long likeCount;
// VIP专属:0免费 1VIP可看
@Field(type = FieldType.Integer)
private Integer isVip;
// 评分(1.0-10.0)
@Field(type = FieldType.Double)
private Double rating;
// 创建时间
@Field(type = FieldType.Date, format = DateFormat.date_time)
private Date createdAt;
// 更新时间
@Field(type = FieldType.Date, format = DateFormat.date_time)
private Date updatedAt;
}
DramaDocumentRepository相当于数据库的dao层,只不过这个是ES的dao层,用于和ES交互的
java
package com.javasm.bunnyflix.repository;
import com.javasm.bunnyflix.document.DramaDocument;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 剧集 ES 数据访问层,提供全文检索能力
*
* @author makejava
* @since 2026-07-29 17:00:00
*/
@Repository
public interface DramaDocumentRepository extends ElasticsearchRepository<DramaDocument, Long> {
// 按分类精确匹配
List<DramaDocument> findByCategory(String category);
// 按标题或简介全文搜索(ik 分词)
List<DramaDocument> findByTitleOrDescription(String title, String description);
// 按状态筛选播放在 TopN 的剧集
List<DramaDocument> findByStatusOrderByPlayCountDesc(Integer status);
// 按播放量降序排列
List<DramaDocument> findAllByOrderByPlayCountDesc();
// 按分类 + VIP 过滤,按评分降序
List<DramaDocument> findByCategoryAndIsVipOrderByRatingDesc(String category, Integer isVip);
}
1.2使用
首先创建controller层的SearchController
less
package com.javasm.bunnyflix.controller;
import com.javasm.bunnyflix.common.R;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import com.javasm.bunnyflix.service.DramaSearchService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/search/mobile")
public class SearchController {
@Resource
DramaSearchService dramaSearchService;
//全量同步短剧数据到ES
@GetMapping("/sync")
public R sync() {
dramaSearchService.sysDataDB2ES();
return R.ok();
}
//根据关键字分页搜索短剧(支持中文和拼音)
@GetMapping("/search")
public R search(@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(dramaSearchService.search(keyword, page, size));
}
//将单部短剧保存到 ES(供 MQ 消费者调用)
@PostMapping("/save")
public R save(@RequestBody DramaInfo dramaInfo) {
dramaSearchService.saveDrama(dramaInfo);
return R.ok();
}
}
service层的实现
接口
arduino
package com.javasm.bunnyflix.service;
import com.javasm.bunnyflix.document.DramaDocument;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import org.springframework.data.domain.Page;
public interface DramaSearchService {
void sysDataDB2ES();
//根据关键字,分页搜索短剧(支持中文和拼音)
Page<DramaDocument> search(String keyword, int page, int size);
/** 将单部短剧保存到 ES(供 MQ 消费者调用) */
void saveDrama(DramaInfo dramaInfo);
}
实现service,这里通过ContentFeignClient去访问远程接口提供的查询数据库,获取到的数据通过工具类实现高亮,排序,并且将拼音字段加入到dramaDocuments中(这个过程叫装车),然后通过elasticsearchOperations执行构造器(发车)
ini
package com.javasm.bunnyflix.service.impl;
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import com.javasm.bunnyflix.api.client.content.ContentFeignClient;
import com.javasm.bunnyflix.document.DramaDocument;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import com.javasm.bunnyflix.repository.DramaDocumentRepository;
import com.javasm.bunnyflix.service.DramaSearchService;
import com.javasm.bunnyflix.utils.ConvertUtil;
import com.javasm.bunnyflix.utils.DramaSearchUtil;
import jakarta.annotation.Resource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.HighlightQuery;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class DramaSearchServiceImpl implements DramaSearchService {
@Resource
private ContentFeignClient contentFeignClient;
@Resource
private DramaDocumentRepository dramaDocumentRepository;
@Resource
private ElasticsearchOperations elasticsearchOperations;
@Override
public void sysDataDB2ES() {
List<DramaInfo> list = contentFeignClient.list(null);
List<DramaDocument> dramaDocuments = ConvertUtil.covertToDocument(list);
dramaDocumentRepository.saveAll(dramaDocuments);
}
@Override
public Page<DramaDocument> search(String keyword, int page, int size) {
// 同时搜索原文和拼音字段,中文/拼音输入都能命中
Query baseQuery = DramaSearchUtil.createDramaPinyinBaseQuery(keyword);
return doSearch(baseQuery, page, size);
}
@Override
public void saveDrama(DramaInfo dramaInfo) {
// 将单部短剧转换并保存到 ES
DramaDocument document = ConvertUtil.convertSingle(dramaInfo);
dramaDocumentRepository.save(document);
}
/**
* 执行 ES 搜索:加权评分 + 高亮 + 分页
*/
private Page<DramaDocument> doSearch(Query baseQuery, int page, int size) {
// 加权评分(播放量 ×1.5,评分 ×10)
Query scoreQuery = DramaSearchUtil.createDramaScoreQuery(baseQuery);
// 高亮配置
HighlightQuery highlightQuery = DramaSearchUtil.createDramaHighlight();
//装车
NativeQuery nativeQuery = NativeQuery.builder()
.withQuery(scoreQuery)
.withHighlightQuery(highlightQuery)
.withPageable(PageRequest.of(page, size))
.build();
//发车
SearchHits<DramaDocument> searchHits = elasticsearchOperations.search(nativeQuery, DramaDocument.class);
List<DramaDocument> documents = DramaSearchUtil.convertHits2Document(searchHits);
return new PageImpl<>(documents, PageRequest.of(page, size), searchHits.getTotalHits());
}
}
1.3完善内容
到此ES搜索服务就已经完成了,但是我们会发现,如果有创作者,发布新的短剧或者修改删除短剧,我们的ES数据库中没办法同步,所以我们需要将更改删除新增的功能时同步到ES中,这时我使用的是AOP+dockeMQ
1.3.1分析
首先如果短剧内容更新了,需要同步到ES中,
那么短剧管理服务(也就是conten-service)就是生产者,需要向MQ中发送消息
搜索服务(也就是search-service)作为ES的增删改功能,也就是消费者,去接受MQ中的消息
1.3.2简单复习MQ原理
在生产者一端我们需要向MQ中发送数据(这里我们使用tag来区分是修改增加还是删除,来找到对应的MQ监听)
javascript
rocketMQTemplate.syncSend(TOPIC + ":" + tag, JSON.toJSONString(dramaInfo));
消费者一端需要监听MQ消息
在类上加注解(这里以修改和增加为例)
ini
@RocketMQMessageListener(
topic = "drama-change",
selectorExpression = "add || update",
consumerGroup = "bunnyflix-search-service-consumer-group"
)
同时类要实现RocketMQListener<String> 并且必须要实现onMessage方法
1.3.3具体代码代码实现
1.3.3.1短剧管理服务(content-service)
首先添加注解类,用于标记需要的类(这里涉及的是AOP知识)
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SyncES {
/** 操作类型:add / update / delete */
String action();
}
添加拦截器,搜索注解,然后通过找到注解的方法,获取方法返回值,将方法返回值用于给MQ发送消息作为MQ的数据
typescript
package com.javasm.bunnyflix.content.aop;
import com.alibaba.fastjson2.JSON;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
/**
* 剧集变更后置通知 --- 发 MQ 消息通知 search-service 同步 ES
* <p>
* 数据流向:content-service(Producer) → RocketMQ → search-service(Consumer) → ES
*
* @author makejava
* @since 2026-07-31
*/
@Slf4j
@Component
@Aspect
public class DramaChangeAspect {
@Resource
private RocketMQTemplate rocketMQTemplate;
/** MQ Topic,search-service 侧监听此 topic 的不同 tag */
private static final String TOPIC = "drama-change";
/**
* 在 @SyncES 标记的方法成功返回后,向 MQ 发送剧集变更消息
*/
@AfterReturning(
value = "@annotation(syncES)",
returning = "result"
)
public void afterDramaChange(JoinPoint joinPoint, SyncES syncES, Object result) {
String action = syncES.action();
// 仅当方法成功返回时才发消息(add 方法可能返回 false)
if ("add".equals(action) && result instanceof Boolean && !(Boolean) result) {
log.debug("剧集新增失败,跳过 MQ 同步: result={}", result);
return;
}
try {
if ("delete".equals(action)) {
// 删除操作:从方法参数中取 id(第一个参数为 Long 类型)
Object[] args = joinPoint.getArgs();
if (args.length > 0 && args[0] instanceof Long id) {
rocketMQTemplate.syncSend(TOPIC + ":delete", id.toString());
log.debug("MQ 删除消息已发送: dramaId={}", id);
}
} else {
// 新增/更新操作:从方法参数中取 DramaInfo
DramaInfo dramaInfo = extractDramaInfo(joinPoint.getArgs());
if (dramaInfo != null) {
String tag = "add".equals(action) ? "add" : "update";
rocketMQTemplate.syncSend(TOPIC + ":" + tag, JSON.toJSONString(dramaInfo));
log.debug("MQ {} 消息已发送: dramaId={}", tag, dramaInfo.getId());
}
}
} catch (Exception e) {
// MQ 发送失败不影响主流程,仅记录日志
log.error("MQ 消息发送失败, action={}", action, e);
}
}
/** 从方法参数中提取 DramaInfo 对象 */
private DramaInfo extractDramaInfo(Object[] args) {
for (Object arg : args) {
if (arg instanceof DramaInfo dramaInfo) {
return dramaInfo;
}
}
return null;
}
}
由于这里的controller就是为了给别的服务提供fegin服务的,所以controller返回的数据就是数据本身,没有通过R类型去加工,所以这里直接在增加,修改,删除的对应controller添加注解@SyncES(action = "add")
kotlin
package com.javasm.bunnyflix.content.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.javasm.bunnyflix.common.JavasmException;
import com.javasm.bunnyflix.common.ReturnCode;
import com.javasm.bunnyflix.content.aop.SyncES;
import com.javasm.bunnyflix.content.service.DramaInfoService;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 剧集管理接口 - 增删改查
*
* @author makejava
* @since 2026-07-29
*/
@RestController
@RequestMapping("/drama")
@Slf4j
@Validated
@Tag(name = "剧集管理接口", description = "剧集的增删改查")
public class DramaInfoController {
@Resource
DramaInfoService dramaInfoService;
//新增剧集 → 发 MQ 同步 ES,返回完整数据供 AOP 使用
@PostMapping
@SyncES(action = "add")
public DramaInfo add(@RequestBody DramaInfo dramaInfo) {
dramaInfoService.save(dramaInfo); // save 后 MyBatis-Plus 自动回填 id
return dramaInfo;
}
//删除剧集 → 先查再删,返回完整数据供 AOP 发 MQ 同步 ES
@DeleteMapping("/{id}")
@SyncES(action = "delete")
public DramaInfo delete(@PathVariable Long id) {
DramaInfo dramaInfo = dramaInfoService.getById(id);
if (dramaInfo == null) {
throw new JavasmException(ReturnCode.DRAMA_NOT_FOUND);
}
dramaInfoService.removeById(id);
return dramaInfo;
}
//更新剧集 → 发 MQ 同步 ES,返回完整数据供 AOP 使用
@PutMapping
@SyncES(action = "update")
public DramaInfo update(@RequestBody DramaInfo dramaInfo) {
if (!dramaInfoService.updateById(dramaInfo)) {
throw new JavasmException(ReturnCode.DRAMA_NOT_FOUND);
}
return dramaInfo;
}
//根据ID查询剧集详情
@GetMapping("/{id}")
public DramaInfo getById(@PathVariable Long id) {
DramaInfo dramaInfo = dramaInfoService.getById(id);
if (dramaInfo == null) {
throw new JavasmException(ReturnCode.DRAMA_NOT_FOUND);
}
return dramaInfo;
}
//查询剧集列表(支持按分类筛选)
@GetMapping("/list")
public List<DramaInfo> list(@RequestParam(required = false) String category) {
LambdaQueryWrapper<DramaInfo> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(category)) {
wrapper.eq(DramaInfo::getCategory, category);
}
wrapper.orderByDesc(DramaInfo::getCreatedAt);
return dramaInfoService.list(wrapper);
}
}
到这里MQ的发送端就已经,完成了,MQ对应的依赖配置,这里不过多介绍
1.3.3.2搜索服务作为ES的管理服务(search-service)
作为消费者,只需要监听MQ的信息,这里分为两类(一类是增加和修改,一类是删除)
在search-service下创建consumer,然后创建两个MQ监听器
第一个(新增和修改)
typescript
package com.javasm.bunnyflix.consumer;
import com.alibaba.fastjson2.JSON;
import com.javasm.bunnyflix.entity.search.DramaInfo;
import com.javasm.bunnyflix.service.DramaSearchService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* 监听 content-service 的剧集新增/更新消息,同步数据到 ES
* <p>
* Topic: drama-change | Tag: add || update
*
* @author makejava
* @since 2026-07-31
*/
@Slf4j
@Component
@RocketMQMessageListener(
topic = "drama-change",
selectorExpression = "add || update",
consumerGroup = "bunnyflix-search-service-consumer-group-add-update"
)
public class DramaChangeConsumer implements RocketMQListener<String> {
@Resource
private DramaSearchService dramaSearchService;
@Override
public void onMessage(String message) {
log.debug("收到剧集新增/更新消息: {}", message);
try {
DramaInfo dramaInfo = JSON.parseObject(message, DramaInfo.class);
dramaSearchService.saveDrama(dramaInfo);
log.debug("剧集同步 ES 成功, dramaId={}", dramaInfo.getId());
} catch (Exception e) {
log.error("消费剧集变更消息失败", e);
}
}
}
第二个(删除)
kotlin
package com.javasm.bunnyflix.consumer;
import com.javasm.bunnyflix.repository.DramaDocumentRepository;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* 监听 content-service 的剧集删除消息,从 ES 中移除对应文档
* <p>
* Topic: drama-change | Tag: delete
*
* @author makejava
* @since 2026-07-31
*/
@Slf4j
@Component
@RocketMQMessageListener(
topic = "drama-change",
selectorExpression = "delete",
consumerGroup = "bunnyflix-search-service-consumer-group-delete"
)
public class DramaDeleteConsumer implements RocketMQListener<String> {
@Resource
private DramaDocumentRepository dramaDocumentRepository;
@Override
public void onMessage(String message) {
log.debug("收到删除剧集消息: {}", message);
try {
Long id = Long.valueOf(message);
dramaDocumentRepository.deleteById(id);
log.debug("ES 文档已删除, dramaId={}", id);
} catch (Exception e) {
log.error("消费删除剧集消息失败", e);
}
}
}
为什么ES同样的对象不能用Drama而非要转换成DramaDocument来存储到ES中呢?
虽然Drama和DramaDocument中的属性完全一致,但是里面的注解不同,一个是给mybatisplus看的一个是给专门的elasticsearch看的。