我平时挺反感一上来就背定义,但 Servlet 这章不一样——只要你想做 Java Web 开发,无论以后用不用框架,它都绕不开。这篇文章不是抄文档,是从“我实际怎么把 Servlet 跑起来并理解它”的角度去写,重点放在 web.xml 方式编写 Servlet 的完整示例,适合正在学 Java Web、刚接触 Tomcat、以及看着 Spring Boot 源码觉得底子不够扎实的同学。代码、配置、排查思路我都会给全,建议你跟着建一个最朴素的项目,亲手动一遍。
1. 为什么现在还要翻开 Servlet 这一页
1.1 Servlet 与 Tomcat 的包含关系
你写一个能处理 HTTP 请求的 Java 程序,会遇到一个麻烦:HTTP 协议本身是文本格式的,你得自己监听端口、解析请求头、拼响应报文。这种重复劳动没有任何技术含量,却占大多数时间。
Tomcat 这类 Web 容器把脏活都干了。它监听 8080 端口,拿到浏览器发来的 HTTP 请求后,解析成 Java 对象,再调用你的业务代码;你只管往响应对象里写内容,剩下的由容器帮你打包成 HTTP 响应发回去。
Servlet 就是“你的业务代码”和“容器的调用规则”之间的约定。
换句话说,Tomcat 不认识你的自定义类,它只认实现了 javax.servlet.Servlet 接口(或继承自 HttpServlet)的类。你写一个类,按规范继承 HttpServlet,在 web.xml 里告诉容器“什么 URL 交给这个类处理”,容器一旦收到匹配的请求,就会自动帮你创建实例并调用对应方法。
这套模型的核心价值在于:你不需要关心网络层,只需要关心业务逻辑。听起来理所当然,但在 Servlet 出现之前,Java 想做 Web 开发需要用 CGI 之类的方案,每个请求起一个进程,性能和开发体验都很差。Servlet 通过“驻留内存 + 多线程处理”的方式,把 Web 开发拉回了普通 Java 编程的轨道。
1.2 框架只是 Servlet 的“壳”
很多人绕开 Servlet 直接学 Spring Boot,觉得“反正现在没人手写 Servlet 了”。这个想法短期能应付增删改查,但一遇到深层问题就露馅。
Spring MVC 的核心入口 DispatcherServlet,本质上就是一个 Servlet。它被定义在 Spring 的 jar 包里,配置文件里注册到 web.xml(或者通过自动配置注册),所有请求先到这个 Servlet,再由它分发到各个 @Controller 方法。
当你明白了这层关系,很多“神奇”的现象就有了解释:
- 为什么过滤器(Filter)会比
@Controller先执行?因为 Filter 是 Servlet 规范里的组件,它在请求到达 DispatcherServlet 之前就拦截了。 - 为什么静态资源要单独配置?因为 DispatcherServlet 默认映射
/,会把静态资源请求也拦截,需要你再配置一个默认 Servlet 放行。 - 为什么启动时要加载一些全局配置?这跟
ServletContextListener的触发时机有关。
我一直跟新人讲,学框架别只背注解,要能回答出“框架底层调用了哪些 Servlet 规范的 API”。把这一章吃透,后面看 Spring、Shiro、Shiro 的 Filter 机制,都会轻松很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:从一个手工 Web 项目开始,跑通第一个 HTTP 周期
2.1 开工前工具搭配与版本
先明确版本,这是很多人掉坑的第一步。
我用的是:
- JDK 8(Servlet 4.0 完全够用;如果你喜欢 JDK 11 或 17 也没问题,但要知道 Tomcat 版本要匹配)
- Tomcat 9.x
- IntelliJ IDEA(社区版和旗舰版都行)
- Maven 3.6+
为什么特别强调 Tomcat 9?因为从 Tomcat 10 开始,Servlet API 的包名从 javax.servlet 换成了 jakarta.servlet。网上大量旧教程、旧代码、旧依赖全是 javax 开头,你用 Tomcat 10 去跑旧代码,经常会遇到 ClassNotFoundException: javax.servlet.http.HttpServlet 这种错误。
初学阶段用 Tomcat 9 最省心,教程和依赖都全面。等完全理解 Servlet 规范之后,再切换到 Tomcat 10/11 了解 jakarta 命名空间,也不迟。
2.2 创建最小项目结构与依赖
我建议不要一开始就依赖 IDEA 的“Java Enterprise”模板,可以先建一个最朴素的 Maven 项目,手动把目录补成 Web 结构,这样每个文件在哪、为什么在那,你心里才有数。
项目最终会长这样:
code复制servlet-chapter
├── pom.xml
├── src
│ └── main
│ ├── java
│ │ └── com
│ │ └── demo
│ │ └── web
│ │ └── HelloServlet.java
│ └── webapp
│ └── WEB-INF
│ └── web.xml
在 pom.xml 里引入 Servlet API 依赖:
xml复制<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
有一点需要说明:scope 设为 provided,意思是编译时需要,但打包进 war 时不要带进去。因为 Tomcat 自己已经有一份 Servlet API 实现,如果你把依赖打进去,反而可能与容器自带的类冲突。
如果你想把项目打包成 war 放到 Tomcat 的 webapps 目录下运行,记得加一个 war 插件配置:
xml复制<build>
<finalName>servlet-chapter</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.3.2</version>
</plugin>
</plugins>
</build>
这里的 finalName 很重要,它决定了解压后的应用目录名,也就是 URL 里的 Context Path。如果打成 servlet-chapter.war,访问路径就是 http://localhost:8080/servlet-chapter/...。
2.3 拿到一份合理的 web.xml 骨架
在 src/main/webapp/WEB-INF/web.xml 创建一个空配置文件。Servlet 4.0 对应的 web.xml 头部是这样:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>servlet-chapter</display-name>
</web-app>
这个骨架平时看起来不起眼,但里面能配置的东西非常多:Servlet 映射、Filter 顺序、监听器、欢迎页、错误页、会话超时时间、全局参数,全都要写在这里。
我遇到过不少同学,拿着一个没有 web.xml 的 Maven Web 项目就开始写注解版 Servlet,虽然能跑,但等到需要配置 Filter 顺序、配置错误页的时候,又不知道怎么兜底。老项目和新项目混杂,web.xml 的配置能力是必须掌握的。
3. 用 web.xml 方式完成示例:从 Servlet 类到浏览器上的一句话
3.1 最小可运行的 Servlet 类
这一步直接对应你在搜索引擎里看到的那个热词:使用 web.xml 方式编写 Servlet 的完整示例。我把代码写全,然后你照着敲一遍。
先在 com.demo.web 包下新建 HelloServlet.java:
java复制package com.demo.web;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class HelloServlet extends HttpServlet {
public HelloServlet() {
System.out.println("HelloServlet 构造方法执行");
}
@Override
public void init() throws ServletException {
System.out.println("HelloServlet init() 执行");
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("<h1>Hello Servlet</h1>");
System.out.println("收到一次 GET 请求");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
@Override
public void destroy() {
System.out.println("HelloServlet destroy() 执行");
}
}
注意几个细节:
- 继承的是
HttpServlet,不是直接实现Servlet接口。HttpServlet已经帮你把service()方法按请求类型分拣好了,你只需重写doGet/doPost这些方法。 - 我在
doPost里直接调用了doGet,这是初学阶段的常见简化写法。真实项目中,GET 和 POST 通常对应不同业务语义,建议还是分开写。 response.setContentType("text/html;charset=UTF-8")这句必须有,它告诉浏览器“我返回的是 HTML,编码是 UTF-8”。少了它,你输出中文很可能变成乱码。- 构造方法、
init、doGet、destroy里我都加了打印语句,是为了让你在控制台清楚地看到 Servlet 的生命周期。这一步建议不要省略,后面讲生命周期时你会回来对照。
3.2 在 web.xml 中完成注册与映射
写完类之后,Servlet 还处于“没人知道它存在”的状态。你要在 web.xml 中注册它,并告诉容器哪个 URL 能访问它。完整的 web.xml 如下:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>servlet-chapter</display-name>
<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.demo.web.HelloServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>HelloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
</web-app>
这里有两组标签:
<servlet>注册 Servlet 实例定义。<servlet-name>是给这个 Servlet 起的逻辑名字,随便起,但建议和类名对应;<servlet-class>必须是完整的类路径(包括包名),不能写错。<servlet-mapping>做 URL 映射。<servlet-name>要和上面定义的名字一致,<url-pattern>是外界访问这个 Servlet 的路径。
为什么非要拆成 <servlet> 和 <servlet-mapping> 两个部分?因为一个 Servlet 类可以被注册成多个名字,每个名字可以映射多个 URL。比如同一个 UserServlet,你可以注册成 adminUser 和 clientUser 两个逻辑名,分别映射到 /admin/user 和 /client/user,两个 URL 各自有一份独立的配置参数,互不干扰。初学者经常忽略这种灵活性的价值,直到项目里需要做“同一种业务逻辑,不同入口不同权限”时才会意识到。
3.3 部署后能观察到什么
用 IDEA 配置 Tomcat 运行,或者把项目打成 war 包放到 Tomcat 的 webapps 目录后启动,打开浏览器访问:
code复制http://localhost:8080/servlet-chapter/hello
如果一切正常,你会看到页面上显示一行 Hello Servlet,而 IDEA 控制台(或 Tomcat 日志)中会依次出现:
code复制HelloServlet 构造方法执行
HelloServlet init() 执行
收到一次 GET 请求
再刷新几次页面,你会发现“构造方法执行”只打印了一次,而“收到一次 GET 请求”会跟着每次访问一起出现。这个现象非常关键,它说明 Servlet 是单实例多线程的,后面展开讲。
如果你访问的是:
code复制http://localhost:8080/servlet-chapter/
会显示 Tomcat 默认的欢迎页或 404,因为你还没配置 index.html 或 index.jsp。只有精确匹配到 /hello 这个路径,才会走进 HelloServlet。
到这里,你已经亲手跑通了一个完整的 HTTP 请求周期:浏览器发请求 -> Tomcat 解析 -> 找到 web.xml 中匹配的 Servlet -> 调用 doGet -> 返回 HTML -> 浏览器渲染。这就是 web.xml 方式编写 Servlet 的完整示例,也是后面一切扩展的地基。
4. 生命周期是 Servlet 最容易理解错的环节
4.1 谁创建了 Servlet 实例
很多同学会把 Servlet 当成普通 Java 类,在 main 方法里 new 出来调用。这是错误的认知:Servlet 对象的创建和销毁完全由 Web 容器(Tomcat)控制,业务代码不该自己 new Servlet。
容器何时创建实例?有两种情况:
- 默认情况下,第一次收到匹配这个 Servlet 的请求时才创建(懒加载)。
- 如果你在
<servlet>标签里配置了<load-on-startup>,Tomcat 启动时就会创建,不等第一个请求。
这种“由外部容器管理对象生命周期”的思想,就是你以后理解 Spring 容器管理 Bean 的基础。Spring 的 Bean 本质上也是“由容器创建、管理、销毁”,只不过 Spring 容器是框架自己实现的容器,而 Servlet 实例的管理者是 Tomcat。
4.2 init、service、destroy 的执行时机
Servlet 接口定义了三个生命周期方法:
init(ServletConfig config):实例创建后,容器立即调用一次,用于初始化资源。service(ServletRequest req, ServletResponse res):每次请求都会调用,用于处理业务。HttpServlet在service内部根据 HTTP 方法再分发到doGet、doPost等方法。destroy():容器卸载应用或关闭前调用,用于释放资源。
你可能会问,为什么初始化不用构造方法,非要单独搞一个 init()?最直接的原因有三个:
第一,Servlet 实例是被反射机制创建的。容器在调用 Class.forName("com.demo.web.HelloServlet").newInstance() 时调用的是无参构造方法,你想靠构造方法传入外部配置非常麻烦。
第二,init() 方法有一个重要的入参 ServletConfig,它能拿到 web.xml 里为这个 Servlet 配置的初始化参数。如果只写在构造方法里,这些配置没法在创建时自动注入。
第三,如果构造方法内部抛异常,容器很难判断是“对象创建失败”还是“初始化失败”。单独定义 init(),容器可以统一处理初始化异常并给出清晰报错。
对照你的控制台日志:
code复制HelloServlet 构造方法执行 -> Tomcat 反射创建对象
HelloServlet init() 执行 -> 容器调用初始化方法
收到一次 GET 请求 -> 请求进来,service 分发到 doGet
当 Tomcat 关闭或应用重新部署时,你会看到 destroy 的日志。
4.3 线程安全问题在 Servlet 场景中的具体样貌
Servlet 是单实例多线程的,这带来了一个开发中必须重视的问题:多个请求会并发进入同一个 Servlet 实例,如果实例里有可变的共享字段,就可能出现线程安全问题。
看一个反面例子:
java复制public class UnsafeServlet extends HttpServlet {
private int count = 0;
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
count++;
response.getWriter().write("count = " + count);
}
}
这个 count 是 Servlet 实例的成员变量。两个用户同时访问时,count++ 不是原子操作,可能发生:请求 A 读到的 count=1,请求 B 也读到的 count=1,各加一次后写回 2,但实际应该加两次变成 3。高并发压测下,这个值一定会乱。
解决思路不是去同步 count,而是尽量不要在 Servlet 中定义可变的成员变量。
把局部变量定义在 doGet 或 doPost 方法内部,每个请求一个栈帧,天然隔离。像数据库连接池、全局配置这种需要共享的资源,也尽量做成只读的,在 init() 中初始化一次,后续只读使用。
这一点特别重要:以后你写 Spring Boot 里的 @Controller,默认也是单例的,同样存在线程安全问题。原理没变。
5. 处理请求与响应:这部分多写代码才不空虚
5.1 从请求中读取表单参数
Servlet 最重要的日常工作是:接收表单数据,处理后返回结果。
假设前端有一个最简单的登录页面 login.html:
html复制<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<form action="login" method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<button type="submit">登录</button>
</form>
</body>
</html>
注意表单的 action="login",这是一个相对路径。如果页面位于 http://localhost:8080/servlet-chapter/login.html,那么表单提交后会访问 http://localhost:8080/servlet-chapter/login。
新建一个 LoginServlet.java:
java复制package com.demo.web;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String username = request.getParameter("username");
String password = request.getParameter("password");
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write("收到登录请求,用户名是:" + username);
}
}
然后在 web.xml 注册:
xml复制<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.demo.web.LoginServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
request.getParameter("username") 会根据表单里的 name 属性取值。如果同一个 name 有多个值,比如复选框多选,需要用 request.getParameterValues("hobby"),返回一个 String[]。
5.2 设置响应并把结果写给浏览器
响应部分,我见过大量新手只写一行 response.getWriter().write(...),对响应头完全没有概念。
setContentType("text/html;charset=UTF-8") 是在给响应设置 Content-Type 头。这个头的作用有两个:一是告诉浏览器返回内容的类型,是 HTML、纯文本还是 JSON;二是告诉浏览器按什么字符集解码。
我经常把这个过程类比成“寄快递时填面单”。你往响应里写数据只相当于往盒子里塞东西,如果你不填 Content-Type,浏览器收到包裹后不知道里面是什么,只能乱猜,猜错就乱码或直接变成下载文件。
如果你要返回的不是 HTML,而是 JSON 数据,需要改一行:
java复制response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\": 200, \"msg\": \"success\"}");
如果你要控制浏览器的缓存行为,还可以设置:
java复制response.setHeader("Cache-Control", "no-store");
这也顺便解释了:为什么很多接口调试工具能看到响应头,因为在 HTTP 层面,响应头和响应体是分开传输的。
5.3 两个必须反复练习的操作:转发与重定向
Servlet 处理完请求后,经常要把控制权交给另一个页面或另一个 Servlet,这就涉及两个非常基础的操作:请求转发和重定向。
请求转发:
java复制request.getRequestDispatcher("/success.jsp").forward(request, response);
重定向:
java复制response.sendRedirect("login.html");
两者最大的差异是:
| 对比项 | 请求转发 forward | 重定向 sendRedirect |
|---|---|---|
| 浏览器地址栏 | 不变,还是原来 Servlet 的地址 | 变为目标地址 |
| 请求次数 | 1 次 | 2 次(第一次返回 302,浏览器再发第二次) |
| 能带 request 数据 | 可以,request 对象是同一个 | 不行,request 对象会重建 |
| 能跳转到外部网站 | 不能 | 可以 |
| 适合场景 | 服务端内部页面跳转、携带数据 | 登录成功后跳转、防止表单重复提交 |
重定向的典型用途是登录成功后的跳转。如果使用 forward,用户刷新页面时,浏览器会再次提交刚才的表单,可能造成重复下单或重复登录;而重定向会让地址栏变成新地址,用户刷新时只请求新页面,不会重复提交。
初学阶段,建议把这两种机制的 HTTP 通信过程抓包看一遍:用浏览器开发者工具访问一个会 sendRedirect 的 Servlet,你会看到第一个请求响应状态码是 302,响应头里有一个 Location 字段,指向下一个地址。理解了这一点,你才算真正理解了重定向的底层原理。
6. 比路由更靠近全局的配置:URL 匹配规则、初始化参数、启动顺序
6.1 四种映射方式和请求优先级
<url-pattern> 不是随便写就能用,Servlet 规范里有几种固定写法:
- 精确匹配:
/hello、/user/detail,完全一致才会命中。 - 目录匹配:
/admin/*,匹配/admin下的所有路径,比如/admin/user/list。 - 扩展名匹配:
*.do,匹配所有以.do结尾的 URL,比如/user.do。 - 默认匹配:
/,匹配所有未被其他 Servlet 处理的请求。Tomcat 自己有一个默认 Servlet 也映射/,用来处理静态资源。
通配符 * 的位置有讲究:放在中间,如 /a/*/b 这种写法是不合法的。要么是 /前缀/*,要么是 *.后缀。
当多个匹配规则同时命中时,Tomcat 按“最长路径匹配优先”的规则选择。比如同时有 /admin/* 和 /admin/user/*,访问 /admin/user/list 时,后者优先,因为它的路径前缀更长。如果路径匹配无法区分,再比较扩展名匹配和精确匹配。实际开发中不建议把规则设计得太绕,能用注解或配置文件集中梳理时,尽量让映射关系一眼能看清。
6.2 配置在 xml 中的参数,代码里如何读取
有时你不希望把某些参数硬编码在 Java 代码里,比如数据库连接地址、密钥、初始化开关。Servlet 规范提供了两级配置参数:
应用级参数写在 web.xml 根部:
xml复制<context-param>
<param-name>appName</param-name>
<param-value>servlet-chapter</param-value>
</context-param>
代码读取:
java复制ServletContext context = getServletContext();
String appName = context.getInitParameter("appName");
Servlet 级参数写在 <servlet> 内部:
xml复制<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.demo.web.HelloServlet</servlet-class>
<init-param>
<param-name>welcome</param-name>
<param-value>你好,Servlet</param-value>
</init-param>
</servlet>
代码读取:
java复制String welcome = getInitParameter("welcome");
这两者的差别在于作用范围:应用级参数整个应用共享,Servlet 级参数只有当前 Servlet 能拿到。你可以把 <context-param> 理解为全局环境变量,把 <init-param> 理解为类自己的属性。
许多遗留系统把数据源配置写在这里。你在 init() 里读取一次,存到成员变量中,后面业务方法直接使用,比每次请求都读文件高效得多。如果你看到别人的代码能在 Servlet 里用 getServletContext() 获取全局参数,来源就在这里。
6.3 load-on-startup 处理初始化任务
默认情况下,Servlet 在第一次被访问时才创建。有些场景不行:比如初始化一个耗时的线程池、连接第三方平台、预热缓存,如果等第一个用户访问时才执行,这个用户就会经历漫长的等待,体验极差。
解决办法是在 <servlet> 标签里配置:
xml复制<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.demo.web.HelloServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
load-on-startup 的值是一个整数,代表启动阶段的加载顺序。数字越小越先加载,正的整数值表示随容器启动加载,不配置则等于“首次访问时才加载”。
这里有一个比较容易误解的地方:load-on-startup 为负数时,表示“不当即加载”,等价于懒加载;0 和正整数都表示容器启动时就加载,Tomcat 会按数值从小到大执行。如果你有两个 Servlet,一个配置为 1,一个配置为 2,那么在 Tomcat 启动阶段,数字小的会先完成 init()。
很多项目会把初始化数据库连接池这种任务放在一个专门 Servlet 的 init() 里,并设置 load-on-startup=1,确保业务请求到达前资源已经就绪。但从规范角度看,更正统的做法是用 ServletContextListener,这就是下一节要讲的 Listener 的用途。
7. 全局逻辑应该交给 Filter 和 Listener,而不是埋在 Servlet 里
7.1 Filter 拦截器链(代码示例:字符集过滤器、登录过滤器)
如果项目里有 20 个 Servlet,每个 Servlet 的 doPost 开头都写一句 request.setCharacterEncoding("UTF-8"),你一定会觉得烦。更合理的方式是把这种“每个请求都需要的公共处理”抽取出来,放在请求进入 Servlet 之前统一执行。Servlet 规范里对应的组件就是 Filter(过滤器)。
Filter 的典型用途:统一字符集、登录权限校验、日志记录、参数清洗、跨域设置。
一个统一 UTF-8 编码的过滤器:
java复制package com.demo.web;
import javax.servlet.*;
import java.io.IOException;
public class EncodingFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
对应 web.xml:
xml复制<filter>
<filter-name>EncodingFilter</filter-name>
<filter-class>com.demo.web.EncodingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>EncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
/* 表示所有请求都经过该过滤器。注意 chain.doFilter(request, response) 这一行必须调用,它表示放行,让请求继续走向目标资源。如果不调用这行,请求会一直被拦截在过滤器中,Servlet 永远收不到。
再举一个登录校验过滤器的例子:
java复制package com.demo.web;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = req.getSession(false);
Object user = session == null ? null : session.getAttribute("user");
if (user == null) {
resp.sendRedirect(req.getContextPath() + "/login.html");
return;
}
chain.doFilter(request, response);
}
}
这是一个理解 Filter 机制很好的例子:先获取当前会话中的用户信息,如果为空就重定向到登录页,并且直接 return,不再调用 chain.doFilter,请求就在这里终止;如果用户已登录,就放行到后续链路。
多个 Filter 同时存在时会形成一个 过滤器链,他们在 web.xml 中的配置顺序即执行顺序。比如先经过字符集过滤器处理中文,再经过登录过滤器校验身份。假设顺序反了,登录过滤器处理时请求参数还是原始字节,可能什么都读不到。
7.2 Listener 实现容器级的开始与结束回调
ServletContextListener 是监听整个 Web 应用生命周期的接口。它不像 Filter 那样拦截请求,而是在应用启动和关闭时各回调一次。这个节点特别适合执行全局资源初始化。
一个简单的启动监听器:
java复制package com.demo.web;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
public class AppStartupListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
System.out.println("应用启动了,开始初始化全局资源");
sce.getServletContext().setAttribute("startTime", System.currentTimeMillis());
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("应用关闭了,开始清理资源");
}
}
web.xml 配置:
xml复制<listener>
<listener-class>com.demo.web.AppStartupListener</listener-class>
</listener>
sce.getServletContext() 返回的是当前应用的 ServletContext,你可以利用它往全局作用域放数据,也可以在应用关闭时关闭数据库连接池、停止后台调度线程。
Listener 与 load-on-startup 的定位差异在于:load-on-startup 只管 Servlet 的初始化顺序,但 Listener 监听的是整个应用上下文事件,语义更清晰。如果某段逻辑和请求处理无关,只是应用启动时要跑一遍,我更推荐用 Listener。
除了 ServletContextListener,Servlet 规范里还有 HttpSessionListener(监听会话创建/销毁)、ServletRequestListener(监听请求创建/销毁)等,都可以在 web.xml 的 <listener> 标签中注册。你可以按需选用,但不需要一开始全记住。把 Listener 存在的意义理解成“应用级事件的回调机制”,遇到场景再查 API 也不迟。
7.3 web.xml 完整配置顺序一次看懂
随着 Filter、Listener、Servlet 都加进来,web.xml 会越来越长。为方便你对照,我整理一份包含常用功能的完整示例:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>servlet-chapter</display-name>
<context-param>
<param-name>appName</param-name>
<param-value>servlet-chapter</param-value>
</context-param>
<listener>
<listener-class>com.demo.web.AppStartupListener</listener-class>
</listener>
<filter>
<filter-name>EncodingFilter</filter-name>
<filter-class>com.demo.web.EncodingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>EncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>LoginFilter</filter-name>
<filter-class>com.demo.web.LoginFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>LoginFilter</filter-name>
<url-pattern>/admin/*</url-pattern>
</filter-mapping>
<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.demo.web.HelloServlet</servlet-class>
<init-param>
<param-name>welcome</param-name>
<param-value>欢迎来到 Servlet 世界</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>HelloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.demo.web.LoginServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
<welcome-file-list>
<welcome-file>login.html</welcome-file>
</welcome-file-list>
</web-app>
这份配置里同时包含:应用级参数、Listener、两个 Filter、两个 Servlet、欢迎页。值得一提的是,<listener> 必须出现在 <filter> 之前吗?严格来说,规范对 web.xml 内部的元素顺序有约束,建议按 context-param -> listener -> filter -> servlet -> welcome-file-list 这个顺序书写。IDE 通常会在启动时报错提示元素顺序问题,照提示调整即可。
8. 初学 Servlet 时最容易撞上的几个问题
8.1 “404”并不是一个笼统的错误
新手看到 404 的第一反应是“页面不存在”,但在 Servlet 环境里,404 的原因往往更具体。我建议你拿到 404 后按下面顺序排查:
- Tomcat 启动了吗? 浏览器访问
http://localhost:8080/,如果能看到 Tomcat 默认首页说明容器正常;如果连接被拒绝,先解决启动问题。 - Context Path 写对了吗? 如果你把 war 包命名为
servlet-chapter.war,部署后的访问路径必定包含/servlet-chapter。很多人只访问了http://localhost:8080/hello,自然会 404。 - web.xml 里映射对了没有? 查看
servlet-mapping的<url-pattern>是不是/hello,以及<servlet-class>是否写成了不存在的类名。 - 请求方式对吗? 如果表单以 POST 提交,而 Servlet 只重写了
doGet,浏览器或应用服务器可能返回 405 或直接显示错误页。刚学时最稳妥的做法是像我在示例中那样,在doPost里调用doGet,先保证能通。 - 应用有没有部署成功? 在 Tomcat 的
webapps目录看有没有对应的应用文件夹,或在 IDEA 的运行面板里看有没有部署报错。
记住:404 是“服务器找不到资源”,但它可能来自 URL 拼写、映射规则、部署目录、请求方式等各种环节。逐一排查比反复重启 Tomcat 有效得多。
8.2 中文乱码问题的根因清单
中文乱码在 Servlet 学习阶段基本是必踩的坑。它并不是一个原因,而是多个环节的编码不一致造成的。
最常见的情况有三类:
第一类:响应输出乱码。 代码里没有设置:
java复制response.setContentType("text/html;charset=UTF-8");
浏览器的默认解析方式不确定,中文字节被按错误的字符集解码。解决办法就是设置 Content-Type。如果你用 response.getWriter() 输出中文,它还会受 response.setCharacterEncoding 影响,设置 ContentType 时最好连编码一起写完整。
第二类:POST 请求参数乱码。 表单以 POST 提交中文时,如果不在读参数之前执行:
java复制request.setCharacterEncoding("UTF-8");
Tomcat 在解析请求体时用的是默认编码 ISO-8859-1,读出来的中文自然乱掉。注意:这句代码必须写在 getParameter() 之前才有效。
第三类:GET 请求参数乱码。 GET 请求的参数放在 URL 里的编码,主要取决于 Tomcat 连接器的 URIEncoding 配置。Tomcat 8 及以上版本默认 URIEncoding="UTF-8",所以较新版本下 GET 中文通常正常。如果你在用老版本 Tomcat,可以在 conf/server.xml 的 <Connector> 上添加 URIEncoding="UTF-8",但这种改法只影响本机运行环境,不推荐,最好还是用和开发环境一致的 Tomcat 版本。
在你写了统一编码过滤器之后,POST 参数乱码的问题会大幅减少,因为所有请求进入 Servlet 前已经设置了编码。前提是 Filter 的 <url-pattern> 覆盖了你的目标路径,并且过滤器链执行顺序里 Filter 在目标 Servlet 之前。
8.3 版本冲突和类加载问题
有时候代码明明没写错,Tomcat 却跟你说找不到类或方法不存在。这时候要检查 Servlet API 版本和 Tomcat 版本是否一致。
重点就是这个变化:Tomcat 9 及以前用 javax.servlet,Tomcat 10 及以后用 jakarta.servlet。
如果你在 Tomcat 10 里部署了一个用 javax.servlet.http.HttpServlet 编译的 war 包,启动时会报 NoClassDefFoundError 或 ClassNotFoundException,因为容器的类加载器里只有 jakarta.servlet 开头的类。
遇到这类报错,先看你的 pom.xml 依赖用的是 javax 还是 jakarta,再看本地 Tomcat 是哪个大版本。如果用的是 Spring Boot 内嵌服务器,还要注意 Spring Boot 2.x 对应 javax,Spring Boot 3.x 对应 jakarta。这个对应关系搞清了,能省下大量浪费在排查上的时间。
另外还有一个特别常见的低级错误:Servlet 类没有继承 HttpServlet,或者类名和文件名不一致。Tomcat 启动时会报“Class ... is not a Servlet”。新手写完一个类,忘了 extends HttpServlet 就去注册,Web 容器加载时无法把它当作 Servlet 处理,自然无法映射 URL。
排查这类问题还有一个通用方法:看日志,别只看浏览器页面。Tomcat 的 logs 目录下会有 catalina.out、localhost.xxx.log 等文件,详细的异常堆栈都在里面。IDEA 里直接看控制台输出也可以。看到带异常类名的第一行,再结合前后几十行上下文,80% 的部署问题都能定位。
我自己带项目的感受是:Servlet 这套东西,代码写得再多,不如先把“请求经过谁、由谁处理、处理后怎么回来”这个链路想明白。你可以在纸上画一遍:浏览器发请求,经过 Filter,到达 Servlet 的 service 方法,按请求类型进入 doGet,业务处理完,通过 Response 对象把内容写回浏览器。能独立把这个过程讲清楚,web.xml 里那些标签的意义你也就真的掌握了。
这篇里给的示例,建议你从第 3 节开始逐个创建并运行,不要复制粘贴。亲手敲一遍 web.xml,你才会注意到 <servlet-name> 要如何对应、<url-pattern> 里的斜杠不能少、类名必须是带包名的全限定名。这些细节看着简单,却正好是真实项目里最容易出问题的地方。等这套 web.xml 方式跑通,之后再去对照注解方式 @WebServlet,你会发现它们只是“配置位置不同”,底层机制完全一样。
