
简介这是一份Spring、SpringMVC、MyBatis与EasyUI整合的分页Demo面向正在学习SSM框架整合及Web分页功能的Java开发者。项目采用Maven构建完整演示了后端数据库查询、MyBatis映射、SpringMVC控制器处理与前端EasyUI分页组件之间的协作流程可帮助理解分页参数传递、PageHelper或自定义拦截器等实现思路。压缩包共122个文件以Java源码、XML配置、SQL脚本、PNG截图为主另有JSP页面、JS、CSS及Eclipse项目配置等充分覆盖了一个SSMEasyUI项目的典型结构压缩后仅1.68MB轻量易用。目前已有260人学习下载。通过学习该Demo开发者可快速掌握SSM框架整合要点、分页前后端联调方法并基于现成的SQL和前端代码完成二次扩展适合作为课设、毕设或企业入门项目的参考资料。 最近在维护一套spring springmvc mybatis easyui拼起来的后台管理系统代码是从上一个团队手里接过来的。功能本身不算复杂但每次新增一个列表接口最容易出问题的往往不是业务 SQL而是分页。前端 EasyUI DataGrid 明明配置了 pagination后端也写了分页查询结果接口一调total 是 0、第二页翻不动、搜索条件一加数据全乱套。这种问题说大不大但排查起来要顺着页面、请求参数、Controller、Mapper SQL 一路找没有经验确实会卡半天。下面就把这套组合从请求参数、JSON 返回格式到 MyBatis 分页的完整链路讲清楚并把我自己联调踩过的坑和验证方法一起整理出来。适合正在接手 SSM 老项目或者第一次在 SpringMVC 项目里给 EasyUI 表格加分页的同事参考。1. 先把这个技术组合的分工和分页位置对齐1.1 四层技术各自管什么我对这套组合的理解是Spring 管对象SpringMVC 管 HTTP 请求和响应MyBatis 管数据库访问EasyUI 管页面表格展示。分页不是某一个层的功能而是横跨四层的一套约定。很多项目失败不是因为某一层写错了而是四层之间的暗号没有对齐。层级主要职责分页关心的事前端 EasyUI DataGrid表格渲染、分页条当前页码、每页条数SpringMVC Controller接收请求参数返回 JSONpage/rows 参数怎么接收Service / MyBatis数据查询、总数查询分页 SQL、count SQL返回 JSON交给 DataGrid 渲染total 总条数、rows 当前页数组1.2 分页容易在哪里走样我见过不少同事在第一次接触 EasyUI 时习惯性地用 Element UI 或 Bootstrap Table 的思维去思考分页心里想的是 pageNum、pageSize实际 EasyUI DataGrid 默认给后端传的是 page 和 rows。这两个词表面上只是命名不同但如果大家都按自己的习惯写前后端一对接就会出现total 能拿到表格只有第一页、后端按 pageNum 取参全是 null这类问题。另一个容易走样的点是页码从 0 开始还是从 1 开始。EasyUI 的默认分页条页码是从 1 开始的而后端很多同事写偏移量时直接(page - 1) * rows。如果前端传 1后端计算成 0那正好对如果某个环节写成了(page - 2) * rows那第一页就会直接从第 rows 条开始取表现就是第一页数据莫名少了前几条。所以解决分页问题前先别急着改代码。打开浏览器开发者工具看看实际发出去的请求参数是什么看看后端返回值是什么。把谁传了什么、谁返回了什么对齐问题往往已经解决一半。2. EasyUI DataGrid 的请求参数和返回 JSON 约定2.1 前端 pagination 到底做了什么一个最基础的 EasyUI DataGrid 配置大概是这样的$(#dg).datagrid({ url: /user/list, method: post, pagination: true, pageSize: 20, pageList: [10, 20, 50, 100], columns: [[ { field: id, title: ID }, { field: name, title: 姓名 } ]] });当表格加载时EasyUI 会自动把分页参数拼到请求里实际请求大概是POST /user/list page1rows20注意这里rows不是数据行数组而是每页显示多少条和常说的 pageSize 一个意思。page是当前页码从 1 开始。这两个参数名是 EasyUI 的默认约定也是后端最容易搞混的地方。如果需要在查询时带上搜索条件可以直接调datagrid(load, {...})$(#searchBtn).click(function () { $(#dg).datagrid(load, { name: $(#name).val(), deptId: $(#deptId).combobox(getValue) }); });这样发出去的请求会变成page1rows20name张三deptId102.2 后端必须返回 total rowsEasyUI DataGrid 对返回 JSON 的要求非常固定必须包含total和rows两个字段。total是符合条件的总条数rows是当前页数据数组。一个标准的返回结构是{ total: 100, rows: [ { id: 1, name: 张三 }, { id: 2, name: 李四 } ] }很多后端会把字段封装成records、list、data或者返回PageResult里包含pageNum、pageSize这些 EasyUI 默认都不认识。结果是表格能显示第一页数据但底部分页栏的 total 永远是 0翻页也会异常。如果后端接口已经定了另一种结构又不想改后端可以在前端 DataGrid 配置里加loadFilter做映射$(#dg).datagrid({ url: /user/list, pagination: true, loadFilter: function (data) { if (data data.result) { return { total: data.result.total, rows: data.result.list }; } return data; } });我的建议是新写的接口直接按total rows返回别留映射这层。老项目如果改后端成本太高再用loadFilter但要在注释里写清楚为什么。3. Controller 到 MyBatis三种分页落地方式和取舍3.1 手写 LIMIT 参数最直白的入门写法如果你的项目不想引额外依赖手写 LIMIT 是最直观的方式。Controller 接收 page 和 rowsService 计算 offsetMapper 的 SQL 用 LIMIT 接收两个参数。RequestMapping(/list) ResponseBody public MapString, Object list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 20) Integer rows, UserQuery query) { int offset (page null || page 1) ? 0 : (page - 1) * rows; query.setOffset(offset); query.setRows(rows); ListUser list userMapper.selectPage(query); int total userMapper.countPage(query); MapString, Object result new HashMap(); result.put(total, total); result.put(rows, list); return result; }Mapper XML 里大概是sql idqueryCondition where if testname ! null and name ! and name like concat(%, #{name}, %) /if if testdeptId ! null and dept_id #{deptId} /if /where /sql select idselectPage resultTypeUser select * from t_user include refidqueryCondition/ order by id desc limit #{offset}, #{rows} /select select idcountPage resultTypeint select count(*) from t_user include refidqueryCondition/ /select这种方式最大的好处是没有魔法SQL 完全可控。代价是每个列表接口都要写 selectPage 和 countPage 两段 SQL还要保证它们查询条件完全一致。条件一旦多起来sql片段的重要性就非常明显了。不同数据库的方言也不一样。手写时至少要清楚自己的库用哪种写法数据库分页写法MySQLLIMIT #{offset}, #{rows}PostgreSQLLIMIT #{rows} OFFSET #{offset}Oracle 12cOFFSET #{offset} ROWS FETCH NEXT #{rows} ROWS ONLYSQL Server 2012OFFSET #{offset} ROWS FETCH NEXT #{rows} ROWS ONLY3.2 PageHelper省心但有几条红线不能碰如果项目里允许引入 PageHelper我通常会优先用插件因为省去了写 count 查询的麻烦也避免了 count 和 list 查询条件不一致这类低级错误。SSM 项目里除了依赖要在 MyBatis 配置里注册分页拦截器plugins plugin interceptorcom.github.pagehelper.PageInterceptor property namehelperDialect valuemysql/ /plugin /pluginsJava 代码里这样用PageHelper.startPage(page, rows); ListUser list userMapper.selectList(query); PageInfoUser pageInfo new PageInfo(list); MapString, Object result new HashMap(); result.put(total, pageInfo.getTotal()); result.put(rows, pageInfo.getList());PageHelper 的原理可以简单理解成startPage把分页参数放到 ThreadLocalMyBatis 执行下一条 select 语句时拦截器动态给这条 SQL 拼接 LIMIT并额外生成一条 count 查询。正是因为这样它有几条非常容易踩的红线startPage后面必须紧跟第一条 Mapper 查询中间不能插入其他无关查询。同一个方法里如果有多条 select只有紧跟startPage后的那一条会被分页后面的不会。如果在 SpringMVC 的 Controller 里用要确保一个请求一个线程不能随便开异步线程去查否则 ThreadLocal 里的分页参数可能串到别的请求。稳妥做法是在 finally 里调用PageHelper.clearPage()防止异常场景下分页参数残留。3.3 自定义拦截器理解原理比拿来用更重要有些老项目因为历史原因不想引第三方分页插件或者需要兼容多套数据库会自己写 MyBatis 拦截器。我也做过但一般不建议在没有充分测试的情况下直接上生产。拦截器的思路大致是实现org.apache.ibatis.plugin.Interceptor拦截Executor的query方法判断参数里是否包含分页对象如果有就先改写 BoundSql 生成 count 查询再把原 SQL 拼接上当前方言的分页语句最后把结果封装成带 total 的对象。这个方案难点不在截获 SQL而在如何判断参数对象里的分页字段、如何同时处理 count 和 list 两条 SQL、如何兼容多方言。说实话做出来至少需要两三天的测试成本。如果你只是要解决一个简单表格的分页手写 LIMIT 或 PageHelper 已经够了。自定义拦截器更适合需要沉淀公司内部框架、统一分页规范的大团队。4. 联调中出现最多的四个分页症状和排查路径4.1 症状对照表我在项目里遇到最多的分页问题基本可以收敛成下面四类症状最常见原因快速处理方向total 显示 0表格却有数据返回 JSON 没有 total 字段或字段名不叫 total查看响应 JSON改字段名或加 loadFilter点第二页数据和第一页完全一样后端没有根据 page 计算 offsetSQL 没拼接 LIMIT看 SQL 日志确认 LIMIT 参数是否变化搜索条件后翻页total 恢复成全部数据搜索条件没有传到 count 查询检查 controller 接收参数和 count SQL 的 where 条件请求 page/rows 总是为空Controller 接收的是 pageNum/pageSize或前端没传统一参数名或在 Controller 用 RequestParam 显式声明4.2 完整的排查链路遇到分页问题我建议按下面的顺序去看而不是一上来就怀疑 MyBatis 配置打开浏览器开发者工具切到 Network重新点一次查询或翻页。查看请求 URL 和 Payload确认有没有page和rows确认值是否在变。查看响应 JSON确认total是不是预期数字rows是不是数组。如果响应没问题但页面显示不对再去看后端日志里 MyBatis 打印的 SQL 和参数。检查 SQL 里的 LIMIT 参数第一页应该是 0, 20第二页应该是 20, 20。这套顺序能帮你快速把问题锁定在前端没传后端没接SQL 没分页返回格式不对四类中的某一类。每次排查都从网络面板开始会节省大量时间。4.3 搜索条件怎么才不会丢搜索条件丢失是分页问题里最隐蔽的一类。很多时候第一页查询带着 name 条件没问题翻到第二页条件就丢了或者 total 变成了不符合条件的数据总数。原因通常是前端翻页时 EasyUI 只带了 page 和 rows之前load传的条件没有保留。解决方法是把搜索条件做成一个独立对象每次翻页和搜索都走datagrid(load, query)而不是直接修改 URL 或只调用reload。function loadGrid() { var query { name: $(#name).val(), status: $(#status).combobox(getValue) }; $(#dg).datagrid(load, query); } $(#searchBtn).click(loadGrid);后端这边建议把查询条件封装成一个 Query 对象Controller 方法参数直接写UserQuery query这样 page、rows 和业务条件都在一个对象里后续加条件也不会漏。5. 让分页 SQL 现形日志配置与大分页优化5.1 MyBatis 日志怎么打开要确认分页到底有没有生效最实在的方法是看 MyBatis 打印的 SQL。在 logback 或 log4j 里把 Mapper 包日志级别调到 DEBUGlogger namecom.example.mapper levelDEBUG/如果项目用的是 MyBatis 全局配置可以显式指定日志实现settings setting namelogImpl valueSLF4J/ /settings打开后执行列表查询时日志里应该能看到类似的输出Preparing: select * from t_user where name like concat(%, ?, %) order by id desc limit ?, ? Parameters: 张三(String), 0(Integer), 20(Integer)看到limit ?, ?和参数里的0, 20说明分页 SQL 是生效的。如果日志里只有 select 没有 limit说明前端 page/rows 参数没有传到 Mapper或者 PageHelper 的 startPage 没有作用到这条查询上。5.2 大分页不要硬翻LIMIT 分页在数据量小时很轻松但一旦出现LIMIT 100000, 20这种深分页数据库仍然要把前 10 万行扫出来再丢掉性能会肉眼可见地下降。对后台管理列表来说最简单的限制是限制最大页码和页大小比如 page 最大 100rows 最大 100超过就返回空或按最大值处理。如果业务确实需要深翻可以考虑主键游标方式。比如列表按 id 排序记录上一页最后一条的 id下一页查询用select * from t_user where id #{lastId} order by id limit #{rows}这样跳过的行数不再随页数增加性能会稳定很多。缺点是 EasyUI 的分页条是基于页码的要做成加载更多或上一页/下一页模式对列表交互有一定改造。如果不想改交互还可以用子查询先取主键select u.* from t_user u inner join ( select id from t_user order by id limit #{offset}, #{rows} ) tmp on u.id tmp.id order by u.id;这种写法对 InnoDB 大表比较友好可以先走覆盖索引拿到主键再回表取完整行。5.3 我习惯的公共分页返回结构最后分享一个我一直在用的公共结构。把请求参数封装成一个 PageQuery把返回结果封装成一个 PageResult所有 Controller 都返回同一套格式前端 EasyUI 只需配置一次后续新页面几乎不需要再调分页public class PageQuery { private Integer page 1; private Integer rows 20; // getter / setter 略 } public class PageResultT { private long total; private ListT rows; // getter / setter 略 }Controller 里统一转成 Map 或者直接返回 PageResult 都行只要最终 JSON 里是 total 和 rows 这两个字段。几个项目跑下来这套小封装能省掉大量重复的分页联调时间。遇到分页问题也只需要盯着一个公共类排查不用每个接口各查各的。本文还有配套的精品资源点击获取