上篇:Java 后端详解(三):全局异常处理与 JPA 数据库映射
本篇以文章列表接口为例,讲清:分页参数如何从 URL 传到数据库 、关键词搜索如何在 JPA 里实现 、分页结果如何统一返回给前端。
一、本篇要解决的问题
改造前,文章列表接口 GET /api/articles 直接 findAll(),一次返回全部数据;前端在浏览器里用 filter() 做搜索。文章一多,就会遇到:
| 问题 | 表现 |
|---|---|
| 网络传输慢 | 每次拉全表 JSON |
| 数据库压力大 | 无 LIMIT,全表扫描 |
| 前端内存占用高 | 全量数据常驻 Pinia |
| 搜索不精准 | 无法利用数据库索引 |
改造后,分页和搜索都在服务端完成:
ini
GET /api/articles?page=0&size=10&keyword=Spring
后端只查当前页、只返回匹配关键词的记录,前端负责展示和翻页。
二、接口一览
1. 请求
| 参数 | 类型 | 默认值 | 必填 | 说明 |
|---|---|---|---|---|
page |
int | 0 |
否 | 页码,从 0 开始 |
size |
int | 10 |
否 | 每页条数,后端限制最大 50 |
keyword |
String | 无 | 否 | 搜索关键词,空则不过滤 |
示例:
http
GET /api/articles?page=0&size=10
GET /api/articles?page=1&size=10&keyword=Vue
2. 响应
json
{
"code": 200,
"message": "success",
"data": {
"content": [
{
"id": 1,
"title": "Spring Boot 入门",
"author": "张三",
"summary": "...",
"content": "...",
"createdAt": "2026-06-29T10:00:00"
}
],
"totalElements": 42,
"totalPages": 5,
"page": 0,
"size": 10
}
}
| 字段 | 含义 |
|---|---|
content |
当前页的文章列表 |
totalElements |
符合条件的总记录数 |
totalPages |
总页数 |
page |
当前页码(从 0 开始) |
size |
当前每页条数 |
三、Controller:用 @RequestParam 接收查询参数
java
@GetMapping("")
public ApiResponse<ArticlePageResult> listArticles(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
ArticlePageResult data = articleService.queryArticles(page, size, keyword);
return ApiResponse.success(data);
}
1. 三个参数,都来自 URL 查询串
| 参数 | 注解 | 来源 | 示例 |
|---|---|---|---|
page |
@RequestParam(defaultValue = "0") |
?page=0 |
第 1 页传 0 |
size |
@RequestParam(defaultValue = "10") |
?size=10 |
每页 10 条 |
keyword |
@RequestParam(required = false) |
?keyword=Vue |
可不传 |
和 @PathVariable、@RequestBody 的分工:
| 注解 | 数据位置 | 典型用途 |
|---|---|---|
@PathVariable |
URL 路径 /articles/{id} |
资源 ID |
@RequestBody |
JSON 请求体 | 创建、更新 |
@RequestParam |
URL 查询参数 ?page=0 |
分页、筛选、排序 |
2. defaultValue 与 required = false
defaultValue = "0":前端不传page时,Spring 自动填0required = false:keyword可省略,值为null
因此这两种请求等价:
http
GET /api/articles
GET /api/articles?page=0&size=10
3. 为什么用 @GetMapping("") 而不是裸 @GetMapping
类上已有 @RequestMapping("/api/articles"),列表方法映射到同一路径时,建议写 @GetMapping(""),与 @GetMapping("/{id}") 区分,避免部分 IDE 对路径组合解析异常。
四、Service:组装分页条件,决定查全量还是搜索
java
public ArticlePageResult queryArticles(int page, int size, String keyword) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 50);
Pageable pageable = PageRequest.of(
safePage, safeSize, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<Article> articlePage;
if (keyword == null || keyword.isBlank()) {
articlePage = articleRepository.findAll(pageable);
} else {
articlePage = articleRepository.search(keyword.trim(), pageable);
}
return ArticlePageResult.from(articlePage);
}
1. 参数校验:防御性编程
| 处理 | 代码 | 目的 |
|---|---|---|
| 页码不能为负 | Math.max(page, 0) |
避免非法页码 |
| 每页至少 1 条 | Math.max(size, 1) |
避免 size=0 |
| 每页最多 50 条 | Math.min(..., 50) |
防止一次查过多 |
2. Pageable:Spring Data 的分页抽象
java
Pageable pageable = PageRequest.of(safePage, safeSize,
Sort.by(Sort.Direction.DESC, "createdAt"));
| 组成部分 | 作用 |
|---|---|
safePage |
第几页(从 0 开始) |
safeSize |
每页几条 |
Sort.by(..., "createdAt") |
按创建时间倒序 |
JPA 会把它翻译成 SQL 类似:
sql
SELECT * FROM article
ORDER BY created_at DESC
LIMIT 10 OFFSET 0;
记忆 :
PageRequest.of负责「怎么分页」,Pageable是传给 Repository 的「分页说明书」。
3. 有/无关键词,走不同 Repository 方法
scss
keyword 为空 → findAll(pageable) 查全部,只分页
keyword 有值 → search(keyword, pageable) 先过滤,再分页
搜索和分页在数据库层一次完成,而不是「先查出全部再在 Java 里 filter」。
五、Repository:JPA 分页 + 自定义 JPQL 搜索
java
public interface ArticleRepository extends JpaRepository<Article, Long> {
@Query("""
SELECT a FROM Article a
WHERE LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%'))
OR LOWER(a.author) LIKE LOWER(CONCAT('%', :keyword, '%'))
OR LOWER(a.summary) LIKE LOWER(CONCAT('%', :keyword, '%'))
OR LOWER(a.content) LIKE LOWER(CONCAT('%', :keyword, '%'))
""")
Page<Article> search(@Param("keyword") String keyword, Pageable pageable);
}
1. 继承 JpaRepository 就自带分页
无需自己写实现类,以下方法由 Spring Data JPA 自动生成:
java
Page<Article> findAll(Pageable pageable);
返回 Page<Article> 而不是 List<Article>,多出的元数据包括总条数、总页数等。
2. @Query 自定义 JPQL
| 写法 | 说明 |
|---|---|
SELECT a FROM Article a |
JPQL,操作实体名 Article,不是表名 article |
LOWER(...) LIKE LOWER(CONCAT('%', :keyword, '%')) |
忽略大小写的模糊匹配 |
:keyword |
命名参数,用 @Param("keyword") 绑定 |
OR 连接多个字段 |
标题、作者、摘要、正文任一匹配即可 |
对应 SQL 思路(简化):
sql
WHERE LOWER(title) LIKE '%spring%'
OR LOWER(author) LIKE '%spring%'
...
3. @Query JPQL 逐行详解
下面把 search 方法上的 @Query 拆开说明。
整体作用
这是一段 JPQL (Java Persistence Query Language),不是直接写 MySQL SQL。
作用:在 标题、作者、摘要、正文 四个字段里做 不区分大小写的模糊搜索。
调用时如果传入 keyword = "vue",语义上等价于:
找出
title/author/summary/content任意一个字段包含vue的文章。
@Query(""" ... """)
表示:这个方法不用 Spring 默认生成的 SQL,而是执行你自定义的 JPQL。
""" ... """ 是 Java 文本块(Text Block),可以换行写长查询,比 "SELECT ..." + " WHERE ..." 更清晰。
SELECT a FROM Article a
sql
SELECT a FROM Article a
| 部分 | 含义 |
|---|---|
Article |
实体类名 ,不是表名 article |
a |
别名,后面用 a.title、a.author 引用 |
SELECT a |
查出整行,映射成 Article 对象 |
JPQL 操作的是 Java 实体,Hibernate 会再翻译成 SQL:
sql
SELECT ... FROM article a ...
对应实体 Article 上的 @Table(name = "article") 及字段 title、content、summary、author。
WHERE LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%'))
以 title 这一行为例,拆成四块:
① CONCAT('%', :keyword, '%')
把关键词前后拼上 %:
ini
keyword = "vue" → "%vue%"
% 是 SQL 通配符,表示「任意字符」:
| 模式 | 能匹配 | 不能匹配 |
|---|---|---|
%vue% |
Vue3 入门、学习vue |
中间断开的字符 |
② :keyword
命名参数 ,不是字符串字面量。调用 search("vue", pageable) 时,由 @Param("keyword") 绑定进去:
java
Page<Article> search(@Param("keyword") String keyword, Pageable pageable);
:keyword 会被替换成 "vue",且走预编译参数,有助于防止 SQL 注入。
③ LIKE
模糊匹配运算符:
sql
a.title LIKE '%vue%'
④ LOWER(...)
两边都转成小写,实现 不区分大小写:
sql
LOWER(a.title) LIKE LOWER('%vue%')
所以 Vue、VUE、vue 都能匹配。
四个 OR
java
WHERE LOWER(a.title) LIKE ...
OR LOWER(a.author) LIKE ...
OR LOWER(a.summary) LIKE ...
OR LOWER(a.content) LIKE ...
逻辑是:任意一个字段匹配就算命中。
例如 keyword = "张三":
- 标题里有「张三」→ 命中
- 或作者是「张三」→ 命中
- 或摘要/正文里出现「张三」→ 命中
四个字段都不包含 → 不返回。
完整示例
请求:
http
GET /api/articles?keyword=Spring&page=0&size=10
调用链:
erlang
ArticleService.queryArticles(0, 10, "Spring")
→ articleRepository.search("Spring", pageable)
JPQL 语义(分页排序由 Pageable 追加):
sql
SELECT a FROM Article a
WHERE LOWER(a.title) LIKE '%spring%'
OR LOWER(a.author) LIKE '%spring%'
OR LOWER(a.summary) LIKE '%spring%'
OR LOWER(a.content) LIKE '%spring%'
ORDER BY createdAt DESC
-- 再分页取前 10 条
几个注意点
| 注意点 | 说明 |
|---|---|
findAll 不会走这段逻辑 |
findAll(pageable) 和 search(...) 是两个独立方法;只有 Service 显式调用 search 时才执行上面的 JPQL |
搜的是 content 全文 |
正文可能很长,LIKE '%关键词%' 难以走索引,数据量大时会变慢 |
summary 可能为 null |
NULL LIKE ... 结果为 NULL(不算 true),但不影响其他 OR 条件 |
| 这是 JPQL,不是原生 SQL | 写的是 Article、a.title;若写表名列名,需 @Query(..., nativeQuery = true) |
记忆 :
@Query只绑定在search方法上;search由ArticleService在keyword非空时调用,实现由 Spring Data JPA 在运行时自动生成。
4. 方法参数为什么同时有 keyword 和 Pageable
java
Page<Article> search(@Param("keyword") String keyword, Pageable pageable);
keyword:参与WHERE条件Pageable:参与ORDER BY、LIMIT、OFFSET,并统计totalElements
Spring Data 会自动把两者合并成一条带分页的查询。
5. Page<T> 里有什么
| 方法 | 返回值 | 用途 |
|---|---|---|
getContent() |
List<Article> |
当前页数据 |
getTotalElements() |
long |
总记录数 |
getTotalPages() |
int |
总页数 |
getNumber() |
int |
当前页码 |
getSize() |
int |
每页大小 |
六、响应 DTO:ArticlePageResult 与 PageResult
1. 为什么不直接返回 Spring 的 Page<Article>
Page 是 Spring Data 内部类型,字段多、结构复杂,直接序列化给前端会携带多余信息,且与项目统一的 ApiResponse 风格不一致。
因此增加 DTO 做「裁剪和翻译」。
2. 通用泛型:PageResult<T>
java
@Data
public class PageResult<T> {
private List<T> content;
private long totalElements;
private int totalPages;
private int page;
private int size;
public static <T> PageResult<T> from(Page<T> page) {
return new PageResult<>(
page.getContent(),
page.getTotalElements(),
page.getTotalPages(),
page.getNumber(),
page.getSize()
);
}
}
适合任意实体分页,可复用到评论、用户等模块。
3. 文章专用:ArticlePageResult(Java Record)
java
public record ArticlePageResult(
List<Article> content,
long totalElements,
int totalPages,
@JsonProperty("page") int pageNumber,
@JsonProperty("size") int pageSize
) {
public static ArticlePageResult from(Page<Article> springPage) {
return new ArticlePageResult(
springPage.getContent(),
springPage.getTotalElements(),
springPage.getTotalPages(),
springPage.getNumber(),
springPage.getSize()
);
}
}
Record 组件在 Java 里叫 pageNumber、pageSize,通过 @JsonProperty 序列化成 JSON 的 page、size,与前端字段保持一致,同时避免与 Spring 的 Page 类型在命名上混淆。
七、完整请求链路
以 GET /api/articles?page=1&size=10&keyword=Vue 为例:
sql
浏览器 / 前端
│ GET /api/articles?page=1&size=10&keyword=Vue
▼
ArticleController.listArticles()
│ @RequestParam 绑定:page=1, size=10, keyword="Vue"
▼
ArticleService.queryArticles()
│ 校验参数 → 构造 Pageable(1, 10, 按 createdAt 倒序)
│ keyword 非空 → 走 search()
▼
ArticleRepository.search("Vue", pageable)
│ JPA 生成 JPQL + count 查询
▼
MySQL
│ WHERE ... LIKE '%Vue%' ORDER BY created_at DESC LIMIT 10 OFFSET 10
▼
Page<Article> 返回给 Service
▼
ArticlePageResult.from(page) 转成前端 DTO
▼
ApiResponse.success(data) 包一层统一响应
▼
JSON 返回前端
链路 C(和异常处理篇对照):
| 阶段 | 本篇关注点 |
|---|---|
| 参数绑定 | @RequestParam 把 query string 变成 Java 基本类型 |
| 业务层 | 决定分页规则、是否搜索 |
| 数据层 | Pageable + @Query 落地到 SQL |
| 响应层 | ArticlePageResult + ApiResponse 统一 JSON 结构 |
八、和前端如何配合(简要)
1. API 层:把参数拼进 query string
javascript
export function getArticles({ page = 0, size = 10, keyword } = {}) {
const params = { page, size }
if (keyword?.trim()) {
params.keyword = keyword.trim()
}
return request.get('/articles', { params })
}
Axios 的 params 会序列化成 ?page=0&size=10&keyword=xxx。
2. Store:保存分页状态
javascript
articles.value = data.content
totalElements.value = data.totalElements
totalPages.value = data.totalPages
page.value = data.page
size.value = data.size
3. 列表页:搜索与翻页都重新请求后端
| 用户操作 | 前端行为 |
|---|---|
| 输入关键词 | 防抖 400ms 后 fetchArticles({ page: 0, keyword }) |
| 点「上一页 / 下一页」 | fetchArticles({ page: targetPage, keyword }) |
| 清空搜索 | keyword = '',回到第 0 页 |
首页「最新 5 篇」也改为 getArticles({ page: 0, size: 5 }),不再拉全量后 slice(0, 5)。
九、改造前 vs 改造后
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 接口 | GET /api/articles 返回 List |
同一路径,加 query 参数,返回分页对象 |
| 搜索 | 前端 filter() 内存过滤 |
后端 JPQL LIKE |
| 分页 | 无,一次全返回 | page + size,默认每页 10 条 |
| 排序 | 数据库默认顺序 | 按 createdAt 倒序 |
| 数据量增大时 | 前后端都变慢 | 只传输当前页 |
十、可扩展方向
按同样模式,可以继续扩展:
| 需求 | 思路 |
|---|---|
| 按作者筛选 | Repository 增加 findByAuthorContaining,或 JPQL 加 AND 条件 |
| 多字段排序 | Sort.by(Sort.Order.desc("createdAt")) |
| 评论分页 | 复用 PageResult<Comment>,findByArticleId(articleId, pageable) |
| 关键词高亮 | 属于前端展示,后端仍只返回匹配结果 |
| 全文检索 | 数据量大时可引入 Elasticsearch,小项目 LIKE 够用 |
十一、本篇小结
分页与搜索的本质,是把「列表要展示什么」拆成三个问题,并在正确层次回答:
| 问题 | 由谁回答 | 本项目做法 |
|---|---|---|
| 要第几页、每页几条? | Controller + Service | @RequestParam + PageRequest.of |
| 要不要按关键词过滤? | Service + Repository | 有 keyword 走 search(),否则 findAll(pageable) |
| 一共多少条、当前页数据是什么? | Repository + DTO | Page<Article> → ArticlePageResult |
记住这条主线即可:
less
@RequestParam → Pageable → Repository(Page) → ArticlePageResult → ApiResponse