Spring Boot 整合 Postgres 实现轻量级全文搜索

通义灵码体验活动抽奖进行中,100%中奖率,参与即有奖。已有群友抽到键盘、鼠标等大礼。只要动动手指就行!正在摸鱼的话,不妨进来抽一把,试试自己的手气~具体如何操作见:抽AI盲盒赢大奖,100%中奖,永不落空~

有这样一个带有搜索功能的用户界面需求:

搜索流程如下所示:

这个需求涉及两个实体:

  • “评分(Rating)、用户名(Username)”数据与User实体相关
  • “创建日期(create date)、观看次数(number of views)、标题(title)、正文(body)”与Story实体相关

需要支持的功能对User实体中的评分(Rating)的频繁修改以及下列搜索功能:

  • 按User评分进行范围搜索
  • 按Story创建日期进行范围搜索
  • 按Story浏览量进行范围搜索
  • 按Story标题进行全文搜索
  • 按Story正文进行全文搜索

Postgres中创建表结构和索引

创建users表和stories表以及对应搜索需求相关的索引,包括:

  • 使用 btree 索引来支持按User评分搜索
  • 使用 btree 索引来支持按Story创建日期、查看次数的搜索
  • 使用 gin 索引来支持全文搜索内容(同时创建全文搜索列fulltext,类型使用tsvector以支持全文搜索)

具体创建脚本如下:

--Create Users table
CREATE TABLE IF NOT EXISTS users
(
  id bigserial NOT NULL,
  name character varying(100NOT NULL,
rating integer,
PRIMARY KEY (id)
)
;
CREATE INDEX usr_rating_idx
ON users USING btree
(rating ASC NULLS LAST)
TABLESPACE pg_default
;

--Create Stories table
CREATE TABLE  IF NOT EXISTS stories
(
    id bigserial NOT NULL,
    create_date timestamp without time zone NOT NULL,
    num_views bigint NOT NULL,
    title text NOT NULL,
    body text NOT NULL,
    fulltext tsvector,
    user_id bigint,
    PRIMARY KEY (id),
CONSTRAINT user_id_fk FOREIGN KEY (user_id)
REFERENCES users (idMATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
NOT VALID
)
;
CREATE INDEX str_bt_idx
ON stories USING btree
(create_date ASC NULLS LAST,
num_views ASC NULLS LAST, user_id ASC NULLS LAST)
;

CREATE INDEX fulltext_search_idx
ON stories USING gin
(fulltext)
;

创建Spring Boot应用

  1. 项目依赖关系(这里使用Gradle构建):
plugins {
   id 'java'
   id 'org.springframework.boot' version '3.1.3'
   id 'io.spring.dependency-management' version '1.1.3'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
   sourceCompatibility = '17'
}

repositories {
   mavenCentral()
}

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
   implementation 'org.springframework.boot:spring-boot-starter-web'
   runtimeOnly 'org.postgresql:postgresql'
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
   useJUnitPlatform()
}
  1. application.yaml中配置数据库连接信息
spring:
  datasource: 
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres
  1. 数据模型

定义需要用到的各种数据模型:

public record Period(String fieldName, LocalDateTime min, LocalDateTime max) {
}

public record Range(String fieldName, long min, long max) {
}

public record Search(List<Period> periods, List<Range> ranges, String fullText, long offset, long limit) {
}

public record UserStory(Long id, LocalDateTime createDate, Long numberOfViews,
                        String title, String body, Long userRating, String userName, Long userId)
 
{
}

这里使用 Java 16推出的新特性record 实现,所以代码非常简洁。如果您还不了解的话,可以前往程序猿DD的Java新特性专栏补全一下知识点:https://www.didispace.com/java-features/

  1. 数据访问(Repository)
@Repository
public class UserStoryRepository {

    private final JdbcTemplate jdbcTemplate;


    @Autowired
    public UserStoryRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<UserStory> findByFilters(Search search) {
        return jdbcTemplate.query(
                """
                  SELECT s.id id, create_date, num_views, 
                         title, body, user_id, name user_name, 
                         rating user_rating 
                  FROM stories s INNER JOIN users u 
                      ON s.user_id = u.id
                  WHERE true
                """
 + buildDynamicFiltersText(search)
                        + " order by create_date desc offset ? limit ?",
                (rs, rowNum) -> new UserStory(
                        rs.getLong("id"),
                        rs.getTimestamp("create_date").toLocalDateTime(),
                        rs.getLong("num_views"),
                        rs.getString("title"),
                        rs.getString("body"),
                        rs.getLong("user_rating"),
                        rs.getString("user_name"),
                        rs.getLong("user_id")
                ),
                buildDynamicFilters(search)
        );
    }

    public void save(UserStory userStory) {
        var keyHolder = new GeneratedKeyHolder();

        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection
                .prepareStatement(
                    """
                      INSERT INTO stories (create_date, num_views, title, body, user_id)
                          VALUES (?, ?, ?, ?, ?)
                    """
,
                    Statement.RETURN_GENERATED_KEYS
            );
            ps.setTimestamp(1, Timestamp.valueOf(userStory.createDate()));
            ps.setLong(2, userStory.numberOfViews());
            ps.setString(3, userStory.title());
            ps.setString(4, userStory.body());
            ps.setLong(5, userStory.userId());

            return ps;
        }, keyHolder);

        var generatedId = (Long) keyHolder.getKeys().get("id");

        if (generatedId != null) {
            updateFullTextField(generatedId);
        }
    }

    private void updateFullTextField(Long generatedId) {
        jdbcTemplate.update(
            """
              UPDATE stories SET fulltext = to_tsvector(title || ' ' || body)
              where id = ?
            """
,
            generatedId
        );
    }

    private Object[] buildDynamicFilters(Search search) {
        var filtersStream = search.ranges().stream()
                .flatMap(
                    range -> Stream.of((Object) range.min(), range.max())
                );

        var periodsStream = search.periods().stream()
                .flatMap(
                    range -> Stream.of((Object) Timestamp.valueOf(range.min()), Timestamp.valueOf(range.max()))
                );

        filtersStream = Stream.concat(filtersStream, periodsStream);

        if (!search.fullText().isBlank()) {
            filtersStream = Stream.concat(filtersStream, Stream.of(search.fullText()));
        }

        filtersStream = Stream.concat(filtersStream, Stream.of(search.offset(), search.limit()));

        return filtersStream.toArray();
    }

    private String buildDynamicFiltersText(Search search) {
        var rangesFilterString =
                Stream.concat(
                  search.ranges()
                        .stream()
                        .map(
                            range -> String.format(" and %s between ? and ? ", range.fieldName())
                        ),
                  search.periods()
                        .stream()
                        .map(
                            range -> String.format(" and %s between ? and ? ", range.fieldName())
                        )
                  )
                  .collect(Collectors.joining(" "));

        return rangesFilterString + buildFulltextFilterText(search.fullText());
    }

    private String buildFulltextFilterText(String fullText) {
        return fullText.isBlank() ? "" : " and fulltext @@ plainto_tsquery(?) ";
    }
}
  1. Controller实现
@RestController
@RequestMapping("/user-stories")
public class UserStoryController {
    private final UserStoryRepository userStoryRepository;

    @Autowired
    public UserStoryController(UserStoryRepository userStoryRepository) {
        this.userStoryRepository = userStoryRepository;
    }

    @PostMapping
    public void save(@RequestBody UserStory userStory) {
        userStoryRepository.save(userStory);
    }

    @PostMapping("/search")
    public List<UserStory> search(@RequestBody Search search) {
        return userStoryRepository.findByFilters(search);
    }
}

小结

本文介绍了如何在Spring Boot中结合Postgres数据库实现全文搜索的功能,该方法比起使用Elasticsearch更为轻量级,非常适合一些小项目场景使用。希望本文内容对您有所帮助。如果您学习过程中如遇困难?可以加入我们超高质量的Spring技术交流群,参与交流与讨论,更好的学习与进步,点击加群。码字不易,欢迎点赞、在看、转发支持!

参考资料

  • Postgres full-text search Spring boot integration
  • Java 16 新特性:record


你还在购买国内的各种昂贵又低质的技术教程吗?这里给大家推荐下我们自研的Youtube视频语音转换插件(https://youtube-dubbing.com/),一键外语转中文,英语不好的小伙伴也可以轻松的学习油管上的优质教程了,下面是演示视频,可以直观的感受一下:

------我们创建了一个高质量的技术交流群,与优秀的人在一起,自己也会优秀起来,赶紧点击加群,享受一起成长的快乐。

推荐阅读

··································

点击卡片关注我,分享一线前沿干货

点击阅读原文,直达Java新特性专栏

相关推荐

  • 告别 BeanUtil.copyProperties,这款IDEA插件才是最优的替代方案!
  • Three.js 快速入门指南
  • 公司新来一个技术总监:谁再在 SQL 中写 in 和 not in,直接走人!
  • redis分布式锁的原理及java的实现代码
  • Hive和Hbase数据互通(用户画像)
  • 原生 JS 打造的一款开箱即用的后台 UI 框架!!!
  • 投身AI的路上,我为什么决定去趟南极?
  • 鹅厂程序员推荐的好书系列第一弹
  • 浙大应届生,校招入职,上班3个月被裁员,Boss都翻烂了也没人要,开始怀疑自己
  • 简单聊聊JVM中的几种垃圾收集算法
  • 人工智能周刊#1:ComfyUI图像放大、Claude 3、北京大学 Open-Sora、经典计算机书籍推荐清单
  • 数据“隐领”未来!【隐私计算实训营】限时免费招募!
  • 角色扮演大模型的碎碎念
  • CCL 2024系统展示征集
  • 全球首个AI程序员诞生,码农饭碗一夜被砸!10块IOI金牌华人团队震撼打造,996写代码训练模型
  • k8s 到底是什么,架构是怎么样的?
  • 我想遇见一群这样的人
  • 用 PrettyError优雅的处理Python错误信息
  • [开源]企业级快速开发框架,低代码、跨平台、简单快捷、开箱即用
  • 通用AI Agent里程碑!谷歌打造游戏“神队友”,操作像人,会600项技能