尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Servlet与web.xml配置详解:从原理到实战踩坑

Servlet与web.xml配置详解:从原理到实战踩坑 刚接触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这边最原始的做法是用CGICommon 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方法往浏览器输出一段HTMLpackage 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;charsetUTF-8); PrintWriter out response.getWriter(); out.println(!DOCTYPE html); out.println(html); out.println(headmeta charset\UTF-8\titleHello Servlet/title/head); out.println(body); out.println(h1Hello, 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 version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-nameservlet-demo/display-name !-- 1. 声明Servlet -- servlet servlet-namehelloServlet/servlet-name servlet-classcom.demo.servlet.HelloServlet/servlet-class load-on-startup1/load-on-startup init-param param-nameappName/param-name param-valuedemo/param-value /init-param /servlet !-- 2. 建立URL映射 -- servlet-mapping servlet-namehelloServlet/servlet-name url-pattern/hello/url-pattern /servlet-mapping welcome-file-list welcome-fileindex.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 groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/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-startup1/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/doPostdestroy()容器关闭或应用卸载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); // 方式二拿到所有参数名再逐一遍历适合动态表单 EnumerationString names request.getParameterNames(); while (names.hasMoreElements()) { String paramName names.nextElement(); String paramValue request.getParameter(paramName); } // 方式三Map形式一次性获取适合批量处理 MapString, String[] paramMap request.getParameterMap();有个坑提前说对于同一个参数名如果表单里出现了多次比如多选框checkboxgetParameter()只会返回第一个值这时必须用getParameterValues(hobby)拿String数组。4.2 HttpServletResponse控制输出的每一处细节响应对象的核心职责是告诉容器你要回给客户端什么。最常用的是设置响应头和写响应体response.setContentType(text/html;charsetUTF-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;charsetUTF-8)同时设置了MIME类型和字符集它会让响应头里带Content-Type: text/html;charsetUTF-8浏览器据此用UTF-8解码。setCharacterEncoding(UTF-8)设置的是PrintWriter写出去字符时用的编码。实战中我建议两个都写上顺序无所谓的但必须在第一次调用getWriter()之前设置否则容器已经根据默认编码写入响应头了后面再改不生效。如果需要返回JSON数据setContentType(application/json;charsetUTF-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 port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /改完重启TomcatGET中文参数就正常了。现在的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?uid1001);一个很容易犯的错误是重定向时忘记加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-namehelloServlet/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-fileindex.html/welcome-file welcome-fileindex.jsp/welcome-file /welcome-file-list注意这里有个顺序逻辑Tomcat会从上到下依次检查文件是否存在用第一个存在的文件。如果文件都不存在会返回404。错误页面配置也很有用可以让404、500这类错误展示友好的提示页而不是丑陋的默认报错页error-page error-code404/error-code location/error404.html/location /error-page error-page error-code500/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头部也要同步改xmlnshttps://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只是这份契约最完整、最显式的一种表达形式。把这份契约吃透了以后无论是注解、还是框架你看到的都是同一个底层的套路。
返回列表