SpringBoot实现ES搜索帖子

预期实现的搜索功能

  1. 可按帖子类型进行搜索
  2. 可根据用户输入的关联词进行搜索,也可不指定,仅获取帖子列表
  3. 可获取指定用户ID的帖子
  4. 可指定排序的类型(即按什么来排序),计划实现按创建时间、热度来进行排序
  5. 可指定排序顺序(升序降序)
  6. 能够指定获取的帖子数量

方案选择

分页方案
  • 如何进行分页?常见的有基于页码的分页基于游标的分页
  • 这里选择游标+页面大小的方式,游标结合 es 的 searchAfter 来定位上一次查询到的帖子位置
  • 为什么这么选择?优点缺点?
    • 作为以帖子为主导的校园社区,帖子数量很大,并且对于随机跳页的需求较少。
    • 优点:
      • 数据量大时游标分页的搜索性能更高。
    • 缺点:
      • 不支持随机跳页,仅支持顺序翻页(应该说不能支持未访问过的帖子的随机跳页吧,如果是已经访问过的,前端可以用一个列表存储每次响应的游标,这样就可以支持已经访问过的帖子的随机跳页了)
  • 游标的选择:
    • 对于按创建时间排序:使用创建时间+帖子ID作为游标
    • 对于按热度排序:使用热度+帖子ID作为游标
    • 为什么还要加上帖子ID?
    • 确保唯一性,帖子的创建时间/热度可能相同,如果仅通过单一指标进行检索可能无法获取正确的位置继续进行查询
  • 请求体设计:
@Data
public class SearchPostRequest {
    /**
     * 请求返回的最大帖子数量
     */
    private long pageSize;
    /**
     * 过滤帖子类型
     */
    private String postType;
    /**
     * 排序字段(默认CREATE_TIME_SORTED)
     */
    private String sortType;
    /**
     * 排序顺序(默认DESC)
     */
    private String sortOrder;
    /**
     * 上一次请求返回的帖子列表中最后一个帖子的createTime参数
     */
    private String timeCursor;
    /**
     * 用户publicId
     */
    private String userPublicId;
    /**
     * 搜索关键词
     */
    private String keyword;
}
热度计算算法

参考Stack Overflow的互动加权和牛顿冷却定律整了一个简单的公式来计算

特点:随时间指数级衰减,适合帖子社区(暂时凑合着用)

代码

  • 帖子表Entity类
@Data
@Document(indexName = "post")
@JsonIgnoreProperties(ignoreUnknown = true)
public class PostEsDocument {
    @Id // 标记为 Elasticsearch 文档的 ID
    @Field(type = FieldType.Keyword)
    private String postId;

    @Field(type = FieldType.Keyword) // 发布者的对外ID
    private String publicId;

    @Field(type = FieldType.Text, analyzer = "standard") // 帖子正文,用于全文搜索,可指定分词器
    private String content;

    @Field(type = FieldType.Date_Nanos,
            format = {},
            pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX")
    private OffsetDateTime createTime; //帖子创建时间

    @Field(type = FieldType.Keyword) // 枚举类型通常映射为 Keyword,用于精确过滤
    private PostType postType; //  帖子类型枚举

    @Field(type = FieldType.Boolean)
    private Boolean hasMedia; // 是否有媒体附件

    @Field(type = FieldType.Long)
    private Long likeCount; //点赞数量

    @Field(type = FieldType.Long)
    private Long collectCount; //收藏数量

    @Field(type = FieldType.Long)
    private Long replyCount; //评论数量

    @Field(type = FieldType.Long)
    private Long shareCount; //分享数量

    @Field(type = FieldType.Long) // 或者是 Keyword 如果您打算将其映射为字符串状态
    private Long status; // 帖子状态

    @Field(type = FieldType.Long)
    private Long totalAttachments; // 总媒体附件数量

    @Field(type = FieldType.Long)
    private Long viewCount; //浏览量

    @Field(type = FieldType.Nested) // 内嵌对象列表
    private List<MediaAttachmentEsDocument> mediaAttachments; //媒体附件列表
}
  • 媒体附件表Entity类
@Data
public class MediaAttachmentEsDocument {
    @Field(type = FieldType.Long) // 或者 Keyword,如果attachId主要用于精确匹配而非范围查询
    private Long attachId;

    @Field(type = FieldType.Keyword) // 枚举类型通常映射为 Keyword
    private MediaType mediaType; // 假设 MediaType 是一个枚举

    @Field(type = FieldType.Keyword) // URL 或路径,通常用 Keyword 进行精确匹配
    private String storagePath;

    @Field(type = FieldType.Long)
    private Long fileSize;

    @Field(type = FieldType.Date, format = DateFormat.date_optional_time)
    private OffsetDateTime uploadTime;

    @Field(type = FieldType.Keyword) // 枚举类型通常映射为 Keyword
    private ContentState status; // 假设 ContentState 是一个枚举
}
  • 帖子类型枚举
public enum PostType {
    HELP_POST(0,"HELP_POST"),
    SECOND_SALED_POST(1,"SECOND_SALED_POST"),
    LOST_POST(2,"LOST_POST"),
    ANY_POST(3,"ANY_POST");
    private final int code;
    private final String value;

    PostType(int code, String value) {
        this.code = code;
        this.value = value;
    }

    @JsonValue
    public String getValue() {
        return value;
    }

    @JsonCreator
    public static PostType fromValue(String value) {
        for (PostType postType : PostType.values()) {
            if (postType.value.equals(value)) {
                return postType;
            }
        }
        throw new IllegalArgumentException("Unknown PostType value: " + value);
    }

    public static boolean isValid(String value){
        for (PostType postType : PostType.values()) {
            if(postType.value.equals(value)){
                return true;
            }
        }
        return false;
    }
}
  • 目标类型枚举
public enum TargetType {
    COMMENT(0,"COMMENT"),
    POST(1,"POST");

    private final int code;
    private final String value;

    TargetType(int code, String value) {
        this.code = code;
        this.value = value;
    }
}
  • 排序类型枚举

public enum SortType {
    TIME_DESCENDING(0,"TIME_DESCENDING_SORTED"),//时间倒叙,从新到旧  // 此为旧版本设计遗留问题,截至写文章时还未修改
    TIME_ASCENDING(1,"TIME_ASCENDING"),//时间正序,从旧到新
    HOT(2,"HOT");
    private final int code;
    private final String value;

    // 根据目标类型返回对应的创建时间字段命名,避免在Service中手动判断要修改时需要修改多处业务
    public String getEsFieldNameCreateTime(TargetType targetType){
        switch(this){
            case TIME_DESCENDING:
                switch (targetType){
                    case POST:
                        return "createTime";
                }
            case TIME_ASCENDING:
                switch (targetType){
                    case POST:
                        return "createTime";
                }
            default:
                return "createTime";
        }
    }
    
    // 根据目标类型返回对应的主键ID字段命名
    public String getEsFieldNameId(TargetType targetType){
        switch (targetType){
            case POST:
                return "postId";
            default:
                return "id";
        }
    }


    SortType(int code, String value) {
        this.code = code;
        this.value = value;
    }
}
  • 搜索帖子请求体
@Data
public class SearchPostRequest {
    /**
     * 请求返回的最大帖子数量
     */
    private long pageSize;
    /**
     * 过滤帖子类型
     */
    private String postType;
    /**
     * 排序字段
     */
    private String sortType;
    /**
     * 排序顺序(默认DESC)
     */
    private String sortOrder;
    /**
     * 上一次请求返回的游标
     */
    private String timeCursor; //命名为旧版本残留
    /**
     * 用户publicId
     */
    private String userPublicId;
    /**
     * 搜索关键词
     */
    private String keyword;
}
  • 搜索帖子响应体
@lombok.Data
public class ListPostsResponse {// 复用了原来的通过MySQL获取帖子列表的DTO
    /**
     * 游标,前端在下一次请求时携带上即可获取下一分页内容
     */
    private String timeCursor;
    /**
     * 当前页的帖子数量
     */
    private Long pageSize;
    /**
     * 帖子列表
     */
    private List<SummaryPost> posts;
    /**
     * 总帖子数
     */
    private Long totalPosts;
}
@Data
public class SummaryPost {
    /**
     * 作者信息
     */
    private SummaryAuthorInfo authorInfo;
    /**
     * 收藏数量
     */
    private Long collectCount;
    /**
     * 内容摘要(Markdown格式)
     */
    private String content;
    /**
     * 发布时间(UTC时间)
     */
    private OffsetDateTime createTime;
    /**
     * 是否包含多媒体
     */
    private boolean hasMedia;
    /**
     * 用于获取帖子列表是判断此次获取是否获取了完整的信息,若是则在进入帖子详细页时可以不用请求
     */
    @JsonProperty("isComplete")
    private boolean isComplete; //后来发现不应该这么设计。。。
    /**
     * 点赞量
     */
    private Long likeCount;
    /**
     * 帖子唯一标识
     */
    private Long postId;
    /**
     * 回复数量
     */
    private Long replyCount;
    /**
     * 分享数量
     */
    private Long shareCount;
    /**
     * 媒体附件部分信息列表
     */
    private List<SummaryMediaAttachment> summaryMediaAttachment;
    /**
     * 附件总数
     */
    private Long totalAttachments;
    /**
     * 浏览量
     */
    private Long viewCount;
    /**
     * 帖子类型
     */
    private PostType postType;
}
@lombok.Data
public class SummaryAuthorInfo {
    /**
     * 作者头像URL(CDN加速)
     */
    private String avatar;
    /**
     * 作者昵称(可显示)
     */
    private String nickName;
    /**
     * 作者对外标识
     */
    private String publicId;
}
@lombok.Data
public class SummaryMediaAttachment {
    /**
     * 文件大小
     */
    private long fileSize;
    /**
     * 媒体类型
     */
    private MediaType mediaType;
    /**
     * CDN加速的媒体路径(带访问签名)
     */
    private String storagePath;
}
  • 游标序列化与反序列化Util

public class EsSearchAfterUtil {
    private static final JsonpMapper mapper=new JacksonJsonpMapper();

    /*
     * @Description: 将searchAfter的值转换为字符串
     */
    public static String serialize(List<FieldValue> searchAfter){
        List<Object>rawValues=new ArrayList<>();
        for (FieldValue value:searchAfter){
            if (value.isString()){
                rawValues.add(value.stringValue());
            } else if (value.isLong()) {
                rawValues.add(value.longValue());
            }else if (value.isDouble()){
                rawValues.add(value.doubleValue());
            }else if ((value.isBoolean())){
                rawValues.add(value.booleanValue());
            }else if (value.isAny()){
                rawValues.add(value.anyValue());
            }
        }
        return JsonpUtils.toJsonString(rawValues, mapper);
    }
    /*
     * @Description: 将字符串转换为List<FieldValue>(searchAfter函数的参数)
     */
    public static List<FieldValue> deserialize(String searchAfter) {
        if (searchAfter==null||searchAfter.isEmpty()){
            return null;
        }
        try(JsonParser parser=mapper.jsonProvider().createParser(new StringReader(searchAfter))){
            return JsonpDeserializer.arrayDeserializer(FieldValue._DESERIALIZER).deserialize(parser, mapper);
        }catch (Exception e){
            throw new RuntimeException("EsSearchAfterUtil反序列化失败"+e);
        }
    }
}
  • 帖子模块Service类
@Slf4j
@Service("PostService")
public class PostService implements IPostService {
    @Autowired
    private ElasticsearchClient elasticsearchClient;
    @Autowired
    private UserService userService;

    @Override
    public ListPostsResponse searchPosts(SearchPostRequest request) {
        try{
            SearchRequest.Builder builder=new SearchRequest.Builder()
                    .index("post")
                    .size((int)request.getPageSize()) //分页大小
                    .query(buildQuery(request)) //构建查询条件
                    ;

            //添加排序规则
            List<SortOptions>sortOptions=buildSortType(request.getSortType(),request.getSortOrder());
            sortOptions.forEach(builder::sort);

            //添加游标
            List<FieldValue>searchAfterValue=EsSearchAfterUtil.deserialize(request.getTimeCursor());
            Optional.ofNullable(searchAfterValue).filter(
                    sa->!sa.isEmpty()
                    )
                    .ifPresent(
                            sa->builder.searchAfter(sa)
                    );

            //执行es搜索
            SearchResponse<PostEsDocument> response = elasticsearchClient.search(
                    builder.build(),
                    PostEsDocument.class
            );

            //获取搜索结果
            List<Hit<PostEsDocument>>hits=response.hits().hits();

            //若搜索结果为空,则返回null
            if (hits.isEmpty()){
                return null;
            }

            //获取新的游标
            List<FieldValue>searchAfterValues=hits.get(hits.size()-1).sort();

            //构建返回信息
            ListPostsResponse listPostsResponse=new ListPostsResponse();
            List<SummaryPost>summaryPosts=new ArrayList<>();
            for (Hit<PostEsDocument> hit:hits){
                SummaryPost summaryPost=convert2SummaryPost(hit.source(),true);
                summaryPosts.add(summaryPost);
            }
            log.debug("搜索到{}条帖子",summaryPosts.size());
            listPostsResponse.setPosts(summaryPosts);
            listPostsResponse.setTotalPosts(response.hits().total().value());
            listPostsResponse.setPageSize((long) response.hits().hits().size());
            listPostsResponse.setTimeCursor(EsSearchAfterUtil.serialize(searchAfterValues));

            return listPostsResponse;
        }catch (ElasticsearchException e) {
            // 处理服务端明确的错误(如索引不存在、查询语法错误)
            log.error("Elasticsearch 服务端错误: {}", e.getMessage());
            throw new RuntimeException("搜索失败:"+e);
        } catch (TransportException e) {
            // 处理网络层错误(如连接超时、SSL 证书问题)
            log.error("传输层错误: {}", e.getMessage());
            throw new RuntimeException("搜索失败:"+e);
        }
        catch (Exception e){

            throw new RuntimeException("搜索失败:"+e);
        }

    }

    /*
     * 将PostEsDocument转换为SummaryPost
     */
    private SummaryPost convert2SummaryPost(PostEsDocument post,boolean withFullContent){
        SummaryPost summaryPost=new SummaryPost();
        BeanUtils.copyProperties(post,summaryPost);
        summaryPost.setPostId(Long.parseLong(post.getPostId()));
        List<SummaryMediaAttachment>summaryMediaAttachments=new ArrayList<>();
        for (MediaAttachmentEsDocument mediaAttachment:post.getMediaAttachments()){
            SummaryMediaAttachment summaryMediaAttachment=new SummaryMediaAttachment();
            BeanUtils.copyProperties(mediaAttachment,summaryMediaAttachment);
            summaryMediaAttachments.add(summaryMediaAttachment);
        }
        summaryPost.setSummaryMediaAttachment(summaryMediaAttachments);
        summaryPost.setComplete(withFullContent);
        summaryPost.setAuthorInfo(userService.getSummaryAuthorInfoByPublicId(post.getPublicId()));

        return summaryPost;
    }

    /*
     * 构建es查询条件
     */
    private Query buildQuery(SearchPostRequest request){
        BoolQuery.Builder builder=new BoolQuery.Builder();

        //若用户查询时指定了关键词
        if (request.getKeyword()!=null&&!"".equals(request.getKeyword())) {
            builder.must(
                    m -> m.match(
                            ma -> ma.field("content").query(request.getKeyword())
                    )
            );
        }else {//若用户查询时没有指定关键词
            builder.must(m->m.matchAll(ma->ma));
        }

        //过滤状态为发布的帖子
        builder.filter(
                f->f.term(t->t.field("status").value(ContentState.PUBLISHED.getCode()))
        );

        //若用户查询时指定了帖子类型
        if (request.getPostType()!=null&&PostType.isValid(request.getPostType())){
            builder.filter(
                    f->f.term(t->t.field("postType").value(request.getPostType()))
            );
        }

        //若用户查询时指定了用户对外的publicId
        if (request.getUserPublicId()!=null&&!request.getUserPublicId().isEmpty()){
            builder.filter(
                    f->f.term(t->t.field("publicId").value(request.getUserPublicId()))
            );
        }

        Query query=Query.of(b->b.bool(builder.build()));
        return query;

    }

    /*
     * 构建es查询排序条件
     */
    private List<SortOptions> buildSortType(String sortTypeStr,String sortOrderStr) {
        List<SortOptions>sortOptions=new ArrayList<>();

        // 根据sortTypeStr和sortOrderStr构建排序条件变量
        SortType sortType=SortType.valueOf(sortTypeStr);
        SortOrder sortOrder="DESC".equals(sortOrderStr)?SortOrder.Desc:SortOrder.Asc;

        //根据sortType和sortOrder构建排序条件
        switch (sortType){
            case TIME_DESCENDING:
            case TIME_ASCENDING: {
                sortOptions.add(SortOptions.of(so -> so.field(f -> f.field(sortType.getEsFieldNameCreateTime(TargetType.POST)).order(sortOrder))
                ));
                break;
            }
            case HOT: {
                long currentTimeMillis = System.currentTimeMillis();
                String scriptSource = """
                        long createTimeMillis = doc['createTime'].value.toInstant().toEpochMilli();
                        double ageDays=(params.currentTimeMillis-createTimeMillis)/86400000.0;
                        double decayFactor=Math.exp(-0.2*ageDays);
                        (doc['viewCount'].value*0.2+doc['likeCount'].value*0.3+doc['replyCount'].value*0.5)*decayFactor
                        """;
                sortOptions.add(SortOptions.of(s -> s
                        .script(s1 -> s1
                                .script(s2 -> s2
                                        .source(scriptSource)
                                        .lang("painless")
                                        .params(Map.of("currentTimeMillis", JsonData.of(currentTimeMillis)))
                                )
                                .order(
                                        sortOrder
                                ).type(ScriptSortType.Number)
                        )
                ));
                break;
            }

        }

        //若创建时间相同/热度相同则使用id进行排序
        sortOptions.add(SortOptions.of(so -> so.field(f -> f.field(sortType.getEsFieldNameId(TargetType.POST)).order(sortOrder))));
        return sortOptions;
    }



}

开发时遇到的问题

createTime字段无法正常反序列化:
  • 报错信息
  • 此事还无法确认是不是CreateTime反序列化失败,毕竟1. 已经配置了全局的 ObjectMapper 注册了 JavaTimeModule 2. 在CreateTime字段上添加了注解@JsonFormat 控制 JSON 序列化与反序列化格式
2025-05-21T15:56:04.730+08:00 DEBUG 30384 --- [wxf-backend-client] [nio-8080-exec-2] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [java.lang.RuntimeException: 搜索失败:co.elastic.clients.transport.TransportException: node: http://127.0.0.1:9200/, status: 200, [es/search] Failed to decode response]
2025-05-21T15:56:04.730+08:00 DEBUG 30384 --- [wxf-backend-client] [nio-8080-exec-2]
 .m.m.a.ExceptionHandlerExceptionResolver : Resolved [java.lang.RuntimeException: 搜索失败:
co.elastic.clients.transport.TransportException: node: http://127.0.0.1:9200/, status: 200,
 [es/search] Failed to decode response]
  • 逐步调试后找到了源抛出的报错信息
PostEsDocument(postId=2, publicId=454544232125544ahjdhkjshjdsdjsadj, content=, createTime=null, postType=null, hasMedia=null, likeCount=null, collectCount=null, replyCount=null, shareCount=null, status=null, totalAttachments=null, viewCount=null, mediaAttachments=null)com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.OffsetDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 386]
  • 解决方法:
    • 自定义 ElasticsearchClient 并注入带 JavaTimeModule 的 ObjectMapper。
    • 创建 ElasticsearchConfig 类:
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ElasticsearchConfig {

    @Autowired
    private RestClient restClient; // 注入 Spring Boot 自动配置的 RestClient

    @Bean
    public ElasticsearchClient elasticsearchClient() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());

        JacksonJsonpMapper jacksonJsonpMapper = new JacksonJsonpMapper(objectMapper);

        RestClientTransport transport = new RestClientTransport(restClient, jacksonJsonpMapper);
        return new ElasticsearchClient(transport);
    }
}
  • 原因:
    • Spring Boot 的全局 ObjectMapper 配置(JacksonConfig)只对 Spring MVC、RabbitMQ 等生效,不会自动作用于 elasticsearch-java 客户端。
    • elasticsearch-java 客户端默认的 Jackson 实例没有注册 JavaTimeModule,所以无法反序列化 OffsetDateTime。
从es中搜索到的createTime字段与MySQL中的格式不相同:
  • 配置完成后使用 curl 获取数据进行测试,发现createTime字段的值变成了毫秒格式,打印从消息队列里获取到的 createTime字段发现是微妙格式,并没有问题,so问题出现在哪?
  • 原来的代码:
@Field(type = FieldType.Data)
private OffsetDateTime createTime;
  • 该代码未指定日期格式FieldType.Date会采用 date_optional_time||epoch_millis,此时微秒会被截断。
  • 解决方法:
    • 自定义日期格式
@Field(type = FieldType.Date,
        format = {},
        pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX") // 修改点
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX")
private OffsetDateTime createTime;
match传入空字符串时不能匹配任意字段
  • 需要判断前端是否传输了关键词,没有则使用 matchAll,而不是原来的 match。
//若用户查询时指定了关键词
if (request.getKeyword()!=null&&!"".equals(request.getKeyword())) {
    builder.must(
            m -> m.match(
                    ma -> ma.field("content").query(request.getKeyword())
            )
    );
}else {//若用户查询时没有指定了关键词则改用matchAll
    builder.must(m->m.matchAll(ma->ma));
}

待解决的问题

  1. 高频更新的互动字段(点赞数等)在mysql、es中的更新
  2. 是否缓存帖子、用户基础数据

杂谈

  • 此为帖子搜索功能专栏的文章之一,该专栏仅用来梳理自己在项目开发中学习到的东西
  • 本文章语雀链接(无广,仅因为md导入到csdn后格式变得有点乱。。。仅微调部分影响观看的):https://www.yuque.com/zhuiyue-w7umu/cqv2ol/ifvvaa65tfvghm8u
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值