云计算百科
云计算领域专业知识百科平台

Spring Boot 分页接口怎么写:从页码校验到前端翻页

列表第一页能显示,点下一页却还是原来的数据;或者最后一页为空时,前端误以为查询失败。写分页功能时,先把页码、每页条数、总数和越界规则约定清楚,能减少很多联调中的猜测。

本文用 Java 17、Spring Boot 3.5.x 的 Web 项目做一个可以独立接入的小演示:后端返回图书分页数据,前端用两个按钮切换。数据固定在内存里,不连接数据库,重点是跑通分页契约。依据官方资料编写,未在读者环境实测。

一、先定接口规则和预期结果

演示接口是 GET /api/demo/books,page 从 1 开始,size 默认 2,范围是 1 到 100。返回 items、total、page、size、pages。超出最后一页返回空 items 和正常总数;page 小于 1 或 size 不合规返回 400,不悄悄改成另一个值。

固定数据共有五本书,每页两本,应得到三页。第一、二页各两条,第三页一条,第四页为空。items.length 只能表示当前页条数,不能拿来充当 total,否则翻页按钮会在第一页就被错误禁用。

这是一份演示接口约定,不意味着所有分页组件都从 1 开始。某些组件使用从 0 开始的索引,接入时应在边界处转换一次,并在前后端文档中写清楚,别在多个位置各加一次 1。

二、添加完整的演示控制器

准备一个已有启动类并包含 spring-boot-starter-web 的 Boot 3.5.x 工程,不需要额外添加数据库依赖。将控制器放在启动类下级包中,示例包名按工程实际修改:

package com.example.catalog.web;

import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class DemoBookController {
public record Book(long id, String title) {}
public record PageData(List<Book> items, long total,
int page, int size, long pages) {}

private static final List<Book> BOOKS = List.of(
new Book(101, "接口入门手记"),
new Book(102, "页面布局练习"),
new Book(103, "数据库小实验"),
new Book(104, "部署排障记录"),
new Book(105, "项目重构清单")
);

@GetMapping("/api/demo/books")
public PageData books(
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "2") int size) {
if (page < 1 || size < 1 || size > 100) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"page must be positive; size must be 1..100");
}
long total = BOOKS.size();
long offset = ((long) page – 1) * size;
long pages = (total + size – 1) / size;
if (offset >= total) {
return new PageData(List.of(), total, page, size, pages);
}
int from = (int) offset;
int to = (int) Math.min(offset + size, total);
return new PageData(List.copyOf(BOOKS.subList(from, to)),
total, page, size, pages);
}
}

RequestParam 的 name 写明请求参数名,减少对编译参数名信息的依赖。字符串形式的 page=abc 无法转换为 int 时,由 MVC 的参数转换流程处理;应用若有自定义异常处理器,应保持它返回合理的错误状态。

偏移量在乘法前转换成 long,避免大页码先以 int 运算发生溢出。只有确认 offset 小于这个演示列表长度后,才转回列表索引。subList 使用左闭右开区间,并复制成结果列表,避免将原列表的视图继续传出。

三、先直接请求,再接入页面

启动工程后,按实际端口访问 /api/demo/books?page=2&size=2。未设置额外上下文时,可在本机使用 http://localhost:8080/api/demo/books?page=2&size=2。依据这组固定数据,items 应包含 id 103、104,total 为 5,pages 为 3。这些是代码推导的期望值,不是实测响应截图。

如果返回 404,先核对控制器扫描、端口和上下文;如果被登录系统拦截,使用项目已有的合法会话。不要为了演示删除全站权限控制。原项目若设置了统一响应包装,还要确认前端实际读取的层级,是响应体直接 items,还是业务包装中的 data.items。

后端独立请求通过后,再把以下文件保存为 src/main/resources/static/page-demo.html。页面通过同源地址请求接口,示例默认无额外 context-path;有上下文的工程需将 fetch 地址包含正确前缀。

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>图书分页演示</title>
<ul id="books"></ul>
<p id="status" role="status"></p>
<button id="prev">上一页</button>
<button id="next">下一页</button>
<script>
let page = 1;
let pages = 0;
let busy = false;
const list = document.querySelector('#books');
const status = document.querySelector('#status');
const prev = document.querySelector('#prev');
const next = document.querySelector('#next');
function controls() {
prev.disabled = busy || page <= 1;
next.disabled = busy || page >= pages;
}
async function load(target) {
busy = true;
controls();
status.textContent = '正在加载';
try {
const response = await fetch(`/api/demo/books?page=${target}&size=2`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
list.replaceChildren();
for (const item of data.items) {
const li = document.createElement('li');
li.textContent = `${item.id}:${item.title}`;
list.append(li);
}
page = data.page;
pages = data.pages;
status.textContent = `第 ${page} 页,共 ${pages} 页,总计 ${data.total} 条`;
} catch (error) {
status.textContent = `加载失败:${error.message},可刷新重试`;
} finally {
busy = false;
controls();
}
}
prev.addEventListener('click', () => load(page – 1));
next.addEventListener('click', () => load(page + 1));
load(1);
</script>
</html>

页面展示内容使用 textContent,不把接口标题拼进 HTML。请求完成前禁用翻页按钮,避免同时发送多次请求后,较慢的旧响应覆盖新页面。页码只在成功获取数据后更新,失败不会假装已经翻页。

四、把边界也作为验收的一部分

用浏览器或接口工具分别请求五组数据:第 1 页、第 3 页、第 4 页、page=0、size=101。预期分别是两条、一条、空列表、400、400。还可以补一个 page=abc,核对参数类型错误没有被异常处理器错误包装为成功。

再到页面点击下一页直到第三页,确认最后一条数据出现,并且下一页按钮不可用。返回第一页后,上一页按钮也应不可用。错误提示要能看到,加载状态也要结束,不能让用户面对一直旋转的等待图标。

当真实数据总数为零时,页数可以是零,页面应显示空状态并关闭翻页按钮。不要把空列表解释为服务故障;但 HTTP 非成功响应也不能当作正常空列表吞掉。这两种状态的反馈不同,排查方向也不同。

五、接数据库时保留契约,替换取数方式

内存切片仅适用于本文固定小数据演示。真实业务应在数据库层做受控分页和总数查询,不能每次把整张大表读回 Java 再 subList。对外仍可保留同样的字段,这样前端无需跟着存储实现反复修改。

接库后重点检查三点:列表与总数使用相同过滤条件;排序包含稳定的唯一键,避免同值记录跨页漂移;用户传入的分页值经过范围校验,查询参数用绑定方式传递,不拼接不受信任的 SQL 片段。

数据频繁变化时,前后两次请求之间可能发生新增或删除,页数和内容会变化,不能把这个演示承诺成跨请求一致快照。先明确需求,再决定是否采用游标分页或其他一致性方案。对普通小列表,先把契约与边界做好,已经能消除很多“第二页不对”的联调问题。

前端组件接入时,另一个常见差异是每页条数变化。如果用户从每页两条切换到每页十条,通常应把当前页回到第一页再请求,避免之前的大页码在新大小下落到空页。本文没有加入条数选择器,后续扩展时应把这一行为写进交互约定,不能只替换 size 而忽略 page。

如果页面已有关键字搜索,条件变化也应重置页码,且 total 必须对应当前条件。排查时把同一次请求的 page、size、筛选条件和返回总数放在一起看。只截取列表数组给后端,很难判断到底是前端没传新页码,还是后端复用了旧参数。联调阶段可以查看 Network,但不要把包含真实个人数据的完整响应公开贴出。

本文五条数据顺序固定,因此适合检查分页公式与组件状态,不适合做性能结论。后续替换数据库实现后,必须重新验证总数、顺序、权限与边界;不要因为演示页面能翻三页,就宣称真实业务在大量数据下也同样可用。先让最小功能行为明确,再按实际规模改进,定位会更容易。

参考资料

  • Spring Framework 6.2:RequestParam 参数绑定
  • Java 17:List 与 subList 约定
  • Spring Boot 3.5:Servlet Web 应用

资料核验日期:2026-10-21。演示图书名称与代码为独立构造,无付费附件或资源下载前置条件。

赞(0)
未经允许不得转载:网硕互联帮助中心 » Spring Boot 分页接口怎么写:从页码校验到前端翻页
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!