有這樣一個帶有搜索功能的用戶界面需求:
搜索流程如下所示:
這個需求涉及兩個實體:
- “評分(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應用
- 項目依賴關係(這裏使用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()
}
application.yaml
中配置數據庫連接信息
spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres
username: postgres
password: postgres
- 數據模型
定義需要用到的各種數據模型:
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新特性專欄補全一下知識點。
- 數據訪問(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(?) ";
}
}
- 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教程可以點擊直達!,歡迎收藏與轉發支持!
參考資料
歡迎關注我的公衆號:程序猿DD。第一時間瞭解前沿行業消息、分享深度技術乾貨、獲取優質學習資源