第8章 JSP、EL 与 JSTL
难度:★★★★☆ | 前置知识:第5章 Tomcat 与 Servlet、第6章 Request 与 Response、第7章 会话技术 | 本章解决:把"在 Java 里拼 HTML"翻转成"在 HTML 里取数据",再用 EL 与 JSTL 把页面里最后一点 Java 代码也请出去
本章导读
- 到第 6 章、第 7 章为止,我们写页面的方式只有一种:在 Servlet 里
response.getWriter().write("<html>..."),一行一行把 HTML 当字符串拼出来。写一个"登录成功"页还能忍,写一个带表格的品牌列表页就要崩溃:引号要转义、标签要闭合、循环要手写、改一个<td>要重新编译整个 Servlet。本章要解决的第一个问题就是:能不能把 HTML 直接写在文件里,只在需要数据的地方插一句 Java? - JSP 就是这个答案。它让你在一个长得像
.html的文件里写<% %>和<%= %>,Tomcat 负责把它翻译成一个 Servlet 再编译执行。所以JSP 是 Servlet 的另一种写法,两者并不是两种语言。这句话是整章的基础,8.3 节会把 Tomcat 翻译出来的.java文件摊开给你看。 - 但是 JSP 只解决了"HTML 好写",没解决"页面里全是 Java"。于是有了第二步演进:用 EL 表达式
${}取值,用 JSTL 标签<c:forEach>、<c:if>做循环和判断,把<% %>从页面里彻底清出去。学完本章,一个列表页应该长这样:没有一行 Java 代码,只有${brand.brandName}和<c:forEach>。 - 第三步是分工。Servlet 负责逻辑,JSP 负责展示,再往上抽象一层就是 MVC 模式与三层架构。本章最后的品牌管理案例,就是这条分工线的落地:浏览器点"查询所有" → Servlet 查库 → 结果存进 request 域 → 转发给 JSP → JSP 用 EL/JSTL 渲染成表格。
- 最后要泼一盆冷水:JSP 在今天已经不推荐使用了,新项目用 Thymeleaf、FreeMarker,或者干脆前后端分离上 Vue/React。但这并不是 JSP 的错误,原因在于它把逻辑和展示耦合得太紧。 学它的价值在于:你会亲眼看到"耦合"长什么样,之后再看模板引擎和前后端分离,才知道它们各自在解决哪个具体问题。
【考点】本章在卷子上的形态很固定:一道"JSP 原理"简答(翻译成 Servlet、
_jspService、九个内置对象),一道"EL/JSTL 读程序题"(给一段 JSP 与一个 Servlet,问页面输出什么),一道"MVC 与三层架构"的概念题,最后是一道综合设计题。记忆负担最轻、分值最稳的是 8.3 的原理与 8.7、8.8 的语法,优先保住。
8.1 JSP 是什么
8.1.1 先从只用 Servlet 写页面的痛说起
先不看 JSP,先看一个已经会写的场景:用户登录成功之后,页面上要显示"某某某,欢迎您"。这个用户名是动态展示的,谁登录就展示谁的名字。
只用 Servlet 怎么实现?思路是:Servlet 里校验完用户名密码,把用户名存进 session,然后由 response.getWriter() 拿到的字符输出流,把整个 HTML 页面一个字一个字写出去。这段代码的形状大致如下(下面只是让你体会"形状",真实代码里要拼的标签比这多得多):
PrintWriter writer = response.getWriter();
writer.write("<html>");
writer.write("<head><title>登录成功</title></head>");
writer.write("<body>");
writer.write("<h1>" + username + ",欢迎您</h1>");
writer.write("</body>");
writer.write("</html>");这就是原文所说的:代码有大量使用到 writer 对象向页面写标签内容,这样我们的代码就显得很麻烦;将来如果展示的效果出现了问题,排错也显得有点力不从心。
麻烦在哪里?把这句话拆开看:
| 痛点 | 具体表现 |
|---|---|
| 引号地狱 | HTML 属性本身要双引号,Java 字符串也要双引号,于是满屏 \",或者被迫全用单引号 |
| 语法割裂 | HTML 的缩进、闭合、层级是靠肉眼维护的,它在 Java 里只是一堆字符串,IDE 不会帮你检查标签有没有闭合 |
| 改动成本高 | 前端说"表格加一列",你改的是 Java 源码,改完要重新编译、重启服务器 |
| 排错困难 | 页面结构错了,浏览器只会给你一个歪掉的页面;异常栈里只有 write() 的行号,看不出是哪个标签的问题 |
| 无法分工 | 前端工程师不会 Java,后端工程师不精 HTML,两边只能靠"你给我静态页、我给你改 JSP"来回倒手 |
8.1.2 JSP 的定义
JSP(全称:Java Server Pages):Java 服务端页面。 它是一种动态的网页技术,其中既可以定义 HTML、JS、CSS 等静态内容,还可以定义 Java 代码的动态内容,也就是 JSP = HTML + Java。
下面就是一段最朴素的 JSP 代码:
<html>
<head>
<title>Title</title>
</head>
<body>
<h1>JSP,Hello World</h1>
<%
System.out.println("hello,jsp~");
%>
</body>
</html>上面代码中,h1 标签的内容是展示在页面上的,而 Java 的输出语句 System.out.println 是输出在 IDEA 的控制台里的。这两种输出走的是两条完全不同的路,一个是 HTTP 响应体,一个是服务器进程的标准输出。这个区别后面会反复用到(见 8.6 节 out 与 System.out 的对比)。
JSP 能做什么?还是那个登录后展示用户名的例子。用 JSP 实现时,页面里基本都是 HTML 标签,只在要显示用户名的地方写一句 Java 表达式:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>登录成功</title>
</head>
<body>
<h1><%=session.getAttribute("username")%>,欢迎您</h1>
</body>
</html>可以看出,这样操作起来要比用 Servlet 实现舒服很多。于是得到 JSP 的定位:
JSP 作用:简化开发,避免了在 Servlet 中直接输出 HTML 标签。
8.1.3 JSP 与 Servlet 的分工
既然两者都能生成页面,那到底谁干什么?答案是分工,而不是二选一:
| 维度 | Servlet | JSP |
|---|---|---|
| 主要职责 | 接收请求、处理业务、调用模型、控制跳转 | 把域中的数据渲染成 HTML 展示 |
| 擅长的事 | 写 Java 逻辑(判断、循环、调 Service、访问数据库) | 写 HTML 结构(表格、表单、样式) |
| 不擅长的事 | 拼 HTML 字符串 | 写复杂业务逻辑 |
| 本质 | 一个 Java 类 | 一个会被翻译成 Java 类的页面 |
| 由谁编写 | 后端工程师 | 偏前端,后端也能改 |
| 本章后半段的角色 | Controller(控制器) | View(视图) |
记法是:Servlet 负责"想",JSP 负责"显示"。 这句话在 8.9 节的 MVC 里会被正式命名,在 8.10 节的案例里会实际运行。
【对比】很多人把 JSP 理解成"升级版的 Servlet",这是错的。JSP 在能力上是 Servlet 的子集(它编译后就是一个 Servlet),在用途上却是 Servlet 的补充:Servlet 用来写逻辑,JSP 用来写展示。两者的关系是"同一种东西的两种写法,各自适合不同的工作",而不是"新的替换旧的"。
【易错点】 不要在 JSP 里写业务逻辑(查数据库、算金额、校验权限),也不要在 Servlet 里
write("<table>")。这两件事都"能跑",但是都违反分工:前者让页面没法交给前端改,后者让排版没法维护。判断标准很简单:这段代码里如果有new、有 SQL、有if判断业务规则,它就不该出现在 JSP 里。
8.2 JSP 快速入门
光看定义没用,先跑通一个最小的 JSP,建立"文件放哪、怎么访问、结果长什么样"的直觉。
8.2.1 搭建环境
创建一个 Maven 的 web 项目,项目结构如下:
jsp-demo
├── pom.xml
└── src
└── main
├── java
└── webapp
└── hello.jsppom.xml 文件内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>jsp-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
</plugin>
</plugins>
</build>
</project><packaging> 必须是 war,因为这是一个要部署到容器里的 Web 工程;provided 表示这个 jar 由 Tomcat 提供,不打进 war 包。
8.2.2 导入 JSP 依赖
在 dependencies 标签中导入 JSP 的依赖,如下:
<dependency>
<groupId>javax.servlet.jsp</groupId>
<artifactId>jsp-api</artifactId>
<version>2.2</version>
<scope>provided</scope>
</dependency>该依赖的 scope 必须设置为 provided,因为 Tomcat 中已经有这个 jar 包了,所以在打包时我们是不希望将该依赖打进到我们的 war 包中的。
【补充】为什么"重复的 jar 包"是灾难?如果
jsp-api被打进WEB-INF/lib,容器里就会出现两份javax.servlet.jsp.*的类:一份来自 Tomcat 自己的jasper.jar,一份来自你的 war 包。一旦两者版本不一致(比如 war 里是 2.2、Tomcat 里是 2.3),就可能抛java.lang.LinkageError或NoSuchMethodError。现象很诡异:编译完全正常,一访问 JSP 就 500。凡是容器已经提供的 API(servlet-api、jsp-api、el-api),一律provided。
8.2.3 创建 JSP 页面
在项目的 webapp 目录下创建 JSP 页面,通过这种方式创建一个名为 hello.jsp 的页面。
【易错点】JSP 文件必须放在
webapp目录(或其子目录)下,不能放在WEB-INF里:WEB-INF下的资源浏览器无法直接访问,只能由服务器内部转发过去。另外文件名后缀必须是.jsp,如果你建成了hello.html再往里写<% %>,浏览器只会把这段代码当普通文字原样显示出来。
8.2.4 编写代码
在 hello.jsp 页面中书写 HTML 标签和 Java 代码,如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<h1>hello jsp</h1>
<%
System.out.println("hello,jsp~");
%>
</body>
</html>第一行的 <%@ page ... %> 是 page 指令,告诉容器"这个页面输出的是 HTML、编码是 UTF-8、脚本用 Java 写"。这一行几乎每个 JSP 都会有,8.4.1 节会把它的每个属性拆开讲。
8.2.5 测试
启动服务器,并在浏览器地址栏输入 http://localhost:8080/jsp-demo/hello.jsp,你可以在页面上看到 hello jsp 这行大字。
同时也可以在 IDEA 的控制台看到输出的 hello,jsp~ 内容。
请注意两者的去向差异:页面上出现的是 hello jsp(HTML 标签被浏览器解析了),控制台出现的是 hello,jsp~(System.out 的输出回到了服务器进程,根本没有进 HTTP 响应)。如果你希望一段文字出现在浏览器里,用 out.print() 或 <%= %>;用 System.out.println() 是永远到不了浏览器的。
【考点】访问一个 JSP 时,Tomcat 内部发生了四步:翻译(
.jsp→xxx_jsp.java)→ 编译(.java→.class)→ 加载(类加载器加载)→ 执行(_jspService)。第一次访问会明显变慢,就是因为前三步;之后每次访问只执行第四步。这个"第一次慢、后面快"的现象,是判断一个 JSP 是否已经被编译过的直观办法。
8.3 JSP 原理
我们在 8.1 节说 JSP 就是一个页面,那么在 JSP 中写 HTML 标签,我们能理解;但是为什么还可以写 Java 代码呢?因为 JSP 就是一个 Servlet。 这一节把这句话拆开验证一遍。这是本章唯一一段"看不见但必须懂"的内容,也是简答题的高频出处。
8.3.1 访问 JSP 的四步
浏览器访问一个 JSP 时,Tomcat 内部按下面四步走:
- 浏览器第一次访问
hello.jsp页面; - Tomcat 会将
hello.jsp转换为名为hello_jsp.java的一个 Servlet; - Tomcat 再将转换的 Servlet 编译成字节码文件
hello_jsp.class; - Tomcat 会执行该字节码文件,向外提供服务。
把四步画成一条流水线,就是下面这样:
①浏览器访问 /hello.jsp
浏览器 ────────────────────────────────► Tomcat
│
│ ②翻译
│ hello.jsp
│ ↓ 生成 Servlet 源码
│ hello_jsp.java
│ ↓ 编译
│ hello_jsp.class
│ ↓ ③加载、④执行
▼
响应一段 HTML ◄───────── _jspService() 里 out.write("...").jsp 是给人写的源文件,.java 和 .class 是 Tomcat 替你补上的"中间产物",你从头到尾不用手写一行。这正是 JSP 被称为"Servlet 的另一种写法"的字面含义。
注意第 1 步里的"第一次"三个字。翻译和编译只在第一次访问(或者 JSP 文件被修改之后)发生,之后 Tomcat 直接复用已经编译好的 .class。这就是为什么改完 JSP 刷新页面就能看到效果,而改完 Servlet 必须重启服务器:JSP 自带"改了就重新编译"的能力,Servlet 没有。
8.3.2 去哪儿找翻译出来的 .java 文件
这不是比喻,文件是真真切切生成在磁盘上的。到项目所在磁盘目录下找下面这个路径:
target\tomcat\work\Tomcat\localhost\jsp-demo\org\apache\jsp这个目录下就能看到转换后的 Servlet:
org\apache\jsp
├── hello_jsp.java
└── hello_jsp.class打开 hello_jsp.java 文件,就能看到 Tomcat 替我们写的那一坨 Java 代码。
【补充】这个
work目录是 Tomcat 的"工作目录",专门存放 JSP 翻译后的中间产物。它的完整路径规律是工作目录\Tomcat\localhost\<项目上下文路径>\org\apache\jsp\,其中"项目上下文路径"就是你在浏览器地址栏里localhost:8080/后面那一段(示例里是jsp-demo)。用 IDEA 的内置 Tomcat 插件跑项目时,工作目录被重定向到了工程的target\tomcat\work下,所以你在项目里能直接看到;如果部署到独立的 Tomcat 安装目录,则要去<Tomcat安装目录>\work\Catalina\localhost\<项目名>\下找。排错时的用法:JSP 报 500 且错误信息指向某一行.java时,就去这个目录打开对应的_jsp.java,把行号对上,你就知道是自己的哪一段 JSP 出的问题。
8.3.3 继承链:它是怎么变成 Servlet 的
打开 hello_jsp.java,看类的声明,会看到它继承了名为 HttpJspBase 的类。那 HttpJspBase 又是什么?到 Tomcat 源码里找下面这个目录:
apache-tomcat-8.5.68-src\java\org\apache\jasper\runtime该目录下就有 HttpJspBase 类,查看该类的继承关系,可以看到:
public abstract class HttpJspBase extends HttpServlet implements HttpJspPage {
// ...
}可以看到该类继承了 HttpServlet;那么 hello_jsp 这个类就间接地继承了 HttpServlet,也就说明 hello_jsp 是一个 Servlet。整条链子画出来是:
hello_jsp → HttpJspBase → HttpServlet → GenericServlet → Object
(Tomcat 生成) (Jasper 提供) (Servlet 规范) (Servlet 规范)8.3.4 _jspService():每次访问真正执行的方法
继续阅读 hello_jsp 类的代码,可以看到有一个名为 _jspService() 的方法,该方法就是每次访问 JSP 时自动执行的方法,和 Servlet 中的 service 方法一样。
把生成代码里与本节相关的部分简化出来(去掉了异常处理与 Jasper 内部的样板代码),结构是这样的:
public final class hello_jsp extends org.apache.jasper.runtime.HttpJspBase {
public void _jspService(final javax.servlet.http.HttpServletRequest request,
final javax.servlet.http.HttpServletResponse response)
throws java.io.IOException, javax.servlet.ServletException {
final javax.servlet.jsp.PageContext pageContext;
javax.servlet.http.HttpSession session = null;
final javax.servlet.ServletContext application;
final javax.servlet.ServletConfig config;
javax.servlet.jsp.JspWriter out = null;
final java.lang.Object page = this;
try {
response.setContentType("text/html;charset=UTF-8");
pageContext = _jspxFactory.getPageContext(this, request, response, null, true, 8192, true);
application = pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out = pageContext.getOut();
out.write("<html>\r\n");
out.write("<head>\r\n");
out.write("<title>Title</title>\r\n");
out.write("</head>\r\n");
out.write("<body>\r\n");
out.write("<h1>hello jsp</h1>\r\n");
System.out.println("hello,jsp~");
out.write("</body>\r\n");
out.write("</html>\r\n");
} catch (java.lang.Throwable t) {
// 异常处理
}
}
}而在 _jspService() 方法中可以看到往浏览器写标签的代码:out.write("<html>\r\n")、out.write("<h1>hello jsp</h1>\r\n")……以前我们自己写 Servlet 时,这部分代码是由我们自己来写的,现在有了 JSP 后,由 Tomcat 完成这部分功能。 你在 JSP 里敲的每一个 HTML 字符,最后都变成了这样一行 out.write()。
这段代码里藏着本章后半部分所有知识点的源头,逐行读一遍:
response.setContentType("text/html;charset=UTF-8"):这就是 page 指令contentType属性的去处;pageContext = _jspxFactory.getPageContext(...):内置对象pageContext在这里被创建,最后两个参数8192和true分别对应 page 指令的buffer与autoFlush;session = pageContext.getSession():内置对象session在这里被赋值,这也是 page 指令session="false"能让它失效的原因;out = pageContext.getOut():内置对象out;application、config、page:另外三个内置对象;request、response:注意它们是方法形参,由 Tomcat 调用_jspService时传入,容器并不会 new 它们。
8.3.5 由这段代码得出的四个结论
第一,JSP 的 HTML 是"写出去",Java 代码是"执行掉"。HTML 部分变成 out.write() 的参数,Java 脚本部分原封不动地留在方法体里执行。所以 <% System.out.println(...) %> 打印到控制台,而 <h1> 打印到浏览器。
第二,JSP 的九大内置对象就是 _jspService 里的局部变量,没有魔法可言。8.6 节会专门列表说明,此处只要记住这个结论。
第三,JSP 是单实例多线程的。容器只创建一个 hello_jsp 实例,每次请求开一个线程执行 _jspService。所以 JSP 里的局部变量(<% %> 里定义的)是线程安全的,而 <%! %> 声明的成员变量是所有请求共享的,不安全,这就是 8.4.4 节要讲的常见错误。
第四,首次访问慢是正常的。翻译要读文件、解析标签、生成 Java 源码,编译要调 JDK 编译器,都是毫秒级的重活;之后就直接执行字节码了。
【对比】JSP 与 Servlet 的对应关系,一张表说清:
JSP 里的东西 翻译成 Servlet 后的样子 整个 .jsp文件一个类 xxx_jsp,继承HttpJspBase(间接继承HttpServlet)页面上的 HTML 文本 out.write("...")一行行写出<% ... %>脚本片段原样放进 _jspService()方法体<%= 表达式 %>out.print(表达式)<%! ... %>声明放进类体,成为成员变量 / 成员方法 <%@ page ... %>影响 _jspService开头那几行(setContentType、getPageContext的参数等)request、response等九个内置对象_jspService的形参或局部变量service()方法被 _jspService()取代,HttpJspBase的service()负责调用它【考点】 "JSP 和 Servlet 的关系"标准答案三句话:① JSP 会被 Tomcat 翻译成 Java 类,该类继承
HttpJspBase,HttpJspBase继承HttpServlet,所以 JSP 就是 Servlet;② JSP 的处理逻辑写在_jspService()方法里,等价于 Servlet 的service();③ 区别在于 JSP 擅长输出 HTML(写起来像网页),Servlet 擅长处理逻辑(写起来像 Java)。
8.4 JSP 的组成:指令、脚本、表达式、声明、注释
一个 JSP 文件,从头到尾只可能由五类东西组成:指令(<%@ %>)、脚本片段(<% %>)、表达式(<%= %>)、声明(<%! %>)、注释(<%-- --%>),剩下的就是原样的 HTML 和普通文本。这一节把五类逐个拆开。
JSP 脚本用于在 JSP 页面内定义 Java 代码。在之前的入门案例中,我们在 JSP 页面里定义的 Java 代码就是 JSP 脚本。
8.4.1 指令
指令的语法是 <%@ 指令名 属性="值" %>,它在翻译期起作用,用来告诉 Tomcat"这个页面该怎么翻译"。JSP 有三种指令:page、include、taglib。
(1)page 指令:描述整个页面的属性
page 指令最常用,属性也最多。常用的几个必须记住:
| 属性 | 取值示例 | 作用 |
|---|---|---|
language | java | 指定脚本语言,固定为 java,可省略 |
contentType | text/html;charset=UTF-8 | 相当于 response.setContentType(),告诉浏览器响应体的类型与编码 |
pageEncoding | UTF-8 | 告诉 Tomcat 用什么编码读取这个 .jsp 源文件 |
import | java.util.List | 等价于 Java 的 import,是唯一可以写多次的属性 |
isELIgnored | true / false | 是否忽略 EL 表达式,默认 false(即 EL 生效) |
session | true / false | 是否自动创建 session,默认 true;写 false 则内置对象 session 不可用 |
errorPage | /error.jsp | 本页出错时跳转到哪个页面 |
isErrorPage | true / false | 本页是否是错误处理页,只有 true 才能用内置对象 exception |
buffer | 8kb / none | out 输出缓冲区大小 |
autoFlush | true / false | 缓冲区满时是否自动刷新到客户端 |
用法示例如下(import 属性最典型的写法就是每个类写一行):
<%@ page import="com.itheima.pojo.Brand" %>
<%@ page import="java.util.List" %>
<%@ page import="java.util.ArrayList" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>也可以把多个类写在一个 import 里,用逗号分隔:
<%@ page import="java.util.List, java.util.ArrayList, com.itheima.pojo.Brand" %>【易错点】
contentType和pageEncoding长得像,作用完全不同,这是最容易被问倒的一个点。pageEncoding管的是"Tomcat 读 .jsp 文件时按什么编码解码",影响的是源码里的中文能不能被正确翻译;contentType里的charset管的是"响应发给浏览器时按什么编码",影响的是浏览器显示中文会不会乱码。只写contentType时,容器会退而用它的 charset 去读源文件,多数情况能蒙对;但如果两个都没写,Tomcat 按 ISO-8859-1 读源文件,源码里的中文会变成乱码,而且报错信息会指向一行你根本没写错的代码。最省事的做法就是像原文那样,两个都写UTF-8。【补充】
isELIgnored是唯一一个"让 EL 失效"的开关,默认false。什么时候会用到它?当页面里本来就要显示${...}这些字符时,比如讲 JavaScript 模板字符串、或者展示一段 JSP 教程代码。这时如果 EL 生效,${}会被当成表达式求值,取不到值就输出空串,你的字面量就"消失"了。解决方式有两种:用<%-- --%>包住不行(注释会整段删掉),正确做法是<%@ page isELIgnored="true" %>,或者用\${...}转义。
(2)include 指令:静态包含
include 指令用于在当前页面中静态包含另一个文件,语法是:
<%@ include file="header.jsp" %>在被包含的文件里可以写 HTML,也可以写 Java 代码片段(因为它在翻译期就被原样合并进来了)。典型用途是把页头、页脚、导航栏抽出来复用。
【对比】
include指令(静态包含)和<jsp:include>动作(动态包含)是最经典的一组易混概念,区别不在"能不能包含",而在"什么时候包含":
对比项 <%@ include file="x.jsp" %><jsp:include page="x.jsp"/>所属 指令 动作(Action) 发生时机 翻译期,合并进一个 .java运行期,每次请求时调用 产物 只有一个 class 主页面和子页面各有一个 class 变量 共享同一个 _jspService里的局部变量不共享,是两个独立的方法 传参 不能传参 可以用 <jsp:param name="x" value="1"/>传参效率 高(只编译一次) 略低(每次请求都要包含一次) 适用场景 页头页脚、固定的公共片段 内容会变、需要独立处理的片段 记忆口诀:指令是"抄进来",动作是"调过去"。
(3)taglib 指令:引入标签库
taglib 指令用于引入 JSTL 之类的标签库,语法是:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>prefix 是使用标签时的前缀(写了 c 就能用 <c:if>),uri 是标签库的唯一标识。这个指令 8.8 节会详细讲,此处只要认识它的长相。
8.4.2 脚本片段 <% %>
JSP 脚本有三个分类,第一类是脚本片段:
<%...%>:内容会直接放到_jspService()方法之中。
在 hello.jsp 中书写:
<%
System.out.println("hello,jsp~");
int i = 3;
%>通过浏览器访问 hello.jsp 后,查看转换的 hello_jsp.java 文件,i 变量定义在了 _jspService() 方法中,它就是一段普通的 Java 代码,可以写 if、for、调方法、声明局部变量。
<%%> 里面写的是 Java 代码,而外边写的是 HTML 标签。这两者可以任意地交错、嵌套,这正是 JSP 写循环生成表格行的原理:
<%
for (int i = 0; i < brands.size(); i++) {
Brand brand = brands.get(i);
%>
<tr>
<td><%=brand.getBrandName()%></td>
</tr>
<%
}
%>注意上面这段代码的括号是"跨出去又跨回来"的:for 的左大括号在第一个脚本里,右大括号在第三个脚本里,中间的 HTML 属于循环体。这在 Java 里不可能出现,但是在 JSP 里完全合法,因为翻译后它们本来就拼在同一个方法体里。
8.4.3 表达式 <%= %>
第二类脚本是表达式:
<%=…%>:内容会放到out.print()中,作为out.print()的参数。
在 hello.jsp 中书写:
<%="hello"%>
<%=i%>通过浏览器访问 hello.jsp 后,查看转换的 hello_jsp.java 文件,该脚本的内容被放在了 out.print() 中,作为参数。
三个必须注意的点:
<%= %>里面只能是表达式,不能写分号结尾的语句。<%=brand.getId()%>正确,<%=int a = 1;%>错误(会编译失败),因为翻译出来是out.print(int a = 1;),语法不成立。<%= %>等价于<% out.print(...); %>,所以它输出到浏览器,而不是控制台。- 输出的内容如果来自用户输入,会有 XSS 风险(原样输出
<script>会被浏览器执行),需要转义。转义用 JSTL 的<c:out>更稳妥,见 8.8 节。
8.4.4 声明 <%! %>
第三类脚本是声明:
<%!…%>:内容会放到_jspService()方法之外,被类直接包含。
在 hello.jsp 中书写:
<%!
void show(){}
String name = "zhangsan";
%>通过浏览器访问 hello.jsp 后,查看转换的 hello_jsp.java 文件,该脚本的内容被放在了成员位置:show() 成了这个类的方法,name 成了这个类的成员变量。
【易错点】声明有两处容易出错的地方,后果都很严重。第一处:不能用内置对象。
<%! %>里的代码在方法外面,而request、out、session都是_jspService的局部变量,所以<%! String n = request.getParameter("name"); %>直接编译报错"找不到符号"。第二处:成员变量是线程不安全的。 容器只创建一个 JSP 实例,所有请求的线程共享同一个成员变量:<%! int count = 0; %> <% count++; %> 你是第 <%=count%> 位访问者这段代码看着像计数器,实际上是并发 bug 的教科书案例:两个用户同时访问,可能都读到 99,都加 1 写回 100,最终少算一次。结论:计数、累加这类"跨请求状态",应该放
application域或数据库,绝不能用<%! %>成员变量。
8.4.5 注释
JSP 里有三种"注释",它们的行为完全不同,必须分清:
| 写法 | 名称 | 会被发给浏览器吗 | 会被编译进 .java 吗 |
|---|---|---|---|
<%-- 注释 --%> | JSP 注释 | 不会 | 不会,翻译时整段丢弃 |
<!-- 注释 --> | HTML 注释 | 会,用户在"查看源代码"里能看到 | 会,作为 out.write() 的内容 |
// 注释、/* 注释 */ | Java 注释 | 不会 | 会,留在脚本片段里 |
用法演示:
<%--
c:if:来完成逻辑判断,替换 java if else
--%>
<c:if test="${status ==1}">
启用
</c:if>【易错点】用 HTML 注释
<!-- -->去"注释掉"一段 JSP 代码,是最常见的误用。因为 HTML 注释在翻译期是普通文本,它不会阻止里面的 JSP 代码被执行:<!-- <% System.out.println("我还是执行了"); %> -->控制台照样打印。更糟的是,如果里面是
<%= %>输出,浏览器"查看源代码"里还能看到注释里的内容。要注释 JSP 代码,一律用<%-- --%>。【对比】
<%= %>与${}是本章最该先分清的一对,它们都能把数据显示到页面上,但根本不是一回事:
对比项 <%= 表达式 %>${ 表达式 }出身 JSP 语法,JSP 1.0 就有 EL 表达式,JSP 2.0 引入 能力 能执行任意 Java 代码(调方法、算表达式、new 对象) 只能取值和做简单运算,不能 new、不能写语句 取不到值时 表达式返回 null时输出字符串"null"输出空字符串(什么都不显示) 遇到 null 对象 brand.getBrandName()里 brand 为 null 直接抛NullPointerException${brand.brandName}中 brand 为 null 时返回 null,不抛异常可读性 页面里混 Java,前端看不懂 页面上没有 Java 关键字,前端能看懂 现代用法 已不推荐,只用于极少数场景 推荐,配合 JSTL 使用 结论:新写的页面里,能用
${}就不用<%= %>。 这也是本章后面几节要走的路线。
8.4.6 案例:用脚本展示品牌数据
需求:使用 JSP 脚本展示品牌数据。
说明两点:素材里提供了一个 brand.html 静态页面;在本案例中,数据不从数据库中查询,而是在 JSP 页面上写死。
第一步,准备数据。 把 Brand.java 文件放置到项目的 com.itheima.pojo 包下,在 brand.jsp 中准备一些数据:
<%
// 查询数据库
List<Brand> brands = new ArrayList<Brand>();
brands.add(new Brand(1,"三只松鼠","三只松鼠",100,"三只松鼠,好吃不上火",1));
brands.add(new Brand(2,"优衣库","优衣库",200,"优衣库,服适人生",0));
brands.add(new Brand(3,"小米","小米科技有限公司",1000,"为发烧而生",1));
%>注意:这里的类是需要导包的(Brand、List、ArrayList),用 page 指令的 import 属性导入。
第二步,把静态表格改成动态。 先看静态版本,表格里是三行写死的数据:
<table border="1" cellspacing="0" width="800">
<tr>
<th>序号</th>
<th>品牌名称</th>
<th>企业名称</th>
<th>排序</th>
<th>品牌介绍</th>
<th>状态</th>
<th>操作</th>
</tr>
<tr align="center">
<td>1</td>
<td>三只松鼠</td>
<td>三只松鼠</td>
<td>100</td>
<td>三只松鼠,好吃不上火</td>
<td>启用</td>
<td><a href="#">修改</a> <a href="#">删除</a></td>
</tr>
<!-- 另外两行同理:优衣库、小米 -->
</table>第三步,套上循环。 很自然会写出这样的代码,但是这行代码是错的:
<%
for (int i = 0; i < brands.size(); i++) {
//获取集合中的 每一个 Brand 对象
Brand brand = brands.get(i);
}
%>
<tr align="center">
<td>1</td>
<td>三只松鼠</td>
<!-- ... -->
</tr>问题在于:tr 在 for 循环外面,循环执行了三遍但什么也没输出,页面永远只有一行。上面的 for 循环需要将 tr 标签包裹起来,这样才能实现循环的效果,代码改进为:
<%
for (int i = 0; i < brands.size(); i++) {
//获取集合中的 每一个 Brand 对象
Brand brand = brands.get(i);
%>
<tr align="center">
<td>1</td>
<td>三只松鼠</td>
<td>三只松鼠</td>
<td>100</td>
<td>三只松鼠,好吃不上火</td>
<td>启用</td>
<td><a href="#">修改</a> <a href="#">删除</a></td>
</tr>
<%
}
%>注意:<%%> 里面写的是 Java 代码,而外边写的是 HTML 标签。 现在能循环三次了,但三行数据一模一样,因为 td 里还是写死的。
第四步,把 td 也改成动态的。 使用 <%= %> 输出每个对象的属性:
<table border="1" cellspacing="0" width="800">
<tr>
<th>序号</th>
<th>品牌名称</th>
<th>企业名称</th>
<th>排序</th>
<th>品牌介绍</th>
<th>状态</th>
<th>操作</th>
</tr>
<%
for (int i = 0; i < brands.size(); i++) {
//获取集合中的 每一个 Brand 对象
Brand brand = brands.get(i);
%>
<tr align="center">
<td><%=brand.getId()%></td>
<td><%=brand.getBrandName()%></td>
<td><%=brand.getCompanyName()%></td>
<td><%=brand.getOrdered()%></td>
<td><%=brand.getDescription()%></td>
<td><%=brand.getStatus() == 1 ? "启用":"禁用"%></td>
<td><a href="#">修改</a> <a href="#">删除</a></td>
</tr>
<%
}
%>
</table>成品代码如下(可以直接抄进 webapp/brand.jsp):
<%@ page import="com.itheima.pojo.Brand" %>
<%@ page import="java.util.List" %>
<%@ page import="java.util.ArrayList" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
// 查询数据库
List<Brand> brands = new ArrayList<Brand>();
brands.add(new Brand(1,"三只松鼠","三只松鼠",100,"三只松鼠,好吃不上火",1));
brands.add(new Brand(2,"优衣库","优衣库",200,"优衣库,服适人生",0));
brands.add(new Brand(3,"小米","小米科技有限公司",1000,"为发烧而生",1));
%>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<input type="button" value="新增"><br>
<hr>
<table border="1" cellspacing="0" width="800">
<tr>
<th>序号</th>
<th>品牌名称</th>
<th>企业名称</th>
<th>排序</th>
<th>品牌介绍</th>
<th>状态</th>
<th>操作</th>
</tr>
<%
for (int i = 0; i < brands.size(); i++) {
Brand brand = brands.get(i);
%>
<tr align="center">
<td><%=brand.getId()%></td>
<td><%=brand.getBrandName()%></td>
<td><%=brand.getCompanyName()%></td>
<td><%=brand.getOrdered()%></td>
<td><%=brand.getDescription()%></td>
<td><%=brand.getStatus() == 1 ? "启用":"禁用"%></td>
<td><a href="#">修改</a> <a href="#">删除</a></td>
</tr>
<%
}
%>
</table>
</body>
</html>测试:在浏览器地址栏输入 http://localhost:8080/jsp-demo/brand.jsp,页面上就出现了三行品牌数据,第一行三只松鼠(启用)、第二行优衣库(禁用)、第三行小米(启用),最后一列都是"修改 删除"两个链接。
页面是出来了,但你应该已经感觉到哪里不对劲:这个文件里一半是 HTML、一半是 Java,<% 和 %> 把 tr 的缩进都打乱了,读完 for 那一行还要往下找它的右大括号。这正是 8.6 节要清算的问题。
8.5 九大内置对象与四大作用域
8.5.1 什么叫"内置"
回头看一眼 8.3.4 那段翻译出来的代码,_jspService() 的开头有一串局部变量:
final javax.servlet.jsp.PageContext pageContext;
javax.servlet.http.HttpSession session = null;
final javax.servlet.ServletContext application;
final javax.servlet.ServletConfig config;
javax.servlet.jsp.JspWriter out = null;
final java.lang.Object page = this;这些变量是 Tomcat 在翻译 JSP 时自动加上去的。它们叫内置对象(也叫隐含对象):容器在翻译期替你声明并赋好值,你在 <% %> 和 <%= %> 里可以直接用,不需要 new,也不需要 request.getSession() 去拿。
一共九个,必须能背出来。按用途分成四组更好记:
| 内置对象 | 类型 | 从哪来 | 干什么 |
|---|---|---|---|
request | HttpServletRequest | _jspService 的形参 | 一次请求:取参数、存 request 域、转发 |
response | HttpServletResponse | _jspService 的形参 | 一次响应:设状态码、设响应头、拿输出流 |
session | HttpSession | pageContext.getSession() | 一次会话:存登录用户等跨请求数据 |
application | ServletContext | pageContext.getServletContext() | 整个应用:全局配置、全局计数 |
config | ServletConfig | pageContext.getServletConfig() | 读 <servlet> 的初始化参数(JSP 里极少用) |
out | JspWriter | pageContext.getOut() | 向浏览器输出字符(带缓冲区) |
page | Object | this | 当前 JSP 实例本身,等价于 Servlet 里的 this |
pageContext | PageContext | 容器创建 | 页面上下文,能拿到其余八个,也能存取四个域 |
exception | Throwable | pageContext.getException() | 异常对象,只有 isErrorPage="true" 的页面才有 |
记忆口诀:"一请求一响应,一会话一应用,一输出一配置,一页面一上下文一异常"。
【考点】"九大内置对象"是必背题,答题时按"对象名 + 类型 + 作用"三件套写。容易漏的是
config、page、exception这三个不常用的;最容易答错的是exception:它不是每个页面都有,只有 page 指令写了isErrorPage="true"的页面才会有,普通页面里用它会编译报错"找不到符号"。【易错点】 内置对象只在
<% %>、<%= %>这种脚本里能用,在<%! %>声明里不能用(原因见 8.4.4)。另外out是JspWriter而不是PrintWriter,两者不是同一个类型,不能互相赋值。
8.5.2 四大作用域
九个对象里有四个是"域对象",它们的共同点是都提供了 setAttribute(String name, Object value)、getAttribute(String name)、removeAttribute(String name) 三个方法,区别只在活得多久、谁能看见:
| 作用域 | 对应的域对象 | 有效范围 | 生命周期 | 典型用途 |
|---|---|---|---|---|
| page | pageContext | 当前 JSP 页面 | 当前请求处理完即失效 | 页面内的临时变量 |
| request | request | 当前一次请求(含转发) | 请求结束即失效 | Servlet 查出的数据交给 JSP 展示 |
| session | session | 当前一次会话(一个浏览器) | 会话超时或销毁 | 登录用户信息、购物车 |
| application | application | 整个应用(所有用户共享) | 服务器关闭或应用重载 | 全局配置、在线人数 |
四句话记法:page 是"这一页",request 是"这一次",session 是"这个人",application 是"所有人"。
范围是从小到大的:page ⊂ request ⊂ session ⊂ application。
8.5.3 查找顺序:同名了怎么办
四个域可以存同名的 key,那么取值时取到谁?规则是从小到大、找到即止:
page → request → session → application
(找到就返回,不再往下找)这一点在 EL 里同样成立(8.7.3 节会正式讲)。看一个能跑的验证:
<%
pageContext.setAttribute("name", "我是page");
request.setAttribute("name", "我是request");
session.setAttribute("name", "我是session");
application.setAttribute("name", "我是application");
%>
${name}页面上输出的是 我是page。把第一行删掉,输出变成 我是request;再把第二行删掉,变成 我是session,依次退让。
【易错点】查找顺序带来的经典 bug:A 页面往 request 里放了
brands,B 页面(另一个功能)往 session 里放了一份同名但内容不同的brands,于是 A 页面刷新几次之后突然显示成了 B 的数据。因为 A 的 request 域数据在转发结束后就没了,而 session 里的还在,${brands}就"顺手"取到了 session 里那份。排查办法:不要靠猜,用${requestScope.brands}精确指定域(8.7.5 节),或者干脆换一个不重名的 key。
8.5.4 out 与 response.getWriter() 的冲突
JSP 里有两个"能往浏览器写字"的东西:内置对象 out(JspWriter,带缓冲区),和 response.getWriter()(PrintWriter,直接写)。
【易错点】在同一个 JSP 里同时用
out.print()和response.getWriter().print(),后写的那一段可能完全不出现,也可能顺序颠倒。原因是JspWriter有自己的缓冲区(默认 8kb,对应 page 指令的buffer),内容先攒在缓冲区里,页面结束时才真正刷到response的输出流;而response.getWriter()是绕过缓冲区直接写的。两者混用,输出顺序就乱了,甚至抛IllegalStateException。规则:一个响应只用一个输出流。 在 JSP 里,永远用
out或<%= %>,不要碰response.getWriter()。
8.6 JSP 的缺点与视图技术的演进
8.4.6 的 brand.jsp 已经能运行了,但是它同时暴露了 JSP 的全部问题。这一节把这些问题和它后面的技术走向说清楚:理解了这一节,你才知道为什么还要学 EL 和 JSTL,也才知道为什么今天的新项目不用 JSP。
8.6.1 JSP 的缺点
由于 JSP 页面内既可以定义 HTML 标签,又可以定义 Java 代码,造成了以下问题:
- 书写麻烦:特别是复杂的页面,既要写 HTML 标签,还要写 Java 代码。
- 阅读麻烦:上面案例的代码,相信你后期再看这段代码时还需要花费很长的时间去梳理。
- 复杂度高:运行需要依赖于各种环境,JRE、JSP 容器、JavaEE……
- 占内存和磁盘:JSP 会自动生成
.java和.class文件占磁盘,运行的是.class文件占内存。 - 调试困难:出错后,需要找到自动生成的
.java文件进行调试。 - 不利于团队协作:前端人员不会 Java,后端人员不精 HTML。如果页面布局发生变化,前端工程师对静态页面进行修改,然后再交给后端工程师,由后端工程师再将该页面改为 JSP 页面。
把这几条归纳成一条:JSP 的问题出在非把 Java 和 HTML 写进同一个文件,并不在于"能写 Java"。 两者耦合在一起,任何一方改动都会牵连另一方。
8.6.2 视图技术的四个阶段
由于上述的问题,JSP 已逐渐退出历史舞台,以后开发更多的是使用 HTML + Ajax 来替代。下来对技术的发展进行简单的说明:
- 第一阶段:使用 Servlet 既实现逻辑代码编写,也对页面进行拼接。这种模式我们之前也接触过(第 6 章那些
writer.write("<html>"))。 - 第二阶段:随着技术的发展,出现了 JSP,人们发现 JSP 使用起来比 Servlet 方便很多,但是还是要在 JSP 中嵌套 Java 代码,也不利于后期的维护。
- 第三阶段:使用 Servlet 进行逻辑代码开发,而使用 JSP 进行数据展示。
- 第四阶段:使用 Servlet 进行后端逻辑代码开发,而使用 HTML 进行数据展示。而这里面就存在问题,HTML 是静态页面,怎么进行动态数据展示呢?这就是 Ajax 的作用了。
本章走的正是第三阶段:接下来用 EL 表达式和 JSTL 标签库替换掉 JSP 中的 Java 代码。注意,这里替换的是页面里的 Java,"不用 JSP"这种理解是错的。Servlet 做逻辑、JSP 做展示,页面里一个 <% %> 都不留。
8.6.3 那为什么还要学 JSP
既然 JSP 已经逐渐退出历史舞台,为什么还要学?原因有两点:
- 一些公司可能有些老项目还在用 JSP,所以要求你必须会 JSP;
- 我们如果不经历这些复杂的过程,就不能体现后面阶段开发的简单。
第二点尤其重要。你只有亲手写过 writer.write("<td>" + brand.getBrandName() + "</td>"),再亲手写过 <c:forEach items="${brands}">,才能体会后者好在哪。技术演进的价值,只有对比过才看得见。
8.6.4 今天的项目用什么
【补充】第四阶段之后还有第五阶段,也就是今天的主流做法。把三条技术路线对照着看,你就明白 JSP 到底被谁取代了:
方案 谁负责渲染 页面长什么样 代表技术 现状 Servlet 拼字符串 服务端 Java writer.write("<td>...")原生 Servlet 早已淘汰 JSP + EL + JSTL 服务端 JSP 引擎 HTML 里嵌 ${}与<c:...>JSP 2.3 老项目维护,新项目不用 模板引擎 服务端模板引擎 HTML 里嵌模板语法 Thymeleaf、FreeMarker、Velocity Spring Boot 官方推荐 Thymeleaf 前后端分离 浏览器里的 JS 纯 HTML + JS,数据靠 Ajax 拿 Vue、React + RESTful 接口 当前主流 为什么模板引擎能取代 JSP?因为 Thymeleaf 的页面可以直接用浏览器打开(它是合法的 HTML,模板属性写在
th:text这种自定义属性里,浏览器会忽略),前端改样式不需要后端重启、也不需要 JSP 容器。而 JSP 的<% %>浏览器根本认不出来,必须部署到 Tomcat 才能看到效果。为什么前后端分离又能取代模板引擎?因为渲染从服务器搬到了浏览器:后端只返回 JSON(
{"id":1,"brandName":"三只松鼠"}),Vue 拿到 JSON 后自己拼 DOM。这样后端不用管页面长什么样,前端不用等后端发版,两边可以并行开发,这才是 8.6.1 里"不利于团队协作"那条缺点的最终解法。顺带回答一个常见疑问:"JSP 是不是被淘汰了、学了没用?" 不是。JSP 的四个域对象、
pageContext、EL 的取值规则,在 Thymeleaf 里概念是一模一样的(Thymeleaf 也能取request、session里的属性);而"Servlet 做逻辑、模板做展示"的分工思想,正是 8.9 节 MVC 的内核。语法会过时,分层不会。
8.7 EL 表达式
8.7.1 概述
EL(全称 Expression Language)表达式语言,用于简化 JSP 页面内的 Java 代码。
EL 表达式的主要作用是获取数据:从域对象中获取数据,然后将数据展示在页面上。
而 EL 表达式的语法也比较简单,${expression}。例如:${brands} 就是获取域中存储的 key 为 brands 的数据。
对照一下就看出它省了什么。同样是输出品牌名:
<%-- JSP 脚本写法 --%>
<td><%=brand.getBrandName()%></td>
<%-- EL 写法 --%>
<td>${brand.brandName}</td>省掉了 <%= %>、省掉了 get、省掉了 (),页面看起来就是纯粹的 HTML 加一个占位符。而且前端工程师能看懂 ${brand.brandName},看不懂 <%=brand.getBrandName()%>,这就是"把 Java 从页面里赶出去"的第一层含义:语法更短只是表面收益,页面上不再有 Java 的痕迹才是重点。
8.7.2 代码演示
先定义一个 Servlet,在 Servlet 中封装一些数据并存储到 request 域对象中,并转发到 el-demo.jsp 页面:
@WebServlet("/demo1")
public class ServletDemo1 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//1. 准备数据
List<Brand> brands = new ArrayList<Brand>();
brands.add(new Brand(1,"三只松鼠","三只松鼠",100,"三只松鼠,好吃不上火",1));
brands.add(new Brand(2,"优衣库","优衣库",200,"优衣库,服适人生",0));
brands.add(new Brand(3,"小米","小米科技有限公司",1000,"为发烧而生",1));
//2. 存储到request域中
request.setAttribute("brands",brands);
//3. 转发到 el-demo.jsp
request.getRequestDispatcher("/el-demo.jsp").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}注意:此处需要用转发,因为转发才可以使用 request 对象作为域对象进行数据共享。
在 el-demo.jsp 中通过 EL 表达式获取数据:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
${brands}
</body>
</html>在浏览器的地址栏输入 http://localhost:8080/jsp-demo/demo1,页面上显示的是一串 [Brand{id=1, brandName='三只松鼠', ...}, Brand{...}, Brand{...}],它直接把 List 的 toString() 结果打印出来了。
这个结果说明两件事:第一,EL 确实取到了 request 域里的对象;第二,EL 只负责"取",不负责"排版"。要在页面上排成表格,得靠 8.8 节的 <c:forEach>。
8.7.3 四大域与查找顺序
JavaWeb 中有四大域对象,分别是:
- page:当前页面有效;
- request:当前请求有效;
- session:当前会话有效;
- application:当前应用有效。
EL 表达式获取数据,会依次从这 4 个域中寻找,直到找到为止。例如 ${brands},EL 表达式获取数据,会先从 page 域对象中获取数据,如果没有再到 request 域对象中获取数据,如果再没有再到 session 域对象中获取,如果还没有才会到 application 中获取数据。
顺序:page → request → session → application,找到即停。
【考点】这个查找顺序是 EL 部分最高频的考点,几乎每年都考。答题时一定要写出"依次从 page、request、session、application 中找,找到就停止"这几个关键词。常见的错误答案是"从 request 开始找":EL 是从 page 开始的,因为
pageContext也是域对象。
8.7.4 取值规则
EL 能取的东西,本质只有三类:属性、下标、方法结果。逐个看。
(1)取 JavaBean 的属性
${brand.brandName} 的底层会调用 getBrandName() 方法,并不会直接读取字段。规则是:${对象.属性名} 会去找 get属性名()(首字母大写),找不到就找 is属性名()(针对 boolean)。所以:
${brand.id}→brand.getId()${brand.brandName}→brand.getBrandName()${brand.class}→brand.getClass()(class是Object自带的方法)
(2)取数组、List 的元素
用中括号加下标,下标从 0 开始:
${brands[0].brandName} <%-- 第一个品牌的名称 --%>
${arr[2]} <%-- 数组第三个元素 --%>
${brands[10]} <%-- 越界时返回 null,不抛异常 --%>(3)取 Map 的值
两种写法等价:
${map.brandName} <%-- 点号写法,key 是合法标识符时可用 --%>
${map['brandName']} <%-- 中括号写法,key 含特殊字符时必须用这种 --%>
${map['user-name']} <%-- key 里有减号,只能用中括号 --%>(4)嵌套取值
可以一路点下去,中间任何一层是 null,最终结果就是 null(不抛异常):
${brand.owner.address.city}(5)直接取集合的大小
${brands.size()} <%-- 调用 List 的 size() 方法 --%>
${fn:length(brands)} <%-- 或使用 JSTL 函数,见 8.8.6 --%>【补充】EL 2.2(Tomcat 7 起)之后,
${}里还可以直接调用方法并传参,例如${brand.getBrandName().toUpperCase()}、${brands.get(0).id}。但这是 EL 里唯一"越界"的地方:它让页面重新能写 Java 逻辑,与"把 Java 赶出页面"的初衷相悖。课程与考试只要求掌握属性取值,方法调用知道有这回事即可。
8.7.5 十一个隐式对象
EL 也有自己的"内置对象",一共 11 个。注意:它们与 JSP 的九个内置对象无关,是 EL 引擎在求值时提供的隐式对象,只能写在 ${} 里面。
前五个与"域"有关,后六个与"请求数据"有关:
| 隐式对象 | 类型 | 作用 |
|---|---|---|
pageContext | PageContext | 页面上下文,唯一一个不是 Map 的,能拿到 request、session、servletContext 等 |
pageScope | Map<String,Object> | 只从 page 域取值 |
requestScope | Map<String,Object> | 只从 request 域取值 |
sessionScope | Map<String,Object> | 只从 session 域取值 |
applicationScope | Map<String,Object> | 只从 application 域取值 |
param | Map<String,String> | 请求参数,等价于 request.getParameter() |
paramValues | Map<String,String[]> | 请求参数数组,等价于 request.getParameterValues() |
header | Map<String,String> | 请求头,等价于 request.getHeader() |
headerValues | Map<String,String[]> | 请求头数组 |
cookie | Map<String,Cookie> | 所有 Cookie,key 是 cookie 名 |
initParam | Map<String,String> | web.xml 里 <context-param> 的全局参数 |
用法举例:
<%-- 精确指定从 request 域取,避免与 session 里的同名数据混淆 --%>
${requestScope.brands}
<%-- 直接取请求参数:访问 /test?username=zhangsan 时输出 zhangsan --%>
${param.username}
<%-- 取请求参数数组的第一个值:/test?hobby=篮球&hobby=足球 --%>
${paramValues.hobby[0]}
<%-- 取请求头(名字里有减号,必须用中括号) --%>
${header['User-Agent']}
<%-- 取 Cookie 的值:cookie.名字.value --%>
${cookie.JSESSIONID.value}
<%-- 取 web.xml 中的全局参数 --%>
${initParam.pageSize}【易错点】
${cookie.JSESSIONID}输出的是javax.servlet.http.Cookie@1a2b3c这样的对象地址,不是会话 ID。因为cookie这个 Map 的 value 是Cookie对象,要拿值必须再加一层.value。这是初学 EL 时最常见的"输出乱码"现象之一。【考点】 EL 的 11 个隐式对象与 JSP 的 9 个内置对象经常被混着考。区分要点:JSP 的九个内置对象是 Java 对象,写在
<% %>里用;EL 的十一个隐式对象是"取数的名字",写在${}里用。 两者的交集是pageContext:它既是 JSP 内置对象,也是 EL 隐式对象,这也是为什么${pageContext.request.contextPath}能成立。
8.7.6 运算符
EL 支持完整的运算,写法与 Java 基本一致,只是多了几个"字母版"的替代写法:
| 类别 | 运算符 | 字母写法 | 说明 |
|---|---|---|---|
| 算术 | + - * / % | div mod | / 的结果是浮点数,${5/2} 输出 2.5 |
| 比较 | == != < > <= >= | eq ne lt gt le ge | 两种写法完全等价 |
| 逻辑 | && || ! | and or not | |
| 空判断 | empty | — | 判断 null、空串、空集合、空数组 |
| 三元 | 条件 ? 值1 : 值2 | — | |
| 括号 | ( ) | — | 改变优先级 |
为什么要提供字母版? 因为 < 和 > 在 HTML/XML 里有特殊含义。当你把比较运算写在标签属性里时,尖括号会跟标签的尖括号打架:
<%-- 危险:< 可能被当成标签开始 --%>
<c:if test="${brand.ordered < 100}">
<%-- 安全:用 lt --%>
<c:if test="${brand.ordered lt 100}">实际上现代的 JSP 解析器大多能正确处理属性里的 <,但养成用 lt/gt 的习惯不吃亏,尤其在老版本容器里能省掉一堆莫名其妙的解析错误。
empty 是 EL 最实用的一个运算符,它一次性覆盖了四种"空":
${empty brands} <%-- brands 为 null → true --%>
${empty brands} <%-- brands 是空集合 size=0 → true --%>
${empty ""} <%-- 空字符串 → true --%>
${empty arr} <%-- 空数组 → true --%>
${not empty brands} <%-- 非空 → true --%>列表页里最常用的一行就是 <c:if test="${empty brands}">暂无数据</c:if>。
三元运算符在页面里极其常见,用来把 0/1 翻译成中文:
${brand.status == 1 ? "启用" : "禁用"}【易错点】
${5 / 2}输出2.5而不是2。EL 的/是浮点除法,两个整数相除也会得到小数。想要整除的结果,要么用${5 div 2}(同样是 2.5,div只是别名),要么在 Java 层就处理好。另外${1 + "2"}输出3:EL 会做类型转换,字符串"2"被转成数字。这类隐式转换在比较时更容易出问题:${"10" > 9}是true(转成数字比),但是${"10" > "9"}是false(都是字符串,按字典序比)。
8.7.7 取不到值的时候:null 与空串
这是 EL 与 <%= %> 行为差异最大的地方,也是考试中容易出错的地方。
规则:EL 求值的结果如果是 null,输出为空字符串,什么都不显示。 它不会输出 "null" 四个字母,也不会抛异常。
对比一下同一个场景:
<%-- request 域里没有 key 为 username 的数据 --%>
<%=request.getAttribute("username")%> <%-- 输出:null --%>
${username} <%-- 输出:(空,什么都没有) --%>三条细则要背下来:
- 域里没有这个 key →
${username}返回null→ 输出空串。 - 对象是 null,去取它的属性 →
${brand.brandName}中brand为 null → 返回null,不抛NullPointerException(这是 EL 与 Java 最大的不同,也是它比<%= %>安全的地方)。 - 对象存在,但属性名写错了 →
${brand.brandNam}(少了个 e)→ 抛异常javax.el.PropertyNotFoundException: Property [brandNam] not found on type [com.itheima.pojo.Brand],页面 500。
第 3 条特别值得强调:"EL 不报错"只对 null 成立,对"名字写错"不成立。 很多人以为 EL 永远不会报错,于是拼错属性名之后对着一个 500 页面发呆。看到 PropertyNotFoundException 就直接去核对 JavaBean 的 getter 名字。
【补充】
${pageContext.request.contextPath}是 JSP 里出现频率最高的一句 EL,值得单独讲透。它做的事是:
pageContext→ 拿request对象 → 调getContextPath()→ 得到当前 Web 应用部署的上下文路径(例如/brand-demo;如果部署在根路径下,则是空字符串"")。为什么需要它?看本章案例里这种写法:
<a href="/brand-demo/selectByIdServlet?id=${brand.id}">修改</a>路径里写死了
/brand-demo。一旦项目改名、或者部署到别的路径(/shop、/),所有链接全部 404,而且你得到处去改。改成:<a href="${pageContext.request.contextPath}/selectByIdServlet?id=${brand.id}">修改</a>部署到
/brand-demo时它生成/brand-demo/selectByIdServlet?id=1,部署到/时它生成/selectByIdServlet?id=1,一处都不用改。为什么不能直接写
/selectByIdServlet?因为 HTML 里以/开头的路径是"站点根路径"(http://localhost:8080/),而不是"应用根路径"(http://localhost:8080/brand-demo/)。少了上下文这一段,请求就打到了 Tomcat 的根应用上,404。一个配套的小技巧:在页面顶部用
<c:set>把它存成一个短变量,后面就能简写:<c:set var="ctx" value="${pageContext.request.contextPath}"/> <a href="${ctx}/selectByIdServlet?id=${brand.id}">修改</a>【考点】 判断题常考:"JSP 中
${brand.brandName}在 brand 为 null 时会不会抛空指针?"答案:不会,EL 返回 null 并输出空串;但如果是brand存在而属性名不存在,会抛PropertyNotFoundException。
8.8 JSTL 标签
EL 解决了"取值",但解决不了"循环"和"判断"。<c:forEach> 就是来补这个缺口的。
8.8.1 概述
JSP 标准标签库(Jsp Standarded Tag Library,简称 JSTL),使用标签取代 JSP 页面上的 Java 代码。如下代码就是 JSTL 标签:
<c:if test="${flag == 1}">
男
</c:if>
<c:if test="${flag == 2}">
女
</c:if>上面代码看起来是不是比 JSP 中嵌套 Java 代码舒服多了?而且前端工程师对标签是特别敏感的,他们看到这段代码是能看懂的。
JSTL 提供了很多标签,按功能分成五类:核心标签(core)、格式化标签(fmt)、SQL 标签、XML 标签、函数标签(functions)。我们只对两个最常用的标签进行讲解:<c:forEach> 标签和 <c:if> 标签。
JSTL 使用也是比较简单的,分为如下三步:
第一步,导入坐标:
<dependency>
<groupId>jstl</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
</dependency>注意这两个依赖都没有 provided,因为 Tomcat 不自带 JSTL,必须打进 war 包。
第二步,在 JSP 页面上引入 JSTL 标签库:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>第三步,使用标签。 下面两节分别讲 <c:if> 和 <c:forEach>。
【易错点】忘记写
taglib指令,是 JSTL 最高频的错误,症状还很迷惑人:
- 完全没引 taglib:JSP 不认识
<c:if>,会把它当成一个未知的 HTML 标签原样输出。页面上会直接显示出<c:if test="${flag == 1}"> 男 </c:if>这行字,不报错,但也没生效。很多人以为是"EL 没取到值",原因在于标签根本没被解析。- uri 写错(比如写成
http://java.sun.com/jstl/core,少了个/jsp):JSP 翻译期直接报错,信息大意是无法在 web.xml 或使用此应用程序部署的 JAR 中解析绝对 URI: [http://java.sun.com/jstl/core],页面 500。prefix写错:prefix="c"但代码里写<c:if>却用了<%@ taglib prefix="jstl" ... %>,症状同第一条,标签原样输出。记住这个 uri 的准确写法:
http://java.sun.com/jsp/jstl/core。它是个"假 URL",浏览器打不开,只是 JSTL 规范约定的唯一标识符。
8.8.2 if 标签
<c:if>:相当于 if 判断。
- 属性:
test,用于定义条件表达式。
<c:if test="${flag == 1}">
男
</c:if>
<c:if test="${flag == 2}">
女
</c:if>代码演示。 定义一个 Servlet,在该 Servlet 中向 request 域对象中添加键是 status、值为 1 的数据:
@WebServlet("/demo2")
public class ServletDemo2 extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//1. 存储数据到request域中
request.setAttribute("status",1);
//2. 转发到 jstl-if.jsp
request.getRequestDispatcher("/jstl-if.jsp").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}定义 jstl-if.jsp 页面,在该页面使用 <c:if> 标签:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<%--
c:if:来完成逻辑判断,替换 java if else
--%>
<c:if test="${status ==1}">
启用
</c:if>
<c:if test="${status ==0}">
禁用
</c:if>
</body>
</html>注意:在该页面一定要引入 JSTL 核心标签库:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>浏览器访问 /demo2,页面上显示"启用",因为 status 是 1,第一个 <c:if> 成立,第二个不成立。
【易错点】
<c:if>没有else。这是从 Java 转过来最容易撞的墙:很多人写<c:if test="...">启用</c:if><c:else>禁用</c:else>,而c:else这个标签根本不存在,JSP 会把它当未知标签原样输出。原文示例之所以写两个<c:if>(一个判==1、一个判!=1),正是因为这个原因。要写多分支,用下一节的<c:choose>。【补充】
<c:if>除了test,还有两个可选属性:var(把判断结果存进一个变量)和scope(存到哪个域,默认 page)。例如:<c:if test="${brand.status == 1}" var="enabled" scope="request"/> 该品牌是否启用:${enabled}这种写法在"同一个判断结果要在页面多处复用"时很有用,可以避免把同一个表达式写三遍。
8.8.3 choose / when / otherwise
这是原文没有展开、但实际开发一定会用的一组标签。它才是 JSTL 里的 if-else if-else。
<c:choose>
<c:when test="${brand.status == 1}">
<td>启用</td>
</c:when>
<c:when test="${brand.status == 0}">
<td>禁用</td>
</c:when>
<c:otherwise>
<td>未知</td>
</c:otherwise>
</c:choose>三条规则:
<c:choose>里只能放<c:when>和<c:otherwise>,不能直接放别的标签或文本;<c:when>至少有一个,test属性必填,按顺序判断,第一个成立的执行,后面的全部跳过;<c:otherwise>最多一个,必须放在最后,相当于else。
【对比】
<c:if>与<c:choose>怎么选?只有一个分支、或者两三个互不排斥的独立判断,用<c:if>;互斥的多分支(只会命中一个),用<c:choose>。 用三个<c:if>去表达互斥的三分支,不仅啰嗦,而且逻辑上允许"同时成立",是隐藏的 bug 源。
8.8.4 forEach 标签(用法一:遍历集合)
<c:forEach>:相当于 for 循环。Java 中有增强 for 循环和普通 for 循环,JSTL 中的 <c:forEach> 也有两种用法。
用法一类似于 Java 中的增强 for 循环。涉及到的 <c:forEach> 中的属性如下:
items:被遍历的容器;var:遍历产生的临时变量;varStatus:遍历状态对象。
如下代码,是从域对象中获取名为 brands 的数据(该数据是一个集合),遍历它,并给该集合中的每一个元素起名为 brand(是 Brand 对象)。在循环里面使用 EL 表达式获取每一个 Brand 对象的属性值:
<c:forEach items="${brands}" var="brand">
<tr align="center">
<td>${brand.id}</td>
<td>${brand.brandName}</td>
<td>${brand.companyName}</td>
<td>${brand.description}</td>
</tr>
</c:forEach>代码演示:Servlet 还是使用之前的名为 ServletDemo1。定义名为 jstl-foreach.jsp 页面,内容如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<input type="button" value="新增"><br>
<hr>
<table border="1" cellspacing="0" width="800">
<tr>
<th>序号</th>
<th>品牌名称</th>
<th>企业名称</th>
<th>排序</th>
<th>品牌介绍</th>
<th>状态</th>
<th>操作</th>
</tr>
<c:forEach items="${brands}" var="brand" varStatus="status">
<tr align="center">
<%--<td>${brand.id}</td>--%>
<td>${status.count}</td>
<td>${brand.brandName}</td>
<td>${brand.companyName}</td>
<td>${brand.ordered}</td>
<td>${brand.description}</td>
<c:if test="${brand.status == 1}">
<td>启用</td>
</c:if>
<c:if test="${brand.status != 1}">
<td>禁用</td>
</c:if>
<td><a href="#">修改</a> <a href="#">删除</a></td>
</tr>
</c:forEach>
</table>
</body>
</html>注意这里的写法:${status.count} 输出的是 1、2、3,而 ${brand.id} 被注释掉了。因为主键是数据库里的 id,可能会跳号(删掉数据后不连续),而"序号"应该永远是 1、2、3。这是业务上常见的一个小细节。
varStatus 提供五个属性,都是只读的:
| 属性 | 含义 | 示例(第一次循环 / 最后一次) |
|---|---|---|
index | 当前索引,从 0 开始 | 0 / 2 |
count | 当前是第几个,从 1 开始 | 1 / 3 |
first | 是否是第一个(boolean) | true / false |
last | 是否是最后一个(boolean) | false / true |
current | 当前这个元素对象本身 | 第 1 个 Brand 对象 |
典型用法:
<%-- 隔行变色:偶数行加灰色背景 --%>
<c:forEach items="${brands}" var="brand" varStatus="s">
<tr <c:if test="${s.index % 2 == 0}">bgcolor="#f2f2f2"</c:if>>
<td>${s.count}</td>
<td>${brand.brandName}</td>
</tr>
</c:forEach>【易错点】
varStatus的值是字符串变量名(varStatus="status"),不是${status},也不加${}。写成varStatus="${status}"会直接报错。同理,var="brand"也是变量名,循环体里才用${brand.xxx}去取。
8.8.5 forEach 标签(用法二:数字循环)
用法二类似于 Java 中的普通 for 循环。涉及到的 <c:forEach> 中的属性如下:
begin:开始数;end:结束数;step:步长。
实例代码:从 0 循环到 10,变量名是 i,每次自增 1:
<c:forEach begin="0" end="10" step="1" var="i">
${i}
</c:forEach>三个细节值得注意:
begin和end都是闭区间,begin="0" end="10"会输出 11 个数字(0 到 10);step可以省略,默认是 1;- 用法二不能和
items同时使用。items是"遍历一个集合",begin/end/step是"数数",两种语义互斥,同时写会抛异常。
8.8.6 常用函数库 fn
JSTL 还提供了一个函数库(functions),用于在 EL 里做字符串处理,比如判断品牌名里有没有"小米"、截断过长的描述、统计集合大小。
引入方式(注意 uri 和 prefix 都变了):
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>常用函数如下(只能写在 ${} 里面,不是标签):
| 函数 | 作用 | 示例 | 结果 |
|---|---|---|---|
fn:length(x) | 集合/数组/字符串的长度 | ${fn:length(brands)} | 3 |
fn:contains(s, sub) | 是否包含子串 | ${fn:contains(brand.description, '好吃')} | true |
fn:startsWith(s, p) | 是否以 p 开头 | ${fn:startsWith(brand.brandName, '小米')} | true |
fn:endsWith(s, p) | 是否以 p 结尾 | ${fn:endsWith(brand.companyName, '公司')} | true |
fn:indexOf(s, sub) | 子串首次出现的下标 | ${fn:indexOf('abcabc', 'b')} | 1 |
fn:substring(s, b, e) | 截取 [b, e) | ${fn:substring('abcdef', 1, 3)} | bc |
fn:replace(s, a, b) | 把 a 替换成 b | ${fn:replace('a-b', '-', '/')} | a/b |
fn:split(s, sep) | 按分隔符切成数组 | ${fn:split('a,b,c', ',')[0]} | a |
fn:toUpperCase(s) | 转大写 | ${fn:toUpperCase('abc')} | ABC |
fn:toLowerCase(s) | 转小写 | ${fn:toLowerCase('ABC')} | abc |
fn:trim(s) | 去掉首尾空格 | ${fn:trim(' a ')} | a |
fn:escapeXml(s) | 转义 HTML 特殊字符 | ${fn:escapeXml('<b>')} | <b> |
两个最实用的场景:
<%-- 描述太长时截断,避免撑破表格 --%>
<td>${fn:length(brand.description) > 20 ? fn:substring(brand.description, 0, 20) : brand.description}</td>
<%-- 转义用户输入,防止 XSS:用户输入 <script> 时原样显示成文字 --%>
<td>${fn:escapeXml(brand.description)}</td>【易错点】
fn:前缀的函数不能当标签用。写成<fn:length value="${brands}"/>是错的,因为fn是函数库不是标签库,它没有标签,只能写在${}内部。另外fn:length对null返回 0,不会抛异常,所以${fn:length(brands)}在brands为 null 时安全地输出 0。【考点】 JSTL 的五个标签库 uri 只需要记住两个:核心库
http://java.sun.com/jsp/jstl/core(前缀习惯用c)、函数库http://java.sun.com/jsp/jstl/functions(前缀习惯用fn)。考试如果问"怎么引入 JSTL",答案必须是"先加 jstl 与 standard 两个 Maven 坐标(或把 jar 放进WEB-INF/lib),再在页面顶部写<%@ taglib %>指令",两步缺一不可。
8.9 MVC 模式和三层架构
MVC 模式和三层架构是一些理论的知识,将来我们使用它们进行代码开发会让代码的维护性和扩展性更好。这两块内容看着像"背概念",却是 8.10 节案例的主干:不先把职责分清楚,那个案例就会写得很乱。
8.9.1 MVC 模式
MVC 是一种分层开发的模式,其中:
- M:Model,业务模型,处理业务;
- V:View,视图,界面展示;
- C:Controller,控制器,处理请求,调用模型和视图。
控制器(Servlet)用来接收浏览器发送过来的请求,控制器调用模型(JavaBean)来获取数据,比如从数据库查询数据;控制器获取到数据后再交由视图(JSP)进行数据展示。
MVC 的好处:
- 职责单一,互不影响。 每个角色做它自己的事,各司其职。
- 有利于分工协作。 写 Controller 的和写 View 的可以是两个人。
- 有利于组件重用。 同一个 Model 可以被多个 View 复用。
把 MVC 和本章的知识对上号:
| 角色 | 本章里是谁 | 干什么 |
|---|---|---|
| Controller | SelectAllServlet、AddServlet 等 Servlet | 收请求、调 Service、存域、转发 |
| Model | Brand、BrandService、BrandMapper | 数据与业务 |
| View | brand.jsp、addBrand.jsp | 用 EL/JSTL 渲染 |
8.9.2 三层架构
三层架构是将我们的项目分成了三个层面,分别是表现层、业务逻辑层、数据访问层。
- 数据访问层:对数据库的 CRUD 基本操作;
- 业务逻辑层:对业务逻辑进行封装,组合数据访问层中基本功能,形成复杂的业务逻辑功能。例如注册业务功能,我们会先调用数据访问层的
selectByName()方法判断该用户名是否存在,如果不存在再调用数据访问层的insert()方法进行数据的添加操作; - 表现层:接收请求,封装数据,调用业务逻辑层,响应数据。
而整个流程是:浏览器发送请求,表现层的 Servlet 接收请求并调用业务逻辑层的方法进行业务逻辑处理,而业务逻辑层方法调用数据访问层方法进行数据的操作,依次返回到 Servlet,然后 Servlet 将数据交由 JSP 进行展示。
三层架构的每一层都有特有的包名称:
| 层 | 包名 |
|---|---|
| 表现层 | com.itheima.controller 或者 com.itheima.web |
| 业务逻辑层 | com.itheima.service |
| 数据访问层 | com.itheima.dao 或者 com.itheima.mapper |
后期我们还会学习一些框架,不同的框架是对不同层进行封装的。
【补充】为什么要分三层,而不是让 Servlet 直接写 SQL?看一个具体的好处:
BrandService.selectAll()这个方法,将来如果被"查询所有品牌"和"导出品牌 Excel"两个功能同时调用,业务逻辑只写一遍。如果 SQL 直接写在 Servlet 里,两个 Servlet 就得各写一遍,改一次规则要改两处,这就是"重复"的代价。分层的本质是把"会变的东西"隔离在各自的层里:换数据库只动 DAO 层,换页面只动 View,换业务规则只动 Service。
8.9.3 MVC 与三层架构的关系
通过 MVC 和三层架构的学习,有些人肯定混淆了。那它们有什么区别和联系?
MVC 模式中的 C(控制器)和 V(视图)就是三层架构中的表现层,而 MVC 模式中的 M(模型)就是三层架构中的业务逻辑层和数据访问层。
可以将 MVC 模式理解成是一个大的概念,而三层架构是对 MVC 模式实现架构的思想。那么我们以后按照要求将不同层的代码写在不同的包下,每一层里功能职责做到单一,将来如果将表现层的技术换掉,而业务逻辑层和数据访问层的代码不需要发生变化。
【对比】这是本章最容易混淆的一组概念,下面用一张表把差异固定下来:
对比项 MVC 模式 三层架构 是什么 一种设计模式,描述"请求怎么被处理、数据怎么被展示" 一种架构分层,描述"代码怎么分包组织" 三个部分 Model、View、Controller 表现层、业务逻辑层、数据访问层 划分依据 按职责角色划分 按代码层次划分 对应关系 C + V = 表现层;M = 业务逻辑层 + 数据访问层 同上,反过来看 关注点 请求的流向 代码的归属 谁包含谁 MVC 是大概念 三层架构是 MVC 的实现方式 记忆要点:MVC 回答"谁处理请求、谁展示数据",三层架构回答"这段代码应该放在哪个包"。 两者回答的是同一件事的两个视角,并不构成竞争关系。
【考点】 "MVC 和三层架构有什么区别和联系"是简答题常客。标准答案三段:① MVC 是设计模式,分 Model/View/Controller;三层架构是架构分层,分表现层/业务逻辑层/数据访问层;② MVC 的 C 和 V 对应三层架构的表现层,M 对应业务逻辑层和数据访问层;③ MVC 是大概念,三层架构是它的实现思想,两者结合能让职责单一、便于分工与替换技术。
8.10 综合案例:品牌数据的增删改查
需求:完成品牌数据的增删改查操作。
这个功能我们之前一直在做,而这个案例是将今天学习的所有内容(包含 MVC 模式和三层架构)进行应用,并将整个流程贯穿起来。案例结束后你会发现:属于 JSP 的代码只有四个页面,其余全是 Servlet、Service、Mapper,这就是"分工"的样子。
【对比】本节案例照原文保留,它的 DAO 层用的是 MyBatis(
BrandMapper接口 +BrandMapper.xml映射)。而本讲义第10章讲的是 JDBC,第11章会用 JDBC 把同一个tb_brand案例从头到尾重做一遍。两处的关系是:业务逻辑、Servlet、JSP、过滤器完全一样,只有 DAO 层的实现换了一套写法。所以你有两条路:想先理解"三层怎么串起来",就读本节;想和本讲义已讲的 JDBC 知识衔接起来,就先跳到第11章,再回来看本节 8.10.2~8.10.5 里的 JSP 渲染部分。第11章 11.4.6 有一个【对比】块,专门列出"把本节这套 MyBatis 写法换成 JDBC 要改哪三处、哪些文件一行都不用动"。
8.10.1 环境准备
环境准备工作,分以下步骤实现:创建新的模块 brand_demo 并引入坐标;创建三层架构的包结构;创建数据库表 tb_brand;创建实体类 Brand;准备 MyBatis 基础环境(Mybatis-config.xml、BrandMapper.xml、BrandMapper 接口)。
(1)创建工程。 创建新的模块 brand_demo,引入坐标。我们只要分析出要用到哪些技术,那么需要哪些坐标也就明确了:
- 需要操作数据库,要 mysql 的驱动包;
- 要使用 mybatis 框架,要 mybatis 的依赖包;
- web 项目需要用到 servlet 和 jsp,要 servlet 和 jsp 的依赖包;
- 需要使用 jstl 进行数据展示,要 jstl 的依赖包。
pom.xml 内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>brand-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.5</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.34</version>
</dependency>
<!--servlet-->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<!--jsp-->
<dependency>
<groupId>javax.servlet.jsp</groupId>
<artifactId>jsp-api</artifactId>
<version>2.2</version>
<scope>provided</scope>
</dependency>
<!--jstl-->
<dependency>
<groupId>jstl</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
</plugin>
</plugins>
</build>
</project>(2)创建包。 创建不同的包结构,用来存储不同的类,包结构如下:
brand-demo
├── pom.xml
└── src
└── main
├── java
│ └── com/itheima
│ ├── mapper BrandMapper.java
│ ├── pojo Brand.java
│ ├── service BrandService.java
│ ├── utils SqlSessionFactoryUtils.java
│ └── web SelectAllServlet.java、AddServlet.java 等
├── resources
│ ├── mybatis-config.xml
│ └── com/itheima/mapper/BrandMapper.xml
└── webapp
├── index.html
├── brand.jsp
├── addBrand.jsp
└── update.jsp(3)创建表。 建库建表语句如下:
-- 删除tb_brand表
drop table if exists tb_brand;
-- 创建tb_brand表
create table tb_brand
(
-- id 主键
id int primary key auto_increment,
-- 品牌名称
brand_name varchar(20),
-- 企业名称
company_name varchar(20),
-- 排序字段
ordered int,
-- 描述信息
description varchar(100),
-- 状态:0:禁用 1:启用
status int
);
-- 添加数据
insert into tb_brand (brand_name, company_name, ordered, description, status)
values ('三只松鼠', '三只松鼠股份有限公司', 5, '好吃不上火', 0),
('华为', '华为技术有限公司', 100, '华为致力于把数字世界带入每个人、每个家庭、每个组织,构建万物互联的智能世界', 1),
('小米', '小米科技有限公司', 50, 'are you ok', 1);(4)创建实体类。 在 pojo 包下创建名为 Brand 的类:
public class Brand {
// id 主键
private Integer id;
// 品牌名称
private String brandName;
// 企业名称
private String companyName;
// 排序字段
private Integer ordered;
// 描述信息
private String description;
// 状态:0:禁用 1:启用
private Integer status;
public Brand() {
}
public Brand(Integer id, String brandName, String companyName, String description) {
this.id = id;
this.brandName = brandName;
this.companyName = companyName;
this.description = description;
}
public Brand(Integer id, String brandName, String companyName, Integer ordered, String description, Integer status) {
this.id = id;
this.brandName = brandName;
this.companyName = companyName;
this.ordered = ordered;
this.description = description;
this.status = status;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getBrandName() {
return brandName;
}
public void setBrandName(String brandName) {
this.brandName = brandName;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public Integer getOrdered() {
return ordered;
}
public void setOrdered(Integer ordered) {
this.ordered = ordered;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
@Override
public String toString() {
return "Brand{" +
"id=" + id +
", brandName='" + brandName + '\'' +
", companyName='" + companyName + '\'' +
", ordered=" + ordered +
", description='" + description + '\'' +
", status=" + status +
'}';
}
}(5)准备 MyBatis 环境。 定义核心配置文件 mybatis-config.xml,并将该文件放置在 resources 下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!--起别名-->
<typeAliases>
<package name="com.itheima.pojo"/>
</typeAliases>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true"/>
<property name="username" value="root"/>
<property name="password" value="1234"/>
</dataSource>
</environment>
</environments>
<mappers>
<!--扫描mapper-->
<package name="com.itheima.mapper"/>
</mappers>
</configuration>在 resources 下创建放置映射配置文件的目录结构 com/itheima/mapper,并在该目录下创建映射配置文件 BrandMapper.xml:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.BrandMapper">
</mapper>8.10.2 查询所有
当我们点击 index.html 页面中的"查询所有"这个超链接时,就能查询到品牌列表的数据。点击"查询所有"超链接需要先请求后端的 Servlet,由 Servlet 跳转到对应的页面进行数据的动态展示。
(1)编写 BrandMapper。 在 mapper 包下创建 BrandMapper 接口,在接口中定义 selectAll() 方法:
/**
* 查询所有
* @return
*/
@Select("select * from tb_brand")
List<Brand> selectAll();(2)编写工具类。 在 com.itheima 包下创建 utils 包,并在该包下创建名为 SqlSessionFactoryUtils 工具类:
public class SqlSessionFactoryUtils {
private static SqlSessionFactory sqlSessionFactory;
static {
//静态代码块会随着类的加载而自动执行,且只执行一次
try {
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
} catch (IOException e) {
e.printStackTrace();
}
}
public static SqlSessionFactory getSqlSessionFactory(){
return sqlSessionFactory;
}
}【补充】为什么不每个方法里都
new SqlSessionFactoryBuilder().build(...)一次?因为构建SqlSessionFactory要解析整个 XML、建立数据库连接池配置,是重量级操作,而SqlSessionFactory本身是线程安全的、可以复用。把它放进static代码块,"类加载时执行一次",整个应用就只有一份。这是"静态代码块 + 单例"的经典写法,第 10 章 JDBC 里的连接池也是同一个思路。
(3)编写 BrandService。 在 service 包下创建 BrandService 类:
public class BrandService {
SqlSessionFactory factory = SqlSessionFactoryUtils.getSqlSessionFactory();
/**
* 查询所有
* @return
*/
public List<Brand> selectAll(){
//调用BrandMapper.selectAll()
//2. 获取SqlSession
SqlSession sqlSession = factory.openSession();
//3. 获取BrandMapper
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
//4. 调用方法
List<Brand> brands = mapper.selectAll();
sqlSession.close();
return brands;
}
}(4)编写 Servlet。 在 web 包下创建名为 SelectAllServlet 的 Servlet,这个 Servlet 的逻辑如下:调用 BrandService 的 selectAll() 方法进行业务逻辑处理,并接收返回的结果;将上一步返回的结果存储到 request 域对象中;跳转到 brand.jsp 页面进行数据的展示。
@WebServlet("/selectAllServlet")
public class SelectAllServlet extends HttpServlet {
private BrandService service = new BrandService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//1. 调用BrandService完成查询
List<Brand> brands = service.selectAll();
//2. 存入request域中
request.setAttribute("brands",brands);
//3. 转发到brand.jsp
request.getRequestDispatcher("/brand.jsp").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}(5)编写 brand.jsp 页面。 将静态页面 brand.html 拷贝到项目的 webapp 目录下,并将该页面改成 brand.jsp 页面,在表格中使用 JSTL 和 EL 表达式从 request 域对象中获取名为 brands 的集合数据并展示出来:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<hr>
<table border="1" cellspacing="0" width="80%">
<tr>
<th>序号</th>
<th>品牌名称</th>
<th>企业名称</th>
<th>排序</th>
<th>品牌介绍</th>
<th>状态</th>
<th>操作</th>
</tr>
<c:forEach items="${brands}" var="brand" varStatus="status">
<tr align="center">
<td>${status.count}</td>
<td>${brand.brandName}</td>
<td>${brand.companyName}</td>
<td>${brand.ordered}</td>
<td>${brand.description}</td>
<c:if test="${brand.status == 1}">
<td>启用</td>
</c:if>
<c:if test="${brand.status != 1}">
<td>禁用</td>
</c:if>
<td><a href="${pageContext.request.contextPath}/selectByIdServlet?id=${brand.id}">修改</a> <a href="#">删除</a></td>
</tr>
</c:forEach>
</table>
</body>
</html>可以看到整页没有一行 Java 代码:循环靠 <c:forEach>,判断靠 <c:if>,取值靠 ${}。和 8.4.6 那个 brand.jsp 对比一下,这就是本章要走的距离。
(6)测试。 启动服务器,并在浏览器输入 http://localhost:8080/brand-demo/index.html,看到"查询所有"的超链接,点击该链接就可以查询出所有的品牌数据。
如果这时你看到品牌名称、企业名称两列是空的,原因是查询到的字段名和实体类对象的属性名没有一一对应:数据库里叫 brand_name、company_name,Java 里叫 brandName、companyName,MyBatis 默认按同名映射,对不上就填 null。解决办法是在映射配置文件中使用 resultMap 标签定义映射关系。映射配置文件内容如下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.BrandMapper">
<resultMap id="brandResultMap" type="brand">
<result column="brand_name" property="brandName"></result>
<result column="company_name" property="companyName"></result>
</resultMap>
</mapper>并且在 BrandMapper 接口中的 selectAll() 上使用 @ResultMap 注解指定使用该映射:
/**
* 查询所有
* @return
*/
@Select("select * from tb_brand")
@ResultMap("brandResultMap")
List<Brand> selectAll();重启服务器,再次访问就能看到我们想要的数据了。
【易错点】这里有两处拼写细节会让人卡半小时:
type="brand"用的是别名(因为mybatis-config.xml里配了<package name="com.itheima.pojo"/>),如果写成全限定名com.itheima.pojo.Brand也可以;注解名是@ResultMap(Result+Map),@Resultmap与@ResuleMap都是错的写法。另外,resultMap的id必须和@ResultMap("...")里写的字符串完全一致,多一个空格都会报"Result Maps collection does not contain value for ..."。
8.10.3 添加
点击"新增"按钮后,会先跳转到 addBrand.jsp 新增页面,在该页面输入要添加的数据,输入完毕后点击"提交"按钮,将数据提交到后端,后端进行数据添加操作,并重新将所有的数据查询出来。
(1)编写 BrandMapper 方法。 在 BrandMapper 接口中定义 add(Brand brand) 方法:
@Insert("insert into tb_brand values(null,#{brandName},#{companyName},#{ordered},#{description},#{status})")
void add(Brand brand);(2)编写 BrandService 方法。 在 BrandService 类中定义添加品牌数据方法 add(Brand brand):
/**
* 添加
* @param brand
*/
public void add(Brand brand){
//2. 获取SqlSession
SqlSession sqlSession = factory.openSession();
//3. 获取BrandMapper
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
//4. 调用方法
mapper.add(brand);
//提交事务
sqlSession.commit();
//释放资源
sqlSession.close();
}【易错点】增删改操作必须
commit()。MyBatis 的openSession()默认是手动提交事务的,如果你只调了mapper.add(brand)就close(),数据库里什么都不会变,而且不报错,你会对着"添加成功"的页面困惑半天。查询不用 commit,是因为查询不改变数据。另一种写法是factory.openSession(true),参数true表示自动提交。
(3)改进 brand.jsp 页面。 需要在表格上面添加"新增"按钮:
<input type="button" value="新增" id="add"><br>并给该按钮绑定单击事件,当点击了该按钮需要跳转到添加品牌数据的页面:
<script>
document.getElementById("add").onclick = function (){
location.href = "${pageContext.request.contextPath}/addBrand.jsp";
}
</script>注意:这个 script 标签建议放在 body 结束标签前面。
(4)编写 addBrand.jsp 页面。 从静态页面 addBrand.html 拷贝到项目的 webapp 下,并改成 addBrand.jsp 动态页面:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>添加品牌</title>
</head>
<body>
<h3>添加品牌</h3>
<form action="${pageContext.request.contextPath}/addServlet" method="post">
品牌名称:<input name="brandName"><br>
企业名称:<input name="companyName"><br>
排序:<input name="ordered"><br>
描述信息:<textarea rows="5" cols="20" name="description"></textarea><br>
状态:
<input type="radio" name="status" value="0">禁用
<input type="radio" name="status" value="1">启用<br>
<input type="submit" value="提交">
</form>
</body>
</html>(5)编写 Servlet。 在 web 包下创建 AddServlet,这个 Servlet 的逻辑如下:设置处理 POST 请求乱码的字符集;接收客户端提交的数据;将接收到的数据封装到 Brand 对象中;调用 BrandService 的 add() 方法进行添加的业务逻辑处理;跳转到 selectAllServlet 资源重新查询数据。
@WebServlet("/addServlet")
public class AddServlet extends HttpServlet {
private BrandService service = new BrandService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//处理POST请求的乱码问题
request.setCharacterEncoding("utf-8");
//1. 接收表单提交的数据,封装为一个Brand对象
String brandName = request.getParameter("brandName");
String companyName = request.getParameter("companyName");
String ordered = request.getParameter("ordered");
String description = request.getParameter("description");
String status = request.getParameter("status");
//封装为一个Brand对象
Brand brand = new Brand();
brand.setBrandName(brandName);
brand.setCompanyName(companyName);
brand.setOrdered(Integer.parseInt(ordered));
brand.setDescription(description);
brand.setStatus(Integer.parseInt(status));
//2. 调用service 完成添加
service.add(brand);
//3. 转发到查询所有Servlet
request.getRequestDispatcher("/selectAllServlet").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}(6)测试。 点击 brand.jsp 页面的"新增"按钮,会跳转到 addBrand.jsp 页面;点击"提交"按钮,就能看到包含刚添加数据的新列表页。
注意最后一步用的是 getRequestDispatcher(...).forward(...) 而不是 sendRedirect:因为转发是服务器内部跳转,浏览器地址栏还停在 /addServlet,刷新页面会重复提交表单。真实项目里解决"表单重复提交"用的正是第 9 章的 Filter 或者 PRG(Post-Redirect-Get)模式,这里先埋个伏笔。
8.10.4 修改
点击每条数据后面的"修改"按钮会跳转到修改页面。在修改页面可以看到当前行的数据回显到表单,修改完点击"提交"按钮将数据提交到后端,后端再将数据存储到数据库中。
所以修改功能要从两方面实现:数据回显和修改操作。
(一)回显数据
当点击"修改"按钮时不能直接跳转到 update.jsp 页面,而是需要先带着当前数据的 id 请求后端程序,后端程序根据 id 查询数据,将数据存储到域对象中跳转到 update.jsp 页面进行数据展示。这就是 brand.jsp 里那个 href 为什么要带 ?id=${brand.id}。
(1)编写 BrandMapper 方法。 在 BrandMapper 接口中定义 selectById(int id) 方法:
/**
* 根据id查询
* @param id
* @return
*/
@Select("select * from tb_brand where id = #{id}")
@ResultMap("brandResultMap")
Brand selectById(int id);(2)编写 BrandService 方法。 在 BrandService 类中定义根据 id 查询数据方法 selectById(int id):
/**
* 根据id查询
* @return
*/
public Brand selectById(int id){
//调用BrandMapper.selectAll()
//2. 获取SqlSession
SqlSession sqlSession = factory.openSession();
//3. 获取BrandMapper
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
//4. 调用方法
Brand brand = mapper.selectById(id);
sqlSession.close();
return brand;
}(3)编写 Servlet。 在 web 包下创建 SelectByIdServlet,这个 Servlet 的逻辑如下:获取请求数据 id;调用 BrandService 的 selectById() 方法进行数据查询的业务逻辑;将查询到的数据存储到 request 域对象中;跳转到 update.jsp 页面进行数据展示。
@WebServlet("/selectByIdServlet")
public class SelectByIdServlet extends HttpServlet {
private BrandService service = new BrandService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//1. 接收id
String id = request.getParameter("id");
//2. 调用service查询
Brand brand = service.selectById(Integer.parseInt(id));
//3. 存储到request中
request.setAttribute("brand",brand);
//4. 转发到update.jsp
request.getRequestDispatcher("/update.jsp").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}(4)编写 update.jsp 页面。 拷贝 addBrand.jsp 页面,改名为 update.jsp,并做出以下修改:title 标签内容改为"修改品牌";form 标签的 action 属性值改为 updateServlet;input 标签要进行数据回显,需要设置 value 属性。
品牌名称:<input name="brandName" value="${brand.brandName}"><br>
企业名称:<input name="companyName" value="${brand.companyName}"><br>
排序:<input name="ordered" value="${brand.ordered}"><br>textarea 标签要进行数据回显,需要在标签体中使用 EL 表达式:
描述信息:<textarea rows="5" cols="20" name="description">${brand.description} </textarea><br>单选框使用 if 标签需要判断 brand.status 的值是 1 还是 0,在指定的单选框上使用 checked 属性,表示被选中状态:
状态:
<c:if test="${brand.status == 0}">
<input type="radio" name="status" value="0" checked>禁用
<input type="radio" name="status" value="1">启用<br>
</c:if>
<c:if test="${brand.status == 1}">
<input type="radio" name="status" value="0" >禁用
<input type="radio" name="status" value="1" checked>启用<br>
</c:if>【易错点】
textarea的回显值必须写在开始标签和结束标签之间,不能写成value属性:<!-- 错误:textarea 没有 value 属性,用户会看到一个空框 --> <textarea name="description" value="${brand.description}"></textarea> <!-- 正确 --> <textarea name="description">${brand.description}</textarea>因为
<textarea>在 HTML 规范里是"双标签内容型"控件,它的值就是标签体里的文本。同样属于"内容型"回显的还有<select>的<option selected>,而<input>、<radio>、<checkbox>才是用value和checked属性。
(二)修改数据
在修改页面进行数据修改,点击"提交"按钮,会将数据提交到后端程序,后端程序会对表中的数据进行修改操作,然后重新进行数据的查询操作。
(1)编写 BrandMapper 方法。 在 BrandMapper 接口中定义 update(Brand brand) 方法:
/**
* 修改
* @param brand
*/
@Update("update tb_brand set brand_name = #{brandName},company_name = #{companyName},ordered = #{ordered},description = #{description},status = #{status} where id = #{id}")
void update(Brand brand);(2)编写 BrandService 方法。 在 BrandService 类中定义修改方法 update(Brand brand):
/**
* 修改
* @param brand
*/
public void update(Brand brand){
//2. 获取SqlSession
SqlSession sqlSession = factory.openSession();
//3. 获取BrandMapper
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
//4. 调用方法
mapper.update(brand);
//提交事务
sqlSession.commit();
//释放资源
sqlSession.close();
}(3)编写 Servlet。 在 web 包下创建 UpdateServlet,这个 Servlet 的逻辑如下:设置处理 POST 请求乱码的字符集;接收客户端提交的数据;将接收到的数据封装到 Brand 对象中;调用 BrandService 的 update() 方法进行修改的业务逻辑处理;跳转到 selectAllServlet 资源重新查询数据。
@WebServlet("/updateServlet")
public class UpdateServlet extends HttpServlet {
private BrandService service = new BrandService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//处理POST请求的乱码问题
request.setCharacterEncoding("utf-8");
//1. 接收表单提交的数据,封装为一个Brand对象
String id = request.getParameter("id");
String brandName = request.getParameter("brandName");
String companyName = request.getParameter("companyName");
String ordered = request.getParameter("ordered");
String description = request.getParameter("description");
String status = request.getParameter("status");
//封装为一个Brand对象
Brand brand = new Brand();
brand.setId(Integer.parseInt(id));
brand.setBrandName(brandName);
brand.setCompanyName(companyName);
brand.setOrdered(Integer.parseInt(ordered));
brand.setDescription(description);
brand.setStatus(Integer.parseInt(status));
//2. 调用service 完成修改
service.update(brand);
//3. 转发到查询所有Servlet
request.getRequestDispatcher("/selectAllServlet").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}(4)存在的问题与修复。 update.jsp 页面提交数据时是没有携带主键数据的,而后台修改数据需要根据主键进行修改。
针对这个问题,我们不希望页面将主键 id 展示给用户看,但是又希望在提交数据时能将主键 id 提交到后端。此时就想到了在学习 HTML 时学到的隐藏域,在 update.jsp 页面的表单中添加如下代码:
<%--隐藏域,提交id--%>
<input type="hidden" name="id" value="${brand.id}">update.jsp 页面的最终代码如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>修改品牌</title>
</head>
<body>
<h3>修改品牌</h3>
<form action="${pageContext.request.contextPath}/updateServlet" method="post">
<%--隐藏域,提交id--%>
<input type="hidden" name="id" value="${brand.id}">
品牌名称:<input name="brandName" value="${brand.brandName}"><br>
企业名称:<input name="companyName" value="${brand.companyName}"><br>
排序:<input name="ordered" value="${brand.ordered}"><br>
描述信息:<textarea rows="5" cols="20" name="description">${brand.description} </textarea><br>
状态:
<c:if test="${brand.status == 0}">
<input type="radio" name="status" value="0" checked>禁用
<input type="radio" name="status" value="1">启用<br>
</c:if>
<c:if test="${brand.status == 1}">
<input type="radio" name="status" value="0" >禁用
<input type="radio" name="status" value="1" checked>启用<br>
</c:if>
<input type="submit" value="提交">
</form>
</body>
</html>【易错点】隐藏域只是看不见,不是安全。用户按 F12 就能改
value,所以"修改别人的品牌"这种越权操作,后端必须再校验一次"这条记录是不是属于当前登录用户"。同理,type="hidden"提交的值在用Integer.parseInt()之前要判空:如果用户手工删掉这个 input,request.getParameter("id")返回 null,parseInt(null)会抛NumberFormatException,页面直接 500。
8.10.5 删除
删除与前面三个功能的固定流程完全一致,只是 SQL 从 update 换成了 delete、参数从 Brand 对象换成了一个 id。按同样的五步走一遍。
(1)编写 BrandMapper 方法:
/**
* 根据id删除
* @param id
*/
@Delete("delete from tb_brand where id = #{id}")
void deleteById(int id);(2)编写 BrandService 方法:
/**
* 删除
* @param id
*/
public void deleteById(int id){
SqlSession sqlSession = factory.openSession();
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
mapper.deleteById(id);
//提交事务
sqlSession.commit();
sqlSession.close();
}(3)编写 Servlet:
@WebServlet("/deleteServlet")
public class DeleteServlet extends HttpServlet {
private BrandService service = new BrandService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
//1. 接收id
String id = request.getParameter("id");
//2. 调用service删除
service.deleteById(Integer.parseInt(id));
//3. 转发到查询所有Servlet
request.getRequestDispatcher("/selectAllServlet").forward(request,response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
this.doGet(request, response);
}
}(4)修改 brand.jsp 的删除链接:
<td>
<a href="${pageContext.request.contextPath}/selectByIdServlet?id=${brand.id}">修改</a>
<a href="${pageContext.request.contextPath}/deleteServlet?id=${brand.id}"
onclick="return confirm('确定要删除【${brand.brandName}】吗?')">删除</a>
</td>(5)测试。 点击"删除",浏览器弹出确认框,点"确定"后该行消失,列表重新加载。
onclick 里的 return confirm(...) 是这里的关键:confirm 会弹出"确定/取消"对话框并返回布尔值,onclick 返回 false 时浏览器会取消这次跳转。少了 return,不管用户点确定还是取消,链接都会跳转,这就是最典型的"确认框形同虚设"的 bug。
8.10.6 一次请求的完整链路
把"查询所有"这条链路完整走一遍,本章所有知识点都在里面:
浏览器点击"查询所有"
│ GET /brand-demo/selectAllServlet ← 第4章 HTTP
▼
Tomcat 按 @WebServlet("/selectAllServlet") 找到 Servlet ← 第5章
│
▼
SelectAllServlet.doGet() ← 表现层(Controller)
│ ① service.selectAll()
▼
BrandService.selectAll() ← 业务逻辑层
│ ② factory.openSession() → getMapper() → mapper.selectAll()
▼
BrandMapper.selectAll() @Select("select * from tb_brand") ← 数据访问层
│ ③ JDBC 执行 SQL,resultMap 把 brand_name 映射到 brandName
▼
List<Brand> 沿原路返回
│
▼
request.setAttribute("brands", brands) ← 存进 request 域
│
▼
request.getRequestDispatcher("/brand.jsp").forward(...) ← 转发(第6章)
│
▼
Tomcat 翻译/执行 brand_jsp.java 的 _jspService() ← JSP 原理
│ <c:forEach items="${brands}"> → EL 从 request 域取 brands
▼
out.write("<tr>...<td>三只松鼠</td>...") ← 生成 HTML
│
▼
浏览器收到 HTML,渲染出表格对照这张图检查一下:哪一步属于 JSP?只有最后两步。 这就是 MVC 与三层架构落到代码里的样子。
【考点】综合题常问"描述一次 JSP 页面的请求处理流程"。照上面这张图从 HTTP 请求讲到浏览器渲染,答出"Servlet 收请求 → Service → Mapper → 存 request 域 → 转发 JSP → JSP 翻译执行 → 输出 HTML"这七个环节,就能拿满分。
本章小结
本章的主线只有一条:把 HTML 从 Java 里解放出来(JSP),再把 Java 从 HTML 里赶出去(EL + JSTL),最后把两边的职责固定下来(MVC + 三层架构)。
| 主题 | 必须记住的结论 |
|---|---|
| JSP 的本质 | JSP 会被翻译成 xxx_jsp.java,继承 HttpJspBase,间接继承 HttpServlet,本质是 Servlet |
| 执行流程 | 翻译 → 编译 → 加载 → 执行,产物在 work\Tomcat\localhost\<项目>\org\apache\jsp |
| 执行方法 | _jspService(),等价于 Servlet 的 service(),每次请求执行一次 |
| 五种组成 | 指令 <%@ %>、脚本 <% %>、表达式 <%= %>、声明 <%! %>、注释 <%-- --%> |
| 九大内置对象 | request、response、session、application、config、out、page、pageContext、exception |
| 四大作用域 | page ⊂ request ⊂ session ⊂ application,取值顺序从小到大 |
| EL | ${} 只能取值;取不到返回 null 并输出空串;属性名写错抛 PropertyNotFoundException |
| EL 11 个隐式对象 | pageContext、4 个 Scope、param/paramValues、header/headerValues、cookie、initParam |
| JSTL | 两个坐标 + <%@ taglib %>;<c:if> 无 else,多分支用 <c:choose> |
<c:forEach> | 用法一 items/var/varStatus,用法二 begin/end/step/var;count 从 1、index 从 0 |
| MVC | Model / View / Controller,Servlet 当 C,JSP 当 V |
| 三层架构 | 表现层 / 业务逻辑层 / 数据访问层,web / service / mapper |
| 两者关系 | C + V = 表现层,M = 业务逻辑层 + 数据访问层;MVC 是大概念 |
这章的知识在下章哪里被用到:
- 第 9 章 Filter 要拦的就是本章这些 Servlet:登录校验拦截
/brand-demo/*,编码过滤器统一处理request.setCharacterEncoding("utf-8")(本章在AddServlet、UpdateServlet里各写了一遍,正是要被抽走的重复代码)。 - 第 10 章 JDBC 讲的正是本章
BrandMapper里那行@Select背后发生的事:连接池、PreparedStatement、结果集映射。 - 第 11 章综合实战 会把本章的四个页面、四个 Servlet 加上 Filter 和连接池,拼成一个完整可部署的系统。
最后记住这条演进线,它是本章的"为什么":Servlet 拼字符串(第一阶段)→ JSP 里嵌 Java(第二阶段)→ Servlet 管逻辑、JSP 管展示(第三阶段,本章)→ HTML + Ajax(第四阶段)→ Thymeleaf / Vue 前后端分离(今天)。 每一步都是被上一步的痛推着走的。
自测
基础题
- 简述 JSP 的本质,并写出访问一个 JSP 时 Tomcat 内部经历的四个步骤。
- 写出 JSP 的九大内置对象。其中哪四个是域对象?它们的有效范围分别是什么?
<% %>、<%= %>、<%! %>三种脚本,翻译成 Java 后分别落在生成类的什么位置?- 写出 EL 中与"域"有关的 5 个隐式对象,并说明
${brands}的查找顺序。 - page 指令的
contentType和pageEncoding有什么区别?只写一个会有什么后果? - 在 JSP 里使用 JSTL 需要哪两步?写出核心库的 taglib 指令。
进阶题
- 读代码写结果。Servlet 中执行
request.setAttribute("name","A"); session.setAttribute("name","B");,然后转发到 JSP,页面中写${name}、${sessionScope.name}、${requestScope.name},三个输出分别是什么? - 找错并改正:
<c:if test="${brand.status == 1}">
<td>启用</td>
</c:if>
<c:else>
<td>禁用</td>
</c:else>- 读代码写结果:
<c:forEach begin="1" end="5" step="2" var="i">${i}</c:forEach>- 页面里有
<a href="/brand-demo/selectAllServlet">查询</a>,把项目部署到/shop之后链接 404 了。请说明原因并给出两种改法。 - 下面这段 JSP 有什么问题?会导致什么现象?
<%! int count = 0; %>
<% count++; %>
你是第 <%=count%> 位访问者参考答案
- JSP 本质是 Servlet:Tomcat 把
.jsp翻译成xxx_jsp.java(继承HttpJspBase,间接继承HttpServlet),再编译成.class执行。四步:翻译 → 编译 → 加载 → 执行。- request、response、session、application、config、out、page、pageContext、exception。域对象是 page(当前页面)、request(一次请求)、session(一次会话)、application(整个应用)。
<% %>→_jspService()方法体内;<%= %>→out.print(...)的参数;<%! %>→ 类体中,成为成员变量或成员方法。- pageContext、pageScope、requestScope、sessionScope、applicationScope。顺序:page → request → session → application,找到即停。
contentType写给浏览器,决定响应体的 MIME 与编码;pageEncoding写给容器,决定 Tomcat 用什么编码读.jsp源文件。只写contentType时容器会退而用它的 charset 读源文件;两个都不写则按 ISO-8859-1 读,源码中文会乱码。- ① 引入 Maven 坐标
jstl:jstl:1.2与taglibs:standard:1.1.2(或把 jar 放入WEB-INF/lib);② 页面顶部写<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。A、B、A。${name}按 page→request→session→application 查找,request 里有所以取A。<c:else>不存在。改为两个<c:if>(一个==1、一个!=1),或用<c:choose><c:when test="...">…</c:when><c:otherwise>…</c:otherwise></c:choose>。135。begin/end闭区间、step为 2,输出 1、3、5。- 原因:
/开头是站点根路径,不是应用根路径,部署到/shop后请求打到了http://localhost:8080/selectAllServlet。改法一:${pageContext.request.contextPath}/selectAllServlet;改法二:用相对路径selectAllServlet(同目录下),或绝对 URL 写全/shop/selectAllServlet(不推荐,路径写死)。count是成员变量,所有请求共享,count++不是原子操作,并发下会少计数;且服务器重启后归零。应改用application域或数据库计数。