刚接触Java Web的时候,我最怕的不是写Servlet代码,而是配web.xml。明明核心逻辑就在那个doGet方法里,可每次部署到Tomcat都卡在配置上——要么servlet-name对不上,要么url-pattern少了斜杠,页面直接甩我一个404,连个像样的日志都不给。后来带过几轮新人,发现几乎所有人都绕不开这道坎:注解一个@WebServlet就能搞定的事,为什么还要去啃web.xml?这篇文章我就从Servlet最基础的地方说起,把它的工作原理、容器加载机制、web.xml里每一行配置的实际作用,以及我这些年踩过的坑,一次性讲透。
1. Servlet到底是什么:一次请求背后的完整逻辑
1.1 为什么需要Servlet
要理解Servlet,先回到Web开发的原始场景。早期做动态网站,Java这边最原始的做法是用CGI(Common Gateway Interface):每个请求来了,服务器就启动一个独立进程去处理,处理完进程退出。这种方式的问题很明显——进程的创建和销毁成本太高,并发一上来服务器就扛不住。
Servlet的设计思路完全相反。它运行在Servlet容器(比如Tomcat)内部,以线程的方式处理请求,一个Servlet类在容器中只实例化一次,多个请求复用这同一个实例。请求进来时,容器从线程池中取出一个线程,调用Servlet的service方法去处理。这本质上是"单实例多线程"模型,避免了进程级别的开销,并发能力比CGI高好几个量级。
1.2 Servlet在整个Java Web技术栈里的位置
很多初学者容易把Servlet、JSP、Spring MVC的关系搞混。我的理解是:Servlet是地基,JSP本质上最终也会被容器编译成Servlet,而Spring MVC的DispatcherServlet本身也是一个Servlet,只不过它在这个基础Servlet之上做了路由分发。
所以你去看任何一个Java Web项目,不管用了多花哨的框架,底层一定是若干个Servlet在干活。理解了Servlet,你才算真正看懂了Java Web的底层运转方式。这也是为什么很多大厂面试还在问Servlet的生命周期、线程模型这些基础问题——因为这些才是真正跨框架的通用知识。
1.3 这篇文章的定位
我会以Tomcat 9 + Servlet 4.0为例,手把手演示一个完整的、使用web.xml方式配置的Servlet项目。内容覆盖:项目结构、Servlet类的编写、web.xml配置详解、请求处理流程、核心API使用、URL映射规则,以及我实际开发中遇到的常见错误和排查思路。不管你用的是Eclipse、IDEA还是纯命令行,核心逻辑都是一样的,照做就能跑通。
2. 一个完整的web.xml版Servlet示例:从零到浏览器看到输出
2.1 先搭好项目目录结构
用web.xml方式写Servlet,最标准的Java Web项目结构是这样的:
servlet-demo/ ├── src/ │ └── com/ │ └── demo/ │ └── servlet/ │ └── HelloServlet.java ├── web/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ │ └── index.html └── build/ (编译输出目录,部署时就是应用根目录)这里有一个原则必须记住:WEB-INF目录下的文件是受容器保护的,客户端不能通过URL直接访问。web.xml必须放在WEB-INF下,这没有任何商量余地。lib目录放项目依赖的jar包,Servlet API的jar其实不用放——因为Tomcat本身就提供了,放了反而可能因为版本冲突出问题。
2.2 编写Servlet类
先写一个最简单的Servlet。继承HttpServlet,重写doGet方法,往浏览器输出一段HTML:
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; public class HelloServlet extends HttpServlet { @Override public void init() throws ServletException { System.out.println("HelloServlet 初始化..."); } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType("text/html;charset=UTF-8"); PrintWriter out = response.getWriter(); out.println("<!DOCTYPE html>"); out.println("<html>"); out.println("<head><meta charset=\"UTF-8\"><title>Hello Servlet</title></head>"); out.println("<body>"); out.println("<h1>Hello, Servlet 世界!</h1>"); out.println("<p>请求URI: " + request.getRequestURI() + "</p>"); out.println("<p>请求方法: " + request.getMethod() + "</p>"); out.println("<p>客户端IP: " + request.getRemoteAddr() + "</p>"); out.println("</body>"); out.println("</html>"); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 简化处理,POST也走doGet的逻辑 doGet(request, response); } @Override public void destroy() { System.out.println("HelloServlet 销毁..."); } }注意几点。第一,*@Override*这些方法名不能拼错,尤其是service、doGet这类核心方法,拼错了容器不会报编译错误,但它会调用父类的HttpServlet.service,最终给你返回一个405。第二,init()和destroy()是生命周期回调,我在这里打印了日志,后面排查问题时会看到它们的作用。第三,重写doPost并转发给doGet,这是开发中很常见的偷懒写法,为了演示方便这没问题,但生产环境中GET和POST各干各的才是规范做法。
2.3 web.xml中声明Servlet并完成URL映射
这一步是用web.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-demo</display-name> <!-- 1. 声明Servlet --> <servlet> <servlet-name>helloServlet</servlet-name> <servlet-class>com.demo.servlet.HelloServlet</servlet-class> <load-on-startup>1</load-on-startup> <init-param> <param-name>appName</param-name> <param-value>demo</param-value> </init-param> </servlet> <!-- 2. 建立URL映射 --> <servlet-mapping> <servlet-name>helloServlet</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping> <welcome-file-list> <welcome-file>index.html</welcome-file> </welcome-file-list> </web-app>这里面的逻辑要拆开理解。<servlet>标签做的事情是"注册":向容器声明有一个类叫com.demo.servlet.HelloServlet,我给它的逻辑名字叫helloServlet。<servlet-mapping>做的事情是"指路":当浏览器访问/hello这个路径时,容器应该把请求交给名字为helloServlet的那个Servlet去处理。这两个标签通过<servlet-name>对应起来,名字必须完全一致,多一个空格都不行。
为什么不直接在<servlet>里写URL,非要拆成两个标签?原因在于一个Servlet可以配置多个URL映射,比如同一个helloServlet可以同时映射到/hello和/hello2,拆开写才能实现这种一对多关系。
2.4 编译部署与验证
编译Servlet需要依赖Servlet API的jar包。如果你用的是Maven,加一个依赖就行:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>注意scope是provided,意思是编译时需要、但运行时由Tomcat提供,打包时不需要打进去。如果设成compile,你的war包会里塞一份Servlet API,部署时反而可能和容器的类冲突。
如果你是纯命令行操作,可以这样做:
# 解压Tomcat后,使用它自带的servlet-api.jar javac -cp $CATALINA_HOME/lib/servlet-api.jar -d build/WEB-INF/classes \ src/com/demo/servlet/HelloServlet.java # 手动构造部署目录,把web.xml和编译产物放进去 cp -r web/* build/ mkdir -p build/WEB-INF/classes cp -r build/com build/WEB-INF/classes/部署完成后把build目录改名为servlet-demo,丢到Tomcat的webapps目录下,启动Tomcat,浏览器访问http://localhost:8080/servlet-demo/hello。看到页面输出"Hello, Servlet 世界!",这套流程就通了。
3. 容器在背后做了什么:Servlet的加载、实例化与请求分发
3.1 Servlet容器启动阶段发生了什么
Tomcat启动时,会扫描webapps下所有应用目录。对于每个应用,它会去读取WEB-INF/web.xml,把里面声明的所有Servlet解析成内部数据结构。但注意——此刻Servlet对象还没有被创建,只是"登记"了信息而已。
真正创建实例发生在两种时机:第一种,请求第一次到达这个Servlet的映射路径时;第二种,如果配置了<load-on-startup>,容器启动时就会立即创建。我在web.xml里写了<load-on-startup>1</load-on-startup>,所以Tomcat一启动就会执行HelloServlet的构造函数和init()方法,你会在日志里立刻看到"HelloServlet 初始化..."这行输出。
load-on-startup的数值表示启动顺序,数字越小越先加载。它的实际价值在于:有些Servlet需要在启动时完成重量级资源的初始化,比如加载配置文件、建立数据库连接池。如果不设置,这个初始化会被推迟到第一个请求进来时,用户就会感受到明显的卡顿——第一次访问特别慢,就是这个原因。
3.2 一次HTTP请求的完整旅程
当一个请求GET /servlet-demo/hello到达Tomcat时,完整的调用链是这样的:
- Tomcat的Connector组件(负责监听端口的线程)接收TCP连接,解析HTTP报文,封装成
HttpServletRequest和HttpServletResponse对象。 - Tomcat的Engine、Host、Context逐级定位应用,最终找到
servlet-demo这个Context。 - Context根据URL路径
/hello去匹配web.xml中配置的<url-pattern>,找到对应的<servlet-name>。 - 容器检查
helloServlet实例是否已存在,不存在则通过反射创建实例,调用init()。 - 容器从线程池中取出一个工作线程,调用
service(request, response)方法。 HttpServlet的service方法根据请求方法(GET还是POST)自动分发,最终调用我们重写的doGet或doPost。- 我们的代码往
response里写了HTML,容器把响应刷回浏览器。 - 这个
request、response对象的使命结束,被回收。
这套流程没有一步是多余的,理解它之后你再去看框架源码,你会发现Spring MVC、Struts这些最终都逃不出这套机制。
3.3 生命周期方法:init、service、destroy的三个阶段
Servlet的生命周期是面试高频考点,我用一句话概括:实例化一次,初始化一次,服务多次,销毁一次。
| 阶段 | 调用时机 | 调用次数 | 典型用途 |
|---|---|---|---|
| init() | 实例创建后、处理首个请求前 | 1次 | 加载配置、初始化连接池 |
| service() | 每次请求到达 | 多次 | 根据HTTP方法分发到doGet/doPost |
| destroy() | 容器关闭或应用卸载 | 1次 | 释放资源、关闭连接 |
有个细节值得注意:init()和destroy()默认是同步执行的,容器保证它们在多线程下只被调用一次。但service()是赤裸裸的多线程并发调用——同一个Servlet实例,同一时刻可能被5个线程同时执行doGet。如果doGet里面访问了共享的实例变量,而你又没做同步处理,就可能出现数据错乱。这是Servlet线程安全问题的根源,写代码时必须把共享状态设计成无状态,或者用局部变量。
3.4 destroy()在什么情况下不会执行
有经验的开发都知道,destroy()并不总是可靠。强制杀掉Tomcat进程(kill -9)、系统崩溃、断电,destroy()都不会执行。所以"在destroy里关闭数据库连接"这种写法只能作为优雅停机的补充,真正关键的资源释放,还是得依赖连接池的自动回收机制,不能把宝全押在destroy上。
4. Servlet核心API实战:请求、响应与参数处理的细节
4.1 HttpServletRequest:从请求里拿到一切
HttpServletRequest封装了整个HTTP请求,按内容分成三部分:请求行、请求头、请求体。
请求行包含请求方法、URI和协议版本,对应的方法是getMethod()、getRequestURI()、getProtocol()。请求头用getHeader("User-Agent")这类方法获取,可以用来判断客户端是浏览器还是爬虫。请求体是POST方式提交的数据,通过getParameter()获取。
参数获取是开发中用到最多的操作。三种方式:
// 方式一:GET请求的查询参数,也适用于POST的urlencoded格式 String name = request.getParameter("name"); // 方式二:拿到所有参数名,再逐一遍历(适合动态表单) Enumeration<String> names = request.getParameterNames(); while (names.hasMoreElements()) { String paramName = names.nextElement(); String paramValue = request.getParameter(paramName); } // 方式三:Map形式一次性获取(适合批量处理) Map<String, String[]> paramMap = request.getParameterMap();有个坑提前说:对于同一个参数名,如果表单里出现了多次(比如多选框checkbox),getParameter()只会返回第一个值,这时必须用getParameterValues("hobby")拿String数组。
4.2 HttpServletResponse:控制输出的每一处细节
响应对象的核心职责是告诉容器"你要回给客户端什么"。最常用的是设置响应头和写响应体:
response.setContentType("text/html;charset=UTF-8"); response.setCharacterEncoding("UTF-8"); response.setHeader("Cache-Control", "no-cache"); response.setStatus(HttpServletResponse.SC_OK); PrintWriter out = response.getWriter(); out.write("<h1>响应内容</h1>");那两行编码设置是重灾区,我详细说一下。setContentType("text/html;charset=UTF-8")同时设置了MIME类型和字符集,它会让响应头里带Content-Type: text/html;charset=UTF-8,浏览器据此用UTF-8解码。setCharacterEncoding("UTF-8")设置的是PrintWriter写出去字符时用的编码。实战中我建议两个都写上,顺序无所谓的,但必须在第一次调用getWriter()之前设置,否则容器已经根据默认编码写入响应头了,后面再改不生效。
如果需要返回JSON数据,setContentType("application/json;charset=UTF-8"),然后直接写JSON字符串。返回文件下载,则要设置Content-Disposition响应头。
4.3 中文乱码的根源:两头编码要一致
中文乱码这个问题几乎每个新手都遇到过。用一句话说清楚:乱码的本质是客户端和服务端在某个环节用了不同的编码规则。
POST请求的乱码,是因为Tomcat 8以前默认用ISO-8859-1解析请求体。解决办法是在读取任何参数之前执行:
request.setCharacterEncoding("UTF-8");注意这个方法的限定条件:只对POST请求的请求体有效,必须在getParameter()之前调用,而且必须在第一次调用之后就不能再改。Tomcat 8及以上版本默认请求体编码已经是UTF-8了,所以新项目里POST乱码通常不出现。
GET请求的乱码是另一个原因——Tomcat对URL中的查询参数用URI编码解析,默认也是ISO-8859-1。解决办法是修改Tomcat的server.xml,在Connector节点加一行:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />改完重启Tomcat,GET中文参数就正常了。现在的Tomcat 8+默认URIEncoding就是UTF-8,所以新环境很少踩这个坑,但你要是维护老项目,碰到GET乱码第一时间查这里。
4.4 转发和重定向:路径语义完全不同
request.getRequestDispatcher("/target").forward(request, response)是转发,response.sendRedirect("target")是重定向。两者最大的区别有三个维度:
- URL变化:转发后浏览器地址栏不变,重定向会变成新地址。
- 请求次数:转发是服务器内部一次请求,重定向是两次请求。
- 数据共享:转发的
request对象可以带参数到目标Servlet,重定向的两次请求完全独立,参数必须通过URL或session传递。
// 转发:URL不变,request里的属性可以带到下一个Servlet request.setAttribute("user", userInfo); request.getRequestDispatcher("/user/detail").forward(request, response); // 重定向:URL变成新地址,参数只能拼在URL上 response.sendRedirect(request.getContextPath() + "/user/detail?uid=1001");一个很容易犯的错误是重定向时忘记加request.getContextPath()。如果你的应用部署在/servlet-demo这个上下文下,直接写sendRedirect("/user/detail")会跳到http://localhost:8080/user/detail,丢失了应用名,导致404。加上了getContextPath()才会变成/servlet-demo/user/detail。
5. URL映射规则与web.xml配置进阶
5.1 四种匹配规则与优先级顺序
web.xml里<url-pattern>支持四种写法:
| 写法 | 示例 | 匹配规则 |
|---|---|---|
| 精确匹配 | /hello | 完全路径一致才命中 |
| 路径匹配 | /user/* | 匹配以/user开头的所有路径 |
| 扩展名匹配 | *.do | 匹配所有以.do结尾的URL |
| 默认匹配 | / | 匹配所有未被其他规则命中的请求 |
优先级顺序是:精确匹配 > 路径匹配 > 扩展名匹配 > 默认匹配。注意/user/*这种路径匹配比*.do优先级高,即使在web.xml里先写了*.do也一样——优先级和声明顺序无关,只看规则类型。
这里有个容易踩的坑:/和/*的区别。/是默认Servlet,当所有其他映射都没命中时兜底,同时它还负责处理webapps下的静态资源。/*则是通配所有路径,它会拦截包括JSP在内的所有请求。如果你把某个Servlet映射到/*,你会发现JSP页面全都不渲染了,全部进入这个Servlet处理——这种匪夷所思的事故,十有八九是/*干的好事。
5.2 一个Servlet配置多个URL模式
前面提过,一个Servlet可以对应多个url-pattern。web.xml是这么写的:
<servlet-mapping> <servlet-name>helloServlet</servlet-name> <url-pattern>/hello</url-pattern> <url-pattern>/hello2</url-pattern> <url-pattern>/greeting/*</url-pattern> </servlet-mapping>这样配置之后,/hello、/hello2、/greeting/anything都会进入同一个Servlet。这个特性在处理"同一套逻辑对外暴露多个入口"时很实用,比如一个API同时支持/api/v1/users和/api/v2/users,可以映射到同一个Servlet,在Servlet内部再根据URI做版本区分。
5.3 init-param:在web.xml中给Servlet传参
Servlet内部可以通过getInitParameter读取web.xml里配置的初始化参数:
String appName = getInitParameter("appName");我在示例的web.xml里配置了<init-param>,对应的就是这段代码读取的值。这个机制的价值在于配置与代码分离:数据库地址、告警开关、环境标识这些可能因环境而变的值,不需要修改代码、重新编译,只改web.xml里的参数就行。
<context-param>则是更高一级的配置,作用范围是整个应用,所有Servlet都能通过getServletContext().getInitParameter("globalKey")读取。适合放全局配置,比如全局字符编码、全局版本号。
5.4 welcome-file与错误页面配置
welcome-file-list指定了用户访问应用根路径时默认展示的页面:
<welcome-file-list> <welcome-file>index.html</welcome-file> <welcome-file>index.jsp</welcome-file> </welcome-file-list>注意这里有个顺序逻辑:Tomcat会从上到下依次检查文件是否存在,用第一个存在的文件。如果文件都不存在,会返回404。
错误页面配置也很有用,可以让404、500这类错误展示友好的提示页,而不是丑陋的默认报错页:
<error-page> <error-code>404</error-code> <location>/error404.html</location> </error-page> <error-page> <error-code>500</error-code> <location>/error500.html</location> </error-page>我接手过的老项目里,有一类很经典的场景:线上用户反馈"页面白屏报错",开发一看就是个500,但用户不知道发生了什么。配好错误页面之后,至少用户看到的是一句友好的提示,运维排查也更有抓手。
6. 踩坑实录:web.xml方式最常见的五个错误与排查思路
6.1 404错误:先分清是路径问题还是映射问题
浏览器访问Servlet的URL返回404时,我的排查顺序是这样的:
第一,确认URL里的应用名对不对。http://localhost:8080/servlet-demo/hello,其中servlet-demo是部署目录的名字。如果你改过war包名或目录名,应用名就变了,URL也要跟着变。
第二,确认<url-pattern>写的对不对。/hello前面必须有斜杠,写成hello容器会在启动时报错或者根本匹配不上。检查web.xml的时候我还会顺带看一眼<servlet-mapping>里的<servlet-name>和<servlet>里的<servlet-name>是否完全一致。
第三,确认应用有没有正常发布。看Tomcat日志里有没有Deploying web application directory和Deployment of web application这两个关键日志。如果没有,说明应用压根没加载,多半是web.xml格式有问题导致解析失败。
6.2 500错误:初始化失败的完整排查链路
500错误比404复杂得多,因为它可能出在任何环节。以一个典型场景为例:java.lang.ClassNotFoundException: com.demo.servlet.HelloServlet。
这个错误说明容器找不到Servlet类。为什么找不到?最常见的三个原因:
- 编译产物没放到正确位置。记得我前文强调过的目录结构吗?类文件必须放在
WEB-INF/classes对应包路径下,也就是WEB-INF/classes/com/demo/servlet/HelloServlet.class。位置错了,类加载器就找不到。 - 编译时用的包名和web.xml里
<servlet-class>写的包名不一致。这种情况经常发生在新手改代码的时候,包名从com.demo改成com.example,忘了同步改web.xml。 - jar包依赖的类缺失。如果Servlet里用到第三方库(比如JSON解析库),这些jar必须放在
WEB-INF/lib下。放在别的地方,应用部署时不会把它加到类路径里。
排查时看Tomcat的localhost.log,里面的异常堆栈会精确告诉你是哪个类加载失败,定位速度比猜快得多。
6.3 改了代码不生效:缓存和重启的先后关系
开发阶段最让人抓狂的bug之一:改了Servlet代码,重新编译部署了,但浏览器访问还是旧逻辑。原因多半是没注意这几点:
- Tomcat没有真正重启。
webapps目录下如果你直接用解压目录部署,容器可能还缓存着旧类。稳妥做法是先把应用从webapps下移走或删掉,停Tomcat,再把新版本放进去,启动。 - 浏览器缓存了页面。尤其是
Content-Type: text/html的响应,浏览器经常缓存。可以开一个无痕窗口验证,或者在响应头上加Cache-Control: no-cache。 - 编译没成功但没报错。
javac如果编译失败会有错误输出,但你连续执行命令时容易看漏。编译完用ls确认一下class文件的时间戳是不是最新的。
6.4 javax和jakarta:包名引起的连锁故障
这是一个新老项目交替时期特有的坑。Tomcat 9及以下版本,Servlet API的包名是javax.servlet;从Tomcat 10开始,包名改成了jakarta.servlet。如果你用Tomcat 10跑一个基于javax.servlet的老项目,启动时会报ClassNotFoundException或者NoClassDefFoundError。
你在网上搜Servlet教程,看到代码里import javax.servlet.http.HttpServlet,先确认自己用的Tomcat版本。如果是Tomcat 10+,需要把导入改成import jakarta.servlet.http.HttpServlet。同时,web.xml的<web-app>头部也要同步改(xmlns="https://jakarta.ee/xml/ns/jakartaee"并对应版本号),否则容器不识别。
我的建议是:新项目直接上Tomcat 10+和jakarta包名,老项目就老老实实用Tomcat 9。两边切换是最容易出幺蛾子的。
6.5 定位问题的终极手段:开日志、看堆栈、翻目录
遇到实在排查不出来的问题,我会做三件事:
第一,看Tomcat的控制台输出和logs目录下的catalina.out、localhost.log。Java Web的绝大多数问题都会在这里留下痕迹。第二,确认部署目录的实际内容,用find . -name "*.class"看看编译产物到底在不在预期位置。第三,确认访问的URL和web.xml映射是否完全吻合,包括大小写和斜杠。
有一次我排查了很久,最后发现是HelloServlet这个名字的大小写问题——Linux文件系统区分大小写,HelloServlet.class和helloServlet.class是两个完全不同的文件。这种问题Windows上不会出现,一上Linux就暴露了。
7. 我的一点实际体会:web.xml和注解,到底怎么选
最后说点掏心窝的话。现在开发新项目,几乎没人会纯手工去写web.xml了,@WebServlet("/hello")确实方便太多。但我依然建议你至少完整走一遍web.xml方式的流程,理由有三个。
第一,理解"声明-映射"这个分离思想。注解方式把两者压缩成了一个注解,隐藏了背后的设计逻辑。你亲手写一遍web.xml,就会真正明白Servlet名和URL是两个维度的事,这对后续理解框架路由有巨大帮助。
第二,维护老项目的必备技能。大量的存量Web应用还是web.xml配置方式,尤其是企业级系统、银行保险项目里的老系统,可能还要改个接口、加个Servlet。这时候你不会web.xml,连门都进不去。
第三,web.xml不止配Servlet。Filter、Listener、welcome-file、error-page、context-param这些全局配置,在web.xml里都能看到全貌。注解方式虽然也有对应API,但配置分散在各个类上,审视全局时反而不如一份web.xml来得直观。
在实际项目中我见到最多的做法是注解为主、web.xml为辅:日常Servlet用注解开发,但涉及安全过滤链顺序、全局参数、错误页这类需要全局视角的配置,还是会集中放在web.xml里管理。所以别把两者对立起来,它们从来不是二选一的关系。
回到文章开头那个问题——为什么明明注解能搞定,还要啃web.xml?我的答案是:学习Servlet不是为了用某个特定方式去写配置,而是为了理解容器和 Servlet 之间的协作契约。web.xml只是这份契约最完整、最显式的一种表达形式。把这份契约吃透了,以后无论是注解、还是框架,你看到的都是同一个底层的套路。