ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JSP+Servlet+JDBC搭建共享租车系统:全链路开发与实战踩坑解析

JSP+Servlet+JDBC搭建共享租车系统:全链路开发与实战踩坑解析 坦白讲最初接到“基于JavaWeb和MySQL的JSPServlet共享租车信息管理系统”这个需求时我心里第一反应是现在谁还从零写JSPServletJDBC直接上个Spring Boot不香吗但真正动手把这套技术栈从建库建表到Tomcat部署完整跑通之后我反而觉得这种“复古”组合的课设/毕设项目才是理解Web后端最好的教材。它把浏览器请求到MySQL返回结果之间的每一步都摆在明面上没有框架替你兜底每一个环节出问题都必须自己找到答案。所以这篇博文我不会讲太多花哨的东西就结合一个完整的共享租车系统把技术选型逻辑、业务建模、表结构设计、JDBC事务控制、Servlet与JSP的请求流转以及运行环境配置和实战踩坑一条条说清楚。无论你是正在做类似课设、毕设还是第一次接触这类老项目的开发这篇文章应该都能帮你避开我踩过的坑。1. 为什么是JSPServletJDBC这套“老技术栈”的真实定位1.1 不是技术落后而是链路够透明我在项目里看到的第一行需求说明是“基于JavaWeb和MySQL采用JSPServletJDBC技术实现”。当时我觉得这要求有点落后但做完一个完整模块后我理解了这种选型在教学场景里的价值。使用Spring Boot MyBatis时你在Controller里写一个方法框架自动帮你完成参数绑定、请求分发、数据库连接管理、事务提交回滚。效率确实高但如果你还没建立完整的Web开发心智模型出了问题会非常痛苦因为你根本不知道请求是怎么被处理的、连接是什么时候关闭的、事务是什么时候提交的。而JSPServletJDBC这套组合整个请求链路是完整暴露的浏览器发送HTTP请求到Tomcat。Tomcat根据URL映射找到对应的Servlet。Servlet调用业务方法内部通过JDBC向MySQL发起SQL。结果返回后Servlet把数据塞进request作用域。最后forward到JSP由JSP渲染成HTML响应给浏览器。中间每一个环节都需要开发者自己写代码、自己管理资源。初看是负担仔细想其实是把整条链路“透明化”了。我后来去维护一些老项目时就是因为当年搞清楚了这套机制才能快速定位问题。1.2 与主流框架的差异对比经常有人问我招行测这种东西到底有没有用我的看法是分场景。如果你是为了快速交付生产系统Spring Boot当然更合适但如果你是为了理解Web工作原理或者老师指定了技术栈那JSPServletJDBC就是绕不开的一课。技术方案开发效率学习成本链路可见性常见场景Spring Boot MyBatis高中高低框架封装配了大量细节企业快速业务开发JSP Servlet JDBC中低低高步骤全部显式课程设计、毕业设计、老项目维护Python Flask SQLAlchemy中高低中中原型、中小型系统这套项目适合的读者我认为有三类一是学校指定了SSH时代或JSPServlet时代的课设题目、又不想用框架糊弄的同学二是想彻底搞懂HTTP请求、Servlet生命周期、JDBC事务的同学三是工作中要维护老系统的朋友。我的建议是别急着嫌它土认真写完一个完整系统你对“请求-响应”这件事的理解会扎实很多。2. 共享租车业务建模从需求到状态流转2.1 三类角色与权限边界拿到这个题目后我没有马上写代码而是先把系统里的角色划分清楚。一个共享租车信息管理系统至少要有三类用户角色游客只能浏览车辆列表和车辆详情不能下单。一旦发起预约操作就要引导到登录页。注册用户可以登录系统、预约车辆、取消预约、确认取车、发起还车、查看历史订单和个人信息。管理员负责车辆管理增删改查、上下架维护、预约订单审核、还车结算确认、查看租车数据和基础统计。权限边界如果不提前划清楚后面会出很多安全问题。比如用户直接访问管理员的Servlet地址结果发现没有做任何校验就能操作别人的订单这是我最常看到课设项目里翻车的地方。解决思路很简单公共页面和放行的接口公开访问其余所有业务操作都必须在Session里校验登录用户身份管理员操作还要额外校验角色。2.2 一条完整的主业务链路系统的主链路其实不复杂我用一条线就能串起来用户注册账号 → 登录系统 → 浏览车辆列表 → 选中目标车辆查看详情 → 发起预约车辆状态从“可用”变为“已预约” → 管理员确认预约 → 用户到店取车车辆状态变“租用中” → 用车结束发起还车 → 系统按天数计算费用 → 结算完成车辆恢复“可用”。这里有个容易被忽略的点什么是“共享租车”和普通租车系统的区别我理解它的核心是“车辆资源的重复利用”。同一辆车可以被不同用户分时段租用所以车辆状态管理特别重要。如果状态设计得不好就很容易出现同一辆车被两个人同时预约成功的情况这在课设答辩里属于致命逻辑错误。2.3 车辆与订单的状态机状态不可乱跳我在代码里对车辆和订单的状态做了严格约定。车辆状态用整数枚举1 可用2 已预约3 租用中4 维护中订单状态则独立设计1 待确认用户刚提交预约2 已确认管理员审核通过3 租用中用户已取车4 已完成还车并结算5 已取消为什么要单独维护一套状态机而不是在车辆状态里直接体现订单因为一个订单要经历完整的生命周期而车辆状态只是车辆当前资源的镜像。比如订单到了“已确认”但用户还没取车车辆显示“已预约”可如果单看车辆状态你并不知道是哪个订单造成的。把两张表状态字段分开管理配合订单表的冗余字段查起来才方便。状态跳转也要在代码里做控制不是写个setStatus就行。比如订单状态从“待确认”不能直接跳到“租用中”必须经过“已确认”已取消的订单不能再激活。我会在Service层用if判断前置状态是否合法保证逻辑不自相矛盾。3. 数据库表结构设计共享租车系统的落库方案3.1 三张核心表的建表SQL建模完成后我先把数据库表结构定了下来。共享租车系统最核心的就是三张表用户表、车辆表、订单表。下面是我实际用的建表脚本你可以直接参考CREATE DATABASE IF NOT EXISTS car_sharing DEFAULT CHARACTER SET utf8mb4; USE car_sharing; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码哈希值, phone VARCHAR(20) DEFAULT , real_name VARCHAR(50) DEFAULT , id_card VARCHAR(20) DEFAULT COMMENT 身份证号, role TINYINT NOT NULL DEFAULT 1 COMMENT 1普通用户 2管理员, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 用户表; CREATE TABLE car ( id INT PRIMARY KEY AUTO_INCREMENT, car_no VARCHAR(20) NOT NULL UNIQUE COMMENT 车牌号, brand VARCHAR(30) NOT NULL COMMENT 品牌, model VARCHAR(50) NOT NULL COMMENT 车型, price_per_day DECIMAL(10,2) NOT NULL COMMENT 日租金, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 2已预约 3租用中 4维护中, description VARCHAR(500), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 车辆表; CREATE TABLE rent_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, user_id INT NOT NULL COMMENT 租车用户ID, car_id INT NOT NULL COMMENT 车辆ID, start_day DATE NOT NULL COMMENT 预计取车日期, end_day DATE NOT NULL COMMENT 预计还车日期, actual_return_date DATE COMMENT 实际还车日期, total_price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 结算金额, status TINYINT NOT NULL DEFAULT 1 COMMENT 1待确认 2已确认 3租用中 4已完成 5已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_car_id (car_id), KEY idx_status (status), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES sys_user(id), CONSTRAINT fk_order_car FOREIGN KEY (car_id) REFERENCES car(id) ) ENGINEInnoDB COMMENT 租车订单表;这三张表已经能支撑整个系统的核心运转了。实际项目里可能还会加支付流水表、车辆图片表等但课设级别先用这三张把逻辑跑通后续再加表不影响结构。3.2 字段类型的设计考量很多同学建表很随意后面做钱和日期计算的时候就开始后悔。我总结几个共享租车这种业务场景里特别需要注意的点第一金额字段必须用DECIMAL。我在系统里统计租金时用过float结果某条订单显示费用是99.999999元这类尴尬数字。二进制浮点数根本表示不精确租金、押金这种跟钱有关的字段一律用DECIMAL(10,2)。这就是我强调“钱不是用来看的是用来算的”这句话的由来。第二日期字段要区分DATE和DATETIME。预估的取车、还车日期用DATE就够了订单创建时间用DATETIME。如果你把日期类型混用在JSP页面用SimpleDateFormat或者JSTL格式化的时候会非常别扭。第三状态字段用TINYINT而不是VARCHAR。用字符串’可用’、’租用中’确实一眼能看懂但存数据库占空间、查询慢而且拼SQL容易写错。用TINYINT配合Java里常量类或者枚举类是更标准的方式。我在代码里定义一个CarStatus类和OrderStatus类存放常量页面上显示时再映射成中文文案。第四一定给高频查询字段建索引。我在rent_order表上建了idx_user_id因为用户查“我的订单”是最频繁的SQL车辆表的car_no建了唯一索引因为同一辆车的车牌不能重复。索引不是越多越好而是根据你的查询习惯来建。热搜里经常有人问“mysql创建索引”怎么正确规划其实思路就是先列出你最常用的查询条件再决定建什么索引。3.3 关于MySQL版本选择的碎碎念这个项目用MySQL 8.0还是5.7我建议环境允许就直接上8.0。原因很实际8.0默认字符集是utf8mb4不需要额外配置中文就正常5.7及更早版本默认utf8mb3存生僻字或表情符可能出问题。如果你装的是MySQL 8.x要注意JDBC驱动要用mysql-connector-java 8.x版本驱动类名也变了我后面专门有一节聊这个问题。另外数据库可视化工具我用Navicat比较多建库建表、看数据都很方便。虽然直接用命令行也能完成但做课设项目时一个直观的图形工具能省很多时间。网上那些“Navicat安装”教程很多安装完记得连一次测试下连接确认账号密码、端口都没问题。4. JDBC连接与事务把“下单扣库存”做成可靠操作4.1 连接管理从裸连到连接池JSPServlet项目里最基础的数据访问方式就是用JDBC。最原始的写法是这样的Class.forName(com.mysql.cj.jdbc.Driver); String url jdbc:mysql://localhost:3306/car_sharing?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; Connection conn DriverManager.getConnection(url, root, password);每次需要操作数据库就创建一次连接用完关闭。这样写在小项目里似乎没问题但仔细想想每次连接都要经过TCP握手、MySQL认证几十个并发请求同时打过来系统很快就卡住了。所以真实项目里几乎都会用数据库连接池。我在这套系统里引入的是Druid连接池用配置文件维护连接参数// druid.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/car_sharing?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 usernameroot password你的密码 initialSize5 maxActive20 maxWait3000然后写一个工具类来获取连接全项目统一从这里拿连接public class DBUtil { private static DruidDataSource dataSource; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader().getResourceAsStream(druid.properties)); dataSource (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new RuntimeException(初始化连接池失败, e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这样每次拿到的都是连接池里复用好的连接性能比DriverManager方式高很多。关于“mysql的数据库连接池”这个话题Druid、HikariCP、C3P0都行但对课设项目来说Druid的资料最多、最容易出结果。4.2 PreparedStatementSQL注入防线我发现很多课设里还在用Statement拼字符串这是非常危险的习惯。比如用户输入手机号作为查询条件如果写成Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(SELECT * FROM sys_user WHERE phone phone );一旦用户在输入框里传一段 OR 11SQL语义就完全变了整张表的数据都能被查出来。正确做法是永远使用PreparedStatement占位符。PreparedStatement ps conn.prepareStatement(SELECT * FROM sys_user WHERE phone ?); ps.setString(1, phone); ResultSet rs ps.executeQuery();PreparedStatement不仅防注入还因为预编译可以提升执行效率。所以我在这套系统里规定所有访问数据库的代码一律不准用Statement拼SQL只准用PreparedStatement或者后期如果引入MyBatis时的#{}参数绑定。4.3 事务边界一个连接干完一件事共享租车系统里最需要事务控制的业务有两个一个是用户下单预约一个是还车结算。先看下单预约的逻辑。用户预约一辆车时需要做三步操作查询车辆的当前状态必须是“可用”。把车辆状态更新为“已预约”。插入一条订单记录状态为“待确认”。如果这三步之间没有事务保护第二步把车锁了、第三步插入订单时数据库报错那车就永远卡在“已预约”状态用户却连订单都查不到。这种逻辑错误在答辩时被老师一问就能问出来。正确写法是在Service层手动控制事务边界并且自始至终使用同一个ConnectionConnection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 检查车辆状态 String checkSql SELECT status FROM car WHERE id ? FOR UPDATE; // 注意FOR UPDATE先对这张行加排他锁避免并发场景下两个事务同时读到“可用” // 2. 更新车辆状态为已预约 // 3. 插入订单记录 conn.commit(); } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) conn.setAutoCommit(true); conn.close(); // 这里关闭是归还给连接池不是真正断开 }特别注意事务里所有操作必须复用同一个连接对象。如果中途去调用一个内部会重新DBUtil.getConnection()的方法那个方法使用的是连接池里的另一个连接事务边界就被割裂了。有时候你会发现明明写了commit却什么都没有生效八成就是这个问题。4.4 连接URL参数的坑与解决连接MySQL时经常碰到的幺蛾子我一次说清。如果你用的MySQL 8.x驱动类是com.mysql.cj.jdbc.Driver而不是老版本的com.mysql.jdbc.Driver。驱动类找不到的报错九成是jar包没引对或者类是拼错了。连接串里建议加上这三个参数useSSLfalseMySQL 8默认开启SSL验证但本地开发环境通常没有证书配置不关掉会报SSL连接错误。serverTimezoneAsia/Shanghai否则驱动会报时区错误时间字段也可能差8小时。characterEncodingutf8配合数据库字符集utf8mb4防止中文乱码。热搜里那些“mysql ssl连接错误”“mysql 8.0安装”“mysql安装配置教程”我基本都翻过一遍大部分问题的根源就是这三项没设置好。如果你跑的是5.7没加serverTimezone可能还能凑合但升级MySQL 8后大概率会直接爆错。我建议一开始就按标准格式写避免后期各种奇怪异常。5. ServletJSP协作登录、预约、还车的完整链路5.1 Servlet的路由组织这个系统里我没有用一个Servlet包打天下而是按模块划分UserServlet、CarServlet、OrderServlet。每个Servlet内部再根据请求参数action分发具体方法。以OrderServlet的doPost为例WebServlet(/order) public class OrderServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String action request.getParameter(action); if (submit.equals(action)) { submitOrder(request, response); } else if (cancel.equals(action)) { cancelOrder(request, response); } else if (confirmReturn.equals(action)) { confirmReturn(request, response); } else { response.sendError(404); } } Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 列表查询、详情跳转等GET请求同样按action分发 } }这样的好处是代码可读性高后续维护时不需要在几十个Servlet之间跳来跳去。如果你把系统做得很小也可以只写一个IndexServlet统一转发但我觉得按模块拆更清晰。5.2 Session与登录过滤器登录成功之后我会把用户对象放进SessionHttpSession session request.getSession(); session.setAttribute(loginUser, user);然后在所有需要用户身份的后台路径上增加一个登录校验过滤器。这样不管用户直接访问哪个Servlet的URL都会先经过过滤器检查Session里有没有loginUser。如果没有就重定向到login.jsp。WebFilter(/*) 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; // 放行公开资源 String uri request.getRequestURI(); if (uri.endsWith(login.jsp) || uri.endsWith(register.jsp) || uri.contains(/user) || uri.contains(/car)) { chain.doFilter(req, resp); return; } // 检查登录状态 HttpSession session request.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() /login.jsp); } } }这个过滤器要小心写不然很容易把CSS、静态资源也拦住。URI判断的规则自己提前设计好博客里给的只是示例实际项目按自己的路径规则调整。5.3 数据交付request转发与JSP渲染Servlet处理完业务后把结果存到request作用域再forward到JSPListCar carList carService.listAvailableCars(); request.setAttribute(carList, carList); request.getRequestDispatcher(/carList.jsp).forward(request, response);JSP页面用EL表达式和JSTL标签取数据不要在JSP里写一堆Java脚本片段。比如车辆列表页的核心部分% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table tr th车牌/th th品牌/th th日租金/th th状态/th /tr c:forEach varcar items${carList} tr td${car.carNo}/td td${car.brand}/td td${car.pricePerDay}/td td c:choose c:when test${car.status 1}可用/c:when c:when test${car.status 2}已预约/c:when c:otherwise不可用/c:otherwise /c:choose /td /tr /c:forEach /tableEL和JSTL是JSP项目的核心技能。很多入门同学刚上手时喜欢在JSP里写% request.getAttribute(xxx) %甚至Java循环搞得页面乱成一锅粥。我建议一定花半小时学一下c:forEach和c:if标签页面整洁度提升好几个档次答辩观感也会更好。5.4 中文乱码一揽子解决方案JSPServlet项目里的中文乱码通常是三个节点没有对齐第一Servlet接收POST请求参数前要先调用request.setCharacterEncoding(UTF-8)。注意这个方法必须在读取任何参数之前调用放在doPost/doGet第一行。第二Servlet响应JSP页面时JSP头部要保证pageEncoding是UTF-8% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%第三MySQL连接串里带上characterEncodingutf8同时建库时指定utf8mb4字符集。这一步我会把环境变量、数据库连接、Servlet编码对齐成同一个UTF-8基本就不会再出乱码。GET请求的中文参数在Tomcat 8及以上默认按UTF-8解码不会出问题如果你用的是老版本Tomcat则需要去server.xml里加URIEncodingUTF-8。这属于比较少见的坑知道有这回事就行。6. 运行配置与实战踩坑从IDEA到Tomcat再到MySQL6.1 环境清单与IDEA搭建这个项目在我本机跑起来的环境组合是JDK 1.8、Tomcat 8.5、MySQL 8.0.33、IDEA 2023.3。也可以用Maven来管理jar包但如果你是纯手工JavaWeb项目也可以把mysql-connector-java直接放到WEB-INF/lib下。两种方式各有优劣Maven好维护依赖版本手工lib直观但容易漏jar。在IDEA里运行JavaWeb项目我强烈建议在Run Configuration里配置Tomcat Server使用war exploded方式部署。这样修改代码后热部署快不会每次都要重新打war包。IDE配置Tomcat时注意指定你的本机Tomcat安装目录选择Application Server时别选成JDK目录。热搜里有个高频词是“idea运行javaweb项目配置”很多人卡在这一步。我的建议是先不要想着一步到位按这个顺序排查——IDEA有没有配置Tomcat Server、Project Structure里的Artifact有没有选war exploded、Dependencies里是否包含Servlet-api和JSP-api这两个Tomcat自带编译时如果没引入会报错运行时又不需要放进war里。6.2 高频错误排查表我把自己运行项目时遇到的高频错误整理成了表格应该能覆盖大部分同学的启动问题现象原因解决办法ClassNotFoundException: com.mysql.cj.jdbc.DriverMySQL驱动包缺失或类名写错检查WEB-INF/lib下是否放了mysql-connector-java 8.x jar包Communications link failureMySQL服务没启动、端口被占用或防火墙拦截先netstat查看3306端口再确认连接串IP/端口Access denied for user用户名或密码错误或者该账号没有远程访问权限用命令行mysql -u root -p验证账号确认授权Table doesnt exist数据库库名选错或表名拼错Navicat里确认表名和SQL中的库名完全一致404Servlet路径找不到WebServlet注解URL或web.xml配置错误检查注解值和前端页面提交的action路径500空指针从数据库查出的对象为null或request.getParameter返回null看Tomcat localhost日志定位具体行号端口占用8080旧Tomcat实例未关闭或其他程序占用netstat -ano查找PID后结束进程或改Tomcat端口排查思路我一般遵循“从外到内”先确认MySQL能不能连再确认Tomcat日志有没有异常最后才去看具体代码。很多同学一看到500就翻业务代码实际上错误在页面提交的路径根本没配好方向反了。6.3 并发抢单行锁怎么用共享租车场景里并发抢同一辆车是绕不开的问题。两个人同时看到同一辆车“可用”同时点了预约怎么办单纯靠事务还不足以保证正确因为两个事务同时读取到“可用”状态然后先后更新车辆状态第二个事务也会认为自己操作成功。解决办法是在查询车辆状态时加上FOR UPDATE对车辆行加排他锁X锁。我前面事务代码里已经写了SELECT status FROM car WHERE id ? FOR UPDATE第一个事务执行这条SQL后持有该行排他锁第二个事务再执行同样的SQL会阻塞等待等第一个事务提交后才能继续执行此时它读取到的status已经是“已预约”就可以判定预约失败。这就是MySQL里“共享锁和排他锁”在实际业务中的典型应用。课设答辩时能主动讲出这个细节绝对是加分项。6.4 资源释放与代码习惯最后说一个容易被忽略但很重要的习惯。虽然用了连接池JDBC的Connection、PreparedStatement、ResultSet依然要在finally或try-with-resources里显式关闭。连接池关闭Connection只是归还给池但ResultSet和Statement如果不释放长时间跑下来会造成游标泄漏最终拖垮数据库。我在这套项目里的统一写法是用try-with-resourcesJava 7以后简洁可靠try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, carId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { // 处理结果 } } }这种写法不仅代码量少而且无论有没有抛异常资源都会自动释放。我自己后来在公司维护老项目时也保持着这个习惯排查过几次别人留下的连接泄漏问题后更确定这个前期习惯有多值钱。整套系统从设计到跑通我最深的体会是开发顺序一定不要乱。先把三张表建好再写JDBC工具类然后是Service层再是Servlet最后才是JSP页面。每一步都以前一步的结果为基础出错时也能迅速定位到具体层。如果你现在正因为课设、毕设卡在这种项目上别慌踏踏实实按“数据库建模 - 事务链路 - 请求流转”的顺序来一条条解决。这套东西虽然老但每个知识点都能在你以后写任何Web系统时派上用场。
返回列表