很多人学 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?nameabc并回车发生的事情大致是这样的浏览器把这一串东西翻译成一段符合 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;charsetUTF-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.jspwebapp这个目录名是 Maven 约定的 Web 资源根容器启动时它就相当于网站的根路径。WEB-INF下面的东西不能通过 URL 直接访问所以放web.xml和类文件是安全的别把 JSP 之外的敏感文件放到webapp根下。3.2 servlet-api 依赖的 scope 为什么必须写 provided在pom.xml里加 Servlet 相关依赖时很多人随手一写就把scope漏了结果打包出一个巨大的 war甚至和容器自带的类冲突。正确写法是dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependencyprovided的意思很直白编译和测试时需要打包时不要带进去。原因在于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-namehello/servlet-name servlet-classcom.demo.HelloServlet/servlet-class init-param param-nameappId/param-name param-value1001/param-value /init-param /servlet servlet-mapping servlet-namehello/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步骤可以梳理成一条线新建 Maven 工程时选择maven-archetype-webapp骨架这样目录结构自动就对了。在pom.xml里补上servlet-api的provided依赖并且把packaging设成war。右键工程 →Properties→Project Facets把Dynamic Web Module的版本勾上确保 Eclipse 认得出这是 Web 工程。在Servers视图里新建一个 Tomcat 实例把工程Add and Remove进去。右键 Tomcat →Start然后在浏览器访问http://localhost:8080/工程名/hello。访问不通时先按下面的顺序排查能解决八成的部署问题现象可能原因检查点404路径写错或映射没生效工程名 url-pattern 拼对了吗注解是否被容器扫描到500 且提示类找不到依赖 scope 或版本不对servlet-api 是不是 provided包名是 javax 还是 jakarta启动报端口占用8080 被别的程序占了换个端口或结束占用进程改了代码没生效容器没重新加载类重新 publish 或重启 Tomcat4. 让 Servlet 去调用大模型 API一个能落地的实战4.1 为什么不建议在 service 方法里直接同步阻塞调用大模型接口有个明显特点响应慢。普通接口几十毫秒返回大模型的推理加上网络往返几秒到几十秒都很正常。而 Servlet 的service方法是由容器的请求线程执行的这个线程来自一个固定大小的线程池Tomcat 默认maxThreads是 200。如果你的 Servlet 在service里同步等着大模型返回那么这段时间线程是被占住的什么也干不了。并发一上来200 个线程全被这几个慢请求占满后面的请求直接排队甚至超时。这就是所谓的慢请求拖垮线程池。所以在实战里我会这么做如果是短交互且并发不高同步调用可以接受但一定要设超时时间别让线程无限期挂着如果是面向多用户的对话场景要么用异步 ServletstartAsync把线程释放掉要么把请求丢给一个独立的线程池处理容器线程立刻返回。这是我在实际项目里反复权衡后得出的结论——能用异步就别让容器线程等。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(); HttpResponseString 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;charsetUTF-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大模型返回长文本时如果能像打字机一样逐步显示体验会好很多这就用到流式返回。协议上通常走 SSEServer-Sent Events也就是响应类型设成text/event-stream服务端不断地往响应流里写数据块。在 Servlet 里实现大概是这个思路resp.setContentType(text/event-stream;charsetUTF-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;charsetUTF-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 客户端的调用问题把超时、并发、密钥这三件事管住剩下的写法跟调任何普通接口没有区别。