1. 为什么要写web.xml方式配置Servlet:很多项目至今还在用
先回答一个很多人刚入行时的疑问:现在用IDE新建一个Servlet,不是自动生成@WebServlet注解就完事了吗,为什么还要去手写web.xml?
这个问题的答案,我在实际开发里体会太深了。先说结论:除非你确定自己永远只做新项目、只用最新框架,否则web.xml这套配置能力必须掌握。我见过好几个维护了七八年的老系统,控制层清一色是web.xml里写死的Servlet映射,后来接手的人因为只会注解方式,连加一个接口都要折腾半天。
再往深处说,web.xml方式其实承载着Servlet规范里最本质的设计思路:把"请求的URL"和"处理请求的Java类"之间的对应关系,交给容器去管理。 这种声明式配置的好处是,代码里不用硬编码路由,要调整路径映射、要加初始化参数、要控制加载顺序,全部改配置文件就行,Java类本身保持纯净。
另外还有一个很实际的原因:学习阶段用web.xml方式,你对Servlet的生命周期、URL匹配规则、容器加载机制的理解会扎实得多。注解方式把这些细节都藏起来了,出了问题反而不知道从哪里排查。这篇文章我就以Tomcat环境为基准,从零到一写一个完整的、能直接在Tomcat里跑起来的Servlet项目,配置全部走web.xml方式,顺便把里面涉及的关键点都拆开讲明白。
注意,这里的web.xml指的是WEB-INF/web.xml,它是Java Web应用的部署描述符。Servlet容器启动时会读取这个文件,根据里面的配置决定创建哪些Servlet实例、如何映射请求路径、加载哪些监听器或过滤器。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前必须搞懂的Servlet生命周期与映射原理
2.1 Servlet的一生:从加载到销毁
Servlet本质上是一个运行在容器里的Java类,它自己没有main方法,什么时候创建、什么时候销毁,都由容器说了算。整个生命周期围绕三个核心方法展开:init()、service()、destroy()。
流程是这样的:
- 容器启动时(或第一次收到请求时)根据web.xml中的配置,通过反射创建Servlet实例。
- 紧接着调用
init(ServletConfig)方法做初始化,这个方法在整个生命周期里只调用一次。所以那些需要加载一次的配置项、需要建立的数据库连接池,都放在init里做。 - 每当容器接收到匹配当前Servlet映射路径的请求,会调用
service(HttpServletRequest, HttpServletResponse)方法。service内部再根据HTTP方法类型(GET、POST等)分发给doGet或doPost处理。 - 容器关闭或应用重新加载时,调用
destroy()方法,释放资源。
有一个容易让人迷惑的点:Tomcat默认使用的是"第一次请求时才实例化",也就是懒加载模式。但如果你希望容器启动时就完成某个Servlet的初始化,后面会讲到web.xml里的load-on-startup配置。
2.2 URL怎么找到Servlet:匹配规则一定要吃透
Web应用收到请求后,容器拿着请求的URL去和web.xml中所有<servlet-mapping>里的<url-pattern>比对,命中哪个就交给对应的Servlet处理。
URL匹配规则要记牢,这直接决定了你在web.xml里怎么配置:
| 规则类型 | 写法示例 | 匹配说明 |
|---|---|---|
| 精确匹配 | /hello |
只有请求路径完全等于/hello才命中 |
| 路径匹配 | /api/* |
所有以/api/开头的路径都命中,比如/api/user/list |
| 扩展名匹配 | *.do |
所有以.do结尾的URL都命中,比如/user/add.do |
| 默认匹配 | / |
兜底Servlet,匹配所有未被其他映射处理的请求 |
这里要特别注意一个常见的坑:/*和/看起来很像,实际行为天差地别。/*会匹配所有请求,包括JSP页面和对静态资源的请求;而/只匹配那些没有其他映射可走的请求。如果你写了/*,很可能会把容器内部的默认Servlet给拦截掉,导致页面渲染异常。
另外,容器在做匹配时,遵循"最长路径优先"的原则。也就是说,如果同一个URL同时匹配了/api/*和精确路径/api/login,精确路径的优先级更高。了解这个原则,在配置多个Servlet时就不容易打架。
2.3 ServletConfig与ServletContext:两个容易混淆的对象
ServletConfig:当前这个Servlet的配置信息,包括Servlet名称、初始化参数(web.xml里的<init-param>)。每个Servlet都有自己的ServletConfig。ServletContext:整个Web应用的全局上下文,所有Servlet共享同一个实例。可以在里面存全局配置或共享数据,比如读取web.xml里的<context-param>。
区分这两个对象很重要。把"只属于某个Servlet的配置"写在<init-param>里,把"整个应用通用的配置"写在<context-param>里,这样代码的可维护性会好很多。
3. 从零搭建一个web.xml方式Servlet的完整示例
3.1 项目结构规划
这里我以一个简单的用户登录模拟功能为例,展示完整流程。这个例子虽然业务逻辑简单,但把web.xml方式的各个关键配置都串起来了。项目采用标准的Maven目录结构。
code复制servlet-webxml-demo
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── demo
│ └── servlet
│ ├── LoginServlet.java
│ ├── UserListServlet.java
│ └── InitDataListener.java
└── webapp
├── index.jsp
├── login.jsp
├── WEB-INF
│ ├── web.xml
│ └── lib
└── static
├── css
└── js
如果你还在用传统的Eclipse Dynamic Web Project结构,没关系,核心不变——Java源码放src目录,web.xml必须放在webapp/WEB-INF/或WebContent/WEB-INF/下,不能放错位置,否则容器根本找不到这个配置文件。
3.2 编写第一个Servlet类并建立web.xml映射
先写一个最简单的UserListServlet,负责返回用户列表JSON数据。之所以用JSON返回而不是转发到JSP,是想展示Servlet在前后端分离场景下的典型用法。
java复制package com.demo.servlet;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class UserListServlet extends HttpServlet {
// 这里故意用init做一些初始化工作,体会生命周期
private List<Map<String, String>> users;
@Override
public void init() throws ServletException {
// 实际项目中,这里通常是加载配置、初始化连接池等
users = new ArrayList<Map<String, String>>();
Map<String, String> user1 = new HashMap<String, String>();
user1.put("id", "1");
user1.put("name", "张三");
users.add(user1);
Map<String, String> user2 = new HashMap<String, String>();
user2.put("id", "2");
user2.put("name", "李四");
users.add(user2);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置响应类型为JSON
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
StringBuilder json = new StringBuilder();
json.append("[");
for (int i = 0; i < users.size(); i++) {
if (i > 0) {
json.append(",");
}
Map<String, String> user = users.get(i);
json.append("{\"id\":\"").append(user.get("id"))
.append("\",\"name\":\"")
.append(user.get("name")).append("\"}");
}
json.append("]");
out.write(json.toString());
out.flush();
}
}
对应在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_3_1.xsd"
version="3.1">
<display-name>Servlet Webxml Demo</display-name>
<!-- 声明Servlet类 -->
<servlet>
<servlet-name>UserListServlet</servlet-name>
<servlet-class>com.demo.servlet.UserListServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<!-- 配置URL映射 -->
<servlet-mapping>
<servlet-name>UserListServlet</servlet-name>
<url-pattern>/api/user/list</url-pattern>
</servlet-mapping>
</web-app>
这里需要注意<servlet-name>这个标签,它只是一个逻辑名称,不必和类名一致,但必须保证<servlet>和<servlet-mapping>中的servlet-name完全相同,否则容器启动直接报错。我在初学阶段因为这个马虎吃过亏,两个配置里名字差了一个字母,应用怎么都起不来。
配置完成后,启动Tomcat,访问http://localhost:8080/servlet-webxml-demo/api/user/list,浏览器里就会返回一段JSON:[{"id":"1","name":"张三"},{"id":"2","name":"李四"}]。
3.3 处理POST请求与表单提交
数据处理类的接口通常走POST。我加一个LoginServlet来演示POST请求的处理,以及request参数获取和response重定向。
java复制package com.demo.servlet;
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 {
private static final String VALID_USERNAME = "admin";
private static final String VALID_PASSWORD = "123456";
@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");
// 实际项目中这里会去数据库校验,这里用模拟数据代替
if (VALID_USERNAME.equals(username) && VALID_PASSWORD.equals(password)) {
// 登录成功,重定向到用户列表Servlet
response.sendRedirect(request.getContextPath() + "/api/user/list");
} else {
// 登录失败,转发回登录页并携带错误信息
request.setAttribute("errorMsg", "用户名或密码错误");
request.getRequestDispatcher("/login.jsp").forward(request, response);
}
}
}
对应web.xml映射:
xml复制<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.demo.servlet.LoginServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/api/login</url-pattern>
</servlet-mapping>
这里顺便展开讲一个转发(forward)和重定向(redirect)的区别,虽然不属于web.xml配置范畴,但和Servlet的日常使用强相关。转发是服务器内部行为,地址栏不变,request对象可以携带属性到目标页面;重定向是浏览器重新发起一次新的请求,地址栏会变化,两次请求之间的request对象不是同一个,所以带参数通常用URL拼接或session。
3.4 使用init-param注入Servlet初始化参数
有时候我们希望Servlet里的配置项不要写死在代码里,比如数据库地址、文件路径等,这样以后改环境不用重新编译。web.xml里的<init-param>就是为这个场景准备的。
还是拿LoginServlet举例,把校验用的用户名密码放到init-param里:
xml复制<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.demo.servlet.LoginServlet</servlet-class>
<init-param>
<param-name>validUsername</param-name>
<param-value>admin</param-value>
</init-param>
<init-param>
<param-name>validPassword</param-name>
<param-value>123456</param-value>
</init-param>
</servlet>
然后在Servlet中通过getInitParameter读取:
java复制@Override
public void init() throws ServletException {
String username = getInitParameter("validUsername");
String password = getInitParameter("validPassword");
// 校验、赋值等操作
}
这个做法的好处是:环境变更时(比如测试环境密码和线上不同),运维只需要改web.xml,不需要动Java代码。很多老项目的这种配置习惯一直沿用至今,学会这招在接手老系统时非常实用。
3.5 配置全局上下文参数与监听器
Web应用级的全局配置用<context-param>定义,然后通过ServletContext读取。
xml复制<context-param>
<param-name>appName</param-name>
<param-value>Servlet Webxml Demo</param-value>
</context-param>
java复制// 在任意Servlet中获取
String appName = getServletContext().getInitParameter("appName");
有时候还需要在容器启动时执行一些全局初始化逻辑,比如加载缓存、创建线程池。这时可以配合ServletContextListener使用,web.xml里注册监听器:
xml复制<listener>
<listener-class>com.demo.servlet.InitDataListener</listener-class>
</listener>
对应监听器实现:
java复制package com.demo.servlet;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
public class InitDataListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
String appName = sce.getServletContext().getInitParameter("appName");
System.out.println("应用 [" + appName + "] 启动,执行全局初始化...");
// 加载数据字典、启动后台任务等
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
// 应用卸载时释放资源
}
}
4. 配置细节中四个高频"翻车点"
4.1 url-pattern中/与/*的区别
这个我前面提过,但值得单独再强调一次。很多人在配置默认Servlet时手一抖写成/*,结果Tomcat控制台安静得很,但页面访问JSP全部变成下载或空白。原因就是/*拦截了所有请求,包括容器用来处理JSP的JSPServlet和默认的静态资源Servlet。
如果你确实需要配置一个兜底Servlet处理所有未被匹配的请求,应该用/。这个坑排查起来耗时且让人抓狂,我见过有人在Stack Overflow上翻了半天答案才反应过来。
4.2 servlet-class类名拼写和包路径问题
<servlet-class>标签里写的必须是类的全限定名,也就是"包名+类名"。如果你的类在com.demo.servlet包里,写com.demo.UserListServlet,启动时马上就报ClassNotFoundException。
这里给一个排查建议:报错时先看Tomcat日志里抛出的异常类型。如果是ClassNotFoundException,优先检查包名路径是否写错;如果是NoClassDefFoundError,通常是缺少依赖包导致类加载失败。这两个异常长得像,排查方向完全不同。
4.3 web.xml版本与Tomcat版本不匹配
web.xml头部声明的version属性要和容器的Servlet规范版本对应得上,否则解析阶段就可能出问题。比如Tomcat 9支持Servlet 4.0,web.xml用3.1完全没问题;但如果你用Tomcat 7却声明了version="4.0",应用直接无法部署。
给出一个实用对照表:
| Tomcat版本 | 对应Servlet规范 | web.xml常用version |
|---|---|---|
| Tomcat 7 | Servlet 3.0 | 3.0 |
| Tomcat 8 | Servlet 3.1 | 3.1 |
| Tomcat 9 | Servlet 4.0 | 4.0(也可以用3.1向下兼容) |
| Tomcat 10 | Servlet 6.0(Jakarta EE) | 6.0 |
注意Tomcat 10之后包名发生了大变化:javax.servlet换成了jakarta.servlet。如果代码里还在用javax.servlet.http.HttpServlet,在Tomcat 10上直接编译不过或者运行时找不到类。初学阶段如果不想被这个迁移问题干扰,建议先老老实实用Tomcat 8.5或者Tomcat 9。
4.4 多个Servlet同名或映射冲突
如果web.xml中存在两个<servlet-name>相同的<servlet>节点,容器部署时会报错。同理,两个<servlet-mapping>配置了完全相同的<url-pattern>,也会启动失败。最常见的冲突场景是复用前人配置时直接复制粘贴一段新<servlet>节点,但忘了改名字。
遇到这类问题,Tomcat会明确提示精确的错误位置,比如SEVERE: Error deploying web application archive后面跟着Caused by,那里会明确指出冲突的servlet名称或url-pattern。看日志时不要只看最上面几行,往下翻到Caused by才是问题根源。
5. 从部署到运行的完整验证链路
5.1 部署结构检查
先把项目打成war包,或者在本地IDE里直接配置Tomcat运行。无论如何,最终部署到Tomcat后,目录结构应该长这样(假设应用上下文路径是servlet-webxml-demo):
code复制apache-tomcat-9.0.x
└── webapps
└── servlet-webxml-demo
├── index.jsp
├── login.jsp
├── static
│ ├── css
│ └── js
└── WEB-INF
├── web.xml
├── classes
│ └── com
│ └── demo
│ └── servlet
│ ├── LoginServlet.class
│ ├── UserListServlet.class
│ └── InitDataListener.class
└── lib
注意一个细节:WEB-INF目录下的文件对浏览器来说是不可直接访问的,用户无法通过URL访问web.xml的内容,这是容器层面的安全保护。但你通过Tomcat的Manager管理页面可以看到WEB-INF/classes目录中有哪些类文件。
5.2 编写一个测试用的登录页面
为了走通POST流程,需要一个简单的login.jsp页面:
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>登录页面</title>
</head>
<body>
<h2>用户登录</h2>
<%
String errorMsg = (String) request.getAttribute("errorMsg");
if (errorMsg != null) {
%>
<p style="color: red;"><%= errorMsg %></p>
<%
}
%>
<form action="${pageContext.request.contextPath}/api/login" method="post">
用户名:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
注意这里form的action使用${pageContext.request.contextPath}动态拼接项目上下文路径,这样应用部署路径改了也不用去改页面。很多初学者直接写死/api/login,换部署路径后请求全404。
5.3 用curl做一轮接口自测
在浏览器里测试表单提交没问题,但接口级别的快速自测,用curl更直接。假设Tomcat监听8080端口,POST请求这样发:
bash复制curl -X POST "http://localhost:8080/servlet-webxml-demo/api/login" \
-d "username=admin&password=123456" \
-i
如果配置无误,会返回302重定向:
code复制HTTP/1.1 302 Found
Location: http://localhost:8080/servlet-webxml-demo/api/user/list
再测试用户列表接口:
bash复制curl "http://localhost:8080/servlet-webxml-demo/api/user/list"
返回:
code复制[{"id":"1","name":"张三"},{"id":"2","name":"李四"}]
到此,从web.xml配置、Servlet编写到请求处理的全链路已经跑通了。
6. 实测中遇到的典型问题与排查思路
6.1 页面404:Servlet类明明存在
第一次配置时,访问路径返回404,通常有下面几种原因,按概率排序:
- URL写错:开发工具里部署的应用上下文路径可能不是你预期的那个。比如项目名是
ServletWebxmlDemo,部署后上下文路径可能是/ServletWebxmlDemo-1.0-SNAPSHOT/。先访问一下http://localhost:8080/看Tomcat主页能出来,再确认实际上下文路径。 - 映射没生效:web.xml没有被打包到
WEB-INF下。检查war包或部署目录里WEB-INF/web.xml是否存在,如果不存在,配置文件写在哪里都没用。 - 容器没重新加载:修改了web.xml后,有些配置需要重启Tomcat才生效。Eclipse或IDEA的自动重载不总是可靠,尤其是改web.xml这种部署描述符时,稳妥起见手动重启一次。
6.2 页面500:空指针或者Servlet类冲突
500错误里的信息量通常很足,必须看Tomcat的catalina.out或IDE的Console。常见原因:
- Servlet的
service或doGet里没有正确处理请求参数,request.getParameter拿到null后直接调方法,导致NullPointerException。 ClassCastException:比如init方法里把数据存进ServletContext,后面取出时类型不匹配。- 类文件版本问题:本地开发用JDK 11编译的class部署到JDK 8的Tomcat环境中,会有
UnsupportedClassVersionError。这是环境一致性没做好的问题。
6.3 参数中文乱码
POST请求表单提交时,中文字段乱码是经典问题。我给的示例代码里在doPost开头调用了request.setCharacterEncoding("UTF-8"),这个必须在读取任何参数之前调用。如果放在读取参数之后,就不会生效。另外,response返回JSON时也要通过setContentType("application/json;charset=UTF-8")设置响应编码,否则浏览器端看到的中文是问号。
如果是GET请求携带中文参数,Tomcat 8以上版本默认使用UTF-8解码URI,问题不大;但如果用的还是Tomcat 7,需要修改server.xml里Connector节点增加URIEncoding="UTF-8"属性。
7. 进一步优化:web.xml方式与注解方式如何共存
文章写到这里,可能有人会问:既然注解那么方便,web.xml是不是过时了?
我的态度是:两者不是非此即彼的关系,而且在一定场景下需要混用。 从Servlet 3.0开始,注解方式确实简化了开发,但web.xml依然保留着不可替代的位置,比如:
- 配置
<context-param>全局参数。 - 注册
ServletContextListener等监听器。 - 配置Filter及其顺序。Filter的执行顺序在web.xml中按照
<filter-mapping>出现的先后顺序执行,注解方式无法做到细粒度排序控制。 - 配置
welcome-file-list、error-page、session-config等部署相关设置。
在实际项目中,我见过一种比较合理的分层策略:自己开发的Servlet类用@WebServlet注解,简单直接;而框架集成、监听器、全局参数和错误页面统一走web.xml。这样既享受了注解的便利,又保留了web.xml的集中管控能力。
另外,web.xml中有一个<absolute-ordering>标签可以控制Servlet 3.0之后引入的片段加载顺序,混合开发时如果发现某些第三方组件初始化顺序不对,往往就是缺少显式排序声明。
xml复制<absolute-ordering>
<name>MyFilter</name>
<name>MyListener</name>
</absolute-ordering>
8. 老项目迁移和新项目选型的实操建议
如果接手的是老项目,改动web.xml时一定要遵循一个原则:每次只改一处,改完立刻部署验证一次。web.xml是所有动态配置的汇聚点,一次改好几处,出问题后根本分不清是哪一步引起的回滚。
遇到需要新增Servlet时,推荐按这个套路操作:
- 拷贝现有
<servlet>和<servlet-mapping>节点,整体粘贴后统一改四个地方:<servlet-name>、<servlet-class>、<servlet-name>(映射里的)、<url-pattern>。 - 检查新Servlet是否需要在启动时加载,如果需要,补上
<load-on-startup>序号,注意不要和已有的序号重复。 - 部署后先访问
/主页确认应用正常,再访问新Servlet路径。 - 查看Tomcat启动日志,确认
Deploying web application没有WARN或ERROR。
新项目选型方面,如果团队里都是熟练工,用Spring Boot或JAX-RS等上层框架自然不用手写Servlet;但如果你的项目就是纯粹的小型接口服务、内部工具,或者你想彻底搞懂Java Web底层原理,手工写Servlet配合web.xml依然是一个值得做的练习。甚至可以说,很多框架(比如Spring MVC)的DispatcherServlet之所以能处理所有请求,靠的就是在web.xml中配置了<url-pattern>/</url-pattern>,本质还是Servlet映射那一套。
我从实际踩坑里积累的一条经验是:不管用什么框架,把原生Servlet和web.xml搞透彻,排查Web容器层面的问题时你会比多数人更快定位到根因,不至于一到404/500就手足无措。这也是这篇示例最想传达的东西。
