
接手过 JSP 老项目的人大概都见过这种场面页面能打开Tomcat 也不报错可往下拉原本该显示列表的地方一片空白控制台里躺着一行The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved。我第一次遇到这个报错时把pom.xml翻来覆去查了半天最后发现依赖里只写了 API 包没带实现包标签在编译期就被当成未知前缀丢掉了。JSTL 这个东西本身不难难的是它横跨了 JSP 1.x 到 Jakarta EE 十多年的版本变迁光命名空间就换过一次坐标和 URI 稍微对不上就是静默失效看得见症状却找不到病灶。这篇就围绕JSTL 依赖配置这件事把版本谱系、jar 落地位置、taglib 声明、EL 解析开关、容器适配这几块串起来讲清楚。不管你是在 IDEA 里新建的 web 项目还是从别人手里接过来的老工程照着走一遍基本能把标签库跑通如果你已经在用c:forEach、fmt:formatDate这类标签但偶尔出怪问题后面关于排查链路的章节也值得翻一翻。1. 为什么 JSP 里还要留 JSTL 这一层1.1 从满屏的尖括号说起早期 JSP 页面的写法是把 Java 代码直接嵌进 HTML% for(...) { %和% user.getName() %交替出现一个表格渲染下来能写三十行脚本片段。这种写法最大的问题不是丑而是页面和业务逻辑之间没有边界前端改个样式要翻 Java 代码后端调个字段名要动 JSP任何一个人都能在页面上顺手 new 一个 DAO 出来。渲染逻辑一旦和取数逻辑混在一起测试也没法做因为你没办法脱离容器单独跑一个 JSP。JSTL 的思路是把循环、判断、格式化、URL 拼接这些页面里反复出现的动作抽象成标签让 JSP 回到模板的位置上。c:forEach items${users} varu这行代码只描述我要遍历 users不描述怎么遍历。取数的事情交给 Servlet 或者 Controller页面只负责把数据摆到该摆的地方。这个分层听起来是老生常谈但在真实项目里它决定了你后面能不能把这个页面替换成别的模板引擎。1.2 JSTL 到底替你兜住了哪三件事第一件是流程控制。c:if、c:choose、c:forEach覆盖了页面上绝大多数分支和迭代场景尤其是c:choose这种带c:when、c:otherwise的组合比在 HTML 里拼三目运算符可读性高得多。第二件是输出安全。c:out value${param.name}/默认会把、、这些字符转义成实体而${param.name}直接输出是不会转义的。这个差别在回显用户输入的场景里非常关键很多人写惯了${}等到页面上被人塞进去一段脚本才反应过来。第三件是格式化和国际化。fmt:formatDate、fmt:formatNumber、fmt:message三个标签把日期格式、数字千分位、多语言资源包这几件麻烦事收进了标签库里配合ResourceBundle就能做出可切换语言的后台页面不用自己写一堆SimpleDateFormat。1.3 不是所有页面都值得上 JSTL说句实在话如果你的项目已经全面转向 Thymeleaf、FreeMarker 或者前后端分离JSP 只剩几个遗留页面那没必要为了统一技术栈去硬上 JSTL。JSTL 的价值在于页面里确实有循环和判断如果只是一个静态的数据展示页直接${}就够了。另外在 Jakarta EE 9 之后的新项目里用 JSTL 2.0/3.0 的坐标是可以的但要多花一点心思确认容器版本这部分后面会专门讲。判断标准很简单页面里出现%开头的东西超过三处就值得引入 JSTL一处都没有就别折腾了。2. JSTL 的坐标谱系javax 与 jakarta 差一个字项目就起不来2.1 JSTL 1.1 到 1.2两个 jar 合并成一个JSTL 1.1 时代需要两个 jarjstl.jar提供接口和 TLD 声明standard.jar提供具体实现类缺任何一个都会在运行期抛ClassNotFoundException。到了 JSTL 1.2官方把两者合并成了一个jstl-1.2.jarMaven 坐标是javax.servlet:jstl:1.2。这个版本是目前存量项目里最普遍的它要求 Servlet 2.5 / JSP 2.1 以上正好和 Tomcat 6 之后的版本对齐。这里有个特别容易被忽略的细节JSTL 1.0 和 1.1 的 taglib URI 完全不一样。1.0 用的是http://java.sun.com/jstl/core还有一个带_rt后缀的变体http://java.sun.com/jstl/core_rt用来支持运行期表达式1.1 之后才改成http://java.sun.com/jsp/jstl/core注意中间多了一段/jsp。网上很多老博客里的示例代码是 1.0 时代写的直接复制过来就会报 URI 无法解析问题就出在这个路径上。2.2 Jakarta EE 9 之后包名和 URI 一起改Jakarta EE 9 做了一件影响面很大的事把所有javax.*包改成了jakarta.*。JSTL 也不例外API 包的 groupId 变成jakarta.servlet.jsp.jstl核心包名从javax.servlet.jsp.jstl变成jakarta.servlet.jsp.jstl。随之而来的是 taglib URI 的大改从http://java.sun.com/jsp/jstl/core变成jakarta.tags.core格式化标签变成jakarta.tags.fmt函数库变成jakarta.tags.functionsSQL 和 XML 分别是jakarta.tags.sql和jakarta.tags.xml。这个变化的杀伤力在于如果你的 Tomcat 是 10.x容器加载的是jakarta.servlet.*此时你放一个javax.servlet:jstl:1.2进去运行时会报java.lang.NoClassDefFoundError: javax/servlet/jsp/tagext/Tag之类的错误——因为实现类实现的是旧接口容器根本不认识。反过来Tomcat 9 上放 jakarta 版本的 jar同样起不来。这个坑我在两个项目里各踩过一次第一次查了半小时才反应过来是容器版本的问题。2.3 一张表把坐标钉死规范版本Maven 坐标core 的 taglib URI适配容器JSTL 1.0jstl:jstl:1.0 standardhttp://java.sun.com/jstl/core很老的容器不推荐JSTL 1.1javax.servlet:jstl:1.1.2taglibs:standard:1.1.2http://java.sun.com/jsp/jstl/coreTomcat 6/7JSTL 1.2javax.servlet:jstl:1.2单 jarhttp://java.sun.com/jsp/jstl/coreTomcat 7/8/9JSTL 2.0APIjakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api:2.0.0 实现org.glassfish.web:jakarta.servlet.jsp.jstl:2.0.0jakarta.tags.coreTomcat 10.0JSTL 3.0API:3.0.0 实现:3.0.1jakarta.tags.coreTomcat 10.1 / 11用之前先确认两件事容器主版本号以及项目里web.xml声明的规范版本。这两个信息一确定坐标就没有第二种选择。3. 把依赖真正塞进 WEB-INF/libMaven 与非 Maven 两条路3.1 pom.xml 的几种写法与 scope 陷阱Maven 项目里最省事的写法就是加一行javax.servlet:jstl:1.2不写 scope默认compile打包时会自动进WEB-INF/lib。但我见过不少项目把它写成了provided理由是这是容器提供的能力。这个判断是错的Tomcat 自带的lib目录里有el-api.jar、jasper.jar、tomcat-api.jar但没有 JSTL 的实现。写成provided的结果就是 IDE 里能编译通过打成 war 之后部署上去页面上一用标签就报 URI 无法解析。Jakarta 版本要注意必须成对引入API 和实现缺一不可dependency groupIdjakarta.servlet.jsp.jstl/groupId artifactIdjakarta.servlet.jsp.jstl-api/artifactId version3.0.0/version /dependency dependency groupIdorg.glassfish.web/groupId artifactIdjakarta.servlet.jsp.jstl/artifactId version3.0.1/version /dependency引入之后建议执行一次mvn dependency:tree确认没有其它依赖把 JSTL 传递性地拉进来造成版本冲突。如果同时存在 1.2 和 3.0 两套 jarMaven 会按最近优先选一个被选中的那个未必是你要的。3.2 不用 Maven 的老项目该怎么放包没有构建工具的项目jar 就手工丢进WEB-INF/lib目录。这个目录是 Web 应用类加载器的搜索路径之一放在这里的 jar 优先级高于容器的lib目录属于应用私有的依赖。目录结构大概是这样的src/ main/ webapp/ WEB-INF/ web.xml lib/jstl-1.2.jar index.jsp放完之后有个硬性要求必须重启容器不能只热部署。理由是 TLD标签库描述文件的扫描发生在 Web 应用启动阶段容器会遍历WEB-INF/lib下所有 jar 里的META-INF/*.tld。热部署有时候只重新加载了 JSP 而不重新扫描 jar你会看到明明包放进去了、URI 还是解析不了白白怀疑人生。另外别把 jar 放到 Tomcat 的lib目录下当全局依赖那样在自己机器上能跑换一台服务器就找不到类跨环境迁移时会非常难受。3.3 IDEA 的 Artifact 配置依赖进去了吗用 IDEA 的 Tomcat 配置跑项目时实际的部署产物来自 Artifact而不是 Maven 的输出目录。这一点是很多IDE 里能跑、命令行打包就挂的根源。检查路径是File → Project Structure → Artifacts找到你的xxx:war exploded看右侧输出布局里WEB-INF/lib节点下有没有 JSTL 的 jar。如果没有通常两种原因一是 Maven 依赖的 scope 是provided或testArtifact 不会打包二是改了pom.xml之后 IDEA 没有重新导入需要点一下 Maven 面板的刷新按钮。改完配置记得清一次target目录和 IDEA 的out目录残留的旧产物会把新配置盖掉这种改了没效果的情况至少有一半是缓存导致的。3.4 用 JSP 编译后的 Java 源码反查依赖是否真的生效JSP 最终会被 Jasper 编译器翻译成一个 Java 类这个中间产物是排查问题的金矿。Tomcat 的默认路径在${CATALINA_BASE}/work/Catalina/localhost/contextPath/org/apache/jsp/下面文件名对应 JSP 的路径比如index.jsp会生成index_jsp.java。IDEA 里跑 Tomcat 时CATALINA_BASE被指向了 IDE 的系统目录大致在用户目录下.IntelliJIdea版本/system/tomcat/项目名/里去那里找work目录即可。打开翻译后的 Java 文件如果 JSTL 配置正常你会看到_jspx_tagPool_c_forEach这类字段声明以及org.apache.taglibs.standard.tag.rt.core.ForEachTag的引用如果配置失败翻译阶段就直接抛异常.java文件根本生成不出来或者生成的代码里前缀被当成普通 HTML 文本原样输出。用这个文件反查比反复重启容器猜问题快得多。4. taglib 声明URI 写错一个字符整页标签全部哑火4.1 五个标准 URI 各管什么% taglib %指令必须写在每个 JSP 的顶部而且作用范围只限当前页面。用jsp:include动态包含的页面不会继承声明静态包含% include %因为在翻译期就把内容拼进来了所以是共享的。这个差别很容易让人困惑为什么有的公共片段里能用c:if换到另一个就报未知前缀。JSTL 一共提供五个标签库各管一摊corec前缀循环、判断、设置变量、输出、URL 拼接用得最多。fmtfmt前缀日期数字格式化、国际化消息、区域设置。functionsfn前缀字符串长度、截取、替换、拆分、判断包含只能用在 EL 表达式里。sqlsql前缀直接在页面上写 SQL 查库。这个库在现代项目里基本不该用容易造成安全和管理问题。xmlx前缀解析 XML 文档用得极少。实际项目里 90% 的场景只需要声明 core 和 fmt 两个。4.2 把长 URI 收进 web.xml如果团队里觉得每次写一长串http://java.sun.com/jsp/jstl/core太啰嗦可以在web.xml里做一次映射用短 URI 代替jsp-config taglib taglib-urihttp://myapp.com/tags/core/taglib-uri taglib-location/WEB-INF/lib/jstl-1.2.jar/taglib-location /taglib /jsp-config之后页面里写% taglib prefixc urihttp://myapp.com/tags/core %就能用。但要提醒一句这种写法在有多个 taglib 声明时容易和容器自动扫描的 TLD 打架某些容器版本下会报重复定义的告警。我个人的建议是老老实实写标准 URI省这一步的收益不大出问题的成本倒不低。4.3 EL 被原样输出的两种典型原因有时候 taglib 声明没问题标签也能解析但页面上直接显示${user.name}这个字符串本身。这跟 JSTL 无关是 EL 解析被关掉了通常有两种原因。一是web.xml声明成了 Servlet 2.3 的 DTD 版本!DOCTYPE web-app PUBLIC -//Sun Microsystems, Inc.//DTD Web Application 2.3//EN http://java.sun.com/dtd/web-app_2_3.dtd2.3 规范下 EL 默认是不解析的需要显式加% page isELIgnoredfalse %。解决办法是把web.xml升级到 2.4 以上的 XSD 版本或者在页面上加那句声明。如果是 Jakarta 项目根节点要换成https://jakarta.ee/xml/ns/jakartaee对应的 schema。二是页面上手动写了% page isELIgnoredtrue %或者web.xml里配了el-ignoredtrue/el-ignored。这种多半是历史遗留搜索一下关键字就能定位。5. 跑到容器里之后才会暴露的坑5.1 容器版本决定你用 javax 还是 jakarta这是整件事里最容易翻车的一环。判断标准只有一个Tomcat 的lib目录里放的是servlet-api.jar还是jakarta.servlet-api.jar。9.x 及之前是前者10.x 及之后是后者。两个命名空间在同一个类加载器里是互不兼容的不存在两套都放进去就都能用这种做法混放的结果往往是随机加载到其中一个版本然后报错。具体对照关系是Tomcat 9 及以下用javax.servlet:jstl:1.2加http://java.sun.com/jsp/jstl/coreTomcat 10.0 用 JSTL 2.0 加jakarta.tags.coreTomcat 10.1 和 11 用 JSTL 3.0 加jakarta.tags.core。升级容器的时候这两处必须同步改改一处不改另一处症状是页面能打开但所有标签变成纯文本。5.2 多项目同域部署时 c:url 与 contextPath 的配合同一个域名下挂多个 web 项目是很常见的部署方式比如/portal和/admin两个应用。这时候页面里的链接千万别写死绝对路径否则一换部署方式就 404。JSTL 的c:url会帮你自动补上上下文路径a hrefc:url value/user/detail?id${user.id}/详情/a a href${pageContext.request.contextPath}/user/detail?id${user.id}详情/a上面两种写法效果一致第一种更简洁第二种更直观。关键是别写成href/user/detail那个/是相对于站点根目录的一旦应用部署在/portal下面就会跳到别的地方去。如果前面还有一层反向代理做路径转发代理配置里的路径和后端应用的 context path 要保持一致否则c:url生成的路径和浏览器实际能访问的路径会对不上号。这种问题在本地开发环境完全看不出来只有到部署环境才会暴露所以建议在开发阶段就养成用c:url的习惯。5.3 一条从 500 报错到根因的排查链路碰到 JSTL 相关的问题我一般按这个顺序走基本五分钟内能定位看报错文案。The absolute uri: xxx cannot be resolved说明 URI 和 jar 对不上先核对版本表ClassNotFoundException: org.apache.taglibs.standard...说明 jar 里只有 API 没有实现或者 jar 压根没进WEB-INF/libNoClassDefFoundError: javax/servlet/jsp/tagext/Tag说明命名空间和容器版本不匹配。确认 jar 的实际位置。部署后的 war 解压目录里看WEB-INF/lib下有没有对应的 jar而不是只看 IDE 的依赖列表。IDE 里显示有、war 里没有的情况太常见了。确认 jar 里的内容。把 jar 解压出来看META-INF目录下有没有.tld文件。只有 API 的 jar 是没有 TLD 的这能直接验证缺实现包这个猜测。看 JSP 翻译产物。去work目录找对应的_jsp.java看标签有没有被翻译成真正的 Tag 调用。清缓存重启。前面四步都没问题还是不行就把容器的work目录整个删掉重新部署一次。注意排查过程中不要一边改配置一边热部署容易把两种问题的症状混在一起最后分不清是哪个改动生效了。6. 常用标签的写法细节与扩展思路6.1 c:forEachvarStatus 和循环里的坑c:forEach是使用频率最高的标签几个属性值得记住items是集合var是循环变量名begin/end/step控制区间和步长varStatus提供一个描述循环状态的对象。varStatus里常用的属性是index从 0 开始、count从 1 开始、first、last做隔行变色或者给第一行加特殊样式的时候特别顺手c:forEach items${orders} varo varStatusst tr class${st.index % 2 0 ? even : odd} td${st.count}/td tdc:out value${o.orderNo}//td tdfmt:formatDate value${o.createTime} patternyyyy-MM-dd HH:mm//td /tr /c:forEach有个细节要注意items为空时循环体不会执行但var变量也不会被创建如果后面直接引用${o}会拿到空值。需要空列表提示的话在外面套一个c:if test${empty orders}更稳妥。另外循环里尽量避免嵌套三层以上JSP 的翻译结果会变得很长出问题时定位成本高。6.2 c:out、c:set、c:url作用域与转义c:out默认escapeXmltrue会转义五个特殊字符这是它和${}最大的区别。凡是回显用户输入的地方都应该用它c:out value${comment.content} escapeXmltrue/c:set用来在指定作用域里定义变量scope可选page、request、session、application默认是page。过度使用session会让内存里的数据越堆越多尤其是存集合对象的时候。c:url除了自动补上下文路径还能配合c:param做参数拼接并自动做 URL 编码c:url value/search varsearchUrl c:param namekeyword value${param.keyword}/ c:param namepage value1/ /c:url a href${searchUrl}搜索/a这样拼出来的参数是编码过的中文和特殊字符不会出问题比手工字符串拼接可靠得多。6.3 fmt 与 fn格式化和字符串处理fmt:formatDate的pattern属性支持标准的日期格式符fmt:formatNumber支持pattern和type做金额展示时可以用typecurrency自动带上货币符号和千分位。国际化场景下先fmt:setLocale再fmt:message从资源包里取文案。fn函数库和前面几个不一样它只能用在 EL 表达式里不能当标签写。常用的是fn:length、fn:substring、fn:contains、fn:trim、fn:escapeXml。比如列表截断显示c:out value${fn:length(item.title) 20 ? fn:substring(item.title, 0, 20) : item.title}/需要提醒一点fn:substring是按字符索引切分的遇到中英文混排、emoji 这类字符时长度计算可能和视觉预期不一致展示场景下够用但不要拿它做严格的业务截断。6.4 什么时候该写标签文件如果一段 JSTL 逻辑在多个页面里反复出现比如统一的表格渲染、统一的分页条就该考虑把它抽成标签文件了。在WEB-INF/tags目录下建.tag文件通过% taglib prefixui tagdir/WEB-INF/tags %引入用法和自己写的自定义标签一样但省去了写 TLD 和 Java 类的麻烦。判断标准可以简单一点同一段标签组合在三个以上的页面里出现过就值得抽只出现一两次的留在页面里反而更好维护。抽得太早改一个参数要跳好几个文件反而降低效率。我在实际项目里养成的习惯是新建 web 项目时先把 JSTL 依赖和 taglib 声明这两件事一次性配好写一个最简单的c:forEach页面跑通再开始写业务。这样后面遇到问题时至少能确定依赖这一层是干净的排查范围能缩小一大截。至于c:out和${}混用的老页面我的做法是不主动大改只在改动到那一行的时候顺手换掉——一次性全量替换的风险比重写一个功能还大。