Servlet是Java Web开发绕不开的第一道门槛。哪怕现在Spring MVC、Spring Boot满天飞,底层请求处理的核心机制依然是Servlet规范。这次要讲的就是最原始的Servlet使用方式:直接实现Servlet接口,手动在service方法里判断请求类型做分发,再用注解完成映射配置。这套路子虽然在实际项目中不会直接这么用,但把请求从浏览器到服务器再到业务代码的完整链路看得清清楚楚,理解之后再看框架源码会轻松得多。适合刚接触Java Web的初学者,也适合想补基础的老手。
1. 为什么直接实现Servlet接口?——设计与思路拆解
1.1 先搞清楚Servlet到底是个什么东西
Servlet本质上是一个运行在服务器端的Java类,它遵守一套约定好的规范(也就是Servlet规范),能够接收浏览器的HTTP请求,处理之后把结果通过HTTP响应返回给客户端。用生活化的类比来说,Servlet就像餐厅的服务员:客人(浏览器)下单(发请求),服务员(Servlet)记录需求、转给后厨(业务逻辑),再把做好的菜(响应结果)端上桌。
这套机制从Java Web诞生之初就存在,至今没有变过。Servlet规范定义了一套标准接口,只要实现了这套接口,你的类就有资格被Servlet容器(比如Tomcat)识别、加载、调用。容器负责管理Servlet的创建、初始化、请求调用、销毁整个生命周期,开发者只需要关心业务逻辑怎么处理请求。
1.2 三条实现路径的对比
实现Servlet有三种常见方式,各有各的使用场景:
| 实现方式 | 代码复杂度 | 请求方法处理 | 使用场景 |
|---|---|---|---|
| 直接实现Servlet接口 | 最高 | 需自己在service中判断 | 教学演示、理解底层机制 |
| 继承GenericServlet | 中等 | 仍需自己在service中判断 | 协议无关的服务端组件 |
| 继承HttpServlet | 最低 | 自动分发到doGet/doPost | 日常项目开发 |
为什么这次选最原始的第一种方式?因为它的过程最透明。继承HttpServlet的时候,容器帮你把很多细节隐藏了——比如请求方法怎么判断的、doGet和doPost是怎么被调用的、service方法为什么能自动识别GET和POST。而直接实现Servlet接口,所有东西都要自己写,反而能把原理看得清清楚楚。等你在service方法里亲手写上if判断、自己手动调出doGet和doPost逻辑的时候,HttpServlet那层封装的面纱就自然揭开了。
1.3 这种方式解决了什么问题
直接实现Servlet接口至少带来三个层面的收获。首先是理解生命周期:接口里定义了init、service、destroy等方法,这些方法什么时候被调用、调用顺序是什么,不亲手实现一遍很难真正内化。其次是理解请求分发:HTTP协议里GET、POST、PUT、DELETE等方法在Java代码中是怎样的存在,为什么框架能自动分发,核心逻辑其实就是一个简单的字符串判断。最后是为框架学习打基础:Spring MVC的DispatcherServlet本身就是个Servlet,只是它把这个接口玩到了极致。
2. Servlet生命周期与核心方法详解
2.1 生命周期全流程:从启动到销毁
Servlet的生命周期由容器管理,标准流程是"加载→实例化→初始化→处理请求→销毁"五步。容器启动或者第一次收到该Servlet的请求时,会创建Servlet实例并调用init方法完成初始化。之后每一次请求到达,容器都会创建一个新线程调用service方法。当应用停止或容器关闭时,调用destroy方法做清理工作。
这个生命周期有几个细节值得注意。init方法只会执行一次,Servlet实例在整个生命周期里只有一个(单例模式),多个请求并发访问的是同一个实例。所以你在Servlet里写成员变量的时候要格外小心并发问题。destroy方法也不是一定能执行到,比如容器被强制kill掉,就来不及调了。日常开发中很少会用到destroy做事情,但了解这个流程对理解容器工作机制很重要。
2.2 接口的五个核心方法逐一说透
Servlet接口一共定义了五个方法,每个方法都有明确的职责分工:
- init(ServletConfig config):初始化方法,容器在创建实例后调用,用于加载配置参数、建立资源连接。注意它接收一个ServletConfig对象,这个对象里封装了Servlet的初始化参数,需要保存起来供后续使用。
- getServletConfig():返回容器传给init方法的ServletConfig对象。开发工具链(比如IDE)和容器需要通过这个方法获取Servlet的配置信息。
- service(ServletRequest req, ServletResponse res):核心处理逻辑所在。接收请求、处理业务、返回响应的所有操作都在这里完成。容器每收到一个请求,就调一次这个方法。
- getServletInfo():返回Servlet的基本信息字符串,比如作者、版本等,仅用于展示,不影响逻辑。
- destroy():销毁方法,容器在移除Servlet之前调用,Release占用的资源。
这五个方法里,真正写业务逻辑的只有service。但其他四个也必须实现,因为这是接口的约束。假如不实现,编译器就会报错。这就引出一个实际问题:每次写Servlet都要把这五个方法全部写一遍,太啰嗦了。
2.3 请求分发核心:service方法是怎么工作的
当浏览器发送一个HTTP请求到服务器,容器解析请求后,会根据请求的URL找到匹配的Servlet,创建一个ServletRequest对象封装请求数据(参数、头信息、方法类型等),创建一个ServletResponse对象用于写入响应内容,然后调用service方法并传入这两个对象。
service方法内部的第一步就是判断请求类型。ServletRequest接口里有一个getMethod方法,返回的是请求的HTTP方法名称字符串,比如"GET"、"POST"、"PUT"、"DELETE"。分发逻辑本质上就是对这个字符串做判断:
String method = req.getMethod(); if ("GET".equals(method)) { // 处理GET请求 } else if ("POST".equals(method)) { // 处理POST请求 } else { // 其他请求方法 }这就是HttpServlet类做的事情。HttpServlet重写了service方法,在内部先判断是HTTP请求还是普通请求,然后根据getMethod的返回值分别调用doGet、doPost、doPut等方法。所以理解了这个简单的字符串判断,你就理解了HttpServlet分发机制的全部本质。
3. 手把手实操:从零搭建一个注解配置的Servlet
3.1 环境准备:版本选型和项目结构
实战之前先把环境准备好。我用的是最经典的组合:JDK 8 + Tomcat 9 + Maven,用IDEA开发。这里有个非常重要的版本兼容问题要提前说清楚:Tomcat 9及之前的版本用的是javax.servlet包,Tomcat 10开始迁移到了jakarta.servlet包。如果选错了依赖,代码会直接编译报错。建议初学者用Tomcat 9,网上资料多、踩坑少。
项目结构用Maven标准布局:
servlet-demo/ ├── pom.xml └── src/main/java/com/example/servlet/ └── HelloServlet.javaMaven的pom.xml里加上Servlet API依赖,注意scope设为provided,因为Tomcat本身自带Servlet API,打包的时候不需要打进去,避免冲突:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>3.2 编写Servlet类:实现接口的完整代码
接下来是核心代码。直接实现Servlet接口,把五个方法全部写上,在service方法里做请求分发:
package com.example.servlet; import javax.servlet.*; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; @WebServlet("/hello") public class HelloServlet implements Servlet { private ServletConfig servletConfig; @Override public void init(ServletConfig config) throws ServletException { this.servletConfig = config; System.out.println("=== init方法被调用,Servlet初始化完成 ==="); } @Override public ServletConfig getServletConfig() { return servletConfig; } @Override public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException { System.out.println("=== service方法被调用,开始处理请求 ==="); res.setContentType("text/html;charset=UTF-8"); PrintWriter writer = res.getWriter(); String method = req.getMethod(); if ("GET".equals(method)) { writer.write("<h1>接收到GET请求</h1>"); writer.write("<p>请求URL:" + req.getScheme() + "://" + req.getServerName() + ":" + req.getServerPort() + req.getRequestURI() + "</p>"); } else if ("POST".equals(method)) { writer.write("<h1>接收到POST请求</h1>"); } else { writer.write("<h1>接收到" + method + "请求</h1>"); } } @Override public String getServletInfo() { return "My HelloServlet, version 1.0"; } @Override public void destroy() { System.out.println("=== destroy方法被调用,Servlet销毁 ==="); } }这段代码有几个关键点需要说明。init方法里把ServletConfig保存到了成员变量getServletConfig才能返回它,这是标准写法。service方法里先调用setContentType设置响应内容的类型和编码,这一步不做的话中文会乱码。getMethod方法拿到的是大写的HTTP方法名,所以判断的时候用"GET"、"POST"这样的字符串。
有人可能会问:直接实现Servlet接口,service方法拿到的参数是ServletRequest和ServletResponse,为什么不直接用HttpServletRequest?因为ServletRequest是通用的,不限于HTTP协议。如果直接用HttpServletRequest,需要强转类型:
HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpResp = (HttpServletResponse) res;强转的安全性取决于容器传入的实际对象类型。Tomcat这个Servlet容器下,传给service方法的就是HttpServletRequestWrapper的实现类,所以强转安全。但直接实现Servlet接口的意义在于兼容所有协议场景,所以不推荐强转。理解这一层,你就明白为什么GenericServlet和HttpServlet这些抽象类逐步演化出来了。
3.3 用注解配置映射:一行搞定URL匹配
代码写完之后需要告诉容器:哪些URL路径应该由这个Servlet处理。传统方式是写web.xml配置文件:
<servlet> <servlet-name>HelloServlet</servlet-name> <servlet-class>com.example.servlet.HelloServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>HelloServlet</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping>Java Web 3.0之后引入了@WebServlet注解,一行搞定:
@WebServlet("/hello")也可以写完整形式:
@WebServlet(name = "HelloServlet", urlPatterns = {"/hello", "/hi"})urlPatterns支持数组,意味着可以配置多个路径指向同一个Servlet。注解的本质是让容器在启动时扫描classpath下的注解,自动完成注册和映射,省去了写XML的繁琐。对于单一Servlet的映射来说,@WebServlet的价值非常明显,配置量从八行降到了一行。
不过底层原理要清楚:注解只是Web.xml的替代写法,最终容器拿到的注册信息和XML方式完全一致。也就是说,注解并没有改变Servlet规范本身,只是换了一种更简洁的声明方式。
3.4 部署运行与请求链路实测
把项目部署到Tomcat,启动后观察控制台输出,你会看到init方法的打印信息。这一步说明Servlet实例已经被创建并完成初始化。然后浏览器访问http://localhost:8080/servlet-demo/hello,页面显示"接收到GET请求",控制台打印service方法的信息。
这里有个非常重要的时序概念:第一次访问时,容器才创建Servlet实例并调用init,然后调用service。之后再次访问,不再调用init,只有service被调用。你可以刷新页面多次,控制台只有一条init日志,但有多条service日志。这就是生命周期单例模式的具体表现。
再做一个POST请求测试。用Postman或者写个简单的HTML表单发起POST请求,你会看到service方法里进入了POST分支。这一步很直观地验证了分发逻辑的正确性。
要注意Maven打包之后的版本号路径问题。默认打包的WAR文件名是servlet-demo-1.0-SNAPSHOT.war,访问路径要带上这个版本号。如果想简化,在pom.xml里配置finalName为servlet-demo,访问路径就干净了。
4. 常见问题与排查实录
4.1 访问出现404:先查映射路径再查部署状态
404是最常见的错误。原因通常有三个:URL路径写错了、Servlet注解配置的路径和实际访问不匹配、项目没有正确部署到Tomcat。排查顺序先确认访问URL里的上下文路径是否正确——就是部署之后的WAR包名称,再确认Servlet的urlPatterns是否和访问路径一致,最后看Tomcat启动日志里有没有报错。
我自己踩过的坑是上下文路径。IDEA部署的时候默认会带Artifact名称,比如项目叫servlet-demo,访问路径就是/servlet-demo_war_exploded/hello。如果你直接访问/hello,必然404。解决方式是在IDEA的Deployment配置里把Application context改成/。
4.2 500错误:看控制台堆栈信息
500表示服务器内部错误,通常是代码执行异常。比如service方法里调用了req.getParameter()方法在非HTTP请求上使用、强转类型失败、业务逻辑抛异常等。排查方式很简单:看Tomcat控制台的异常堆栈,定位到出错的行号。
有一个容易被忽略的坑是getWriter和getOutputStream不能同时调用。如果你想在service里既用PrintWriter又用OutputStream,会抛出IllegalStateException。解决方式是一次只能选一种方式输出。
4.3 中文乱码问题:漫不经心就会踩的坑
中文乱码分两种场景:响应乱码和请求参数乱码。
响应乱码是因为服务器返回的数据编码和浏览器解析的编码不一致。在service方法里加上这一行:
res.setContentType("text/html;charset=UTF-8");这句话让服务器以UTF-8编码输出内容,同时告诉浏览器用UTF-8解码。注意setContentType要在getWriter之前调用,否则写入的数据可能拿不到正确的编码设置。
请求参数乱码发生在表单提交的时候。GET请求的参数在URL上,POST请求的参数在请求体里。对于POST请求,浏览器以页面编码提交数据,服务器需要同样编码解析,解决办法是获取参数之前调用:
req.setCharacterEncoding("UTF-8");这个方法只对POST请求体有效,GET请求乱码需要在Tomcat配置里处理。
4.4 包名对不上:javax和jakarta的前世今生
新手最容易困惑的是导入的包名。用Tomcat 9,导入的是:
import javax.servlet.Servlet; import javax.servlet.annotation.WebServlet;如果用Tomcat 10或者Tomcat 11,导入的变成:
import jakarta.servlet.Servlet; import jakarta.servlet.annotation.WebServlet;不只是Servlet接口,所有相关类(HttpServletRequest、HttpServletResponse、ServletContext等)都从javax迁移到了jakarta。这背后的原因是Java EE把相关规范捐给了Eclipse基金会,品牌改名导致包名变更。
不确定性因素在于你用的IDE或教程可能默认Tomcat版本不同,导致代码导入的包名和容器自带的包名不一致。排查方式很简单:看编译报错提示的包是否存在,或者看Tomcat lib目录下的servlet-api.jar里是javax还是jakarta。
4.5 注解不生效:忘了开启注解扫描
在支持Servlet 3.0以上规范的容器里,注解默认就是开启的,一般不需要额外配置。但如果你用了web.xml,并且web.xml里的版本声明是2.5或者更低,容器会认为你不支持注解,从而忽略@WebServlet。
解决方法是在web.xml的根节点把版本改到3.0以上:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> </web-app>如果项目完全没有web.xml,默认就是最新版本规范,注解必然生效。
5. 实操经验总结与进阶建议
直接实现Servlet接口这件事,看起来代码写得多、效率低,但恰恰因为这个"慢",你才能真正理解容器和Servlet之间的信任关系。init方法为什么只执行一次,service为什么每请求调用一次,请求分发本质是什么,这些不问清楚,之后学Spring MVC的一堆抽象概念会非常吃力。
进阶方向上,建议按顺序做三件事。第一,学完这次内容后,去看GenericServlet的源码,你会发现它主要是把init的参数做保存、提供了一些简便方法,但service还是抽象的。第二,再看HttpServlet的源码,盯着service方法看它怎么调用doGet、doPost的,你会发现自己写的if判断和它内部逻辑几乎一致。第三,之后学Spring MVC的时候带着"DispatcherServlet也是一个Servlet"这个视角去看,理解路径解析、HandlerMapping这类概念会轻松很多。
最后分享一个排查技巧:在init、service、destroy三个方法里各加一条System.out.println,观察日志打印的顺序和时间点,这是理解Java Web请求全链路最直观的方式。等这些基础颗粒归仓之后,后续学框架、读源码,都会顺畅得多。