News
首页  -  资讯  - 
Java 后端详解(四):分页与搜索
Java 后端详解(四):分页与搜索 上篇:Java 后端详解(三):全局异常处理与 JPA 信息库映射 本篇以文章列表接口为例,讲清: 分页参数如何从 URL 传到数据库 、 关键词搜索如何在 JPA 里实现 、 分页结果如何统一返回给前端 。 一、本篇要解决的问题 改造前,文章列表接口 GET /api/articles 直接 findAll() ,一次返回全部数据;前端在浏览器里用 filt...

Java 后端详解(四):分页与搜索 上篇:Java 后端详解(三):全局异常处理与 JPA 信息库映射 本篇以文章列表接口为例,讲清: 分页参数如何从 URL 传到数据库 、 关键词搜索如何在 JPA 里实现 、 分页结果如何统一返回给前端 。 一、本篇要解决的问题 改造前,文章列表接口 GET /api/articles 直接 findAll() ,一次返回全部数据;前端在浏览器里用 filter() 做搜索。文章一多,就会遇到: 问题 表现 网络传输慢 每次拉全表 JSON 数据库压力大 无 LIMIT ,全表扫描 前端内存占用高 全量数据常驻 Pinia 搜索不精准 无法利用数据库索引 改造后, 分页和搜索都在服务端完成 : GET /api/articles? page = 0 &size= 10 &keyword=Spring 后端只查当前页、只返回匹配关键词的记录,前端负责展示和翻页。 二、接口一览 1. 请求 参数 类型 默认值 必填 说明 page int 0 否 页码, 从 0 开始 size int 10 否 每页条数,后端限制最大 50 keyword String 无 否 搜索关键词,空则不过滤 示例: GET /api/articles?page=0&size=10 GET /api/articles?page=1&size=10&keyword=Vue 2. 响应 { "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 接收查询参数 @GetMapping("") public ApiResponse 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 自动填 0 required = false : keyword 可省略,值为 null 因此这两种请求等价: GET /api/articles GET /api/articles?page=0&size=10 3. 为什么用 @GetMapping("") 而不是裸 @GetMapping 类上已有 @RequestMapping("/api/articles") ,列表方法映射到同一路径时,建议写 @GetMapping("") ,与 @GetMapping("/{id}") 区分,避免部分 IDE 对路径组合解析异常。 四、Service:组装分页条件,决定查全量还是搜索 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

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 的分页抽象 Pageable pageable = PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "createdAt" )); 组成部分 作用 safePage 第几页(从 0 开始) safeSize 每页几条 Sort.by(..., "createdAt") 按创建时间倒序 JPA 会把它翻译成 SQL 类似: SELECT * FROM article ORDER BY created_at DESC LIMIT 10 OFFSET 0 ; 记忆 : PageRequest.of 负责「怎么分页」, Pageable 是传给 Repository 的「分页说明书」。 3. 有/无关键词,走不同 Repository 方法 keyword 为空 → findAll (pageable) 查全部,只分页 keyword 有值 → search (keyword, pageable) 先过滤,再分页 搜索和分页在数据库层一次完成,而不是「先查出全部再在 Java 里 filter」。 五、Repository:JPA 分页 + 自定义 JPQL 搜索 public interface ArticleRepository extends JpaRepository { @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
search ( @Param("keyword") String keyword, Pageable pageable) ; } 1. 继承 JpaRepository 就自带分页 无需自己写实现类,以下方法由 Spring Data JPA 自动生成: Page
findAll (Pageable pageable) ; 返回 Page
而不是 List
,多出的元数据包括总条数、总页数等。 2. @Query 自定义 JPQL 写法 说明 SELECT a FROM Article a JPQL,操作实体名 Article ,不是表名 article LOWER(...) LIKE LOWER(CONCAT('%', :keyword, '%')) 忽略大小写的模糊匹配 :keyword 命名参数,用 @Param("keyword") 绑定 OR 连接多个字段 标题、作者、摘要、正文任一匹配即可 对应 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 SELECT a FROM Article a 部分 含义 Article 实体类名 ,不是表名 article a 别名,后面用 a.title 、 a.author 引用 SELECT a 查出整行,映射成 Article 对象 JPQL 操作的是 Java 实体 ,Hibernate 会再翻译成 SQL: SELECT ... FROM article a ... 对应实体 Article 上的 @Table(name = "article") 及字段 title 、 content 、 summary 、 author 。 WHERE LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')) 以 title 这一行为例,拆成四块: ① CONCAT('%', :keyword, '%') 把关键词前后拼上 % : keyword = "vue" → "%vue%" % 是 SQL 通配符,表示「任意字符」: 模式 能匹配 不能匹配 %vue% Vue3 入门 、 学习vue 中间断开的字符 ② :keyword 命名参数 ,不是字符串字面量。调用 search("vue", pageable) 时,由 @Param("keyword") 绑定进去: Page
search ( @Param("keyword") String keyword, Pageable pageable) ; :keyword 会被替换成 "vue" ,且走预编译参数,有助于防止 SQL 注入。 ③ LIKE 模糊匹配运算符: a.title LIKE '%vue%' ④ LOWER(...) 两边都转成小写,实现 不区分大小写 : LOWER (a.title) LIKE LOWER ( '%vue%' ) 所以 Vue 、 VUE 、 vue 都能匹配。 四个 OR WHERE LOWER (a.title) LIKE ... OR LOWER (a.author) LIKE ... OR LOWER (a.summary) LIKE ... OR LOWER (a.content) LIKE ... 逻辑是: 任意一个字段匹配就算命中 。 例如 keyword = "张三" : 标题里有「张三」→ 命中 或作者是「张三」→ 命中 或摘要/正文里出现「张三」→ 命中 四个字段都不包含 → 不返回。 完整示例 请求: GET /api/articles?keyword=Spring&page=0&size=10 调用链: ArticleService. query Articles( 0 , 10 , "Spring" ) → articleRepository.search( "Spring" , pageable) JPQL 语义(分页排序由 Pageable 追加): 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 Page
search ( @Param("keyword") String keyword, Pageable pageable) ; keyword :参与 WHERE 条件 Pageable :参与 ORDER BY 、 LIMIT 、 OFFSET ,并统计 totalElements Spring Data 会自动把两者合并成一条带分页的查询。 5. Page 里有什么 方法 返回值 用途 getContent() List
当前页数据 getTotalElements() long 总记录数 getTotalPages() int 总页数 getNumber() int 当前页码 getSize() int 每页大小 六、响应 DTO: ArticlePageResult 与 PageResult 1. 为什么不直接返回 Spring 的 Page
Page 是 Spring Data 内部类型,字段多、结构复杂,直接序列化给前端会携带多余信息,且与方案统一的 ApiResponse 风格不一致。 因此增加 DTO 做「裁剪和翻译」。 2. 通用泛型: PageResult @Data public class PageResult { private List content; private long totalElements; private int totalPages; private int page; private int size; public static PageResult from (Page page) { return new PageResult <>( page.getContent(), page.getTotalElements(), page.getTotalPages(), page.getNumber(), page.getSize() ); } } 适合任意实体分页,可复用到评论、使用者等模块。 3. 文章专用: ArticlePageResult (Java Record) public record ArticlePageResult ( List
content, long totalElements, int totalPages, @JsonProperty("page") int pageNumber, @JsonProperty("size") int pageSize ) { public static ArticlePageResult from (Page
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 为例: 浏览器 / 前端 │ 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 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:保存分页状态 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 , findByArticleId(articleId, pageable) 关键词高亮 属于前端展示,后端仍只返回匹配结果 全文检索 数据量大时可引入 Elasticsearch,小项目 LIKE 够用 十一、本篇小结 分页与搜索的本质,是把「列表要展示什么」拆成三个问题,并在正确层次回答: 问题 由谁回答 本项目做法 要第几页、每页几条? Controller + Service @RequestParam + PageRequest.of 要不要按关键词过滤? Service + Repository 有 keyword 走 search() ,否则 findAll(pageable) 一共多少条、当前页数据是什么? Repository + DTO Page
→ ArticlePageResult 记住这条主线即可: @RequestParam → Pageable → Repository (Page) → ArticlePageResult → ApiResponse