最近在帮团队整理一个内部实验项目,需求很简单:前端页面上通过fetch发一个异步请求,Tomcat 9后面接一个Servlet处理,把数据以JSON形式返回给页面,页面再局部刷新。听起来就是教科书级别的demo,但真正从零搭一遍,发现里面其实堆了不少容易踩的小坑,比如fetch默认不带Cookie、POST请求体读取姿势不对、跨域预检请求把后端打懵、Tomcat 9的异步Servlet跟旧版本写法的差异等等。这篇就把这个“fetch异步简单版本”完整拆开,从环境准备、前端fetch核心用法,到后端Servlet实现、Tomcat 9配置,再到AsyncContext异步处理实战,全部过一遍。
我默认你是有一点点Java Web基础、但可能还没把前后端交互这条链路彻底摸清的人,或者你正在做一个需要前端异步刷新、后端却不想上Spring Boot那种“轻框架”的小项目。这个项目版本我用的是Tomcat 9,Servlet规范对应的是4.0,不需要任何额外重量级依赖,一套下来可以直接借鉴甚至照抄。
1. 项目整体设计与技术选型思路
1.1 这个“简单版本”到底在解决什么问题
先把这个项目要处理的核心场景说清楚:页面上有一个按钮,点击后发起一个异步请求到后端接口,后端处理可能要花几秒钟,前端在这个过程中不能卡死,页面不能白屏,数据回来后只更新页面上某一小块区域,而不是整页刷新。这就是fetch异步最典型的一种用法,也是很多现代Web项目的基础交互单元。
我见过不少刚接触前后端分离的人,第一反应是直接上Spring Boot + Vue/React,整个工程拉起来几十个文件,环境还没装完人先劝退了。而这个“简单版本”的思路是把复杂度压到最低:前端就一个HTML文件加原生JS的fetch,后端就是Tomcat 9加一个Servlet,不加框架,彻底看清底层机制。等把这个链路跑通了,再去套框架,你会发现自己能一眼看出框架帮我们封装了什么,出了问题排查起来也更有底气。
1.2 为什么选Tomcat 9 + Servlet,而不是Spring Boot
选Tomcat 9,而不是直接上Spring Boot,主要有几个实际考量:
- Servlet语义更直接。一个HTTP请求进来,doGet/doPost里就是完整的处理逻辑,没有拦截器链、没有DispatcherServlet转发、没有Spring容器初始化,这些封装在你排查问题时反而容易挡住视线。
- 依赖极轻。只要一个Servlet API的编译期依赖,运行期直接交给Tomcat 9自带的那份JAR,连包都不用打进War里。
- 异步原语更明显。Tomcat 9对应Servlet 3.1/4.0规范,支持AsyncContext、异步Listener这些机制,拿它来理解“服务端异步”比在Spring Boot里写作简单直观得多。
- 部署方式简单。War包扔进webapps目录就能跑,不用Maven中央仓库拉一堆依赖,适合实验和教学场景。
当然这不是说Spring Boot不好,而是当你想搞清楚“前端fetch发出去的请求到底是怎么被后端处理并返回的”这个问题时,Servlet是最近的答案。
1.3 环境准备清单
这个项目我本地的环境供你参考:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | Tomcat 9 官方支持 JDK 8 及以上 |
| Tomcat | 9.0.x | 建议 9.0.60 以上版本,修复了一些老问题 |
| IDE | IntelliJ IDEA | 社区版即可 |
| 构建工具 | Maven 3.6+ | 也可以不用,直接手工放JAR |
| 前端 | 原生HTML + JS | 不需要任何Node环境 |
如果不用Maven,也可以直接到Tomcat的lib目录里找到servlet-api.jar,手工在IDEA里添加为依赖,一样能跑。这里我建议用Maven,因为war包构建、目录结构、依赖管理都顺手,后期想加json解析库也方便。
2. 前端fetch异步请求的核心玩法
2.1 fetch的基本用法与Promise机制
fetch是现代浏览器内置的异步请求API,返回的是一个Promise对象。Promise这个东西,简单理解就是一个“尚不确定结果,但保证将来会给结果”的容器。你不需要在发请求那一刻就拼命等结果,而是注册好“成功后续动作”和“失败后续动作”,等网络返回后再执行。
最基本的fetch用法是:
fetch('http://localhost:8080/demo/api/hello') .then(function(response) { return response.json(); }) .then(function(data) { console.log(data); }) .catch(function(error) { console.error('请求失败', error); });这里要注意的是:fetch只有在网络层真正出错时才会走catch,比如DNS解析失败、连接被拒绝、请求被取消。如果是后端返回了错误状态码(比如404、500),fetch照样会走then,不会走catch。很多人踩的第一个坑就是“我明明后端返回500了,为什么前端catch没抓到”,原因就是这个。
所以判断请求是否成功,不能只看有没有进catch,还要主动检查response.ok:
fetch('/demo/api/hello') .then(function(response) { if (!response.ok) { throw new Error('HTTP状态码异常: ' + response.status); } return response.json(); }) .catch(function(error) { console.log('包含HTTP错误在内的任何异常都会到这里'); });2.2 async/await:把异步代码写得像同步
Promise写法用久了会发现,一旦有依赖关系,then里面套then还是很难受。async/await是Promise之上的一层语法糖,目的就是让异步代码的阅读顺序符合人类直觉。
async function loadData() { try { const response = await fetch('/demo/api/hello'); if (!response.ok) { throw new Error('状态码异常: ' + response.status); } const data = await response.json(); document.getElementById('result').innerText = JSON.stringify(data); } catch (error) { console.error('加载失败', error); } }一个特别重要的概念:async函数里出现await,并不会阻塞整个页面。浏览器的事件循环会把await后面的代码挂起,先去执行其他任务,等Promise完成后,再回来继续执行await后续的代码。所以即使你在await一个慢接口,页面上的动画、按钮点击、其他脚本都还是正常运行的,这就是“异步”的核心体验。
2.3 请求参数的三种常见传递方式
在实战里,前端要把参数传给后端,一般有三种常规姿势:
第一种是GET请求直接把参数拼在URL上,适合参数简单、无敏感信息、要支持刷新分享的场景:
const userId = '1001'; const response = await fetch(`/demo/api/user?userId=${encodeURIComponent(userId)}`);这里encodeURIComponent要养成习惯,如果参数里有中文、空格、特殊符号,不编码很容易变成乱码或者服务端截断。
第二种是POST请求带表单格式参数,传统Servlet对这种方式支持最直接,使用application/x-www-form-urlencoded:
const response = await fetch('/demo/api/user/add', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ name: '张三', age: '25' }) });第三种是POST请求带JSON字符串,这是现在前后端分离项目的标准姿势:
const response = await fetch('/demo/api/user/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '张三', age: 25 }) });三种方式后端读取参数的代码完全不同,这也就是很多人发现“前端传的东西后端拿不到”的根源。我们后面在Servlet部分会对应讲解。
2.4 fetch的真实坑:Cookie、超时与请求取消
这里分享几个我在实际项目中踩过、后来写成团队规范的点。
第一,fetch默认不带Cookie。如果你登录后,后端通过session记录了用户状态,fetch请求默认不会把当前域的Cookie带上去。要让跨域或同域请求携带Cookie,都必须显式设置:
fetch('/demo/api/user/info', { credentials: 'include' // 或者 'same-origin' });如果不设置,后端session.getAttribute()拿到的一直是null,但你又找不到原因,非常隐蔽。
第二,fetch本身没有超时机制。一个请求如果后端一直不返回,fetch会一直挂着。要超时,需要借助AbortController:
const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10000); try { const response = await fetch('/demo/api/slow', { signal: controller.signal }); clearTimeout(timer); } catch (error) { if (error.name === 'AbortError') { console.log('请求超时,已取消'); } }第三,响应体只能读取一次。如果你先调了res.text(),再想调res.json(),浏览器会直接报错“Body has already been consumed”。这是流式读取的设计,早做预防,别在调试时被它坑到。
注意:fetch在页面卸载、组件销毁时如果还没完成,建议调用AbortController取消请求,避免后续对已销毁DOM进行操作时报错。这一点在做前端单页应用时尤其重要。
3. 后端Servlet实现与Tomcat 9配置要点
3.1 注解方式快速搭建Servlet
Tomcat 9支持Servlet 3.0开始引入的@WebServlet注解,也就是说不需要在web.xml里手动登记Servlet了,直接在类上标注就行,非常方便。
一个最简单的Servlet长这样:
package com.demo.servlet; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet(urlPatterns = "/api/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"message\":\"Hello from Tomcat 9\"}"); } }这里有两个细节值得说。
第一个是HTTP方法对应规则:前端fetch如果不指定method,默认是GET,那后端就必须有个能处理GET的入口。如果你想统一用一个方法处理多种类型的请求,可以重写service方法,或者用doGet和doPost都指向同一个处理逻辑。
第二个是Web应用的根路径。如果你把工程打成war包,部署到Tomcat后,访问路径通常是http://localhost:8080/工程名/api/hello。工程名对应war包文件名,假设war包叫demo.war,那请求路径里的上下文就是/demo,所以我前端fetch写法里都会有/demo这个前缀。如果你的部署环境里上下文改了,前端所有请求路径都要跟着改,这个要留意。
3.2 读取参数与请求体的完整姿势
这一节是“前端传了后端拿不到”这个经典问题的手术台。我们把三种参数传递方式一一对应起来。
方式一:GET查询参数。后端用req.getParameter("userId")直接取,想取多个就再调一次:
String userId = req.getParameter("userId");Tomcat 9默认的URI编码是UTF-8,所以GET参数里有中文,一般情况下不会乱码。但如果前端没做encodeURIComponent,后端某些特殊字符可能解析出错。
方式二:表单格式POST。后端同样用req.getParameter("name")取,前提是Content-Type是application/x-www-form-urlencoded,Tomcat容器会自动解析表单体:
String name = req.getParameter("name"); String age = req.getParameter("age");方式三:JSON格式POST。这时不能用getParameter,因为Tomcat没有把请求体解析成参数表,必须自己读取请求体输入流,再手动解析。读取输入流的代码如下:
StringBuilder sb = new StringBuilder(); try (BufferedReader reader = req.getReader()) { String line; while ((line = reader.readLine()) != null) { sb.append(line); } } String jsonBody = sb.toString();拿到JSON字符串后,可以自己写解析逻辑,也可以引入Jackson或Gson库。我为了保持项目简单,直接用了一个小型JSON解析工具类,或者干脆用fastjson/gson的Maven依赖。这里演示用Gson:
JsonObject jsonObject = JsonParser.parseString(jsonBody).getAsJsonObject(); String name = jsonObject.get("name").getAsString(); int age = jsonObject.get("age").getAsInt();需要引入依赖:
<dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.10.1</version> </dependency>提示:如果用axios、fetch、postman等多个工具混合调试,最容易搞混的就是Content-Type不匹配。后端按根表单参数还是按JSON体解析,完全取决于这个请求头。遇到“参数为null”先检查请求头里的Content-Type到底是不是后端期待的那一种。
3.3 统一处理CORS跨域问题
如果你的前端页面和后端接口不在同一个域名、端口或协议下,浏览器的同源策略就会拦截请求,表现是前端控制台出现CORS错误,而实际网络请求可能已经被后端处理了(因为跨域拦截是发生在浏览器端,并非服务端拒绝)。
解决跨域最直接的方式是在后端加一个Filter,把允许跨域的响应头加上。像我这种实验项目,一般就加一个简单过滤器处理所有请求:
package com.demo.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebFilter(urlPatterns = "/*") public class CorsFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; HttpServletRequest req = (HttpServletRequest) request; resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin") == null ? "*" : req.getHeader("Origin")); resp.setHeader("Access-Control-Allow-Credentials", "true"); resp.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); resp.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if ("OPTIONS".equalsIgnoreCase(req.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(request, response); } }这里面有两个非常关键的细节。
第一个是Access-Control-Allow-Origin不能设置成* 的同时又允许携带Cookie。当你前面fetch设了credentials: include,浏览器要求后端响应里的Access-Control-Allow-Origin必须是具体的源,不能是通配符。我上面的写法是动态把请求的Origin原样返回,这样在调试localhost的多个端口时非常省心,比如前端跑在3000端口、后端跑在8080端口也能正常工作。
第二个是OPTIONS预检请求。当你的请求包含了非简单请求头(比如Content-Type: application/json),浏览器会先发一个OPTIONS请求来探测后端允不允许跨域。很多不熟悉这点的后端同学发现“前端明明发的POST,后端怎么收到的是OPTIONS”,其实就是这个机制。我们的Filter直接对OPTIONS返回200,不带后续Servlet链条,这个属于标准操作。
3.4 返回JSON数据的完整代码示例
现在把前面的内容串成一个完整的后端Servlet,让它能接收POST JSON数据并返回处理结果:
package com.demo.servlet; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.BufferedReader; import java.io.IOException; @WebServlet(urlPatterns = "/api/user/add") public class UserAddServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { // 统一设置编码和返回类型 req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); // 读取请求体 StringBuilder sb = new StringBuilder(); try (BufferedReader reader = req.getReader()) { String line; while ((line = reader.readLine()) != null) { sb.append(line); } } JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject(); String name = json.get("name").getAsString(); int age = json.get("age").getAsInt(); // 模拟业务处理,实际场景这里可能是落库、调用服务等 boolean success = name != null && !name.isEmpty(); // 构造返回JSON JsonObject result = new JsonObject(); result.addProperty("success", success); result.addProperty("name", name); result.addProperty("age", age); result.addProperty("message", success ? "添加成功" : "参数错误"); resp.getWriter().write(result.toString()); } }反过来,如果是GET请求返回用户列表,效果类似,只是参数从URL上取。为了在博文里控制篇幅,我这里给一个同时处理GET和POST的模板:
@Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); JsonObject result = new JsonObject(); if ("GET".equalsIgnoreCase(req.getMethod())) { result.addProperty("type", "get"); result.addProperty("keyword", req.getParameter("keyword")); } else if ("POST".equalsIgnoreCase(req.getMethod())) { req.setCharacterEncoding("UTF-8"); StringBuilder sb = new StringBuilder(); try (BufferedReader reader = req.getReader()) { String line; while ((line = reader.readLine()) != null) { sb.append(line); } } result.addProperty("type", "post"); result.addProperty("body", sb.toString()); } resp.getWriter().write(result.toString()); }由于重写了service方法,doGet和doPost可以不用管,所有HTTP方法都会走到这里。但要注意,重写service之后,那些只在doGet/doPost里的逻辑就不会再被调用了,这个是一个很常见的自动思维盲区。
3.5 Tomcat 9部署与启动检查
代码写完后,用Maven打包war,命令是:
mvn clean package然后去target目录拿到demo.war,把它复制到Tomcat 9的webapps目录下,启动Tomcat:
bin/startup.sh如果一切正常,你会看到Tomcat日志中出现“Deployment of web application archive [demo.war] has finished”类似的信息。之后通过浏览器访问:
http://localhost:8080/demo/index.html就能看到页面了。这里要注意:Tomcat 9默认监听8080端口,如果你的8080被占用,需要去conf/server.xml里改端口,改完重启。我曾在某些环境下8080被其他服务占用,导致一直访问超时,排查了很久才发现是端口冲突。
还有一个小点:Tomcat下如果解压过旧版本的war包,再次替换war时最好先把旧的解压目录删除,否则可能遇到“看不到最新修改”的诡异问题。
4. Tomcat 9下的异步处理进阶:AsyncContext实战
4.1 什么时候才需要服务端异步
很多人以为前端用了fetch异步,后端就自动是异步了。这是两码事。前端fetch只是让浏览器不用等结果,但请求到达Tomcat后,Tomcat的线程池会分配一个工作线程来处理这个请求,如果这个线程一等等很久,资源就会被占住,并发一高,线程池耗尽,后续请求全部排队,整个应用就“卡死”了。
比如一个请求要调用一个外部接口,响应需要5秒钟,在这5秒钟里,Tomcat线程没有任何事做,就是干等着。Tomcat默认工作线程一般在200到400个左右,如果每秒有100个这种慢请求进来,很快就把线程池塞满。这时候就该考虑服务端异步:请求进来后,我们不占用Tomcat线程去等那个慢接口,而是把任务丢给另一个线程池,然后立刻把Tomcat线程释放掉,等任务完成后,通过AsyncContext再把结果写回客户端。
这个场景和前端fetch搭配起来,就是一套完整的“异步链路”:前端不卡页面,后端不卡线程。
4.2 startAsync()的使用与注意事项
Tomcat 9支持Servlet 3.1规范的异步处理。核心API就几步:
AsyncContext asyncContext = req.startAsync(); asyncContext.setTimeout(30000); // 30秒超时调用startAsync()之后,当前请求会进入异步模式,Tomcat不会因为Servlet方法执行完就把响应提交,而是等待你在希望的时刻调用asyncContext.complete()或内部再处理完后结束。
最简单的用法是配合一个线程池:
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(10); @WebServlet(urlPatterns = "/api/asyncTask") public class AsyncTaskServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) { AsyncContext asyncContext = req.startAsync(); asyncContext.setTimeout(30000); EXECUTOR.submit(() -> { try { // 模拟耗时任务 Thread.sleep(3000); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"message\":\"async task finished\"}"); } catch (Exception e) { e.printStackTrace(); } finally { asyncContext.complete(); } }); } }核心注意事项有下面几点:
第一,异步Servlet的请求处理链路里,如果你用了Filter,一定要记得调用chain.doFilter之后,Filter默认对异步分发不生效。如果需要在异步线程里继续走Filter逻辑,要在@WebFilter里加上asyncSupported = true,同时在@WebServlet里也要加asyncSupported = true。不然你会发现请求结束后,响应内容没有经过预期的Filter加工。
第二,startAsync()之后如果一直不调complete(),连接会一直挂着,最终触发超时。超时后会抛出AsyncListener的onTimeout事件,你需要在监听器里做清理并返回错误信息,避免客户端无限等待。
第三,注意自己的线程池生命周期。Executors.newFixedThreadPool创建的线程池如果应用卸载,线程不会自动关闭,可能导致Tomcat无法优雅停止。严谨点的做法是用ServletContextListener管理线程池生命周期,或者使用Spring的ThreadPoolTaskExecutor。
4.3 用AsyncContext解决一个真实场景
我做一个例子来帮助你理解:前端有一个导入任务,点击导入后,后端要做三件事:解析文件、写库、生成结果文件。这三件事加起来要耗8秒。如果用传统同步Servlet,这8秒内Tomcat工作线程一直被占用。
改用异步Servlet后,前端fetch发起请求,后端startAsync(),任务丢到业务线程池,Tomcat线程立刻释放,业务线程慢慢做8秒,最后把结果写回,asyncContext.complete()结束。前端那边await这个fetch,等8秒后拿到的就是完整结果。
如果你还想进一步优化用户体验,可以在任务开始后立即返回“任务已接收”,前端再通过另一个fetch轮询任务状态接口,或者用更高级的SSE(Server-Sent Events)推送进度。Tomcat 9对SSE也有很好的支持,不过那就是另一个话题了。
我个人的建议是:如果你的项目里确实存在“慢接口”和“高并发”同时存在的情况,才需要引入服务端异步。如果你的接口响应都在几百毫秒以内,按时让Tomcat线程把活干完,反而更简单可靠。异步不是银弹,引入之前想清楚收益。
5. 常见问题与排查技巧实录
5.1 Failed to fetch到底是哪里出了问题
“Failed to fetch”这个错误我在工作中看过的频率极高,很多人一看到这几个单词就懵了。根据我的排查经验,它背后的原因一般分这几类:
- 跨域被拦截。浏览器控制台会同时伴随CORS报错信息,后端有没有正确返回CORS响应头是关键。
- 请求地址写错。端口错、上下文路径错、Servlet路径错,都会导致连接失败或者404。最好先在后端日志里看请求到底有没有到达,没到达就说明是网络层/地址层问题。
- HTTPS与HTTP混合。如果你的页面是HTTPS,fetch却请求一个HTTP接口,浏览器会直接阻止。开发环境下前端和后端最好保持同协议同域名不同端口,或者统一走HTTPS。
- 请求被取消。可能是AbortController主动取消,也可能是页面跳转、刷新导致请求终止。
排查优先级我建议这样:打开浏览器开发者工具的Network面板,看这个请求的状态是什么,是canceled、failed还是直接没有发出。然后看Response有没有内容,再看控制台有没有具体的错误信息。80%的问题在这一步就能定位。
5.2 中文乱码的三个关键位置
中文乱码分三个环节,任何一个环节没设置好都会乱:
第一是请求参数乱码。如果是POST表单格式,调用getParameter前执行:
req.setCharacterEncoding("UTF-8");如果是GET参数带中文乱码,Tomcat 9默认URI编码已经是UTF-8,通常不会乱。如果改过Tomcat的conf/server.xml里的URIEncoding配置,要确保一致。
第二是响应乱码。后端返回中文,必须保证响应头里声明了UTF-8:
resp.setContentType("application/json;charset=UTF-8");千万不要只写resp.setCharacterEncoding("UTF-8")而不写setContentType,浏览器可能还是按预览默认的编码解析。我在把文本以text/plain方式返回时就吃过这个亏。
第三是前端解析乱码。如果你拿着fetch的响应text自己转JSON,浏览器会按响应头里的charset解码。如果响应头没声明,且内容里有中文,个别浏览器可能按Windows-1252解析,导致乱码。这种情况后端设置好charset后就能解决。
5.3 405和404的排查方向
405 Method Not Allowed,意思是路径找到了,但方法不对头。比如前端发了POST,后端只有doGet,就会405。还有一个容易忽略的情况:如果你重写了service方法,但没有处理某个方法类型,也会返回405。排查时先确认前端fetch的method写的是什么,再看后端Servlet到底处理了哪些方法。
404 Not Found,要么是路径没对上,要么是Servlet没部署上。路径是否包含上下文路径(/demo),很关键。很多人拿本机测试时习惯直接写localhost:8080/api/xxx,忘了加上工程名,结果一直404。另外检查war包是否被Tomcat正常加载,可以看Tomcat的logs目录下localhost当天日志,有报错的话会记录在这里。
5.4 端口、版本和部署的琐碎坑
Tomcat 9与Tomcat 8的最大区别之一是Servlet版本差异。如果你用的是Tomcat 8或更早版本,下面这两个语法就有兼容问题:
javax.servlet还是jakarta.servlet。Tomcat 9及之前的版本包名是javax.servlet,从Tomcat 10开始改成jakarta.servlet。这里特别容易混:你在Tomcat 10项目里导入import javax.servlet.annotation.WebServlet,编译都过不去,或者运行时ClassNotFound。项目标题写了Tomcat 9,那统一用javax.servlet是没问题的。
Tomcat 9可以运行在Java 8及以上,但如果你用Java 17以上版本,某些老版本Tomcat 9会有反射报错,建议升级到9.0.70以上。
端口占用这个坑,我想单独提醒一下。如果你启动Tomcat时控制台没有报错,但页面一直打不开,运行:
netstat -ano | grep 8080看看端口到底被谁占了。我曾经遇到一个情况是之前用debug模式启动了一个Tomcat实例没关干净,再次startup时看起来启动了,实际上新实例根本起不来。
5.5 一条稳定的调试链路
最后总结一套我实际用了很多年的调试链路,从底向上排查问题效率很高:
- 先用Postman或curl直接请求后端接口,确认接口本身没毛病:
curl -X POST http://localhost:8080/demo/api/user/add \ -H "Content-Type: application/json" \ -d '{"name":"张三","age":25}'这个返回如果正常,说明后端没问题,问题出在前端fetch或网络链路。
再打开浏览器Network面板,观察fetch请求的Request Headers、Payload、Response,核对Content-Type、参数格式、状态码。
最后才是改代码加日志。在后端Servlet入口打一行日志,确认请求到底有没有到达后端。很多时候问题在第一步就暴露了,根本不用改代码。
这条链路走一遍,绝大多数fetch异步项目的问题都能被快速定位,不至于靠猜。
6. 这个简单版本还可以怎么扩展
如果你已经把这个fetch + Tomcat 9的链路跑通了,我个人觉得可以顺着下面几个方向加深理解:
把Servlet改造成一个简易的前后端分离接口层,前端用fetch + async/await封装统一的request工具函数,后端把JSON返回格式统一成{code, message, data}结构。这会让你接触到前后端接口设计的一些约定问题,比如错误码怎么定义、分页参数怎么传。
然后可以尝试把后端某个耗时操作改造成异步Servlet + 前端轮询的组合,加深对AsyncContext的理解。这个方向适合想搞明白“服务端在什么场景下需要异步”的人。
再想深入的话,可以引入WebSocket或SSE,让后端主动推数据给前端。Tomcat 9对这两者的支持都不错,而且都是基于异步思想的延伸。等你把同步请求、异步请求、服务端推送这三层全跑通,Web前后端交互这块的基本功就算扎实了。
我最后再啰嗦一句:技术栈可以换,Spring Boot可以上,前端框架随便选,但fetch异步请求、参数传输格式、Servlet生命周期、线程池资源释放这些底层机制是共通的。这个“简单版本”看着不起眼,能把它彻底吃透,后面写大项目心里会踏实很多。