乐趣区

关于java:Spring-Boot整合Postgres实现轻量级全文搜索

有这样一个带有搜寻性能的用户界面需要:

搜寻流程如下所示:

这个需要波及两个实体:

  • “评分(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(100) NOT 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 (id) MATCH 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 新个性专栏补全一下知识点。

  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 技术交换群,参加交换与探讨,更好的学习与提高!更多 Spring Boot 教程能够点击中转!,欢送珍藏与转发反对!

参考资料

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

欢送关注我的公众号:程序猿 DD。第一工夫理解前沿行业音讯、分享深度技术干货、获取优质学习资源

退出移动版