Elasticsearch+MQ搜索服务实战解析

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看的。

相关推荐
都叫我大帅哥12 小时前
Spring Data JPA 查询之道:方法命名与示例查询完全指南
java·后端·spring
卷无止境13 小时前
PageIndex:把RAG的检索逻辑从"找相似"改成了"讲道理"
后端·python
卷无止境14 小时前
什么是VoxCPM2?一场TTS架构的彻底重构
后端·python
程序员cxuan14 小时前
A 社封杀了 1140 万个账号,下午 Claude 崩了。
人工智能·后端·程序员
星栈15 小时前
MCP 2026 史上最大架构重构:从会话握手到无状态协议
人工智能·后端
IT_陈寒15 小时前
Vite静态资源路径这个坑差点让我加班到凌晨
前端·人工智能·后端
神经蛙199615 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
二月龙16 小时前
Spring 事务失效的 8 种场景,很多老手依然频繁踩雷
后端
掘金酱16 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
长大198816 小时前
MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案
后端