我先说结论:若依的分页不排斥 POST。网上很多文章说“RuoYi 分页只能走 GET”,包括不少从表单页面复制出来、随手加了一行method:'post'的同学,卡在了“返回 total 一直是 0、点击第二页又跳回第一页”的问题上,最后绕回去用 GET 了。这个结论我不太认同,与其说是框架写死了请求方式,不如说是大家在改造 POST 时没有把分页参数送到 PageHelper 手里。RuoYi 的分页底层是 PageHelper,它其实不关心你是 GET 还是 POST,它关心的是pageNum、pageSize这两个值在 SQL 执行前能不能被正确读到。
对需要把列表查询从 GET 改成 POST 的同学来说,这篇文章可以当成一份实操笔记看。我会先把 RuoYi 的分页链路完整拆一遍,找出“POST 请求分页失效”的根源,再给出三种可落地的改造方案,覆盖前端 api 封装、后端 Controller 和常用的请求包装器,顺便把我在真实项目里踩过的一些坑也整理出来。内容以 RuoYi-Vue 前后端分离版本为例,后面统称若依。
1. 先从“POST 改完永远第一页”说起
1.1 问题现场复现
很多项目为了列表查询条件的统一性和请求体安全,会想把查询接口从 GET 改成 POST。改之前代码通常是若依生成器默认的样子,SysUserController 里是@GetMapping("/list"),SysUser 作为查询条件参数接收,前端 API 文件里是method: 'get',表格组件通过params传递分页参数。这时页面一切正常。
接着大家会做两步改动。后端@GetMapping("/list")改成@PostMapping("/list"),前端 api 文件从params: query改成data: query,列表页面把分页参数也并到 query 对象里一起发送。重新刷新页面后会发现,列表数据还在,但左下角的总条数不对,点击第 2 页、第 3 页没有任何反应,永远返回第一页的数据。如果后端在 Controller 方法上顺手加了@RequestBody SysUser user,还可能遇到完全查不到任何数据的情况。
这两类问题,本质上不是同一个错误,但根源都指向同一个点:分页插件根本没有拿到前端传过来的页码。我在排查时是按这个顺序来定位的。
1.2 顺着调用链追到 TableSupport
如果你对若依的列表请求流程还不算陌生,那一定见过这三个类:BaseController、PageUtils、TableSupport。用户列表请求进来以后,Controller 方法调用startPage(),这个方法内部实际去构建一个 PageDomain,而 PageDomain 的数据来源是TableSupport.buildPageRequest()。
下面是不带异常处理的简化流程:
// BaseController 里的封装 protected void startPage() { PageUtils.startPage(); }// PageUtils 里的核心逻辑 public static void startPage() { PageDomain pageDomain = TableSupport.buildPageRequest(); Integer pageNum = pageDomain.getPageNum(); Integer pageSize = pageDomain.getPageSize(); String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy()); PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(true); }也就是说,RuoYi 把“从请求中提取分页参数”这件事做成了统一入口,Controller 根本不用关心分页字段是怎么来的。这个设计对 GET 请求很友好,因为 GET 的查询参数会全部进入request.getParameterMap(),TableSupport从 request parameter 里能直接拿到pageNum和pageSize。
问题恰恰在这里:当前端用 POST 且请求体是 JSON 时,request.getParameterMap()是空的。浏览器或 axios 发送的 JSON 请求体是原始字节流,Servlet 容器不会主动把它解析成 parameter key-value。于是TableSupport拿不到任何分页字段,最终PageHelper.startPage()会用一个兜底默认值去执行,页码固定在 1,每页大小固定在 10。这就是为什么页面永远只有第一页。
如果你在这个基础上又给 Controller 方法加了@RequestBody,情况会更隐蔽。@RequestBody能让业务查询条件完整绑定到 SysUser,但 PageHelper 依然走的是TableSupport,它依然拿不到 body 里的分页字段。两条读取路径完全不一致,造成“条件生效了、分页没生效”的怪异局面。
2. 误区复盘:不是请求方法的问题,是参数可见性的问题
2.1 若依并没有在框架层限制 GET
RuoYi 里分页相关的源码,我翻来翻去也没看到类似if ("GET".equals(request.getMethod()))之类的判断。很多初学者会从前端默认 API 写法反推框架设计:
export function listUser(query) { return request({ url: '/system/user/list', method: 'get', params: query }) }这是若依代码生成器生成的标准写法,看起来像是“分页列表必须走 GET”。但认真想一下就会明白,GET 只是若依列表页的前端习惯,不是后端能力的上限。PageHelper 本身由PageHelper.startPage(pageNum, pageSize, orderBy)触发,它在 Mapper 执行前把参数放入 ThreadLocal,等下一个 SQL 查询执行时自动拼接 limit 语句。只要这条 SQL 前面的任意位置能拿到 pageNum 和 pageSize,请求方式根本不重要。
哪怕是 POST 请求,只要请求体的编码方式是application/x-www-form-urlencoded,Servlet 容器也会把 key-value 放进request.getParameterMap(),RuoYi 的分页代码就能正常读到页码。这也是为什么有些同学把列表改成 POST 后“碰巧能用”,因为他们用 form 表单或 axios 拼接字符串传参,没有遇到 JSON body 的坑。
2.2 关键结论:分页插件看不见 JSON body
把几种常见请求方式过一遍,结论会更清楚:
| 请求方式与内容类型 | request.getParameterMap()能否读到分页参数 | RuoYi 默认分页代码能否生效 |
|---|---|---|
| GET + URL query | 能 | 能 |
POST +application/x-www-form-urlencoded | 能 | 能 |
POST +application/json | 不能 | 不能 |
POST +text/plain | 不能 | 不能 |
RuoYi 的 TableSupport 走的是 Servlet 标准的 parameter 机制,而 axios 默认发送 JSON 时,参数写在原始请求体里。JSON 只有被@RequestBody或专门的 JSON 解析器读取后,才能映射成 Java 对象。这两个体系之间天然隔着一段距离。
想支持 POST JSON,核心就一句话:要么想办法把 JSON 里的pageNum、pageSize等字段搬到request.getParameterMap()里,要么干脆绕过 TableSupport,在 SQL 执行前手动把分页参数塞给 PageHelper。后面的三种方案,全是围绕这个核心展开的。
3. POST 分页的三种落地姿势
3.1 方案一:前端改成 POST,但请求体保持表单编码
这是改动量最小的方案。如果你不想动后端 Controller,也不打算引入额外过滤器,可以在前端 api 封装层做一个小转换。页面还是正常传 query 对象,发送时把对象转成 URL 编码字符串,同时把请求头设置成application/x-www-form-urlencoded。
import request from '@/utils/request' import qs from 'qs' export function listUser(query) { return request({ url: '/system/user/list', method: 'post', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, data: qs.stringify(query) }) }后端 Controller 不用加@RequestBody,保持原来的参数接收方式:
@PostMapping("/list") public TableDataInfo list(SysUser user) // 注意不要加 @RequestBody { startPage(); List<SysUser> list = userService.selectUserList(user); return getDataTable(list); }Spring MVC 从 request parameter 里绑定对象属性,表单编码的 body 会进 parameter,所以 SysUser 里的 userName、status 等查询条件能绑上,RuoYi 的 TableSupport 也能读到页码。这个方案适合只想小范围改、不想折腾后端的老项目。
但这个方案有几个明显短板。第一,若依的查询参数偶尔会出现params[beginTime]、params[endTime]这类 BaseEntity 预留参数,qs 能处理,但遇到复杂嵌套对象时,代码维护起来不够直观。第二,列表搜索条件如果包含数组、对象或文件,表单编码会变得很难受。第三,前后端明明说好用 JSON,最后发出去的是表单,接口文档容易对不上。
所以我实际项目中很少把这个当成“终极改造”,更多是应急用。特别是在临时需要给第三方系统提供 POST 形式的分页接口时,后端不想大改,前端又不是我维护,那就让对接方自己把参数做成表单格式,两边代码都不动。
3.2 方案二:后端手动读取 body,把分页参数直接给 PageHelper
如果你希望保留 POST JSON body 的语义,那更推荐在后端做改造。最直观的做法是,Controller 使用@RequestBody接收一个查询对象,这个对象里包含分页字段和业务查询字段,然后手动调用 PageHelper。
先定义一个分页基础类。若依传统的 BaseController 分页方式依赖 PageDomain,但 PageDomain 是给 TableSupport 用的,它并不会自动从@RequestBody里取值。所以我自己会建立一个独立的分页查询基类:
import com.fasterxml.jackson.annotation.JsonIgnore; public class PageQuery { /** 当前页码 */ private Integer pageNum = 1; /** 每页显示条数 */ private Integer pageSize = 10; /** 排序列 */ private String orderByColumn; /** 排序方向 */ private String isAsc = "asc"; public Integer getPageNum() { return pageNum; } public void setPageNum(Integer pageNum) { this.pageNum = pageNum; } public Integer getPageSize() { return pageSize; } public void setPageSize(Integer pageSize) { this.pageSize = pageSize; } public String getOrderByColumn() { return orderByColumn; } public void setOrderByColumn(String orderByColumn) { this.orderByColumn = orderByColumn; } public String getIsAsc() { return isAsc; } public void setIsAsc(String isAsc) { this.isAsc = isAsc; } @JsonIgnore public String getOrderBy() { if (StringUtils.isEmpty(orderByColumn)) { return ""; } return StringUtils.toUnderScoreCase(orderByColumn) + " " + isAsc; } }查询对象再去继承这个分页基础类,同时包含业务过滤字段:
public class SysUserPageQuery extends PageQuery { private String userName; private String status; private String phonenumber; private String beginTime; private String endTime; // getter/setter 省略 }Controller 改造如下:
import com.github.pagehelper.PageHelper; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @PreAuthorize("@ss.hasPermi('system:user:list')") @PostMapping("/list") public TableDataInfo list(@RequestBody(required = false) SysUserPageQuery query) { if (query == null) { query = new SysUserPageQuery(); } String orderBy = SqlUtil.escapeOrderBySql(query.getOrderBy()); PageHelper.startPage(query.getPageNum(), query.getPageSize(), orderBy); SysUser user = new SysUser(); user.setUserName(query.getUserName()); user.setStatus(query.getStatus()); user.setPhonenumber(query.getPhonenumber()); user.getParams().put("beginTime", query.getBeginTime()); user.getParams().put("endTime", query.getEndTime()); List<SysUser> list = userService.selectUserList(user); return getDataTable(list); }注意这里我没有调用原来的startPage(),因为这个方法内部会走 TableSupport,它拿不到 body 参数。PageHelper 的职责是拦截数据库查询,只要你把起始页和每页大小传进去了,后续第一次查询就会自动分页。它本身和请求参数来源解耦,所以这种手动传参的方式在逻辑上是成立的。
这个方案的好处很明显:Controller 对 JSON body 的支持是显式、可控的。缺点是得为每个需要 POST 分页的接口额外建一个继承了 PageQuery 的查询对象,如果项目里要改的接口很多,工作量不小。对只想临时支持某个报表系统或第三方对接,倒是够用。
3.3 方案三:用请求包装器把 body JSON 映射为 parameter
第三个方案最省前端和后端接口调整,但对 Servlet 原理要求高一些。思路是写一个 Filter,当请求是 POST 且 Content-Type 为 JSON 时,把 body 字符串读出来,解析成 Map,然后将pageNum、pageSize、orderByColumn、isAsc这些字段放入自定义的 request 包装器,再放行。这样 TableSupport 从request.getParameterMap()里又能读到分页参数,RuoYi 原来的startPage()自然恢复作用。
这里有一个技术难点:request 的 body 只能读一次。如果 Filter 里先读了 body,后面 Controller 再使用@RequestBody就会读到空流。所以需要缓存 body 并重写getInputStream()/getReader(),让后续读取从缓存的字节数组里拿。下面是包装器的核心框架:
public class CacheBodyRequestWrapper extends HttpServletRequestWrapper { private final byte[] bodyBytes; private final Map<String, String[]> extraParams = new HashMap<>(); public CacheBodyRequestWrapper(HttpServletRequest request, String body, Map<String, Object> jsonMap) throws IOException { super(request); this.bodyBytes = body.getBytes(StandardCharsets.UTF_8); if (jsonMap != null) { for (Map.Entry<String, Object> entry : jsonMap.entrySet()) { Object value = entry.getValue(); if (value == null) { extraParams.put(entry.getKey(), new String[]{}); } else if (value instanceof Collection<?> collection) { extraParams.put(entry.getKey(), collection.stream() .map(String::valueOf).toArray(String[]::new)); } else { extraParams.put(entry.getKey(), new String[]{String.valueOf(value)}); } } } } @Override public String getParameter(String name) { String[] values = getParameterValues(name); if (values == null || values.length == 0) { return null; } return values[0]; } @Override public String[] getParameterValues(String name) { String[] original = super.getParameterValues(name); String[] extra = extraParams.get(name); if (original == null || original.length == 0) { return extra; } if (extra == null || extra.length == 0) { return original; } String[] merged = Arrays.copyOf(original, original.length + extra.length); System.arraycopy(extra, 0, merged, original.length, extra.length); return merged; } @Override public Map<String, String[]> getParameterMap() { Map<String, String[]> result = new HashMap<>(super.getParameterMap()); for (Map.Entry<String, String[]> entry : extraParams.entrySet()) { if (result.containsKey(entry.getKey())) { result.put(entry.getKey(), entry.getValue()); } else { result.put(entry.getKey(), entry.getValue()); } } return result; } @Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bodyBytes); return new ServletInputStream() { @Override public int read() throws IOException { return byteArrayInputStream.read(); } @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // 按需实现 } }; } @Override public BufferedReader getReader() throws IOException { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }Filter 里做拦截和 JSON 解析。如果 JSON 解析失败,直接放行原始请求,避免影响其他接口:
@Component public class PagePostFilter implements Filter { private static final String CONTENT_TYPE_JSON = "application/json"; @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; if ("POST".equalsIgnoreCase(request.getMethod()) && request.getContentType() != null && request.getContentType().startsWith(CONTENT_TYPE_JSON)) { String body = readBody(request); Map<String, Object> jsonMap = parseBody(body); CacheBodyRequestWrapper wrapper = new CacheBodyRequestWrapper(request, body, jsonMap); chain.doFilter(wrapper, response); return; } chain.doFilter(request, response); } // 读取 body、解析 JSON 的辅助方法省略 }这个方案看起来很“通用”,能让你在几乎不改 Controller 的情况下继续使用 RuoYi 默认的列表方法,甚至前端可以放心地发送 JSON。但我强烈建议,不要为了这个方案把所有 POST JSON 请求都包一层。它有几个实践代价:
- 每个 POST JSON 请求都会缓存请求体,大字段上传接口会被白白吃掉内存。
- body 解析后的 Map 与
request.getParameterMap()混合,遇到同名参数会发成合并逻辑,容易产生歧义。 - 若依中很多查询条件不是简单字符串,像
params[beginTime]这样的参数如果被错误平铺到 parameter 里,可能破坏原查询逻辑。
我自己的落地方案通常是方案二为主、方案三为辅。只给路由路径包含/list或/page的核心接口加这个 Filter,控制拦截范围,不全局拦截。
| 对比项 | 方案一:表单编码 | 方案二:手动 PageHelper | 方案三:请求包装器 |
|---|---|---|---|
| 是否保留 POST JSON | 否,转为表单 | 是 | 是 |
| 后端 Controller 改动量 | 几乎为零 | 每个接口要调整 | 初始写一次核心逻辑即可 |
| 是否影响其他接口 | 无 | 无 | 需要控制拦截范围 |
| 适合场景 | 临时对接、应急调整 | 明确以 POST JSON 为标准的长期项目 | 通用组件、RuoYi 二次开发平台 |
4. 实操示例:用户管理页 POST JSON 分页改造
下面用若依用户管理页做一个完整的最小改造示例。这个例子既包含前端页面,也包含后端查询对象和 Mapper 的复用,实践性更强。
4.1 后端创建查询对象
用户查询条件包含用户名、手机号、状态、时间范围。我直接把查询对象放在 controller 包下,不一定非要建一堆 DTO 包。
@Data @EqualsAndHashCode(callSuper = true) public class SysUserQuery extends SysUser { /** 当前页码,默认 1 */ private Integer pageNum = 1; /** 每页大小,默认 10 */ private Integer pageSize = 10; /** 排序列名 */ private String orderByColumn; /** 排序方向 */ private String isAsc; /** 开始时间 */ private String beginTime; /** 结束时间 */ private String endTime; }为什么不直接让 Controller 接收SysUser?因为 SysUser 本身是业务实体,用它承载分页参数会破坏原有语义。继承 SysUser 能保留 MyBatis 的绑定字段映射,查询条件可以直接透传到selectUserList(SysUser user),很省事。
时间范围也可以塞到 SysUser 的 params 属性里,但那样 JSON 结构会变成params: { beginTime: '...' },前端传参更绕。我选择直接把 beginTime、endTime 平铺在查询对象上,Controller 里再转存。
4.2 后端 Controller 改造
@RestController @RequestMapping("/system/user") public class SysUserController extends BaseController { @Autowired private ISysUserService userService; @PreAuthorize("@ss.hasPermi('system:user:list')") @PostMapping("/list") public TableDataInfo list(@RequestBody(required = false) SysUserQuery query) { if (query == null) { query = new SysUserQuery(); } SysUser condition = new SysUser(); condition.setUserName(query.getUserName()); condition.setPhonenumber(query.getPhonenumber()); condition.setStatus(query.getStatus()); condition.getParams().put("beginTime", query.getBeginTime()); condition.getParams().put("endTime", query.getEndTime()); String orderBy = SqlUtil.escapeOrderBySql( StringUtils.isBlank(query.getOrderByColumn()) ? "" : StringUtils.toUnderScoreCase(query.getOrderByColumn()) + " " + query.getIsAsc()); PageHelper.startPage(query.getPageNum(), query.getPageSize(), orderBy).setReasonable(true); List<SysUser> list = userService.selectUserList(condition); return getDataTable(list); } }几个细节我再强调一下。
StringUtils.toUnderScoreCase是把前端传来的驼峰列名转成数据库下划线风格,这是若依自带工具。不加的话,前端如果传orderByColumn: createTime,MyBatis 拼接 order by 时可能报列名不存在。
setReasonable(true)是 PageHelper 的合理化参数。若依原版PageUtils.startPage()里也保留了这一行。它保证页码小于 1 时自动归一,超过总页数时自动归到最后一页。手动开启能保持和原来一致的行为。
最终我还是通过condition去调用原来的userService.selectUserList。这样 Service 和 Mapper XML 完全不用动,MyBatis 只要看到非空字段就会拼接 where 条件。
4.3 前端 api 文件调整
api/system/user/index.js 的改动很小:
export function listUser(query) { return request({ url: '/system/user/list', method: 'post', data: query }) }原来的params: query改成了data: query。由于 RuoYi 的 request.js 封装会在请求头里默认带上 JSON Content-Type,所以发送的就是标准 POST JSON。
前端页面getList方法里,query 对象和分页字段通常是分开的。ElementUI 的分页组件切换页码时会触发handleQuery,它把查询条件收集好之后,再通过 listUser 发送。若依页面里标准结构是这样的:
getList() { this.loading = true const query = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize, userName: this.queryParams.userName, status: this.queryParams.status, phonenumber: this.queryParams.phonenumber, beginTime: this.queryParams.params && this.queryParams.params.beginTime, endTime: this.queryParams.params && this.queryParams.params.endTime } listUser(query) .then(response => { this.userList = response.rows this.total = response.total this.loading = false }) }如果你原来的 Vue 页面沿用了若依搜索表单,会有一点需要注意。若依搜索表单里的params.beginTime是封在queryParams.params里的一个子对象,后端直接接收 SysUser 时,SysUser 里也有 params 字段。所以列表搜索能匹配。现在改成 SysUserQuery 后,我额外平铺了 beginTime/endTime,前端如果还是传params:{beginTime:...},则后端query.getBeginTime()拿不到值。改成上面这种扁平写法,再由后端塞回 params,两边都能对上。
4.4 验证改造成果
启动项目后,打开用户管理页,在浏览器开发者工具 Network 面板观察请求。正常情况下能看到一个 POST 请求,路径是/dev-api/system/user/list,请求载荷是 JSON,里面包含 pageNum、pageSize、orderByColumn 和 isAsc。响应里的 total 应该是数据库总数,rows 是当前页数据。
点击第 2 页后再看请求载荷,pageNum 应该变为 2。如果请求体里 pageNum 变了,后端返回的仍是第一页,那大概率是后端用了原来的startPage(),它读不到 body 数据。如果返回的 total 是 0,先检查后端有没有把请求体正确绑定到 SysUserQuery 上,再检查 SysUserQuery 的 getter 是否完整。
5. 常见问题与踩坑实录
这个主题下,我在群里被问到最多的几种情况,基本集中在这几个点上。
Q1:后端方法签名保持
SysUser user,前端改成 POST JSON,为什么查询条件全空?因为
SysUser user不带@RequestBody时,Spring 把它当作 model attribute 绑定,数据来源是 request parameter。POST JSON body 不在 parameter 里,所以绑不到。解决办法要么加@RequestBody,把参数类型改成你自己的查询对象;要么使用方案一让前端发表单编码数据。Q2:加
@RequestBody后,RuoYi 的startPage()不报错,但分页无效。不报错是正常的。
startPage()内部走 TableSupport,TableSupport 从request.getParameterMap()取值。JSON body 不会自动进 parameter map,所以 pageNum、pageSize 是 null。此时分页插件会走默认值或直接抛出“分页参数错误”。你真正要做的不是调用原来的startPage(),而是手动PageHelper.startPage(pageNum, pageSize, orderBy)。Q3:启动后报
PageHelper 分页插件查询异常。一般是因为
startPage()之后,第一条执行的 SQL 不是目标列表 SQL。比如 Service 里先查了用户角色关联数据、先做了数据权限查询,再查用户列表。PageHelper 会对第一个查询生效,导致目标 SQL 反而没有分页。排查方式是把PageHelper.startPage的位置尽量靠近目标 Mapper 查询,中间不要穿插其他查询操作。Q4:接口改 POST 后,前端出现 404 或 405。
先确认后端 Controller 是否真的改成了
@PostMapping。如果没有,前端 POST 到原 GET 路径上,路由不存在,Spring 会返回 404 或 405。另外看一下若依网关层的 CORS 配置。若依前后端分离开发时,浏览器会先发 OPTIONS 预检,如果网关只放行 GET 请求,POST 会被拦截,表现像是接口不存在。Q5:ElementUI 分页组件的 layout 是 total、size、prev、pager、next,点击总条数没变。
这不是请求方式问题,而是反馈的数据结构问题。若依的
getDataTable返回的是{ rows: [], total: 0 },前端取 response.total。如果后端手动返回对象没有用 getDataTable 包装,total 字段可能取不到。检查 Network 响应体的结构,确保有 rows 和 total 两个字段。Q6:项目同时用了 MyBatis-Plus 和 PageHelper,POST 分页总是不对。
这是两个分页插件的冲突。若依从某个版本开始对 MyBatis-Plus 有额外支持,如果项目里同时存在 MyBatis-Plus 的
PaginationInnerInterceptor和 PageHelper,两个插件会分别尝试解析 SQL,互相干扰。建议统一使用一种。用 MyBatis-Plus 时,分页对象直接用Page<T>,不再需要PageHelper.startPage()。Q7:POST JSON 发送大量过滤条件,Filter 缓存 body 后接口变慢。
请求包装器会把整个 body 读入内存,如果列表过滤条件里带着很长的文本字段,内存和 CPU 成本都会上升。建议把过滤放到 doFilter 之后而不是每次都缓存。如果只是分页参数需要读取,也可以只解析前几 KB,不完整缓存整个 JSON。缓存整个 body 的方案只适合报文偏小、频率可控的业务。
Q8:POST 分页下,
orderByColumn传createTime报 SQL 语法错误。前端传的是驼峰,数据库列名下划线,需要先用
StringUtils.toUnderScoreCase()转换。同时记得用SqlUtil.escapeOrderBySql过滤掉分号、注释等特殊字符,否则有 SQL 注入风险。
6. 项目落地时我的选择与建议
若依的分页组件确实优秀,但它的优秀体现在对 GET 场景的默认封装顺手,而不是真的只接受 GET。理解清楚这一点之后,POST 分页不应再是拦路虎。
我自己在维护的项目里定过一条不成文规则:列表查询这类读取接口,如果查询条件复杂、字段多、后续可能扩展,就尽量统一走 POST JSON;如果只是简单的名称模糊搜索、状态筛选,那保留 GET 也未尝不可。改造时,我会优先把分页字段穷尽到一个基类 PageQuery 里,避免每个 Controller 都复制粘贴 pageNum、pageSize 三个 getter。
6.1 一个少踩坑的落地套路
最后分享一个我带团队时常用的套路。新接口如果一开始就要支持 POST JSON 分页,我不会等到写完再回头改,而是从 Controller 入口就把对象建模清楚。分页字段放 PageQuery 基类,业务过滤字段放子类,Service 的入参保持业务对象,Controller 里负责两个对象之间的转换。代码看起来多几行,但后续维护时最不容易出问题。
@PostMapping("/list") public TableDataInfo list(@RequestBody(required = false) SomePageQuery query) { query = query == null ? new SomePageQuery() : query; PageHelper.startPage(query.getPageNum(), query.getPageSize(), SqlUtil.escapeOrderBySql(query.getOrderBy())); // 转换查询条件,调用 service return getDataTable(list); }注意 PageHelper 的 pageNum 和 pageSize 默认值。如果前端漏传,直接空指针报错的机会虽然不大,但要兜底。我在 PageQuery 基类里给默认值,同时在 Controller 入口判断空对象,相当于双保险。
6.2 关于“把分页参数放进参数对象”的另一层思考
把分页参数和业务查询参数放在同一个请求对象里,是很多团队反对的,认为分页属于“控制参数”,不该和业务查询条件混在一起。理论上这个分层很有道理,但实际对接前端时,如果你让前端把分页参数放进请求头、其他查询条件放 body,后端 TableSupport 为了兼容请求头反而要额外写代码。我试过一次这种“优雅拆分”,结果前端在 enctype、header 上反复出问题,最后又回到了统一放 body。
所以这里我的经验是:对外提供 HTTP 接口,可以让分页字段和查询字段平级共存;在系统内部,Controller 和服务层之间还是要保持查询对象干净。一层有一层的职责,别把所有维度都压在一个对象里。
6.3 顺带提醒:分页性能优化不靠请求方式
热搜词里我看到不少关于“分页查询慢怎么用 Redis 优化”的咨询。结合 POST 分页改造提一句:请求方式只是传输形式,真正决定性能的是 SQL 和表结构。若依默认的 PageHelper 在数据量超过几十万后,深分页需要扫描很多无用的 offset 行,这是数据库通用问题。要优化可以走覆盖索引、延迟关联、游标分页等方式,跟 GET 还是 POST 没有关系。如果你因为列表慢而把 GET 改成 POST,那是完全走错了方向。
开发若依项目时,很多问题并不是框架能力不够,而是我们对框架的预设不对。分页支持 POST 这件事也是如此——理解清楚参数读取链路后,你会发现其实可以有很多种解法。希望这篇笔记能帮你在下一次改造 POST 分页时少踩几个坑。