
最近接手维护一个老牌的课程设计项目——基于 JSP Servlet JDBC MySQL 的共享租车信息管理系统技术栈一看就是典型的 JavaWeb 教学案例没有 Spring、没有 MyBatis甚至连 Maven 都没用就是最原始的 Java JSP Servlet JDBC 直连数据库。说实话这几年大家一上来就学 Spring BootJSP 这套上古组合反而成了稀缺技能。但恰恰是这种项目把 Servlet 生命周期、HTTP 状态管理、JDBC 事务、Tomcat 部署这些 JavaWeb 底层原理暴露得最彻底。对于正在做课程设计、毕业设计或者想补一补 JavaWeb 基础的同学来说这东西比一百个 CRUD 的 Spring Boot 教程都值得啃。这篇文章我就从业务建模、环境配置、核心代码链路、部署踩坑四个维度把这个共享租车系统彻底拆开讲清楚让你拿到的不是一段能跑但看不懂的代码而是一套能自己复现、能在答辩时讲明白的完整项目。1. 共享租车系统的业务边界为什么四张核心表就能撑起整套流程很多同学拿到项目第一件事就是看代码这其实是个坏习惯。老项目最怕没有注释直接看逻辑你根本不知道每一层在干什么。我习惯先理清业务模型再去看表结构最后才去对代码这样效率高得多。1.1 用户视角下的核心需求共享租车和传统租车有个本质区别传统租车是按天租共享租车是按小时租而且取车和还车可能不在同一个地点。不过作为课程设计级别的项目往往会把业务收敛到最核心的闭环避免功能过于分散后代码失控。我这个系统里实际跑的业务闭环是这样用户注册并登录系统注册信息包括手机号、密码、姓名、身份证号用于租车资质校验。用户浏览车辆列表按车型筛选查看车辆的实时状态可租/已租。用户发起租车请求系统锁住车辆记录租用开始时间。用户还车系统根据用车时长计算费用并更新车辆状态为可租。管理员登录后台维护车辆信息上架、下架、修改价格查看所有订单。这个流程看起来简单但如果你把每一步对应的数据访问都拆出来涉及的JSP页面、Servlet类、DAO方法加起来有五六十个文件。这正是JSPServlet项目的典型特征没有框架帮你省事所有请求映射、参数获取、数据库操作都必须手写也因此每一行代码都值得细看。1.2 数据库表的字段设计我在设计数据库时遵循最少表、但字段不偷懒的原则。整个系统只用了四张表表名用途核心字段t_user用户信息id, username, password, phone, idcard, realname, create_timet_car车辆信息id, car_no, brand, model, price_per_hour, status, create_timet_order租车订单id, user_id, car_id, start_time, end_time, total_fee, statust_admin管理员id, username, password其中车辆表里的 status 字段是关键我用了 int 类型做状态机0 表示可租1 表示已租2 表示维修中。订单表的 status 字段同样用 int 表达待取车、使用中、已完成、已取消。这种用数字表达状态的做法看起来朴素但好处是 JSP 页面里做状态展示时判断逻辑非常干净类似c:if test${order.status 2}也方便后续扩展。我见过很多同学喜欢用字符串存状态今天写个可用明天写个可用中后天写个AVALIABLE数据一乱前端全崩这点必须要控制住。1.3 建表 SQL 的几个实用细节共享租车系统涉及费用和时间有两个字段细节容易被忽略车辆表的 price_per_hour 用 DECIMAL(10,2) 而不是 DOUBLE。因为 DOUBLE 在涉及浮点累加时容易出现精度误差计费场景不能用浮点。订单表的 start_time 和 end_time 用 DATETIME不要用 TIMESTAMP。TIMESTAMP 有 2038 年问题虽然现在看起来遥远但课程设计里经常直接拿now()测试DATETIME 的可读性更好存储范围更大。另外我建议在 user 表里给 username 加唯一索引。你永远不知道答辩演示的时候会不会有同学手滑注册了两次然后系统里出现两条一模一样的账号。这种边界情况提前用数据库约束挡掉比在 Servlet 里做逻辑判断省心得多。2. 跑通项目的环境组合从 IDEA 配置到 MySQL 连接串老项目的第一道坎通常不是代码而是环境。JSPServlet 项目对工具链版本特别敏感JDK 版本太高编译不过Tomcat 版本太新又不兼容 Servlet 规范MySQL 8.0 的连接串写法跟 5.7 又不一样。我先把这套组合讲清楚你照着搭基本不会翻车。2.1 版本组合怎么选才稳定我实际验证过最稳的一套组合是JDK 1.8不要用 11 或 17很多老项目的 javax.servlet 包在新 JDK 下会报警告甚至编译失败Tomcat 8.5兼容 Servlet 3.1对 JSP 2.3 支持好MySQL 5.7 或 8.0后面连接串写法略有差异我会单独说明IDEA社区版就够不需要旗舰版有人会问为什么不直接用 Tomcat 9 或 10很简单Tomcat 10 开始把javax.servlet改成了jakarta.servlet老项目的 import 全是 javax 开头部署上去直接 NoClassDefFoundError。所以如果你的项目是 2020 年之前写的老老实实用 Tomcat 8.5别折腾。2.2 MySQL 连接串一个字符都不能错连接串是 JDBC 项目的高频事故区。我见过太多次账号密码明明对了但项目就是连不上数据库的情况十次有九次是连接串参数写法问题。MySQL 5.7 推荐的写法是private static final String URL jdbc:mysql://localhost:3306/car_share?useUnicodetruecharacterEncodingutf8useSSLfalse;MySQL 8.0 则要加上时区参数并且驱动类变了private static final String URL jdbc:mysql://localhost:3306/car_share?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai; private static final String DRIVER com.mysql.cj.jdbc.Driver;驱动包也要对应5.7 用 mysql-connector-java 5.1.478.0 用 8.0.x 版本驱动 jar 包必须放进项目的WEB-INF/lib目录下而不是只放在 IDEA 的 library 里。后面部署到 Tomcat 的时候tomcat 认的是 WEB-INF/lib这一点我见过无数次有人踩坑。2.3 IDEA 里配置 Tomcat 的正确姿势在 IDEA 中运行 JSPServlet 项目很多新手会卡在 404 或页面无法访问。正常流程是这样的在项目结构里找到 Artifacts确保输出的类型是 Web Application Exploded并且加了WEB-INF/lib下所有依赖。添加 Tomcat Server - Local选择本地 Tomcat 安装目录。Deployment 里把项目的 Artifacts 加进去Application context 建议设为/不要带项目名。这样访问地址就是http://localhost:8080/省得每次输一长串路径。修改 Tomcat 的端口时要同时检查 HTTP port 和 JMX port很多冲突报错其实是 JMX 端口占用引起的。这一步做完Run 起来如果浏览器直接能看到登录页说明环境跑通了。跑不通的时候不要急着看业务代码先看 IDEA 控制台的 Tomcat 日志90% 的问题在日志里写得明明白白。3. 核心业务链路拆解从注册登录、车辆查询到租车还车的事务处理项目跑起来之后就到了大家最喜欢问的环节代码是怎么写的这节我会按照系统的三条核心业务链——用户登录、租车下单、还车计费——逐一拆开把 Servlet 和 JDBC 的关键代码逻辑讲透。3.1 JDBC 工具类的封装省掉九九八十一行重复代码JSPServlet 项目中几乎每个 DAO 都要拿连接、执行 SQL、关资源。如果不封装一个项目里到处都是Class.forName、DriverManager.getConnection、conn.close()代码量爆炸且极易漏关连接。我习惯封装一个DBUtil类用静态代码块加载驱动用ThreadLocal管理连接这样后续做事务时也能保证同一个线程用的是同一个 Connectionpublic class DBUtil { private static final String URL jdbc:mysql://localhost:3306/car_share?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; private static ThreadLocalConnection connHolder new ThreadLocal(); static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败请检查WEB-INF/lib下是否有驱动jar包); } } public static Connection getConnection() throws SQLException { Connection conn connHolder.get(); if (conn null) { conn DriverManager.getConnection(URL, USER, PASSWORD); connHolder.set(conn); } return conn; } public static void closeAll(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException e) { } } if (stmt ! null) { try { stmt.close(); } catch (SQLException e) { } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { } } connHolder.remove(); } }这个封装里有三个设计点值得注意第一驱动加载放进静态代码块只执行一次第二资源关闭用三个 try-catch 独立包裹避免一个关闭异常影响后面资源的释放第三关闭连接后要remove()防止 ThreadLocal 在 Web 容器线程池中造成内存泄漏。3.2 用户登录流程JSP 表单、Servlet 控制、JDBC 查询的一整套串联登录功能是 JSPServlet 项目中最经典的教科书链路但往往也是同学们第一个写糊涂的地方。我把它拆成四个环节JSP 页面登录表单的action指向UserServletmethod为post。Servlet 控制层doPost方法里先获取参数再判断是登录还是注册操作我习惯通过隐藏字段action区分。DAO 层findUserByUsernameAndPassword方法执行查询。Session 管理登录成功后把 user 对象放入 session页面跳转。Servlet 核心代码大概是这个形态WebServlet(/user) public class UserServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (login.equals(action)) { String username req.getParameter(username); String password req.getParameter(password); User user userDao.login(username, password); if (user ! null) { HttpSession session req.getSession(); session.setAttribute(loginUser, user); resp.sendRedirect(index.jsp); } else { req.setAttribute(msg, 用户名或密码错误); req.getRequestDispatcher(login.jsp).forward(req, resp); } } else if (register.equals(action)) { // 注册逻辑省略 } } }注意这里我用了resp.sendRedirect做登录成功跳转用req.getRequestDispatcher().forward做失败跳转。两者的区别在于sendRedirect 是重定向地址栏会变化且 request 域中的属性会丢失forward 是转发地址栏不变request 域可以传递数据。出错时用 forward 把错误信息塞进 request 域带到 JSP 页面这是最常规的写法。3.3 租车下单与还车计费一次必须用事务的完整场景很多人写 JDBC 的增删改查很溜但一到多表操作就翻车了——因为不知道事务什么时候该用。这个租车流程里就有一个教科书级的例子租车下单时既要插入订单记录又要修改车辆状态。这两步必须同时成功或者同时失败否则就会出现订单生成了但车辆状态还是可租的数据不一致。下单的核心流程是根据 vehicleId 查出车辆信息确认 status 0可租。插入一条订单记录状态为使用中记录租车开始时间。更新车辆 status 为 1已租。这三步我放在同一个方法里用手动事务控制public void rentCar(int userId, int carId) throws SQLException { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 第1步检查车辆状态 Car car carDao.findById(carId); if (car null || car.getStatus() ! 0) { throw new RuntimeException(车辆不存在或已被租出); } // 第2步插入订单 Order order new Order(); order.setUserId(userId); order.setCarId(carId); order.setStartTime(new Date()); order.setStatus(1); orderDao.insert(order); // 第3步更新车辆状态 carDao.updateStatus(carId, 1); conn.commit(); } catch (Exception e) { conn.rollback(); throw new RuntimeException(租车失败事务已回滚, e); } finally { conn.setAutoCommit(true); DBUtil.closeAll(conn, null, null); } }关键点在于setAutoCommit(false)之后所有操作都在同一个事务里一旦任何一步抛出异常rollback()会撤销已执行的 SQL。这是 JSPServlet 项目里最实用也最容易被忽视的事务写法。很多同学的代码明明从表设计到功能实现都对就是这里没开事务导致做一步错一步越做越怀疑人生。还车计费同理。还车时要计算end_time - start_time的时长并按小时价格算钱再更新订单状态和车辆状态。计算逻辑不难但同样要放进事务确保费用算对了 订单状态改了 车辆恢复可租这三件事原子生效。3.4 车辆列表分页查询LIMIT 参数在 Servlet 和 JSP 之间怎么传共享租车的车辆数量多了以后列表不能一次全查。我用的方案是最经典的下拉分页Servlet 接收pageNow和pageSize参数DAO 层执行带 LIMIT 的查询JSP 页面用PageBean对象渲染分页按钮。SELECT * FROM t_car WHERE status 0 ORDER BY id LIMIT ?, ?注意 JDBC 中 LIMIT 的参数占位符要用setInt来设置因为LIMIT后面的值是数字类型不能当字符串处理。我见过有人写成setStringMySQL 直接报语法错误。这个细节看起来小排错时却很烦人。分页查询还需要一个总数统计SELECT COUNT(*) FROM t_car WHERE status 0然后根据总记录数和每页条数计算出总页数。Idea 里调试分页时最常遇到的问题就是翻到第 5 页之后再点下一页pageNow超过总页数导致界面空白。解决办法是在 Servlet 里做一个保护如果 pageNow 大于总页数强制重置为总页数。4. 部署打包与那些必然踩到的坑war 包、驱动版本、乱码与端口占用环境配好了代码也写通了最后一步是把项目打成 war 包部署到 Tomcat——很多人到了这一步又开始抓狂。这里把高频问题汇总成一张表并给出具体的处理建议。4.1 传统 JSP 项目怎么打 war 包在 IDEA 里打包 war 包很简单进入 Project Structure - Artifacts新建一个 Web Application Archive 类型名称取car_share然后 Build - Build Artifacts。生成出来的 war 包直接复制到 Tomcat 的webapps目录下启动 Tomcat 后它会自动解压并发布。如果是命令行环境也可以用jar -cvf car_share.war *在编译好的 Web 根目录下打但要注意保证目录结构是WEB-INF/classes和WEB-INF/lib这对标准形态。有一点要特别提醒war 包部署后Tomcat 访问路径默认是http://localhost:8080/car_share/如果你开发时用的 Application context 是/那么部署后的所有 internal 相对路径都要多带一层项目名否则资源加载全是 404。这个问题开发时不容易发现一到部署就炸。解决方案是在所有 JSP 页面里都用${pageContext.request.contextPath}来拼绝对路径。4.2 高频报错排查表照着查就能定位问题报错现象根本原因解决办法java.lang.ClassNotFoundException: com.mysql.jdbc.Driver驱动 jar 未放入 WEB-INF/lib把 mysql-connector-java 复制到 WEB-INF/lib 目录Access denied for user rootlocalhost数据库账号或密码错误核对 DBUtil 中的账号密码注意 MySQL8 默认认证插件Unknown system variable query_cache_sizeMySQL 8 连接串未加时区参数改用serverTimezoneAsia/Shanghai并升级驱动Tomcat 启动端口冲突8080 被其他进程占用改 Tomcat 端口或用任务管理器杀掉占用进程中文乱码请求参数编码和 JSP 编码不一致JSP 页面pageEncodingUTF-8Servlet 里req.setCharacterEncoding(UTF-8)页面加载后 CSS/图片失效使用相对路径导致资源加载失败统一换成${pageContext.request.contextPath}这张表里的前四条我都实际踩过尤其是 MySQL 8 连 5.7 项目的驱动包那个报错信息极其迷惑光看日志完全不知道是驱动版本的问题。所以我在做这个项目时直接就把驱动固定成com.mysql.cj.jdbc.Driver给定一个统一标准。4.3 为什么明明代码没问题Tomcat 就是热部署不生效最后一个常见困惑是我改了 JSP 页面刷新浏览器没变化我删了某个 ServletTomcat 还继续执行旧类。这在 IDEA 的 Debug 模式里尤其明显。原因是 Tomcat 默认的 reloadable 参数是 false或者 IDEA 的 On Upate Action 设置不对。建议把 Tomcat 配置里的 On Update Action 改成 Update resources这样改 JSP 后按 CtrlF10 就会生效如果是改 Java 文件需要 Update classes and resources。如果怎么热部署都不生效那就干脆重启 Tomcat尤其是我们这种纯 Servlet 项目注解注册的 Servlet 在热部署时经常出现旧类残留。4.4 一点关于老技术栈的思考最后说点务虚的感受。很多人对 JSPServlet 嗤之以鼻觉得这是已经被时代淘汰的技术做课程设计纯粹是为了交差。我的看法不太一样如果你已经是 Spring Boot 的老手回来看这种项目确实会觉得繁琐但如果你还没完整地做过一个 JavaWeb 项目从 JSPServlet 起步反而是最快的路径。因为它把 Web 开发的所有中间层都撕掉了没有 Spring IoC 帮你管理依赖没有 MyBatis 帮你映射 SQL没有模板引擎帮你渲染数据。你被迫一步步手动完成客户端请求 - Servlet 解析 - JDBC 操作 - 结果返回的完整过程这会让你对 HTTP、Session、事务这些概念的理解到达另一个层次。等到你能徒手写出一个能跑、能部署、能处理事务的 JSPServlet 项目再去看 Spring Boot 的封装你会看得懂它到底替你省了什么、又藏了什么。反过来一上来就 Spring Boot 的同学遇到依赖冲突和数据源配置问题往往连从哪里排查都不知道。我的实操建议是把这个共享租车系统做出来之后再自己加两个功能练手——比如用户查看历史订单和管理员导出订单报表。加功能的过程会逼你重新梳理刚才提到的状态机、事务和多表查询这才是这门课真正的收获。这套 JSPServlet 的组合虽然老但它值得你花时间把它彻底吃透。