刚入行那会儿,我最头疼的就是搞明白“Servlet交互”这几个字到底在说啥。很多教程上来就甩一堆类和接口,讲完doGet讲doPost,看得人云里雾里。等真正接手项目,要用web.xml方式自己从零搭一个Servlet出来,才发现那些基础概念全串起来了。今天这篇就专门聊这个事:基于web.xml配置方式,把Servlet交互的来龙去脉、完整示例和实操中的坑一次讲清楚。适合刚学完Java基础想入门Web开发的朋友,也适合工作几年一直在用Spring MVC、突然要维护老项目或者面试前想补基础的同学。
1. Servlet交互的前置认知与方案选型
1.1 Servlet在整个Web体系里到底扮演什么角色
我们先别急着写代码,先把位置摆清楚。你的浏览器发一个HTTP请求出去,到服务器返回一个HTML页面,中间这一圈,Servlet就是那个干活的“中间商”。
Tomcat这类Web容器负责监听端口、接收请求、解析HTTP报文,然后把请求信息封装成一个HttpServletRequest对象,把响应通道封装成HttpServletResponse对象,最后找到一个合适的Servlet,把这两个对象塞给它。你的业务逻辑写在Servlet的service、doGet或者doPost方法里,读取请求参数,处理业务,再往response里写内容,Tomcat会把response里写的字节流按HTTP协议格式打包发回浏览器。
所以Servlet交互,本质上就是“容器怎么找到一个Servlet并调用它”和“Servlet怎么从请求里拿数据、怎么把结果写回去”这一整套机制。默认情况下,Servlet是单实例多线程的:一个Servlet类在容器里只会被加载一次、实例化一次,所有请求共用同一个实例,每次请求由容器分配一个线程来执行。
1.2 为什么现在还要学web.xml配置方式
我知道很多新手会问:现在不都是注解@WebServlet或者直接Spring MVC一把梭吗?为什么还要死磕web.xml?
我的回答是:理解web.xml,是理解Servlet机制的最短路径。注解方式本质上是Servlet 3.0以后提供的一个“语法糖”,容器启动时还是得扫描注解、生成和web.xml等价的注册信息。你直接看web.xml,相当于把注册过程摊开了、讲明白了,哪个url-pattern对应哪个servlet-class,一目了然。而且在实际工作中,老项目、政府项目、企业内部系统,用web.xml做配置的依然是大量存在的。你总不想遇到老项目就发怵吧。
还有一个原因:web.xml能配置很多东西,比如load-on-startup设置启动加载顺序、init-param传初始化参数、filter过滤器、listener监听器。这些如果只用注解,有些场景反而不如web.xml直观,尤其是多环境切换和运维调整参数的时候。
1.3 版本选择与环境准备
Servlet版本和Tomcat版本、Java版本是挂钩的,就像齿轮咬合一样。这里给出一张常见对应表,避免你去网上乱搜索踩坑:
| Servlet版本 | Tomcat版本 | Java版本 | 说明 |
|---|---|---|---|
| 2.5 | Tomcat 6.x | Java 5+ | web.xml配置方式,老项目多 |
| 3.0 | Tomcat 7.x | Java 6+ | 开始支持注解,但web.xml仍是主流 |
| 3.1 | Tomcat 8.x | Java 7+ | 支持异步处理增强 |
| 4.0 | Tomcat 9.x | Java 8+ | HTTP/2支持 |
| 5.0 | Tomcat 10.x | Java 11+ | Jakarta EE 9,包名改为jakarta.* |
这里有个大坑:Tomcat 10及以上版本,Servlet的包名从javax.servlet改成了jakarta.servlet。你如果拿Tomcat 9的代码部署到Tomcat 10,直接报ClassNotFoundException。我这篇示例用的是Servlet 4.0 + Tomcat 9 + Java 8这套经典组合,包名还是javax.servlet,你网上搜到的多数资料也是这个体系,学习成本最低。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程骨架与web.xml核心配置
2.1 项目目录结构
Servlet项目最标准的目录结构,就是Maven的标准Web结构。如果你是手动建目录,也请严格按这个规矩来,别自创一套。Tomcat检查一个Web应用是否合法,就是看这些目录,缺了WEB-INF/web.xml,它直接拒绝部署。
text复制servlet-demo
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── demo
│ └── servlet
│ └── LoginServlet.java
└── webapp
├── index.html
├── login.jsp
├── WEB-INF
│ └── web.xml
注意几个细节:编译后的class文件必须放在WEB-INF/classes目录下,第三方jar包必须放在WEB-INF/lib目录下。Tomcat的ClassLoader是从这两个目录加载类的,你放错地方,它看都看不到你的Servlet。如果你是直接手动编译部署,这一步能拦下不少报404的人。
Maven的pom.xml里,最核心就是引入Servlet API。记住,这个依赖的scope必须是provided,因为Tomcat本身已经带了一套Servlet API实现。你如果把scope写成compile,打包出来WEB-INF/lib下面会多出一个servlet-api.jar,和Tomcat自带的那套冲突,经常会出现一些莫名其妙的类加载问题,比如NoSuchMethodError。我见过不止一个同事栽在这上面。
xml复制<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
2.2 web.xml关键配置逐项拆解
写Servlet的web.xml配置,本质上就是在向容器“注册”Servlet并声明“谁来响应哪个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 Demo</display-name>
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.demo.servlet.LoginServlet</servlet-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
<welcome-file-list>
<welcome-file>index.html</welcome-file>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
</web-app>
我来把这几个标签讲透:
<servlet>和<servlet-mapping>必须成对出现。<servlet>是“声明有这么个类”,<servlet-mapping>是“规定这个类的哪个URL归它管”。两者通过<servlet-name>关联。你光写<servlet>不写<servlet-mapping>,这个Servlet初始化了但没入口;光写<servlet-mapping>不写<servlet>,容器启动直接报错。这个错很容易被忽略,因为Tomcat启动日志会刷出来org.apache.catalina.startup.ContextConfig相关的报错,但控制台不一定看得到。
<url-pattern>必须写成/xxx或者/xxx/*这种形式,也就是以斜杠开头。这是Servlet规范明确规定的,不是你想怎么写就怎么写。常见的写法有这几种:
| url-pattern | 匹配规则 | 示例 |
|---|---|---|
/login |
精确匹配,只匹配这个路径 | /login匹配,/login/不匹配 |
/user/* |
路径通配,匹配这个前缀下面所有路径 | /user/add、/user/list都匹配 |
*.do |
扩展名匹配,匹配所有以.do结尾的路径 | /editUser.do匹配 |
/ |
默认Servlet,匹配所有容器找不到对应Servlet的请求 | 静态资源走这个 |
匹配有个优先级原则:精确匹配 > 路径前缀匹配 > 扩展名匹配 > 默认匹配。举个例子,同时配置了/user/profile和/user/*,访问/user/profile时精确匹配生效,走的是/user/profile对应的Servlet,而不是/user/*。这原则面试也常考。
<load-on-startup>表示容器启动时就初始化Servlet,数字越小越优先,不配的话Servlet会在第一次被请求时才加载(懒加载)。如果你希望某些Servlet在应用启动时就去做资源初始化、连接池预热之类的事情,就配这个。注意,正着说反着说都对,但别写负数,规范里不允许。
<init-param>是给Servlet传初始化参数的,在init()方法里通过getInitParameter(String name)取。这些参数是在web.xml写死的,相当于Servlet生命周期内的“常量”。我一般用它来配置编码方式、默认值、外部接口地址这些不经常变的东西。后面讲代码时会演示。
2.3 web.xml的放置位置与读取时机
web.xml必须放在WEB-INF目录下,这点反复强调都不为过。Tomcat启动Web应用时,会解析WEB-INF/web.xml,把里面声明的Servlet、Filter、Listener全部注册到容器上下文里。解析完成之后才对外提供HTTP服务。所以改完web.xml,必须重启应用或者热部署,配置文件才会重新加载。
有一个值得注意的细节:web.xml文件的根元素<web-app>中version="4.0"这个版本号,决定了容器按哪个版本的Schema来校验文件。如果你用的Tomcat是9.0,web.xml写3.1版本也兼容,因为Servlet规范向后兼容。但如果你在Tomcat 9里写web.xml配了某个只有4.0才有的特性,容器就会在解析时报错,提示你Schema校验失败。所以一个稳妥的做法是:web.xml的version和容器支持的Servlet版本保持一致。
3. 完整示例:从请求到响应的闭环交互
3.1 编写核心Servlet类
理论说了半天,还是得把代码跑起来才踏实。我以一个很贴近实际场景的“登录处理”作为示例:前端提交用户名和密码,Servlet接收后校验,校验成功后把用户信息存到Session里,再重定向到欢迎页;失败则带着错误提示转发回登录页。
先写Servlet本体。这里有一个关键点:通常我们继承HttpServlet,按需重写doGet或doPost。如果你想两个都管,也可以重写service方法,但一般不建议。因为HttpServlet的service方法已经帮我们做了路由:根据请求的HTTP方法,把GET请求分发到doGet,把POST请求分发到doPost。你如果连service都重写了,就相当于绕过了它的路由逻辑,所有请求都进你的service,业内多认为这种做法不够规范。
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 javax.servlet.http.HttpSession;
import java.io.IOException;
public class LoginServlet extends HttpServlet {
// init方法可以从web.xml的init-param里拿初始化参数
@Override
public void init() throws ServletException {
String encoding = getInitParameter("encoding");
System.out.println("LoginServlet 初始化,encoding=" + encoding);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// GET请求直接转发到登录页面
request.getRequestDispatcher("/login.jsp").forward(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置请求编码,解决POST表单中文乱码
request.setCharacterEncoding("UTF-8");
String username = request.getParameter("username");
String password = request.getParameter("password");
// 模拟用户校验逻辑:真实项目中这里会查询数据库
if ("admin".equals(username) && "123456".equals(password)) {
HttpSession session = request.getSession();
session.setAttribute("username", username);
// 重定向到成功页
response.sendRedirect(request.getContextPath() + "/success.jsp");
} else {
// 校验失败,带错误信息转发回登录页
request.setAttribute("errorMsg", "用户名或密码错误");
request.getRequestDispatcher("/login.jsp").forward(request, response);
}
}
}
这段代码已经覆盖了Servlet交互的大部分核心API:
getParameter:从POST表单或URL查询串里取参数setAttribute/getAttribute:在request作用域内传数据,伴随一次转发生效getSession:获取或创建Session对象sendRedirect:重定向,浏览器地址栏会变,产生两次HTTP请求getRequestDispatcher().forward():服务器内部转发,地址栏不变,一次请求
有人可能会问:request.setCharacterEncoding("UTF-8")这行是什么时候加的?我是在doPost里第一行就调用了。这个调用只对POST请求的请求体解析生效,必须在第一次调用getParameter之前设定。如果你先调了getParameter再去setCharacterEncoding,解析已经完成了,就晚了。
3.2 URL映射与部署验证
启动Tomcat后,访问http://localhost:8080/servlet-demo/login,请求会经历这样的路径匹配流程:
- Tomcat接收到
/servlet-demo/login这个请求,其中/servlet-demo是应用上下文路径(Context Path),Tomcat根据这个前缀找到对应的Web应用 - 在应用内,剩下的
/login作为Servlet路径,去web.xml的<url-pattern>里找匹配项 - 命中
/login精确匹配,找到LoginServlet - 容器检查
LoginServlet是否已实例化,如果没有,先执行构造方法,再调用init方法 - 根据HTTP请求方法,GET请求进
doGet,POST请求进doPost
这个流程同样也是面试高频题,记住“路径解析→Servlet匹配→实例化→init→针对方法的分发”这五步,基本就过关了。
部署的时候,有几种方式,我对新手最推荐的是IDEA里配置Tomcat的Artifacts直接打war包部署。有一点必须提醒:**修改了Java代码,一定要重新编译并重启Tomcat;修改了web.xml,至少也要重启Context。**很多人改了代码不重启,访问的还是旧class,在那瞎排查半天,最后发现是没热部署。
3.3 请求参数编码乱码的完整解法
中文乱码是Servlet新手最容易碰到的第一个大问题。它分两种情况,解法完全不同:
POST请求乱码:因为浏览器在POST请求体里的编码方式和Servlet解析时用的编码不一致。解决方式就是request.setCharacterEncoding("UTF-8"),必须在读取参数前调用。注意Tomcat 8之后,Tomcat 8之前,POST请求默认编码是ISO-8859-1,不设置必乱码。
GET请求乱码:GET请求参数在URL上。URL的编码与解码涉及到connector的URIEncoding和useBodyEncodingForURI这两个属性。Tomcat 8及以上,URIEncoding默认已经是UTF-8,所以大部分场景GET请求中文不会乱。但如果你用的Tomcat 7或者更早的版本,默认ISO-8859-1,就需要在server.xml的Connector上手动加上:
xml复制<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
URIEncoding="UTF-8" />
响应乱码:这是另一种乱。分两步看,一是response.setCharacterEncoding("UTF-8"),告诉Tomcat输出字节流时用什么编码把字符串转成字节;二是response.setContentType("text/html; charset=UTF-8"),把内容类型和编码告诉浏览器,让浏览器用UTF-8解码。我习惯的做法是合并写一行:
java复制response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("中文内容");
这样两步都覆盖了,因为setContentType里指定了charset,容器也会同步设置CharacterEncoding。
有朋友可能会遇到“编码也设置了,为什么还是乱码”的诡异情况。这时要检查一个终端小细节:JSP页面自身的pageEncoding属性,它的职责是告诉JSP引擎这个JSP文件本身是用什么编码写的。你JSP文件明明存的UTF-8,却写了pageEncoding="ISO-8859-1",那JSP引擎就会按ISO-8859-1去读文件生成Java代码,怎么调response都没用。我当时排查出来这个原因的时候哭笑不得,因为被坑了一整天。
前端的HTML页面同理,<meta charset="UTF-8">要和文件存储编码一致。这一整条链路:文件存储编码 → 请求/响应编码 → 容器解码编码,任何一个环节掉链子,最后都是乱码。建议新手在写页面时,全部统一成UTF-8。
3.4 转发和重定向,怎么选才不踩坑
转发和重定向是Servlet交互里最容易混的两个操作,我把它们的区别整理成一张表:
| 维度 | 转发 forward | 重定向 sendRedirect |
|---|---|---|
| 代码形式 | request.getRequestDispatcher("/page.jsp").forward(request, response) |
response.sendRedirect(request.getContextPath() + "/page.jsp") |
| 浏览器地址栏 | 不变,还是第一个地址 | 变成第二个地址 |
| HTTP请求次数 | 1次(服务器内部跳转,浏览器无感知) | 2次(浏览器重新发起请求到新地址) |
| 请求域数据共享 | 同一个request对象,setAttribute能带到目标页面 | 不同request对象,数据不共享 |
| 能否跨域 | 只能应用内跳转 | 可以跳转到外部地址 |
| 性能 | 快(少一次网络往返) | 略慢 |
| 适用场景 | 表单校验失败回显数据 | 登录成功后防止表单重复提交 |
我经常打一个生活化比方:转发像你到银行柜台办业务,柜员内部把你的单子递到后台同事手里,你不需要移动,后台同事直接处理完再通过柜员回应你。重定向像你去错了办税大厅,工作人员跟你说“您得到另一个窗口去办”,你得自己走到另一个窗口,再重新说明一遍来意。
所以在登录这个例子里,登录成功我用重定向:因为如果我用转发,用户停留在/login这个地址却看到了登录成功的内容。此时刷新页面,浏览器会再次提交上一次的POST表单,导致重复登录。用重定向的话,其实用户从post请求响应中拿到重定向地址,走了新的GET请求,即使刷新也只是重新GET新的页面,不会重复提交表单。
校验失败我用转发:因为我要把errorMsg这个错误信息和用户刚输入的username带回登录页显示。如果用重定向,request对象就没了,还得靠Session或者URL拼接传参,麻烦且容易出安全漏洞。
3.5 数据交互:Servlet的request作用域与Session使用
写完登录逻辑,还有一个关键交互场景要讲:多个请求之间怎么共享状态。Servlet规范把数据共享的范围分成了几层,很多人背过,但没实操过。我结合场景来捋一遍:
请求作用域(request):从这次请求开始,到这次请求结束,通过setAttribute存进去的数据,可以在转发的目标页面里通过getAttribute取出来。数据生命周期很短,页面刷一下就没了。
会话作用域(session):从用户第一次访问开始,到会话超时或主动失效结束。通过HttpSession存储用户登录状态、购物车信息这些东西。示例里登录成功后我执行了session.setAttribute("username", username),后面任何页面都能通过request.getSession().getAttribute("username")取到。Session的超时时间在web.xml里可以配置:
xml复制<session-config>
<session-timeout>30</session-timeout>
</session-config>
单位是分钟,默认一般是30分钟。要注意的是,Session底层依赖Cookie,容器会给浏览器下发一个名为JSESSIONID的Cookie,浏览器后续请求都得带着它,服务器才能认出“这是同一个用户”。如果你在跨域场景下,Cookie不共享,Session就失效。
应用作用域(application):整个Web应用所有用户共享,通过ServletContext.setAttribute存取。常用于全局计数器、缓存、公共配置。一个典型的坑是:别把和单个用户相关的数据存到ServletContext里,否则用户A的数据可能被用户B看到。这个错误新手特别容易犯。
我见过一个实际案例:有同事把当前登录用户信息存进了application作用域,结果所有在线用户看到的都是最后登录那个人的名字。这个Bug排查了挺久,最后定位到作用域选择错误,改成Session后问题立刻消失。
4. 实战经验:常见问题与排查技巧
4.1 部署后404:八成是url-pattern没对上
访问的时候报404,别急着怀疑代码逻辑。先确认这几点:
第一,应用路径。访问地址是什么?部署的Context Path是啥?我见过有人把Web应用部署成根路径/,然后访问http://localhost:8080/servlet-demo/login,404找不着应用。反过来,部署的时候应用名叫servlet-demo,访问http://localhost:8080/login,也会404,因为/login没有对应的应用。访问地址必须等于contextPath + servletPath。
第二,web.xml里的url-pattern。写的是/login就是/login,不要带应用上下文前缀。很多人习惯性地在web.xml里写/servlet-demo/login,这就不对了。容器匹配Servlet路径时,拿到的已经是去掉上下文路径后的部分。
第三,编译输出的class路径。检查WEB-INF/classes下有没有com/demo/servlet/LoginServlet.class。如果没编译出来,或者放到了WEB-INF/lib下面,Tomcat找不到类,在启动日志里会报ClassNotFoundException,接着应用部署失败,页面上就是中文或英文的404/500。
一个排查技巧:每次部署完,先看一眼Tomcat catalina.out日志有没有Deployment of web application has finished。如果中间有红色异常堆栈,就直接在堆栈里找Caused by,那往往是根因。踩过的经验告诉我,这一招能快速过滤掉80%的低级错误。
4.2 405 Method Not Allowed:服务方法没实现
进去页面再把表单一提交,结果报405。这个状态码的含义是“请求方法不被允许”,在Servlet场景下,90%是因为你的Servlet没重写对应的doXxx方法。比如表单method是POST,但你的Servlet只重写了doGet,那容器会调用父类HttpServlet的doPost,而父类方法默认就是返回405。别问我怎么知道的,我实习那会儿就这么坑过自己。
解决办法很简单:要么前端表单method改成get,要么Servlet里重写doPost。更合理的做法是,让doGet和doPost互相调用,或者统一入口到service之外的一个自定义方法。我在简单页面里经常写:
java复制@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
handleRequest(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
handleRequest(request, response);
}
private void handleRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 统一处理逻辑
}
这样无论表单用什么方法提交,都能收到请求。当然,更规范的做法是区分GET和POST的语义,GET用于查询,POST用于变更。但在页面只管展示逻辑、没有风险操作的场景,统一入口可以显著减少重复代码。
4.3 中文乱码的排查顺序
前面讲了编码设置,这里再给一个遇到乱码时的排查顺序:
- 先定位是哪一侧乱码:是HTML页面直接打开乱,还是通过Servlet响应输出的中文乱,还是前端提交中文到后端再显示出来乱。
- HTML页面直接乱:检查文件存储编码和
<meta charset>是否一致。 - Servlet输出乱:检查
response.setContentType是否指定了charset。 - POST提交乱:检查
request.setCharacterEncoding("UTF-8")是否在所有getParameter调用之前。 - GET提交乱:检查Tomcat Connector的
URIEncoding是否为UTF-8。 - JSP页面乱:检查pageEncoding是否和文件存储编码一致。
按这个顺序走一遍,通常十分钟内能定位问题。别一上来就怀疑代码,先把编码链路理一遍。我处理乱码问题,最常用的一个骚操作是:在Servlet里先打印一下getParameter拿到的原始值到控制台。如果控制台输出都是正确的,就不用怀疑后端编码,问题一定在响应输出或前端展示那一侧。反过来,控制台直接就是乱码,说明请求参数解析就出了问题,直接去查request.setCharacterEncoding有没有生效。
4.4 线程安全:单实例多线程的陷阱
很多人不知道,你的Servlet默认是单实例的,但每个请求是不同线程来执行的。这就意味着:如果在Servlet里定义了实例变量,并且这些变量是可变的话,就存在线程安全问题。
举个例子,你定义了private String currentUser;,在doPost里给它赋值currentUser = request.getParameter("username"),然后接下来用这个变量。看起来没问题,但高并发下,线程A和线程B同时进入这个Servlet,线程A刚把currentUser赋值为“张三”,线程B立刻把它改成“李四”,线程A后续读到的就变成“李四了”。这个Bug特别隐蔽,因为平时压测流量小根本不会触发。
解决方案是:
- 尽量把局部变量放在方法内部,不要用实例变量。
- 如果必须共享数据,用
ThreadLocal或者加锁。 - 只读的属性(比如初始化参数)天然安全,但写操作必须先考虑并发。
Servlet的init方法是在单线程环境下执行的,只执行一次,所以init里初始化的只读字段是安全的。service方法才是并发执行的战场。这个原则不仅对Servlet有效,对后端的很多“单例组件”都是通用的:单例 + 无状态 = 安全;单例 + 有状态 = 高风险。我后来写代码养成的习惯是:Servlet里尽量不做具有状态的实例变量的定义,能用参数传递就用参数传递。
4.5 修改代码不生效:热部署与缓存疑云
还有一个高频问题:改了代码,重启了Tomcat,为什么访问的还是旧页面?
常见的坑首先是浏览器缓存。HTML、JS、CSS这些静态资源如果响应头里带了缓存策略,浏览器会优先使用本地缓存。你可以在浏览器的无痕模式下重新访问试试,或者强制刷新(Ctrl+F5)。如果无痕模式下是新代码,那就是浏览器缓存的问题,与应用无关。
其次是编译产物没更新。IDEA里修改完Java代码,需要重新编译。有些人直接改了代码就在Tomcat里Reload,但IDEA没有自动out put编译的class文件,导致Tomcat加载的还是旧的class碎片。然后老手习惯了改完代码先mvn clean package或者Ctrl+F9重新构建,再重启Tomcat。
还有Tomcat的Context热加载机制:默认情况下,Tomcat配置了reloadable="false"或true,如果reloadable,Web应用下有class文件更新时会自动重新加载Context。但reload可能加载不干净,有些静态变量、内存数据会被保留,导致异常。我在开发阶段会开着reload真方便,但一旦遇到诡异问题,第一反应是“手动完全重启Tomcat”。这个土办法,其实能解决80%莫名其妙的IDE热部署问题。
5. 从web.xml配置看Servlet容器的运作机制
5.1 Servlet实例生命周期全流程
写了不少代码,最后把Servlet容器管理实例的过程整理成一条线,这条线面试也爱考,但自己动手做过一遍之后,会发现理解起来容易得多:
容器启动时,根据web.xml的注册信息,把Servlet的类名加载进来。注意,这里只加载类,不一定会实例化。真正实例化的情况有两种:一种是配置了load-on-startup,容器启动时立即创建实例并调用init;另一种是第一次请求到达时才创建。创建之后,容器把Servlet实例放到实例池中,之后所有请求都复用这一个实例,分别分配线程去调用它的service方法。当应用停止或重启时,容器会调用destroy方法,释放资源,然后卸载类。
所以Servlet实例经历了三个阶段:init(初始化)→ service(服务,doGet/doPost都被service分发)→ destroy(销毁)。
5.2 request与response这把“钥匙”怎么用
HttpServletRequest和HttpServletResponse这两个对象,是所有Servlet交互的入口和出口。理解它们的本质,能帮你写出更扎实的代码。
request对象里封装了本次请求的全部信息:请求行(方法、URL、协议版本)、请求头(Host、User-Agent、Cookie)、请求体(POST表单内容)。你可以通过getParameter获取请求参数,通过getHeader获取请求头,通过getCookies获取Cookie,通过getSession获取或创建会话对象,通过getRequestDispatcher获取转发器。
response对象则是你向浏览器回写数据的通道。你可以用getWriter()拿到字符输出流,用getOutputStream()拿到字节输出流,但注意这两个流不能同时用,分别表示不同的数据形式。前者适合输出文本,后者适合输出图片、文件下载,用过一个再去拿另一个会抛IllegalStateException。
还有一个我当年最深有体会的点:response一旦提交(缓冲区满了或者调用了flushBuffer),就不能再改变响应状态码和响应头了。所以如果你想先返回一个重定向或者设置状态码,必须在写入任何响应体之前操作。
5.3 过滤器与监听器:web.xml里容易被低估的两类配置
web.xml不止能配Servlet,还能配Filter和Listener。它们和Servlet配合起来,才能完成比较完善的交互链路。举两个我在项目中真正用过的例子。
Filter(过滤器)做登录校验是最经典的应用。如果你只在自己的Servlet里做登录判断,那每个需要登录的Servlet都要重复写一遍“检查Session有没有用户”。但如果写一个/*的Filter,那么所有请求都会先经过它,统一判断登录状态,未登录直接重定向到登录页。这比在每个Servlet里重复写要优雅得多:
xml复制<filter>
<filter-name>LoginFilter</filter-name>
<filter-class>com.demo.filter.LoginFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>LoginFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
Listener(监听器)可以在应用启动时做初始化,比如加载配置文件、启动定时任务。写一个类实现ServletContextListener接口,在contextInitialized里写初始化逻辑,然后在web.xml注册:
xml复制<listener>
<listener-class>com.demo.listener.AppListener</listener-class>
</listener>
Filter的调用顺序与<filter-mapping>的顺序有关;Listener的初始化时机在Filter之前。这些虽然不直接体现在“Servlet交互”的字面上,但既然你用web.xml方式,这层配置是绕不开的。
有个我没少踩过的坑是:Filter如果写了<url-pattern>/*</url-pattern>,它也会拦截静态资源(CSS、JS、图片)。如果你的Filter逻辑是“没登录就重定向”,而登录页本身引用的CSS也走这个Filter,就可能出现“静态资源也被拦截然后重定向到登录页”这种循环问题。通常用Filter的HttpServletRequest路径判断一下,或者把静态资源路径排除在拦截范围之外。
6. 多环境配置与建议
6.1 开发环境与生产环境的web.xml差异
开发环境和生产环境的web.xml往往是不一样的。常见几种做法:
一是在项目中维护多套web.xml,根据不同环境用构建工具切换。Maven里的profile可以做到,比如src/main/resources下放web-dev.xml、web-prod.xml,打包时通过mvn resources:resources -Penv把对应文件复制成web.xml。但这种方法对web.xml的切换要小心,因为web.xml是Web应用的部署描述符,Tomcat解析的是Web应用目录里实际的那份,构建脚本必须把它正确放到WEB-INF/web.xml的位置。
二是web.xml保持一份,把环境相关的配置放进init-param或者外置配置文件。比如数据库连接地址、Redis地址这些,写进一个config.properties文件,在Init方法里读取。这样web.xml本身不用动,环境差异由配置文件来承担。我在实际项目中比较推崇这种模式,因为web.xml频繁变动的风险太高,一不小心改坏一个标签,整个应用都起不来。
第二种方案还有个好处:部署运维时,只需要改配置文件,不需要重新打包war包。对生产环境来说,少动一次包就少一分风险。
6.2 一个能少走弯路的“最小可用”起步思路
如果你是第一次用自己的代码调通一个完整Servlet交互流程,我建议你按这个顺序渐进:
第一步,什么都不写,先部署一个空的Web应用,验证Tomcat和项目结构没问题。看到默认首页或者Index of /就算通过。
第二步,写一个最简单的Servlet,只输出一句话。不连接数据库,不写JSP,就验证“从浏览器到Servlet”这半条链路通不通。
第三步,添加登录页(HTML表单),把请求参数从浏览器传到Servlet,再在Servlet里输出到页面。这验证“从Servlet到浏览器”的下半条链路。
第四步,再加上转发、重定向、Session,完成一个相对完整的交互闭环。
第五步,再加数据库、Filter、Listener,逐步丰富。
很多人一上来就想要一个包含数据库连接池、Redis、Log4j的大项目,结果哪里配错了连Tomcat都跑不起来,根本分不清是Servlet代码的问题还是框架的问题。步步为营,反而最快。
7. 开发常用工具与调试技巧
7.1 用浏览器开发者工具观察交互细节
调试Servlet交互,除了看IDE控制台日志,还有一个非常有效的工具:浏览器的开发者工具(F12)。
打开Network面板,提交登录表单后,你能看到这次请求的完整细节:请求URL、请求方法、状态码、请求头、响应头。重点看这几个信息:
- 状态码:200是正常,302是重定向(sendRedirect会返回302),404是找不到资源,500是服务器内部错误
- 请求方法:确认表单method是get还是post,和Servlet重写的方法是否匹配
- 请求参数:POST请求在Payload里能看到表单提交的内容;GET请求参数在Query String Parameters里能看到
- 响应内容:看返回给浏览器的原始内容,判断是Servlet输出的还是Tomcat默认的错误页
有一次一个同事说“Servlet里明明写了forward,但页面没反应”,我看他Network面板,发现请求状态是302,原来代码里执行到了sendRedirect分支,并不是forward没生效,而是走了重定向逻辑。多亏Network面板帮忙看清了真实请求路径。这种“代码逻辑路径”和“浏览器实际行为”的偏差,光靠读代码很难定位,工具才是硬道理。
7.2 写日志比System.out.println更靠谱
初学阶段大家喜欢用System.out.println打印调试信息。它能用,但有个明确的问题:生产环境没人看控制台,而且System.out不受日志级别控制,线上疯狂打印会拖垮性能。所以我建议一旦涉及比较复杂的Servlet交互逻辑,尽早改用日志框架。
不用引入太重的框架,Java自带的java.util.logging或者简单的slf4j+logback都可以。在Servlet里这样写:
java复制private static final Logger logger = LoggerFactory.getLogger(LoginServlet.class);
logger.info("用户登录请求,用户名={}", username);
logger.warn("用户登录失败,用户名={}", username);
用占位符{}代替字符串拼接,比"用户登录请求,用户名=" + username这样的写法更清晰,还避免了不必要的String创建。
7.3 一次实际排错的完整复盘
最后,分享一下我最近一次帮同事排查Servlet交互问题的完整过程,供你参考。
现象:通过web.xml配置的Servlet,访问就404,Tomcat控制台也没有明显异常。
排查步骤:
第一步,确认应用部署成功。看catalina.out日志,有没有Deployment of web application archive [XXX.war] has finished。结果发现应用部署成功了,所以不是整个应用没起来。
第二步,确认访问URL。检查访问的是http://localhost:8080/servlet-demo/login,其中/servlet-demo是应用名,/login是Servlet路径,看着没错。
第三步,检查web.xml里Servlet类名。打开WEB-INF/web.xml,发现<servlet-class>写的是com.demo.servlets.LoginServlet,但实际包名是com.demo.servlet.LoginServlet。多了个s。这一个字符之差,容器启动时加载类失败,但Web应用并没有因为这个错误直接拒绝部署,应用是起来了,只是这个Servlet没有注册成功,所以访问就404。
第四步,修改包名错误,重新clean package部署,问题解决。
这类问题光看页面是404,根本想不到是类名拼写错误。所以遇到404一定要先看Tomcat日志,日志里明明白白写着ClassNotFoundException: com.demo.servlets.LoginServlet。红字就在那,不看就绕远路。排查这类问题的核心思路始终是:页面报错 → 反推服务端日志 → 找到Caused by → 根据根因修复。
8. 一点实操体会
这套web.xml方式配置Servlet的完整流程写下来,我自己最大的体会是:理解了Servlet交互的本质之后,再回头看那些框架,思路会清晰很多。Spring MVC底层就是一个大号的DispatcherServlet,它拦截所有请求,再根据注解或配置找到对应的Controller方法。这个工作机制和我们在web.xml里配<servlet-mapping>把/login指向LoginServlet,本质上一模一样。
最后给个小建议:不管你现在用不用web.xml,都亲手把这种最古朴的配置方式走一遍。从建目录、写web.xml、部署、调试到看清楚一条请求的生命周期,这个过程带给你的“底层骨架感”,是任何高层框架都给不了的。很多年后你回头再看,会发现今天这一下午的折腾,非常值。
