
1. 为什么手写分页标签比引入现成组件更值得做做 JavaWeb 项目的人大多经历过这个阶段列表页数据一多前端就开始催分页后端返回的却是一坨List页码、总条数、上一页下一页全靠 JSP 里写if拼字符串。我见过不少同学直接抄一段网上的分页代码跑是能跑但参数名对不上、页码越界、搜索条件丢失调起来比重新写还累。分页标签的本质是把「数据切片」和「页码渲染」这两件事拆开。PageBean负责算清楚当前页有哪些数据、总共几页、有没有上一页下一页标签类负责把这些数字翻译成 HTMLJSP 只负责调用。这样后端换数据库、前端换样式都不用动分页逻辑本身。这篇要解决的具体场景是一个带关键字搜索的列表页需要支持页码跳转、上一页下一页、以及「第 N 页 / 共 M 页」的输入框跳转。同时分页接口在本地调试时经常出现参数传错、返回结构对不上的问题我会用 TaoToken 的统一 Key 把接口调试链路串起来让「写标签」和「验接口」两件事在同一个工作流里完成。适合谁看已经会写 Servlet 和 JSP、但分页还在用LIMIT手拼字符串的 JavaWeb 学习者或者想搞清楚SimpleTagSupport到底怎么用、tld文件每一行什么意思的开发者。下面从PageBean开始一步步把可复制的代码给出来。2. TaoToken 统一 Key 在接口调试链路里的前置准备写分页标签之前得先确认后端接口返回的数据结构是稳定的。很多分页 bug 不是标签写错而是接口返回的total字段名和前端预期不一致。所以我会先把分页查询接口调通再回来写标签。TaoToken 在这里的角色是统一入口你不需要为每个模型或每个调试场景单独配一套 Key一个 Key 就能覆盖模型对话、接口调试、编码辅助这些场景。对于分页接口调试来说最直接的是用它的模型对话能力来生成测试数据、检查 JSON 结构或者用 Coding Plan 在写标签类时做代码补全。前置准备分三步。第一步拿到 Key。访问https://taotoken.net/api-keys带 UTM?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi-keys在控制台创建一个 API Key。这个 Key 就是后面所有请求的凭证。第二步确认 Base URL。TaoToken 的 API 入口是https://taotoken.net/api注意这个地址不加 UTM 参数直接作为请求前缀使用。如果你用的是 OpenAI 兼容的客户端Base URL 填这个就行。第三步选模型。分页接口调试不需要太重的模型选一个响应快的即可。Model ID 在控制台的模型列表里能看到复制出来备用。这里有个容易踩的坑有人把 Base URL 写成带/v1的完整路径结果请求 404。TaoToken 的 API 地址就是https://taotoken.net/api具体路径由客户端拼接。如果你用 curl 测试完整请求是https://taotoken.net/api/chat/completions。注意Key 不要硬编码在 JSP 或前端代码里。调试阶段可以放在环境变量或本地配置文件生产环境走服务端转发。准备好这三样之后先别急着写标签。用一条最简单的请求验证 Key 是否可用确认返回结构正常再进入分页逻辑。下一步我会给出具体的配置片段和验证命令。3. 可复制的 PageBean、标签类与 tld 配置片段这一节是核心所有代码都可以直接复制到项目里改包名使用。我按「数据封装 → 标签类 → tld 声明 → JSP 调用」的顺序来。3.1 PageBean 封装分页参数PageBean的职责是持有当前页数据、总记录数、每页条数并计算出总页数、上一页、下一页。字段命名要和接口返回保持一致避免调试时对不上。package com.example.model; import java.util.List; public class PageBeanT { private int pageNo 1; // 当前页码 private int pageSize 10; // 每页条数 private int total; // 总记录数 private int pageSum; // 总页数 private ListT list; // 当前页数据 public void setTotal(int total) { this.total total; this.pageSum (total pageSize - 1) / pageSize; } public int getPageNo() { return pageNo; } public void setPageNo(int pageNo) { if (pageNo 1) pageNo 1; if (pageSum 0 pageNo pageSum) pageNo pageSum; this.pageNo pageNo; } public int getPageSize() { return pageSize; } public void setPageSize(int pageSize) { this.pageSize pageSize; } public int getTotal() { return total; } public int getPageSum() { return pageSum; } public ListT getList() { return list; } public void setList(ListT list) { this.list list; } public boolean isHasPrev() { return pageNo 1; } public boolean isHasNext() { return pageNo pageSum; } }注意setPageNo里做了边界处理页码小于 1 归 1大于总页数归总页数。这样即使前端传了非法页码后端也不会查出空数据。3.2 标签类继承 SimpleTagSupport标签类的作用是把PageBean渲染成 HTML。继承SimpleTagSupport后重写doTag()方法通过JspWriter输出。package com.example.tag; import com.example.model.PageBean; import javax.servlet.jsp.JspException; import javax.servlet.jsp.JspWriter; import javax.servlet.jsp.tagext.SimpleTagSupport; import java.io.IOException; public class PageTag extends SimpleTagSupport { private PageBean? pm; private String url; private String key; private String type; public void setPm(PageBean? pm) { this.pm pm; } public void setUrl(String url) { this.url url; } public void setKey(String key) { this.key key; } public void setType(String type) { this.type type; } Override public void doTag() throws JspException, IOException { JspWriter out getJspContext().getOut(); StringBuilder sb new StringBuilder(); sb.append(form classsever namepageForm action).append(url).append(); sb.append(input typehidden nametype value).append(type null ? : type).append(/); sb.append(input typehidden nameKeyWords value).append(key null ? : key).append(/); int pageNo pm.getPageNo(); int pageSum pm.getPageSum(); if (pageNo 1) { sb.append(a href).append(url).append(?pageNo1KeyWords).append(key) .append(type).append(type).append(首页/a); sb.append(a href).append(url).append(?pageNo).append(pageNo - 1) .append(KeyWords).append(key).append(type).append(type).append(上一页/a); } int begin Math.max(1, pageNo - 2); int end Math.min(pageSum, pageNo 2); for (int i begin; i end; i) { if (i pageNo) { sb.append(span classnowPage).append(i).append(/span); } else { sb.append(a href).append(url).append(?pageNo).append(i) .append(KeyWords).append(key).append(type).append(type) .append().append(i).append(/a); } } if (pageNo pageSum) { sb.append(a href).append(url).append(?pageNo).append(pageNo 1) .append(KeyWords).append(key).append(type).append(type).append(下一页/a); sb.append(a href).append(url).append(?pageNo).append(pageSum) .append(KeyWords).append(key).append(type).append(type).append(末页/a); } sb.append( 第input classinput namepageNo value).append(pageNo).append(/ / ) .append(pageSum).append(页 input classsubmit typesubmit valueGO//form); out.print(sb.toString()); } }这里把原来 JSP 里的out.print逻辑搬到了 Java 类里好处是可以用 IDE 调试、可以单元测试而且不用担心 JSP 编译时的转义问题。3.3 tld 文件声明标签在WEB-INF下新建mytags.tld声明标签名、类路径和属性。?xml version1.0 encodingUTF-8? taglib xmlnshttp://java.sun.com/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-jsptaglibrary_2_1.xsd version2.1 tlib-version1.0/tlib-version short-namemy/short-name urihttp://example.com/mytags/uri tag namepage/name tag-classcom.example.tag.PageTag/tag-class body-contentempty/body-content attribute namepm/name requiredtrue/required rtexprvaluetrue/rtexprvalue typecom.example.model.PageBean/type /attribute attribute nameurl/name requiredtrue/required rtexprvaluetrue/rtexprvalue /attribute attribute namekey/name requiredfalse/required rtexprvaluetrue/rtexprvalue /attribute attribute nametype/name requiredfalse/required rtexprvaluetrue/rtexprvalue /attribute /tag /taglibrtexprvalue设为true表示属性值可以用 EL 表达式比如pm${pageBean}。body-content设为empty因为标签不需要标签体。3.4 JSP 页面调用在 JSP 顶部引入 taglib然后在列表下方调用。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixmy urihttp://example.com/mytags % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle分页列表/title/head body table c:forEach items${pageBean.list} varitem trtd${item.name}/tdtd${item.desc}/td/tr /c:forEach /table my:page pm${pageBean} urllist.do key${param.KeyWords} type${param.type}/ /body /html到这里标签部分就完整了。下一步是验证接口返回的数据能不能正确填充PageBean。4. 验证分页请求与返回结果是否跑通标签写完之后最容易出问题的地方是接口返回的 JSON 结构和PageBean字段对不上。我习惯先用 TaoToken 的模型对话能力快速生成一份测试数据确认字段名再写 Servlet。打开https://taotoken.net/api对应的对话入口带 UTM?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentchat发一条请求让模型返回一个分页结构的 JSON 示例。比如输入「返回一个包含 pageNo、pageSize、total、list 的 JSONlist 里放三条用户数据」。拿到返回后对照PageBean的字段检查total是不是数字、list是不是数组、pageNo有没有被正确解析。然后用 curl 直接请求你的分页接口确认参数传递正确curl -X GET http://localhost:8080/demo/list.do?pageNo2pageSize10KeyWordsjavatype1 \ -H Authorization: Bearer $TAOTOKEN_KEY返回结果里重点看三个地方total是否等于数据库总记录数、list长度是否等于pageSize、pageNo是否等于请求的页码。如果total对但list为空多半是 SQL 的LIMIT偏移算错了偏移量应该是(pageNo - 1) * pageSize。实测下来分页接口最常见的返回结构是这样的{ pageNo: 2, pageSize: 10, total: 35, pageSum: 4, list: [ {id: 11, name: user11}, {id: 12, name: user12} ] }如果接口返回的是这种结构Servlet 里直接new PageBean()把total和listset 进去再 set 页码就能传给 JSP。JSP 里用${pageBean.list}遍历用my:page渲染页码。验证通过后打开浏览器访问列表页点击第 2 页、上一页、下一页观察 URL 里的pageNo是否变化、列表内容是否跟着变。如果页码变了但内容没变检查 Servlet 有没有从request.getParameter(pageNo)取值并传给查询方法。5. 分页标签常见报错与排查对照这一节列几个我实际遇到过的报错以及对应的排查方向。报错一javax.servlet.jsp.JspException: com.example.tag.PageTag找不到类这是 tld 文件里tag-class的包名和实际类路径不一致。检查com.example.tag.PageTag是否和 Java 文件里的package声明完全一致包括大小写。另外确认编译后的.class文件在WEB-INF/classes/com/example/tag/下。报错二According to TLD or attribute directive, attribute pm does not accept any expressionstld 里pm属性的rtexprvalue没设为true或者设了但 JSP 里写的是my:page pmpageBean而不是pm${pageBean}。改成 EL 表达式即可。报错三local proxy failed或401 Unauthorized这是接口调试阶段的报错不是标签本身的问题。401说明 TaoToken 的 Key 无效或没带上。检查请求头里Authorization: Bearer Key是否完整Key 有没有多余空格。local proxy failed通常是本地代理配置和 Base URL 冲突确认https://taotoken.net/api没有被本地代理拦截。报错四Cannot read property choices of undefined这个报错出现在用模型对话调试接口返回时。说明返回的 JSON 里没有choices字段可能是请求体格式不对或者 Model ID 写错了。对照控制台里的模型列表确认 Model ID 拼写正确。报错五页码跳转后搜索关键字丢失检查标签类里拼接 URL 时有没有把KeyWords和type带上。上面给的PageTag代码里每个a的 href 都拼了KeyWords和type如果漏了跳页后搜索条件就没了。报错六OAuth相关错误如果你用的是需要 OAuth 的客户端确认回调地址和 Key 的权限范围匹配。TaoToken 的 API Key 方式不需要 OAuth直接用 Bearer Token 即可。排查顺序建议先看浏览器控制台的网络请求确认参数传对了再看服务端日志确认 SQL 执行了最后看 JSP 渲染结果确认标签输出了正确的 HTML。6. 把分页标签接入真实项目的几个建议标签跑通之后有几个细节值得再打磨一下。第一PageBean的pageSize不要写死在类里从配置文件或请求参数读。我一般设一个默认值 10允许前端通过pageSize参数覆盖但加一个上限比如 100防止有人传pageSize99999把数据库拖垮。第二标签类里的 URL 拼接要注意转义。如果key里包含或中文直接拼进 href 会出问题。可以用URLEncoder.encode(key, UTF-8)处理一下再拼。第三分页接口的total查询和list查询最好在同一个事务里或者至少保证两次查询之间没有数据变更。否则可能出现total是 35、但list查出来只有 34 条的情况。第四如果你用 TaoToken 的 Coding Plan 做长期开发可以把PageBean和PageTag作为模板代码存起来新项目直接复制。Coding Plan 的入口在https://taotoken.net/coding-plan带 UTM?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan适合需要反复调试接口和写标签代码的场景。最后一点经验分页标签的样式不要写在 Java 类里。上面代码里的classsever、classnowPage这些应该抽到独立的 CSS 文件标签类只负责输出结构。这样换皮肤的时候不用重新编译 Java。整套流程走下来从PageBean到标签类到 tld 到 JSP再到用统一 Key 验证接口链路是完整的。你可以先把PageBean和PageTag复制到项目里改包名配好 tld然后用一条 curl 请求确认接口返回最后在浏览器里点一遍页码。跑通之后再回头优化样式和边界处理。