尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Servlet从概念到实战:Maven搭建与大模型HTTP接口调用

Servlet从概念到实战:Maven搭建与大模型HTTP接口调用 很多人第一次接触 Java Web 的时候教材和视频里张口就是 Servlet可真让你说清楚 Servlet 到底是什么、它在一次请求里扮演什么角色、为什么现在都用 Spring Boot 了还得回头学它大部分人是要卡壳的。这篇文章我想把 Servlet 从概念到落地完整捋一遍不玩虚的讲清楚它是什么、能干什么、适合谁看然后带着你在 Eclipse 里用 Maven 建一个能跑起来的 Servlet 项目最后再上一个稍微有意思的实战——用 Servlet 做一层中转去调大模型开放平台提供的 HTTP 接口。全程都是我自己动手跑过的路径踩过的坑也会一并写出来。不管你是刚学 Java Web 的学生还是写了几年业务代码、对底层一直模模糊糊的开发者看完应该都能自己动手复现一遍。1. Servlet到底是什么把一次HTTP请求拆开看1.1 浏览器喊一声后端凭什么叫应答我们平时在地址栏敲一个网址浏览器做的事其实很朴素建立一条 TCP 连接把一段符合 HTTP 协议格式的文本发出去内容是我要 GET /hello 这个路径然后等对方回话。服务器那头收到这段文本以后总得有个东西负责解析它、判断该交给谁来处理、再把处理结果拼成 HTTP 响应写回去。Servlet 就是干这个的准确说Servlet 是一套运行在服务端 Java 容器里的规范它规定了一个 Java 类要实现哪些方法才能被容器接管 HTTP 请求。这个定义听起来还是很绕我换个说法。你可以把 Tomcat 想成一家餐厅的前台Servlet 就是后厨里某个具体岗位的厨师。客人点单HTTP 请求进来前台不会自己炒菜它按照菜单web.xml 或者注解里的映射关系找到对应的厨师Servlet 实例把订单信息递过去。厨师做完菜把盘子递回前台前台再端给客人。整个过程中Tomcat 负责连接管理、协议解析、线程调度这些脏活累活Servlet 只负责这道菜怎么做这段业务逻辑。这里面有几个关键点值得单独拎出来。第一Servlet 不是独立的程序它没有 main 方法必须依附于容器才能存活脱离 Tomcat 这类容器它就是一个普通的 Java 类。第二Servlet 处理的是请求-响应模型天生是一来一回的这一点和后来的响应式编程模型有本质区别。第三Servlet 是规范不是实现javax.servlet新版本是 jakarta.servlet这组接口定义了该长什么样Tomcat、Jetty、Undertow 这些容器各自去实现它所以同一份 Servlet 代码换个容器也能跑。理解到这一层你就明白为什么当年学 Java Web 一定要先学 Servlet 了——它是整个 Java 服务端体系的地基。后来那些花里胡哨的框架本质上都是在 Servlet 这层地基上盖起来的房子。1.2 Servlet、JSP、Spring MVC 三者关系别被框架带偏初学者最容易混淆的就是这三个东西。我见过不少人以为 JSP 就是 Servlet 的升级版或者觉得 Spring MVC 出现以后 Servlet 就淘汰了这两种理解都不对。JSP 的全称是 Java Server Pages它的本质是一个写起来像 HTML 的 Servlet。第一次被访问的时候容器会把 .jsp 文件翻译成一个 Java 类这个类最终会继承 HttpServlet 并且实现输出 HTML 的逻辑。也就是说JSP 是 Servlet 的一种语法糖包装它存在的意义是让页面模板写起来舒服一点不用在 Java 代码里一坨一坨地拼字符串。再说 Spring MVC它做的事情是把请求分发给哪个方法这件事从硬编码变成了配置化。在没有框架的年代一个 URL 对应一个 Servlet 类你有五十个接口就得写五十个 Servlet 类还要在 web.xml 里写五十条映射维护起来非常痛苦。Spring MVC 用一个 DispatcherServlet 接管所有请求然后根据注解和配置把请求转发给你写的 Controller 方法。注意DispatcherServlet 它自己就是一个标准的 Servlet只是它内部做了二次分发而已。所以这三者的关系是一条清晰的演进链Servlet 是地基JSP 是页面层的简化写法Spring MVC 是请求分发层的抽象封装。你去翻 Spring Boot 的启动日志里面一定有一行是 Tomcat 在 8080 端口启动那个 Tomcat 从头到尾都在按 Servlet 规范工作。把 Servlet 搞明白你再看框架源码就不会有那种它凭什么能拦到我的请求的困惑。1.3 规范里的四个核心角色先混个脸熟Servlet 规范里除了 Servlet 本身还有几个接口是绕不开的它们分工明确我建议你先记住各自管什么后面写代码的时候自然会对上号。角色全称负责的事情生命周期Servletjavax.servlet.Servlet处理单次请求的具体逻辑由容器创建和销毁ServletConfigjavax.servlet.ServletConfig给单个 Servlet 传初始化参数随 Servlet 一起产生ServletContextjavax.servlet.ServletContext整个应用共享的全局信息和资源应用启动到关闭Filterjavax.servlet.Filter在请求到达 Servlet 前后做拦截处理应用启动时创建这里有个特别实用的小技巧ServletContext 是全局唯一的你可以在应用启动的时候往里塞一些共享对象比如数据库连接池、配置信息、缓存实例然后在任意一个 Servlet 里取出来用。很多人写 Servlet 练手项目的时候每个 Servlet 都自己 new 一个连接池跑几十个并发就能把数据库打满根子就在于没用 ServletContext 做统一管理。Filter 这一层也值得单独说。它是责任链模式的典型应用请求进来先过过滤器链每个过滤器都可以决定是放行还是直接返回。做统一编码、登录校验、访问日志、跨域处理这些东西的时候Filter 是最合适的位置因为它对所有请求生效不用在每个 Servlet 里重复写一遍。我在后面的实战章节里会用到它来解决字符编码问题。2. 生命周期与并发模型写Servlet踩坑最多的地方2.1 init、service、destroy 三个方法的真实调用时机Servlet 的生命周期只有三个节点看起来简单但恰恰是理解偏差最多的地方。init()方法在整个应用运行期间只被调用一次时机是在 Servlet 第一次被访问的时候如果你在 web.xml 里配置了 load-on-startup 为正数那就是应用启动时。注意这里的措辞是只被调用一次这就意味着你不能把跟每次请求相关的数据放在 init 里比如当前登录用户是谁那显然是每次请求都不一样的。service()方法才是真正处理请求的地方每次有请求进来都会被调用一次。它内部会根据 HTTP 方法名自动分发到 doGet、doPost、doPut、doDelete 这些方法上所以你平时直接重写 doGet 和 doPost 就够了不需要动 service。当然如果你想对所有 HTTP 方法做统一处理也可以重写 service这时候要注意调用时机和 super 的问题。destroy()方法在容器关闭或者应用被卸载的时候调用一次用来释放资源比如关闭文件流、注销监听器、优雅关闭后台线程。它跟 init 一样只调用一次很多人写代码的时候直接把连接关闭写在 service 里那是完全不同的语义。我见过最典型的误用是把数据库连接的获取放在 init 里。乍一看好像很合理——初始化的时候建一条连接后面一直用。问题是这条连接会在多线程下被并发使用而 JDBC 的 Connection 本身并不是线程安全的结果就是偶发的数据错乱而且极难复现。正确的做法是 init 里建连接池service 里从池子借、用完还。2.2 单实例多线程为什么成员变量会串数据这是 Servlet 新手翻车率最高的一个点我单独开一节讲。Servlet 容器对同一个 Servlet 只会创建一个实例这就是所谓的单实例但是会有多个线程同时调用这个实例的 service 方法。这是容器设计上的性能考量——每次请求都新建一个 Servlet 对象创建和销毁的开销太大而 Servlet 本身是无状态设计复用实例完全没问题。但前提是无状态。一旦你在 Servlet 类里写了成员变量并且这个变量会随着请求改变问题就来了。举个具体的例子假设你写了这样一个类public class BadServlet extends HttpServlet { // 危险成员变量会被所有线程共享 private String currentUser; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { currentUser req.getParameter(user); // 模拟一点处理耗时 try { Thread.sleep(100); } catch (InterruptedException e) {} // 这里输出的可能已经是别人的用户名了 System.out.println(当前用户 currentUser); } }两个请求几乎同时进来A 先赋值了 张三还没来得及打印B 就把变量改成了 李四A 醒过来打印的时候看到的就是 李四。这就是典型的线程安全问题。注意Servlet 里凡是可以写成局部变量的绝对不要写成成员变量。数据库连接池、配置对象这类只读且线程安全的共享资源才适合放到成员变量或者 ServletContext 里。那如果确实需要每个请求独立的存储呢两个方案。一是直接用局部变量最简单也最安全。二是用HttpServletRequest.setAttribute()它是绑定在当前请求对象上的天然隔离。至于 ThreadLocal能用但一定要记得在 finally 里 remove否则在线程池复用的情况下会造成内存泄漏和脏数据。2.3 请求响应的常用 API 速查表HttpServletRequest 和 HttpServletResponse 的方法加起来上百个但日常真正高频的就那么十几个。我把它们整理成一张表写代码的时候对着查就行。方法归属用途备注getParameter(String)Request取 URL 或表单里的单个参数返回 String没有则为 nullgetParameterValues(String)Request取同名多值参数如复选框返回 String[]setCharacterEncoding(String)Request设置请求体编码必须在取参数之前调用setAttribute / getAttributeRequest请求域内传值转发时数据能带过去getRequestDispatcher(String).forward()Request服务端内部转发地址栏不变一次请求sendRedirect(String)Response客户端重定向地址栏变化两次请求setContentType(String)Response设置响应类型和编码建议写在最前面getWriter()Response获取字符输出流输出 HTML 文本getOutputStream()Response获取字节输出流输出图片、文件下载getSession()Request获取或创建会话跨请求保存用户状态getServletContext()Request拿到全局上下文读全局配置和资源getHeader(String)Request读请求头常用来读 User-Agent 等这张表里我想特别强调两条。第一条setCharacterEncoding必须在任何getParameter之前调用因为一旦容器开始解析参数编码就已经按默认值通常是 ISO-8859-1定下来了再改也来不及。第二条getWriter和getOutputStream不能同时用容器会抛 IllegalStateException输出文本用前者输出二进制用后者。3. Eclipse 加 Maven手把手搭一个能跑的 Servlet 项目3.1 版本选型的取舍JDK、Tomcat、Servlet API 怎么搭动手之前先把版本关系捋清楚这一步选错了后面全是莫名其妙的报错。Servlet 规范在 2018 年之后从 javax 包迁移到了 jakarta 包这是一次破坏性的包名变更。分界线大致是 Servlet 5.0对应 Jakarta EE 9包名 jakarta.servlet和之前的 Servlet 4.0对应 Java EE 8包名 javax.servlet。Tomcat 这边Tomcat 9 及以前用的是 javaxTomcat 10 及以后用的是 jakarta。我个人的建议是如果你是新起一个练手项目直接用 Tomcat 9 加 Servlet 4.0 这条线因为网上能搜到的教程、示例代码绝大多数是这个组合遇到问题好查。选 Tomcat 10 当然也没问题但你得时刻记得把网上抄来的import javax.servlet.*手动改成import jakarta.servlet.*否则编译直接报错卡在这里的人我见过太多了。JDK 版本上Tomcat 9 支持 JDK 8 及以上我一般用 JDK 17因为它自带 HttpClient后面调大模型的实战章节正好用得上不用引第三方 HTTP 库。Eclipse 版本建议用 2022-06 之后的对 Maven 和 Tomcat 的集成更顺。环境清单我列一下你可以照着核对组件我的选择说明JDK17自带 java.net.http.HttpClientEclipse2023-09 或更新Enterprise 版本自带 Web 工具Tomcat9.0.x使用 javax.servlet 包名Servlet API4.0.1scope 设为 providedMavenEclipse 内置即可无需单独安装提示Tomcat 一定要下载 zip 或者 tar.gz 的免安装版解压后用 Eclipse 指向目录就行千万别去下 exe 安装包它会往系统里写服务注册卸载和换版本都很麻烦。3.2 pom.xml 与打包方式war 还是 jarWeb 项目必须打 war 包这一点没有商量余地。原因是 Servlet 代码需要被容器加载它依赖容器的类加载机制和目录结构约定打成可执行 jar 是跑不起来的除非你内嵌 Tomcat。pom.xml 里最关键的几行我贴出来然后逐条解释为什么这么写。project xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion groupIdcom.demo/groupId artifactIdservlet-lab/artifactId version1.0.0/version !-- 关键点一打包方式必须是 war -- packagingwar/packaging properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies !-- 关键点二scope 必须是 provided -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency /dependencies build finalNameservlet-lab/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-war-plugin/artifactId version3.4.0/version /plugin /plugins /build /projectscope设为 provided 是这里最容易搞错的地方。provided 的含义是编译时需要打包时不带。Servlet API 在 Tomcat 的 lib 目录里已经有了如果你的 war 包里再塞一份部署的时候就会出现类加载冲突表现形式可能是启动报 ClassCastException也可能是莫名其妙的 NoSuchMethodError。我早期就因为这个踩过坑明明代码没问题就是跑不起来排查了半天才发现是依赖重复。finalName也建议显式指定。默认情况下 Maven 会用 artifactId 加版本号做包名部署到 Tomcat 之后访问路径里带个 1.0.0看着别扭改成固定名字清爽很多。3.3 目录结构与两种注册方式Maven 的 Web 项目目录结构是有约定的别乱摆摆错了 Eclipse 会不认。servlet-lab ├── pom.xml └── src └── main ├── java │ └── com/demo/web │ └── HelloServlet.java └── webapp ├── WEB-INF │ └── web.xml └── index.htmlsrc/main/java放 Java 代码src/main/webapp是 Web 根目录浏览器能直接访问到的静态文件都放这里。WEB-INF是受保护目录浏览器访问不到web.xml 和编译后的 class 都在它下面。有一点必须提醒Eclipse 用 Maven 建项目之后如果src/main/java显示成普通文件夹而不是源码目录右键项目 → Maven → Update Project 一下就好这是 Eclipse 的元数据没同步。注册 Servlet 有两种方式我建议两种都了解因为老项目里全是 web.xml 的写法。第一种是 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-namehello/servlet-name servlet-classcom.demo.web.HelloServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namehello/servlet-name url-pattern/hello/url-pattern /servlet-mapping welcome-file-list welcome-fileindex.html/welcome-file /welcome-file-list /web-app第二种是注解方式从 Servlet 3.0 开始支持代码更简洁package com.demo.web; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; WebServlet(name hello, urlPatterns /hello, loadOnStartup 1) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 先设编码再拿 writer resp.setContentType(text/html;charsetUTF-8); PrintWriter out resp.getWriter(); out.println(h1Servlet 跑起来了/h1); out.println(p请求路径 req.getRequestURI() /p); out.println(p客户端 IP req.getRemoteAddr() /p); } }注意两种方式不要对同一个 Servlet 同时使用会报重复映射的错误。web.xml 里的metadata-completetrue属性如果被设上注解会全部失效这一点在老项目改造时特别容易中招。3.4 部署、访问与验证代码写完接下来是把它跑起来。Eclipse 里最省事的做法是配置 Server 视图Window → Show View → Servers右键新建一个 Tomcat 9 的服务器实例指定你解压出来的 Tomcat 目录然后把项目 add 进去点启动。启动过程中有几个观察点值得留意。控制台会打印 Tomcat 的启动日志如果看到Server startup in xxx ms基本就成功了。如果启动失败日志里一般会给明确的错误位置。部署完成后浏览器访问http://localhost:8080/servlet-lab/hello路径里的 servlet-lab 就是前面finalName配置的名字。验证的时候我习惯做三件事。第一看返回的 HTML 里getRequestURI()是不是/servlet-lab/hello确认映射生效。第二故意访问一个不存在的路径看是不是标准的 404 页面确认应用整体部署没问题。第三在 doGet 里打一行 System.out多刷几次页面观察控制台是不是每次都打印这能确认 service 方法确实每次请求都执行。另外提一个 Eclipse 的常见坑有时候改了代码重启 Tomcat 之后发现还是旧的逻辑在跑。这通常是 Eclipse 的自动构建没触发或者 class 文件没同步到 Tomcat 的 webapps 目录。解决办法是 Project → Clean 一下或者干脆把 Tomcat 里的部署目录删掉重新发布。4. 进阶实战用 Servlet 调一次大模型 HTTP 接口4.1 为什么中间要加 Servlet 这一层现在很多大模型开放平台都提供标准的 HTTP 接口你申请一个 API Key往指定地址发一个 POST 请求带上 JSON 格式的问题内容返回的也是 JSON。技术上你在浏览器里用 fetch 直接调也不是不行但有两个现实问题绕不开。第一个是密钥暴露。API Key 如果写在纯前端页面里任何人按 F12 打开开发者工具都能看到等于把钥匙挂在门上。第二个是跨域限制浏览器的同源策略会拦掉大部分直接调用你需要在服务端做转发或者配置 CORS。Servlet 在这里的位置就很合适浏览器把问题发给 ServletServlet 在服务端拿着密钥去调大模型的接口拿到结果之后再把内容回传给浏览器。密钥始终在服务端前端完全看不到跨域问题也顺手解决了。这个模式其实就是最朴素的后端代理网关很多生产系统里的 API 网关干的就是这件事只不过功能更复杂。需要说明的是不同平台提供的接口地址、参数名、返回结构会有差异具体以你申请到的平台文档为准我这里用的是通用的示例地址你替换成自己拿到的地址就行。4.2 用 JDK 自带 HttpClient 发 POST 的完整代码JDK 11 之后自带的java.net.http.HttpClient已经足够好用不需要再引 Apache HttpClient 或者 OkHttp少一个依赖少一份麻烦。完整代码我贴出来然后逐段解释。package com.demo.web; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; WebServlet(/chat) public class ChatProxyServlet extends HttpServlet { // 示例地址请替换为你在平台申请到的实际接口地址 private static final String API_URL https://api.example-llm.com/v1/chat/completions; // 生产环境请从环境变量或配置中心读取不要硬编码在代码里 private static final String API_KEY System.getenv(LLM_API_KEY); // HttpClient 是线程安全的作为成员变量复用即可 private final HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetUTF-8); String question req.getParameter(q); if (question null || question.trim().isEmpty()) { resp.setStatus(400); resp.getWriter().write({\error\:\问题不能为空\}); return; } String body buildBody(question); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(API_URL)) .timeout(Duration.ofSeconds(30)) .header(Content-Type, application/json) .header(Authorization, Bearer API_KEY) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); try { HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString(java.nio.charset.StandardCharsets.UTF_8)); resp.setStatus(response.statusCode()); resp.getWriter().write(response.body()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); resp.setStatus(500); resp.getWriter().write({\error\:\请求被中断\}); } catch (IOException e) { resp.setStatus(502); resp.getWriter().write({\error\:\上游接口不可达\}); } } private String buildBody(String question) { // 手工拼 JSON 仅用于演示正式项目请用 Jackson 或 Gson String escaped question.replace(\\, \\\\).replace(\, \\\); return {\model\:\your-model-name\,\messages\:[{\role\:\user\,\content\:\ escaped \}],\temperature\:0.7}; } }这段代码里有几个设计决定了它的实用性我拆开说。HttpClient被定义成成员变量因为它是线程安全的并且内部维护了连接池每次请求都 new 一个的话连接复用就完全失效了高并发下性能会很差。这跟前面讲的成员变量要小心并不矛盾——判断标准是这个对象本身是否线程安全而不是简单的一刀切。超时设置了两层connectTimeout 是建立连接的超时request 上的 timeout 是整个请求的超时。大模型接口的响应时间通常比普通接口长我给的是 30 秒如果你调用的模型比较大或者上下文很长可能要放宽到 60 秒。这里别省不设超时的话上游卡住你的线程会被一直占着并发上来直接把 Tomcat 的线程池耗干。异常处理我分了两种InterruptedException 单独处理是因为它涉及到线程中断标志标准做法是捕获后重新调用Thread.currentThread().interrupt()把标志位还原否则上层拿不到中断信号。IOException 归类成 502语义上是上游网关异常比直接返回 500 更准确。4.3 超时、编码、参数拼装这几个必踩的坑第一个坑是编码。req.setCharacterEncoding(UTF-8)必须写在第一次getParameter之前这一点在 2.3 节提过。如果是 GET 请求带中文参数设置请求体的字符集是不起作用的因为 GET 参数在 URL 里归 Tomcat 的 server.xml 里 Connector 的 URIEncoding 管。Tomcat 8 以后默认就是 UTF-8所以一般不用改但如果你的项目是从很老的版本迁移过来的记得检查这一项。响应侧同理resp.setContentType(application/json;charsetUTF-8)要写在getWriter()之前。我见过有人先拿 writer 再设 contentType结果前端收到一堆问号还以为是模型返回的中文有问题其实是响应流已经按默认编码初始化了。第二个坑是手工拼 JSON。上面的示例里我为了讲清楚原理用了字符串拼接但它在真实项目里是不合格的。只要问题文本里包含引号、反斜杠、换行符拼出来的 JSON 就是非法的上游接口会直接返回 400。正式项目请老老实实引 Jackson 或者 Gson把对象序列化成字符串。我早期图省事手工拼过一次测试的时候用的都是简单句子一切正常上线第一天就有一条带双引号的问题把接口打挂了。第三个坑是密钥管理。绝对不要把 API Key 写死在代码里再提交到代码仓库。上面的代码用的是System.getenv配置起来也简单在 Tomcat 的启动脚本里设置环境变量或者在 Eclipse 的 Run Configuration 里配。更进一步的做法是放到配置中心这和 Servlet 本身没关系了但思路要一开始就建立起来。第四个坑是响应体大小。大模型返回的内容可能很长几十 KB 很正常。HttpResponse.BodyHandlers.ofString()默认是能处理这个量级的但如果你要返回的是流式内容一段一段推给前端那这个同步模型就撑不住了需要用 Servlet 3.0 的异步支持或者改用 SSE。这一块展开讲篇幅太长先记着有这么回事等你真需要流式输出的时候再深入。4.4 配一个最小前端页面把链路跑通后端通了前端要有个东西触发它。放在 webapp 目录下的一个普通 HTML 就够不需要任何构建工具。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleServlet 调大模型演示/title /head body h2问点什么/h2 textarea idq rows4 cols60用一句话解释什么是 Servlet/textarea br button idsend发送/button pre idresult/pre script document.getElementById(send).onclick async function () { const result document.getElementById(result); result.textContent 请求中...; const params new URLSearchParams(); params.append(q, document.getElementById(q).value); try { const resp await fetch(chat, { method: POST, headers: {Content-Type: application/x-www-form-urlencoded;charsetUTF-8}, body: params }); const text await resp.text(); result.textContent text; } catch (e) { result.textContent 出错了 e.message; } }; /script /body /html注意 fetch 里的地址写的是相对路径chat它会基于当前页面所在的路径拼接。如果你的页面是http://localhost:8080/servlet-lab/index.html实际发出的请求就是http://localhost:8080/servlet-lab/chat正好对应WebServlet(/chat)的映射不用硬编码域名端口。请求体我用的是application/x-www-form-urlencoded格式因为 Servlet 的getParameter原生就支持解析它。当然你也可以传 JSON但那样就得自己从 InputStream 里读并解析多一道工序。练手阶段用表单格式最省事。跑通之后你会看到页面上打印出上游返回的原始 JSON。想看得舒服一点可以对返回内容做一次 JSON.parse 再取值这个就留给读者自己动手了。整个链路的时序是这样的浏览器发请求给 ServletServlet 加好密钥和请求头转发给大模型接口拿到响应原样写回浏览器。中间这一层虽然简单但已经把密钥不外泄这个核心目标达成了。5. 常见问题与排查技巧实录5.1 404、405、500 速查表Servlet 开发中遇到的错误八成集中在这几个状态码上。我做了一张对照表把常见原因和排查动作列清楚出问题的时候按表走比漫无目的地翻日志快得多。状态码常见原因排查动作404url-pattern 拼错、应用上下文路径不对、web.xml 没生效对比浏览器地址和映射路径检查 Context Root405请求方法和 doXxx 方法不匹配比如 POST 打到了 doGet看 Network 面板的 Request Method500代码抛异常、依赖缺失、空指针看 Tomcat 控制台的第一行 Caused by503线程池耗尽、应用正在重载查 JVM 线程数和 Tomcat 连接数400参数格式非法、上游接口拒绝打印实际发出的请求体核对502转发到外部接口失败检查目标地址可达性和超时设置重点说 404 和 405 这两个因为它们最容易被误判成代码没写对。404 的排查顺序应该是先确认应用本身有没有部署成功访问首页或者一个已知的静态页如果首页能开说明部署没问题问题在映射路径上。这时候打开浏览器开发者工具的网络面板看实际请求的 URL 是什么再回头对 WebServlet 里的路径。最常见的错是 context path 忘了加比如你以为访问的是/chat实际应该是/servlet-lab/chat。405 出现的时候不要怀疑代码先看请求方法。浏览器地址栏直接输入 URL 发的是 GET表单默认也是 GET而你的代码可能只写了 doPost。反过来也一样前端用 POST 发后端只重写了 doGet一样是 405。这个错误在前后端联调的时候特别高发因为双方经常没对齐方法。5.2 中文乱码的三个场景与解法乱码是 Java Web 的老朋友了但它其实只有三种场景搞清楚了就不难。场景一POST 请求体里的中文乱码。解法是在取参数前调用request.setCharacterEncoding(UTF-8)。如果项目里到处都要写这一行那就在 Filter 里统一处理一次WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }Filter 写在/*上对所有请求生效一次性解决问题比在每个 Servlet 里重复写优雅得多。这就是 1.3 节讲的 Filter 的典型用途。场景二响应内容乱码。解法是response.setContentType(text/html;charsetUTF-8)并且必须写在 getWriter 之前。这一条我重复了三次因为它是真的高频出错。场景三GET 请求参数乱码。这个不在代码里解决要改 Tomcat 的 server.xml在 Connector 节点上加URIEncodingUTF-8。Tomcat 8 之后默认已经是 UTF-8所以新项目基本不用管但如果你接手的是老系统或者容器版本比较旧这一项要专门确认。5.3 线程安全与资源泄漏的隐蔽坑除了前面讲的成员变量问题还有几个更隐蔽的坑我按遇到频率排一下。第一个是把流对象或者连接对象放到成员变量里。有人在 init 里getServletContext().getResourceAsStream()拿一个输入流存起来想着复用结果第一个请求读完流就关了后面的请求全报流已关闭。流这种有状态的东西天生就是一次用完的语义不能复用。第二个是 ThreadLocal 忘记清理。用 ThreadLocal 存当前用户信息是常见做法但 Tomcat 的线程是复用的一个线程处理完请求 A 之后去处理请求 B如果 A 没有清理 ThreadLocalB 就会读到 A 的数据。这个 bug 的可怕之处在于它只在高并发下偶发本地测试永远复现不了。标准写法是在 Filter 的 finally 块里remove()。第三个是监听器里的资源没释放。用 ServletContextListener 在应用启动时初始化连接池那就要在 contextDestroyed 方法里把它关掉否则每次热部署都会泄漏一批连接开发阶段反复重启几十次数据库那边的连接数就满了。第四个是 HttpClient 每次请求都新建。这个在 4.2 节提过它不是线程安全问题而是性能问题。每个 HttpClient 内部都有自己的连接池和线程池频繁创建销毁的开销非常大而且没被关闭的实例会一直占着资源。5.4 我压箱底的几条实操心得写了这么多代码最后分享几条我觉得比技术细节更值钱的经验。第一练手项目也要用真日志。很多人练手阶段全靠 System.out代码一多就分不清哪行输出来自哪个请求了。换成 SLF4J 加 Logback 也就多两个依赖但你能拿到时间戳、线程名、级别这些信息排查并发问题的时候这些就是救命稻草。第二先让请求跑通再考虑美观。我见过太多人卡在项目结构上纠结包名怎么起、要不要分 service 层结果一个 Hello World 都没跑起来。Servlet 的意义在于理解请求-响应这条链路链路通了代码丑一点完全没关系后面有的是机会重构。第三出问题先看日志的第一行。Tomcat 抛异常的时候堆栈很长很多人从下往上看越看越懵。正确的顺序是从上往下看找到第一个Caused by那才是真正的根因。上面那一堆通常是框架调用链对你没用。第四把 Servlet 当理解框架的钥匙而不是过时的技术。你在 Spring MVC 里配RequestMapping的时候心里要清楚它最终对应到哪个 Servlet 的哪个方法你加一个OncePerRequestFilter的时候要知道它跟原生 Filter 的关系是什么。有了这层认知你看框架源码、读报错堆栈、排查诡异问题的能力会明显不一样。第五动手改一改再跑一遍。这篇文章里的代码都可以直接抄但真正让你记住的是你把它改坏了之后自己修回来的过程。比如把WebServlet(/chat)改成/chat2看看 404 长什么样把setCharacterEncoding那行删掉看看中文变成什么样。亲手制造一次错误比看十遍正确示例管用得多。
返回列表