
Servlet这个词放在今天动辄微服务、云原生的大环境下多少有点“老古董”的感觉。但你只要还在写Java后端不管用Spring Boot还是Spring MVC请求真正进来之后最终处理的还是Servlet容器那一层。很多新人会直接跳过Servlet觉得Spring Boot里一个Controller就搞定了结果一遇到ServletRequest、Filter这些底层概念或者要在非Spring环境下写点接口时整个人就懵了。这篇内容来自我的入门实战笔记核心就两件事把Servlet这个规范真正讲透以及常见的两种配置方式——web.xml和WebServlet注解——分别怎么落地。适合刚学完Java语法和HTML基础、准备理解Java Web底层运行机制的读者也适合那些“用过但没细想过”的后端同学查漏补缺。1. Servlet到底解决了什么问题一个请求的完整旅程1.1 从地址栏到服务器函数中间隔了多少层你打开浏览器输入http://localhost:8080/hello按下回车这一瞬间发生了什么从计算机层面看浏览器向目标服务器的8080端口发起TCP连接然后按照HTTP协议送出一段纯文本请求报文。报文长这样GET /hello HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0服务器端某个进程一直在监听8080端口它拿到了这个字符串需要自己解析“哦这是一条GET请求路径是/hello”。然后再根据业务逻辑生成响应字符串通过同一个TCP连接返回给浏览器。这个过程本身不复杂但问题在于如果每个应用开发者都从解析TCP报文开始写那日子就没法过了。而且服务器里往往同时运行多个功能模块耗时的、高并发的、需要保存会话状态的如果全靠自己用ServerSocket手搓几乎等于回到原始社会。Servlet规范就是在这里被发明出来的。它把共性的东西——监听端口、解析HTTP报文、管理并发、处理会话——全部下沉到容器里完成Java程序员只需要遵循一套接口约定写自己的业务类容器会在合适的时机调用你写的方法。1.2 Servlet容器、HTTP协议与Servlet的分工我用一个饭馆类比来解释三者的关系。HTTP协议是点菜单的格式规范客人必须按照固定格式写清楚要什么菜、备注什么口味。Servlet容器是饭馆前台它负责接待客人、读取菜单、然后喊后厨做菜。而你写的Servlet就是后厨的厨师只管根据前台下单的内容把菜做出来。前台下单对应容器根据报文创建了两个对象HttpServletRequest封装了请求路径、参数、Header等信息和HttpServletResponse封装了响应的输出流、状态码等然后把这两个对象传给“厨师”——也就是你Servlet里的方法。至于怎么接收网络流、怎么解析报文、怎么处理并发连接这些都是容器的事你不用碰。这个分工决定了Servlet一个核心特性Servlet本身是平台无关的Java类它不直接接触Socket不直接解析HTTP文本一切都是面对对象操作。整个应用跑在ServletContext这个全局上下文里不同Servlet之间可以共享数据这又为后面聊到的Filter、Listener打下了基础。1.3 为什么不直接写ServerSocket加main方法确实有一批人用过纯ServerSocket写HTTP服务体验基本是解析请求头要自己写正则匹配路径要自己写状态行拼错一个空格浏览器就报错并发上来还得自己管理线程池Session管理基本靠一个ConcurrentHashMap硬扛。这些活不是不能干而是既容易出错又难维护。Servlet规范把“Web服务器中重复发生的事”抽象成接口和生命周期模型谁实现这个规范谁就是容器。Tomcat是其中最常用的一个实现。你只需要把写好的Servlet类打成War包扔进Tomcat的webapps目录容器负责实例化、初始化、调用、销毁共性问题全部由容器统一处理。这也是我反复跟初学者强调的学Servlet重心一半在Java代码另一半在理解容器的生命周期与映射机制。2. Servlet生命周期与核心API不只是背概念要理解容器为什么这样设计2.1 五步生命周期每一步容器都在把控Servlet从被容器识别到最终释放一共经历五个关键阶段加载与实例化、初始化init、处理请求service、进入可用状态、销毁destroy。加载与实例化发生在容器启动或第一次请求到达时。默认情况下Servlet不会在Tomcat启动时就实例化而是懒加载——只有第一次请求触达时才创建对象。你可以通过load-on-startup参数调整这个行为让某些重量级Servlet随容器启动就初始化。init()只执行一次是初始化资源的最佳位置例如读取配置参数、建立数据库连接池、加载缓存。这里千万注意不要把耗时操作写在构造函数里构造函数是Java对象层面的行为此时Servlet尚未获取ServletConfig拿不到web.xml里配置的初始化参数。所以init(ServletConfig)方法才是生命周期正式入口super.init(config)内部会保存config并把调用转发给无参init()这也是为什么覆写init时记得先调super。请求来了之后容器会调用service()方法默认实现根据请求的HTTP方法GET、POST、PUT、DELETE等分发给对应的doXxx()方法。整个服务期间同一个Servlet实例会服务多个线程这就是下一章要展开的线程安全问题的来源。容器关闭或应用卸载时destroy()会被调用用于释放资源。调用完成后Servlet对象成为垃圾等待GC回收。这套模型非常成熟理解它之后你在排查“为什么我的初始化日志没打”“为什么静态变量状态混乱”等问题时会清晰很多。2.2 HttpServlet的service、doGet、doPost到底怎么联动写Servlet时你继承的是HttpServlet它基于GenericServlet扩展出了HTTP能力。最核心的方法关系是这样的请求进来容器调用的是service(HttpServletRequest, HttpServletResponse)HttpServlet重写了这个方法它先判断请求方法然后分别调用doGet、doPost、doPut等。如果某个方法你没有覆写父类默认实现会向响应写入405错误提示Method Not Allowed。这里有个很常见的坑有些新手直接在Servlet里覆写了service()方法但忘了调用super.service()于是doGet和doPost整个就不会被触发。如果你确实需要在分发前做统一处理记住在service()里保留super.service()调用或者干脆用Filter做前置处理这样更符合分层规范。简单来说doGet适合无副作用或幂等的请求比如查询、跳转doPost适合有副作用的提交请求比如表单数据落库。虽然你强行在doGet里写业务也能跑但严格遵守语义能让你后续接触Spring MVC时理解更顺畅。2.3 request和response两个对象的边界感HttpServletRequest承载“浏览器发给服务器的一切”请求行、请求头、请求参数、表单体、Cookie、Session等。常用方法比如getParameter()、getParameterValues()、getHeader()、getSession()、getRequestDispatcher()前两个咱们的Demo里一会儿就会用到。HttpServletResponse则负责“服务器返回浏览器的一切”设定响应状态码、响应头、重定向路径以及通过getWriter()拿到PrintWriter写入响应体。值得留意的是在一次请求处理中一旦你调用了getWriter()并写入内容部分响应头可能就无法再修改了所以先设Header再写Body是稳妥习惯。3. 配置方式一web.xml声明式配置的完整细节3.1 web.xml文件位置与基本结构Servlet 2.5及更早版本所有Servlet配置都必须集中写在Web应用目录下的WEB-INF/web.xml里。这个文件既是部署描述符也是容器识别Web应用的重要标记虽然Servlet 3.0后非必须但传统项目里它依然常见。一个基本的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 servlet servlet-namehelloServlet/servlet-name servlet-classcom.demo.HelloServlet/servlet-class /servlet servlet-mapping servlet-namehelloServlet/servlet-name url-pattern/hello/url-pattern /servlet-mapping /web-app注意servlet-name只是逻辑名由你自己命名它不必和类名相同但必须在servlet-mapping里保持一致。servlet-mapping里的url-pattern是浏览器访问的路径以/开头。定义与映射拆开是web.xml设计的核心思想同一个Servlet可以同时映射到多个URL也可以被不同逻辑名引用。3.2 url-pattern的匹配规则新手最容易在这里翻车url-pattern的匹配规则不是简单的“前缀匹配”它分四类写法匹配范围示例精确匹配只匹配完全相等的路径/hello只匹配/hello路径匹配/某前缀/*匹配指定前缀下所有路径/api/*匹配/api/list、/api/user/1扩展名匹配*.后缀匹配所有以该后缀结尾的路径*.do匹配/user.do默认匹配/匹配所有未被其他模式匹配的路径兜底用这里有两个极易踩的坑。第一个是/与/*完全不同/代表默认Servlet当其他URL匹配不到时交给它通常用来处理静态资源/*则是一网打尽的通配符会把一切请求全部截走包括JSP。新手如果把Servlet映射写成/*访问项目里的JSP页面会直接被这个Servlet接管导致页面显示异常。第二个坑是扩展名匹配不能写成/加后缀比如/*.do是非法的正确的是*.do。如果同时存在精确匹配和通配符匹配容器优先采用精确匹配这是标准的“最长匹配”原则。3.3 init-param与load-on-startup配置里的小机关web.xml里可以在servlet节点内嵌套init-param这些参数会在Servlet实例化后通过ServletConfig传入。比如servlet servlet-namehelloServlet/servlet-name servlet-classcom.demo.HelloServlet/servlet-class init-param param-namegreeting/param-name param-value你好欢迎来到Servlet世界/param-value /init-param load-on-startup1/load-on-startup /servletServlet代码里这样读取String greeting getServletConfig().getInitParameter(greeting);load-on-startup的取值是非负整数数字越小优先级越高。容器启动时会按数字从小到大依次初始化这些Servlet。这个功能常用来预加载核心Servlet或执行某些启动时任务。不过我用个人经验提醒一句尽量不要把重数据库逻辑塞到init()里因为容器启动阶段如果初始化失败可能会导致整个应用无法启动。3.4 metadata-complete这个属性你可能没注意过如果你项目里保留了web.xml且web-app标签上写了metadata-completetrue这是在告诉容器部署描述信息已经完整不要再扫描所有的注解和元数据。这会导致后面要讲的WebServlet注解通通失效。不少同学在旧项目里引入注解后“莫名”404最后查到是旧web.xml里遗留着这个属性。排查思路就是先看web.xml根标签如果是true注解方式的Servlet不会生效需要删掉该属性或改为false。4. 配置方式二WebServlet注解配置与Servlet 3.0之后的变化4.1 注解方式怎么用代码量直接少一半Servlet 3.0对应Tomcat 7及以上版本开始规范支持通过注解声明Servlet我们的web.xml里终于可以不用再写那一大坨了。改造后的Servlet代码看起来更紧凑package com.demo; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.annotation.WebServlet; import java.io.IOException; WebServlet(name helloServlet, urlPatterns /hello, loadOnStartup 1) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(text/html;charsetUTF-8); response.getWriter().write(h1Hello Servlet by Annotation/h1); } }只要你把类放在应用可扫描的包路径下容器启动时会自动扫描WebServlet注解并注册。原来的web.xml里那一组servlet和servlet-mapping定义就被这一个注解全部代替了。4.2 注解属性的含义与web.xml对照WebServlet支持的主要属性和web.xml里的配置项基本一一对应注解属性对应web.xml配置默认值nameservlet-name类全名如com.demo.HelloServleturlPatterns/valueurl-pattern两者互斥不能同时用空数组loadOnStartupload-on-startup-1表示懒加载initParamsinit-param可写多个空数组asyncSupportedasync-supportedfalsedisplayNamedisplay-name类全名urlPatterns和value二者作用相同但规范上不允许你同时写。写法上建议用urlPatterns可读性更明确比如WebServlet(urlPatterns {/hello, /hi})可以让同一个Servlet对应多个访问路径。4.3 注解方式下的三个常见误区第一个误区以为有了注解就不需要web.xml存在。确实Servlet 3.0后你的动态Web项目可以完全没有web.xml那个web.xml不再是必须文件但如果你要配置欢迎页、全局过滤器顺序、Session超时时间等仍建议保留web.xml它与注解可以共存。第二个误区不做包扫描路径控制。Tomcat启动时默认扫描WEB-INF/classes下所有类的注解如果项目很大注解扫描会拖慢启动速度正式项目往往配置metadata-completefalse或使用其他机制来控制扫描范围。第三个误区修改注解里的URL后不重新编译。注解写在Java源码里它本质是字节码元数据改完注解必须重新编译打包不像web.xml那样改完重启即可生效。这是很多开发者在IDE里改注解却抱怨“没变化”的原因。4.4 顺带一提还有编程式配置这条暗线除web.xml和注解外Servlet还有第三种配置前面虽然没有展开——通过ServletContainerInitializer或ServletContext.addServlet()做编程式注册。这种写法在Spring Boot等框架内部很常见框架不依赖你的web.xml也不依赖遍历注解而是直接在启动时动态注册Servlet。你一般用不到但了解它的存在对理解“一个Servlet到底从哪来”会有帮助。5. 从零跑通一个Servlet Demo两种配置方式完整实践5.1 环境准备和项目骨架实践讲究最小闭环。我的建议是用Maven创建一个war包类型的Web项目装上Tomcat 9对应javax.servlet或Tomcat 10对应jakarta.servlet同时配好IDEA/Eclipse里的Tomcat运行环境。Maven的pom.xml只需要最简依赖dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency /dependencies注意scope必须是provided因为最终Servlet API由Tomcat提供如果打到war包里反而会冲突。Tomcat 10用户则要把groupId换成jakarta.servletartifactId换成jakarta.servlet-api同时import的包名也从javax.servlet变为jakarta.servlet。这个差异是Java EE移交Eclipse基金会后改的好多人升级Tomcat后疯狂报ClassNotFoundException多半就是包名没换。项目结构servlet-demo/ ├── pom.xml └── src/main/ ├── java/com/demo/UserFormServlet.java └── webapp/ ├── WEB-INF/web.xml └── index.html5.2 写一个带实际业务含义的Servlet表单提交与显示我写一个非常贴近实战的小例子页面显示一个表单收集用户的姓名和城市提交后服务器用动态HTML回显信息。这个例子覆盖了GET、POST、request参数读取、response字符流输出麻雀虽小五脏俱全。package com.demo; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.annotation.WebServlet; import java.io.IOException; WebServlet(name userFormServlet, urlPatterns /form) public class UserFormServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(text/html;charsetUTF-8); PrintWriter out response.getWriter(); out.println(htmlheadtitle用户登记/title/headbody); out.println(h2请输入你的信息/h2); out.println(form actionform methodpost); out.println(姓名input typetext nameusernamebr/); out.println(城市input typetext namecitybr/); out.println(button typesubmit提交/button); out.println(/form/body/html); } Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String city request.getParameter(city); response.setContentType(text/html;charsetUTF-8); PrintWriter out response.getWriter(); out.println(htmlheadtitle结果/title/headbody); out.println(h2你好 username 来自 city /h2); out.println(a hrefform返回/a); out.println(/body/html); } }这个例子最直观的地方在于访问/form用GET返回表单填写后点提交浏览器发的是POST请求同一个Servlet里doGet和doPost各自承担职责。如果你把表单的method改成get提交后你会发现地址栏出现?usernamexxxcityxxx参数被拼到了URL里两种请求方式的差异一目了然。5.3 用两种配置方式分别驱动这个Servlet上面代码里已经有WebServlet注解这是第二种配置方式。为了演示第一种方式我把注解删掉换到web.xml里声明servlet servlet-nameuserFormServlet/servlet-name servlet-classcom.demo.UserFormServlet/servlet-class /servlet servlet-mapping servlet-nameuserFormServlet/servlet-name url-pattern/form/url-pattern /servlet-mapping两种方式跑起来效果完全一致浏览器访问http://localhost:8080/servlet-demo/form都会进入同一个Servlet。区别在于注解方式中URL和类绑定在一起看类就知道路径web.xml中URL和类分离要改映射路径不用动Java代码适合映射关系经常调整的场景。你可以分别配置一次部署两次对比中途URL变化与报错差异这种“亲手切换”比看一百篇对比文章都管用。5.4 验证请求参数与中文显示关于中文这里必须先统一字符编码。POST请求进来获取参数前调用request.setCharacterEncoding(UTF-8)否则getParameter(username)拿到的中文就是乱码。响应写HTML时response.setContentType(text/html;charsetUTF-8)放在getWriter()之前浏览器才能正确解码。我见过很多新手明明代码逻辑都对就是因为漏掉这一行页面上全是问号。6. 实战中绕不开的坑乱码、线程安全、404/405排查与Servlet的现实意义6.1 中文乱码方向不同解法不同中文乱码老实说可以单独写一篇长文。请求方向和响应方向要分开看。响应方向最简单response.setCharacterEncoding(UTF-8)只设置字符流编码再配合response.setContentType(text/html;charsetUTF-8)通知浏览器就不容易乱。请求方向分两种POST请求靠request.setCharacterEncoding(UTF-8)必须在读取任何参数前调用GET请求的参数在URL上由Tomcat解码Tomcat 8及以上版本默认URIEncoding已经是UTF-8老版本则需要在server.xml的Connector上显式配置URIEncodingUTF-8。实际项目里最稳妥的做法是统一让容器层面的过滤器做编码处理这在后续学习Filter时会有更优雅的解法。初学阶段先把请求和响应两侧的编码理解到位。6.2 单实例多线程成员变量非常危险Servlet实例只有一个但所有请求共享这个实例。Tomcat默认用多线程处理并发请求多个线程同时执行同一个实例的方法意味着你写在Servlet类里的成员变量是全局共享的、存在并发问题。举个例子如果你在Servlet里定义了一个int count每次请求自增期望值是“访问了多少次”但并发时由于count不是原子操作你得到的结果往往小于实际请求数。严谨的写法应该是局部变量优先方法内的数据用完就释放如果有跨请求共享状态的需求要么使用ServletContext要么加锁或改用原子类。这个坑在学Servlet时就要种下“线程安全”的意识后面你理解Spring的Bean作用域时会非常受益。6.3 404和405出现时按这几步排查报404首先要分清四种情况应用没启动、访问端口错、路径写错、映射没生效。路径写错最常见比如访问的是/form但web.xml里写的是/userform映射没生效最常见的原因就是前面提到的metadata-completetrue或注解类没有被包扫描覆盖。打开Tomcat的localhost.log看看启动日志写上“Deployment of web application”且无异常基本就排除了部署问题。报405则简单直接请求方式与Servlet支持的方法不匹配。你用GET访问一个只重写了doPost的Servlet会因为父类默认的doGet实现而返回405。另外表单提交后的页面刷新会重复POST有时也容易让人误认为是405问题实际是浏览器在重新提交表单。6.4 都Spring Boot时代了为什么还要学Servlet这个问题我每次带人都要答一遍。Spring Boot看起来是内嵌Tomcat、一个Controller搞定接口可DispatcherServlet本身就是一个ServletSpring MVC的请求处理链完全构建在Servlet规范之上。你不懂Servlet遇到Filter执行顺序的问题会一头雾水写文件下载接口时不知道response流的手动控制逻辑遇到Nginx转发配置也搞不清路径规则从哪来的。Servlet不是要被淘汰的技术它是Java Web的地基。把地基里这两块配置方式吃透后面接触Spring Boot时很多“魔法”都能还原成朴素的技术演进。我在实际带新人的过程中发现最快建立Servlet手感的方法不是直接套Spring Boot写CRUD而是把这个最简单的表单Demo分别用web.xml和注解方式各跑一遍然后故意改错几处改错url-pattern看404不重写doGet看405忘记设置编码看乱码。每个错都自己撞一遍再回头看这篇的所有概念你会觉得整条线一下子通了。下一节可以沿着这个Demo继续加Filter做登录校验或者引入Session保持会话状态基础牢了后面学什么都不慌。