☰
Servlet 核心原理与实战:从 Tomcat、Maven 到调用大模型 API
2026/10/1 19:44:06 网站建设 项目流程

很多人学 Java Web 的路子是从 Spring Boot 起步的:新建一个工程,写个@RestController,跑起来就能返回 JSON,舒服得让人误以为 Web 开发本来就这么简单。直到某天线上出了个诡异问题——请求参数莫名其妙丢了、过滤器顺序不对、静态资源被拦截——翻遍 Spring 的文档才发现,真正在背后干活的东西压根不是框架,而是那个被无数教程一笔带过的 Servlet。它不是什么花哨的新技术,也不是可以跳过的基础,而是整个 Java 服务端体系的地基。这篇就按我自己的学习与踩坑路径,把 Servlet 是什么、起什么作用、怎么用 Maven 在 Eclipse 里跑起来、以及怎么让一个 Servlet 去调用大模型 API,完整地梳理一遍,适合刚接触 Java Web 的同学,也适合用过框架但没搞懂底层的朋友。

1. Servlet 不是框架,它是 Java Web 的地基

1.1 一次 HTTP 请求在容器里到底走了哪些路

要理解 Servlet 是做什么的,最直接的办法是跟着一次请求走一遍。当你在浏览器里敲下http://localhost:8080/hello?name=abc并回车,发生的事情大致是这样的:浏览器把这一串东西翻译成一段符合 HTTP 协议的文本,通过 TCP 连接发给 8080 端口;监听这个端口的是一个叫 Tomcat 的程序,它先做协议解析,把请求行、请求头、请求体拆成结构化的数据;接着它要判断这个路径/hello应该交给谁处理——这个"谁",就是 Servlet。

换句话说,Servlet 的本质就是运行在服务端的一个 Java 类,专门用来接收 HTTP 请求并生成 HTTP 响应。它不是一个独立的可执行程序,也不能自己启动,必须寄生在一个"容器"里。容器负责网络通信、协议解析、线程调度、生命周期管理这些脏活累活,Servlet 只管业务逻辑:拿到参数,算一算,写个结果回去。这个分工非常关键,它解释了为什么我们写 Servlet 时从来不用new ServerSocket(8080)——因为容器已经替我们做完了。

理解这一层之后,很多模糊的概念就清晰了:Spring MVC 的DispatcherServlet本身就是一个 Servlet,它把自己注册到容器上,然后把请求再分发给各个 Controller。所以你用的框架,本质上是在 Servlet 之上又盖了一层。

1.2 Servlet、Servlet 容器、Tomcat 三者的关系别搞混

初学者最容易把这三个词混着用,其实它们是三个层次的东西:

名称性质职责
Servlet一套接口规范定义"处理请求的组件"应该长什么样,有哪几个方法
Servlet 容器规范的具体实现负责加载 Servlet、管理生命周期、解析 HTTP、分配线程
Tomcat一种容器产品最常见的容器之一,另外还有 Jetty、Undertow 等

打个比方:Servlet 规范像是"插座国标",规定了三个孔的尺寸和位置;Tomcat 像是按照这个国标生产出来的插线板;而你写的 Servlet 类,就是那个要插上去的电器。只要符合国标,插线板可以换品牌,电器也能插到别家的板子上——这就是为什么你的 Web 应用从 Tomcat 迁到 Jetty 时,业务代码几乎不用动。

这个类比还有一层意思:插座国标里只规定了"三个孔",没规定电器内部怎么工作。Servlet 接口也一样,它只规定了init、service、destroy这几个方法,至于你在service里查数据库还是调接口,规范不管。

1.3 框架时代,为什么还得回头啃 Servlet

我见过太多人跳过 Servlet 直接学 Spring Boot,写业务没问题,但一碰到下面这些场景就抓瞎:

  • 想自己写一个过滤器做统一的请求日志,搞不清楚Filter和Interceptor谁先执行、为什么request里的 body 读一次就没了;
  • 需要处理文件上传,不知道multipart/form-data是怎么被容器解析成Part对象的;
  • 排查 404,只能靠猜,不知道容器的 URL 匹配规则是精确匹配优先还是通配匹配优先;
  • 想做一个流式输出的接口,不清楚response.getWriter()和getOutputStream()为什么不能同时用。

这些问题的答案全在 Servlet 规范里。掌握它之后,你看框架源码时会有一种"原来如此"的通透感——框架并没有变魔术,它只是在 Servlet 的基础上封装了更好用的 API。而且说实话,Servlet 本身的学习成本并不高,接口方法加起来也就十来个,投入产出比相当划算。

2. Servlet 接口的骨架与生命周期拆解

2.1 init、service、destroy 分别在什么时候被触发

Servlet 接口定义的方法不多,但每一个的调用时机都有讲究,搞错时机用错地方是新手常犯的毛病。

init(ServletConfig config)在 Servlet 实例被创建之后、开始服务之前调用,整个生命周期内只调用一次。它的典型用途是读取初始化参数、建立数据库连接池、加载配置文件。这里有个细节:如果你在init里做了很耗时的操作,那么第一个请求会被卡住,直到初始化完成,所以能异步化的初始化尽量异步。另外init还有无参版本init(),如果你想重写又不想操心ServletConfig,覆盖无参的那个更省事。

service(ServletRequest req, ServletResponse res)是真正干活的入口,每次请求都会调用一次。绝大多数情况下你不应该直接覆盖它,而是继承HttpServlet后覆盖doGet、doPost等方法。原因在于HttpServlet.service()已经帮你把请求方法判断、HEAD/OPTIONS等特殊方法的处理都写好了,你只需要关心具体业务。如果你硬要覆盖service,就得自己处理GET、POST的分支,纯属自找麻烦。

destroy()在 Servlet 被容器移除之前调用,也只调用一次,用来释放资源:关闭连接池、停止后台线程、清理临时文件。注意它不保证一定会被调用——如果容器是被强制杀进程的,destroy就没机会执行,所以关键的数据落盘不能只依赖它。

一个容易被问到的点是:Servlet 实例是单例的吗?答案是默认是单例的,容器只创建一个实例,然后用多个线程并发调用它的service方法。这个设计决定了后面要讲的线程安全问题。

2.2 ServletConfig 和 ServletContext 一字之差,用途差很远

这两个接口名字长得像,很多人背完就忘。区分它们的办法是看作用范围:

  • ServletConfig:每个 Servlet 独有一份。它的主要用途是读取当前 Servlet 在web.xml或注解里配置的初始化参数<init-param>,还能拿到ServletContext的引用。比如某个 Servlet 需要知道一个回调地址,就适合放在这里。
  • ServletContext:整个 Web 应用共享一份。它代表整个应用,可以读取全局的上下文参数<context-param>、存取应用级别的属性setAttribute、获取资源的真实路径getRealPath、以及拿到RequestDispatcher做转发。

实战里最常见的用法是用ServletContext存全局配置,比如一个应用启动时从配置文件读出来的环境标识,所有 Servlet 都能读到。但要注意一点:因为它是全局共享的,往里面塞可变对象时一定要考虑并发问题,用ConcurrentHashMap这类结构而不是普通的HashMap。

2.3 request 和 response 里几个坑过无数人的细节

HttpServletRequest和HttpServletResponse是日常打交道最多的两个对象,但它们有两个隐藏规则,不踩一次坑很难记住。

第一,输入流和输出流都是"一次性"的。request.getInputStream()和request.getReader()两者只能用一个,且读完之后不能重读;response.getWriter()和response.getOutputStream()也是二选一,混用会直接抛IllegalStateException。这个特性在做签名校验、日志记录时特别容易出问题——过滤器里把 body 读走了,后面的业务代码就读不到了。解决办法是包一层自定义的HttpServletRequestWrapper,把内容缓存到byte[]里。

第二,设置响应内容类型要在写数据之前。response.setContentType("application/json;charset=UTF-8")必须在getWriter()之前调用才有效,因为一旦开始写响应体,响应头就已经发出去了,之后再改也没用。这个顺序问题在返回 JSON 接口时最容易翻车,表现为浏览器把 JSON 当纯文本或者中文变乱码。

3. 用 Eclipse + Maven 从零搭一个能跑的 Servlet 工程

3.1 为什么这个组合值得单独讲一遍

现在主流是用 IDEA,但 Eclipse 配上 Maven 依然是不少学校、不少公司的标配,而且它的配置步骤更"裸露",反而更容易看清一个 Web 工程到底由哪些部分组成。更重要的原因是:搞懂 Maven 的目录约定和打包方式,比记住某个 IDE 的按钮在哪有价值得多。

一个标准的 Maven Web 工程,骨架长这样:

my-servlet-demo ├── pom.xml └── src └── main ├── java // Java 源码 ├── resources // 配置文件 └── webapp // Web 资源根目录 ├── WEB-INF │ └── web.xml └── index.jsp

webapp这个目录名是 Maven 约定的 Web 资源根,容器启动时它就相当于网站的根路径。WEB-INF下面的东西不能通过 URL 直接访问,所以放web.xml和类文件是安全的,别把 JSP 之外的敏感文件放到webapp根下。

3.2 servlet-api 依赖的 scope 为什么必须写 provided

在pom.xml里加 Servlet 相关依赖时,很多人随手一写就把scope漏了,结果打包出一个巨大的 war,甚至和容器自带的类冲突。正确写法是:

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>

provided的意思很直白:编译和测试时需要,打包时不要带进去。原因在于HttpServlet、HttpServletRequest这些类,Tomcat 自己已经提供了,你的 war 里再塞一份就是重复。真放进去会怎样?轻则包体积变大,重则出现ClassCastException或者方法签名对不上——因为类加载器加载了两份同名类,你的代码用 A 加载器加载的类去接收 B 加载器创建的对象,直接类型不匹配。

顺便说一个版本选择的坑:Tomcat 9 及以前用的是javax.servlet包名,Tomcat 10 开始换成了jakarta.servlet。如果你换了 Tomcat 版本但依赖没换,会出现"类找不到"的报错。选依赖时先确认容器版本,这一步做对了能省掉后面无数排查时间。

3.3 web.xml 和 @WebServlet 两种注册方式怎么选

注册一个 Servlet 有两条路。老派做法是在web.xml里写:

<servlet> <servlet-name>hello</servlet-name> <servlet-class>com.demo.HelloServlet</servlet-class> <init-param> <param-name>appId</param-name> <param-value>1001</param-value> </init-param> </servlet> <servlet-mapping> <servlet-name>hello</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping>

新派做法是在类上加注解:

@WebServlet(name = "hello", urlPatterns = "/hello", initParams = @WebInitParam(name = "appId", value = "1001")) public class HelloServlet extends HttpServlet { ... }

两者功能上等价,但注解更省事、改动更集中。那为什么还要了解web.xml?因为它有两个注解替代不了的作用:一是配置<context-param>、<welcome-file-list>、错误页面映射这类全局项,只能写在web.xml里;二是有些老项目、有些需要按环境切换映射路径的场景,仍然靠 XML 来做。我的建议是:Servlet 的映射用注解,全局配置用 web.xml,各取所长。

这里还有一个必须记住的规则:不要给同一个 Servlet 同时配注解和 XML 映射,容易注册两次,导致访问一次却执行两遍业务逻辑。

3.4 部署到 Tomcat 并验证的完整流程

在 Eclipse 里跑通一个 Servlet,步骤可以梳理成一条线:

  1. 新建 Maven 工程时选择maven-archetype-webapp骨架,这样目录结构自动就对了。
  2. 在pom.xml里补上servlet-api的provided依赖,并且把<packaging>设成war。
  3. 右键工程 →Properties→Project Facets,把Dynamic Web Module的版本勾上,确保 Eclipse 认得出这是 Web 工程。
  4. 在Servers视图里新建一个 Tomcat 实例,把工程Add and Remove进去。
  5. 右键 Tomcat →Start,然后在浏览器访问http://localhost:8080/工程名/hello。

访问不通时,先按下面的顺序排查,能解决八成的部署问题:

现象可能原因检查点
404路径写错或映射没生效工程名 + url-pattern 拼对了吗;注解是否被容器扫描到
500 且提示类找不到依赖 scope 或版本不对servlet-api 是不是 provided;包名是 javax 还是 jakarta
启动报端口占用8080 被别的程序占了换个端口或结束占用进程
改了代码没生效容器没重新加载类重新 publish 或重启 Tomcat

4. 让 Servlet 去调用大模型 API:一个能落地的实战

4.1 为什么不建议在 service 方法里直接同步阻塞调用

大模型接口有个明显特点:响应慢。普通接口几十毫秒返回,大模型的推理加上网络往返,几秒到几十秒都很正常。而 Servlet 的service方法是由容器的请求线程执行的,这个线程来自一个固定大小的线程池(Tomcat 默认maxThreads是 200)。

如果你的 Servlet 在service里同步等着大模型返回,那么这段时间线程是被占住的,什么也干不了。并发一上来,200 个线程全被这几个慢请求占满,后面的请求直接排队甚至超时。这就是所谓的"慢请求拖垮线程池"。

所以在实战里我会这么做:如果是短交互且并发不高,同步调用可以接受,但一定要设超时时间,别让线程无限期挂着;如果是面向多用户的对话场景,要么用异步 Servlet(startAsync)把线程释放掉,要么把请求丢给一个独立的线程池处理,容器线程立刻返回。这是我在实际项目里反复权衡后得出的结论——能用异步就别让容器线程等。

4.2 用 HttpClient 发起请求的完整写法

Java 11 之后自带了java.net.http.HttpClient,比老的HttpURLConnection好用太多,推荐直接用。一个封装好的调用方法大概长这样:

import java.net.URI; import java.net.http.*; import java.time.Duration; public class LlmClient { private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); public static String chat(String apiKey, String prompt) throws Exception { String body = "{" + "\"model\":\"your-model-name\"," + "\"messages\":[{\"role\":\"user\",\"content\":\"" + escape(prompt) + "\"}]," + "\"stream\":false" + "}"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://your-api-host/v1/chat/completions")) .timeout(Duration.ofSeconds(60)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); } private static String escape(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); } }

这里有几个我自己踩出来的经验点:

  • HttpClient应该是全局复用一个实例,不要每次请求都new一个。它内部维护连接池,反复创建会白白浪费连接建立的开销。
  • connectTimeout和控制单次请求的timeout是两回事,前者管建连,后者管整个请求生命周期,两个都要设。
  • 拼 JSON 字符串时一定要转义引号和反斜杠,否则用户输入里的双引号会把 JSON 结构搞坏。生产环境建议引入 Jackson 或 Gson 来序列化,而不是手拼字符串——手拼只在写 Demo 时偷懒用。

然后在一个 Servlet 里调用它:

@WebServlet("/llm") public class LlmServlet extends HttpServlet { private String apiKey; @Override public void init() { this.apiKey = System.getenv("LLM_API_KEY"); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); String prompt = req.getParameter("prompt"); try { String result = LlmClient.chat(apiKey, prompt); resp.getWriter().write(result); } catch (Exception e) { resp.setStatus(502); resp.getWriter().write("{\"error\":\"upstream failed\"}"); } } }

4.3 想把回答一个字一个字推给前端,得处理 SSE

大模型返回长文本时,如果能像打字机一样逐步显示,体验会好很多,这就用到流式返回。协议上通常走 SSE(Server-Sent Events),也就是响应类型设成text/event-stream,服务端不断地往响应流里写数据块。在 Servlet 里实现大概是这个思路:

resp.setContentType("text/event-stream;charset=UTF-8"); resp.setHeader("Cache-Control", "no-cache"); PrintWriter writer = resp.getWriter(); // 上游流式接口逐块返回,这里循环读取并转发 while ((chunk = readFromUpstream()) != null) { writer.write("data: " + chunk + "\n\n"); writer.flush(); // 关键:不 flush 前端收不到 } writer.write("data: [DONE]\n\n"); writer.flush();

这里有三个必须注意的地方。第一是flush(),Servlet 默认会缓冲输出,你不主动刷新,数据就会攒在缓冲区里,前端看起来就像卡住了。第二是加Cache-Control: no-cache之类的头,避免中间环节缓存了流式内容。第三是客户端断开连接要能感知到,writer.write在连接断开后会抛异常,要捕获并跳出循环,否则后台线程会一直空转。

另外提醒一句,流式场景下不要再手动设置Content-Length,长度是未知的,容器会用分块传输编码来处理。

4.4 密钥管理、并发和超时这几个真心容易翻的点

把大模型接口接进来之后,安全和稳定性问题会集中冒出来,我列几条实际的应对办法:

  • 密钥绝不能写死在代码里,也绝不能返回给前端。上面例子里用的是环境变量,你也可以用容器的context-param从外部配置文件读。不管哪种,密钥只在服务端流转,前端发来的请求里只带用户输入。
  • 输入长度要有上限。用户随手贴一篇几万字的文章进来,既浪费额度也可能超出模型限制,在 Servlet 里先做长度校验再发出去。
  • 超时必须显式设置,而且要给用户一个明确的失败反馈。默认不设超时的话,请求可能挂几分钟,用户体验极差。
  • 限制并发。哪怕用了异步,上游也是有限流配额的。我会用一个Semaphore或者有界线程池把并发压住,超出就快速失败并返回"当前繁忙",比让请求堆积到超时更友好。
  • 日志里不要打印完整请求体和响应体,里面可能包含用户隐私内容,打印长度和状态码就够了。

5. 踩坑记录:Servlet 开发里反复出现的几个问题

5.1 404 和 405,本质上是在考容器的匹配规则

404 和 405 是新手最常撞的两个状态码,它们的区别很清晰:404 是"这个地址没有对应的处理者",405 是"地址找到了,但不支持你用的请求方法"。

404 的常见成因有几个:url-pattern写成了/hello/(多了斜杠就是另一条路径);注解里的路径和访问路径大小写不一致;工程名没拼上;或者web.xml里的映射被注解映射覆盖了。排查时有个小技巧:把容器启动日志调到FINE级别,它会打印出所有注册过的 Servlet 和它们的映射路径,对着看一目了然。

405 则大多是因为浏览器地址栏直接访问走的是GET,而你只写了doPost。这种情况要么补上doGet,要么用表单或接口工具发POST。还有一个隐蔽的坑:如果你重写了doPost但里面调用了super.doPost(...),父类默认返回的也是 405,别被这个绕进去。

5.2 中文乱码的三个来源,要分开治

乱码问题几乎每个 Java Web 新手都遇到过,但乱码的成因其实分三种,得对症下药。

第一种是请求参数乱码。GET请求的参数编码由容器决定,Tomcat 8 之后默认就是 UTF-8,一般不用管;POST请求的表单数据编码由请求体的字符集决定,默认可能是 ISO-8859-1,需要在读取参数之前调用request.setCharacterEncoding("UTF-8")。注意顺序,必须在第一次getParameter之前设置,晚了就不生效。

第二种是响应乱码。解决办法是设置response.setContentType("text/html;charset=UTF-8"),或者response.setCharacterEncoding("UTF-8")。前者更推荐,因为同时告诉了浏览器怎么解析。

第三种是编译期乱码。源码文件的编码和maven-compiler-plugin里配置的encoding不一致,导致中文字符串在编译时就已经坏了。这个最隐蔽,表现是代码里写死的中文在页面上全是问号。解决办法是在pom.xml里明确设置project.build.sourceEncoding为 UTF-8,并且让 IDE 的工程编码和它保持一致。

我一般的做法是写一个CharacterEncodingFilter,统一在里面设置请求和响应的编码,一次配置全局生效,比在每个 Servlet 里重复写要省心。

5.3 单例 Servlet 的成员变量:一个非常安静的陷阱

前面提到 Servlet 默认是单例的,容器用多个线程并发调用同一个实例的service方法。这意味着Servlet 里的成员变量是所有请求共享的。看下面这段代码:

public class BadServlet extends HttpServlet { private String user; // 危险:所有请求共享 protected void doGet(HttpServletRequest req, HttpServletResponse resp) { this.user = req.getParameter("user"); // 中间有耗时操作 resp.getWriter().write("hello " + this.user); } }

在高并发下,A 用户设置完user,还没来得及写响应,B 用户就把user覆盖成了自己的名字,结果 A 看到的可能是 B 的名字。这种 bug 特别难复现,本地单线程测试永远是对的。

正确做法是所有跟单次请求相关的数据都放在方法局部变量里,实例成员只保留那些真正全局共享且不可变的东西,比如ServletConfig、配置好的HttpClient、线程安全的缓存。这是一个用血泪换来的规矩,我现在写 Servlet 时会下意识地扫一眼类里有没有非 final 的实例字段。

5.4 转发和重定向,选错了会让用户看到奇怪的现象

request.getRequestDispatcher("...").forward(...)和response.sendRedirect("...")都能跳转页面,但行为完全不同。

转发是服务端内部行为,浏览器根本不知道发生了什么,地址栏不变,整个过程中只有一个请求,所以request里存的属性在转发后的目标页面里能取到。它适合"处理完逻辑后展示结果页"这种场景,比如查询完列表转发到 JSP 渲染。

重定向是让浏览器再发一次请求,地址栏会变成新地址,本质上是两个独立的请求,request里的属性全部丢失,会话信息要靠session或参数传递。它适合"提交表单后跳转到列表页"这种场景,目的是防止用户刷新页面时重复提交。

常见的错误用法是在提交表单后用转发,结果用户按 F5 就又问了一遍数据库,多出一条脏数据。记住一个简单判断:要防止重复提交就用重定向,要在服务端传递数据就用转发。


回过头看,Servlet 这套东西并不复杂,它的复杂度其实来自"看不见的容器"。容器替我们处理了网络和线程,代价就是很多行为需要通过规范去理解,而不是靠读代码想当然。我的经验是,遇到莫名其妙的问题时,先别急着搜框架的 Issue,先问一句"这时候容器在做什么"——多数情况下,答案就藏在 Servlet 规范和容器的默认配置里。至于用它去调大模型接口这件事,本质上就是一个慢速 HTTP 客户端的调用问题,把超时、并发、密钥这三件事管住,剩下的写法跟调任何普通接口没有区别。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询