网站搭建实操(八)后台管理-搜索服务

forum-search-service 搜索服务

搜素使用 Elasticsearch

为什么搜索服务要用 Elasticsearch

MySQL 搜索的问题

问题 说明 影响
全表扫描 LIKE '%keyword%' 无法使用索引 百万级数据时查询极慢(秒级)
不支持分词 搜索"Spring框架"无法匹配"Spring Boot" 搜索结果不准确
不支持相关性排序 无法按匹配度排序 用户体验差
不支持拼音搜索 输入"spring"无法匹配"Spring" 大小写敏感
不支持高亮 需要手动处理 开发成本高
不支持同义词 搜索"电脑"无法匹配"计算机" 召回率低

Elasticsearch vs MySQL 对比

对比项 MySQL Elasticsearch
本质 关系型数据库 分布式搜索引擎
数据量 千万级性能下降 PB级仍保持毫秒响应
搜索速度 秒级到分钟级 毫秒级
分词 不支持 支持中文分词(IK)
相关性排序 不支持 支持(TF-IDF/BM25)
高亮 需手动实现 自动支持
拼音搜索 需手动实现 支持拼音插件
同义词 需手动实现 支持同义词配置
分布式 需手动分库分表 原生支持

搜索流程

ES详解链接:
ES详解

1.模块结构

text 复制代码
forum-search-service/
├── pom.xml
├── src/main/java/com/forum/search/
│   ├── SearchServiceApplication.java
│   ├── controller/
│   │   └── SearchController.java
│   ├── dto/
│   │   ├── SearchDTO.java
│   │   └── SearchResultVO.java
│   ├── service/
│   │   ├── SearchService.java
│   │   └── impl/
│   │       └── SearchServiceImpl.java
│   ├── config/
│   │   ├── ElasticsearchConfig.java
│   │   └── Knife4jConfig.java
│   └── listener/
│       └── PostSyncListener.java
└── src/main/resources/
    └── application.yml

2.具体实现

pom

java 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>forum-backend</artifactId>
        <groupId>org.example</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>forum-search-service</artifactId>
    <packaging>jar</packaging>
    <description>搜索服务 - 提供全文搜索功能</description>

    <dependencies>
        <!-- Service模块 -->
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>forum-service</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!-- Core模块 -->

        <dependency>
            <groupId>org.example</groupId>
            <artifactId>forum-core</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Elasticsearch -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

        <!-- Knife4j -->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <mainClass>com.forum.search.SearchServiceApplication</mainClass>
                    <finalName>forum-search-service</finalName>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

启动类

java 复制代码
package com.forum.search;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages = {"com.forum"})
@MapperScan("com.forum.dao.mapper")
public class SearchServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(SearchServiceApplication.class, args);
        System.out.println("========================================");
        System.out.println("搜索服务启动成功!");
        System.out.println("访问地址: http://localhost:8085");
        System.out.println("API文档: http://localhost:8085/doc.html");
        System.out.println("========================================");
    }
}

DTO类

java 复制代码
package com.forum.search.dto;


import com.forum.common.result.PageRequest;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
@ApiModel(description = "搜索请求")
public class SearchDTO extends PageRequest {

    @ApiModelProperty(value = "搜索关键词", required = true)
    private String keyword;

    @ApiModelProperty(value = "搜索类型 1:帖子 2:用户", example = "1")
    private Integer type = 1;

    @ApiModelProperty(value = "版块ID过滤")
    private Long categoryId;

    @ApiModelProperty(value = "用户ID过滤")
    private Long userId;
}

SearchResultVO

java 复制代码
package com.forum.search.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@ApiModel(description = "搜索结果")
public class SearchResultVO {

    @ApiModelProperty(value = "ID")
    private Long id;

    @ApiModelProperty(value = "标题")
    private String title;

    @ApiModelProperty(value = "内容摘要")
    private String summary;

    @ApiModelProperty(value = "用户ID")
    private Long userId;

    @ApiModelProperty(value = "用户名")
    private String username;

    @ApiModelProperty(value = "版块ID")
    private Long categoryId;

    @ApiModelProperty(value = "版块名称")
    private String categoryName;

    @ApiModelProperty(value = "浏览量")
    private Integer viewCount;

    @ApiModelProperty(value = "回复数")
    private Integer replyCount;

    @ApiModelProperty(value = "高亮标题")
    private String highlightTitle;

    @ApiModelProperty(value = "高亮内容")
    private String highlightContent;

    @ApiModelProperty(value = "创建时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdTime;

    @ApiModelProperty(value = "相关性得分")
    private Float score;
}

Elasticsearch文档实体

java 复制代码
package com.forum.search.entity;

import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.time.LocalDateTime;

@Data
@Document(indexName = "forum_post")
public class PostDocument {

    @Id
    private Long id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String title;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String content;

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

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

    @Field(type = FieldType.Integer)
    private Integer viewCount;

    @Field(type = FieldType.Integer)
    private Integer replyCount;

    @Field(type = FieldType.Integer)
    private Integer likeCount;

    @Field(type = FieldType.Date)
    private LocalDateTime createdTime;

    @Field(type = FieldType.Date)
    private LocalDateTime updatedTime;
}

Repository

java 复制代码
package com.forum.search.repository;


import com.forum.search.entity.PostDocument;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PostSearchRepository extends ElasticsearchRepository<PostDocument, Long> {

    /**
     * 根据标题或内容搜索
     */
    Page<PostDocument> findByTitleContainingOrContentContaining(String title, String content, Pageable pageable);

    /**
     * 自定义搜索查询
     */
    @Query("{\"bool\": {\"must\": [{\"match\": {\"title\": \"?0\"}}]}}")
    Page<PostDocument> searchByTitle(String keyword, Pageable pageable);

    /**
     * 按版块搜索
     */
    Page<PostDocument> findByCategoryId(Long categoryId, Pageable pageable);

    /**
     * 按用户搜索
     */
    Page<PostDocument> findByUserId(Long userId, Pageable pageable);
}

服务类

java 复制代码
package com.forum.search.service;

import com.forum.common.result.PageResult;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;

public interface SearchService {

    /**
     * 全文搜索
     */
    PageResult<SearchResultVO> search(SearchDTO dto);

    /**
     * 索引帖子
     */
    void indexPost(Long postId);

    /**
     * 批量索引帖子
     */
    void indexAllPosts();

    /**
     * 删除索引
     */
    void deletePostIndex(Long postId);

    /**
     * 清空所有索引
     */
    void clearAllIndexes();
}

实现类

java 复制代码
package com.forum.search.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.forum.common.exception.BusinessException;
import com.forum.common.result.PageResult;
import com.forum.dao.entity.PostEntity;
import com.forum.dao.mapper.PostMapper;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
import com.forum.search.entity.PostDocument;
import com.forum.search.repository.PostSearchRepository;
import com.forum.search.service.SearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class SearchServiceImpl implements SearchService {

    private final PostSearchRepository postSearchRepository;
    private final PostMapper postMapper;

    @Override
    public PageResult<SearchResultVO> search(SearchDTO dto) {
        log.info("搜索: keyword={}, type={}, page={}, size={}",
                dto.getKeyword(), dto.getType(), dto.getPageNum(), dto.getPageSize());

        if (StringUtils.isBlank(dto.getKeyword())) {
            throw new BusinessException("搜索关键词不能为空");
        }

        Pageable pageable = PageRequest.of(dto.getPageNum() - 1, dto.getPageSize());
        Page<PostDocument> page;

        // 根据搜索类型执行不同搜索
        if (dto.getType() == 1) {
            // 帖子搜索
            page = postSearchRepository.findByTitleContainingOrContentContaining(
                    dto.getKeyword(), dto.getKeyword(), pageable);
        } else {
            // 用户搜索(待实现)
            page = Page.empty();
        }

        List<SearchResultVO> records = page.getContent().stream()
                .map(this::convertToVO)
                .collect(Collectors.toList());

        return PageResult.of(dto.getPageNum(), dto.getPageSize(), page.getTotalElements(), records);
    }

    @Override
    public void indexPost(Long postId) {
        log.info("索引帖子: postId={}", postId);

        PostEntity post = postMapper.selectById(postId);
        if (post == null || post.getStatus() != 1) {
            log.warn("帖子不存在或已删除,跳过索引: postId={}", postId);
            return;
        }

        PostDocument document = new PostDocument();
        BeanUtil.copyProperties(post, document);
        postSearchRepository.save(document);

        log.info("帖子索引成功: postId={}", postId);
    }

    @Override
    public void indexAllPosts() {
        log.info("开始批量索引所有帖子");

        List<PostEntity> posts = postMapper.selectList(null);
        List<PostDocument> documents = posts.stream()
                .filter(post -> post.getStatus() == 1)
                .map(post -> {
                    PostDocument doc = new PostDocument();
                    BeanUtil.copyProperties(post, doc);
                    return doc;
                })
                .collect(Collectors.toList());

        postSearchRepository.saveAll(documents);
        log.info("批量索引完成,共索引{}条帖子", documents.size());
    }

    @Override
    public void deletePostIndex(Long postId) {
        log.info("删除帖子索引: postId={}", postId);
        postSearchRepository.deleteById(postId);
    }

    @Override
    public void clearAllIndexes() {
        log.info("清空所有索引");
        postSearchRepository.deleteAll();
    }

    private SearchResultVO convertToVO(PostDocument document) {
        SearchResultVO vo = new SearchResultVO();
        BeanUtil.copyProperties(document, vo);

        // 高亮处理
        vo.setHighlightTitle(document.getTitle());
        vo.setHighlightContent(getSummary(document.getContent()));

        return vo;
    }

    private String getSummary(String content) {
        if (StringUtils.isBlank(content)) {
            return "";
        }
        return content.length() > 200 ? content.substring(0, 200) + "..." : content;
    }
}

controller

java 复制代码
package com.forum.search.controller;

import com.forum.common.base.BaseController;
import com.forum.common.result.PageResult;
import com.forum.common.result.Result;
import com.forum.search.dto.SearchDTO;
import com.forum.search.dto.SearchResultVO;
import com.forum.search.service.SearchService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Slf4j
@Api(tags = "搜索服务")
@RestController
@RequestMapping("/api/search")
@RequiredArgsConstructor
public class SearchController extends BaseController {

    private final SearchService searchService;

    @ApiOperation("全文搜索")
    @GetMapping("/posts")
    public Result<PageResult<SearchResultVO>> search(@Valid SearchDTO dto) {
        PageResult<SearchResultVO> result = searchService.search(dto);
        return success(result);
    }

    @ApiOperation("索引帖子(管理员)")
    @PostMapping("/index/{postId}")
    public Result<Void> indexPost(@PathVariable Long postId) {
        if (!isAdmin()) {
            return error("无权限操作");
        }
        searchService.indexPost(postId);
        return success();
    }

    @ApiOperation("批量索引所有帖子(管理员)")
    @PostMapping("/index/all")
    public Result<Void> indexAllPosts() {
        if (!isAdmin()) {
            return error("无权限操作");
        }
        searchService.indexAllPosts();
        return success();
    }

    @ApiOperation("健康检查")
    @GetMapping("/health")
    public Result<String> health() {
        return success("搜索服务正常运行");
    }
}
相关推荐
灰色小旋风4 小时前
力扣合并K个升序链表C++
java·开发语言
_MyFavorite_4 小时前
JAVA重点基础、进阶知识及易错点总结(28)接口默认方法与静态方法
java·开发语言·windows
Elastic 中国社区官方博客4 小时前
当 TSDS 遇到 ILM:设计不会拒绝延迟数据的时间序列数据流
大数据·运维·数据库·elasticsearch·搜索引擎·logstash
helx825 小时前
SpringBoot中自定义Starter
java·spring boot·后端
_MyFavorite_5 小时前
JAVA重点基础、进阶知识及易错点总结(31)设计模式基础(单例、工厂)
java·开发语言·设计模式
沐风___5 小时前
Claude Code 权限模式完全指南:Auto、Bypass、Ask 三模式深度解析
大数据·elasticsearch·搜索引擎
ILYT NCTR5 小时前
SpringSecurity 实现token 认证
java
rleS IONS5 小时前
SpringBoot获取bean的几种方式
java·spring boot·后端
014-code5 小时前
Java SPI 实战:ServiceLoader 的正确打开方式(含类加载器坑)
java·开发语言
程序员榴莲6 小时前
Javase(七):继承
java