Java Web入门:手写Servlet+JSP+MySQL购物网站实战
简介这是一套基于Java Web经典技术栈ServletJSPMySQL实现的购物网站系统专为计算机专业学生课程设计、期末大作业及项目实战练习打造已获98分高分评价具备完整业务流程与工程规范性。资源包共69个文件包含15个核心Java源码、13个JSP页面、16个编译后Class文件、11个GIF和6个JPG等静态资源辅以SQL建表脚本、CSS样式、JS交互逻辑及README说明文档整体压缩后仅292KB轻量易部署。已有234人下载学习适合零基础入门Web开发或巩固MVC分层结构的学习者。读者可直接运行调试完整掌握用户注册登录、商品浏览、购物车管理、订单提交等核心模块实现逻辑并通过清晰的目录结构如src/com/、web/shopping4.jsp、WEB-INF/index.jsp等理解前后端协作机制与典型Java Web项目组织方式。1. 为什么一个用 Java Servlet JSP MySQL 搭建的购物网站至今仍是 Java Web 入门最硬核的“通关副本”不是因为技术多新——Servlet 3.1 是 2013 年定稿的规范JSP 在 Spring Boot 主流化后早已退居二线恰恰相反正因为它“老”才暴露出真实工程里最扎手的筋骨HTTP 请求怎么一层层穿过容器、过滤器、Servlet、JSP 编译器最终拼出 HTML数据库连接怎么在无框架下手动管理生命周期避免连接泄漏表单提交、会话保持、中文乱码、路径跳转这些看似 trivial 的环节稍一疏忽就让页面白屏、数据错乱、登录态丢失。这不是玩具 Demo而是把 Java Web 底层契约全摊开给你看的“裸机训练场”。适合三类人刚学完 Java 基础想落地 Web 开发的新手、面试前急需补全传统 Web 技术栈的求职者、以及需要快速搭建轻量后台验证业务逻辑的嵌入式/硬件团队。它不追求高并发或微服务但要求你亲手写web.xml、手写 JDBC 连接池、手写购物车 Session 管理——这些动作本身就是对 Java Web 运行时最扎实的肌肉记忆。2. 从零启动用 Tomcat 9 JDK 8 搭建可运行的最小购物系统骨架这个项目不是靠 Spring Boot 自动装配出来的“黑匣子”它的每一层都必须你亲手拧紧螺丝。我们不装 IDE 插件、不依赖 Maven 模板、不抄 starter只用最原始的 WAR 包结构和标准 Servlet 容器行为来构建。目标是部署后能访问首页、展示商品列表、完成用户登录、添加商品到购物车——四个核心链路全部跑通且每个环节的代码都能在 Tomcat 日志里找到对应输出。2.1 创建标准 Web 应用目录结构不依赖任何构建工具Java Web 传统项目的生命线是WEB-INF/web.xml和WEB-INF/classes下的字节码。我们手动创建以下结构路径以shop-web为根shop-web/ ├── index.jsp ├── login.jsp ├── product-list.jsp ├── WEB-INF/ │ ├── web.xml │ ├── classes/ │ │ ├── servlet/ │ │ │ ├── LoginServlet.class │ │ │ ├── ProductListServlet.class │ │ │ └── CartAddServlet.class │ │ └── dao/ │ │ ├── ProductDao.class │ │ └── UserDao.class │ └── lib/ │ └── mysql-connector-java-8.0.33.jar └── static/ ├── css/ │ └── style.css └── images/ └── logo.png提示WEB-INF是 Tomcat 的安全边界其下所有资源包括web.xml、classes、lib无法被浏览器直接访问这是 Servlet 规范强制要求。所有业务逻辑必须通过 Servlet 路由不能把.java源码放进去只能放编译后的.class。2.2 配置web.xml定义 Servlet 映射与初始化参数WEB-INF/web.xml是整个应用的“宪法”它声明了哪些类是 Servlet、它们响应什么 URL、是否需要初始化、会话超时时间等。以下是精简但完整的配置Servlet 3.1 兼容?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_3_1.xsd version3.1 !-- 应用名称 -- display-nameShopping Web App/display-name !-- 会话超时30分钟 -- session-config session-timeout30/session-timeout /session-config !-- 字符编码过滤器解决 POST 中文乱码 -- filter filter-nameCharacterEncodingFilter/filter-name filter-classjavax.servlet.Filter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameCharacterEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- 登录 Servlet -- servlet servlet-nameLoginServlet/servlet-name servlet-classservlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping !-- 商品列表 Servlet -- servlet servlet-nameProductListServlet/servlet-name servlet-classservlet.ProductListServlet/servlet-class /servlet servlet-mapping servlet-nameProductListServlet/servlet-name url-pattern/products/url-pattern /servlet-mapping !-- 购物车添加 Servlet -- servlet servlet-nameCartAddServlet/servlet-name servlet-classservlet.CartAddServlet/servlet-class /servlet servlet-mapping servlet-nameCartAddServlet/servlet-name url-pattern/cart/add/url-pattern /servlet-mapping /web-app关键点说明session-config设置会话超时时间单位为分钟。Tomcat 默认是 30 分钟但显式声明更可靠尤其在负载均衡场景下需与反向代理同步。filter是解决中文乱码的唯一可靠方式。很多新手在 JSP 里写% page contentTypetext/html;charsetUTF-8%但这只影响响应头对 POST 请求体无效。必须用 Filter 在请求进入 Servlet 前调用request.setCharacterEncoding(UTF-8)。servlet-class必须是完整类名含包路径且该类必须继承HttpServlet并实现doGet()或doPost()。Tomcat 启动时会扫描WEB-INF/classes下的 class 文件并加载。2.3 编写第一个 ServletLoginServlet实现表单接收与重定向LoginServlet不做密码加密生产环境必须加 BCrypt但必须体现 Servlet 核心契约获取请求参数、调用 DAO、设置会话属性、转发或重定向。// src/servlet/LoginServlet.java package servlet; import dao.UserDao; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; public class LoginServlet extends HttpServlet { private static final long serialVersionUID 1L; Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取表单参数注意GET 请求用 getParameterPOST 同样适用 String username request.getParameter(username); String password request.getParameter(password); // 2. 调用 DAO 验证此处简化为硬编码校验实际应查 DB boolean isValid new UserDao().validate(username, password); if (isValid) { // 3. 登录成功将用户信息存入 Session不是 Cookie HttpSession session request.getSession(true); session.setAttribute(user, username); session.setMaxInactiveInterval(1800); // 30分钟与 web.xml 一致 // 4. 重定向到首页避免刷新重复提交 response.sendRedirect(request.getContextPath() /index.jsp); } else { // 5. 登录失败回传错误信息到 login.jsp request.setAttribute(error, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // GET 请求直接转到登录页 request.getRequestDispatcher(/login.jsp).forward(request, response); } }逻辑说明与参数说明request.getSession(true)true表示如果当前没有 Session则新建一个false表示只获取已有 Session不存在则返回 null。登录场景必须确保 Session 存在。response.sendRedirect(...)是客户端重定向浏览器地址栏会变且是 302 响应。它比RequestDispatcher.forward()更安全因为后者是服务器端跳转URL 不变用户刷新会导致表单重复提交。request.setAttribute(...)设置的属性仅在本次请求生命周期内有效用于forward传递数据给 JSP而session.setAttribute(...)是跨请求的用于保持登录态。request.getContextPath()返回应用上下文路径如/shop-web避免硬编码路径提升部署灵活性。编译该类并放入WEB-INF/classes/servlet/LoginServlet.class后即可通过http://localhost:8080/shop-web/login访问假设部署名为shop-web。3. 数据持久层手写 JDBC 连接池与商品/用户 DAO 实现不用 MyBatis、不靠 Hibernate只用原生 JDBC DBCP2Apache Commons DBCP 2.9.0实现连接复用。这不是复古情怀而是为了看清连接怎么申请、怎么归还、事务怎么控制、SQL 注入怎么防御。MySQL 8.0.33 驱动必须用com.mysql.cj.jdbc.Driver旧版com.mysql.jdbc.Driver已废弃。3.1 配置DBCP2连接池WEB-INF/classes/db.properties不写死在代码里用外部配置文件解耦。内容如下# db.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/shop_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue usernameroot passwordyour_password initialSize5 maxTotal20 maxIdle10 minIdle5 maxWaitMillis5000 timeBetweenEvictionRunsMillis30000 minEvictableIdleTimeMillis60000 testOnBorrowtrue validationQuerySELECT 1参数说明useSSLfalse本地开发可关闭 SSL生产环境必须开启并配置证书serverTimezoneAsia/Shanghai必须显式指定否则 MySQL 8 会因时区不匹配抛java.sql.SQLException: The server time zone value ... is unrecognizedallowPublicKeyRetrievaltrueMySQL 8.0.21 默认禁用公钥检索开启才能连接initialSize5Tomcat 启动时预创建 5 个连接避免首请求慢testOnBorrowtruevalidationQuerySELECT 1每次从池中借连接前执行SELECT 1检测连通性防止拿到失效连接。3.2 编写JDBCUtil封装连接获取与释放逻辑// src/util/JDBCUtil.java package util; import org.apache.commons.dbcp2.BasicDataSource; import java.io.InputStream; import java.util.Properties; public class JDBCUtil { private static BasicDataSource dataSource; static { try { // 1. 加载配置文件 InputStream is JDBCUtil.class.getClassLoader() .getResourceAsStream(db.properties); Properties props new Properties(); props.load(is); // 2. 初始化 DBCP2 数据源 dataSource new BasicDataSource(); dataSource.setDriverClassName(props.getProperty(driverClassName)); dataSource.setUrl(props.getProperty(url)); dataSource.setUsername(props.getProperty(username)); dataSource.setPassword(props.getProperty(password)); // 3. 设置连接池参数与 properties 中一致 dataSource.setInitialSize(Integer.parseInt(props.getProperty(initialSize))); dataSource.setMaxTotal(Integer.parseInt(props.getProperty(maxTotal))); dataSource.setMaxIdle(Integer.parseInt(props.getProperty(maxIdle))); dataSource.setMinIdle(Integer.parseInt(props.getProperty(minIdle))); dataSource.setMaxWaitMillis(Long.parseLong(props.getProperty(maxWaitMillis))); dataSource.setTimeBetweenEvictionRunsMillis( Long.parseLong(props.getProperty(timeBetweenEvictionRunsMillis))); dataSource.setMinEvictableIdleTimeMillis( Long.parseLong(props.getProperty(minEvictableIdleTimeMillis))); dataSource.setTestOnBorrow(Boolean.parseBoolean(props.getProperty(testOnBorrow))); dataSource.setValidationQuery(props.getProperty(validationQuery)); } catch (Exception e) { throw new RuntimeException(初始化数据库连接池失败, e); } } public static java.sql.Connection getConnection() throws java.sql.SQLException { return dataSource.getConnection(); } public static void close(java.sql.Connection conn, java.sql.PreparedStatement ps, java.sql.ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (java.sql.SQLException e) { /* ignore */ } } if (ps ! null) { try { ps.close(); } catch (java.sql.SQLException e) { /* ignore */ } } if (conn ! null) { try { conn.close(); } catch (java.sql.SQLException e) { /* ignore */ } } } }关键设计点static块保证类加载时只初始化一次dataSource符合单例模式close(...)方法必须按ResultSet → PreparedStatement → Connection顺序关闭且每个都try-catch忽略异常JDBC 关闭异常不影响主流程getConnection()返回的是池中连接调用方无需关心创建逻辑但必须调用close()归还连接否则池会耗尽。3.3 实现ProductDao参数化查询与结果映射// src/dao/ProductDao.java package dao; import model.Product; import util.JDBCUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class ProductDao { public ListProduct findAll() throws SQLException { String sql SELECT id, name, price, stock FROM product ORDER BY id DESC; ListProduct list new ArrayList(); try (Connection conn JDBCUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Product p new Product(); p.setId(rs.getLong(id)); p.setName(rs.getString(name)); p.setPrice(rs.getBigDecimal(price)); p.setStock(rs.getInt(stock)); list.add(p); } } return list; } public Product findById(Long id) throws SQLException { String sql SELECT id, name, price, stock FROM product WHERE id ?; Product p null; try (Connection conn JDBCUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, id); // 防 SQL 注入用 ? 占位符 setXXX() try (ResultSet rs ps.executeQuery()) { if (rs.next()) { p new Product(); p.setId(rs.getLong(id)); p.setName(rs.getString(name)); p.setPrice(rs.getBigDecimal(price)); p.setStock(rs.getInt(stock)); } } } return p; } }避坑重点所有PreparedStatement必须用setXXX()设置参数绝不能字符串拼接 SQL如WHERE id id这是 SQL 注入的温床try-with-resources自动关闭ResultSet、PreparedStatement但Connection仍需显式close()已在JDBCUtil.close()中处理getBigDecimal(price)比getDouble()更安全避免浮点精度丢失价格必须用 BigDecimal。4. JSP 页面渲染从 MVC 分离到 EL 表达式与自定义标签实战JSP 不是“写 HTML 嵌 Java 代码”而是 MVC 中的 View 层。它的职责是展示数据、不处理业务、不操作数据库。我们用 EL 表达式替代% %脚本用 JSTL 替代循环判断用自定义标签封装重复逻辑如分页、图片路径拼接。4.1product-list.jsp用 JSTLc:forEach渲染商品列表% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % !DOCTYPE html html head meta charsetUTF-8 title商品列表/title link relstylesheet href${pageContext.request.contextPath}/static/css/style.css /head body div classcontainer h1全部商品/h1 !-- 判断是否有商品 -- c:if test${empty products} p classno-data暂无商品/p /c:if !-- 商品卡片列表 -- div classproduct-grid c:forEach items${products} varp div classproduct-card img src${pageContext.request.contextPath}/static/images/product-${p.id}.jpg alt${p.name} width150 height150 h3${p.name}/h3 p classpricefmt:formatNumber value${p.price} pattern¥#,##0.00//p p库存c:out value${p.stock}/ 件/p form action${pageContext.request.contextPath}/cart/add methodpost input typehidden nameproductId value${p.id} button typesubmit加入购物车/button /form /div /c:forEach /div /div /body /html关键细节说明c:if test${empty products}EL 表达式${empty xxx}是判断集合为空的最简写法比products null || products.size() 0更安全避免 NPE${pageContext.request.contextPath}动态获取应用上下文路径如/shop-web确保静态资源路径在任意部署路径下都正确fmt:formatNumberJSTL 格式化标签pattern¥#,##0.00保证价格显示为 “¥1,299.00”避免BigDecimal.toString()输出科学计数法c:out value${p.stock}/自动 HTML 转义防止 XSS如stock字段含script会被转成lt;scriptgt;。4.2 自定义标签img-url标签统一处理图片路径JSP 中反复写src${pageContext.request.contextPath}/static/images/xxx.jpg易出错。我们写一个自定义标签封装创建WEB-INF/tags/img-url.tag%-- WEB-INF/tags/img-url.tag --% % tag description生成图片绝对路径 pageEncodingUTF-8% % attribute namename requiredtrue typejava.lang.String % % String contextPath pageContext.getRequest().getContextPath(); String url contextPath /static/images/ pageContext.getAttribute(name); pageContext.setAttribute(imgUrl, url); % ${imgUrl}在 JSP 中使用% taglib prefixmy tagdir/WEB-INF/tags % !-- ... -- img srcmy:img-url nameproduct-${p.id}.jpg/ alt${p.name}优势路径逻辑集中维护修改图片目录只需改 tag 文件标签名my:img-url语义清晰比硬编码可读性强十倍。4.3login.jsp表单验证与错误提示的 JSP 实践% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html head meta charsetUTF-8 title用户登录/title /head body div classlogin-box h2用户登录/h2 !-- 显示错误信息 -- c:if test${not empty error} div classerror-message${error}/div /c:if form action${pageContext.request.contextPath}/login methodpost label用户名/label input typetext nameusername requiredbrbr label密码/label input typepassword namepassword requiredbrbr button typesubmit登录/button /form /div /body /html注意input required是 HTML5 前端校验不能替代后端校验。它只是用户体验优化真正校验必须在LoginServlet中进行如空值判断、长度限制。5. 避坑指南这 4 个血泪问题90% 的新手在部署时当场翻车部署阶段不是“复制 WAR 包到 webapps 就完事”而是暴露底层机制的照妖镜。以下问题我带过 17 届学生实训几乎人人踩过列出来就是救命清单。5.1 现象访问http://localhost:8080/shop-web/显示 404但http://localhost:8080/shop-web/index.jsp能打开原因web.xml中未配置 welcome-file-listTomcat 不知道默认首页是哪个文件。JSP 文件虽能直访但违背 MVC 路由原则首页应由 Servlet 控制。解决在web.xmlweb-app标签下添加welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list提示welcome-file-list是 Servlet 规范的一部分Tomcat 会按顺序查找index.jsp、index.html等。若写成index.html则index.jsp不会被命中。5.2 现象登录后跳转到index.jsp但页面顶部显示java.lang.ClassNotFoundException: servlet.ProductListServlet原因ProductListServlet.class编译后未放入WEB-INF/classes/servlet/目录或包名与目录结构不匹配如类声明package servlet;但 class 文件放在WEB-INF/classes/根目录下。解决确认编译命令为javac -d WEB-INF/classes -cp WEB-INF/lib/mysql-connector-java-8.0.33.jar:WEB-INF/lib/commons-dbcp2-2.9.0.jar:WEB-INF/lib/commons-pool2-2.11.1.jar src/servlet/ProductListServlet.java且-d参数指向WEB-INF/classes。5.3 现象商品列表页面中文显示为??数据库里明明是 UTF-8原因MySQL 连接 URL 缺少characterEncodingutf8mb4参数且web.xml的字符编码 Filter 未生效常见于 Filter mapping 的url-pattern写成/而非/*。解决修改db.properties的url行urljdbc:mysql://localhost:3306/shop_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8mb4确保web.xml中 Filter mapping 的url-pattern是/*不是/或/login在 MySQL 命令行执行ALTER DATABASE shop_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;。5.4 现象添加商品到购物车后刷新页面购物车清空原因购物车数据存在HttpSession中但未调用session.setAttribute(cart, cartList)或cartList是局部变量未存入 session或session.setMaxInactiveInterval()设置过短。解决在CartAddServlet中明确写HttpSession session request.getSession(true); ListProduct cart (ListProduct) session.getAttribute(cart); if (cart null) { cart new ArrayList(); } cart.add(product); // product 从 DAO 查询得到 session.setAttribute(cart, cart); // 必须重新 set否则引用未更新注意session.setAttribute()是值传递不是引用传递。cart是 ArrayList 引用cart.add()修改的是堆内存对象所以session.setAttribute(cart, cart)可省略错session.setAttribute()会触发内部序列化/反序列化机制必须显式调用才能确保 session 容器感知变更。6. 进阶技巧用 Filter 实现登录拦截与请求日志让系统真正“活”起来一个能上线的购物系统不能只满足功能跑通。它得有安全底线未登录不能访问商品页、有可观测性知道谁在什么时候做了什么、有可维护性日志能定位问题。这些都不靠框架靠两个 Filter 就搞定。6.1 登录拦截 Filter保护/products、/cart/*等敏感路径// src/filter/LoginCheckFilter.java package filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; public class LoginCheckFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); // 放行登录页、静态资源、登录接口 if (uri.contains(/login.jsp) || uri.contains(/login) || uri.contains(/static/) || uri.contains(/favicon.ico)) { chain.doFilter(req, resp); return; } // 检查 Session 中是否有 user 属性 HttpSession session request.getSession(false); if (session null || session.getAttribute(user) null) { // 未登录重定向到登录页 response.sendRedirect(request.getContextPath() /login.jsp); return; } // 已登录放行 chain.doFilter(req, resp); } }注册到web.xmlfilter filter-nameLoginCheckFilter/filter-name filter-classfilter.LoginCheckFilter/filter-class /filter filter-mapping filter-nameLoginCheckFilter/filter-name url-pattern/products/url-pattern url-pattern/cart/*/url-pattern /filter-mapping效果访问/products时自动跳转/login.jsp登录后再回来这才是真实业务逻辑。6.2 请求日志 Filter记录 IP、URL、耗时不依赖 Log4j// src/filter/LogFilter.java package filter; import javax.servlet.*; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; public class LogFilter implements Filter { private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; long start System.currentTimeMillis(); // 执行后续链 chain.doFilter(req, resp); // 记录日志打印到控制台生产环境应写入文件 long end System.currentTimeMillis(); String ip getClientIP(request); String uri request.getRequestURI(); String method request.getMethod(); int status ((HttpServletResponse) resp).getStatus(); System.out.printf([%s] %s %s %s %dms %d%n, LocalDateTime.now().format(FORMATTER), ip, method, uri, end - start, status); } private String getClientIP(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (xff ! null !xff.isEmpty()) { return xff.split(,)[0].trim(); } return request.getRemoteAddr(); } }注册到web.xml放在LoginCheckFilter之前确保日志记录所有请求filter filter-nameLogFilter/filter-name filter-classfilter.LogFilter/filter-class /filter filter-mapping filter-nameLogFilter/filter-name url-pattern/*/url-pattern /filter-mapping日志样例[2024-06-15 14:22:31] 127.0.0.1 GET /shop-web/products 127ms 200 [2024-06-15 14:22:35] 127.0.0.1 POST /shop-web/cart/add 8ms 302价值不用引入任何日志框架就能获得请求链路基础指标。当用户说“点击加入购物车没反应”你一眼看到302状态码就知道重定向成功问题不在后端而在前端跳转逻辑。6.3 最后一个习惯每次修改web.xml或添加新 Servlet务必重启 Tomcat这是新手最容易忽略的玄学点。Tomcat 对web.xml和WEB-INF/classes下的 class 文件是热加载的但不是实时生效。尤其是 Servlet 映射、Filter 配置、Listener 注册必须重启才能确保容器重新解析配置。我见过太多人改完web.xml不重启然后花两小时 debug “为什么 Filter 不生效”最后发现是缓存问题。我的做法是写完配置关掉 Tomcat 进程再双击startup.batWindows或./startup.shLinux看着控制台输出Server startup in [xxx] ms才开始测试。这 30 秒省下 3 小时。希望帮到你。本文还有配套的精品资源点击获取