最近接手维护一个老牌的课程设计项目——基于 JSP + Servlet + JDBC + MySQL 的共享租车信息管理系统,技术栈一看就是典型的 JavaWeb 教学案例:没有 Spring、没有 MyBatis,甚至连 Maven 都没用,就是最原始的 Java + JSP + Servlet + JDBC 直连数据库。
说实话,这几年大家一上来就学 Spring Boot,JSP 这套"上古组合"反而成了稀缺技能。但恰恰是这种项目,把 Servlet 生命周期、HTTP 状态管理、JDBC 事务、Tomcat 部署这些 JavaWeb 底层原理暴露得最彻底。对于正在做课程设计、毕业设计,或者想补一补 JavaWeb 基础的同学来说,这东西比一百个 CRUD 的 Spring Boot 教程都值得啃。
这篇文章我就从业务建模、环境配置、核心代码链路、部署踩坑四个维度,把这个共享租车系统彻底拆开讲清楚,让你拿到的不是一段能跑但看不懂的代码,而是一套能自己复现、能在答辩时讲明白的完整项目。
1. 共享租车系统的业务边界:为什么四张核心表就能撑起整套流程
很多同学拿到项目第一件事就是看代码,这其实是个坏习惯。老项目最怕"没有注释直接看逻辑",你根本不知道每一层在干什么。我习惯先理清业务模型,再去看表结构,最后才去对代码,这样效率高得多。
1.1 用户视角下的核心需求
共享租车和传统租车有个本质区别:传统租车是"按天租",共享租车是"按小时租",而且取车和还车可能不在同一个地点。不过作为课程设计级别的项目,往往会把业务收敛到最核心的闭环,避免功能过于分散后代码失控。
我这个系统里实际跑的业务闭环是这样:
- 用户注册并登录系统,注册信息包括手机号、密码、姓名、身份证号(用于租车资质校验)。
- 用户浏览车辆列表,按车型筛选,查看车辆的实时状态(可租/已租)。
- 用户发起租车请求,系统锁住车辆,记录租用开始时间。
- 用户还车,系统根据用车时长计算费用,并更新车辆状态为"可租"。
- 管理员登录后台,维护车辆信息(上架、下架、修改价格),查看所有订单。
这个流程看起来简单,但如果你把每一步对应的数据访问都拆出来,涉及的JSP页面、Servlet类、DAO方法加起来有五六十个文件。这正是JSP+Servlet项目的典型特征:没有框架帮你省事,所有请求映射、参数获取、数据库操作都必须手写,也因此每一行代码都值得细看。
1.2 数据库表的字段设计
我在设计数据库时遵循"最少表、但字段不偷懒"的原则。整个系统只用了四张表:
| 表名 | 用途 | 核心字段 |
|---|---|---|
| t_user | 用户信息 | id, username, password, phone, idcard, realname, create_time |
| t_car | 车辆信息 | id, car_no, brand, model, price_per_hour, status, create_time |
| t_order | 租车订单 | id, user_id, car_id, start_time, end_time, total_fee, status |
| t_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 连接串
老项目的第一道坎通常不是代码,而是环境。JSP+Servlet 项目对工具链版本特别敏感,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?useUnicode=true&characterEncoding=utf8&useSSL=false";MySQL 8.0 则要加上时区参数,并且驱动类变了:
private static final String URL = "jdbc:mysql://localhost:3306/car_share?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String DRIVER = "com.mysql.cj.jdbc.Driver";驱动包也要对应:5.7 用 mysql-connector-java 5.1.47,8.0 用 8.0.x 版本,驱动 jar 包必须放进项目的WEB-INF/lib目录下,而不是只放在 IDEA 的 library 里。后面部署到 Tomcat 的时候,tomcat 认的是 WEB-INF/lib,这一点我见过无数次有人踩坑。
2.3 IDEA 里配置 Tomcat 的正确姿势
在 IDEA 中运行 JSP+Servlet 项目,很多新手会卡在 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 工具类的封装:省掉九九八十一行重复代码
JSP+Servlet 项目中,几乎每个 DAO 都要拿连接、执行 SQL、关资源。如果不封装,一个项目里到处都是Class.forName、DriverManager.getConnection、conn.close(),代码量爆炸且极易漏关连接。
我习惯封装一个DBUtil类,用静态代码块加载驱动,用ThreadLocal管理连接,这样后续做事务时也能保证同一个线程用的是同一个 Connection:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/car_share?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; private static ThreadLocal<Connection> 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 查询的一整套串联
登录功能是 JSP+Servlet 项目中最经典的"教科书链路",但往往也是同学们第一个写糊涂的地方。我把它拆成四个环节:
- JSP 页面:登录表单的
action指向UserServlet,method为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。这是 JSP+Servlet 项目里最实用也最容易被忽视的事务写法。很多同学的代码明明从"表设计到功能实现"都对,就是这里没开事务,导致做一步错一步,越做越怀疑人生。
还车计费同理。还车时要计算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后面的值是数字类型,不能当字符串处理。我见过有人写成setString,MySQL 直接报语法错误。这个细节看起来小,排错时却很烦人。
分页查询还需要一个总数统计: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 'root'@'localhost' | 数据库账号或密码错误 | 核对 DBUtil 中的账号密码,注意 MySQL8 默认认证插件 |
Unknown system variable 'query_cache_size' | MySQL 8 连接串未加时区参数 | 改用serverTimezone=Asia/Shanghai并升级驱动 |
| Tomcat 启动端口冲突 | 8080 被其他进程占用 | 改 Tomcat 端口,或用任务管理器杀掉占用进程 |
| 中文乱码 | 请求参数编码和 JSP 编码不一致 | JSP 页面pageEncoding="UTF-8",Servlet 里req.setCharacterEncoding("UTF-8") |
| 页面加载后 CSS/图片失效 | 使用相对路径导致资源加载失败 | 统一换成${pageContext.request.contextPath} |
这张表里的前四条我都实际踩过,尤其是 MySQL 8 连 5.7 项目的驱动包,那个报错信息极其迷惑,光看日志完全不知道是驱动版本的问题。所以我在做这个项目时直接就把驱动固定成com.mysql.cj.jdbc.Driver,给定一个统一标准。
4.3 为什么"明明代码没问题,Tomcat 就是热部署不生效"
最后一个常见困惑是:我改了 JSP 页面,刷新浏览器没变化;我删了某个 Servlet,Tomcat 还继续执行旧类。这在 IDEA 的 Debug 模式里尤其明显。
原因是 Tomcat 默认的 reloadable 参数是 false,或者 IDEA 的 On Upate Action 设置不对。建议把 Tomcat 配置里的 On Update Action 改成 Update resources,这样改 JSP 后按 Ctrl+F10 就会生效;如果是改 Java 文件,需要 Update classes and resources。如果怎么热部署都不生效,那就干脆重启 Tomcat,尤其是我们这种纯 Servlet 项目,注解注册的 Servlet 在热部署时经常出现旧类残留。
4.4 一点关于"老技术栈"的思考
最后说点务虚的感受。很多人对 JSP+Servlet 嗤之以鼻,觉得这是已经被时代淘汰的技术,做课程设计纯粹是为了交差。我的看法不太一样:如果你已经是 Spring Boot 的老手,回来看这种项目确实会觉得繁琐;但如果你还没完整地做过一个 JavaWeb 项目,从 JSP+Servlet 起步反而是最快的路径。
因为它把 Web 开发的所有"中间层"都撕掉了:没有 Spring IoC 帮你管理依赖,没有 MyBatis 帮你映射 SQL,没有模板引擎帮你渲染数据。你被迫一步步手动完成"客户端请求 -> Servlet 解析 -> JDBC 操作 -> 结果返回"的完整过程,这会让你对 HTTP、Session、事务这些概念的理解到达另一个层次。
等到你能徒手写出一个能跑、能部署、能处理事务的 JSP+Servlet 项目,再去看 Spring Boot 的封装,你会看得懂它到底替你省了什么、又藏了什么。反过来,一上来就 Spring Boot 的同学,遇到依赖冲突和数据源配置问题,往往连从哪里排查都不知道。
我的实操建议是:把这个共享租车系统做出来之后,再自己加两个功能练手——比如"用户查看历史订单"和"管理员导出订单报表"。加功能的过程会逼你重新梳理刚才提到的状态机、事务和多表查询,这才是这门课真正的收获。这套 JSP+Servlet 的组合虽然老,但它值得你花时间把它彻底吃透。