我一直觉得,JSP教科书不该是那种只讲语法、不告诉你什么时候别用的书。它更像一份在线指南——既要讲清JSP体系里Servlet、指令、内置对象这些骨架,也要把EL表达式、JSTL标签库这些能把页面变干净的工具讲透,最后还要落到JSP开发模型和分页查询这种真实项目天天用的场景上。如果你正准备系统学Java Web,或者手头维护着老系统,这篇内容值得从头到尾过一遍。
前两天帮朋友看一个跑了快十年的老项目,点开一个列表页,一千多行JSP,里面全是<% %>脚本片段,一个字段的取值逻辑散落了七八处。说实话,这种代码看着就脑仁疼。但转念一想,当年写这些代码的人,也不是不想用EL和JSTL,而是很多人学JSP的时候,教材上来就是语法,从来没人告诉他"什么场景该用什么"。所以这篇我不打算按语法书的顺序平铺,而是按我实际排查和重构项目时的思考路径,把JSP体系彻底拆开。
1. 为什么JSP体系至今还值得完整啃一遍
1.1 JSP解决了什么历史问题
JSP刚出来那几年,Java Web开发的主流方式是Servlet。写一个带表格的HTML页面,你得在Java代码里out.println("<tr><td>" + user.getName() + "</td></tr>"),循环几行还好,整个页面都是这种拼接代码,写的人崩溃,看的人更崩溃。
JSP的思路是把这件事反过来:先写HTML,在需要动态插入数据的位置开个小口,把Java片段嵌进去。容器会自动把JSP翻译成Servlet去执行,开发者不用手动管理编译和输出。这个思路在当年是革命性的,也是JSP体系一切特性的源头——你理解了"JSP本质是Servlet"这句话,后面所有语法都好解释了。
1.2 JSP与Servlet不是对立的,是同一件事
很多初学者会问:JSP和Servlet到底选哪个?这个问题本身就有问题。JSP文件第一次被访问时,容器会把它解析成一个Servlet的Java源文件,再编译成class文件。也就是说,JSP是"用写模板的方式写Servlet"。
所以你在JSP里能直接用request、response、session这些对象,并不是什么魔法,而是因为翻译出来的Servlet本来就提供了这些。理解这一点,你就不会觉得JSP的九大内置对象是死记硬背的知识点,而是Servlet编程模型里天然存在的东西。
1.3 生命周期:你的页面到底是怎么跑起来的
JSP的生命周期可以拆成四段:编译、初始化、服务、销毁。
第一次访问某个.jsp时,容器先把它翻译成_jsp.java,再编译成_jsp.class,然后实例化并调用jspInit()初始化。之后每次访问都走_jspService()方法,这个方法就是那个被翻译出来的Servlet的service()逻辑。容器关闭或页面被替换时,调用jspDestroy()收尾。
这里有一个非常实际的体验:部署后第一次访问JSP页面总觉得慢,原因就是容器正在现场编译。如果页面很多,首次启动后最好把主要页面都访问一遍"热热身"。另外,你修改了JSP文件后,容器会检测到文件时间戳变化,自动重新编译,不需要重启应用。但如果改了web.xml或者类路径下的jar包,那还是得重启。这个坑我见过不少同事踩过,页面改了没生效,第一反应是重启服务器。
2. JSP页面体系解剖:指令、脚本元素与九大内置对象
2.1 三大指令分别管什么
JSP指令不产生任何输出,只给容器下命令。常用的是三个:page、include、taglib。
page指令控制当前页面的属性。最常见的配置长这样:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="java.util.*" session="true" buffer="8kb" errorPage="/error.jsp" isErrorPage="false" isELIgnored="false" %>这几个属性里,有两个特别容易踩坑:
第一个是pageEncoding和contentType的编码问题。pageEncoding管的是JSP文件本身在磁盘上的读取编码,contentType管的是响应给浏览器的编码。两者不一致,中文必乱码。如果是Maven项目,还要确保文件在磁盘上的实际编码和pageEncoding一致,否则编译直接报错。
第二个是isErrorPage。只有设置成true的页面,才能在内置对象里使用exception,否则你在页面上写${exception}或者<%=exception%>会直接编译失败。想统一处理异常,需要在可能出错的Page上配errorPage="/error.jsp",而error.jsp要配isErrorPage="true"。
include指令是静态包含,代码长这样:
<%@ include file="header.jsp" %>它是在编译阶段把被包含文件的文本内容直接合并进来,两个文件最终编译成一个Servlet。优点是性能好,缺点是如果两个文件里定义了同名变量,直接编译冲突。
taglib指令用于引入标签库,后面讲EL和JSTL时会专门展开。
2.2 脚本元素:用还是不用,是个判断题
JSP里的脚本元素有三种:<% %>、<%= %>、<%! %>。
<% %>叫脚本片段,里面写的是_jspService()方法里的Java语句,可以做循环、判断、调用方法。<%= %>叫表达式,等价于out.print(...),用来输出一个值。<%! %>叫声明,里面定义的变量和方法会成为Servlet类的成员。
用<%! %>声明成员变量时要格外小心:成员变量是多个请求线程共享的,如果用它保存用户状态,高并发下必然出问题。我在老项目里见过拿<%! int count = 0; %>做访问计数的,一压测数字全乱。
我的经验是:脚本元素能不用就不用。维护阶段最痛苦的就是看到一堆<% %>穿插在HTML标签里,标签配对不齐,编译报错都定位不到行。如果项目允许,尽量用EL和JSTL替代;实在有复杂逻辑,放到Servlet或者JavaBean里,页面上只留一行标签。
2.3 include动作:另一种包含方式
除了<%@ include %>,还有<jsp:include>动作元素:
<jsp:include page="header.jsp" flush="true" />区别在于:指令是文本级合并,动作是请求级转发。<jsp:include>在运行时把另一个资源的输出拿过来,相当于底层做了一次子请求,被包含页面有自己独立的作用域,也可以通过<jsp:param>传参数。
那公共头部到底用哪种?如果头部是不变的静态片段,用<%@ include %>更省;如果头部需要根据当前请求变化,比如读取登录用户显示不同菜单,那就用<jsp:include>。这个判断直接决定你做模板复用时的代码结构。
2.4 九大内置对象:一张表记住所有
内置对象就是容器在_jspService()方法里帮你创建好的变量,直接在JSP里用就行。
| 内置对象 | 类型 | 典型用途 |
|---|---|---|
| request | HttpServletRequest | 获取请求参数、请求头、转发 |
| response | HttpServletResponse | 重定向、设置响应头 |
| out | JspWriter | 直接输出内容 |
| session | HttpSession | 会话级数据 |
| application | ServletContext | 应用级共享配置 |
| pageContext | PageContext | 页面上下文,可访问其他8个对象 |
| config | ServletConfig | 获取Servlet初始化参数 |
| page | Object(this) | 当前Servlet实例 |
| exception | Throwable | 仅在isErrorPage="true"时可用 |
其中pageContext最容易被忽略,但它是EL表达式的关键枢纽。通过pageContext可以拿到request、session、application等所有对象,而且它还维护了page作用域。后面EL表达式里的${pageContext.request.contextPath},就是通过它取应用根路径的标准写法。
3. EL表达式:把页面上的Java碎块清干净
3.1 先看一组对比,你就明白为什么要有EL
不用EL时,输出一个请求参数是这样的:
<%= request.getParameter("username") %>用了EL之后是这样:
${param.username}前者的麻烦不止是代码长。request.getParameter()返回null时,<%= %>输出的结果是字符串"null";而EL表达式遇到空值,直接输出空白串,不会有莫名其妙的"null"出现在页面上。这一点在日常开发里太重要了,它让页面在数据不完整时依然保持可读,不会给用户看到一堆英文null。
3.2 EL的取值路径与运算符
EL语法核心就是${表达式}。取值可以用点号,也可以用方括号:
${user.name} ${user["name"]}两者大部分场景等价,但方括号能做更多事:比如Map的key里带特殊字符、List按下标取值、动态拼接key。取List元素是这样:${list[0]},取Map元素是${map['key']}。
EL的运算符也很紧凑:
- 算术:
+-*/或div%或mod - 逻辑:
&&或and||或or!或not - 比较:
==或eq!=或ne<或lt>或gt<=或le>=或ge - 空值判断:
${empty var},empty不但能判断null,还能判断空列表、空字符串
特别提醒:EL里的/是浮点除法,${7 / 2}结果是3.5,不是Java里的整数除法结果3。写价格计算类页面时要注意这个区别。
3.3 四个作用域与查找顺序
page、request、session、application四个作用域是JSP数据传递的基础,EL访问变量时会按这个顺序依次查找:pageScope → requestScope → sessionScope → applicationScope。找到第一个就直接返回,后面就不再查了。
这个顺序会带来一个隐蔽的坑:如果session里存了一个user对象,request里又放了一个同名的user,你写${user.name}时拿到的是requestScope里的那个。想强制指定作用域,必须显式写${requestScope.user.name}或${sessionScope.user.name}。
3.4 EL隐式对象与常见陷阱
EL提供了11个隐式对象,最常用的有:
param/paramValues:获取请求参数header/headerValues:获取请求头cookie:获取CookieinitParam:获取web.xml里配置的context-param初始化参数pageContext:PageContext对象本身,不是pageScopepageScope/requestScope/sessionScope/applicationScope:四个Map
一个经典用法是${pageContext.request.contextPath}获取应用部署根路径,拼接CSS、JS和链接时基本离不开它。相对路径在转发到/WEB-INF/jsp/xxx.jsp时会乱套,绝对根路径最稳。
容易踩的坑是initParam只对应ServletContext的初始化参数,对应web.xml里的<context-param>;如果你要获取某个Servlet自己的<init-param>,EL表达式是拿不到的,必须通过config对象或者Java代码处理。
4. JSTL标签库:从页面拙劣脚本到标签化思维
4.1 环境配置:jar包和uri别搞混
JSTL的英文全称是JavaServer Pages Standard Tag Library。要用它,先引入依赖。老项目通常需要jstl.jar和standard.jar;如果用较新的Servlet容器,依赖坐标会变成jakarta.servlet.jsp.jstl。
页面引入标签库的方式:
<%@ 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" %>新版的uri可能变成jakarta.tags.core、jakarta.tags.fmt、jakarta.tags.functions。如果你用的是Tomcat 10及以上,还是写老版uri,运行时会报找不到标签库的错,这是现在很常见的环境配置问题。
4.2 核心标签实测:页面循环与条件分支
JSTL核心标签库最常用的四个是c:out、c:if、c:choose、c:forEach。
c:out输出变量,默认会把XML特殊字符转义,能防XSS,推荐用它替代<%= %>直接输出数据的地方。
c:if做单分支判断,没有else,想写else要用c:choose组合:
<c:choose> <c:when test="${user.status == 1}"> <span class="normal">正常</span> </c:when> <c:otherwise> <span class="disabled">停用</span> </c:otherwise> </c:choose>c:forEach是循环主力,遍历List渲染表格是它最典型的场景:
<c:forEach items="${userList}" var="user" varStatus="st"> <tr class="${st.count % 2 == 0 ? 'even' : 'odd'}"> <td>${st.count}</td> <td><c:out value="${user.username}" /></td> <td>${user.realName}</td> <td> <fmt:formatDate value="${user.createTime}" pattern="yyyy-MM-dd HH:mm:ss" /> </td> </tr> </c:forEach>varStatus有很多好用的属性:count从1开始计数,index从0开始,first和last判断首尾。利用它做表格斑马纹、序号列都非常方便,完全不需要写Java代码。
还应该提到c:url标签:生成带参数的URL时,c:param会自动编码,避免中文参数把链接搞坏。这个放在分页查询部分会重点实践。
4.3 fmt标签:让日期和数字不再难看
Java里格式化一个日期通常要new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"),然后手动调用方法。在JSTL里一行搞定:
<fmt:formatDate value="${user.createTime}" pattern="yyyy-MM-dd HH:mm:ss" />数字格式化用fmt:formatNumber,比如保留两位小数、千分位:
<fmt:formatNumber value="${salary}" pattern="#,##0.00" />这个标签的价值不只是少写代码,更在于模板的可维护性。你改日期格式时不需要动Java代码重新编译,直接在JSP里改pattern就能生效。
4.4 fn函数库:字符串处理的轻量武器
fn标签库提供了一批字符串函数,最常用的是fn:length、fn:contains、fn:substring等。
<c:if test="${fn:length(userList) > 0}">有数据</c:if> ${fn:substring(user.name, 0, 2)}注意fn:length对List和数组都有效,对字符串也能返回字符长度。fn:contains可以替代Java里的contains()判断。页面上遇到字符串截断、包含判断这些操作,用fn标签比在Servlet里提前处理更灵活。
5. JSP开发模型:从Model1到Model2的职责革命
5.1 Model1的快速原型优势与后期灾难
Model1开发模型下,浏览器请求直接打到JSP,JSP里既负责接收参数、调用JavaBean获取数据,又负责渲染页面。流程如下:
浏览器 → JSP → JavaBean → 数据库这种模型上手极快,页面少、逻辑简单时开发效率很高。我自己早期做课程设计就喜欢这么写:一个JSP页面里调Service、查数据库、表格循环输出,全部塞在一个文件里,改起来很爽。
但需求一复杂就露馅。一个页面上百行<% %>脚本,里面混着SQL、业务判断和HTML,随便改一个字段都可能牵连出一堆逻辑。"这个页面到底做什么"过两个月连自己都看不懂。项目到后期,修改频率越高,Model1的维护成本指数级上升。
5.2 Model2如何划分职责
Model2就是经典的MVC分层,职责划分如下:
- Servlet当控制器,接收请求、解析参数、调用Service、把数据放进request作用域、转发或重定向到JSP
- JavaBean或POJO当模型,封装业务数据和业务逻辑
- JSP只当视图,负责把控制器传过来的数据渲染成HTML
流程是这样:
浏览器 → Servlet → Service/DAO → JavaBean → 转发到JSP → 渲染HTMLJSP里不再出现业务逻辑,只剩下EL和JSTL做数据展示。好处非常明显:改页面字体、布局不会影响业务逻辑,改业务逻辑不会碰页面。前后端同事的分工边界一下子清晰了。
5.3 WEB-INF目录:视图保护的关键
实现Model2后,一个经典部署结构是:
src/main/webapp ├── WEB-INF │ ├── web.xml │ └── jsp │ ├── user │ │ ├── list.jsp │ │ └── edit.jsp │ └── common │ ├── header.jsp │ └── footer.jsp └── static ├── css └── jsJSP放入WEB-INF目录后,浏览器无法通过URL直接访问,只能通过Servlet内部forward跳转。这意味着用户不能绕开控制器直接打开页面,权限校验、参数处理等逻辑必须在Servlet里完成。这是我特别想强调的一点:Model2不是单纯的技术分层,更是一种访问控制手段。
5.4 我见过的模型混乱现场
维护老项目时,最让我头疼的不是JSP本身,而是模型混用。一个系统里,有的页面走Servlet转发,有的页面直接用JSP访问WebRoot下的文件,还有的Servlet不转发,自己用out.write()输出整个HTML页面。
这种混乱会让后来的维护者完全无法判断一条请求链路是怎么回事。所以选型的时候,要么全系统统一Model2,要么只在极小的内部工具页面用Model1,千万不要边写边换。一个项目最怕的不是选了一个"旧"的模型,而是没有模型。
6. 分页查询从SQL到页面导航的完整落地
6.1 分页SQL怎么写
分页的第一件事,是让数据库只返回当前页的数据。不同数据库语法不同:
MySQL:
SELECT * FROM t_user ORDER BY id LIMIT #{offset}, #{pageSize}这里的offset是偏移量,计算规则是(pageNo - 1) * pageSize。Oracle 12c以上用OFFSET ... ROWS FETCH NEXT ... ROWS ONLY;SQL Server 2012以上也是类似的OFFSET ... FETCH。老版本Oracle用ROWNUM子查询,写起来比较绕。
为什么强调分页要在SQL层做?因为如果一次性把全表数据查出来,到Java层再截取,数据量一大内存直接扛不住。我见过有人用这种方式处理几千条数据,页面卡得没法看。
6.2 PageBean到底封装什么
分页组件最好设计成一个通用的PageBean,而不是每次手写一堆参数。核心字段这些:
| 字段 | 含义 | 计算/赋值方式 |
|---|---|---|
| pageNo | 当前页码 | 从请求参数解析,默认1 |
| pageSize | 每页条数 | 可配置,需设上限 |
| totalCount | 总记录数 | count查询得到 |
| totalPage | 总页数 | (totalCount + pageSize - 1) / pageSize |
| records | 当前页数据 | 列表查询结果 |
| offset | SQL偏移量 | (pageNo - 1) * pageSize |
Java代码长这样:
public class PageBean<T> { private int pageNo; private int pageSize; private int totalCount; private int totalPage; private List<T> records; public PageBean(int pageNo, int pageSize, int totalCount, List<T> records) { this.pageNo = pageNo; this.pageSize = pageSize; this.totalCount = totalCount; this.records = records; this.totalPage = (totalCount + pageSize - 1) / pageSize; } // getter/setter... }为什么总页数不直接用除法?因为Java整数除法会丢掉小数。(totalCount + pageSize - 1) / pageSize这种写法是向上取整的标准技巧,比如总记录数13条、每页5条,结果是(13 + 4) / 5 = 3,正好3页。
6.3 三层代码串起来
DAO层用MyBatis的话,函数和XML配置大概是这样的思路:
public PageBean<User> queryUsersByCondition(String keyword, int pageNo, int pageSize) { int offset = (pageNo - 1) * pageSize; List<User> records = userMapper.selectByCondition(keyword, offset, pageSize); int totalCount = userMapper.countByCondition(keyword); return new PageBean<>(pageNo, pageSize, totalCount, records); }对应的SQL用<where>和<if>动态拼接:
<select id="selectByCondition" resultType="user"> SELECT * FROM t_user <where> <if test="keyword != null and keyword != ''"> AND username LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY id LIMIT #{offset}, #{pageSize} </select> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM t_user <where> <if test="keyword != null and keyword != ''"> AND username LIKE CONCAT('%', #{keyword}, '%') </if> </where> </select>这里有个原则必须守住:列表查询和totalCount查询的where条件要完全一致。否则列表只有5条数据,总数却显示20条,总页数全是错的。很多分页bug都是这边漏了一个条件那边多了一个条件。
Servlet层负责接收参数、调用Service、把PageBean放进request并转发:
@WebServlet("/user/list") public class UserListServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ... { int pageNo = parsePageNo(req.getParameter("pageNo")); int pageSize = 5; String keyword = req.getParameter("keyword"); PageBean<User> pageBean = userService.queryUsersByCondition(keyword, pageNo, pageSize); req.setAttribute("pageBean", pageBean); req.setAttribute("keyword", keyword); req.getRequestDispatcher("/WEB-INF/jsp/user/list.jsp").forward(req, resp); } private int parsePageNo(String param) { int pageNo = 1; try { pageNo = Integer.parseInt(param); } catch (Exception e) { pageNo = 1; } return Math.max(pageNo, 1); } }parsePageNo这一步很多人会忽略。用户手动改URL参数传一个非数字或者负数,如果不处理,Integer.parseInt直接抛异常。请求参数永远不可信,这是写Servlet必须有的意识。
6.4 JSP分页导航与搜索条件回显
到了JSP页面,用JSTL渲染分页导航条。最朴素的版本是首页、上一页、页码数字、下一页、末页:
<div class="pagination"> <c:if test="${pageBean.pageNo > 1}"> <a href="${pageContext.request.contextPath}/user/list?pageNo=1&keyword=${keyword}">首页</a> <a href="${pageContext.request.contextPath}/user/list?pageNo=${pageBean.pageNo - 1}&keyword=${keyword}">上一页</a> </c:if> <span>第 ${pageBean.pageNo} / ${pageBean.totalPage} 页,共 ${pageBean.totalCount} 条</span> <c:if test="${pageBean.pageNo < pageBean.totalPage}"> <a href="${pageContext.request.contextPath}/user/list?pageNo=${pageBean.pageNo + 1}&keyword=${keyword}">下一页</a> <a href="${pageContext.request.contextPath}/user/list?pageNo=${pageBean.totalPage}&keyword=${keyword}">末页</a> </c:if> </div>这里的关键是pageNo > 1才显示首页和上一页,pageNo < totalPage才显示下一页和末页,避免出现点击上一页还是停在第一页、点击下一页跑出有效范围的尴尬。
但直接拼URL有个隐患:如果keyword里包含中文、空格或&字符,链接会错乱。更稳妥的做法是用c:url和c:param生成链接:
<c:url var="pageLink" value="/user/list"> <c:param name="pageNo" value="${pageBean.pageNo + 1}" /> <c:param name="keyword" value="${keyword}" /> </c:url> <a href="${pageLink}">下一页</a>c:url会自动拼上contextPath,c:param自动URL编码,等于帮我们处理了路径和特殊字符两件麻烦事。
搜索条件回显是分页里最容易出体验问题的地方。用户在搜索框输入"张三",点第二页后,搜索框还是"张三"吗?答案是,需要你在JSP的input里显式回显:
<input name="keyword" value="${empty keyword ? '' : keyword}" />如果不回显,用户翻页后搜索框空了,他会以为自己搜的条件被重置。这个细节看着小,却直接影响搜索功能的可用性。
6.5 分页里的常见翻车现场
我挑四个高频问题,给大家排雷:
第一个,总数查询和列表查询的条件不一致。我排查过一个列表页,总数显示有283条,但点最后一页只有两行数据,原因就是count语句漏了一个过滤条件。正确做法是把条件片段抽取成公共SQL片段,在MyBatis里用<sql>标签复用,避免copy-paste时改一处漏一处。
第二个,排序字段直接拼接进SQL。页面上传一个sort=createTime,DAO就直接拼ORDER BY createTime。这种写法一旦被恶意传参,等于把SQL注入的窗口打开。正确做法是做白名单映射,只允许固定的字段集合。
第三个,pageNo没做边界处理。用户手动访问?pageNo=999999,数据库查询没问题,但页面会渲染出一个巨大的页码数字。要么查完后把pageNo修正到totalPage以内,要么在Service层直接限制。
第四个,pageSize不设上限。一个恶意请求把pageSize传成100000,等于让数据库一次性返回全表,接口性能瞬间被打满。建议在Servlet层加一个Math.min(pageSize, 100)的限制。
最后再分享一点个人体会。我最初也觉得JSP这套体系"过时",但后来发现,EL表达式的取值链、JSTL把循环和格式化从代码中抽出来的思路,在后来的Thymeleaf、甚至前端框架的模板语法里都能看到影子。你把JSP体系学透了,再学别的模板引擎会很顺,因为它们解决的是同一个问题:怎么把动态数据干净地渲染到页面上。而且遇到那种跑了十几年的老系统,你至少有底气打开一个.jsp文件,三分钟内定位到问题在哪一层——这在真实项目里,是很值钱的能力。