Java(10):10.0 JavaWeb核心知识点速查

1. [了解] 网络编程入门

前情提要:JavaWeb作为博主最早期的笔记,最早期杂乱无比…但是JavaWeb在2025年的今天,我认为学习的必要性已经不大了,尽管很多人都知道Spring系列基于tomcat和servlet,可是完全不同的语法更认为如果是优先上手Spring系列会更加合适,所以对于这些不怎样使用的知识,仅作为查询的资源即可

网络编程的核心在于,遵循统一的网络通信协议,使得不同计算机上运行的程序能够进行数据交换。我们日常使用的即时通讯、在线游戏、电子邮件等,都是网络编程在现实世界中的应用。本章,我们将首先了解网络应用的基本架构,并掌握实现网络通信所必需的三大核心要素。

1.1 软件架构:C/S 与 B/S

在探讨网络编程之前,我们先来理解两种主流的软件架构模式。

1. C/S 架构 (Client/Server)
  • 定义:即客户端/服务器架构。在这种模式下,用户需要在自己的设备上安装一个专门的客户端软件(如 QQ、微信、大型网络游戏客户端)才能与服务器进行通信。
  • 优点
    • 性能:部分业务逻辑可以在客户端处理,减轻服务器压力,响应速度快。
    • 体验:客户端可以设计出功能丰富、表现力强的用户界面。
    • 安全:通信协议可以自定义,相对较为安全。
  • 缺点
    • 开发与维护成本高:需要为不同平台(Windows, macOS, Android, iOS)开发和维护多个客户端版本。
    • 更新不便:用户需要手动下载和安装更新。
2. B/S 架构 (Browser/Server)
  • 定义:即浏览器/服务器架构。在这种模式下,用户只需通过标准的浏览器(如 Chrome, Firefox)即可访问服务,无需安装任何额外软件。我们日常访问的大部分网站都采用此架构。
  • 优点
    • 开发与维护成本低:开发者只需关注服务端逻辑和标准的 Web 前端技术。
    • 更新便捷:服务更新仅需部署在服务器端,用户刷新浏览器即可获取最新版本。
    • 跨平台性好:只要有浏览器,就能访问服务。
  • 缺点
    • 体验限制:受限于浏览器的标准化,实现复杂交互和酷炫效果的难度较大。
    • 性能瓶颈:所有业务逻辑均在服务器端处理,对服务器性能要求较高。

小结:无论是 C/S 还是 B/S 架构,其本质都是为了实现客户端与服务器之间的数据通信,而网络编程正是实现这一切的基石。

1.2 网络通信三要素

要想在网络中精确地找到另一台计算机并与之成功通信,必须依赖三个核心要素:IP 地址、端口号和传输协议。它们如同寄快递时的“收货地址”、“收件人”和“运输方式”。

1. IP 地址 - 定位网络中的设备
  • 定义:IP 地址(Internet Protocol Address)是网络中设备的唯一标识,如同每家每户的门牌号,确保数据能够被准确送达。
  • 分类
    • IPv4: 由 32 位二进制数组成,通常表示为 4 个十进制数(如 192.168.1.1)。地址空间约 42 亿,已基本耗尽。
    • IPv6: 为解决 IPv4 地址枯竭问题而生,由 128 位二进制数组成,通常表示为 8 组四位十六进制数。其地址空间巨大,号称“能为地球上每一粒沙子分配一个 IP”。
  • 常用命令
    • ipconfig (Windows) / ifconfig (macOS/Linux): 查看本机 IP 地址。
    • ping [IP地址或域名]: 测试与目标主机的网络连通性。
  • 特殊 IP
    • 127.0.0.1: 回环地址(Loopback Address),永远指向本机。
    • localhost: 主机名,默认也指向本机,通过本地 hosts 文件配置。
2. 端口号 - 定位设备上的应用程序
  • 定义:如果 IP 地址是门牌号,那么端口号(Port)就是房间号。它用于区分同一台设备上不同应用程序的网络通信。
  • 规则
    • 端口号是一个 16 位的整数,范围从 065535
    • 0 ~ 1023: 公认端口,被知名服务(如 HTTP-80, FTP-21, HTTPS-443)占用。
    • 1024 ~ 65535: 动态端口,可供普通应用程序使用。
    • 注意:同一个协议下,一个端口号在同一时间只能被一个应用程序占用。启动程序时若提示“端口被占用”,则说明该端口已被其他进程使用。
3. 传输协议 - 规定数据传输的规则
  • 定义:协议(Protocol)是通信双方必须共同遵守的一套规则,它规定了数据的格式、速率、传输方式等。
特性对比TCP (Transmission Control Protocol)UDP (User Datagram Protocol)
连接性面向连接 (通信前需建立连接,即“三次握手”)无连接 (直接发送数据,无需建立连接)
可靠性可靠 (通过确认、重传、流量控制保证数据不丢、不乱)不可靠 (尽最大努力交付,可能丢包、乱序)
传输效率 (建立连接、保证可靠性带来额外开销) (开销小,传输效率高)
数据形式流式 (数据像水流一样,无边界)数据报 (每个数据包都是独立的,有明确边界)
应用场景文件传输、电子邮件、网页浏览等要求高可靠性的场景视频直播、在线游戏、语音通话等要求高时效性的场景

1.3 [进阶] 深入理解 TCP 协议

TCP 之所以可靠,其精髓在于通信前后的“握手”与“挥手”过程。这个过程不仅确保了通信双方收发能力的正常,也常常是网络面试中的高频考点。

1. TCP 协议的六种核心标识 (Flags)

TCP 在包头中使用 6 个比特位作为标识,来控制连接的不同状态:

  • SYN (Synchronize): 请求建立连接,在三次握手时使用。
  • ACK (Acknowledge): 确认,表示确认收到了对方的数据包。
  • FIN (Finish): 请求断开连接,在四次挥手时使用。
  • PSH (Push): 推送数据,提示接收方应尽快将数据交给应用层,而非等待缓冲区填满。
  • RST (Reset): 重置连接,用于异常中断连接。
  • URG (Urgent): 紧急,表示报文中存在紧急数据,应优先处理。
2. 三次握手 (Three-Way Handshake) - 建立连接

三次握手的过程,我们可以类比于一次通话:

  1. 第一次握手 (Client -> Server):

    • 客户端 发送一个带有 SYN 标志的包,并携带一个随机的初始序列号 seq=x
    • 白话:“喂,服务器,你在吗?我能听到你吗?这是我的起始编号 x。”
    • 状态:客户端进入 SYN_SENT 状态。
  2. 第二次握手 (Server -> Client):

    • 服务器 收到后,回复一个带有 SYNACK 标志的包。
    • 它也选择一个自己的随机初始序列号 seq=y
    • 确认号为 ack=x+1,表示已收到客户端的序列号 x
    • 白话:“我听到了!我也在。这是我的起始编号 y,另外我确认收到了你的编号 x。”
    • 状态:服务器进入 SYN_RCVD 状态。
  3. 第三次握手 (Client -> Server):

    • 客户端 收到服务器的回复后,发送一个带有 ACK 标志的包。
    • 序列号为 seq=x+1
    • 确认号为 ack=y+1,表示已收到服务器的序列号 y
    • 白话:“好的,我也确认收到你的编号 y 了。我们可以开始通信了。”
    • 状态:客户端进入 ESTABLISHED 状态;服务器收到该包后也进入 ESTABLISHED 状态。
3. [高频面试题] 为什么是三次握手,而不是两次或四次?

Q: 为什么 TCP 建立连接需要三次握手,而不是两次?

A: 这是为了防止已失效的连接请求报文突然又传到服务器,从而导致服务器资源浪费。

  1. 场景假设:如果只有两次握手。客户端发送 SYN,服务器回复 SYN+ACK,连接即建立。
  2. 问题出现:客户端发出的第一个 SYN 包因网络延迟,在超时后没到达服务器。于是客户端重发了一个新的 SYN 包,并成功建立连接、传输数据、断开连接。此时,那个迷路的老 SYN才姗姗来迟地到达服务器。
  3. 两次握手的弊端:服务器收到这个过期的 SYN 包后,会误以为是新的连接请求,于是回复 SYN+ACK 并进入连接状态,等待客户端发送数据。但客户端此时并不会响应,因为它认为连接早已结束。这就导致服务器单方面开启了一个连接,白白浪费了资源
  4. 三次握手的优势:在三次握手模型下,服务器收到过期的 SYN 后,会回复 SYN+ACK。但客户端会发现这个 ack 并不是它期望的值,因此会发送 RST 包来重置连接,或者直接忽略。这样服务器就不会傻傻地维持一个无效的连接。

总结:三次握手的核心目的是双方都确认对方的发送和接收能力均正常,并同步双方的初始序列号。

4. SYN 洪水攻击 (SYN Flood)
  • 原理:攻击者伪造大量虚假 IP 地址,向目标服务器疯狂发送 SYN 包。服务器每收到一个 SYN 包,就必须分配资源并回复 SYN+ACK,然后进入 SYN_RCVD 状态等待客户端的第三次握手。由于源 IP 是伪造的,服务器永远等不来这个 ACK
  • 后果:服务器的“半连接队列”被迅速耗尽,导致无法处理正常用户的连接请求,造成服务瘫痪。
  • 状态特征:在服务器上观察到大量处于 SYN_RCVD 状态的连接,就是 SYN 洪水攻击的典型特征。
5. 四次挥手 (Four-Way Handshake) - 断开连接

断开连接比建立连接复杂,因为数据传输是双向的,需要双方都同意关闭。

  1. 第一次挥手 (Client -> Server):

    • 客户端数据发送完毕,向服务器发送一个 FIN 包,请求关闭从客户端到服务器方向的连接。
    • 状态:客户端进入 FIN_WAIT_1
  2. 第二次挥手 (Server -> Client):

    • 服务器收到 FIN 包后,回复一个 ACK 包,表示“你的关闭请求我收到了”。
    • 此时,服务器到客户端方向的数据可能还没发完,所以服务器不能立即关闭。
    • 状态:客户端进入 FIN_WAIT_2,服务器进入 CLOSE_WAIT
  3. 第三次挥手 (Server -> Client):

    • 服务器数据也发送完毕后,向客户端发送一个 FIN 包,请求关闭从服务器到客户端方向的连接。
    • 状态:服务器进入 LAST_ACK
  4. 第四次挥手 (Client -> Server):

    • 客户端收到服务器的 FIN 包后,回复一个 ACK 包,确认关闭。
    • 状态:客户端进入 TIME_WAIT 状态,等待 2MSL(两倍报文最大生存时间)后,才最终关闭。服务器收到 ACK 后直接关闭。
6. [高频面试题] 为什么客户端最后要等待 2MSL?

Q: 在四次挥手中,为什么客户端最后需要进入 TIME_WAIT 状态并等待 2MSL

A: 这主要出于两个原因:

  1. 保证服务器能可靠地关闭连接:客户端发送的最后一个 ACK 包可能会在网络中丢失。如果丢失,处于 LAST_ACK 状态的服务器会因为收不到确认而超时重传 FIN 包。如果客户端不等 2MSL 而是直接关闭,就无法响应服务器重传的 FIN,导致服务器无法正常关闭。TIME_WAIT 状态确保了客户端有足够的时间来响应可能重传的 FIN 包。
  2. 防止“已失效的连接请求报文段”出现在本连接中2MSL 的时间足以让本次连接中产生的所有报文段都从网络中消失。这样,在下一个新的连接中,就不会收到旧连接的、延迟到达的报文,避免了数据混淆。

1.4 Java 网络编程实战

1.4.1 UDP 编程实践

  • 核心类:
    • DatagramSocket: 用于发送和接收 UDP 数据报的“快递站”。
    • DatagramPacket: 用于封装数据的“包裹”,包含了数据本身、目标 IP 和端口。
场景1:UDP - 实现简单的客户端发送与服务端接收

背景:我们需要实现一个最基础的 UDP 通信模型,一个客户端向服务端发送一条消息。

客户端 (发送端) 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.example.udp;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

public class UdpClient {
public static void main(String[] args) throws Exception {
// 1. 创建 DatagramSocket 对象,相当于开一个快递站,系统会分配一个随机可用端口
DatagramSocket socket = new DatagramSocket();

// 2. 准备要发送的数据
String message = "你好,UDP服务器!";
byte[] data = message.getBytes("UTF-8");

// 3. 指定接收端的 IP 和端口
InetAddress serverAddress = InetAddress.getByName("127.0.0.1");
int serverPort = 8888;

// 4. 创建 DatagramPacket 对象,将数据打包
DatagramPacket packet = new DatagramPacket(data, data.length, serverAddress, serverPort);

// 5. 发送数据包
socket.send(packet);
System.out.println("客户端:消息已发送。");

// 6. 释放资源
socket.close();
}
}
// 输出:
// 客户端:消息已发送。

服务端 (接收端) 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.udp;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class UdpServer {
public static void main(String[] args) throws Exception {
// 1. 创建 DatagramSocket 对象,并指定监听的端口号
DatagramSocket socket = new DatagramSocket(8888);
System.out.println("服务端:启动成功,等待数据...");

// 2. 创建一个空的字节数组,用于存放接收到的数据
byte[] buffer = new byte[1024];

// 3. 创建 DatagramPacket 对象,用于接收数据
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

// 4. 接收数据(此方法会阻塞,直到收到数据)
socket.receive(packet);

// 5. 解析数据包
byte[] receivedData = packet.getData();
int length = packet.getLength(); // 获取实际接收到的数据长度
String message = new String(receivedData, 0, length, "UTF-8");

String clientAddress = packet.getAddress().getHostAddress();
int clientPort = packet.getPort();

System.out.println("收到来自 [" + clientAddress + ":" + clientPort + "] 的消息: " + message);

// 6. 释放资源
socket.close();
}
}
// 预期输出:
// 服务端:启动成功,等待数据...
// 收到来自 [127.0.0.1:xxxx] 的消息: 你好,UDP服务器!

小结:UDP 编程非常直接,sendreceive 是核心。值得注意的是,UDP 是无连接的,即使接收端未启动,发送端也不会报错。

1.4.2 TCP 编程实践

  • 核心类:
    • Socket: 客户端套接字,用于发起连接和通信。
    • ServerSocket: 服务端套接字,用于监听客户端连接请求。
    • InputStream/OutputStream: 用于在建立的连接上读写数据。
场景2:TCP - 实现客户端与服务端的双向通信

背景:我们需要构建一个可靠的 TCP 通信,客户端发送消息给服务端,服务端收到后回复一条消息给客户端。

客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.tcp;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class TcpClient {
public static void main(String[] args) throws Exception {
// 1. 创建 Socket 对象,并指定服务端的 IP 和端口号,发起连接请求
Socket socket = new Socket("127.0.0.1", 9999);
System.out.println("客户端:成功连接到服务器。");

// 2. 获取输出流,向服务端发送数据
OutputStream os = socket.getOutputStream();
os.write("Hello, Server!".getBytes("UTF-8"));
socket.shutdownOutput(); // ★★★ 发送结束标记,告诉服务端数据发完了

// 3. 获取输入流,读取服务端响应回来的数据
InputStream is = socket.getInputStream();
byte[] buffer = new byte[1024];
int len = is.read(buffer);
if (len != -1) {
System.out.println("收到服务器响应: " + new String(buffer, 0, len, "UTF-8"));
}

// 4. 关闭资源
is.close();
os.close();
socket.close();
System.out.println("客户端:连接已关闭。");
}
}
// 预期输出:
// 客户端:成功连接到服务器。
// 收到服务器响应: Hello, Client! I have received your message.
// 客户端:连接已关闭。

服务端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.tcp;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class TcpServer {
public static void main(String[] args) throws Exception {
// 1. 创建 ServerSocket 对象,并设置监听的端口号
ServerSocket serverSocket = new ServerSocket(9999);
System.out.println("服务端:启动成功,等待客户端连接...");

// 2. 调用 accept() 方法,等待客户端连接,此方法会阻塞
Socket socket = serverSocket.accept();
System.out.println("服务端:接收到客户端连接 " + socket.getRemoteSocketAddress());

// 3. 获取输入流,用于读取客户端发送过来的数据
InputStream is = socket.getInputStream();
byte[] buffer = new byte[1024];
int len = is.read(buffer);
if (len != -1) {
System.out.println("收到客户端消息: " + new String(buffer, 0, len, "UTF-8"));
}

// 4. 获取输出流,用于给客户端响应数据
OutputStream os = socket.getOutputStream();
os.write("Hello, Client! I have received your message.".getBytes("UTF-8"));

// 5. 关闭资源
os.close();
is.close();
socket.close();
serverSocket.close();
System.out.println("服务端:连接已关闭。");
}
}
// 预期输出:
// 服务端:启动成功,等待客户端连接...
// 服务端:接收到客户端连接 /127.0.0.1:xxxx
// 收到客户端消息: Hello, Server!
// 服务端:连接已关闭。
[避坑指南] socket.shutdownOutput() 的重要性

问题:在 TCP 通信中,如果客户端发送完数据后,紧接着就调用 is.read() 等待服务端响应,程序可能会卡住,为什么?

解答:

  1. TCP 是流式协议:它没有明确的数据边界。服务端在调用 is.read() 时,并不知道客户端的数据是否已经发送完毕。它会一直等待,直到流的末尾(即-1)或连接中断。
  2. shutdownOutput() 的作用:这个方法会关闭套接字的输出流,并向对方发送一个流结束的信号(在 TCP 层面是 FIN 包)。这相当于告诉服务端:“我的数据已经全部发给你了,不会再发了。”
  3. 正确流程:客户端 os.write() -> socket.shutdownOutput() -> 服务端 is.read() 此时就能读到 -1,知道客户端数据已完结 -> 服务端 os.write() -> 客户端 is.read() 接收响应。
  4. 结论:在需要进行半双工通信(即一方说完,另一方再说)的场景下,必须使用 shutdownOutput()shutdownInput() 来显式地告知对方数据传输的阶段性完成。

1.5 [实战] TCP 文件上传

这是 TCP 编程的一个经典应用,也是面试中常考的场景。它综合考察了网络编程和 IO 流的知识。

场景3:客户端上传文件,服务端保存文件

背景:客户端需要将本地的一个图片文件,通过 TCP 连接上传到服务器,服务器接收后保存到指定目录,并给客户端一个“上传成功”的响应。

文件上传客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.fileupload;

import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class FileUploadClient {
public static void main(String[] args) throws Exception {
// 1. 创建 Socket 连接到服务器
Socket socket = new Socket("127.0.0.1", 10086);

// 2. 创建 FileInputStream,用于读取本地图片文件
// 请确保此路径下有文件存在
FileInputStream fis = new FileInputStream("E:\\path\\to\\your\\image.jpg");

// 3. 获取 Socket 的输出流,用于将图片数据发送给服务端
OutputStream os = socket.getOutputStream();

// 4. 边读边写,实现文件传输
byte[] buffer = new byte[1024];
int len;
System.out.println("客户端:开始上传文件...");
while ((len = fis.read(buffer)) != -1) {
os.write(buffer, 0, len);
}

// 5. ★★★ 发送结束标记,告知服务端文件已传完
socket.shutdownOutput();
System.out.println("客户端:文件上传完毕,等待服务器响应...");

// 6. 获取输入流,读取服务端的响应结果
InputStream is = socket.getInputStream();
byte[] responseBuffer = new byte[1024];
int responseLen = is.read(responseBuffer);
System.out.println("服务器响应: " + new String(responseBuffer, 0, responseLen, "UTF-8"));

// 7. 关闭所有资源
is.close();
os.close();
fis.close();
socket.close();
}
}

文件上传服务端 (多线程版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.fileupload;

import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class FileUploadServer {
public static void main(String[] args) throws Exception {
// 1. 创建 ServerSocket
ServerSocket serverSocket = new ServerSocket(10086);
System.out.println("服务端:启动,等待客户端上传文件...");

// 创建一个线程池来处理客户端连接,提高性能
ExecutorService threadPool = Executors.newCachedThreadPool();

while (true) {
// 2. 监听并接受客户端连接
final Socket socket = serverSocket.accept();
System.out.println("服务端:接收到新连接 from " + socket.getRemoteSocketAddress());

// 3. 为每个客户端连接创建一个新的任务并提交到线程池
threadPool.submit(() -> {
try (
// 自动资源管理,简化关闭操作
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream()
) {
// 4. 生成唯一文件名,防止重名覆盖
String fileName = UUID.randomUUID().toString().replaceAll("-", "") + ".jpg";
// 请确保此目录存在
FileOutputStream fos = new FileOutputStream("E:\\path\\to\\upload\\" + fileName);

// 5. 边读边写,将从客户端接收的数据写入文件
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
fos.close(); // 确保文件流写入完成并关闭
System.out.println("服务端:文件 " + fileName + " 保存成功。");

// 6. 向客户端发送响应结果
os.write("上传成功".getBytes("UTF-8"));
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
}
}

小结:在服务端使用**多线程(或线程池)**是处理并发请求的标准实践。UUID.randomUUID()是生成唯一文件名以避免冲突的常用技巧。这个例子完美地展示了如何将网络编程与 IO 操作结合起来解决实际问题。

说明: 关于第三章 Tomcat第四章 Servlet及其后续内容,它们属于 Java Web 开发 的范畴,与底层的网络编程概念(如 TCP/IP)在抽象层次上有所不同。Servlet 容器(如 Tomcat)已经为我们封装好了底层的 TCP 连接、请求解析等复杂工作。我们将另起章节,详细、系统地梳理 Web 开发的核心知识。这里为了保持学习路径的连贯性,我们先完成正则表达式的学习。

2. [核心] 正则表达式

正则表达式(Regular Expression, regex)是一个强大的字符串处理工具。它使用一个具有特殊语法的字符串来描述、匹配一系列符合某个句法规则的字符串。在开发中,它常用于数据校验(如手机号、邮箱格式)、文本查找替换等场景。

2.1 正则表达式入门:校验 QQ 号

  • 需求:校验一个字符串是否是合法的 QQ 号(规则:5-15位,全数字,第一位不为0)。
传统方法 vs. 正则表达式

背景:在没有正则表达式之前,我们需要编写复杂的逻辑代码来完成校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class QQValidator {
public static void main(String[] args) {
System.out.println("传统方法校验 '12345': " + validateQQByLogic("12345"));
System.out.println("传统方法校验 '01234': " + validateQQByLogic("01234"));
System.out.println("传统方法校验 '123a5': " + validateQQByLogic("123a5"));

// 使用 String.matches() 方法进行正则校验
String regex = "[1-9][0-9]{4,14}";
System.out.println("正则方法校验 '12345': " + "12345".matches(regex));
System.out.println("正则方法校验 '10001': " + "10001".matches(regex));
}

// 传统逻辑校验方法
private static boolean validateQQByLogic(String qq) {
if (qq == null) return false;
// 1. 校验长度 5-15 位
if (qq.length() < 5 || qq.length() > 15) {
return false;
}
// 2. 校验第一位不能是 '0'
if (qq.startsWith("0")) {
return false;
}
// 3. 校验必须全是数字
for (int i = 0; i < qq.length(); i++) {
char c = qq.charAt(i);
if (c < '0' || c > '9') {
return false;
}
}
return true;
}
}
// 输出:
// 传统方法校验 '12345': true
// 传统方法校验 '01234': false
// 传统方法校验 '123a5': false
// 正则方法校验 '12345': true
// 正则方法校验 '10001': true

小结:通过对比,我们能直观地感受到正则表达式的威力:用一个简洁的字符串 [1-9][0-9]{4,14} 就替代了一大段复杂的逻辑代码。String 类提供的 matches(String regex) 方法是使用正则表达式进行校验最便捷的方式。

2.2 正则表达式语法详解

2.2.1 字符类 (Character Classes)

字符类用方括号 [] 表示,用于匹配括号内所列举的任意一个字符。

语法描述示例
[abc]匹配 ‘a’、‘b’ 或 ‘c’ 中的任意一个字符。"a".matches("[abc]") -> true
[^abc]否定:匹配除 ‘a’、‘b’、‘c’ 以外的任意一个字符。"d".matches("[^abc]") -> true
[a-z]匹配 ‘a’ 到 ‘z’ 之间的任意一个小写字母。"c".matches("[a-z]") -> true
[a-zA-Z]匹配任意一个大写或小写字母。"B".matches("[a-zA-Z]") -> true
[a-d[m-p]]并集:匹配 ‘a’到’d’ 或 ‘m’到’p’ 之间的字符。"n".matches("[a-d[m-p]]") -> true

2.2.2 预定义字符 (Predefined Characters)

为了简化书写,正则表达式提供了一些预定义的字符类,它们是常用字符类的简写形式。

语法等价于描述
.N/A匹配除换行符外的任意单个字符。
\d[0-9]匹配任意一个数字。
\D[^0-9]匹配任意一个数字字符。
\s[ \t\n\x0B\f\r]匹配任意一个空白字符(空格、制表符、换行符等)。
\S[^\s]匹配任意一个空白字符。
\w[a-zA-Z_0-9]匹配任意一个单词字符(字母、数字、下划线)。
\W[^\w]匹配任意一个单词字符。

注意:在 Java 字符串中,反斜杠 \ 是一个转义字符,所以表示预定义字符时需要再次转义,写成 \\d, \\s 等。

2.2.3 数量词 (Quantifiers)

数量词用于指定前面的一个字符或分组出现的次数。

语法描述
X?X 出现零次或一次。
X*X 出现零次或多次。
X+X 出现一次或多次。
X{n}X 恰好出现 n 次。
X{n,}X 至少出现 n 次。
X{n,m}X 出现 nm 次(包含 n 和 m)。

2.2.4 逻辑与边界 (Logical & Boundary)

语法类型描述
XY逻辑与X 后面紧跟着 Y
X|Y逻辑或匹配 XY
(X)分组X 作为一个整体,可以对这个整体使用数量词。
^边界匹配一行的开头。
$边界匹配一行的结尾。
实战场景:综合运用
  • 校验手机号(规则:1开头,第二位是3/4/5/7/8/9,后跟9位数字)

    • Regex: ^1[3-9]\\d{9}$
    • 解析: ^ 表示开头,1 匹配数字1,[3-9] 匹配3到9中任意一个,\\d{9} 匹配9个数字,$ 表示结尾。
  • 校验邮箱 (一个简单的版本)

    • Regex: ^\\w+@\\w+(\\.\\w+)+$
    • 解析: ^ 开头,\\w+ 匹配一个或多个单词字符(用户名部分),@ 匹配@符号,\\w+ 匹配域名主体,(\\.\\w+)+ 匹配一个或多个由 . 和单词字符组成的顶级域名或子域名,$ 结尾。

2.3 String 类中与正则相关的方法

除了 matches()String 类还有其他几个与正则表达式紧密相关的方法。

方法名功能描述
boolean matches(String regex)**(主力校验)**判断整个字符串是否完全匹配给定的正则表达式。
String[] split(String regex)根据正则表达式匹配到的内容作为分隔符,来拆分字符串。
String replaceAll(String regex, String replacement)将字符串中所有匹配正则表达式的部分替换为指定的字符串。
splitreplaceAll 实战

背景:我们有一段包含多个不规则空格的文本,需要将其规范化:拆分成单词,并将多个空格替换为单个空格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.regex;

import java.util.Arrays;

public class StringRegexMethods {
public static void main(String[] args) {
String text = "Hello world, this is a test.";

// 场景1: 使用一个或多个空格进行分割
// 正则 " +" 表示匹配一个或多个空格
String[] words = text.split(" +");
System.out.println("分割后的单词数组: " + Arrays.toString(words));

// 场景2: 将多个连续的空格替换为单个空格
String normalizedText = text.replaceAll(" +", " ");
System.out.println("规范化后的文本: " + normalizedText);
}
}
// 输出:
// 分割后的单词数组: [Hello, world,, this, is, a, test.]
// 规范化后的文本: Hello world, this is a test.

注意: split 方法的结果中可能会因为分隔符在开头或结尾而产生空字符串,实际使用中需要注意处理。

小结:正则表达式是处理字符串的利器,熟练掌握其语法,能极大地提高开发效率。建议收藏正则表达式的语法表,并在需要时查阅。

  • 在线正则测试工具推荐https://regex101.com/,可以实时测试和调试正则表达式,并有详细的语法解释。

3. [了解] Tomcat:现代Java Web的基石

标签: [核心], [架构], [部署]

在我们深入Servlet编程之前,必须先理解它运行的“家”——Tomcat。简单来说,如果Servlet是我们编写的用于处理Web请求的Java代码,那么Tomcat就是一个强大的“管家”(即Servlet容器),它负责加载、执行和管理这些Servlet的整个生命周期。本章,我们将深入剖C析Tomcat的核心角色、目录结构、部署方式,并揭示其与IDEA等开发工具集成的底层原理,为后续的Web开发打下坚实的基础。

3.1 Tomcat的核心角色:不仅仅是Web服务器

初学者常将Tomcat简单等同于一个Web服务器,但这只说对了一部分。实际上,Tomcat扮演着三个紧密关联但又各不相同的关键角色,这构成了它在现代Java Web技术栈中的核心地位。

1. Servlet容器 - 动态内容的核心

这是Tomcat最核心的职责。它遵循Java Servlet规范(由Jakarta EE定义),为我们编写的Servlet类提供了一个完整的运行时环境。这包括:

  • 生命周期管理: Tomcat负责Servlet实例的创建、调用init()方法进行初始化、在每次请求时调用service()方法,以及在应用关闭时调用destroy()方法进行销毁。
  • 请求/响应封装: Tomcat将底层的HTTP请求报文解析并封装成HttpServletRequest对象,同时创建一个HttpServletResponse对象,然后将它们作为参数传递给我们的Servlet
  • 多线程处理: Tomcat为每个进入的请求分配一个线程来处理,这使得我们的应用能够并发地服务于多个客户端。(这也引出了Servlet的线程安全问题,我们稍后会深入探讨)。
2. JSP引擎 - 动态页面的转换器

Tomcat内置了一个名为Jasper的JSP引擎。当一个对.jsp文件的请求到达时,Jasper会负责将这个JSP文件:

  1. 翻译(Translate) 成一个.java文件(它本质上是一个Servlet)。
  2. 编译(Compile) 成一个.class文件。
  3. 像普通Servlet一样加载并执行它,生成最终的HTML响应。
3. Web服务器 (Web Server) - 静态资源与HTTP处理

这是Tomcat最基础的角色。它能够像Nginx、Apache httpd一样,监听指定的HTTP端口(默认为8080),接收客户端的HTTP请求,并将服务器上的静态资源(如HTML, CSS, JavaScript, 图片文件)返回给客户端。对于动态请求,它则会交给Servlet容器处理。

3.2 Tomcat版本与技术栈选择

选择正确的Tomcat版本至关重要,因为它直接决定了您需要使用的Java版本以及Servlet API的规范。特别是从Tomcat 10开始,Java EE迁移到了Jakarta EE,导致了包名的重大变化,这是2025年开发者必须注意的关键点。

  • 版本兼容性速查表
Tomcat版本Servlet/JSP规范Jakarta EE/Java EE规范最低JDK版本主流应用场景与说明
11.0.xServlet 6.1 / JSP 4.0Jakarta EE 11JDK 17+**(前沿推荐)**面向未来的新项目,享受最新的虚拟线程等特性。
10.1.xServlet 6.0 / JSP 3.1Jakarta EE 10JDK 11+**(当前主流)**广泛用于新项目,完全基于jakarta.*包名。
9.0.xServlet 4.0 / JSP 2.3Java EE 8JDK 8+**(维护/遗留)**大量存量项目仍在使用,基于javax.*包名。
8.5.xServlet 3.1 / JSP 2.3Java EE 7JDK 7+**(老旧项目)**除非维护旧系统,否则不推荐新项目使用。

[避坑指南] Jakarta EE vs. Java EE: javax.*jakarta.* 的包名之变

Q: 为什么我的项目在Tomcat 9上运行正常,迁移到Tomcat 10/11后出现大量ClassNotFoundException,提示找不到javax.servlet.http.HttpServlet等类?

A: 这是因为从Tomcat 10开始,为了摆脱Oracle对Java EE商标的控制,社区将其迁移到了Eclipse基金会旗下,并更名为Jakarta EE。这个过程伴随着一个破坏性的API变更:

  1. 包名变更: 所有的API包名从javax.*全部迁移到了jakarta.*
    • javax.servlet.http.HttpServlet -> jakarta.servlet.http.HttpServlet
    • javax.servlet.annotation.WebServlet -> jakarta.servlet.annotation.WebServlet
    • 等等…
  2. 对开发者的影响:
    • 新项目 (Tomcat 10+): 必须在代码中import jakarta.*包,并在pom.xmlbuild.gradle中引入jakarta.servlet:jakarta.servlet-api等依赖。
    • 旧项目迁移: 如果想将一个基于javax.*的老项目升级到Tomcat 10+,需要将所有源码中的import语句从javax改为jakarta,并更新项目依赖。这是一个有工作量的迁移过程。
  3. 最佳实践:
    • 新项目: 毫不犹豫地选择Tomcat 10.1.x或11.0.x,并从一开始就使用jakarta.* API。
    • 维护旧项目: 如果没有强烈的升级需求,继续在Tomcat 9.0.x上运行是完全可行的,可以避免大规模的代码和依赖修改。

3.3 核心目录结构解析

标签: [核心]

解压Tomcat后,我们会看到一系列的目录,理解它们各自的用途是高效配置和排查问题的基础。

1
2
3
4
5
6
7
8
apache-tomcat-10.1.x/
├── bin/ # 启动、关闭等可执行脚本
├── conf/ # 全局配置文件
├── lib/ # Tomcat服务器自身运行所需的库
├── logs/ # 日志文件
├── temp/ # 临时文件
├── webapps/ # 默认的Web应用部署目录
└── work/ # JSP编译后生成的工作目录
  • 核心目录功能详解
目录名功能描述
bin包含启动 (startup.bat/startup.sh) 和关闭 (shutdown.bat/shutdown.sh) Tomcat的脚本。
conf(极其重要) 存放Tomcat的全局配置文件。server.xml(配置端口、Host、Engine等)、web.xml(全局部署描述符)、tomcat-users.xml(配置管理用户)。
lib存放Tomcat服务器运行所依赖的JAR包,如Servlet API (servlet-api.jar)、JSP API等。注意:不要将应用程序的私有库放在这里。
logs存放Tomcat的运行日志,如catalina.out。当服务器启动失败或运行出错时,这是我们第一个要查看的地方。
webapps默认的应用部署目录。将一个Web应用(打包成.war文件或解压后的目录)放入此目录,Tomcat启动时会自动加载并部署它。
workTomcat将JSP文件转换成的Servlet源码(.java)和编译后的字节码(.class)会存放在这里。当JSP页面出错时,可以来这里查看生成的Servlet代码以帮助调试。

3.4 Web应用的部署方式

我们有两种主要的方式来告诉Tomcat我们的Web应用在哪里。

1. 标准部署 - 直接放入 webapps 目录

这是最简单直接的方式。我们只需将项目打包成一个.war文件,或者将编译好的项目目录直接复制到webapps目录下。

  • Web应用标准结构
    一个标准的Web应用目录结构如下,Tomcat会识别这个结构并正确加载它:

    1
    2
    3
    4
    5
    6
    7
    8
    my-app/
    ├── WEB-INF/ # (核心) 受保护目录,客户端无法直接访问
    │ ├── classes/ # 存放编译后的.class文件
    │ ├── lib/ # 存放项目依赖的JAR包
    │ └── web.xml # (可选) 项目的部署描述符
    ├── index.html # 欢迎页面
    ├── static/ # 存放CSS, JS, 图片等静态资源
    └── ... # 其他页面和资源
2. 虚拟路径部署 - 解耦项目物理位置

在开发中,我们通常不希望每次修改代码后都重新打包复制到webapps目录。更优雅的方式是让Tomcat直接加载我们项目工作区中的Web应用,这就是虚拟路径部署。

场景1:虚拟路径部署 - 在conf目录中配置外部项目

背景:我们的Web项目位于D:\workspace\my-cool-app,我们不希望将它复制到Tomcat的webapps目录,而是想让Tomcat直接运行它,并且通过http://localhost:8080/cool来访问。

操作步骤

  1. 在Tomcat的 conf/Catalina/localhost/ 目录下创建一个XML文件。
  2. 文件名决定了应用的访问路径(上下文路径,Context Path)。例如,我们创建cool.xml
  3. cool.xml中写入以下内容,指向项目的实际物理路径。
1
2
3
4
5
6
7
8
9
<!-- 文件路径: TOMCAT_HOME/conf/Catalina/localhost/cool.xml -->

<!--
Context: 定义一个Web应用
path: 应用的访问路径。由于文件名已是"cool",此处的path属性会被忽略,可以不写。
docBase: 指向你的Web应用在磁盘上的根目录。
reloadable: 设置为true时,当WEB-INF/classes或WEB-INF/lib目录下的文件发生变化时,Tomcat会尝试自动重新加载应用。开发时方便,生产环境建议设为false。
-->
<Context docBase="D:\workspace\my-cool-app" reloadable="true" />

小结:这种方式实现了Tomcat服务器与Web应用项目的物理分离,是专业开发和部署中的首选。它让部署更灵活,也保持了Tomcat目录的整洁。IntelliJ IDEA等IDE正是利用了这一原理来实现项目的热部署

3.5 IDEA与Tomcat集成的工作原理剖析

标签: [源码/底层], [工具]

当我们在IDEA中点击“运行”按钮启动一个Web项目时,背后发生了一系列自动化操作,其核心思想就是我们上面讲的“虚拟路径部署”。

  1. 创建Tomcat副本配置: IDEA不会修改你本地安装的Tomcat。它会在一个用户目录(例如 C:\Users\YourName\AppData\Local\JetBrains\IntelliJIdea2024.1\tomcat\...)下创建一个临时的Tomcat配置基地(CATALINA_BASE)。

  2. 构建Artifact: IDEA使用Maven或Gradle将你的项目编译打包。对于Web应用,它通常会创建一个“exploded war” artifact,这其实就是一个符合标准Web应用结构的文件夹,包含了所有编译后的.class文件、依赖的jar包和静态资源。

  3. 生成Context XML: IDEA会在第1步创建的配置基地中的conf/Catalina/localhost/目录下,自动生成一个.xml文件(例如 ROOT.xmlmy_app_exploded.xml)。这个文件的内容就类似于我们手动创建的:

    1
    <Context docBase=".../target/my-app-1.0-SNAPSHOT" ... />

    这里的docBase精确地指向了第2步中构建好的exploded war目录。

  4. 启动Tomcat: IDEA启动你本地的Tomcat实例,但通过设置环境变量(CATALINA_HOME指向你安装的Tomcat,CATALINA_BASE指向IDEA创建的临时配置目录),让Tomcat加载的是临时的、由IDEA管理的配置。

小结: IDEA通过自动化地创建临时配置文件和构建产物,巧妙地利用了Tomcat的虚拟路径部署机制。这使得我们可以在不污染原始Tomcat安装目录的情况下,实现快速、高效的开发与调试循环。理解这一原理,有助于我们在遇到部署问题时,能准确地找到问题所在。

4. [核心] Servlet:Java Web的动态心脏

在上一章我们了解了Tomcat这个“家”,现在我们要认识家里的“核心成员”——Servlet。如果说静态HTML页面是网站的“骨架”,那么Servlet就是赋予网站生命、使其能够与用户交互、处理业务逻辑的“心脏”。它是一种运行在服务器端的Java程序,专门用于处理客户端的请求并生成动态响应。本章,我们将一起从零开始,编写第一个Servlet,理解其完整的生命周期,掌握其核心API,并剖析那些在面试和实战中至关重要的话题,如线程安全、请求转发与重定向。

4.1 为什么要用Servlet:从静态到动态的飞跃

想象一下,我们去一家蛋糕店。

  • 静态资源:就像是橱窗里已经做好的、固定款式的蛋糕。任何人来买,拿到的都是一模一样的。这对应于我们服务器上的index.html, style.css等文件,它们的内容是固定不变的。
  • 动态资源:你对店员说:“我想要一个8寸的水果蛋糕,上面要写‘生日快乐’”。店员(相当于Servlet)接收到你的请求(Request),然后进入后厨,根据你的要求,现场制作出一个独一无二的蛋糕,并交给你(响应-Response)。

Servlet的诞生,正是为了处理这种“按需定制”的场景。它让我们的Web应用不再是简单的静态文件展示,而是能够根据用户输入、数据库数据等动态信息,实时生成内容,从而实现登录、注册、查询、下单等复杂的业务功能。

4.2 我们的第一个Servlet:注解驱动的现代开发

标签: [实战], [Java 8+], [注解驱动]

在Servlet 3.0(对应Tomcat 7)之后,注解(Annotation)已成为配置Servlet的首选方式,它极大地简化了配置,让代码更内聚。我们将直接从这种现代化的方式入手。

1. UserServlet - 校验用户名是否存在

我们的目标是创建一个Servlet,它接收前端表单提交的username,并判断该用户名是否为"Prorise"。如果是,则响应"NO"(表示已占用),否则响应"YES"(表示可用)。

场景1:@WebServlet - 使用注解快速开发一个Servlet

背景:我们需要一个后端接口来处理用户注册时的实时用户名校验请求。

1. 创建 index.html (前端页面)
这个HTML文件包含一个表单,它将通过POST方法向路径为/checkUser的Servlet发送请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户名校验</title>
</head>
<body>
<h1>实时用户名校验</h1>
<form method="post" action="checkUser">
<label for="username">用户名:</label>
<input type="text" id="username" name="username">
<button type="submit">校验</button>
</form>
</body>
</html>

2. 创建 UserCheckServlet.java (后端Servlet)
这是我们的核心Java代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.servlet;

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.io.PrintWriter;

/**
* @WebServlet 是Servlet 3.0+的核心注解,用于替代传统的web.xml配置。
* "/checkUser" 是此Servlet的访问路径(URL Pattern),与前端form的action相对应。
*/
@WebServlet("/checkUser")
public class UserCheckServlet extends HttpServlet {

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1. 设置请求体编码,防止中文乱码 (对于POST请求至关重要)
req.setCharacterEncoding("UTF-8");

// 2. 从请求(req)中获取名为"username"的参数值
String username = req.getParameter("username");

// 3. 处理核心业务逻辑
String responseMessage;
if ("Prorise".equals(username)) {
// 模拟数据库中已存在该用户名
responseMessage = "<h1>NO</h1>";
} else {
responseMessage = "<h1>YES</h1>";
}

// 4. 设置响应(resp)的内容类型和编码,告知浏览器这是一个HTML,并使用UTF-8解析
resp.setContentType("text/html;charset=UTF-8");

// 5. 获取响应输出流,并将处理结果写入响应体
try (PrintWriter writer = resp.getWriter()) {
writer.write(responseMessage);
} // try-with-resources 语法会自动关闭writer
}
}
// 预期行为:
// - 如果在表单中输入 "Prorise" 并提交, 页面将显示 "NO"。
// - 如果输入其他任何内容, 页面将显示 "YES"。

小结:通过@WebServlet注解,我们将Servlet类和它的访问路径紧密地绑定在了一起,无需任何XML配置,极大地提升了开发效率和代码可读性。HttpServletRequest用于“读”客户端数据,HttpServletResponse用于“写”数据给客户端,这是Servlet工作的基本模式。

2. 传统web.xml配置方式(了解即可)

标签: [遗留/历史]

在Servlet 3.0之前,我们必须在WEB-INF/web.xml文件中进行配置。了解这种方式有助于我们阅读和维护老旧项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- WEB-INF/web.xml -->
<web-app ...>
<!-- 1. 声明一个Servlet,并给它起一个内部名字(servlet-name) -->
<servlet>
<servlet-name>userChecker</servlet-name>
<servlet-class>com.example.servlet.UserCheckServlet</servlet-class>
</servlet>

<!-- 2. 将这个Servlet映射到一个公开的URL路径(url-pattern) -->
<servlet-mapping>
<servlet-name>userChecker</servlet-name>
<url-pattern>/checkUser</url-pattern>
</servlet-mapping>
</web-app>

小结:XML方式将配置与代码分离,但在大型项目中会导致web.xml文件变得臃肿和难以维护。在年的新项目中,我们应始终优先使用注解方式。

4.3 Servlet的生命周期:从诞生到消亡

标签: [核心], [源码/底层]

Servlet的实例不是由我们手动new出来的,而是由Tomcat等Servlet容器来管理其从创建到销毁的整个过程。这个过程被称为生命周期,主要包含四个阶段,对应三个核心方法。

  • Servlet生命周期方法速查表
阶段核心方法触发时机执行次数
1. 实例化构造器 public MyServlet()首次被访问时 或 服务器启动时仅1次
2. 初始化public void init()实例化之后,service调用之前仅1次
3. 服务public void service(...)每当有匹配的请求到达时多次
4. 销毁public void destroy()Web应用卸载 或 服务器关闭时仅1次
Servlet生命周期 实战场景详解

场景1:生命周期方法 - 观察Servlet的创建与销毁过程

背景:为了直观地理解生命周期,我们创建一个Servlet,在它的每个生命周期方法中打印一条日志。我们还通过loadOnStartup=1配置,让它在服务器启动时就立即被创建和初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.lifecycle;

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(urlPatterns = "/lifecycle", loadOnStartup = 1)
* loadOnStartup: 一个正整数,表示Servlet的加载顺序。
* > 0 : 服务器启动时就加载。数字越小,优先级越高。
* <= 0 (或不设置) : 第一次被访问时才加载(懒加载)。
*/
@WebServlet(urlPatterns = "/lifecycle", loadOnStartup = 1)
public class LifecycleServlet extends HttpServlet {

// 阶段1: 实例化 (由容器调用)
public LifecycleServlet() {
System.out.println("1. [实例化]: LifecycleServlet的构造器被调用。");
}

// 阶段2: 初始化 (由容器调用)
@Override
public void init() throws ServletException {
System.out.println("2. [初始化]: init()方法被调用。");
}

// 阶段3: 服务 (由容器调用)
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("3. [服务]: service()方法被调用,处理请求...");
resp.getWriter().write("Lifecycle check completed. See console for logs.");
}

// 阶段4: 销毁 (由容器调用)
@Override
public void destroy() {
System.out.println("4. [销毁]: destroy()方法被调用。");
}
}
// 操作与预期输出:
//
// 1. 启动Tomcat服务器:
// 控制台输出:
// 1. [实例化]: LifecycleServlet的构造器被调用。
// 2. [初始化]: init()方法被调用。
//
// 2. 在浏览器中访问 http://localhost:8080/your-app/lifecycle:
// 控制台输出:
// 3. [服务]: service()方法被调用,处理请求...
//
// 3. 再次访问 http://localhost:8080/your-app/lifecycle:
// 控制台输出:
// 3. [服务]: service()方法被调用,处理请求...
// (注意:构造器和init方法不会再次调用)
//
// 4. 关闭Tomcat服务器:
// 控制台输出:
// 4. [销毁]: destroy()方法被调用。

小结:通过这个实验,我们清晰地看到:Servlet在默认情况下是单例的。容器只创建一个实例,并用它来处理所有后续的请求。这个特性带来了高性能,但也引出了一个至关重要的问题——线程安全。

[高频面试题/避坑指南] Servlet是线程安全的吗?

Q: 请解释一下Servlet的线程安全问题,以及如何解决?

A: Servlet本身的设计不是线程安全的。这是因为Servlet容器(如Tomcat)默认对每个Servlet只创建一个实例(即单例模式),但会为每个进来的请求分配一个新的线程。这些线程会并发地调用同一个Servlet实例的service方法。

  1. 问题根源: 如果我们在Servlet中定义了成员变量(实例变量),并且在service方法中对它进行了修改操作(写操作),那么多个线程同时修改这个共享的成员变量,就会导致数据错乱,即线程安全问题。

  2. 错误示例:

    1
    2
    3
    4
    5
    6
    7
    8
    public class UnsafeServlet extends HttpServlet {
    private int counter = 0; // 共享的成员变量

    protected void service(HttpServletRequest req, HttpServletResponse resp) {
    counter++; // 非原子操作,多线程下会出问题
    // ...
    }
    }
  3. 解决方案/最佳实践:

    • 首选方案:不要使用成员变量。坚持“无状态”原则,将所有需要的数据都作为局部变量service方法内部定义和使用。局部变量存储在每个线程独有的栈空间中,因此是线程安全的。这是最简单、最高效的解决方案。
    • 备选方案:使用ThreadLocal。如果业务场景确实需要在多个方法调用之间共享某个变量,可以使用ThreadLocal。它为每个线程都创建了一个变量的副本,从而避免了线程间的竞争。
    • 下下策:使用synchronized加锁。可以对修改共享变量的代码块或整个service方法加锁。但这会使Servlet变成同步执行,极大地降低并发性能,将多线程处理的优势完全抹杀。在Web开发中应极力避免这种做法

4.4 Servlet的继承体系:为什么我们继承HttpServlet

我们通常不直接实现Servlet接口,而是继承HttpServlet。这是因为Java Web的先驱们已经为我们构建了一个优雅的继承体系,来简化开发。

1
2
3
4
5
6
7
8
9
10
[接口] Servlet

│ (implements)
[抽象类] GenericServlet

│ (extends)
[抽象类] HttpServlet

│ (extends)
[我们自己的类] UserCheckServlet
  1. Servlet 接口: 定义了所有Servlet必须遵守的规范,包含了init(), service(), destroy()等生命周期方法。它非常底层,不关心HTTP协议。

  2. GenericServlet 抽象类:

    • 目的: 提供了Servlet接口的骨干实现。它实现了除service()方法外的所有方法,并提供了一些便利的功能,如获取ServletConfig
    • 开发者职责: 继承它的人只需要专注于实现核心的service()方法即可。
  3. HttpServlet 抽象类:

    • 目的: 这是为HTTP协议量身定做的Servlet。它继承了GenericServlet,并重写了service()方法。
    • 核心逻辑: 在它的service()方法内部,它会判断请求是GET、POST、PUT还是DELETE,然后调用对应的doGet(), doPost(), doPut(), doDelete()方法。
    • 开发者职责: 我们不再需要重写service()方法,而是根据业务需要,选择性地重写doGet()doPost()等方法,使得代码职责更清晰。

结论:我们继承HttpServlet,是因为它已经帮我们处理了HTTP协议的解析和请求分发,让我们可以直接关注于GET请求的业务逻辑或POST请求的业务逻辑,这是一种典型的模板方法设计模式的应用。

5. Servlet核心API:请求、响应与上下文

标签: [核心], [API], [实战]

一个Servlet孤军奋战是无法完成任务的。在它的service方法中,容器会巧妙地传入几个至关重要的对象,它们是Servlet与外部世界沟通的桥梁。本章,我们将逐一解密这些核心API,学习如何通过它们获取配置信息、读取客户端请求、向客户端发送响应,并掌握两种关键的页面跳转技术:请求转发和响应重定向。

5.1 配置与上下文:ServletConfigServletContext

在处理具体请求之前,我们先来了解两个为Servlet提供“环境信息”的对象。

1. ServletConfig - 每个Servlet的专属小管家
  • 是什么: ServletConfig对象由容器创建,每个Servlet实例都有一个自己专属的ServletConfig对象。它就像是分配给这个Servlet的私人助理,负责管理该Servlet的初始化参数
  • 为什么用: 当我们想为某个特定的Servlet配置一些独有的、硬编码在代码中不合适的参数时(例如,指定某个Servlet的默认编码),就可以使用它。
  • 怎么用: 参数可以在@WebServlet注解的initParams属性中配置,然后在Servlet内部通过getServletConfig().getInitParameter("paramName")来获取。
ServletConfig 实战场景详解

场景1:@WebInitParam - 为Servlet配置专属初始化参数

背景:我们有一个EncodingServlet,希望它的编码方式是可配置的,而不是写死在代码里。我们计划通过初始化参数来指定其编码为UTF-8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.config;

import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebInitParam;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

// 使用 @WebInitParam 注解来定义初始化参数
@WebServlet(
urlPatterns = "/config-test",
initParams = {
@WebInitParam(name = "encoding", value = "UTF-8"),
@WebInitParam(name = "author", value = "Prorise")
}
)
public class ConfigServlet extends HttpServlet {
private String defaultEncoding;
private String author;

@Override
public void init(ServletConfig config) throws ServletException {
// 通常在init方法中一次性读取配置,供后续使用
// 虽然可以在service方法中通过getServletConfig()获取,但init()是更合适的时机
super.init(config); // 建议调用父类的init,它会保存config对象
this.defaultEncoding = config.getInitParameter("encoding");
this.author = config.getInitParameter("author");
System.out.println("ConfigServlet初始化完成,编码为: " + defaultEncoding + ", 作者: " + author);
}

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html;charset=" + this.defaultEncoding);
resp.getWriter().write("你好,世界!本Servlet由 " + this.author + " 创建。");
}
}
// 输出:
// 服务器启动时,控制台输出:
// ConfigServlet初始化完成,编码为: UTF-8, 作者: Prorise
//
// 浏览器访问/config-test,页面显示:
// 你好,世界!本Servlet由 Prorise 创建。

小结ServletConfig提供了一种将配置与代码解耦的方式,让Servlet更加灵活和可复用。它的作用域仅限于当前的Servlet实例。

2. ServletContext - 整个Web应用的共享大厅
  • 是什么: ServletContext对象同样由容器创建,但与ServletConfig不同,整个Web应用在运行期间,只有一个ServletContext实例。它代表了当前的Web应用程序本身,是所有Servlet、Filter、Listener共享的全局空间。我们称之为“应用域(Application Scope)”。
  • 为什么用:
    1. 共享数据: 在不同Servlet之间共享数据(例如,网站访问计数器、共享的数据库连接池)。
    2. 获取全局配置: 读取在web.xml中配置的全局初始化参数(<context-param>)。
    3. 获取资源真实路径: 获取Web应用中资源的在服务器上的绝对物理路径。
  • 怎么用: 在Servlet中,可以通过this.getServletContext()getServletConfig().getServletContext()来获取。
ServletContext 实战场景详解

场景1:setAttribute/getAttribute - 在不同Servlet间共享数据

背景:我们想实现一个网站总访问量计数器。CounterServlet负责增加计数并存入ServletContextDisplayServlet负责从ServletContext中读取并显示计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// CounterServlet.java
@WebServlet("/count")
public class CounterServlet extends HttpServlet {
@Override
public void init() throws ServletException {
// 在应用启动时,初始化一个计数器到ServletContext中
ServletContext context = getServletContext();
context.setAttribute("counter", 0);
}

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
ServletContext context = getServletContext();
// 使用synchronized确保线程安全
synchronized (context) {
Integer currentCounter = (Integer) context.getAttribute("counter");
currentCounter++;
context.setAttribute("counter", currentCounter);
resp.getWriter().write("Counter incremented. Current value: " + currentCounter);
}
}
}

// DisplayServlet.java
@WebServlet("/display")
public class DisplayServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
ServletContext context = getServletContext();
Integer currentCounter = (Integer) context.getAttribute("counter");
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<h1>网站总访问量: " + currentCounter + "</h1>");
}
}
场景2:getRealPath - 获取资源的服务器物理路径

背景:我们需要在Servlet中读取位于WEB-INF目录下的一个配置文件db.properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@WebServlet("/get-path")
public class PathServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
ServletContext context = getServletContext();
// 参数是相对于Web应用根目录的路径
String realPath = context.getRealPath("/WEB-INF/db.properties");

// 输出示例: C:\apache-tomcat-10.1.20\webapps\your-app\WEB-INF\db.properties
System.out.println("db.properties的真实物理路径是: " + realPath);

// 接下来就可以使用这个路径来创建FileInputStream等
// try (InputStream input = new FileInputStream(realPath)) { ... }

resp.getWriter().write("Path has been printed to server console.");
}
}

小结ServletContext是Web应用级别的全局“管家”,为跨组件通信和资源定位提供了强大的支持。由于其全局共享性,在进行写操作时必须注意线程安全问题。

5.2 洞察客户端:HttpServletRequest

HttpServletRequest对象封装了客户端发送过来的所有HTTP请求信息。它是Servlet获取输入的主要来源。

  • 常用方法速查表
分类方法名功能描述
请求行getMethod()(主力) 获取请求方法,如 “GET”, “POST”。
getRequestURI()(主力) 获取请求的URI(从域名后到参数前部分),如 /app/user/info
getRequestURL()获取完整的请求URL,如 http://host:port/app/user/info
getContextPath()(主力) 获取应用的上下文路径(部署名),如 /app
请求头getHeader(String name)(主力) 根据头名称获取头信息。
getHeaderNames()获取所有请求头名称的Enumeration
请求参数getParameter(String name)(核心主力) 根据参数名获取参数值。
getParameterValues(String name)(主力) 获取同名参数的所有值(如复选框)。
getParameterMap()获取所有参数的Map<String, String[]>
setCharacterEncoding(String env)(关键) 设置请求体的编码,仅对POST请求有效
[避坑指南] POST请求中文乱码问题

Q: 为什么我用POST方法提交表单,收到的中文字符变成了乱码?

A: 这是Java Web开发中最经典的问题之一。

  1. 根源: HTTP请求体的数据是以字节流的形式发送的。当Tomcat接收到这些字节后,需要知道用什么字符集(如UTF-8, GBK)来将它们解码成字符串。Tomcat的默认字符集可能与你前端页面发送时使用的不一致,导致解码错误,产生乱码。
  2. GET vs POST: GET请求的参数在URL中,其编码由Tomcat的server.xml中的<Connector URIEncoding="...">属性控制(新版Tomcat默认为UTF-8,通常不会出问题)。而POST请求的参数在请求体中,解码方式更为灵活。
  3. 解决方案: 必须在第一次调用getParameter()系列方法之前,显式地告诉HttpServletRequest对象使用哪种编码来解析请求体。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
    // 黄金法则:在读取任何参数前,先设置请求编码
    req.setCharacterEncoding("UTF-8");

    // 现在可以安全地获取参数了
    String username = req.getParameter("username");
    // ...
    }
    为了避免在每个Servlet中重复编写,最佳实践是使用一个Filter来统一处理全站的字符编码。

5.3 构建响应:HttpServletResponse

HttpServletResponse对象用于封装服务器要发送回客户端的所有响应信息。

  • 常用方法速查表
分类方法名功能描述
响应行setStatus(int sc)设置HTTP状态码,如 200 (OK), 404 (Not Found)。
响应头setHeader(String name, String value)设置响应头。
setContentType(String type)(核心主力) 设置Content-Type头,通常包含MIME类型和字符集,如 text/html;charset=UTF-8
响应体getWriter()(主力) 获取字符输出流(PrintWriter),用于输出文本数据。
getOutputStream()(主力) 获取字节输出流(ServletOutputStream),用于输出非文本数据(如图片、文件下载)。
[避坑指南] getWriter()getOutputStream() 的冲突

Q: 我在Servlet中既想输出一些文本,又想下载一张图片,为什么会报错IllegalStateException

A: getWriter()getOutputStream()这两个方法是互斥的。在一个请求的处理周期中,你只能调用其中一个

  1. 原因: getWriter()用于处理字符数据,它有自己的缓冲区和编码处理机制。getOutputStream()用于处理原始的字节数据。两者操作的是同一个底层的响应输出流,如果同时使用,会造成状态混乱。
  2. 规则: 一旦你调用了其中一个,再试图调用另一个就会抛出IllegalStateException
  3. 解决方案: 明确你的Servlet的单一职责。如果它是一个API,返回JSON文本,那就只用getWriter()。如果它是一个文件下载服务,那就只用getOutputStream()。不要试图在一个Servlet中同时做两件性质完全不同的事。

5.4 两种跳转:请求转发 vs. 响应重定向

当一个Servlet处理完部分逻辑后,需要将请求交给另一个Servlet或JSP页面继续处理时,就需要用到页面跳转。

1. 请求转发 (Forward)
  • 是什么: 服务器内部的“交接”。Servlet A将请求对象和响应对象直接转交给Servlet B,整个过程发生在服务器内部,客户端毫不知情。
  • 代码实现: req.getRequestDispatcher("/targetServlet").forward(req, resp);
  • 特点:
    • 一次请求: 浏览器只发送了一次请求。
    • 地址栏不变: 浏览器地址栏仍然显示的是请求Servlet A的地址。
    • 共享数据: request对象被传递下去,因此通过req.setAttribute()存放的数据可以被下一个资源获取。
    • 只能内部跳转: 只能转发到当前应用内部的资源。
2. 响应重定向 (Redirect)
  • 是什么: 服务器告诉客户端:“你去找别人吧”。Servlet A向客户端发送一个特殊的响应(状态码302和一个新的URL),浏览器收到后,会立即向这个新URL发起一个全新的请求。
  • 代码实现: resp.sendRedirect("/app/targetServlet");
  • 特点:
    • 两次请求: 浏览器至少发送了两次请求。
    • 地址栏改变: 浏览器地址栏会更新为重定向后的新地址。
    • 数据不共享: 第二次是全新的请求,所以第一次请求的request对象及其中的数据都丢失了。
    • 可以外部跳转: 可以重定向到任何有效的URL,包括其他网站(如www.google.com)。
[高频面试题] 请求转发和响应重定向的根本区别是什么?

Q: 请详细对比forwardredirect的区别,并说明各自的应用场景。

A:

特性请求转发 (Forward)响应重定向 (Redirect)
行为主体服务器行为,服务器内部资源跳转。客户端行为,服务器建议,浏览器执行。
请求次数1次至少2次
地址栏URL不改变改变为目标地址
数据共享可以。共享同一个request对象。不可以。是两个独立的请求。

| 底层对象 | RequestDispatcher.forward(req, resp) | HttpServletResponse.sendRedirect(url) |

| 跳转范围 | 只能跳转到应用内部的资源。 | 可以跳转到任何URL,包括外部网站。 |
| 性能 | 较高,因为只是服务器内部调用。 | 较低,因为涉及多次网络往返。 |

应用场景:

  • 使用请求转发 (Forward):
    • 当需要在多个Servlet之间传递处理结果,并且这些Servlet共同完成一个业务逻辑时(例如,查询数据 -> 处理数据 -> 显示数据)。
    • 当需要访问WEB-INF下的安全资源时(因为重定向无法直接访问)。
  • 使用响应重定向 (Redirect):
    • 当用户完成一个写操作(如新增、修改、删除)后,为了防止用户刷新页面导致重复提交,通常会重定向到一个查询或展示页面。这被称为PRG (Post-Redirect-Get)模式
    • 当需要跳转到应用外部的另一个网站时。
    • 当需要用户登录后跳转到他们之前想访问的页面时。

6. 会话、过滤器与监听器:Servlet的三大进阶利器

标签: [核心], [进阶], [设计模式]

如果说Servlet是处理请求的“工人”,那么本章介绍的三个组件就是为这些“工人”提供支持的强大“基础设施”。**会话(Session)**解决了HTTP无状态协议的根本难题,让服务器能“记住”用户;**过滤器(Filter)如同应用的“门卫”,能在请求到达Servlet前后执行通用逻辑;而监听器(Listener)**则是应用的“事件播报员”,能在特定事件发生时自动触发相应操作。

6.1 会话管理:让服务器拥有记忆

标签: [核心], [状态管理], [Cookie & Session]

HTTP协议是**无状态(Stateless)**的。这意味着服务器默认不会记录任何关于客户端的信息。你第一次访问和第一百次访问,对服务器来说都是一个全新的请求。这对于需要登录、购物车等功能的应用是致命的。为了解决这个问题,会话管理技术应运而生。

会话管理的核心是CookieSession这对黄金搭档。

  • 是什么: Cookie是服务器发送到客户端浏览器并保存在本地的一小块数据。浏览器在之后对该服务器的每次请求中,都会自动携带上这块数据。
  • 工作流程:
    1. 服务器通过Set-Cookie响应头将Cookie发送给浏览器。resp.addCookie(new Cookie("key", "value"));
    2. 浏览器收到后,将其存储起来。
    3. 之后每次访问该域名,浏览器都会在Cookie请求头中带上之前存储的数据。
    4. 服务器通过req.getCookies()读取这些数据。
  • 特点:
    • 数据存储在客户端,不安全,易被篡改,不应存储敏感信息。
    • 大小和数量都有限制(通常单个4KB,每个域名约50个)。
    • 可以设置过期时间,实现持久化存储。
2. HttpSession - 服务器端的专属档案柜

由于Cookie不安全且容量小,我们不能把用户的敏感信息(如登录状态、购物车详情)直接放在Cookie里。于是,HttpSession登场了。

  • 是什么: Session是服务器为每个独立客户端创建的一块专属内存区域。每个客户端访问时,服务器都能精准地找到属于它的那块内存。

  • 工作流程:

    1. 当客户端第一次调用req.getSession()时,服务器会:
      • 在服务器内存中创建一个HttpSession对象。
      • 生成一个全局唯一的ID,称为Session ID(通常是JSESSIONID)。
      • 将这个JSESSIONID作为一个特殊的Cookie,通过Set-Cookie响应头发送给客户端。
    2. 客户端浏览器收到这个JSESSIONID的Cookie并保存。
    3. 之后每次请求,浏览器都会自动带上这个JSESSIONID的Cookie。
    4. 服务器接收到请求后,从Cookie中提取JSESSIONID,并以此为钥匙,在内存中找到对应的HttpSession对象,从而恢复该用户的会话状态。
  • HttpSession作为域对象HttpSession是一个域对象,我们称之为“会话域(Session Scope)”。它提供了setAttribute, getAttribute, removeAttribute方法,让我们可以在多次请求之间为同一个用户存储和共享数据。

HttpSession 实战场景详解

场景1:用户登录 - 使用Session保持登录状态

背景:我们要实现一个简单的用户登录功能。用户在登录页提交用户名,LoginServlet验证成功后,将用户信息存入Session。之后,用户访问受保护的ProfileServlet时,该Servlet能从Session中识别出用户身份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// LoginServlet.java
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String username = req.getParameter("username");
// 这里应有数据库验证逻辑,我们简化为非空即成功
if (username != null && !username.trim().isEmpty()) {
// 获取或创建Session
HttpSession session = req.getSession();
// 将用户信息存入Session域
session.setAttribute("loggedInUser", username);
System.out.println("用户 '" + username + "' 登录成功, Session ID: " + session.getId());
// 重定向到个人资料页面
resp.sendRedirect("profile");
} else {
resp.getWriter().write("Login failed.");
}
}
}

// ProfileServlet.java
@WebServlet("/profile")
public class ProfileServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession session = req.getSession(false); // 参数为false,如果Session不存在则不创建,返回null

if (session != null && session.getAttribute("loggedInUser") != null) {
String username = (String) session.getAttribute("loggedInUser");
resp.setContentType("text/html;charset=UTF-8");
resp.getWriter().write("<h1>欢迎回来, " + username + "!</h1>");
} else {
// 如果Session中没有用户信息,说明未登录,重定向到登录页
resp.sendRedirect("login.html");
}
}
}

小结:Session机制巧妙地结合了服务器端的存储和客户端Cookie的标识作用,完美解决了HTTP的无状态问题。它是实现用户认证、购物车等功能的技术基石。

6.2 过滤器:应用的优雅“门卫”

标签: [进阶], [设计模式], [AOP]

过滤器(Filter)是一种强大的Web组件,它允许我们在请求到达Servlet之前和响应离开Servlet之后,对请求和响应进行拦截和处理。这是一种典型的责任链(Chain of Responsibility)设计模式和**面向切面编程(AOP)**思想的体现。

  • 核心API: jakarta.servlet.Filter接口

    • init(FilterConfig config): 初始化方法,在Filter实例化后调用一次。
    • destroy(): 销毁方法,在Filter被销毁前调用一次。
    • doFilter(ServletRequest req, ServletResponse resp, FilterChain chain): 核心方法。每次匹配的请求都会调用此方法。
  • 关键代码: chain.doFilter(req, resp);

    • 这行代码是过滤器的“放行”开关。
    • 调用它,请求会继续传递给下一个过滤器或最终的目标Servlet。
    • 不调用它,请求将被在此处中断。
    • 在这行代码之前写的代码,是在请求处理前执行。
    • 在这行代码之后写的代码,是在响应返回前执行。
Filter 实战场景详解

场景1:编码过滤器 - 解决全站乱码问题

背景:与其在每个Servlet的doPost方法中写req.setCharacterEncoding("UTF-8"),不如创建一个过滤器来统一处理所有请求的编码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.filter;

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;

/**
* @WebFilter("/*") 表示此过滤器将拦截所有的请求。
*/
@WebFilter("/*")
public class EncodingFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

// 1. 在请求到达Servlet前,设置请求和响应的编码
request.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");

System.out.println("EncodingFilter: 请求编码已设置。");

// 2. 放行,让请求继续传递
chain.doFilter(request, response);

// 3. 在响应返回浏览器前,可以执行其他操作
System.out.println("EncodingFilter: 响应即将返回。");
}

// init() 和 destroy() 方法在这里可以省略
}
场景2:认证过滤器 - 保护受限资源

背景:我们希望/admin/*路径下的所有资源都必须是登录用户才能访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.filter;

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;

@WebFilter("/admin/*")
public class AuthenticationFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
HttpSession session = request.getSession(false);

boolean isLoggedIn = (session != null && session.getAttribute("loggedInUser") != null);

if (isLoggedIn) {
// 用户已登录,放行
chain.doFilter(request, response);
} else {
// 用户未登录,重定向到登录页面
System.out.println("未授权访问,重定向到登录页。");
response.sendRedirect(request.getContextPath() + "/login.html");
}
}
}

小结:过滤器提供了一种优雅、可插拔的方式来处理横切关注点(如编码、认证、日志、压缩等),让我们的Servlet可以更专注于核心业务逻辑,极大地提高了代码的模块化和可维护性。

6.3 监听器:应用的事件“播报员”

标签: [进阶], [设计模式], [事件驱动]

监听器(Listener)是另一种Web组件,它遵循观察者(Observer)设计模式。我们可以创建监听器来“订阅”Web应用中特定对象的生命周期事件或属性变化事件。当这些事件发生时,容器会自动调用监听器中相应的方法。

监听器主要监听三大域对象:ServletContext, HttpSession, HttpServletRequest

  • 常用监听器接口
监听对象监听事件接口核心方法
ServletContext创建与销毁ServletContextListenercontextInitialized, contextDestroyed
属性变化ServletContextAttributeListenerattributeAdded, attributeRemoved, attributeReplaced
HttpSession创建与销毁HttpSessionListenersessionCreated, sessionDestroyed
属性变化HttpSessionAttributeListenerattributeAdded, attributeRemoved, attributeReplaced
HttpServletRequest创建与销毁ServletRequestListenerrequestInitialized, requestDestroyed
Listener 实战场景详解

场景1:ServletContextListener - 应用启动时初始化资源

背景:我们的应用需要在启动时就初始化一个数据库连接池,并在关闭时优雅地释放它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.listener;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;

// 模拟一个数据库连接池
class MockDataSource {
public MockDataSource() { System.out.println("数据库连接池已创建!"); }
public void close() { System.out.println("数据库连接池已关闭!"); }
}

@WebListener
public class AppLifecycleListener implements ServletContextListener {

/**
* 当Web应用启动,ServletContext对象被创建时调用。
*/
@Override
public void contextInitialized(ServletContextEvent sce) {
System.out.println("Web应用启动中...");
// 1. 创建数据库连接池
MockDataSource dataSource = new MockDataSource();
// 2. 将其存入ServletContext,供所有Servlet使用
ServletContext context = sce.getServletContext();
context.setAttribute("dataSource", dataSource);
System.out.println("应用资源初始化完成。");
}

/**
* 当Web应用关闭,ServletContext对象被销毁前调用。
*/
@Override
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("Web应用关闭中...");
ServletContext context = sce.getServletContext();
MockDataSource dataSource = (MockDataSource) context.getAttribute("dataSource");
if (dataSource != null) {
// 3. 关闭数据库连接池
dataSource.close();
}
System.out.println("应用资源已释放。");
}
}

小结:监听器为我们提供了一个在应用生命周期的关键节点执行代码的钩子。ServletContextListener特别适合用于执行那些需要在应用启动时完成的全局初始化任务,以及在应用关闭时进行的资源清理工作,例如初始化Spring容器、创建数据库连接池、启动定时任务等。

7. 避坑指南:彻底征服乱码与路径问题

标签: [核心], [避坑指南], [实战]

在Web开发的征途上,几乎每个开发者都曾被中文乱码和**资源路径错误(404)**这两个问题困扰过。它们就像是潜伏在代码中的幽灵,时常出现,令人头疼。本章,我们将扮演“捉鬼大师”,系统地解构这两大经典难题。我们将从根源上分析乱码产生的原因,并提供一套从前端到后端、从GET到POST的完整解决方案。同时,我们也会深入探讨相对路径与绝对路径的奥秘,确保您在任何场景下都能精准地引用资源。

7.1 乱码问题的根源与终极解决方案

乱码,本质上只有一个原因:编码与解码时使用了不一致的字符集。就像你用A密码箱加密,却试图用B密码箱的钥匙去解密,结果自然是一堆无法识别的乱码。在Web交互的整个链条中,有多处可能发生这种“密码不匹配”的情况。

[待插入:Web请求与响应中字符集流转图]

我们的目标是:在整个数据流转链路中,始终统一使用UTF-8字符集。UTF-8是国际通用的、可变长的Unicode编码方案,能够表示世界上几乎所有的字符,是现代Web开发的标准选择。

7.1.1 前端层面:HTML与表单

源头控制:确保你的HTML文件自身就是以UTF-8格式保存的。现代IDE(如IntelliJ IDEA)默认就是如此。

告知浏览器:在HTML的<head>标签内,必须包含一个meta标签,明确告知浏览器使用UTF-8来解析此页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>统一编码</title>
</head>
<body>
<!-- 这个页面本身以及提交的表单数据都会被浏览器优先以UTF-8处理 -->
<form action="someServlet" method="post">
<input type="text" name="message" value="你好">
<button type="submit">提交</button>
</form>
</body>
</html>

7.1.2 服务器层面:Tomcat配置

Tomcat 8+的福音:从Tomcat 8开始,对于GET请求参数的URI编码,默认就是UTF-8。这意味着对于GET请求,我们通常无需再对Tomcat进行额外配置。

老旧Tomcat (7及以下):如果还在使用旧版Tomcat,你可能需要在conf/server.xml<Connector>标签中手动添加URIEncoding="UTF-8"。但在****,我们强烈建议升级Tomcat。

7.1.3 Servlet层面:请求与响应处理

这是最关键的环节,我们必须在这里处理好请求数据的解码和响应数据的编码。

1. 请求乱码处理
  • GET请求:如上所述,在现代Tomcat中已无需特殊处理。
  • POST请求:POST请求的数据在请求体中,其编码由前端页面的charset和浏览器行为决定。服务器端必须在读取任何参数之前,主动设置请求体的解码字符集。
2. 响应乱码处理

服务器端向响应体写入数据时,也必须明确告知浏览器两件事:

  1. 响应内容的MIME类型是什么(例如text/html, application/json)。
  2. 应该使用什么字符集来解码这些内容。

这两件事可以通过response.setContentType("text/html;charset=UTF-8")一步到位。

全栈编码过滤器 - 一劳永逸的解决方案

我们已经知道如何处理请求和响应的编码,但如果在每个Servlet中都重复编写这些代码,既繁琐又容易遗漏。最佳实践是使用一个Filter来集中处理全站的编码问题。

背景:创建一个名为CharacterEncodingFilter的过滤器,它将拦截所有请求,并统一设置请求和响应的编码为UTF-8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.filter;

import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* @WebFilter("/*") 拦截所有进入应用的请求。
* 这是解决全站乱码问题的黄金标准实践。
*/
@WebFilter("/*")
public class CharacterEncodingFilter implements Filter {

@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {

HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;

// 1. 统一设置请求体编码为UTF-8 (仅对POST请求有效)
request.setCharacterEncoding("UTF-8");

// 2. 统一设置响应内容类型和编码为UTF-8
// 注意: 这里设置的是一个默认值。
// 如果后续Servlet需要返回JSON或图片,它可以在自己的逻辑中调用`response.setContentType()`来覆盖这个设置。
response.setContentType("text/html;charset=UTF-8");

// 3. 放行请求,让它继续它的旅程
chain.doFilter(request, response);
}

// init() 和 destroy() 在此场景下可以为空
}

小结:有了这个过滤器,我们就可以从繁杂的编码设置中解放出来。任何Servlet都无需再关心乱码问题,只需专注于业务逻辑即可。这是所有Java Web项目都应该具备的基础设施。

7.2 路径问题的迷宫与破解之道

路径错误(通常表现为HTTP 404 - Not Found)是另一个常见痛点。错误的根源在于没能正确理解相对路径绝对路径在Web环境下的真正含义。

1. 相对路径 - 以“当前资源”为参照物
  • 定义: 不以/开头的路径。它会以当前浏览器地址栏中的URL(而不是文件在服务器上的物理位置)为基准来计算目标路径。
  • 特点:
    • 写法: static/style.css, ./image.png, ../user/login
    • 优点: 在简单的目录结构中,书写方便。
    • 致命缺点: 极其不稳定。当你的页面被包含在不同层级的URL下时(例如,直接访问index.html和通过servlet/forward访问),相对路径的计算基准会变化,导致路径解析失败。
2. 绝对路径 - 以“服务器根”为参照物
  • 定义: 以/开头的路径。它会以**服务器的根路径(http://localhost:8080)**为基准来计算目标路径。
  • 写法: /app-context/static/style.css

关键概念:上下文路径 (Context Path)
上下文路径是你的Web应用在Tomcat中的部署名,例如http://localhost:8080/my-cool-app中的/my-cool-app。当使用绝对路径时,必须在路径开头包含这个上下文路径。

  • 问题: 上下文路径在开发、测试、生产环境中可能会变化。硬编码在代码中会导致维护噩梦。
路径问题 最佳实践:动态获取上下文路径

前端解决方案:使用<base>标签或JSP/Thymeleaf表达式

在前端页面中,我们应该避免硬编码上下文路径。

1. <base>标签法(适用于纯静态HTML或简单JSP)

在页面的<head>中定义一个<base>标签,为页面上所有的相对路径提供一个统一的基准URL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<% String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath() + "/"; %>
<!DOCTYPE html>
<html>
<head>
<base href="<%=basePath%>">
<title>路径示例</title>
</head>
<body>
<!-- 这里的相对路径会以basePath为基准 -->
<img src="static/images/logo.png">
<a href="user/profile">个人中心</a>
</body>
</html>

2. 模板引擎法(推荐)

现代Web开发通常使用Thymeleaf等模板引擎,它们提供了内置的、更优雅的方式来处理URL。

1
2
3
<!-- Thymeleaf 模板 -->
<img th:src="@{/static/images/logo.png}">
<a th:href="@{/user/profile}">个人中心</a>

Thymeleaf的@{...}语法会自动在URL前添加正确的上下文路径。

后端解决方案:请求转发与重定向中的路径

1. 请求转发 (RequestDispatcher.forward)

  • 转发的路径是相对于当前应用的上下文的。
  • 因此,绝对路径应该不包含上下文路径,直接以/开头,代表应用的根。
    1
    2
    3
    4
    5
    // 正确:从应用根目录开始查找
    request.getRequestDispatcher("/WEB-INF/views/user/profile.jsp").forward(request, response);

    // 错误:转发路径不应该包含上下文路径
    // request.getRequestDispatcher(request.getContextPath() + "/WEB-INF/...")

2. 响应重定向 (response.sendRedirect)

  • 重定向是让浏览器发起一个新请求,所以URL必须是完整的,能被浏览器识别的路径。
  • 因此,绝对路径必须包含上下文路径。
[高频面试题/避坑指南] 如何在重定向中动态处理上下文路径?

Q: 在sendRedirect时,如何优雅地处理可变的上下文路径,避免硬编码?

A: 硬编码如resp.sendRedirect("/my-cool-app/login");是非常脆弱的。一旦应用部署时上下文路径改变,代码就会失效。

最佳实践是使用request.getContextPath()动态获取。

1
2
3
4
5
6
7
8
9
10
11
// 1. 动态获取上下文路径
String contextPath = request.getContextPath();

// 2. 构建完整的重定向URL
String redirectUrl = contextPath + "/login";

// 3. 执行重定向
response.sendRedirect(redirectUrl);

System.out.println("Redirecting to: " + redirectUrl);
// 输出示例: Redirecting to: /my-cool-app/login

进一步优化:缺省上下文路径

在开发环境中,为了让URL更简洁,我们经常将应用的上下文路径配置为/(根路径)。这可以在IDEA的Tomcat运行配置中设置,或在server.xml<Context>标签中设置path=""

  • 优点: 这样一来,request.getContextPath()会返回一个空字符串"",我们拼接URL时代码依然正确,且浏览器地址栏中没有了冗余的应用名,更加美观。
  • 注意: 即使在开发时使用根路径,代码中也应该保留request.getContextPath()的拼接,以保证代码在任何部署环境下(无论上下文路径是什么)都能正确运行。这体现了代码的健壮性。

本章总结:乱码和路径问题虽然基础,却考验着开发者对Web工作原理的理解深度。我们的策略是:

  • 对于乱码:全链路统一UTF-8,并通过一个编码过滤器一劳永逸地解决。
  • 对于路径:在后端,深刻理解forwardredirect对路径解析的根本不同;在前端,利用模板引擎或base标签等技术动态生成URL,彻底告别硬编码。掌握了这些,才能为构建复杂的Web应用扫清障碍。

8. MVC架构模式:组织Web项目的最佳实践

标签: [核心], [架构], [设计模式]

当我们开始构建一个真正的Web应用时,很快会发现将所有代码——数据库查询、业务逻辑、HTML生成——都塞在一个巨大的Servlet里,会迅速演变成一场维护的噩梦。代码会变得难以阅读、修改和测试。为了解决这个问题,软件工程领域提出了MVC(Model-View-Controller)架构模式,这是一种将软件系统有效分层的思想,旨在实现关注点分离(Separation of Concerns)

8.1 MVC模式的核心思想

MVC模式将一个Web应用划分为三个相互协作但又职责分明的核心部分:

  • Model (模型): 应用的“大脑”与“数据中心”

    • 数据: 包含应用的业务数据和状态。这通常由POJO(Plain Old Java Object)或JavaBean来表示,例如User, Product, Order等实体类。
    • 业务逻辑: 包含处理这些数据的业务规则和操作。例如,如何计算订单总价、如何验证用户密码、如何更新库存等。在Java项目中,这部分逻辑通常封装在Service层DAO(Data Access Object)层
  • View (视图): 应用的“面孔”

    • 职责: 负责呈现数据给用户,并接收用户的输入。它只关心“如何展示”,而不关心数据从何而来,也不包含任何业务逻辑。
    • 实现: 在传统的Java Web中,这部分通常由JSP、Thymeleaf或纯HTML文件来承担。在现代前后端分离架构中,视图层已经演变成一个独立的前端项目(如Vue, React, Angular)。
  • Controller (控制器): 应用的“交通警察”

    • 职责: 作为Model和View之间的协调者。它接收来自用户的请求,决定调用哪个Model的业务逻辑来处理,然后选择一个合适的View来呈现结果。
    • 实现: 在Java Web中,Servlet天生就是扮演Controller角色的最佳选择。

[待插入:MVC经典交互流程图]

MVC交互流程 (经典Web场景):

  1. 用户请求: 用户在浏览器中点击一个链接或提交一个表单,请求发送到服务器。
  2. Controller接收: 一个Servlet(控制器)接收到这个HTTP请求。
  3. Controller调用Model: 控制器解析请求参数,然后调用相应的Service(模型)来执行业务逻辑(如查询数据库)。
  4. Model返回数据: Service层处理完毕后,将结果数据(如一个User对象或一个List<Product>)返回给控制器。
  5. Controller存储数据: 控制器将从Model获取的数据存放到一个可以传递给View的作用域中,通常是请求域(Request Scope),即request.setAttribute("key", data);
  6. Controller选择View: 控制器选择一个合适的JSP或模板文件(视图)来展示这些数据。
  7. Controller转发到View: 控制器通过请求转发 (forward),将请求连同存储在请求域中的数据一起,转交给选定的JSP页面。
  8. View呈现: JSP页面从请求域中取出数据(request.getAttribute("key")),并使用JSP标签或EL表达式将其渲染成最终的HTML。
  9. 服务器响应: 生成的HTML被作为HTTP响应发送回浏览器,用户看到最终的页面。

8.2 从混沌到有序:一个MVC改造实例

标签: [实战], [重构]

让我们通过一个具体的例子,来看看MVC如何将一个混乱的Servlet重构为一个结构清晰的应用。

场景1:意大利面条式代码 - 未使用MVC前的混乱Servlet

背景:我们要实现一个功能——显示所有用户列表。在一个未使用MVC的Servlet中,代码可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// SpaghettiCodeServlet.java - 混乱的示例
@WebServlet("/list-users-bad")
public class SpaghettiCodeServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html;charset=UTF-8");
PrintWriter out = resp.getWriter();

// --- 1. 数据访问逻辑 (本应在DAO层) ---
// 模拟从数据库获取数据
List<String> userNames = new ArrayList<>();
try {
// 假设这里有复杂的JDBC连接、查询、关闭代码
Thread.sleep(10); // 模拟数据库延迟
userNames.add("Prorise");
userNames.add("Alice");
userNames.add("Bob");
} catch (InterruptedException e) {
// ...
}

// --- 2. 业务逻辑 (本应在Service层) ---
// (此例中业务逻辑简单,暂无)

// --- 3. 视图呈现逻辑 (本应在View层) ---
out.println("<html><head><title>用户列表</title></head><body>");
out.println("<h1>用户列表 (糟糕的实现)</h1>");
out.println("<ul>");
for (String name : userNames) {
// 将数据和HTML紧密耦合
out.println("<li>" + name + "</li>");
}
out.println("</ul>");
out.println("</body></html>");
}
}

问题分析:

  • 职责不清: Servlet同时承担了数据访问、业务处理和页面渲染三重角色。
  • 难以维护: 如果要修改HTML样式,需要改动Java代码。如果要更换数据库,也要改动这个Servlet。
  • 难以测试: 无法单独测试数据访问逻辑或视图逻辑。
  • 代码复用性差: 数据库查询逻辑无法被其他Servlet复用。
场景2:MVC模式重构 - 清晰分层的最佳实践

现在,我们用MVC模式来重构这个功能。

1. 创建Model层

  • User.java (POJO - 数据模型)
    1
    2
    3
    4
    5
    package com.example.model;
    public class User {
    private String name;
    // ... getters and setters ...
    }
  • UserDao.java (DAO - 数据访问)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package com.example.dao;
    import com.example.model.User;
    import java.util.List;
    // ...
    public class UserDao {
    public List<User> findAll() {
    // 模拟数据库查询
    // 真实的实现会使用JDBC或MyBatis等
    List<User> users = new ArrayList<>();
    // ...
    return users;
    }
    }
  • UserService.java (Service - 业务逻辑)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package com.example.service;
    import com.example.dao.UserDao;
    import com.example.model.User;
    import java.util.List;

    public class UserService {
    private UserDao userDao = new UserDao();

    public List<User> getAllUsers() {
    // 可能有其他业务逻辑,比如数据转换、权限校验等
    return userDao.findAll();
    }
    }

2. 创建View层

  • userList.jsp (JSP - 视图)
    存放于WEB-INF/views/目录下,以保证它不能被客户端直接访问,只能通过控制器转发。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <%@ page import="java.util.List" %>
    <%@ page import="com.example.model.User" %>
    <!DOCTYPE html>
    <html>
    <head><title>用户列表</title></head>
    <body>
    <h1>用户列表 (MVC实现)</h1>
    <ul>
    <%
    // 从请求域中获取控制器传递过来的数据
    List<User> userList = (List<User>) request.getAttribute("users");
    if (userList != null) {
    for (User user : userList) {
    %>
    <li><%= user.getName() %></li>
    <%
    }
    }
    %>
    </ul>
    </body>
    </html>
    注意: 上述JSP使用了Scriptlet,在现代开发中更推荐使用JSTL和EL表达式来替代,以实现更好的代码分离。

3. 创建Controller层

  • UserListController.java (Servlet - 控制器)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    package com.example.controller;

    import com.example.model.User;
    import com.example.service.UserService;
    // ... imports ...

    @WebServlet("/list-users-good")
    public class UserListController extends HttpServlet {
    private final UserService userService = new UserService();

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    // 1. 调用Model获取数据
    List<User> users = userService.getAllUsers();

    // 2. 将数据存入请求域,准备传递给View
    req.setAttribute("users", users);

    // 3. 通过请求转发,将控制权交给View
    RequestDispatcher dispatcher = req.getRequestDispatcher("/WEB-INF/views/userList.jsp");
    dispatcher.forward(req, resp);
    }
    }

重构后的小结:

  • 职责清晰: UserListController只负责协调,UserService负责业务,UserDao负责数据,userList.jsp负责展示。每个部分都可以独立修改和测试。
  • 高内聚,低耦合: 修改页面样式只需要编辑JSP文件,无需触碰Java代码。更换数据源只需修改DAO实现,控制器和视图不受影响。
  • 可复用性: UserService可以被其他需要用户列表的控制器复用。

8.3 现代Web架构中的MVC演进

标签: [架构], [前后端分离], [API]

传统的MVC模式(如我们刚才演示的JSP模式)中,后端同时负责业务逻辑和视图渲染。而在****,前后端分离已成为主流架构。

在这种演进后的模型中:

  • 后端(Java/Spring MVC) 完全扮演了ModelController的角色,但它的View不再是JSP页面,而是JSON数据。后端通过API接口(如RESTful API)向外提供数据。
  • 前端(Vue/React) 则完全接管了View的角色。它是一个独立的项目,通过HTTP请求调用后端的API获取JSON数据,然后在浏览器端负责将数据动态渲染成用户看到的界面。

这种分离带来了极致的解耦,使得前后端可以并行开发、独立部署,并能更好地适配移动端、桌面端等多种客户端。但其核心思想,仍然源自于经典的MVC模式。理解Servlet时代的MVC,是理解现代框架设计哲学的重要一步。