12.JavaWeb与Servlet、JSP基础

Java Web:从 Tomcat 到 Servlet、JSP 和 JSTL
前面用 Socket 让两个程序交换消息。浏览器访问网页,也是在发送请求、接收响应,只不过双方还要遵守 HTTP 的格式。
这一篇沿着这个过程往下学:先把 Java Web 程序放进 Tomcat,再用 Socket 做一个小服务器,看看容器帮我们做了什么;接着认识 Servlet 的创建和调用,最后用 JSP、EL 和 JSTL 展示页面数据。
1. Java Web 与 Tomcat
1.1 Java Web 是什么,为什么需要容器
Java Web 是用 Java 开发 Web 应用的一类技术。比如浏览器提交一个用户编号,Java 程序接收编号、查询数据,再把结果返回给浏览器。
如果从 Socket 开始写,就要自己处理连接、HTTP 请求解析和响应格式。Tomcat 已经做好了这些工作,我们可以把主要精力放在“收到请求后要做什么”上。
Tomcat 是 Apache 维护的 Web 服务器和 Servlet 容器,支持 Servlet、JSP 等技术。Servlet 负责处理请求,Filter 可以在处理前后做过滤,底层的网络连接由容器管理。
text
浏览器 → HTTP 请求 → Tomcat → 对应的 Servlet
浏览器 ← HTTP 响应 ← Tomcat ← Servlet 的处理结果
**Servlet 类本身不监听端口。**它需要容器创建并调用。容器可以独立启动,也可以嵌在 Java 应用里启动;例如 Spring Boot 的常见 Web 应用可以自带嵌入式 Tomcat,并不一定要手工部署到外部 Tomcat。
Tomcat 之外,还有 WildFly(由原 JBoss AS 演进而来)、WebLogic 等应用服务器,它们也提供 Web 容器功能,但能力范围不完全相同。
1.2 Tomcat 的目录
| 目录 | 作用 |
|---|---|
bin |
启动、停止等脚本 |
conf |
配置文件,例如端口、应用和用户配置 |
lib |
容器使用的 JAR 包 |
logs |
运行日志,启动和访问失败时先看这里 |
temp |
临时文件 |
webapps |
常见的应用部署目录,可放应用目录或 WAR 包 |
work |
工作目录,包含 JSP 编译生成的 Java 源码、class 等 |
不是放进 webapps 的所有内容都能直接访问。应用内的 WEB-INF、META-INF 等位置受到保护,浏览器不能直接请求其中的文件。JSP 可以放在 WEB-INF 中,再由 Servlet 转发过去,这样用户就要先经过业务处理。
反过来,容器也不会自动把电脑上的任意文件公开到网络;只有配置为可访问的资源,或程序明确读取并返回的内容,才会被发送出去。
1.3 先选对 Tomcat、JDK 和包名
Tomcat 的版本会影响 Servlet API 和 JDK 要求。常见搭配是:
| Tomcat 系列 | 最低 Java 版本 | Servlet 版本 | Servlet 包名 |
|---|---|---|---|
| 9 | Java 8 | Servlet 4.0 | javax.servlet.* |
| 10.1 | Java 11 | Servlet 6.0 | jakarta.servlet.* |
| 11 | Java 17 | Servlet 6.1 | jakarta.servlet.* |
**Tomcat 一直由 Apache 维护。**从 Java EE 迁移到 Jakarta EE 后,相关规范由 Eclipse Foundation 下的 Jakarta EE 项目发展,Servlet 包名也从 javax.servlet 改成了 jakarta.servlet。这不是 Tomcat 换了维护者,也不是所有 javax 包都改了名字。
下面统一使用 Java 17 语法、Tomcat 11 和 jakarta.servlet。如果使用 Tomcat 9,需要配套使用旧 Servlet 和旧 JSTL 依赖,不能只换导入语句后混用两套包。
在 Maven Web 工程的 pom.xml 中,声明 Servlet API:
xml
<packaging>war</packaging>
<dependencies>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<!-- Tomcat 已提供这个 API,不重复打进应用。 -->
<scope>provided</scope>
</dependency>
</dependencies>
这是 pom.xml 中的配置片段,不是完整 POM。项目的 Java 编译版本也要设为 17 或更高。后面的 JSTL 依赖加入同一个 dependencies 中。
1.4 在 IDEA 中启动并访问
IDEA 的界面会随版本和所装插件变化,可以按这几个关键步骤检查:
- 创建 Jakarta EE Web Application,或创建 Maven WAR 工程。
- 设置项目 JDK,并在支持应用服务器的 IDEA 环境中添加本机 Tomcat 目录。
- 在 Tomcat 运行配置的 Deployment 中部署应用,确认 Application context,例如
/javaweb001。 - 启动 Tomcat,查看日志,确认端口没有被其他程序占用。
- 用浏览器访问“主机 + 端口 + 应用路径 + Servlet 路径”。
如果 IDEA 没有 Tomcat 配置入口,也可以用 Maven 打包 WAR,把它放进独立 Tomcat 的 webapps 中运行。Tomcat 默认 HTTP 端口通常是 8080,实际以 conf/server.xml 或运行配置为准。
先沿用 TestServlet,返回一句话:
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/test")
public class TestServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 在取得 Writer 之前设置字符集,避免中文响应乱码。
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("Hello World");
}
}
如果应用路径是 /javaweb001,访问 http://localhost:8080/javaweb001/test,页面会显示 Hello World。@WebServlet("/test") 是应用内部的路径,不包含 /javaweb001。
2. 用 Socket 手写一个小型 HTTP 服务器
2.1 要做什么,为什么先做这个练习
这个练习沿用 HttpServer、HttpRequest、HttpResponse 和 Test 四个类,不使用 Servlet,完成原来的几个目标:
- 浏览器发送 GET 请求。
- 控制台打印请求行和请求头。
- 请求
/时返回默认页面。 - 文件存在就返回文件,不存在就返回 404。
这样能看清:**Java 可以直接用 Socket 处理 HTTP,Servlet 是容器提供的开发方式,并不是唯一途径。**这个小程序只展示静态资源和基本收发,还不具备真正 Servlet 容器的生命周期管理等功能。
先看浏览器发送的一个简化请求:
http
GET /index.html HTTP/1.1
Host: localhost:8090
第一行是“方法、请求目标、HTTP 版本”,后面是请求头,空行表示请求头结束。HTTP 报文中的换行使用 \r\n。
响应也有固定结构:
http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 这里填写正文的字节数
Connection: close
这里是 HTML 正文
Content-Length 要数字节,不能用字符串的 length()。中文转成 UTF-8 后,字符数和字节数经常不同。
2.2 先准备默认页面
在运行 Test 时的工作目录下创建 WebContent,其中放一个 index.html。IDEA 的 Working directory 决定相对路径从哪里开始找。
页面内容可以很简单:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的小服务器</title>
</head>
<body>
<h1>你好,Java Web!</h1>
</body>
</html>
这里只需要这个页面。四个 Java 类属于同一个小例子,分别定义,再一起编译运行。
2.3 HttpServer:接收连接,把工作交给请求和响应对象
主线程监听 8090 端口。每收到一个连接,先解析请求,再作出响应,最后关闭连接。
java
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class HttpServer {
private final int port = 8090;
public void receive() throws IOException {
// 只监听本机,先用浏览器在当前电脑上练习。
try (ServerSocket server = new ServerSocket(
port, 50, InetAddress.getByName("127.0.0.1"))) {
System.out.println("服务已启动:http://localhost:" + port);
while (true) {
try (Socket socket = server.accept()) {
// 浏览器迟迟不发送数据时,最多等 3 秒。
socket.setSoTimeout(3000);
HttpRequest request = new HttpRequest(socket.getInputStream());
HttpResponse response = new HttpResponse(socket.getOutputStream());
try {
request.parse();
response.sendStaticResource(request);
} catch (IllegalArgumentException e) {
response.sendText(400, "Bad Request", "请求格式不正确");
}
} catch (IOException e) {
// 单个连接失败后仍继续监听,不让整个服务退出。
System.out.println("本次请求处理失败:" + e.getMessage());
}
}
}
}
}
accept() 返回当前客户端的 Socket;关闭它不会关闭外层的 ServerSocket。这里一次处理一个连接,先把过程看清,再考虑多线程。
2.4 HttpRequest:解析请求行,打印请求头
TCP 不保证一次 read() 就得到完整请求,所以不能只读一个 2048 字节数组,再假定请求全到了。这里按行读取请求行和请求头,读到空行为止。
java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
public class HttpRequest {
private final InputStream inputStream;
private String method;
private String uri;
public HttpRequest(InputStream inputStream) {
this.inputStream = inputStream;
}
public void parse() throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(
inputStream, StandardCharsets.ISO_8859_1));
String requestLine = reader.readLine();
if (requestLine == null) {
throw new IllegalArgumentException("缺少请求行");
}
System.out.println(requestLine);
String[] parts = requestLine.split(" ");
if (parts.length != 3 || !parts[1].startsWith("/")
|| !(parts[2].equals("HTTP/1.1") || parts[2].equals("HTTP/1.0"))) {
throw new IllegalArgumentException("请求行不正确");
}
method = parts[0];
// 取路径部分,/index.html?id=1 仍然访问 index.html。
uri = parseUri(parts[1]);
String line;
while ((line = reader.readLine()) != null && !line.isEmpty()) {
System.out.println(line);
}
if (line == null) {
throw new IllegalArgumentException("请求头没有结束空行");
}
System.out.println("uri: " + uri);
}
public String parseUri(String target) {
String path = URI.create(target).getPath();
if (path == null || !path.startsWith("/")) {
throw new IllegalArgumentException("请求路径不正确");
}
return path;
}
public String getMethod() {
return method;
}
public String getUri() {
return uri;
}
}
HTTP 请求行和头部先按单字节方式读取,URL 路径中的百分号编码由 URI 解析。这里没有读取 POST 正文;GET 静态资源是当前练习的范围。
2.5 HttpResponse:找文件,返回状态、响应头和正文
先把 / 换成 /index.html,再到 WebContent 中查找。路径要留在资源目录内,不能让 ../ 跑出去读取其他文件。
java
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class HttpResponse {
private final OutputStream outputStream;
public HttpResponse(OutputStream outputStream) {
this.outputStream = outputStream;
}
public void sendStaticResource(HttpRequest request) throws IOException {
if (!"GET".equals(request.getMethod())) {
sendText(405, "Method Not Allowed", "本例只支持 GET 请求");
return;
}
Path root = Path.of("WebContent").toRealPath();
String uri = request.getUri();
if ("/".equals(uri)) {
uri = "/index.html"; // 默认页面
}
// 去掉 URL 开头的 /,再相对于资源目录定位文件。
Path file = root.resolve(uri.substring(1)).normalize();
if (!file.startsWith(root)) {
sendText(403, "Forbidden", "不能访问资源目录之外的文件");
return;
}
if (!Files.isRegularFile(file)) {
sendText(404, "Not Found", "404:请求的文件不存在");
return;
}
// 目录中的链接也不能指向资源目录之外。
if (!file.toRealPath().startsWith(root)) {
sendText(403, "Forbidden", "不能访问资源目录之外的文件");
return;
}
// 小文件直接读成字节,不把图片等二进制文件转成字符串。
byte[] body = Files.readAllBytes(file);
String contentType = Files.probeContentType(file);
if (uri.endsWith(".html")) {
contentType = "text/html; charset=UTF-8";
} else if (contentType == null) {
contentType = "application/octet-stream";
}
writeResponse(200, "OK", contentType, body);
}
public void sendText(int status, String reason, String message)
throws IOException {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
writeResponse(status, reason, "text/plain; charset=UTF-8", body);
}
private void writeResponse(int status, String reason,
String contentType, byte[] body) throws IOException {
String headers = "HTTP/1.1 " + status + " " + reason + "\r\n"
+ "Content-Type: " + contentType + "\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ (status == 405 ? "Allow: GET\r\n" : "")
+ "Connection: close\r\n\r\n";
// 响应头和正文分开写,长度按正文实际字节数计算。
outputStream.write(headers.getBytes(StandardCharsets.US_ASCII));
outputStream.write(body);
outputStream.flush();
}
}
这里的 writeResponse() 对应“包装响应”的工作:状态行、响应头、空行、正文。Files.readAllBytes() 会完整读取小文件,避免一次 read() 读不完;大文件应使用分块传输,不能都装进内存。
2.6 Test:启动,再用浏览器验证
java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class Test {
public static void main(String[] args) throws IOException {
// 启动前确认页面目录存在,避免每次请求才发现路径配置错误。
if (!Files.isDirectory(Path.of("WebContent"))) {
throw new IOException("请先在工作目录下创建 WebContent");
}
new HttpServer().receive();
}
}
启动后可以逐个访问:
| 地址 | 预期结果 |
|---|---|
http://localhost:8090/ |
返回默认页面,显示“你好,Java Web!” |
http://localhost:8090/index.html |
返回同一个页面 |
http://localhost:8090/index.html?id=1 |
查询参数不影响文件定位 |
http://localhost:8090/missing.html |
返回 404 和提示文字 |
浏览器还可能自动请求 /favicon.ico。没有这个图标时看到额外的 404 很正常,不代表首页访问失败。
这个服务器会在每次响应后关闭连接;它没有完整校验 HTTP 请求头、限制请求头总大小,也不支持 HTTPS、持久连接、分块编码或 Servlet。保留这个范围,就能专心看清基本通信过程。
3. Servlet:让容器调用我们的业务代码
3.1 Servlet 是什么
Servlet 是一个接口,规定了初始化、处理请求、销毁等方法。容器知道这些方法,就能按统一的方式调用不同的业务类。
实现接口的意思是“提供这些方法的实现”,不是类只要写上 implements Servlet 就自动开始监听网络。还需要把类交给容器部署,并配置 URL 映射。
常见做法是继承已经实现接口的 HttpServlet,重写 doGet()、doPost()。Tomcat 收到 HTTP 请求后,调用 Servlet 的 service(),再由它分发到对应方法。
3.2 读取参数,再返回响应
沿用 HelloServlet。浏览器访问 /hello?id=1 时,用 request 取参数,用 response 返回文字:
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
String id = request.getParameter("id");
System.out.println("前端传来的参数为:" + id);
response.setContentType("text/plain;charset=UTF-8");
// 没有传 id 时 getParameter() 返回 null。
response.getWriter().write("Hello World,id=" + (id == null ? "未提供" : id));
}
@Override
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 普通表单正文需要在读取参数前指定解码字符集。
request.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("调用了 POST 方法,id=" + request.getParameter("id"));
}
}
访问 http://localhost:8080/javaweb001/hello?id=1,页面显示 Hello World,id=1。浏览器地址栏通常发送 GET;要演示 POST,可以用表单:
html
<form action="hello" method="post">
<input name="id" value="1">
<button type="submit">提交编号</button>
</form>
把表单放在应用根目录的 HTML 页面中,action="hello" 就指向同一应用下的 /hello。getParameter() 适用于 URL 查询参数和普通表单参数,不会自动把 JSON 正文解析成对象。
容器管理响应流,通常不需要在 Servlet 中自己关闭 response.getWriter()。设置编码和响应头要在取得 Writer、输出正文之前完成。
3.3 Tomcat 怎样找到并创建 Servlet
init()、service()、destroy() 都是实例方法,所以容器必须先有 Servlet 对象,才能调用它们。
可以顺着这几个问题理解:
- **访问哪个类?**部署时发现
@WebServlet("/hello"),建立路径到 Servlet 的映射;映射也可以写在web.xml中。 - **对象谁来创建?**容器负责。在常见的类名注册方式下,容器加载类并通过无参构造方法创建实例。
- **对象建好后做什么?**先调用
init(),再通过service()处理请求。
用反射可以模拟“根据类名创建对象”这一步。下面调用片段使用上一节的 HelloServlet,放在声明了 throws ReflectiveOperationException 的 main 中:
java
import jakarta.servlet.Servlet;
// 类名来自配置,而不是在这里直接 new HelloServlet()。
Class<?> type = Class.forName("com.java_learning_hub.javaweb001.HelloServlet");
Servlet servlet = (Servlet) type.getDeclaredConstructor().newInstance();
System.out.println(servlet.getClass().getSimpleName()); // HelloServlet
这只是演示构造步骤,不是完整 Tomcat 实现。真正容器还要提供 ServletConfig、管理请求和响应、调度并发调用。自己 new 出来的对象,也不会自动获得这些服务。
3.4 404 和 500 分别说明什么
| 状态码 | 常见原因 | 先检查什么 |
|---|---|---|
| 404 | 应用、Servlet 映射或文件没有找到 | 应用路径、注解路径、部署是否成功 |
| 500 | 服务端处理发生未正确处理的错误 | Tomcat 日志、异常栈、失败的代码位置 |
404 不一定是磁盘上少了文件,也可能是地址写错或 Servlet 没部署。500 也不一定是语法错误;程序已经编译成功,运行时仍可能因为空值、数据库连接等问题失败。
4. Servlet 生命周期与继承体系
4.1 先创建,再初始化,重复处理请求,最后销毁
Servlet 生命周期由容器管理:
text
构造实例 → init() 一次 → service() 多次 → destroy() 一次
这里的“一次”是针对一个正常创建并初始化的实例。应用重新部署会创建新实例;同一类也可能有多个 Servlet 注册,不能把它理解成 Java 单例设计模式。
| 阶段 | 什么时候发生 |
|---|---|
| 构造方法 | 容器创建实例时 |
init() |
创建后、开始服务前 |
service() |
每次请求交给该 Servlet 时 |
destroy() |
实例被移出服务时,例如正常关闭、重新部署 |
默认情况下,Servlet 常在首次请求时初始化。如果设置 loadOnStartup = 1 等非负值,也可以在应用启动时初始化。进程被强行终止时,不能保证 destroy() 执行。
为了观察调用次数,把上一节的 HelloServlet 替换成下面这个版本。两者都映射 /hello,不要同时注册:
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(value = "/hello", loadOnStartup = 1)
public class HelloServlet extends HttpServlet {
public HelloServlet() {
System.out.println("构造 HelloServlet");
}
@Override
public void init() {
System.out.println("初始化 HelloServlet");
}
@Override
protected void service(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
System.out.println("处理一次请求");
// 保留父类的 GET、POST 等分发逻辑。
super.service(request, response);
}
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("Hello World");
}
@Override
public void destroy() {
System.out.println("销毁 HelloServlet");
}
}
应用启动时打印“构造”和“初始化”;访问两次,就打印两次“处理一次请求”;正常停止应用时再打印“销毁”。
容器通常会让多个请求线程同时调用同一个实例。请求参数放在方法里的局部变量中,不要写到 Servlet 的实例字段中,否则不同用户可能互相覆盖数据。
4.2 ServletConfig 和接口中的五个方法
ServletConfig 保存当前 Servlet 的配置信息,可以取得 Servlet 名称、初始化参数和当前应用的 ServletContext。这些方法都有用途,并不是除 service() 外都“没用”。
| Servlet 接口方法 | 作用 |
|---|---|
init(ServletConfig config) |
初始化,并接收容器配置 |
getServletConfig() |
返回当前 Servlet 的配置 |
service(ServletRequest req, ServletResponse res) |
处理请求 |
getServletInfo() |
返回 Servlet 的说明信息 |
destroy() |
销毁前做收尾 |
普通业务代码经常只关心请求处理,所以 Servlet API 提供了父类,帮我们完成通用部分:
text
HelloServlet → HttpServlet → GenericServlet → Servlet 接口
4.3 第一层:MyGenericServlet 实现通用工作
继续按原来的三层方式模拟。MyGenericServlet 保存配置,实现通用方法,把真正处理请求的 service() 留给下一层。
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.Servlet;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import java.io.IOException;
public abstract class MyGenericServlet implements Servlet {
private ServletConfig config;
@Override
public void init(ServletConfig config) throws ServletException {
// 配置由容器传进来,保存后才能通过 getServletConfig() 取到。
this.config = config;
}
@Override
public ServletConfig getServletConfig() {
return config;
}
@Override
public abstract void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException;
@Override
public String getServletInfo() {
return "用于学习的 Servlet";
}
@Override
public void destroy() {
// 当前示例没有需要释放的自建资源。
}
}
实际 GenericServlet 还提供便利方法,例如无参的 init()。这里展示的是分层思路,通用父类本身不配置 URL 映射。
4.4 第二层:MyHttpServlet 按请求方法分发
在 HTTP 容器里,请求和响应对象实现了对应的 HTTP 接口,所以可以转成 HttpServletRequest、HttpServletResponse,读取请求方法再分发。
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public abstract class MyHttpServlet extends MyGenericServlet {
@Override
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String method = request.getMethod();
System.out.println("当前的请求类型是:" + method);
switch (method) {
case "GET" -> doGet(request, response);
case "POST" -> doPost(request, response);
default -> {
response.setHeader("Allow", "GET, POST");
response.sendError(405);
}
}
}
protected abstract void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException;
protected abstract void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException;
}
这个教学版本只分发 GET 和 POST。正式 HttpServlet 还处理 HEAD、OPTIONS 等方法,实际开发直接使用正式类即可。
4.5 第三层:HelloServlet 只写具体处理
再替换 /hello 的实现,这一版继承刚才的 MyHttpServlet:
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/hello")
public class HelloServlet extends MyHttpServlet {
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("调用了 GET 方法");
}
@Override
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException {
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("调用了 POST 方法");
}
}
GET 请求显示“调用了 GET 方法”,POST 显示“调用了 POST 方法”。容器调用的是 service();父类分发后,才调用子类重写的具体方法。
第一层处理通用配置,第二层处理 HTTP 分发,第三层处理具体业务。继承让每层复用前一层的工作,所以最后不必再写完整的五个接口方法。
5. JSP:把页面结构和数据组合起来
5.1 为什么需要 JSP
如果只返回一份固定 HTML,可以直接访问静态页面。但用户列表、订单详情等页面还需要后端查出的数据,这时就要把 HTML 结构和动态数据组合起来。
用 response.getWriter().write() 也能输出 HTML,不过标签一多,就要拼很多字符串,维护起来费劲。JSP(JavaServer Pages)提供页面模板:主体写 HTML,需要动态内容时再取数据。
Tomcat 的 JSP 引擎会把 JSP 转成 Servlet 源码并编译执行,相当于帮我们生成输出页面的代码。生成结果通常可以在 work 目录中观察。浏览器收到的是生成后的 HTML,不会执行 JSP 中的 Java。
5.2 JSP 的基础语法
| 写法 | 用途 |
|---|---|
<%@ page ... %> |
页面指令,例如字符集和内容类型 |
<% Java 代码 %> |
在页面执行时运行 Java 代码,称为脚本片段 |
<%= 表达式 %> |
计算表达式并输出 |
<%-- 注释 --%> |
JSP 注释,不发送给浏览器 |
先沿用“张三、用户编号”的页面,帮助理解 Java 和 HTML 怎样组合。把它作为 index.jsp,放在应用的 Web 根目录(Maven 工程通常是 src/main/webapp)中:
jsp
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>用户信息</title></head>
<body>
<%
String name = "张三";
// id 是 Servlet 放进当前请求的数据。
String id = (String) request.getAttribute("id");
%>
<h1>Hello World!</h1>
<h2>Java Web</h2>
<h3><%= name %></h3>
<h3><%= id %></h3>
</body>
</html>
脚本片段可以说明原理;后面用 EL 和 JSTL 来替代页面里的取值、循环代码,让页面更好读。未经转义的用户输入不能直接拼进 HTML,展示这类内容可以使用后面的 c:out。
5.3 JSP 的九个内置对象
内置对象是 JSP 引擎提供的变量,不需要在脚本片段里再 new 一个。它们并非都在每次请求中重新创建,例如 application 对应整个应用。
| 名称 | 类型或含义 | 用途 |
|---|---|---|
request |
HTTP 场景中是 HttpServletRequest |
请求参数、请求内的数据 |
response |
HTTP 场景中是 HttpServletResponse |
响应状态、响应头等 |
pageContext |
PageContext |
页面上下文,也能访问各作用域 |
session |
HttpSession |
同一会话内的数据;session="false" 时不可用 |
application |
ServletContext |
当前 Web 应用的共享数据 |
config |
ServletConfig |
当前 JSP 对应 Servlet 的配置 |
out |
JspWriter |
向响应输出内容 |
page |
当前 JSP 对应的 Servlet 对象 | 类似当前页面实现的 this |
exception |
Throwable |
只在设置 isErrorPage="true" 的错误页面中可用 |
page 要小写。session 代表会话,并不自动等于一个已登录用户;是否存放用户信息,由应用决定。application 中的数据能被多个用户访问,共享可变数据还要考虑线程安全。
5.4 request:参数、属性和转发
| 方法 | 作用 |
|---|---|
getParameter(String name) |
取浏览器传来的字符串参数,没传时为 null |
setAttribute(String name, Object value) |
在当前请求中保存服务端数据 |
getAttribute(String name) |
取当前请求中保存的数据 |
getRequestDispatcher(String path) |
取得转发器,再调用 forward(request, response) |
参数与属性是两回事:/hello?id=1 给的是参数;setAttribute("id", id) 才把它保存成请求属性,供 JSP 读取。
把 HelloServlet 换成转发版本,仍继承正式的 HttpServlet:
java
package com.java_learning_hub.javaweb001;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
String id = request.getParameter("id");
request.setAttribute("id", id);
// / 从当前应用根目录开始找,不是电脑的磁盘根目录。
request.getRequestDispatcher("/index.jsp").forward(request, response);
}
}
访问 /javaweb001/hello?id=1,JSP 显示张三和编号 1。浏览器地址仍是 /hello?id=1;容器在内部转给 JSP,当前请求的属性也跟着过去。
转发之前不要把响应正文写出并提交,否则可能无法再转发。业务执行后转发到页面,就是“Servlet 准备数据,JSP 展示数据”的基本分工。
5.5 response:重定向为什么取不到 request 数据
sendRedirect() 让浏览器再请求另一个地址;与 forward() 的内部转发不同,它通常产生两次请求。
| 比较 | 转发 forward() |
重定向 sendRedirect() |
|---|---|---|
| 谁继续处理 | 容器内部交给目标资源 | 浏览器按新地址再次请求 |
| request 属性 | 可以继续读取 | 新请求不会自动带上旧属性 |
| 地址栏 | 通常保持原地址 | 变成重定向的地址 |
| 常见用途 | 把本次业务数据交给 JSP | 提交完成后跳到结果页等 |
用刚才的 JSP 对比,只替换 doGet() 方法体:
java
String id = request.getParameter("id");
request.setAttribute("id", id);
// 浏览器重新访问 index.jsp;旧请求里的 id 属性不会过去。
response.sendRedirect(request.getContextPath() + "/index.jsp");
第一次访问传了 id=1,但新请求的 JSP 取不到旧属性,当前脚本表达式会显示 null。这不是数据被删了,而是取数据的请求已经换了。
若确实需要跨请求保留数据,可以使用同一个 Session:
java
String id = request.getParameter("id");
// 容器通过会话标识,把后续请求关联到同一个 Session。
request.getSession().setAttribute("id", id);
response.sendRedirect(request.getContextPath() + "/index.jsp");
JSP 中明确从 Session 取值:
jsp
<h3>${sessionScope.id}</h3>
浏览器通常通过 JSESSIONID Cookie 携带会话标识。会话可能因超时、调用 invalidate() 等失效;关闭浏览器通常影响会话 Cookie,但不能保证服务器上的 Session 立即销毁。“浏览器不关,数据就一直存在”也不成立。
仅传一个普通编号时,也可以通过查询参数传到新地址,再用 ${param.id} 读取。不要把密码等敏感数据放在 URL 中;选择 Session 还是参数,要看数据用途。
5.6 EL:少写取值代码
EL(Expression Language)用于简化页面中的取值和表达式。例如,原来要强转再输出,现在直接写 ${id}。
先存入作用域,再读取:
jsp
<%
// Java 局部变量 name 不会自动成为 EL 能找到的属性。
String name = "张三";
request.setAttribute("name", name);
%>
<h3>${requestScope.name}</h3>
<h3>${requestScope.id}</h3>
对于普通属性名,${id} 按 page → request → session → application 顺序查找;显式写 ${requestScope.id} 就只从 request 中取。找不到时页面输出通常为空,不会打印 Java 的 null 字样。
常用写法:
| 表达式 | 含义 |
|---|---|
${requestScope.id} |
当前请求属性 |
${sessionScope.id} |
当前会话属性 |
${param.id} |
请求参数,不需要先 setAttribute |
${user.name} |
对 JavaBean 通常调用 getName() |
${empty list} |
判断值是否为空或集合是否没有元素 |
${name} 不能直接读取前面脚本里的局部变量 name。对象属性通常依靠 getter 读取,并不是 EL 随意访问私有字段。
6. JSTL:用标签完成页面中的判断、遍历和格式化
6.1 JSTL 是什么,怎样接入
JSTL 是 JSP Standard Tag Library,传统名称是 JSP 标准标签库;在 Jakarta 体系中对应 Jakarta Tags。EL 帮我们取值,JSTL 帮我们遍历、判断、格式化这些值。
例如 Servlet 已经传来一个用户列表,页面要为每个用户生成一行表格。用 JSTL 就不用再把 Java 的 for 循环夹进 HTML。
Tomcat 提供 JSP 引擎,但不会自动给每个应用配上 JSTL。本文使用 Jakarta Tags 3.0,需要在应用中加入 API 和实现:
xml
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
<version>3.0.1</version>
</dependency>
将它们加入前面的 dependencies,使用默认的编译作用域,打包进应用的 WEB-INF/lib。Tomcat 9 要使用旧 javax 体系的依赖,不能混用这一组。
需要什么标签,就在 JSP 顶部声明对应标签库:
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
c、fmt、fn 是前缀;URI 用来标识标签库,不是让页面运行时去下载某个网址。旧例子常见 http://java.sun.com/jsp/jstl/core 等 URI,Tags 3.0 为兼容也支持旧 URI;本篇统一使用新的名称。
6.2 用户列表:先用 Java 循环,再换成 forEach
保留 User 的编号、姓名和年龄。Bean 的 getter 让 EL 能读取属性,setter 供后面的 c:set 修改姓名:
java
package com.java_learning_hub.javaweb001.dot;
public class User {
private final int id;
private String name;
private final int age;
public User(int id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}
public int getId() { return id; }
public String getName() { return name; }
public int getAge() { return age; }
public void setName(String name) { this.name = name; }
}
新增一个 UserServlet,使用 /users,与前面的 /hello 分开。它只准备两条数据并转发:
java
package com.java_learning_hub.javaweb001;
import com.java_learning_hub.javaweb001.dot.User;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@WebServlet("/users")
public class UserServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
List<User> list = List.of(new User(1, "张三", 18), new User(2, "李四", 20));
// JSP 只负责显示,不需要在页面里创建或查询用户列表。
request.setAttribute("list", list);
request.getRequestDispatcher("/users.jsp").forward(request, response);
}
}
先看脚本循环的写法,作为 users.jsp 的一种版本:
jsp
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="java.util.List,com.java_learning_hub.javaweb001.dot.User" %>
<table>
<tr><th>ID</th><th>姓名</th><th>年龄</th></tr>
<% for (User user : (List<User>) request.getAttribute("list")) { %>
<tr>
<td><%= user.getId() %></td>
<td><%= user.getName() %></td>
<td><%= user.getAge() %></td>
</tr>
<% } %>
</table>
这个版本假定先经过 /users,列表已经存在,并且姓名是当前示例里的固定文字。循环开始和结束被 HTML 分开,看起来不太连贯。
把整个页面替换成 JSTL 版本:
jsp
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>用户列表</title></head>
<body>
<h1>这是 users.jsp 页面</h1>
<table>
<tr><th>ID</th><th>姓名</th><th>年龄</th></tr>
<c:forEach items="${requestScope.list}" var="user">
<tr>
<td>${user.id}</td>
<%-- c:out 默认转义 HTML 中的特殊字符。 --%>
<td><c:out value="${user.name}" /></td>
<td>${user.age}</td>
</tr>
</c:forEach>
</table>
</body>
</html>
访问 http://localhost:8080/javaweb001/users,表格中显示 1、张三、18 和 2、李四、20。items 是待遍历列表,var="user" 表示每次取出的对象,${user.name} 读取它的姓名。
JSTL 的判断和遍历用于页面展示;查询数据库、销售等业务逻辑仍放在 Java 代码中。
6.3 通用标签:set、out、remove、catch
set:先存值,再取值
c:set 可以往作用域里保存属性。默认作用域是 page,不是 request;需要 request 时明确写出来:
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="message" value="张三" scope="request" />
<c:out value="${requestScope.message}" />
输出张三。不要把 value="message" 当成取变量,它只是字面量字符串;取值要写 value="${message}"。
c:set 也能修改已有对象的属性。下面沿用前面的 User,在这个单独的 JSP 例子中创建一个对象:
jsp
<%@ page import="com.java_learning_hub.javaweb001.dot.User" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%
request.setAttribute("user", new User(1, "张三", 18));
%>
<c:set target="${requestScope.user}" property="name" value="李四" />
<c:out value="${requestScope.user.name}" />
输出李四。property="name" 对应 setName();展示修改效果时读取姓名,比直接打印整个对象更清楚。
out:取值并输出,缺少值时使用默认文字
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:out value="${requestScope.message}" default="未定义" />
request 中有 message 就输出它,没有就输出“未定义”。c:out 默认转义 <、> 等 HTML 特殊字符,适合在页面正文里显示外部输入;JavaScript、URL 等其他位置还需要对应的处理方式。
remove:删除指定作用域中的属性
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="info" value="Java" scope="request" />
<c:out value="${requestScope.info}" />
<c:remove var="info" scope="request" />
<c:out value="${requestScope.info}" default="已删除" />
依次输出 Java、已删除。写出 scope,可以避免误删其他作用域里同名的数据。
catch:捕获标签体执行时的异常
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:catch var="error">
<%
int divisor = 0;
int result = 10 / divisor; // 运行时抛出 ArithmeticException
%>
</c:catch>
<c:if test="${not empty error}">计算失败</c:if>
页面显示“计算失败”,异常对象存入 page 作用域的 error。表达式是 ${error},不是 %{error}。使用变量作为除数,是为了观察运行时异常;c:catch 不能解决 JSP 编译错误。
页面可以判断是否失败,具体异常细节应记录到服务端日志,不直接展示内部异常栈。
6.4 条件标签:if 和 choose
c:if 在条件成立时输出标签体:
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="num1" value="${1}" />
<c:set var="num2" value="${2}" />
<c:if test="${num1 > num2}">${num1}</c:if>
<c:if test="${num1 < num2}">${num2}</c:if>
这里输出 2。两个条件独立检查;如果值相等,两段都不会输出。
如果只想选一个分支,用 choose → when → otherwise:
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="num1" value="${1}" />
<c:set var="num2" value="${2}" />
<c:choose>
<c:when test="${num1 > num2}">${num1}</c:when>
<c:otherwise>${num2}</c:otherwise>
</c:choose>
也输出 2。多个 when 时,只执行第一个成立的分支;都不成立才走 otherwise。标签名中的冒号要用英文 :。
6.5 迭代标签:forEach
前面的用户表格已经展示了集合遍历。也可以按范围重复,例如输出 1 到 3:
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:forEach begin="1" end="3" var="number">
<p>第 ${number} 个</p>
</c:forEach>
end 包含结束值,所以显示第 1、2、3 个。遍历列表时还可以用 varStatus="status",再用 ${status.count} 获取从 1 开始的序号。
6.6 格式化标签:日期和数字
fmt 把已有数据变成适合页面阅读的文本,不负责查询或计算业务结果。
先在 Servlet 转发前准备日期和数字。日期使用固定输入,便于对照结果:
java
import java.util.Date;
import java.time.Instant;
request.setAttribute("date", Date.from(Instant.parse("2026-10-09T06:05:06Z")));
request.setAttribute("price", new java.math.BigDecimal("1234.5"));
request.setAttribute("rate", new java.math.BigDecimal("0.25"));
日期格式化:
jsp
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:setLocale value="zh_CN" />
<fmt:setTimeZone value="Asia/Shanghai" />
<p><fmt:formatDate value="${date}" /></p>
<p><fmt:formatDate value="${date}" pattern="yyyy年MM月dd日 HH:mm:ss" /></p>
第一行按区域的默认日期格式显示;第二行显示 2026年10月09日 14:05:06。HH 是 24 小时制,hh 是 12 小时制,MM 是月份,mm 是分钟。formatDate 接收 java.util.Date,不能直接把 LocalDateTime 当作 Date 传进去。
数字格式化:
jsp
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:setLocale value="zh_CN" />
<p><fmt:formatNumber value="${price}" pattern="#,##0.00" /></p>
<p><fmt:formatNumber value="${rate}" type="percent" maxFractionDigits="0" /></p>
依次显示 1,234.50、25%。前者固定两位小数并加分组符,后者把 0.25 显示成百分比。这里改变的是显示方式,金额计算与舍入规则仍由业务代码决定。
6.7 函数库:对字符串做小处理
函数库通过 EL 调用。继续使用 Java 字符串,先给出数据:
jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<c:set var="info" value="Java Web" />
<p>${fn:contains(info, "Java")}</p>
<p>${fn:startsWith(info, "JA")}</p>
<p>${fn:endsWith(info, "Web")}</p>
<p>${fn:replace(info, "Java", "C++")}</p>
四行依次输出 true、false、true、C++ Web。函数名是 endsWith,不是 endWith;比较区分大小写,所以 Java 不以 JA 开头。
7. 把整个过程串起来
现在可以顺着一次用户列表请求,把学到的内容连起来:
text
浏览器请求 /javaweb001/users
→ Tomcat 找到 /users 对应的 UserServlet
→ Servlet 准备 List<User>,放入 request
→ forward() 把同一个请求交给 users.jsp
→ JSP 通过 EL 取值,用 JSTL 遍历
→ 生成 HTML,返回浏览器
手写 Socket 例子让我们看到收发的基础;Tomcat 接管网络和容器管理;Servlet 写请求处理;JSP 写页面结构;EL 和 JSTL 把取值、判断、遍历写得更清楚。
如果页面没显示出来,先按范围排查:连接失败看端口和启动日志,404 看路径和映射,500 看服务端异常,中文乱码看请求和响应编码,找不到 JSTL 标签看依赖是否打进 WEB-INF/lib。

评论