
最近在帮团队整理一个内部实验项目需求很简单前端页面上通过fetch发一个异步请求Tomcat 9后面接一个Servlet处理把数据以JSON形式返回给页面页面再局部刷新。听起来就是教科书级别的demo但真正从零搭一遍发现里面其实堆了不少容易踩的小坑比如fetch默认不带Cookie、POST请求体读取姿势不对、跨域预检请求把后端打懵、Tomcat 9的异步Servlet跟旧版本写法的差异等等。这篇就把这个“fetch异步简单版本”完整拆开从环境准备、前端fetch核心用法到后端Servlet实现、Tomcat 9配置再到AsyncContext异步处理实战全部过一遍。我默认你是有一点点Java Web基础、但可能还没把前后端交互这条链路彻底摸清的人或者你正在做一个需要前端异步刷新、后端却不想上Spring Boot那种“轻框架”的小项目。这个项目版本我用的是Tomcat 9Servlet规范对应的是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 环境准备清单这个项目我本地的环境供你参考组件版本说明JDK1.8 或 11Tomcat 9 官方支持 JDK 8 及以上Tomcat9.0.x建议 9.0.60 以上版本修复了一些老问题IDEIntelliJ 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、500fetch照样会走then不会走catch。很多人踩的第一个坑就是“我明明后端返回500了为什么前端catch没抓到”原因就是这个。所以判断请求是否成功不能只看有没有进catch还要主动检查response.okfetch(/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-urlencodedconst 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会一直挂着。要超时需要借助AbortControllerconst 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 注解方式快速搭建ServletTomcat 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;charsetUTF-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-urlencodedTomcat容器会自动解析表单体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依赖。这里演示用GsonJsonObject jsonObject JsonParser.parseString(jsonBody).getAsJsonObject(); String name jsonObject.get(name).getAsString(); int age jsonObject.get(age).getAsInt();需要引入依赖dependency groupIdcom.google.code.gson/groupId artifactIdgson/artifactId version2.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;charsetUTF-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;charsetUTF-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目录下启动Tomcatbin/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;charsetUTF-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轮询任务状态接口或者用更高级的SSEServer-Sent Events推送进度。Tomcat 9对SSE也有很好的支持不过那就是另一个话题了。我个人的建议是如果你的项目里确实存在“慢接口”和“高并发”同时存在的情况才需要引入服务端异步。如果你的接口响应都在几百毫秒以内按时让Tomcat线程把活干完反而更简单可靠。异步不是银弹引入之前想清楚收益。5. 常见问题与排查技巧实录5.1 Failed to fetch到底是哪里出了问题“Failed to fetch”这个错误我在工作中看过的频率极高很多人一看到这几个单词就懵了。根据我的排查经验它背后的原因一般分这几类跨域被拦截。浏览器控制台会同时伴随CORS报错信息后端有没有正确返回CORS响应头是关键。请求地址写错。端口错、上下文路径错、Servlet路径错都会导致连接失败或者404。最好先在后端日志里看请求到底有没有到达没到达就说明是网络层/地址层问题。HTTPS与HTTP混合。如果你的页面是HTTPSfetch却请求一个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-8resp.setContentType(application/json;charsetUTF-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生命周期、线程池资源释放这些底层机制是共通的。这个“简单版本”看着不起眼能把它彻底吃透后面写大项目心里会踏实很多。