Java 后端详解(四):分页与搜索

上篇: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. defaultValuerequired = false

  • defaultValue = "0":前端不传 page 时,Spring 自动填 0
  • required = falsekeyword 可省略,值为 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.titlea.author 引用
SELECT a 查出整行,映射成 Article 对象

JPQL 操作的是 Java 实体,Hibernate 会再翻译成 SQL:

sql 复制代码
SELECT ... FROM article a ...

对应实体 Article 上的 @Table(name = "article") 及字段 titlecontentsummaryauthor

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%')

所以 VueVUEvue 都能匹配。

四个 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 写的是 Articlea.title;若写表名列名,需 @Query(..., nativeQuery = true)

记忆@Query 只绑定在 search 方法上;searchArticleServicekeyword 非空时调用,实现由 Spring Data JPA 在运行时自动生成。

4. 方法参数为什么同时有 keywordPageable

java 复制代码
Page<Article> search(@Param("keyword") String keyword, Pageable pageable);
  • keyword:参与 WHERE 条件
  • Pageable:参与 ORDER BYLIMITOFFSET,并统计 totalElements

Spring Data 会自动把两者合并成一条带分页的查询。

5. Page<T> 里有什么

方法 返回值 用途
getContent() List<Article> 当前页数据
getTotalElements() long 总记录数
getTotalPages() int 总页数
getNumber() int 当前页码
getSize() int 每页大小

六、响应 DTO:ArticlePageResultPageResult

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 里叫 pageNumberpageSize,通过 @JsonProperty 序列化成 JSON 的 pagesize,与前端字段保持一致,同时避免与 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 keywordsearch(),否则 findAll(pageable)
一共多少条、当前页数据是什么? Repository + DTO Page<Article>ArticlePageResult

记住这条主线即可:

less 复制代码
@RequestParam → Pageable → Repository(Page) → ArticlePageResult → ApiResponse
相关推荐
她的男孩1 小时前
数据权限为什么不能只靠注解?Forge 的 Mapper 层 SQL 改写源码拆解
java·后端·架构
labixiong2 小时前
还原一个完整符合规范的 Promise(二)
前端·javascript
烤代码的吐司君2 小时前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
苏三说技术2 小时前
为什么越来越多的人使用FastAPI?
后端
JavaGuide2 小时前
比 iTerm2 更适合 Claude Code/Codex 的终端,我换成 Ghostty 了
人工智能·后端
tntxia2 小时前
Mybatis的日志输入
java
To_OC2 小时前
万字解析《JS 语言精粹》之第五章:继承 5 大核心精髓(JS 原型核心)
前端·javascript·代码规范
DyLatte3 小时前
AI 时代,最危险的不是被替代,而是努力不沉淀
前端·后端·程序员
神奇小汤圆3 小时前
架构师必备:CPU使用率不均匀排查
后端