快递e栈JavaWeb实战:MVC分层、Filter拦截与取件并发控制

简介:《JavaWeb综合项目——快递e栈》面向已掌握Java基础、希望进阶JavaWeb实战的学习者,以快递业务为场景串联Servlet、Filter、Session与MVC分层开发,可用于课程设计、毕业项目参考或综合练习。压缩包共831个文件、约17.23MB,主要包含java源码与class文件、html/js/css前端资源、png/gif/jpg图片素材,以及jar依赖、jsp、xml和properties配置,覆盖页面展示、后端控制、静态资源与工程配置。已有890人学习下载。项目整合HTML、CSS、JavaScript、jQuery、Bootstrap、Layui、layer、Ajax及MySQL、Tomcat等技术,读者可对照源码梳理请求流转、会话管理、过滤器拦截与MVC分层结构,理解前后端交互和Web模块组织方式,也可作为二次开发与功能扩展的基础工程。

1. 快递 e 栈:一个能跑通、也能改得动的 JavaWeb 练习项目

前段时间帮人看一个课程设计:用户输入取件码点“确认取件”,页面转了两秒回到首页,数据库里那条记录还是“待取件”,日志干干净净。打开代码发现所有逻辑都堆在一个doPost里,Session随手new,异常被catch (Exception e) {}直接吃掉——这种“能跑但改不动”的状态,是大部分 JavaWeb 学习者卡住的地方。快递 e 栈这个项目正好相反,它把快递驿站每天真正会发生的几件事压进一个工程:登录鉴权、取件码校验、快递状态流转、分页列表、快递员录入、后台统计。技术栈不花哨,Servlet 当控制器、Filter 做统一拦截、Session 存登录态、JSP 渲染视图、JDBC 直连 MySQL,MVC 分层落地。适合刚学完 Servlet 想找个完整项目练手的人,也适合要交课程设计又不想只交一份 CRUD 的人。

2. 工程结构与请求链路:一个取件请求从 Servlet 走到 DAO

这一章先把工程骨架铺开。拿到一个 JavaWeb 项目,我习惯先看三样东西:包名怎么分、web.xml或注解怎么配、有没有一层统一的基类。这三样决定了后面是“加个功能改三个文件”还是“加个功能翻十个文件”。快递 e 栈用的是标准的 Maven Web 工程,Servlet 3.1 之后注解和web.xml可以混用,但配置文件里的东西必须和注解对得上,这是后面避坑章要展开的第一个雷。

2.1 目录结构:包名就是分层

分层不是画在 PPT 上的,是刻在包名里的。下面这个结构是我按项目实际组织方式整理出来的,改哪儿、加哪儿,看一眼包名就有数:

express-e-station/ ├── pom.xml ├── src/main/java/com/example/express/ │ ├── controller/ # Servlet 层:收参数、校验、转发 │ │ ├── BaseServlet.java # 反射分发,子类只写业务方法 │ │ ├── UserServlet.java # 登录/注册/退出 │ │ ├── ExpressServlet.java # 取件/查询/我的快递 │ │ └── AdminServlet.java # 录入/批量导入/统计 │ ├── service/ # 业务层:事务边界在这里 │ │ ├── ExpressService.java │ │ └── UserService.java │ ├── dao/ # 数据访问层:只写 SQL │ │ ├── ExpressDao.java │ │ └── UserDao.java │ ├── entity/ # POJO,字段和表一一对应 │ ├── filter/ # 编码、登录、权限 │ ├── util/ # JdbcUtils / CodeUtil / Result │ └── listener/ # 启动时初始化连接池 ├── src/main/webapp/ │ ├── WEB-INF/views/ # JSP 放这里,禁止浏览器直接访问 │ ├── static/ # css / js / img │ └── login.jsp └── src/main/resources/ ├── db.properties └── druid.properties

WEB-INF/views这个位置有讲究。JSP 放在WEB-INF下面,浏览器就拼不出地址直接访问,所有页面都必须经过 Servlet 转发才渲染得出来,登录态校验也就绕不过去。新手常见的做法是把 JSP 直接扔在webapp根目录,结果就是用户不登录、手敲一个list.jsp也能看到全部快递单——这个漏洞在课程答辩现场被问出来是很难看的。

分层之间的调用方向是单向的:Controller 只能调 Service,Service 只能调 Dao,Dao 不认识 Servlet 的任何类。判断一个项目分层有没有做扎实,我一般搜一下import javax.servlet出现在哪些包里,只要 Service 和 Dao 里出现,说明逻辑已经开始往上漏了。

2.2 请求链路:从/express/take到一条 UPDATE

以取件为例,完整链路是这样的:前端表单 POST 到/express/take,EncodingFilter先把请求编码设成 UTF-8,LoginFilter从 Session 里取loginUser判断是否放行,放行后进ExpressServlet,BaseServlet从 URL 最后一段解析出方法名take,反射调用take(req, resp)。take里只做两件事:校验取件码格式、把参数交给ExpressService。Service 开事务、加行锁、判断状态、更新状态,返回结果对象。Controller 拿到结果后决定是转发 JSP 还是输出 JSON。

整条链路上每一层只干一件事,出问题时可以精确地定位到“是拦截没放行”“是参数没进来”还是“是 SQL 没提交”。这是 MVC 在这个规模的项目里唯一的意义——不是为了显得专业,是为了出 bug 的时候能少猜五次。

2.3 BaseServlet:用反射干掉 if-else 分发

一个 Servlet 里塞五六个if ("take".equals(action)),是这类项目最常见的写法。项目里换成继承一个基类,子类只写业务方法,分发交给反射:

public class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 约定:URL 最后一段就是方法名,例如 /express/take -> take(...) String uri = req.getRequestURI(); // /express-e-station/express/take String methodName = uri.substring(uri.lastIndexOf('/') + 1); try { // 只找签名固定为 (req, resp) 的 public 方法 Method method = this.getClass().getMethod( methodName, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(HttpServletResponse.SC_NOT_FOUND, "方法不存在: " + methodName); } catch (InvocationTargetException e) { // 把业务层抛出的真实异常剥出来,否则日志里全是反射壳子 Throwable real = e.getTargetException(); throw new ServletException(real); } catch (IllegalAccessException e) { throw new ServletException(e); } } }

service是HttpServlet里被doGet/doPost共同调用的入口,重写它等于同时接管了两种请求方法,前端用 GET 查列表、POST 提交表单都不用再写两遍。

getMethod的第二个、第三个参数是参数类型,必须和子类方法签名严格一致,HttpServletRequest写成ServletRequest就反射不到,运行时抛NoSuchMethodException。InvocationTargetException这个包装必须剥掉,不剥的话堆栈第一行永远是method.invoke,真正的SQLException藏在 cause 里,排查时会多绕一圈。

子类写起来就干净了:

public class ExpressServlet extends BaseServlet { private final ExpressService expressService = new ExpressService(); // 对应 POST /express/take public void take(HttpServletRequest req, HttpServletResponse resp) throws IOException { String code = req.getParameter("code"); User loginUser = (User) req.getSession().getAttribute("loginUser"); Result result = expressService.take(code, loginUser.getId()); writeJson(resp, result); } }

提示:反射分发要求方法名在类内唯一且为 public,子类里别写重载的同名方法,否则getMethod只认签名完全匹配的那一个。

2.4 数据层:JdbcUtils 加一层薄模板

Dao 层不引 ORM 也可以写得很干净,关键是连接获取和释放必须收口到一个工具类里,不能每个方法自己DriverManager.getConnection。项目里JdbcUtils从druid.properties读连接池配置,对外只暴露三个方法:

public final class JdbcUtils { private static DataSource DS; static { try (InputStream in = JdbcUtils.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); DS = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("连接池初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DS.getConnection(); // 从池里借,不是新建 } public static void rollback(Connection conn) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) { } } } public static void close(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException ignored) { } // 归还给池 } } }

参数上唯一要盯的是druid.properties里的maxActive。课程演示环境并发很低,设 10 就够;设成 200 反而容易把 MySQL 的max_connections打满。initialSize建议 2 到 5,启动时预建几条连接,第一个请求不会卡在建连上。

Dao 方法统一用 try-with-resources 管PreparedStatement和ResultSet,连接由 Service 传进来或由 Dao 自己借自己还,规则只能选一种,混着写迟早出现“连接借了没还”。

public List<Express> pageByStatus(int status, int page, int size) throws SQLException { String sql = "SELECT id, code, phone, status, shelf, in_time FROM express " + "WHERE status = ? ORDER BY in_time DESC LIMIT ?, ?"; List<Express> list = new ArrayList<>(); try (Connection conn = JdbcUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, status); ps.setInt(2, (page - 1) * size); // LIMIT 偏移从 0 开始,别直接传 page ps.setInt(3, size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } return list; }

LIMIT ?, ?的第一个问号是偏移量不是页码,这是分页翻车的经典位置:第 2 页传page=2会跳过 2 条而不是 10 条,现象是“第二页和第一页数据重叠”。

2.5 表结构:三张表撑起 90% 的业务

快递单表是核心,用户表和驿站货架表是配角。字段设计上我建议一开始就把状态和索引定好,后面加功能不用改表:

CREATE TABLE `express` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `code` CHAR(6) NOT NULL COMMENT '取件码,6 位数字', `phone` VARCHAR(11) NOT NULL COMMENT '收件人手机号', `user_id` BIGINT DEFAULT NULL COMMENT '关联用户,散客为 NULL', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待取 1已取 2退回', `shelf` VARCHAR(16) DEFAULT NULL COMMENT '货架位,如 A-03-12', `in_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `out_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_phone_status` (`phone`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_code这个唯一索引不是装饰,它是取件码生成逻辑的兜底——并发插入两条相同取件码时数据库会直接拒绝,程序捕获DuplicateKeyException重试即可,比在应用层做“先查再插”靠谱得多。idx_phone_status是给“我的快递”列表用的,用户端查询几乎都是“某手机号 + 某状态”,联合索引顺序不能反。

3. 登录态与拦截:Filter 和 Session 怎么配合才不打架

这套项目里最容易出玄学问题的地方就是登录态。现象往往是“我明明登录了,点两下又跳回登录页”,或者“退出登录后按浏览器后退还能看到订单”。根子不在 Session 本身,在于 Session 里存了什么、Filter 什么时候读、页面有没有被缓存。这一章把这几个点拆开讲。

3.1 Session 里到底该存什么

我的习惯是只存一个精简对象,不存整个User实体。实体里往往带密码字段、带手机号,一旦某天 JSP 里手滑写了${loginUser.password},就是事故。

public class LoginUser implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String nickname; private Integer role; // 1 普通用户 2 快递员 3 管理员 // 不含 password、不含手机号全量 }

登录成功后:

LoginUser loginUser = userService.login(phone, rawPassword); if (loginUser != null) { HttpSession old = req.getSession(false); if (old != null) { old.invalidate(); // 防会话固定:登录后换一个新 session } req.getSession(true).setAttribute("loginUser", loginUser); req.getSession().setMaxInactiveInterval(30 * 60); // 30 分钟 }

invalidate()再新建这一步叫会话固定防护,攻击者预先塞给你一个 sessionId,你登录后继续用同一个 id,对方就顺理成章拿到了登录态。教学项目里很少写这一步,但它只多两行代码,补上不亏。

setMaxInactiveInterval单位是秒,不设的话走容器默认,Tomcat 默认 30 分钟,web.xml里改<session-config>也能全局覆盖,两处都写以代码为准。

3.2 登录校验 Filter 的完整写法

Filter 要做三件事:放行白名单、识别未登录、区分页面请求和 Ajax 请求。第三种最容易漏,漏了的后果是前端 ajax 拿到一整页登录页 HTML,JSON.parse直接抛错,控制台一片红。

@WebFilter(urlPatterns = {"/express/*", "/admin/*", "/user/*"}) public class LoginFilter implements Filter { // 白名单:登录、注册、静态资源 private static final String[] WHITE = { "/user/login", "/user/register", "/login.jsp", "/static/" }; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 去掉上下文路径,得到 /express/take 这种干净路径 String path = req.getRequestURI().substring(req.getContextPath().length()); for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); return; } } // 关键:传 false,没有会话就不要新建一个空的 HttpSession session = req.getSession(false); Object loginUser = session == null ? null : session.getAttribute("loginUser"); if (loginUser == null) { if ("XMLHttpRequest".equals(req.getHeader("X-Requested-With"))) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"登录已失效,请重新登录\"}"); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } return; // 这里必须 return,不能继续 chain } chain.doFilter(request, response); // 放行 } }

getSession(false)和getSession()的区别在于前者在无会话时返回null,后者会顺手建一个。之前一次排查中发现访问日志里每个未登录请求都会建一个空 Session,内存里堆了几万个,就是这一处写成了无参版本。

sendRedirect之后必须return,否则请求会继续传给下一个 Filter 或 Servlet,运气不好就在重定向响应里又写了一段 HTML,浏览器报“响应头已提交”。

3.3 静态资源和白名单:别让 css 也被拦

urlPatterns写成/*是最省事也最容易翻车的写法。css、js、图片全部经过 Filter,白名单没覆盖到就一律被重定向成登录页,现象是“登录页样式全丢了,Network 里 css 返回的是 HTML”。两种解法:一是像上面那样把/static/放进白名单,精确匹配前缀;二是把 Filter 映射收窄成/express/*、/admin/*、/user/*这种业务前缀,静态资源根本不在射程内。我更倾向第二种,配置少、漏洞面小。

白名单的匹配方式也要注意,path.startsWith("/user/")会把/user/login之外的所有/user/*也放过去,想精确匹配就得用equals。项目里登录、注册这类接口老老实实写全路径比较稳。

3.4 角色权限:三档身份怎么判

登录只解决“你是谁”,不解决“你能不能干这个”。管理员能看全部快递单,普通用户只能看自己的,这层判断放在 Filter 里做最省事,也可以放在 Service 里做。我一般两边都放:Filter 拦掉明显越权的入口,Service 里再校验一次数据归属,防止有人绕过页面直接拼参数。

/** 角色校验,用在 /admin/* 上 */ public class RoleFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; LoginUser user = (LoginUser) req.getSession().getAttribute("loginUser"); // LoginFilter 先执行,这里理论上不会为空,兜一层保险 if (user == null || user.getRole() == null || user.getRole() < 3) { ((HttpServletResponse) response).sendError(403, "无权限访问"); return; } chain.doFilter(request, response); } }

两个 Filter 的执行顺序由web.xml里<filter-mapping>的声明顺序决定,用注解的话顺序不确定。如果项目里同时有编码 Filter 和登录 Filter,编码必须在前,否则登录 Filter 里读中文参数会读到乱码。这类顺序问题用注解配很容易踩,稳妥的做法是把顺序敏感的两个 Filter 写进web.xml,其余用注解。

3.5 退出登录:别只删一个属性

退出登录有人写成session.removeAttribute("loginUser"),看着能用,实际上 Session 还在,其他属性还在,服务端资源也没释放。正确做法是invalidate()直接销毁:

public void logout(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(false); if (session != null) { session.invalidate(); } resp.sendRedirect(req.getContextPath() + "/login.jsp"); }

还有一个细节:退出后按浏览器后退键,页面可能还是从缓存里读出来的旧内容。给需要登录的页面加一句响应头就能解决,放在LoginFilter的放行分支里统一设:

resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); resp.setHeader("Pragma", "no-cache");

4. 核心业务拆解:取件码生成、行锁取件与分页列表

前面两章搭的是骨架,这一章讲真正有业务味道的地方。驿站项目和普通 CRUD 的区别就在取件这一步:它天然带并发,两个人同时输同一个取件码,只能成功一次;它还带状态约束,已取件的单子不能被重复取。这两条如果只在页面上下工夫,数据库里迟早出现两条status=1的异常数据。

4.1 取件码生成:SecureRandom 加唯一索引兜底

取件码一般是 6 位数字,量级一百万。小项目用Random也够,但我习惯上SecureRandom,成本几乎为零,还能避免同一毫秒内连续调用取到同一序列。

public class CodeUtil { private static final SecureRandom RANDOM = new SecureRandom(); /** * 生成 6 位数字取件码,冲突则重试 * @param dao 用来查重 * @return 未被占用的取件码 */ public static String nextCode(ExpressDao dao) throws SQLException { for (int i = 0; i < 5; i++) { String code = String.format("%06d", RANDOM.nextInt(1_000_000)); if (!dao.existsByCode(code)) { return code; } } // 连试 5 次都撞,说明号段接近饱和,需要人工介入或扩位 throw new IllegalStateException("取件码分配失败,请检查号段占用率"); } }

String.format("%06d", ...)保证7会被补成000007,不然前端输入框限 6 位,用户永远输不进去。存在性检查只是降低冲突概率,真正的兜底是表上的uk_code唯一索引:并发场景下两个线程可能同时查到“不存在”,然后同时插入,后插入的那个被数据库拒绝,Service 捕获后重试一次就行。

public Result save(Express express) { for (int i = 0; i < 3; i++) { try { express.setCode(CodeUtil.nextCode(expressDao)); expressDao.insert(express); return Result.ok(express.getCode()); } catch (DuplicateKeyException e) { // 并发撞码,重新生成再试 log.warn("取件码冲突,重试第 {} 次", i + 1); } catch (SQLException e) { return Result.fail("入库失败:" + e.getMessage()); } } return Result.fail("取件码分配失败,请稍后再试"); }

4.2 取件动作:行锁加状态判断,防重复取件

取件的核心是一段必须原子的“读状态—改状态”。只写UPDATE express SET status = 1 WHERE code = ?是危险的,它不检查当前状态,两个人同时提交,两条 UPDATE 都成功,业务上等于同一件快递被取走两次。

public Result take(String code, Long userId) { if (code == null || !code.matches("\\d{6}")) { return Result.fail("取件码格式不正确"); } Connection conn = null; try { conn = JdbcUtils.getConnection(); conn.setAutoCommit(false); // 手动事务 // SELECT ... FOR UPDATE 锁住这一行,另一个事务会阻塞等待 Express e = expressDao.selectByCodeForUpdate(conn, code); if (e == null) { conn.rollback(); return Result.fail("取件码不存在"); } if (e.getStatus() != 0) { conn.rollback(); return Result.fail(e.getStatus() == 1 ? "该件已被取走" : "该件已退回"); } if (userId != null && e.getUserId() != null && !userId.equals(e.getUserId())) { conn.rollback(); return Result.fail("取件人与收件人不一致"); } expressDao.updateStatus(conn, e.getId(), 1); // 0 -> 1 conn.commit(); return Result.ok("取件成功,货架位:" + e.getShelf()); } catch (SQLException ex) { JdbcUtils.rollback(conn); return Result.fail("系统繁忙,请重试"); } finally { JdbcUtils.close(conn); // 归还连接池 } }

selectByCodeForUpdate里的 SQL 是SELECT ... FROM express WHERE code = ? FOR UPDATE,行锁生效的前提是code上有索引,没索引会退化成表锁,并发一高整个表都堵住。

conn.setAutoCommit(false)之后所有分支都必须显式commit或rollback,包括return之前。少写一个rollback的后果是连接带着未提交事务被归还给池,下一个借到它的请求可能读到脏数据,或者连接一直挂着锁不释放——这就是连接池耗尽类问题的常见起因之一。

4.3 状态机:三个状态值定死,不要随便加

状态字段用数字没问题,但取值必须在文档和代码里锁死,不然半年后没人记得2是退回还是异常。项目里就三个值:

值含义允许的下一步触发方
0待取件1 已取件 / 2 已退回用户取件 / 管理员退回
1已取件无(终态)用户
2已退回无(终态)管理员

out_time只在0 -> 1或0 -> 2时写入,用NOW()而不是应用层取时间,避免服务器和数据库时区不一致导致时间对不上。这个字段看起来不重要,做“当日取件量”统计的时候就是唯一的依据。

4.4 分页列表:把总数和当页数据一起返回

列表页最忌讳“只查当页、前端算总数”。正确做法是一次返回total和list,前端按页渲染。Service 里两条 SQL,一条COUNT(*),一条带LIMIT的查询:

public PageResult<Express> page(int status, int page, int size) throws SQLException { if (page < 1) page = 1; if (size < 1 || size > 100) size = 10; // 卡住 size 上限,防止被拖库 int total = expressDao.countByStatus(status); List<Express> list = total == 0 ? Collections.emptyList() : expressDao.pageByStatus(status, page, size); PageResult<Express> result = new PageResult<>(); result.setTotal(total); result.setList(list); result.setPage(page); result.setPages((total + size - 1) / size); // 向上取整,别用 total/size return result; }

size上限这一行别省。有人手动改 URL 传size=100000,一条 SQL 把整张表捞进内存,页面直接 502。total == 0时直接返回空列表,省一次无意义的数据库往返。

对应的 JSP 渲染部分,用 JSTL 遍历,不要在里面写 Java 小脚本:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table class="table"> <c:forEach items="${page.list}" var="e"> <tr> <td>${e.code}</td> <td>${e.phone}</td> <td>${e.shelf}</td> <td> <c:choose> <c:when test="${e.status == 0}">待取件</c:when> <c:when test="${e.status == 1}">已取件</c:when> <c:otherwise>已退回</c:otherwise> </c:choose> </td> </tr> </c:forEach> </table>

${e.status == 0}这种 EL 表达式依赖 JSP 2.0 以上和 JSTL 依赖,pom.xml里必须引jstl,web.xml的版本不能写成 2.3,否则 EL 不解析,页面上直接打印${e.status}原文。这个坑在避坑章还会再提一次。

5. 排查避坑:五个让项目「能跑但不对劲」的坑

下面这几条都是我在这类项目上真实遇到过的,症状看起来五花八门,原因都很朴素。每条按现象、原因、解决三段说,遇到同类问题可以直接对照。

5.1 中文乱码:GET 和 POST 是两套解法

现象:表单提交的收件人姓名存进数据库变成????,但同一个页面用 Chrome 开发者工具看请求体又是正常的汉字。

原因:POST 请求体的编码由request.setCharacterEncoding("UTF-8")控制,必须在第一次读取参数之前设置;GET 请求的参数在 URL 上,编码由server.xml里 Connector 的URIEncoding决定,Servlet 里设 request 编码对它无效。很多项目只在 Servlet 里设了一句,POST 好了 GET 还是乱的。

解决:写一个EncodingFilter放在 Filter 链最前面,同时覆盖请求和响应;Tomcat 8 以后默认URIEncoding就是 UTF-8,但 7 及更早版本要手工在 Connector 上加URIEncoding="UTF-8"。数据库连接串也别漏,jdbc:mysql://...?useUnicode=true&characterEncoding=utf8,表统一utf8mb4。

@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 只对 POST 请求体生效 resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }

5.2 连接池耗尽:连接借了没还

现象:本地跑得好好的,演示时点几下就卡住,日志里出现wait millis 60000, active 10, maxActive 10,之后所有请求全部超时。

原因:某个 Dao 方法在异常分支里直接return,finally里又只关了ResultSet,Connection没还。也可能是setAutoCommit(false)之后没commit,连接一直挂着一个未结束事务,池子认为它还在使用。

解决:所有 Dao 方法统一用 try-with-resources 管Connection、PreparedStatement、ResultSet三个资源;Service 里手动事务的连接必须在finally里close。验证方式是压测 200 次请求后看 Druid 监控页的active数是否回到 0,回不去就说明有泄漏。

5.3 Filter 被执行了两次

现象:在 Filter 里打日志,发现同一个请求doFilter打印了两遍,登录校验看着也没错,但重定向逻辑触发了两次。

原因:@WebFilter注解和web.xml里同时配了同一个 Filter 类,容器把它当成两个 Filter 注册,链上跑两遍。项目从教程里复制web.xml配置、又加了注解,最容易撞上。

解决:二选一。用注解就把web.xml里的<filter>和<filter-mapping>删干净;用web.xml就把注解去掉。需要控制执行顺序的场景(比如编码 Filter 必须最先)建议统一走web.xml,注解的加载顺序不受控。

5.4 Session 重启即失效,登录态莫名丢失

现象:改完代码重启 Tomcat,之前登录的账号全部变回未登录,前端没有任何提示,点按钮直接跳登录页。

原因:Tomcat 默认会在关闭时把 Session 序列化到work目录,重启后恢复,但前提是 Session 里存的对象实现了Serializable。LoginUser没实现序列化接口,反序列化失败,Session 直接被丢弃。另外项目重新部署(redeploy)时整个work目录被清空,Session 一样没了。

解决:Session 里存的对象一律实现Serializable并显式声明serialVersionUID。真要解决“重启不掉线”,得把登录态挪到外部存储,教学项目里没必要,接受重启掉线更省事,前端加个统一的 401 拦截跳登录即可。

5.5 JSP 直接返回源码或者报 404

现象:访问/admin/list.jsp浏览器下载了一个.jsp文件,或者页面上的${page.total}原样打印出来。

原因:访问路径少了/WEB-INF前缀,容器把这个请求当成静态资源处理;EL 不解析多半是web.xml声明成了 Servlet 2.3,那个版本默认关闭 EL。

解决:JSP 放在WEB-INF/views下,只能通过req.getRequestDispatcher("/WEB-INF/views/list.jsp").forward(req, resp)访问;web.xml头部声明升到 3.1,pom.xml里补上jstl和taglibs-standard-impl依赖。转发用forward不用redirect,redirect会让浏览器重新发一次请求,request 域里的数据全丢,列表页会变成空白。

6. 二次开发:给快递 e 栈加一个自提柜模块的改造路径

项目跑通之后,真正有意思的是加东西。自提柜是个合适的练手需求:它和快递单是一对多,又带一点状态约束(柜门被占就不能再放),改造路径能完整走一遍 MVC 的五层。我把当时加这个模块的顺序记一下,照着做基本不会漏。

先建表,柜门和快递单用外键松耦合(不建物理外键,用字段关联,方便后期分库):

CREATE TABLE `locker` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `station_id` BIGINT NOT NULL COMMENT '所属驿站', `door_no` VARCHAR(8) NOT NULL COMMENT '柜门编号,如 A-01', `size` TINYINT NOT NULL DEFAULT 1 COMMENT '1小 2中 3大', `express_id` BIGINT DEFAULT NULL COMMENT '占用它的快递单,NULL 表示空闲', PRIMARY KEY (`id`), UNIQUE KEY `uk_station_door` (`station_id`, `door_no`), KEY `idx_express` (`express_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

接着按依赖方向从下往上写:LockerDao提供findFreeBySize、bind、unbind三个方法;LockerService里做“找空柜—绑定—失败重试”的逻辑,事务边界放在这里;LockerServlet extends BaseServlet暴露/locker/list和/locker/bind;最后在LoginFilter的urlPatterns里加上/locker/*,在角色判断里限定只有快递员和管理员能调绑定接口。

有个细节值得单独说:绑定柜门本质是一次“抢占”,必须防并发。

// 用条件更新代替“先查后改”,一条 SQL 完成抢占 String sql = "UPDATE locker SET express_id = ? WHERE id = ? AND express_id IS NULL"; int rows = ps.executeUpdate(); if (rows == 0) { return Result.fail("该柜门已被占用,请刷新后重试"); }

WHERE express_id IS NULL是这里的灵魂,两个人同时抢同一个空柜,只有一行会更新成功,另一行rows为 0,直接返回失败让用户刷新。这比加锁轻,也不依赖事务隔离级别,属于我加新功能时的默认写法。

验证方式我一般分三步走:先用curl直接打接口,确认状态码和返回体正确;再用两个终端并发跑同一个绑定请求,看是不是只有一个 200;最后在页面上手动点一遍,确认柜门状态刷新正常。第三步最容易被跳过,但很多问题是“接口对、页面没刷新”导致的。

改造点需要改的文件容易漏的地方
新增表locker.sql忘了给station_id + door_no加唯一键
Dao 层LockerDao.java抢占用条件更新,别再写先查后改
Service 层LockerService.java事务边界,成功和失败都要显式提交
Servlet 层LockerServlet.java继承BaseServlet,方法名和 URL 尾段对上
拦截配置LoginFilter新路径没加进urlPatterns,接口裸奔
页面WEB-INF/views/locker/list.jsp放WEB-INF下,别放根目录

这套改造路径其实可以套用到任何新模块上:建表、Dao、Service、Servlet、Filter、JSP,六步走完一个功能就闭环了。我现在拿到一个不熟的 JavaWeb 项目,改功能前都会先按这个顺序把调用链在纸上画一遍,画不通的地方就是后面要踩的坑。希望帮到你。

本文还有配套的精品资源,点击获取