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

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("搜索服务正常运行");
    }
}
相关推荐
ch.ju10 小时前
Java Programming Chapter 3——Default value of array
java·开发语言
bandaoyu10 小时前
【CUDA】store/load普通访存 vs 非临时(Non-Temporal)访存
java·开发语言·redis
AI人工智能+电脑小能手10 小时前
【大白话说Java面试题 第53题】【JVM篇】第13题:JVM采用什么算法判断一个对象是否需要被回收?
java·jvm·算法·面试
逍遥德10 小时前
常见的任务调度框架介绍
java·spring boot·中间件
jiayong2310 小时前
Memory 写入、检索与纠错机制:让 Agent 记住,也让它忘对
java·服务器·网络·hermes
小赵不会秃头10 小时前
数据结构Day 06:线性结构、库操作及 Makefile 完整学习笔记
java·linux·数据结构·算法·面试
xqqxqxxq10 小时前
Maven 完整配置与使用技术笔记
java·笔记·maven
砍材农夫10 小时前
物联网 基于netty理解粘包/拆包
java·物联网·struts
Counter-Strike大牛10 小时前
Nacos源码修改tomcat版本方法
java·tomcat
念越10 小时前
HTTPS 安全内核:对称与非对称加密的博弈,数字证书一战定局
java·网络·网络协议·安全·https