1. 这不是“过时技术”的复习课,而是现代Web架构的底层解剖刀
很多人看到“Servlet”三个字,第一反应是:这玩意儿不是十年前就该进博物馆了吗?Spring Boot一键生成、Node.js轻量路由、云原生Serverless函数满天飞——谁还手写doGet/doPost?我最初也这么想。直到去年参与一个某高校实验室的跨平台教学系统重构项目,后端要对接三套异构教务接口(老Java EE系统、新微服务网关、第三方SaaS API),要求响应延迟必须压在80ms内、并发承载不低于3000TPS,且所有请求链路必须可审计、可熔断、可灰度。团队试过Spring MVC拦截器链嵌套、试过WebFlux响应式流编排,最终上线前一周,核心网关模块在压测中频繁出现线程阻塞和上下文丢失。最后是把整个请求分发层抽出来,用原生Servlet+Filter链重写,配合自定义RequestWrapper和AsyncContext手动管理IO生命周期,才稳住水位。
这不是怀旧,是回归本质。Servlet规范从J2EE 1.2时代诞生至今,其核心契约——容器托管、生命周期可控、协议抽象清晰、线程模型明确——从未被真正替代。Spring MVC的DispatcherServlet本质仍是Servlet;Netty的HttpServerHandler在语义上复刻了service()方法;就连Cloudflare Workers的fetch事件,其request/response处理范式也脱胎于Servlet的“一次请求-一次响应”契约。关键词不是“Servlet”,而是“应用程序设计”——它指向的是一套经过二十年高并发、多协议、强安全场景锤炼的工程化方法论:如何让代码在容器的约束下既稳定又灵活,既可扩展又可追溯,既满足业务逻辑又不被框架绑架。
这篇文章不讲XML配置怎么写,不列@WebServlet注解的全部属性,更不会带你抄一段HelloWorld。我们要拆解的是:当你要设计一个真正落地的Servlet应用时,那些文档里不会明说、但线上事故里反复出现的决策点——比如为什么Filter链顺序不能乱、为什么AsyncContext必须手动complete、为什么HttpServletRequestWrapper要重写getInputStream而不是getReader、为什么在service()里直接new Thread是自杀行为。这些不是语法细节,而是架构权衡。你不需要记住所有API,但必须理解每个选择背后的代价。因为今天你写的每行doPost代码,明天都可能运行在百万QPS的网关里,而那个决定系统是否雪崩的临界点,往往就藏在一次未经思考的getReader()调用里。
2. Servlet容器不是“后台服务”,而是你代码的“操作系统内核”
很多开发者把Tomcat、Jetty当成一个“启动Web服务的工具”,就像用vscode打开一个文件夹。这是根本性误解。Servlet容器(Container)的本质,是为Java Web应用提供了一套受控的运行时环境,它承担着操作系统内核级别的职责:资源调度、生命周期管理、安全边界隔离、协议转换。理解这一点,是设计可靠应用的前提。
2.1 容器如何接管你的代码:从main()到service()的完整委托链
当你在web.xml里声明一个Servlet,或用@WebServlet("/api")标注一个类,你并没有“启动”它。你只是向容器注册了一个可实例化的模板。真正的执行流程如下:
加载与实例化:容器启动时,根据部署描述符(web.xml)或注解扫描结果,加载Servlet类字节码,调用无参构造器创建实例。注意:每个Servlet类在单个Web应用中只有一个实例(除非显式配置load-on-startup且为负值)。这个实例会被所有请求线程共享。
初始化(init):容器调用init(ServletConfig)方法,传入ServletConfig对象。此时ServletConfig已封装了web.xml中 定义的参数,以及ServletContext引用。init()是唯一允许执行耗时操作(如数据库连接池初始化、缓存预热)的安全时机。若init()抛出ServletException,容器将标记该Servlet不可用,后续请求直接返回503。
服务(service):这是核心。容器收到HTTP请求后,解析URL匹配到对应Servlet,创建HttpServletRequest和HttpServletResponse对象(注意:这两个对象每次请求都是新实例),然后调用service()方法。标准实现中,service()会根据request.getMethod()自动分发到doGet()、doPost()等。关键点在于:service()方法体内的所有代码,都运行在容器分配的工作线程中。这个线程可能来自Tomcat的NIO线程池(如org.apache.tomcat.util.threads.TaskThread),其生命周期完全由容器控制。
销毁(destroy):容器关闭或应用卸载时调用。用于释放资源(如关闭数据库连接、取消定时任务)。destroy()之后,该Servlet实例即失效,任何对其方法的调用都可能导致NPE或未定义行为。
提示:不要在Servlet实例变量中存储请求相关数据(如用户ID、token)。因为实例是共享的,而请求对象是线程局部的。常见错误是把request.getAttribute("user")赋值给servlet的private User user字段——这会导致多线程脏读。
2.2 线程模型:为什么“在doPost里开新线程”是反模式
Servlet规范明确要求:service()方法必须是线程安全的。容器通过两种方式保障:
- 单实例多线程模型:一个Servlet实例被多个工作线程并发调用。
- 请求对象线程局部性:每个请求的HttpServletRequest/Response对象只在当前工作线程内有效。
这意味着:你在doPost()里写的任何代码,都天然运行在容器管理的线程上。如果你在此处new Thread(() -> {...}).start(),会发生什么?
- 新线程脱离容器管理,无法访问ServletContext、无法使用容器提供的JNDI资源、无法参与容器级事务;
- 容器不知道这个线程的存在,因此在应用关闭时不会等待它结束,导致资源泄漏(如数据库连接未关闭);
- 更严重的是,新线程中调用
request.getReader()会失败——因为原始请求流已被容器的工作线程读取并缓冲,底层Socket连接可能已关闭。
正确做法是使用异步Servlet(Async Servlet)。其核心是request.startAsync(),它返回一个AsyncContext对象,将请求处理权从工作线程转移到你可控的线程池:
protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 1. 启动异步上下文,释放容器工作线程 AsyncContext asyncContext = request.startAsync(); // 2. 将耗时操作提交到自定义线程池(如ForkJoinPool.commonPool()) CompletableFuture.supplyAsync(() -> { // 模拟耗时操作:调用外部API、复杂计算 return callExternalService(request); }).thenAccept(result -> { // 3. 在回调中写入响应(注意:必须在asyncContext的线程中操作response) try (PrintWriter writer = asyncContext.getResponse().getWriter()) { writer.write("{\"status\":\"success\",\"data\":" + result + "}"); } catch (IOException e) { // 异常处理 } // 4. 必须显式complete,否则请求永远挂起 asyncContext.complete(); }); }注意:AsyncContext.complete()是强制调用项。漏掉它会导致连接不释放,最终耗尽容器线程池。我们曾在线上因一个未complete的异步日志记录,导致Tomcat线程数在2小时内从200涨到1000,触发全站503。
2.3 生命周期的隐含契约:ServletContext与ServletConfig的分工
ServletContext:代表整个Web应用的上下文,全局唯一。它在应用启动时创建,关闭时销毁。适合存放应用级共享数据(如配置中心客户端、全局缓存实例)、提供应用范围内的资源访问(getResourceAsStream()读取WEB-INF下的文件)、注册监听器(ServletContextListener)。
ServletConfig:仅对当前Servlet实例有效。它封装了该Servlet的初始化参数( ),并通过getServletContext()方法提供对全局上下文的引用。ServletConfig本身不存储状态,它只是一个参数传递通道。
常见误区是把所有配置都塞进ServletContext.setAttribute()。这会导致内存泄漏风险(尤其在热部署场景),且违背单一职责。正确实践是:
- 应用级配置(如数据库URL、Redis地址)→ 存入ServletContext,由ServletContextListener在contextInitialized()中初始化;
- Servlet级配置(如该接口的超时时间、是否启用缓存)→ 通过ServletConfig.getInitParameter()获取,在init()中解析并存为Servlet实例的final字段。
public class DataQueryServlet extends HttpServlet { private final long timeoutMs; // 实例字段,线程安全 private final boolean enableCache; @Override public void init(ServletConfig config) throws ServletException { super.init(config); // 从ServletConfig获取本Servlet专属参数 this.timeoutMs = Long.parseLong(config.getInitParameter("timeout")); this.enableCache = Boolean.parseBoolean(config.getInitParameter("cache-enabled")); } }3. Filter链:比AOP更底层的横切逻辑控制台
如果说Servlet是处理请求的“主干道”,那么Filter就是架设在主干道上的“收费站”、“安检门”和“气象站”。它不处理业务逻辑,但决定了业务逻辑能否被执行、以何种条件被执行、执行后如何被包装。Filter链的设计质量,直接决定应用的安全性、可观测性和可维护性。
3.1 Filter的执行时机与责任边界:从请求进入容器到响应离开容器的全程监控
Filter的生命周期由容器管理,其核心方法doFilter(ServletRequest, ServletResponse, FilterChain)会在以下两个关键节点被调用:
- 请求阶段:在Servlet的service()方法执行之前;
- 响应阶段:在Servlet的service()方法执行之后,响应数据写回客户端之前。
FilterChain对象是关键。调用chain.doFilter(request, response),意味着将控制权交给链中的下一个Filter(或最终的Servlet)。Filter链的顺序由web.xml中 的声明顺序决定,或由@WebFilter注解的value属性(URL模式)和dispatcherTypes共同决定。顺序错误会导致逻辑错乱。
典型Filter链设计(按执行顺序):
- CharsetEncodingFilter:设置request.setCharacterEncoding("UTF-8")和response.setCharacterEncoding("UTF-8")。必须放在最前面,否则后续Filter或Servlet读取参数时会出现乱码。
- SecurityFilter:校验JWT Token、检查IP白名单、验证CSRF Token。若校验失败,直接
response.sendError(401)并return,不调用chain.doFilter()。 - LoggingFilter:记录请求URL、Method、耗时、响应状态码。注意:必须在chain.doFilter()前后分别记录开始和结束时间,才能准确计算耗时。
- CachingFilter:对GET请求检查ETag或Last-Modified,若命中则直接write304并return。
- CompressionFilter:对响应内容进行GZIP压缩(需设置response.setHeader("Content-Encoding", "gzip"))。
注意:Filter中修改request或response对象,会影响后续所有Filter和Servlet。例如,LoggingFilter中调用
request.setAttribute("startTime", System.currentTimeMillis()),后续Filter可通过request.getAttribute("startTime")获取。但修改request.getInputStream()或response.getOutputStream()需极度谨慎——必须使用Wrapper类(见3.2节),否则会破坏原始流。
3.2 Request/Response Wrapper:安全改造请求与响应的唯一合法途径
你想在日志中打印完整的请求Body?想统一添加响应Header?想对JSON请求体做预处理?直接调用request.getInputStream()或response.getOutputStream()是危险的,因为:
- InputStream只能被读取一次,后续Servlet再读就会得到空流;
- OutputStream一旦被写入,后续Filter或Servlet的write操作会覆盖或报错。
解决方案是使用Wrapper模式:继承HttpServletRequestWrapper/HttpServletResponseWrapper,重写关键方法,内部持有一个可重复读取的缓冲区。
以记录RequestBody为例:
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper { private final byte[] cachedContent; public ContentCachingRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 一次性读取并缓存整个请求体 InputStream is = request.getInputStream(); this.cachedContent = is.readAllBytes(); // Java 9+ } @Override public ServletInputStream getInputStream() throws IOException { // 返回基于缓存内容的新输入流 ByteArrayInputStream bais = new ByteArrayInputStream(cachedContent); return new ServletInputStream() { private final InputStream delegate = bais; @Override public int read() throws IOException { return delegate.read(); } @Override public boolean isFinished() { return false; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) {} }; } // 提供便捷方法获取缓存内容 public String getRequestBodyAsString() throws IOException { return new String(cachedContent, StandardCharsets.UTF_8); } }在Filter中使用:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 包装请求,使其可多次读取 ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(httpRequest); // 记录日志 try { log.info("Request: {} {}, Body: {}", httpRequest.getMethod(), httpRequest.getRequestURI(), wrappedRequest.getRequestBodyAsString()); } catch (IOException e) { log.warn("Failed to read request body", e); } // 将包装后的请求传递下去 chain.doFilter(wrappedRequest, response); }警告:Wrapper类必须重写所有可能被调用的方法。例如,如果Servlet调用
request.getReader()而非getInputStream(),你还需要重写getReader()方法,返回基于cachedContent的BufferedReader。否则会抛出IllegalStateException。
3.3 Filter链的致命陷阱:异常传播与资源清理
Filter链中任何一个Filter抛出未捕获异常,都会中断整个链,导致后续Filter和Servlet完全不执行。这既是保护机制,也是隐患。
安全Filter抛出异常:如SecurityFilter发现非法Token,应主动
throw new ServletException("Invalid token"),由容器统一处理为500错误。但更好的做法是response.sendError(401)并return,避免异常栈污染日志。资源泄漏风险:Filter中打开了数据库连接、文件句柄、网络Socket,必须确保在finally块中关闭。尤其在调用
chain.doFilter()后,仍需处理响应阶段的清理:public void doFilter(...) { long startTime = System.currentTimeMillis(); try { chain.doFilter(request, response); // 可能抛出异常 } finally { // 无论成功或失败,都记录耗时 long duration = System.currentTimeMillis() - startTime; metrics.record("filter.duration", duration); } }
4. 请求处理的深度控制:从参数解析到响应生成的全链路拆解
Servlet的核心价值,不在于它能“接收HTTP请求”,而在于它提供了对HTTP协议各层要素的精细、低阶、无框架干预的控制能力。这种控制力在需要极致性能、特殊协议适配或深度安全审计的场景下,无可替代。
4.1 参数解析的三种路径:getQueryString、getInputStream与getReader的抉择逻辑
HTTP请求的参数(Query String、Form Data、JSON Body)并非自动“解析好”交给你。Servlet API提供了三套原始访问接口,选择哪一套,决定了你的应用健壮性。
| 访问方式 | 适用场景 | 关键特性 | 风险点 |
|---|---|---|---|
request.getParameter("name") | GET查询参数、POST表单(application/x-www-form-urlencoded) | 容器自动URL解码、字符集转换(依赖request.setCharacterEncoding()) | 对JSON Body无效;若未设置编码,中文参数乱码 |
request.getInputStream() | 任意二进制数据(JSON、XML、文件上传) | 返回原始字节流,无任何转换 | 只能读取一次;需手动处理字符集(new String(bytes, "UTF-8")) |
request.getReader() | 文本型Body(JSON、XML) | 返回字符流,自动按request.getCharacterEncoding()解码 | 与getInputStream互斥;若先调用getInputStream,再调用getReader会抛异常 |
决策树:
- 如果是GET请求 → 用
getParameter(); - 如果是POST且Content-Type为
application/x-www-form-urlencoded→ 用getParameter(); - 如果是POST且Content-Type为
application/json、text/xml等 →优先用getInputStream(),原因:getReader()的字符集依赖request.getCharacterEncoding(),而JSON规范要求UTF-8,但客户端可能不声明charset=utf-8,导致容器默认用ISO-8859-1解码,中文变乱码;getInputStream()拿到原始字节,你可以用Jackson ObjectMapper直接解析(ObjectMapper默认UTF-8),绕过容器的字符集转换。
// 安全的JSON解析 InputStream is = request.getInputStream(); ObjectMapper mapper = new ObjectMapper(); MyRequestDto dto = mapper.readValue(is, MyRequestDto.class); // 内部自动处理UTF-8经验:在Filter中统一调用
request.setCharacterEncoding("UTF-8"),并不能解决JSON Body的乱码问题,因为setCharacterEncoding()只影响getParameter()和getReader(),对getInputStream()无作用。根源在HTTP协议:JSON规范强制UTF-8,容器不应介入解码。
4.2 响应生成的黄金法则:状态码、Header与Body的协同艺术
HttpServletResponse不是简单的“输出流”,而是一个状态机。它的三个核心组件必须协同设置,否则会产生意料之外的行为。
HTTP状态码(Status Code):通过
response.setStatus(int)或response.sendError(int, String)设置。sendError()会自动设置状态码、写入错误消息体,并禁止后续写入(调用getWriter()会抛IllegalStateException)。业务逻辑中,应优先使用sendError()处理错误分支。响应Header:通过
response.setHeader(String, String)或response.addHeader(String, String)设置。关键Header包括:Content-Type: 必须在调用getWriter()或getOutputStream()之前设置,否则可能被忽略;Content-Length: 若手动设置,必须精确匹配Body字节数,否则浏览器可能截断或等待超时;Cache-Control,ETag,Last-Modified: 控制客户端缓存行为;Set-Cookie: 设置Cookie,注意HttpOnly、Secure标志位。
响应Body:通过
response.getWriter()(文本)或response.getOutputStream()(二进制)获取。二者互斥:调用其中一个后,再调用另一个会抛IllegalStateException。
最佳实践流程:
protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 1. 设置状态码(可选,若为200可省略) response.setStatus(HttpServletResponse.SC_OK); // 2. 设置Header(必须在获取Writer/Stream之前) response.setContentType("application/json;charset=UTF-8"); response.setHeader("X-App-Version", "1.2.0"); // 3. 获取Writer并写入Body try (PrintWriter writer = response.getWriter()) { writer.write("{\"code\":0,\"message\":\"success\"}"); // writer.flush() 可省略,close()会自动flush } catch (IOException e) { // 记录错误,但不要在此处调用sendError(),因为Writer已获取 log.error("Failed to write response", e); } }注意:
response.getWriter()返回的PrintWriter,其字符集由setContentType("...;charset=UTF-8")中的charset决定。若未指定charset,默认为ISO-8859-1,中文会乱码。这是线上最常见的乱码根源。
4.3 文件上传的底层实现:为什么Apache Commons FileUpload仍是首选
Servlet 3.0+引入了request.getPart()和@MultipartConfig,但其底层仍依赖容器实现,且对大文件、进度监控、内存溢出防护支持有限。生产环境推荐使用Apache Commons FileUpload,因为它提供了对InputStream的完全控制。
核心步骤:
- 从
request.getInputStream()获取原始HTTP请求流; - 使用
ServletFileUpload解析multipart/form-data边界; - 遍历
FileItem,区分普通字段(isFormField()==true)和文件字段(isFormField()==false); - 对文件字段,调用
item.getInputStream()获取文件内容流,直接写入磁盘或云存储,避免加载到内存。
// 配置FileUpload DiskFileItemFactory factory = new DiskFileItemFactory(); factory.setRepository(new File("/tmp/upload")); // 临时文件目录 ServletFileUpload upload = new ServletFileUpload(factory); upload.setFileSizeMax(10 * 1024 * 1024); // 单文件10MB upload.setSizeMax(50 * 1024 * 1024); // 总请求50MB List<FileItem> items = upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { // 处理普通字段 String name = item.getFieldName(); String value = item.getString("UTF-8"); // 指定字符集 } else { // 处理文件字段 String fileName = item.getName(); InputStream fileStream = item.getInputStream(); // 原始文件流 // 直接上传到OSS或写入本地磁盘 ossClient.putObject(bucket, key, fileStream); } }踩坑经验:
item.getString()会将整个文件内容加载到内存再转字符串,对大文件是灾难。务必对文件字段使用getInputStream()。我们曾因一个误用getString()的图片上传接口,导致JVM堆内存瞬间打满,触发Full GC。
5. 生产级Servlet应用的四大支柱:监控、安全、容错与可维护性
一个能跑通HelloWorld的Servlet,和一个能支撑百万用户的生产系统,中间隔着四堵墙:监控埋点、安全加固、故障隔离、代码可维护。这些不是“锦上添花”,而是“生死线”。
5.1 监控埋点:从容器指标到业务黄金指标的贯通
Servlet应用的监控不能只看JVM内存、CPU。必须打通容器层、应用层、业务层指标。
容器层指标(通过JMX暴露):
Catalina:type=ThreadPool,name="http-nio-8080":currentThreadCount(当前线程数)、maxThreads(最大线程数)、processingCount(正在处理请求数);Catalina:type=GlobalRequestProcessor,name="http-nio-8080":requestCount(总请求数)、errorCount(错误数)、processingTime(总处理时间)。
应用层指标(Filter中埋点):
- 每个请求的耗时(P95/P99)、状态码分布(2xx/4xx/5xx占比)、慢查询(>1s)比例;
- 关键业务操作的成功率(如“支付创建”、“订单查询”)。
业务黄金指标(在Servlet业务逻辑中埋点):
- 核心转化率(如“加购→下单→支付”漏斗);
- 业务SLA达标率(如“99%的订单查询响应<200ms”)。
实现方案:使用Micrometer + Prometheus。在Filter中创建Timer:
// 全局MeterRegistry private static final Timer REQUEST_TIMER = Timer.builder("servlet.request.time") .tag("method", "POST") .tag("path", "/api/order") .register(Metrics.globalRegistry); public void doFilter(...) { Timer.Sample sample = Timer.start(Metrics.globalRegistry); try { chain.doFilter(request, response); } finally { sample.stop(REQUEST_TIMER); } }关键:Timer必须在try-finally中stop,确保即使发生异常也能记录耗时。否则监控数据会严重失真。
5.2 安全加固:超越OWASP Top 10的Servlet原生防护
Servlet提供了原生API应对常见漏洞,无需依赖框架:
- SQL注入:永远使用PreparedStatement,禁用字符串拼接。Servlet本身不处理DB,但
request.getParameter()获取的参数是注入源头,必须在DAO层严格防御。 - XSS(跨站脚本):对所有输出到HTML页面的动态内容,使用OWASP Java Encoder进行HTML编码:
Encode.forHtml(request.getParameter("name"))。 - CSRF(跨站请求伪造):在Filter中验证
X-Requested-With头(AJAX请求通常有),或在表单中嵌入隐藏Token(<input type="hidden" name="csrf_token" value="<%=session.getAttribute("csrf_token")%>">),并在Filter中比对。 - 敏感信息泄露:禁用
<error-page>的<exception-type>java.lang.Throwable</exception-type>,避免堆栈信息暴露。自定义错误页,只返回通用错误码。
5.3 容错设计:降级、熔断与优雅关闭的Servlet实践
- 降级:在Filter中检测下游服务(如Redis、MySQL)健康状态。若连续3次超时,自动切换到本地缓存或返回兜底数据。
- 熔断:使用Resilience4j的CircuitBreaker。在Servlet中调用外部API前,先
circuitBreaker.tryAcquirePermission(),失败则执行降级逻辑。 - 优雅关闭:实现
ServletContextListener,在contextDestroyed()中:- 关闭所有异步任务线程池(
executor.shutdown(); executor.awaitTermination(30, TimeUnit.SECONDS)); - 清理缓存(
cache.clear()); - 确保所有AsyncContext已complete(遍历
AsyncContext列表并调用complete())。
- 关闭所有异步任务线程池(
5.4 可维护性:Servlet设计的三大反模式与重构路径
反模式1:胖Servlet
所有逻辑(参数解析、业务处理、响应组装)挤在一个doPost()里。
重构:提取为Command模式,每个Servlet只负责路由和协调,具体逻辑由独立Service类处理。反模式2:静态工具类滥用
StringUtils.isEmpty()、DateUtils.format()等静态方法遍布代码,导致单元测试困难。
重构:引入依赖注入(如Google Guice),将工具类封装为单例Service,便于Mock。反模式3:硬编码配置
数据库URL、超时时间写死在代码里。
重构:通过ServletContext初始化参数或外部配置中心(如Apollo)注入,Servlet通过getInitParameter()获取。
我在某跨平台教学系统的网关模块中,将上述原则全部落地。上线后,平均响应时间从320ms降至68ms,P99延迟稳定在150ms内,月度故障率从12次降至0。最深的体会是:Servlet不是古董,它是Web开发的“汇编语言”——你不必天天写,但必须懂它。因为所有高级框架,最终都要翻译成Servlet的字节码去执行。当你能看清service()方法背后那条由容器、线程、流、编码构成的精密流水线时,你就拥有了重构任何Web架构的底气。