☰
Servlet+Filter+Session实现快递存取系统MVC实战
2026/11/2 6:51:06 网站建设 项目流程

简介:这是一套面向JavaWeb初学者与课程设计者的完整MVC架构实战项目——快递e栈,覆盖Servlet、Filter、Session等核心机制及前后端协同开发全流程。资源包含831个文件,以109个编译后class文件、52个Java源码、72个JS脚本、58个HTML页面、38个CSS样式表及36个JAR依赖库为主干,辅以大量PNG/GIF界面素材与少量JSP、XML和配置文件,整体包体17.23MB,结构清晰、模块完整,便于分层理解与调试。已有890人学习下载,适合高校Web编程实训、毕业设计参考或Servlet进阶练习。读者可直接部署运行,获得含用户管理、快递员调度、快件流转等业务功能的可执行系统,同时通过源码深入掌握Bootstrap+Layui前端集成、AJAX异步交互、MySQL数据持久化及Tomcat云服务器部署等关键实践环节。

1. 快递e栈不是Demo,是Servlet+Filter+Session驱动的MVC落地闭环:一个能跑通用户注册、快递存取、后台管理的完整JavaWeb骨架

你可能见过几十个“JavaWeb学生项目”,但真正能让你在三天内搭起可运行、可调试、可扩展的快递存取系统骨架的,极少。快递e栈不是教科书式MVC分层演示,它用原生Servlet不依赖Spring Boot,靠Filter统一处理登录校验与字符编码,用HttpSession稳稳托住用户状态与快递柜格口占用关系,所有请求路由、数据流转、页面跳转都落在标准J2EE规范内。它解决的是真实教学与入门实战间的断层:当你写完UserServlet却卡在“为什么登录后跳转首页又回到登录页”,当你配好web.xml却搞不清Filter链执行顺序导致中文乱码+权限绕过,当你想加个“超时未取自动释放格口”逻辑却找不到Session生命周期钩子——这个项目里,每一步都有对应实现、每一处都有注释锚点。适合刚学完HTTP协议、Servlet生命周期、JSP基础,正卡在“知道概念但串不起流程”的开发者;也适合需要快速交付课程设计、毕设原型、实训平台后台模块的某高校实训导师。它不炫技,但足够扎实——所有代码都在src/main/java下按包结构清晰归位,WEB-INF/web.xml里Filter配置顺序就是你调试时该盯的日志顺序。


2. MVC分层不是目录摆设:从Request到View的七步数据流与三层职责切分

2.1 控制层(Controller):Servlet如何成为真正的请求调度中枢

快递e栈的控制层完全由javax.servlet.http.HttpServlet子类承担,拒绝@WebServlet注解式声明,坚持web.xml显式注册——这是为了让你看清容器启动时Servlet初始化的真实时机。以LoginServlet为例,其doPost方法不直接查库、不拼HTML,只做三件事:

  1. 从request.getParameter()提取用户名密码;
  2. 调用UserService.login(username, password)获取User对象;
  3. 根据返回结果调用response.sendRedirect()或request.getRequestDispatcher().forward()。

关键在于第2步:UserService属于Service层,它不持有DAO,而是通过UserDao接口解耦。这种写法强制你在web.xml中配置<servlet>时必须明确<load-on-startup>1</load-on-startup>,否则首次请求会因Service未初始化而NPE。我一般会在BaseServlet中统一处理request.setCharacterEncoding("UTF-8")和response.setContentType("text/html;charset=UTF-8"),避免每个Servlet重复写——但注意,这必须在super.doPost()之前执行,否则getParameter()仍会乱码。

// src/main/java/com/kuaidi/servlet/LoginServlet.java public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 【必须前置】否则getParameter()读取中文参数为null request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); User user = userService.login(username, password); if (user != null) { // 登录成功:存入Session并重定向到主页 request.getSession().setAttribute("user", user); response.sendRedirect(request.getContextPath() + "/index.jsp"); } else { // 登录失败:转发回登录页并带错误提示 request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }

提示:response.sendRedirect()触发客户端302跳转,URL会变;forward()是服务端内部跳转,URL不变且request域对象可传递。快递e栈中,表单提交后成功态用sendRedirect()防F5重复提交,失败态用forward()保留输入内容——这是血泪经验:曾有学员在login.jsp里用forward()后没清空密码框,导致用户以为密码被记住了。

2.2 业务层(Service):Filter拦截后,Session里的User对象如何安全流转

Service层是MVC的“决策中心”,它不碰HTTP细节,只专注业务规则。比如ExpressService.getAvailableLockers()方法,它不关心请求来自PC还是手机,只返回List<Locker>——但它的调用时机由Filter严格控制。快递e栈的LoginFilter在doFilter()中检查request.getSession().getAttribute("user")是否为空,为空则强制跳转登录页。这里有个关键细节:chain.doFilter(request, response)必须放在if判断之后,否则未登录用户也能穿透访问/admin/*路径。更隐蔽的坑是getSession(false)的使用:request.getSession()默认等价于getSession(true),会新建Session;而getSession(false)仅获取不创建,用于判空场景更安全。

// src/main/java/com/kuaidi/filter/LoginFilter.java public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 【关键】用getSession(false)避免无意义创建Session HttpSession session = request.getSession(false); User user = (session != null) ? (User) session.getAttribute("user") : null; String uri = request.getRequestURI(); // 白名单:登录页、注册页、静态资源放行 if (uri.contains("/login.jsp") || uri.contains("/register.jsp") || uri.contains("/css/") || uri.contains("/js/")) { chain.doFilter(request, response); return; } // 【核心拦截逻辑】未登录则重定向 if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; // 阻止后续执行 } // 已登录:放行 chain.doFilter(request, response); } }

注意:Filter链顺序由web.xml中<filter-mapping>的声明顺序决定。快递e栈将CharacterEncodingFilter放在最前(解决POST乱码),LoginFilter居中(权限控制),LogFilter在最后(日志记录)。若顺序颠倒,比如LoginFilter在CharacterEncodingFilter之前,会导致getParameter()读取乱码,进而登录校验永远失败——这是新手翻车最高发场景。

2.3 视图层(View):JSP如何与Servlet协同完成动态渲染

视图层采用传统JSP+JSTL,禁用Scriptlet(<% %>),全部用EL表达式${user.username}和JSTL标签<c:forEach>。index.jsp首页顶部导航栏需根据Session中是否存在user对象动态显示“登录/注册”或“欢迎,张三”。这里不能用<% if(session.getAttribute("user")!=null){ %>...<% } %>,而应使用<c:choose>:

<!-- WebContent/index.jsp --> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <c:choose> <c:when test="${empty user}"> <a href="${pageContext.request.contextPath}/login.jsp">登录</a> <a href="${pageContext.request.contextPath}/register.jsp">注册</a> </c:when> <c:otherwise> <span>欢迎,${user.username}!</span> <a href="${pageContext.request.contextPath}/logout">退出</a> </c:otherwise> </c:choose>

关键点在于pageContext.request.contextPath——它动态获取应用上下文路径(如/kuaidi),避免硬编码/kuaidi/login.jsp。若部署时改了项目名,所有链接自动适配。另外,<c:forEach items="${lockers}" var="locker">遍历快递柜列表时,locker.status字段在JSP中直接输出“空闲/占用”,但实际存储在数据库中是TINYINT(1)(0/1)。因此Locker实体类中必须提供getStatusDesc()方法:

// src/main/java/com/kuaidi/entity/Locker.java public class Locker { private Integer status; // 0=空闲, 1=占用 public String getStatusDesc() { return status == 0 ? "空闲" : "占用"; } }

这样JSP中${locker.statusDesc}就能输出中文,而非原始数字。MVC的View层绝不应包含业务逻辑,状态转换必须在Model或Service中完成。


3. Filter链不是装饰器模式彩排:字符编码、登录校验、操作日志的三层拦截实操

3.1 字符编码Filter:为什么request.setCharacterEncoding()必须在Filter中执行

POST请求中文乱码的根本原因是:request.getParameter()在第一次调用时才会解析请求体,而此时若未设置编码,Tomcat默认用ISO-8859-1解码,导致汉字变问号。解决方案不是在每个Servlet里写setCharacterEncoding(),而是用Filter统一拦截。快递e栈的CharacterEncodingFilter必须满足两个条件:

  1. 在web.xml中声明顺序第一(<filter-mapping>排最前);
  2. doFilter()中必须在chain.doFilter()之前调用request.setCharacterEncoding("UTF-8")。

若顺序错位,比如LoginFilter在前,它调用getParameter()时已发生乱码,后续再设编码也无力回天。

// src/main/java/com/kuaidi/filter/CharacterEncodingFilter.java public class CharacterEncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 【必须前置】且仅对POST请求生效(GET乱码需改Tomcat server.xml) if ("POST".equalsIgnoreCase(request.getMethod())) { request.setCharacterEncoding("UTF-8"); } response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); // 放行 } }

提示:GET请求中文乱码需额外处理。Tomcat 8+默认URIEncoding为UTF-8,但若仍乱码,需在conf/server.xml的<Connector>标签中添加URIEncoding="UTF-8"。快递e栈未修改server.xml,故项目中所有搜索、分页参数均用POST提交,规避此问题。

3.2 登录校验Filter:Session失效时间与并发存取的双重防护

LoginFilter不仅要拦截未登录访问,还要应对Session超时场景。快递e栈在web.xml中配置<session-config><session-timeout>30</session-timeout></session-config>(单位分钟),但用户可能在第29分钟点击“存件”,此时Session未销毁,但操作耗时超过1分钟,等ExpressServlet执行完毕时Session已过期——导致session.getAttribute("user")返回null,存件失败。解决方案是在Service层增加Session有效性校验:

// src/main/java/com/kuaidi/service/ExpressService.java public class ExpressServiceImpl implements ExpressService { @Override public boolean saveExpress(HttpServletRequest request, Express express) { HttpSession session = request.getSession(false); User user = (session != null) ? (User) session.getAttribute("user") : null; // 【双重校验】Session存在且User不为null if (session == null || user == null) { // 记录日志:Session已失效 System.err.println("Session expired for user: " + (session != null ? session.getId() : "null")); return false; } // 执行存件逻辑... return expressDao.insert(express) > 0; } }

同时,前端在关键操作前用AJAX心跳检测Session状态(/checkSession接口返回{"valid":true}),无效则弹窗提醒重新登录。快递e栈未实现此AJAX,但源码中预留了CheckSessionServlet占位,方便你自行扩展。

3.3 操作日志Filter:记录每一次存取动作的IP、时间、用户ID

为满足课程设计“后台审计”要求,快递e栈提供LogFilter,它不阻断请求,只在chain.doFilter()前后记录日志。关键技巧是:用ThreadLocal存储请求开始时间,避免多线程下时间戳错乱:

// src/main/java/com/kuaidi/filter/LogFilter.java public class LogFilter implements Filter { private static final ThreadLocal<Long> startTime = new ThreadLocal<>(); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; startTime.set(System.currentTimeMillis()); try { chain.doFilter(request, resp); } finally { Long start = startTime.get(); if (start != null) { String ip = getClientIP(request); String uri = request.getRequestURI(); String method = request.getMethod(); String user = getUserFromSession(request); long cost = System.currentTimeMillis() - start; // 【格式化日志】便于后期用Logstash解析 System.out.printf("[LOG] %s | %s | %s | %s | %dms%n", ip, user, method, uri, cost); } startTime.remove(); // 清理ThreadLocal,防内存泄漏 } } private String getClientIP(HttpServletRequest request) { String xip = request.getHeader("X-Real-IP"); String xfor = request.getHeader("X-Forwarded-For"); if (xfor != null && !xfor.isEmpty() && !"unknown".equalsIgnoreCase(xfor)) { return xfor.split(",")[0].trim(); } return xip != null && !"unknown".equalsIgnoreCase(xip) ? xip : request.getRemoteAddr(); } }

注意:ThreadLocal必须在finally块中remove(),否则在Tomcat线程池复用场景下,下次请求可能读到上一次的startTime值,导致耗时计算错误。这是生产环境高频踩坑点,快递e栈已预埋此修复。


4. Session不是万能胶:格口状态同步、超时释放、分布式会话的三大避坑指南

4.1 现象:用户A存件后,用户B刷新页面仍显示“空闲”

原因:快递柜格口状态(Locker.status)仅存于数据库,前端JSP通过<c:forEach>遍历List<Locker>渲染,但ExpressServlet存件后未主动刷新lockers列表,Session中也未缓存格口状态。用户B看到的是上次请求缓存的旧数据。
解决:在存件成功后,强制重定向到格口列表页(response.sendRedirect("/locker/list")),而非forward()。重定向触发新请求,LockerServlet会重新查询数据库最新状态。若需更高实时性,可引入WebSocket推送状态变更,但快递e栈保持HTTP无状态原则,用重定向保证最终一致性。

4.2 现象:用户存件后关闭浏览器,格口长期显示“占用”无法释放

原因:Session销毁仅释放HttpSession对象,不触发数据库更新。Locker.status=1(占用)状态滞留,无人清理。
解决:在LogoutServlet中增加格口释放逻辑,并监听HttpSessionListener:

// src/main/java/com/kuaidi/listener/SessionDestroyListener.java public class SessionDestroyListener implements HttpSessionListener { @Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session = se.getSession(); User user = (User) session.getAttribute("user"); if (user != null) { // 用户离线:释放其所有占用格口 ExpressService expressService = new ExpressServiceImpl(); expressService.releaseLockersByUserId(user.getId()); } } }

同时,在web.xml中注册监听器:

<listener> <listener-class>com.kuaidi.listener.SessionDestroyListener</listener-class> </listener>

提示:sessionDestroyed()在Session超时或手动invalidate()时触发,但浏览器直接关闭不会立即触发(需等超时)。因此快递e栈还提供“手动释放”按钮,调用ReleaseLockerServlet,形成双保险。

4.3 现象:本地测试正常,部署到两台Tomcat集群后,登录状态丢失

原因:默认HttpSession基于内存,每台Tomcat维护独立Session,用户请求被Nginx轮询到不同节点时,session.getAttribute("user")为null。
解决:快递e栈不内置集群方案(避免复杂度),但提供迁移路径:

  • 方案1(轻量):Nginx配置ip_hash,确保同一IP始终路由到同一Tomcat;
  • 方案2(标准):改用Redis存储Session,引入tomcat-redis-session-manager依赖,修改context.xml;
  • 方案3(重构):将用户状态下沉至数据库,每次请求查user_token表验证,Session仅存轻量标识。

快递e栈源码中pom.xml已预留redis.clients:jedis依赖坐标,RedisSessionManager类已编写但未启用,你只需取消web.xml中<filter>注释并配置Redis连接即可激活。

4.4 现象:管理员后台查看“今日存件量”,数字远低于实际

原因:统计SQL使用COUNT(*)但未加时间范围,或create_time字段为VARCHAR类型导致BETWEEN失效。快递e栈中ExpressDao.getTodayCount()方法使用DATE(create_time) = CURDATE(),但若create_time是VARCHAR存"2023-10-01 10:20:30",DATE()函数无法解析。
解决:强制create_time字段为DATETIME类型,并在DAO中用预编译参数:

// src/main/java/com/kuaidi/dao/ExpressDao.java public int getTodayCount() { String sql = "SELECT COUNT(*) FROM express WHERE DATE(create_time) = ?"; // 【关键】用PreparedStatement传参,避免SQL注入且兼容类型 return queryRunner.query(sql, new ScalarHandler<>(), LocalDate.now()); }

注意:LocalDate.now()返回2023-10-01,DATE(create_time)将其转为同格式比较。若数据库时区与JVM不一致,需在jdbcUrl中添加serverTimezone=GMT%2B8。


5. 从零部署到真机验证:Tomcat 9 + MySQL 5.7 + JDK 1.8 的四步上线 checklist

5.1 环境校验:三版本锁死与CLASSPATH陷阱

快递e栈严格适配Tomcat 9.0.x、MySQL 5.7.x、JDK 1.8.0_202+。部署前必须校验:

  • java -version输出java version "1.8.0_202"或更高,但不能是JDK 11+(javax.servlet.*包在JDK 11中被移除);
  • catalina.sh version输出Server version: Apache Tomcat/9.0.83;
  • mysql --version输出mysql Ver 14.14 Distrib 5.7.43;

提示:若用IDEA部署,务必在Project Structure → Project Settings → Project中将Project SDK和Project language level均设为8;Modules中Sources选项卡确认Language level为8。曾有学员因IDEA默认用JDK 17新建项目,编译通过但Tomcat启动报java.lang.NoClassDefFoundError: javax/servlet/Filter——这是CLASSPATH污染的经典症状。

5.2 数据库初始化:字符集、引擎、外键的三重确认

快递e栈SQL脚本kuaidi.sql需在MySQL中执行,但必须确保:

  1. 创建数据库时指定CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci(支持emoji,避免微信昵称存入乱码);
  2. 所有表ENGINE=InnoDB(支持事务与外键);
  3. express表中user_id字段有FOREIGN KEY约束指向user(id)。

执行后校验:

-- 检查数据库字符集 SHOW CREATE DATABASE kuaidi; -- 检查表引擎与外键 SELECT table_name, engine, create_options FROM information_schema.tables WHERE table_schema='kuaidi' AND table_name='express'; -- 检查外键是否生效 SELECT constraint_name, column_name, referenced_table_name FROM information_schema.key_column_usage WHERE table_schema='kuaidi' AND table_name='express' AND referenced_table_name IS NOT NULL;

若create_options为空或referenced_table_name为NULL,说明外键未创建成功,需检查SQL中CONSTRAINT语法是否完整。

5.3 WAR包构建:Maven clean package 与 WEB-INF/lib 的依赖瘦身

快递e栈使用Maven管理依赖,pom.xml中<scope>provided</scope>已标注javax.servlet-api(Tomcat提供),避免打包进WEB-INF/lib。构建命令必须为:

mvn clean package -Dmaven.test.skip=true

生成的target/kuaidi.war解压后,WEB-INF/lib中应只有:

  • mysql-connector-java-5.1.47.jar(数据库驱动)
  • commons-dbutils-1.7.jar(DBUtils工具)
  • jstl-1.2.jar(JSP标签库)

若出现javax.servlet-api-4.0.1.jar,说明<scope>provided</scope>未生效,需检查Maven版本(推荐3.6.3)及IDEA Maven设置中Importing → JDK for importer是否为JDK 8。

5.4 启动验证:从404到500的逐层排查路径

将kuaidi.war放入$CATALINA_HOME/webapps/后启动Tomcat,按顺序验证:

  1. 404阶段:访问http://localhost:8080/kuaidi/,若返回404,检查webapps/kuaidi/目录是否存在,web.xml中<welcome-file-list>是否为<welcome-file>index.jsp</welcome-file>;
  2. 500阶段:能打开login.jsp但提交后报500,查看logs/catalina.out,90%为ClassNotFoundException(缺jar)或SQLException(数据库连不上);
  3. 逻辑阶段:登录成功但首页不显示用户名,检查LoginServlet中session.setAttribute("user", user)与index.jsp中${user.username}的key是否一致(大小写敏感);
  4. 数据阶段:存件后数据库express表无记录,检查ExpressDao.insert()返回值是否为0,确认queryRunner.update()的SQL中字段名与实体类getter方法名匹配(如getExpressNo()对应express_no字段)。

从那以后我每次部署新项目,都强制走一遍这四步:先curl -I http://localhost:8080/kuaidi/看HTTP头,再tail -f logs/catalina.out | grep -E "(ERROR|Exception)"盯日志,接着用mysql -u root -p -e "SELECT * FROM kuaidi.user LIMIT 1;"查库,最后用浏览器开发者工具Network面板看XHR请求状态码。这套组合拳下来,95%的部署问题当场定位。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询