← 返回首页
Java学习#Java

12.JavaWeb与Servlet、JSP基础

2026-10-09·1 次浏览
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 的界面会随版本和所装插件变化,可以按这几个关键步骤检查:

  1. 创建 Jakarta EE Web Application,或创建 Maven WAR 工程。
  2. 设置项目 JDK,并在支持应用服务器的 IDEA 环境中添加本机 Tomcat 目录。
  3. 在 Tomcat 运行配置的 Deployment 中部署应用,确认 Application context,例如 /javaweb001。
  4. 启动 Tomcat,查看日志,确认端口没有被其他程序占用。
  5. 用浏览器访问“主机 + 端口 + 应用路径 + 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 对象,才能调用它们。

可以顺着这几个问题理解:

  1. **访问哪个类?**部署时发现 @WebServlet("/hello"),建立路径到 Servlet 的映射;映射也可以写在 web.xml 中。
  2. **对象谁来创建?**容器负责。在常见的类名注册方式下,容器加载类并通过无参构造方法创建实例。
  3. **对象建好后做什么?**先调用 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。

8. 官方资料

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000