JSTL 标签库这个东西,属于那种"平时不用觉得无所谓,一旦用上就再也不想回去写脚本片段"的存在。它的依赖配置本身并不复杂,但在 web 项目里翻车的概率高得离谱——jar 放进去了页面还是报The absolute uri ... cannot be resolved,Maven 里依赖写了却打不进 war 包,换了台 Tomcat 就集体失灵,这些场景我几乎每年都要碰上几次。问题的根子在于 JSTL 的依赖配置不是"加一个 jar"这么简单,它同时牵扯到 Servlet 容器的版本、javax 与 jakarta 两套命名空间、JSP 标签库描述文件的 URI 映射,以及 IDE 里"编译期能识别"和"运行期能加载"这两件经常被混为一谈的事。这篇内容会把这几条线捋清楚:先讲版本对齐的对应关系,再给 Maven 和非 Maven 两条路线的完整配置,然后是部署验证、编译产物反查、报错速查,最后是我在实际项目里踩出来的几个细节。不管你是刚在 IDEA 里建第一个 Java Web 项目,还是在维护一个跑了七八年的老系统,应该都能从中找到对得上的那一段。
1. JSTL 到底解决什么问题,为什么依赖配置总在同一个坑里翻车
1.1 从一段被复制了八遍的循环说起
早些年写 JSP,列表渲染基本靠脚本片段堆出来。一个订单列表页,开头要<%@ page import="java.util.List" %>,中间要<% List<Order> list = (List<Order>) request.getAttribute("orders"); %>,然后if (list != null && !list.isEmpty())包一层,再for (int i = 0; i < list.size(); i++)里面写<%= list.get(i).getOrderNo() %>。这段代码写一遍还行,问题是它会在同一个项目里出现几十遍,每换一个实体类就要重写一遍循环结构,空集合判断还会漏掉。更麻烦的是页面一旦复杂起来,脚本里混着 HTML,改一个字段名要在几百行里找getXXX(),做前端的人根本不敢碰。
JSTL 就是把这个套路抽成标签。<c:forEach items="${orders}" var="order">一行顶替了上面那一整坨,<c:if>、<c:choose>把判断也标签化,<fmt:formatDate>负责日期格式化,<fn:length>负责字符串和集合的长度计算。写完之后 JSP 页面里几乎看不到<% %>,前端同学可以自己调整结构,后端只关心把数据塞进 request 域。这是它最核心的价值:把视图层从 Java 代码里剥离出来,让页面回归页面。
1.2 依赖配置翻车的三种典型现场
第一个现场最常见:jar 明明拷进了WEB-INF/lib,访问页面还是抛异常,提示找不到某个绝对 URI。这种情况九成是 jar 的位置或打包方式有问题,比如放在了项目根目录的lib而不是WEB-INF/lib,或者 IDEA 的 Artifact 输出布局里没有把这个库纳进去,编辑器里不报错是因为模块依赖里有,但编译出来的 war 包里没有。
第二个现场更隐蔽:页面能跑,某些标签却报ClassNotFoundException。这通常意味着只引了 API 包没有引实现包,JSTL 1.1 时代 API 和实现是分开的两个 jar,少一个就只在用到具体标签时才炸,平时c:out可能还正常,一用c:forEach就挂。
第三个现场是升级容器之后全线崩溃。项目原来跑在 Tomcat 8 上好好的,换成 Tomcat 10 之后所有 JSTL 标签全部失效,报错信息还是那个熟悉的 URI 找不到。根因是 Tomcat 10 开始全面转向 jakarta 命名空间,旧的javax.servlet.jsp.jstl包名容器根本不认,必须换成 jakarta 版本的 JSTL,连 JSP 里的 taglib URI 都要一起改。这三个现场串起来其实就是一句话:JSTL 的依赖配置是版本、打包、URI 三件事的联合体,缺一环都不行。
2. 版本对齐:Servlet 容器、JSTL、命名空间的对应关系
2.1 javax 到 jakarta 的分水岭
Java EE 移交给 Eclipse 基金会之后改名叫 Jakarta EE,随之而来的是包名从javax.*整体迁移到jakarta.*。这个迁移不是改个名字那么简单,它对 JSTL 的影响体现在两个层面:一是实现类的包路径变了,二是标签库描述文件里声明的 URI 变了。Tomcat 作为最常用的 Servlet 容器,从 10.0 版本开始完全按 Jakarta EE 9 及以上规范实现,也就是说 Tomcat 10、11 只认jakarta.servlet.*,而 Tomcat 8.5、9 认的是javax.servlet.*。JSTL 作为运行在容器上的标签库,必须和服务器的命名空间保持一致,否则容器扫描 TLD 时压根匹配不上。
这条分界线在实际操作中的表现非常直接:如果你在 Tomcat 10 上部署一个用javax.servlet:jstl:1.2的项目,页面上所有 JSTL 标签都会失效,控制台抛出The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved。反过来,在 Tomcat 9 上用 jakarta 版本的 JSTL,同样跑不起来。所以选型第一步不是挑版本号,而是先确认手里的 Tomcat 是第几代。
2.2 版本对照表与选型建议
把常用的组合整理成一张表,照着抄基本不会出错:
| Servlet 容器 | Servlet 规范 | JSTL 版本 | 命名空间 | taglib URI 前缀 |
|---|---|---|---|---|
| Tomcat 8.5 | Servlet 3.1 | JSTL 1.2 | javax | http://java.sun.com/jsp/jstl/core |
| Tomcat 9 | Servlet 4.0 | JSTL 1.2 | javax | http://java.sun.com/jsp/jstl/core |
| Tomcat 10.1 | Servlet 6.0 | Jakarta JSTL 3.0 | jakarta | jakarta.tags.core |
| Tomcat 11 | Servlet 6.1 | Jakarta JSTL 3.0 | jakarta | jakarta.tags.core |
| Jetty 11 / 12 | Servlet 5.0 / 6.0 | Jakarta JSTL 3.0 | jakarta | jakarta.tags.core |
选型上有两条建议值得记住。新建项目一律从 Tomcat 10.1 起步,直接上 Jakarta JSTL 3.0,不要为了图省事去挑老版本 Tomcat,后面升级更痛苦。维护老项目就老老实实跟着原来的容器走,别单独把 JSTL 升上去,容器和标签库必须是配套的。另外要注意 Spring Boot 内嵌 Tomcat 的情况,Spring Boot 3.x 用的是 Tomcat 10.x,同样是 jakarta 命名空间,如果 JSP 页面里写的是旧 URI,在 Spring Boot 3 里一样会挂。
2.3 Tomcat 为什么不自带 JSTL
经常有人问,Servlet API 是 Tomcat 提供的,为什么 JSTL 不一起提供?原因是 Tomcat 把自己定位成 Servlet 容器,只实现规范强制要求的部分,JSP 标准标签库属于可选组件,不在容器的实现范围内。历史上只有少数应用服务器(比如某些商业容器)会把 JSTL 打进去,Tomcat 从始至终都没有带。这也解释了为什么 JSTL 的依赖 scope 不能写provided:provided的语义是"编译时需要,运行时有容器提供",而这里运行时容器不提供,写provided的后果就是本地跑没问题、打包后必炸。
顺带说一句,很多人排查时会去翻 Tomcat 的lib目录,发现里面确实有个跟标签相关的东西,就想当然认为容器自带。实际情况是容器自带的只有 Jasper(JSP 引擎)和 Servlet/JSP API 的实现,TLD 扫描机制是有的,但具体的c.tld、fmt.tld这些描述文件必须由 JSTL 的 jar 提供。扫描机制和标签库本身是两回事,混淆这两个概念会让人在排查时完全找错方向。
3. Maven 项目中的 JSTL 依赖配置
3.1 现代栈(Tomcat 10/11)的 pom 写法
现在新建项目基本都会用 Maven 或 Gradle 管理依赖,这是最省心的路线。Tomcat 10.1 及以上配合 Jakarta JSTL 3.0,pom 里需要两个依赖:API 包和实现包。
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- Servlet API:容器提供,scope 必须是 provided --> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>6.0.0</version> <scope>provided</scope> </dependency> <!-- JSTL API:标签库的接口定义 --> <dependency> <groupId>jakarta.servlet.jsp.jstl</groupId> <artifactId>jakarta.servlet.jsp.jstl-api</artifactId> <version>3.0.0</version> </dependency> <!-- JSTL 实现:包含 tld 文件和具体标签实现类 --> <dependency> <groupId>org.glassfish.web</groupId> <artifactId>jakarta.servlet.jsp.jstl</artifactId> <version>3.0.1</version> </dependency> </dependencies>这里的关键点是 JSTL 的两个依赖都不能写provided或者runtime之外的奇怪 scope。API 包提供的是编译期用到的接口和常量,实现包提供的是tld描述文件和运行时真正干活的类。有人图省事只写实现包,编译期可能会报找不到jakarta.servlet.jsp.jstl.core.Config这类符号;只写 API 包则运行时报类找不到。两个都要,这是最稳的组合。
还有一个容易被忽略的细节:打包方式必须是war。如果 pom 里的<packaging>是默认的jar,Maven 不会按 web 项目布局打包,webapp/WEB-INF下的内容会全部丢掉,JSTL 的 jar 自然也进不去。
<packaging>war</packaging>3.2 为什么 scope 不能写 provided
这是个值得单独讲的点。我见过不少项目里 JSTL 依赖被写成provided,理由是"标签库由服务器提供",这个说法是错的,而且错得很有迷惑性。provided在 Maven 里的准确含义是"编译和测试阶段参与,打包阶段排除",适合那些容器确实会提供的依赖,比如 Servlet API、JSP API。开发时用 IDEA 启动 Tomcat,因为这些 API 在 Tomcat 的lib里存在,所以页面能正常跑;一旦真正打包部署到另一台服务器,jar 被排除了,容器又不提供,标签立刻失效。
判断某个依赖该不该写provided,我有个简单的方法:去目标容器的lib目录里找有没有同名 jar。Servlet API 有,写provided;JSTL 没有,老老实实用默认的compile。这个动作大概十秒钟,能省掉后面几小时的排查。
另外要注意依赖冲突。如果项目里既引了 jakarta 版本的 JSTL,又因为某个老依赖传递引进了 javax 版本的 JSTL,war 包里会同时存在两套 TLD,Jasper 扫描时可能匹配到错误的那一套,症状是页面能打开但标签行为异常,或者干脆抛出类转换异常。用mvn dependency:tree看清楚依赖树,发现双重存在就加 exclusions 排掉。
3.3 老项目(Tomcat 8/9)的写法与 jar 合并问题
维护老项目的时候会遇到 JSTL 1.2,它的坐标写法跟新版本完全不同:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>这个坐标只需要一个依赖,因为它本身已经是一个合并包,里面同时包含 API 和实现。但在网上搜教程的时候,你会看到大量文章让你再加一个taglibs:standard:1.1.2,这是 JSTL 1.1 时代的做法,那时候jstl.jar只有 API,实现放在standard.jar里,两个一起用。到了 JSTL 1.2 官方把两者合并了,如果还把standard-1.1.2.jar一起放进去,两个 jar 里都有org.apache.taglibs.standard包下的类,类加载顺序不确定,轻则出现行为诡异,重则抛LinkageError。
这里给一条经验:判断项目用的是哪个版本,看WEB-INF/lib里 jar 的文件名和大小。jstl-1.2.jar通常在 400KB 上下且同时含有javax/servlet/jsp/jstl和org/apache/taglibs/standard两个目录;jstl.jar只有几十 KB 且只有 API 目录。后者必须配standard.jar,前者千万别再配。把这个规则记住,能避免一大类疑难杂症。
还有一点,老项目如果从 JSTL 1.1 迁到 1.2,JSP 里的 taglib URI 不需要改,两个版本对外暴露的 URI 是一样的,改的只是依赖坐标和删掉多余的standard.jar。
4. 非 Maven 项目:手工摆 jar 与 IDEA 里的两处配置
4.1 Java Web 项目的标准目录结构
不用构建工具的项目,目录结构就变得格外重要,因为没有任何自动机制帮你把依赖塞到正确的位置。标准的 Java Web 目录结构长这样:
myweb/ ├── src/ # Java 源码,编译后进 WEB-INF/classes │ └── com/example/... ├── webapp/ # 或者叫 WebContent、WebRoot │ ├── WEB-INF/ │ │ ├── web.xml # 部署描述符 │ │ ├── lib/ # 第三方 jar,JSTL 就放这里 │ │ └── classes/ # 编译输出 │ ├── static/ │ │ └── css/app.css │ └── index.jsp └── ...WEB-INF这个目录有两层含义:一是里面的内容客户端无法直接访问,属于安全边界;二是lib和classes目录对类加载器有特殊意义,容器启动时会自动把它们加入应用的类路径。所以 JSTL 的 jar 必须放在webapp/WEB-INF/lib下,放在项目根目录自己建的lib里是无效的,容器根本不会去扫。我在面试和带新人的时候,见过太多人把 jar 放在webapp/lib(少了一层 WEB-INF),结果排查半天。
4.2 IDEA 2024/2025 建 web 项目并加入 JSTL
先说创建项目。IDEA Ultimate 版里,File → New → Project,左侧选 Jakarta EE(部分版本仍显示 Java Enterprise),右侧勾选 Web application,同时选中 Create web.xml,Server 选择已经配置好的 Tomcat。IDEA 2024 之后的模板里,Additional Libraries 一栏通常只有 Servlet API 之类的选项,JSTL 一般不在列表里,需要自己补。创建完成后项目里会自动生成webapp/WEB-INF/web.xml,还有一个 artifact 配置。
如果你用的是 Community 版,情况不太一样,社区版没有 Tomcat 的运行配置集成,也没有 Jakarta EE 模板。可行的做法是用maven-archetype-webapp原型创建 Maven 项目,再手动把 Tomcat 配到运行配置里(或者在 Maven 里配置一个容器插件来启动)。社区版也能开发,只是少了很多自动化,打包和部署要自己手动来。
加入 JSTL 的 jar 有两种途径。用 Maven 的话照第三章写 pom 就行,IDEA 会自动下载并识别。手工的话,把下载好的jstl-1.2.jar复制到webapp/WEB-INF/lib/目录下,然后在这个 jar 上右键选择 Add as Library,IDEA 会创建一个库并把它加进当前模块的依赖里。做完这一步,JSP 文件里的c:forEach之类的标签就不报红了。
4.3 Project Structure 里必须做两件事
只把 jar 放到WEB-INF/lib还不够,尤其是 Maven 项目。你要打开File → Project Structure,做这两个动作。
第一件事,确认模块依赖里有 JSTL。在 Modules → Dependencies 标签页里应该能看到对应的库,Maven 项目会自动带出来。这一层解决的是"编辑器认不认识这些标签",影响的是代码提示和编译期检查,跟运行时能不能跑没关系。
第二件事,确认 Artifact 的输出布局里有 JSTL 的 jar。切到 Artifacts 标签页,选中xxx:war exploded,右侧会看到输出布局树,展开WEB-INF/lib看看里面有没有 JSTL 的 jar。没有的话,从下方 Available Elements 里找到对应的库,双击加到WEB-INF/lib节点下,然后 Apply。这一层解决的才是"打包时会不会带上",是真正决定部署后能不能跑的一步。
这里有个特别容易踩的坑:Maven 项目里,providedscope 的依赖不会出现在 Artifacts 的 Available Elements 里,因为它本来就不该被打包;而compilescope 的依赖默认会出现在 WEB-INF/lib 下,但如果你之前手动调整过布局,可能会被误删,之后就一直是"本地能跑、打包就炸"。所以每次动了依赖相关配置,最好都去看一眼这个输出布局,养成习惯。
5. taglib 声明与第一个能跑的 JSP 页面
5.1 两种 URI 写法,别混着用
JSTL 的使用从 JSP 顶部的 taglib 指令开始。javax 时代的写法和 jakarta 时代完全不同,必须和依赖版本对应:
<%-- Tomcat 8/9 的写法,对应 JSTL 1.2 --%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %><%-- Tomcat 10/11 的写法,对应 Jakarta JSTL 3.0 --%> <%@ taglib prefix="c" uri="jakarta.tags.core" %> <%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %> <%@ taglib prefix="fn" uri="jakarta.tags.functions" %>prefix是自己定的短名,习惯上c表示 core,fmt表示格式化,fn表示函数,保持这个约定能让别人一眼看懂。URI 是标签库描述文件里声明的唯一标识,容器启动时会扫描所有 jar 里的 tld 文件,把这些 URI 登记进一张映射表,JSP 编译时按 URI 去查表找到对应的实现类。URI 写错了或者 jar 没扫到,查表失败,就会抛出那个经典的绝对 URI 无法解析的异常。
Jakarta JSTL 3.0 里旧的http://java.sun.com/...形式的 URI 已经不再默认注册了,网上那些粘贴过来的老代码直接放到新项目里必然报错。有个应急办法是在web.xml里显式做映射:
<jsp-config> <taglib> <taglib-uri>http://java.sun.com/jsp/jstl/core</taglib-uri> <taglib-location>/WEB-INF/tlds/c.tld</taglib-location> </taglib> </jsp-config>做法是把 jar 里的c.tld抽出来放到WEB-INF/tlds下,然后手动建立 URI 到文件的映射。我不太推荐长期这么干,因为它掩盖了版本不一致的本质问题,正确做法还是把 JSP 里的 URI 改成新形式。
5.2 核心标签实战:遍历、判断、格式化
先看一个列表渲染的完整例子,这是使用频率最高的场景:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="jakarta.tags.core" %> <%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %> <!DOCTYPE html> <html> <head><title>订单列表</title></head> <body> <table> <thead> <tr><th>订单号</th><th>金额</th><th>下单时间</th><th>状态</th></tr> </thead> <tbody> <c:forEach items="${orders}" var="order" varStatus="st"> <tr class="${st.index % 2 == 0 ? 'odd' : 'even'}"> <td>${order.orderNo}</td> <td><fmt:formatNumber value="${order.amount}" pattern="#,##0.00"/></td> <td><fmt:formatDate value="${order.createTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td> <c:choose> <c:when test="${order.status == 1}">待支付</c:when> <c:when test="${order.status == 2}">已支付</c:when> <c:otherwise>已关闭</c:otherwise> </c:choose> </td> </tr> </c:forEach> <c:if test="${empty orders}"> <tr><td colspan="4">暂无数据</td></tr> </c:if> </tbody> </table> </body> </html>这里面有几个细节值得单独说。varStatus提供循环状态,st.index从 0 开始,st.count从 1 开始,st.first和st.last用于判断首尾,做斑马纹和分隔符处理非常方便。c:choose相当于 switch,c:when依次匹配,c:otherwise兜底,比嵌套c:if清晰得多。fmt:formatNumber的pattern属性用的是 Java 的 DecimalFormat 语法,千分位和精度都能精确控制;fmt:formatDate用的是 SimpleDateFormat 语法,注意它要求value是java.util.Date类型,如果传的是LocalDateTime会直接报错,这是 Java 8 之后非常常见的坑,解决办法是在后端转成 Date 或者用函数标签自己格式化。
fmt库还有一个容易被忽略的用法是国际化。配置资源文件之后,<fmt:setLocale>切换语言,<fmt:setBundle>绑定资源包,<fmt:message key="order.title"/>按 key 取文案。做多语言站点的时候,这套标签比自己在后端拼字符串要干净得多,而且切换语言只需要改setLocale一处。
5.3 c:out 与转义:不要直接输出用户输入
${order.remark}这种写法会把内容原样输出到 HTML 里,如果 remark 里包含用户提交的脚本片段,会直接被执行,这是最典型的跨站脚本问题。JSTL 的c:out默认会做 XML 转义:
<c:out value="${order.remark}" default="无备注"/>default属性在值为 null 时输出兜底文案,比写order.remark == null ? '无备注' : order.remark干净。但要注意escapeXml的默认值是 true,如果你确实需要输出一段后端拼好的 HTML 片段,得显式写escapeXml="false",而且要非常清楚这段 HTML 的来源是可信的。
顺带提一下fn函数库,处理字符串时特别顺手:
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %> 长度:${fn:length(order.items)} 截断:${fn:substring(order.title, 0, 20)} 包含:${fn:contains(order.remark, '加急')}fn:length对字符串、集合、数组都有效,比在页面里写list.size()灵活。不过要注意,函数标签在 JSTL 里是通过 EL 函数映射实现的,如果容器版本和 JSTL 版本不匹配,可能出现函数不可用的情况,排查思路和前面说的一样,先看 jar 再看 URI。
6. 部署到 Tomcat 后的验证:看编译产物确认标签真的被解析了
6.1 work 目录与 CATALINA_BASE 的关系
JSP 不是解释执行的,第一次访问时会被 Jasper 编译成一个 Servlet 的 Java 文件,再编译成 class 加载。这个中间产物就放在 Tomcat 的 work 目录下,路径规则是work/Catalina/localhost/<应用上下文>/org/apache/jsp/。如果是 IDEA 里启动的 Tomcat,情况稍有不同,IDEA 会创建一个临时的 CATALINA_BASE,work 目录在那个临时目录里,而不是你安装目录下的 work。
找这个临时目录有个简单办法:看 IDEA 控制台启动 Tomcat 时打印的日志,里面会有一行形如Using CATALINA_BASE: ...的信息,把路径复制出来就能找到。也可以在 Tomcat 运行配置的 Server 标签页里看 Catalina base 的配置项,不同 IDEA 版本叫法略有差异,但指向的是同一个东西。知道这个位置之后,很多疑难问题就变得可观测了。
6.2 反查编译后的 Java 类
假设页面index.jsp里写了<c:forEach items="${orders}" var="order">,编译后在 work 目录下会生成index_jsp.java,里面能看到 Jasper 为每个标签生成的私有方法,名字类似_jspx_meth_c_005fforEach_005f0,方法体里会直接 new 出标签的实现类:
private boolean _jspx_meth_c_005fforEach_005f0(JspTag _jspx_th_c_005fforEach_005f0, PageContext _jspx_page_context) throws Throwable { JspWriter out = _jspx_page_context.getOut(); ForEachTag _jspx_th_c_005fforEach_005f0 = (ForEachTag) _jspx_th_c_005fforEach_005f0; _jspx_th_c_005fforEach_005f0.setItems((java.lang.Object) _jspx_page_context.findAttribute("orders")); _jspx_th_c_005fforEach_005f0.setVar("order"); _jspx_th_c_005fforEach_005f0.doStartTag(); // ... }看到这段代码,说明标签已经被正确解析、实现类也已经加载成功了。如果标签没生效,你会在生成的 Java 文件里看到那段<c:forEach>被当成普通文本原样输出,或者干脆编译失败并抛出无法解析 URI 的异常。这个反查手法在我排查"页面直接显示标签源码"这类怪问题时特别管用——看到 Java 文件里标签变成了out.write("<c:forEach ..."),就能确认是 taglib 指令没生效,而不是浏览器缓存的问题。
还有个小技巧:如果生成的 Java 文件是旧的,可以把 work 目录里的对应应用目录整个删掉再重新访问,强制重新编译。改完 JSP 之后页面没变化,八成也是缓存或增量编译的问题,清一次 work 目录基本就解决了。
6.3 前端挂 nginx、后端多项目时的路径问题
生产环境常见的部署形态是 nginx 在前,Tomcat 在后,一台服务器上跑好几个 web 项目,用不同的路径前缀区分。比如 nginx 里配置/shop/转发到http://127.0.0.1:8080/shop/,/crm/转发到http://127.0.0.1:8081/。这种结构下 JSTL 页面里最忌讳的就是硬编码绝对路径。
正确做法是用c:url生成链接,它会自动带上当前应用的上下文路径:
<c:url value="/order/detail" var="detailUrl"> <c:param name="id" value="${order.id}"/> </c:url> <a href="${detailUrl}">查看详情</a>如果直接写href="/order/detail",在 nginx 的反向代理下会被解析到根路径,请求打到 nginx 之后没法正确转发到对应的应用。用c:url生成的路径是带上下文的,能规避这类问题,同时<c:param>会自动做 URL 编码,中文和特殊字符都不用手动处理。
另一个坑是会话 Cookie 的路径。Tomcat 默认把 JSESSIONID 的 path 设置为应用的上下文路径,如果 nginx 暴露的路径和上下文路径不一致,浏览器可能不会把 Cookie 带到目标地址,表现为"登录成功后跳转又变回未登录"。解决办法是在web.xml里显式指定 Cookie 路径:
<session-config> <cookie-config> <path>/</path> <http-only>true</http-only> </cookie-config> </session-config>把路径设成根路径之后,同域名下的所有子路径都能带上这个 Cookie。http-only打开能防止脚本读取会话标识,是个低成本的加固手段。另外如果 nginx 做了静态资源直出,注意别把WEB-INF目录暴露出去,最好在 nginx 里显式拒绝访问以/WEB-INF/开头的路径。
7. 报错速查与排查顺序
7.1 四类高频报错的具体处理
绝对 URI 无法解析,报错信息通常是The absolute uri: ... cannot be resolved。按这个顺序查:jar 是否在WEB-INF/lib下、jar 版本和容器命名空间是否匹配、JSP 里的 URI 写法是否对应这个版本、打包产物里是否真的包含这个 jar。四条对完基本能定位。
找不到实现类,形如ClassNotFoundException: org.apache.taglibs.standard.tag.rt.core.ForEachTag。这基本就是缺实现包,检查是不是只引了 API 包,或者 jar 被误设成了provided。
页面把标签当文本输出,浏览器里直接看到<c:forEach ...>的源码。这通常是 taglib 指令缺失或写错,也可能是 JSP 配置里 EL 表达式被禁用了。检查web.xml或页面指令里有没有isELIgnored="true",老项目从 JSP 1.2 规范升级过来时容易出现这个问题。
中文输出乱码,fmt:formatDate出来的月份变成问号,或者页面编码不对。检查三个地方:JSP 页面头部的pageEncoding和contentType里的 charset、项目源码编码(IDEA 设置里 File Encodings 统一成 UTF-8)、Tomcat 连接器的 URIEncoding。
7.2 排查速查表
| 现象 | 最可能的原因 | 处理动作 |
|---|---|---|
| 绝对 URI 无法解析 | jar 没打进 war 包 | 检查 Artifacts 输出布局的 WEB-INF/lib |
| 所有标签失效且换了容器 | 命名空间不匹配 | 换对应的 JSTL 版本并改 URI |
| 只有部分标签报类找不到 | 缺少实现包 | 补上实现依赖 |
| 页面源码中出现标签文本 | taglib 指令缺失或 EL 被禁用 | 补指令、清 work 目录 |
| 本地正常、服务器失效 | 依赖 scope 写成 provided | 改成默认 compile |
| 日期格式化报类型错误 | 传入 LocalDateTime | 后端转成 java.util.Date |
| 循环里内容错位 | var 属性名和 EL 表达式不一致 | 逐行核对属性名 |
7.3 我自己固定的排查顺序
遇到 JSTL 相关问题,我现在基本按固定顺序走,一般不超十分钟。第一步看 war 包里的实际内容,用解压工具打开WEB-INF/lib,这一步能直接排掉百分之六十的"打包没带上"问题。第二步看容器版本和 jar 的对应关系,确认命名空间一致。第三步清 work 目录,重新访问页面,看编译生成的 Java 文件里标签到底变成了什么。第四步才是去看依赖树,排查有没有版本冲突。这个顺序的原则是:先确认事实,再推理原因,别一上来就猜。
8. 几个我踩过之后才记住的细节
第一个细节是关于 IDEA 的 red code 和运行时错误的区别。IDEA 里 JSP 标签报红,说明模块依赖里没有对应的库,影响的是开发体验;而运行时能不能跑,取决于 war 包里有没有 jar。这两件事互相独立,很多人看到编辑器不报红就以为万事大吉,结果打包之后才发现问题。我的习惯是:编辑器里红不红先不管,打包之后一定解压看一眼WEB-INF/lib,以那个为准。
第二个细节是别在WEB-INF/lib里同时放多个版本的 JSTL。有人在升级过程中图省事,新 jar 拷进去旧的没删,容器扫描 TLD 时先扫到哪个不确定,行为随机,可能今天正常明天就出问题。清理依赖的时候一定要下狠手,只留一个版本。
第三个细节关于web.xml的版本声明。Servlet 3.0 以上的规范支持注解和更宽松的配置,如果web.xml的version属性还是 2.3,容器的行为会退化到很古老的模式,一些默认配置会变,标签库的扫描策略也可能受影响。新建项目建议直接用匹配容器规范的版本,比如 Tomcat 10.1 配6.0,Tomcat 9 配4.0。改这一行有时候能解决一些说不上来的诡异问题。
最后留一个扩展方向。JSTL 在纯 JSP 项目里很顺手,但如果项目已经上了前后端分离,视图层交给前端框架,JSTL 的使用场景会大幅收缩。真要继续用 JSP,可以考虑把公共的头部、尾部、分页组件抽成.tag文件或者独立的 JSP 片段,用<jsp:include>组合,比在每个页面里重复写c:forEach分页逻辑要省事得多。这是我维护了几个老系统之后,觉得性价比最高的一次重构动作。