做了小半年的JavaWeb项目,终于把一个完整的酒店管理系统从前到后捋了一遍。这套东西用的是java+jsp+servlet+bootstrap+jquery+mysql的组合,典型到不能再典型的JavaWeb教学案例,同时也是很多学校毕业设计里反复出现的题目。你说它老也好,说它基础也罢,但真正把这个栈从头到尾跑通,把业务逻辑和技术细节都理清楚,你对JavaWeb的理解会扎实非常多。这篇文章我不打算像写说明书一样逐行贴代码,而是把一个完整的酒店管理系统在设计和实现过程中最关键的决策、最容易被忽略的坑、以及怎么一步步把功能落地,全部展开讲清楚。不管是正在做课设的大学生,还是想系统补JavaWeb基础的自学者,这篇文章都能给你一套可以直接参考的完整思路。
1. 项目整体设计与技术选型思路
1.1 为什么会选jsp+servlet这套"老组合"
现在很多人一上来就是Spring Boot + MyBatis Plus + Vue,确实方便,但你让他解释一个HTTP请求从浏览器出发到数据库再返回浏览器的完整过程,他可能说不清楚。jsp+servlet这套组合最大的价值不是"先进",而是"透明"。Servlet负责接收请求、调用业务逻辑、控制页面跳转,JSP负责渲染动态页面,MySQL负责数据持久化,整个请求链路每一步都是显式的,没有任何框架替你包办。做酒店管理系统这种业务复杂度适中的项目,用这套组合反而能把每个环节都看得明明白白。
再说毕业设计和课程设计的场景。不少学校对项目的要求是"能跑起来、有完整业务逻辑、有数据库设计文档",这套技术栈完全够用。而且从答辩角度讲,你能够把Servlet生命周期、请求转发与重定向的区别、JDBC连接数据库的步骤这些基础概念讲清楚,反而比背一堆Spring注解更能让老师认可。这一点实际到了找工作面试阶段也有用,很多面试官就喜欢从底层问起,你做过原生Servlet项目,回答这类问题时明显有底气。
1.2 为什么用Bootstrap+jQuery来做前端
Bootstrap+jQuery这套前端方案看起来也不新,但放在这个项目里非常合适。先说Bootstrap,它提供了一套完整的CSS组件库,栅格系统、导航栏、表格、表单、模态框、标签页这些后台管理系统最常用的UI元素全都封装好了。做酒店管理系统的后台界面,无非就是左侧菜单栏、右侧内容区、顶部导航栏这种经典布局,Bootstrap的栅格系统可以快速搞定响应式布局,不需要你自己写太多CSS。
再说jQuery,它的选择器、事件绑定、AJAX方法都极其简洁,尤其ajax那一块,$.ajax、$.post、$.get三个方法能覆盖几乎所有需求。酒店管理系统里最常见的异步交互是"选择房间类型后异步刷新可用房间列表"和"提交表单前做前端校验",jQuery处理这类场景非常顺手。更重要的是,这套前端方案不需要Node.js环境,不需要webpack构建,直接把CSS和JS文件丢到webapp目录下引入就行,和JSP天然融合,部署到Tomcat就不会有跨域和打包的问题。
1.3 哪些功能模块是必须规划的
酒店管理系统的核心业务场景是围绕"房间"和"客人"展开的。我从需求角度梳理了一遍,最终确定的功能模块是这几块:用户登录模块、房间类型管理、房间信息管理、客户入住登记、退房结算。如果你还有余力,可以再加上预订管理和营业额统计。登录模块负责控制访问权限,防止未登录用户直接访问后台;房间类型管理用来维护大床房、标准间、豪华套房等分类;房间信息管理则是具体到每个房间号的维护,包括房间号、所属类型、价格、当前状态;客户入住登记是最核心的业务,需要选择空闲房间、登记客人信息、生成入住记录;退房结算则根据入住时长和房价计算金额。
这五个模块已经构成了一个完整的业务闭环。从技术角度讲,它们正好覆盖了增删改查的全部操作,并且包含一个非常关键的场景——入住登记时需要"把房间状态从空闲改为已入住、同时生成一条入住记录",这两个操作必须放在同一个事务里,否则就会出现房间被重复分配的严重Bug。这是整个项目里最有技术含量的部分,后面我会详细展开。
2. 数据库设计:酒店管理系统的地基
2.1 核心表结构设计与字段说明
数据库设计是这类管理系统最重要的一步,表设计得不好,后面写代码全是泪。我最终定下来五张表:用户表t_user、房间类型表t_room_type、房间表t_room、入住信息表t_checkin、客户表t_customer。用户表比较简单,就存管理员账号密码和姓名,字段包括id、username、password、real_name、create_time。这里有一个容易忽略的点,密码在数据库里绝对不能存明文,至少要做一个MD5加密,虽然这个项目的安全级别不用太高,但这是一个必须养成的习惯。
房间类型表t_room_type用来存类型名称和基础价格,字段是id、type_name、base_price。把房间类型单独拆一张表而不是直接在房间表里写字符串,是为了避免数据冗余。试想一下,如果你在房间表里直接存"大床房"三个字,哪天想统一改成"豪华大床房",你得更新好几条记录,而且手一抖就可能漏掉一条。拆成类型表之后,房间表只要存一个type_id就行了。
房间表t_room是本项目的关键表,字段包括id、room_no、type_id、floor、status。room_no是房间号,比如101、202,必须加唯一索引,这是防止同一栋楼出现两个相同房间号的最底层保障。status字段用TINYINT类型存,1代表空闲,2代表已入住,3代表维修中。为什么不用字符串?因为状态值是固定枚举,用数字占用空间小,查询速度快,程序里再做一个状态映射就可以显示成中文了。入住信息表t_checkin是业务流程里的核心表,记录每次入住和退房的操作,字段包括id、customer_id、room_id、check_in_time、check_out_time、total_amount、status。customer_id和room_id都是外键,分别指向客户表和房间表。
2.2 表关系与外键设计思路
这几张表之间的关系很清晰:房间类型表和房间表是一对多关系,一个类型下面有多个房间;客户表和入住信息表是一对多关系,一个客户可以多次入住;房间表和入住信息表也是一对多关系,一个房间在不同时间会对应多条入住记录。客户表单独拆出来是为了一个场景——同一个客户可能多次来住,如果每次入住都把客人姓名、身份证号、手机号重复录入一次,数据冗余不说,想统计"哪些是回头客"都不知道怎么查。单独的客户表可以让你通过身份证号来判断老客户,入住时只要根据身份证号查询,如果存在就直接关联已有客户ID。
有一个细节值得展开说:入住信息和房间的关系。从业务上看,房间在某个时间点只有一个入住状态,但入住记录表里会有多条同房间的历史记录。所以查询"当前谁住在205房间"不能简单地查t_checkin表,而要加上status条件,查status为在住的记录,同时还要和t_room表的状态配合。这个逻辑如果没想清楚,后面做退房的时候很容易出现"房间已经退了但状态没更新"的Bug。我实际测试的时候还发现一个很有意思的问题,如果退房的时候不校验"当前房间确实被这个客户住着",直接更新状态,就会出现把空闲房间"退房"的离谱操作,所以退房必须带room_id和入住记录id两个条件去更新,这点在写SQL时一定要仔细。
2.3 建表SQL中容易被忽略的细节
字段类型的选型里藏了很多经验。价格字段一定要用DECIMAL(10,2),绝对不能使用FLOAT或DOUBLE。浮点数在计算金钱时有精度丢失的问题,比如0.1加0.2的结果并不是0.3,这在退房结算的时候是致命的。DECIMAL是定点数,按十进制存储,做价格计算是安全的。另一个细节是时间字段,入住时间一般用DATETIME类型,如果需要自动记录当前时间,可以直接在字段定义里加上DEFAULT CURRENT_TIMESTAMP,插入数据的时候就不用再手动填当前时间了。
外键约束这个问题我要特别提醒一下:初学者为了展示"我懂外键",喜欢给所有关联字段都加FOREIGN KEY约束。实际做项目的时候,我更倾向于不加数据库层面的物理外键,而是通过程序逻辑维护关联关系。原因是物理外键会在插入、更新、删除时做额外的完整性检查,性能有损耗,而且后期做数据迁移或者批量导入数据时经常因为外键约束报错,非常麻烦。程序里通过事务和业务校验来保证数据一致性,比物理外键更灵活。当然,索引是必须要加的,外键关联字段、身份证号、房间号这些查询频繁的字段都要建索引。
3. 后端实现:Servlet+JDBC实战解析
3.1 JDBC连接管理的演进:从DriverManager到连接池
很多初学JDBC的人写代码是这样的:每次查询都Class.forName注册驱动,然后DriverManager.getConnection获取连接,用完再close。这套流程单看没问题,但放在真实的Web项目里就是灾难。Tomcat是并发处理请求的,每个用户点一个按钮,后台就可能同时有好几个请求要查数据库。如果每个请求都重新建立物理连接,数据库的连接数瞬间就会被耗尽,而且建立连接本身是开销很大的操作,TCP握手加认证来回要好几次,性能会肉眼可见地变慢。
我在这个项目里用的是Druid连接池,阿里巴巴开源的,功能强大而且中文文档全。连接池的思路说白了就是"提前建好一批连接放着,谁用谁取,用完还回去"。初始化的时候创建比如10个连接,业务代码里通过dataSource.getConnection()获取,用完调用close()实际上不是真的关闭物理连接,而是把连接还回池子里复用。这样连接池里的连接始终是热的,性能提升非常明显。配置Druid的核心代码很简单,一个配置文件加几行Java代码就能用起来。
// druid.properties driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=123456 initialSize=10 maxActive=50 maxWait=3000public class DBUtil { private static DataSource dataSource; static { try (InputStream in = DBUtil.class.getClassLoader().getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("数据库连接池初始化失败"); } } public static Connection getConnection() { try { return dataSource.getConnection(); } catch (SQLException e) { throw new RuntimeException("获取数据库连接异常", e); } } }这里有个连接字符串的细节一定要说。url里的useUnicode=true&characterEncoding=utf8是保证中文能正确写入数据库的关键,timezone参数是MySQL 8.x版本必需的,因为从MySQL 8.0开始默认时区是UTC,如果不指定serverTimezone,你存进去的时间会和你本地时间相差8个小时,排查起来极其崩溃。我第一次踩这个坑的时候,明明数据库里显示的时间和实际时间差了8小时,查了半天才发现是时区问题。
3.2 Servlet的职责划分与路由设计
整个系统的Servlet划分我是按业务模块来分的,一个模块一个Servlet,每个Servlet里根据action参数区分具体操作。这样做的好处是类数量不会太多,同时每个类的职责又足够清晰。用户相关的就一个LoginServlet,负责登录和退出;房间类型相关的RoomTypeServlet,负责类型列表的展示和增删改;房间管理用RoomServlet,负责房间的列表、新增、修改、删除和状态查询;入住和退房放在CheckinServlet里,这两个操作虽然业务上是一进一出,但都属于"房间与客户关联"的模块,放一起维护起来顺一些。
有个更简洁的写法是只写一个统一的ControllerServlet,用action参数分发到不同的方法,所有HTTP请求都进同一个入口。这种写法最大的好处是web.xml或注解配置特别少,但缺点是类会越来越庞大,一个Servlet几百行甚至上千行,维护起来很痛苦。我的建议是项目规模小就按我前面说的一个模块一个Servlet来,既能保证每个类的行数在可控范围内,代码结构又清晰,答辩或者代码审查的时候讲起来也好讲。
@WebServlet("/room") public class RoomServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("list".equals(action)) { listRooms(request, response); } else if ("toEdit".equals(action)) { toEditPage(request, response); } else if ("delete".equals(action)) { deleteRoom(request, response); } } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("save".equals(action)) { saveRoom(request, response); } } }这里还有一个小知识点,doGet和doPost为什么要分开处理或者都转到一个方法里。项目里我倾向于让doGet处理查询请求,doPost处理新增修改请求,这是符合HTTP语义的。但实际开发中很多前端表单提交用的是POST,浏览器地址栏直接访问是GET,所以有时候会出现"页面能打开但提交按钮点了没反应"的情况,排查下来发现是Servlet里只重写了doGet,doPost没处理。最稳妥的做法是让其中一个方法调用另一个,或者统一转发到一个execute方法里。
3.3 DAO模式与三层架构的必要性
因为用Servlet+JSP这套技术栈,很多人写完Servlet直接把JDBC代码塞在Servlet里,存取数据全在一个类里完成。小Demo确实可以这么做,但放到酒店管理系统这种有多张表、多个业务模块的项目里,这种写法过不了一周你就会后悔。我使用的是经典的三层架构:Servlet层只负责接收请求参数、调用Service层、控制页面跳转;Service层处理业务逻辑,比如入住登记时校验房间是否空闲、计算退房金额;DAO层封装对数据库的增删改查操作。这样做最大的好处是职责边界清楚,出了问题第一个就知道去哪个类里查。
DAO层还有一个细节值得说一下。我习惯把一个模块的查询方法先定义成接口,然后写实现类。比如RoomDao是一个接口,RoomDaoImpl是实现类。为什么多此一举?因为面试的时候这可以说清楚"面向接口编程"的思想,项目里也方便以后替换实现,比如从JDBC换成MyBatis,或者写一个Mock实现做单元测试。当然,对于课程设计这个规模的项目,你也可以不定义接口直接写实现类,代码量会少一些,但养成接口习惯对你以后写Spring项目有很大帮助,Spring最核心的依赖注入就是建立在接口之上的。
public class CheckinDaoImpl implements CheckinDao { @Override public Checkin findByRoomAndStatus(Integer roomId, Integer status) { String sql = "select * from t_checkin where room_id = ? and status = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, roomId); ps.setInt(2, status); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapToCheckin(rs); } } } catch (SQLException e) { throw new RuntimeException("查询入住记录失败", e); } return null; } }3.4 入住登记:必须使用事务的经典场景
这个项目里最值得演示事务处理的场景就是入住登记。整个入住操作包含三步:根据客户信息获取或创建客户;往t_checkin表插入一条入住记录;把t_room表中对应房间的状态从空闲改成已入住。如果这三步中间任何一步出了问题而其他步骤已经执行成功,就会出现严重的数据不一致。比如房间状态已经改成已入住,但入住记录没插进去,那这个房间就永远被"神秘占用"了,前台也没法查出是谁住的。
事务解决的思路是:把这三步操作放进同一个数据库连接里,先设置setAutoCommit(false)关闭自动提交,三步全部执行成功后调用commit()提交,任何一步抛异常就在catch里调用rollback()回滚。这样要么三步全部成功,要么全部失败,不可能出现中间状态。
public void checkin(Checkin checkin, Integer roomId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 保存或获取客户 Customer customer = customerDao.findOrCreateByPhone(conn, checkin.getCustomer()); // 2. 插入入住记录 checkin.setCustomerId(customer.getId()); checkinDao.insert(conn, checkin); // 3. 更新房间状态为已入住 roomDao.updateStatus(conn, roomId, 2); conn.commit(); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException("入住登记失败", e); } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }事务处理是区分"会写增删改查"和"真正懂业务数据一致性"的分水岭。面试时候如果问你"多个表同时更新怎么保证一致性",你能马上说出事务机制和回滚逻辑,这绝对是加分项。做这个项目的时候,我强烈建议把这段事务代码亲手敲一遍,理解连接、提交、回滚三者之间的关系,后面学Spring的@Transactional注解就轻松多了。
4. 前端实现:JSP页面与Bootstrap+jQuery的配合
4.1 JSP中的Java代码应该怎么写才不那么乱
JSP的定位是视图层,它的职责是展示数据而不是处理业务逻辑。很多老项目里JSP的页面头部全是<% %>循环加ifelse,整个页面又臭又长,维护起来特别遭罪。我在这个项目里做一个约定:JSP页面里不写Java业务代码,所有需要展示的数据都在Servlet里放进request域或者session域,JSP里只用EL表达式${}和JSTL标签来渲染。
比如房间列表页,Servlet里查询完数据后这样设置:
request.setAttribute("roomList", roomList); request.getRequestDispatcher("/admin/room_list.jsp").forward(request, response);JSP页面里这样取数据:
<table class="table table-bordered table-hover"> <thead> <tr> <th>房间号</th> <th>类型</th> <th>楼层</th> <th>状态</th> <th>价格</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${roomList}" var="room"> <tr> <td>${room.roomNo}</td> <td>${room.typeName}</td> <td>${room.floor}</td> <td> <c:choose> <c:when test="${room.status == 1}"><span class="label label-success">空闲</span></c:when> <c:when test="${room.status == 2}"><span class="label label-danger">已入住</span></c:when> <c:otherwise><span class="label label-warning">维修中</span></c:otherwise> </c:choose> </td> <td>${room.price}</td> <td> <c:if test="${room.status == 1}"> <button class="btn btn-primary btn-sm" onclick="openCheckinModal(${room.id})">入住</button> </c:if> </td> </tr> </c:forEach> </tbody> </table>要是没用JSTL,这个表格你得这样写:<% for(...) { %>...<% } %>,不仅难看,还容易在HTML标签里写错Java语法导致整个页面编译报错。用JSTL之后代码清爽非常多。这里提醒一下,JSTL需要往WEB-INF/lib目录里放jstl.jar和standard.jar,或者用Maven的话引入依赖,很多同学忘了这一步导致页面报"Can not find the tag library descriptor"的错误,看到这条报错要先想到JSTL包没引入。
4.2 Bootstrap搭建后台框架的常用套路
Bootstrap做后台管理界面的套路非常固定,就是把页面分成三块:顶部导航栏、左侧菜单栏、右侧内容区。顶部导航栏一般放系统标题、当前登录用户、退出登录按钮;左侧菜单栏用Bootstrap的列表组或者折叠面板组件做导航;右侧内容区用栅格系统放数据表格或者表单。这套布局在网上有大量模板可以直接参考,不需要自己从头写CSS。
我用的Bootstrap版本是3.x,虽然Bootstrap 4和5有更新的特性,但3.x的资料最多、兼容性最好,在老的教程和教学文档里出现频率最高。这里有个实际经验:BootCDN有时候加载慢,万一你上课或者答辩的时候网络不好,整个页面就会变成没有样式的纯HTML排版,非常尴尬。最稳妥的做法是把Bootstrap的css和js文件下载到本地,放进项目的webapp/css和webapp/js目录里,通过相对路径引用。这样项目在没有任何外网的情况下也能正常显示,而且部署到Tomcat后加载速度也更快。
还有一个路径问题是新手很容易犯的:JSP页面的静态资源引用路径要以项目的ContextPath开头,不能写死成/bootstrap/css/bootstrap.min.css。比如项目部署名是hotel,写死的路径在本地可能是对的,但换一个部署名路径就全部404了。正确写法是在页面顶部先获取一下项目路径:
<% String path = request.getContextPath(); %> <link rel="stylesheet" href="<%=path%>/css/bootstrap.min.css">或者用EL表达式和JSTL配合的写法:
<link href="${pageContext.request.contextPath}/css/bootstrap.min.css" rel="stylesheet">4.3 jQuery实现异步交互的几个场景
酒店管理系统里用jQuery做异步交互主要有三个场景。第一个是登录表单的提交校验,点击登录按钮后先用jQuery校验用户名密码不为空,再通过ajax提交到后台,这样用户不用等整个页面刷新就知道结果。第二个是入住登记页面里的"选择房间类型后异步刷新房间列表",客人选了"标准间",页面立刻通过ajax查询当前空闲的标准间房间号,展示在下拉框里,这个交互体验比同步刷新整个页面好很多。第三个是退房的时候点击退房按钮,弹一个确认框,确认后ajax请求后台完成退房操作,页面局部刷新房间列表就行了。
function loadAvailableRooms(typeId) { $.ajax({ url: contextPath + '/room', type: 'GET', data: {action: 'available', typeId: typeId}, dataType: 'json', success: function (data) { var select = $('#roomSelect'); select.empty(); $.each(data, function (i, room) { select.append($('<option>').val(room.id).text(room.roomNo)); }); }, error: function () { alert('加载可用房间失败'); } }); }ajax的dataType这里我直接定义成json,这要求后台Servlet里返回的数据必须是JSON格式的字符串。Servlet里可以用手拼字符串的方式返回JSON,比如{"id":1,"roomNo":"101"},但字段一多就特别容易出错。我推荐使用阿里巴巴的fastjson或者Gson这个工具库,把Java对象直接序列化成JSON字符串,代码简单还不会出错。Gson用法就是一行,new Gson().toJson(roomList),返回的字符串前端直接解析。
前后端交互时有一个很常见的问题:Servlet返回的JSON里如果有中文,前端有可能出现乱码。解决方法是在Servlet里设置响应编码:
response.setContentType("application/json;charset=UTF-8");这行代码必须每次返回JSON前都设置,漏掉就会导致浏览器解析时使用了错误的编码。
5. 完整实操:从零搭建一个可运行的酒店管理系统
5.1 开发环境准备与项目结构
动手前先列一下我使用的环境版本,避免版本不对导致各种奇怪的报错。JDK用的1.8版本,不是因为它新,而是因为市面上绝大多数教材和文档都是围绕JDK 8写的,遇到问题搜解决方案的时候最方便。IDE我用的是Eclipse,虽然IDEA更现代,但Eclipse对JavaWeb项目的原生支持其实很省心,新建Dynamic Web Project直接就是标准的Web目录结构。Tomcat用了8.5或者9.0版本都可以,都支持Servlet 3.1规范,用@WebServlet注解不需要在web.xml里注册。MySQL用的8.0,主要是因为新版还在更新,安装包和文档都好找,要注意8.0的JDBC驱动类名是com.mysql.cj.jdbc.Driver,老版本是com.mysql.jdbc.Driver,类名写错就报ClassNotFoundException。
项目的目录结构遵循标准的Maven Web项目约定或者Eclipse Dynamic Web Project结构,源代码和配置文件的划分要清晰。src/main/java放Java源码,按包名分层管理,我当时分包是com.hotel.servlet、com.hotel.service、com.hotel.dao、com.hotel.entity、com.hotel.util这几个。webapp目录下放JSP页面、css、js、images等静态资源。JSP页面建议再按功能细分目录,比如admin目录放后台管理页面,common目录放登录页和公共组件。这样分好之后,文件多了也不会乱。
整个项目运行的基本链路是这样的:浏览器发出一个请求,比如GET /hotel/room?action=list,Tomcat根据@WebServlet注解匹配到RoomServlet,Servlet调用Service层方法,Service调用DAO层方法,DAO通过连接池获取连接,执行SQL,把结果封装成Java对象返回,Servlet再把List设置到request属性里,转发到room_list.jsp,JSP用JSTL循环输出整个表格。把这套链路默写出来,你对JavaWeb项目就有一个完整的心智模型了。
5.2 登录模块的完整实现链路
登录模块虽然简单,但它包含了JavaWeb最常见的知识点,非常适合作为第一个动手功能。前端login.jsp页面里放一个表单,表单里有username和password两个输入框,以及一个提交按钮。按钮点击后用jQuery做非空校验,校验通过后表单正常提交到LoginServlet的doPost方法。也可以用ajax提交返回结果,体验更好,我两版都写过,建议至少先写同步提交版本,理解了整个请求-响应过程之后再加ajax版本。
LoginServlet里第一件事是设置请求编码,否则从表单传过来的中文会乱码。然后通过request.getParameter获取用户名和密码,调用UserService的login方法,内部查询数据库,把用户输入的密码做MD5加密后和数据库里存的密码比对。如果正确,把用户对象存进session里,response.sendRedirect重定向到主页,同时设置session超时时间。如果失败,通过request.setAttribute传一个错误提示,转发回login.jsp页面显示。登录成功之后在主页上显示"当前登录用户:xxx",这个从session里取值就行。
有个细节很容易出错:登录成功用sendRedirect重定向,登录失败用forward转发。两者有本质区别,重定向是浏览器重新发起一个新的请求,地址栏URL会变,适合"登录成功后跳到一个新页面"的语义,同时可以避免刷新页面时表单重复提交。如果登录成功用forward,页面上按F5刷新就会重复提交登录表单,造成重复登录记录。这个知识点面试特别喜欢问,你一定要彻底搞清楚。
5.3 入住登记模块的前后端串联
入住登记是整个系统里最具"完整业务链"感觉的功能。页面上的表单需要选择客户(或者输入客户信息)、选择房间类型、选择具体房间、填写入住天数,然后提交。这里的交互设计是房间的可用列表要和房间类型联动,也就是客人先选类型,数据库里该类型的空闲房间列表就异步加载到房间下拉框里。
后端CheckinServlet接收到入住请求后,先做一个参数校验,然后计算退房时间和预估费用,再调Service层的checkin方法。Service方法里做一个关键校验:根据roomId查询房间当前状态,如果状态不为空闲,直接抛业务异常,提示"该房间已被入住,请刷新页面"。这个校验非常重要,因为可能出现两个前台同时操作的情况,一个人刚提交了101房间的入住,另一个还停留在旧页面上也提交同一房间,如果不校验就会造成同一房间重复入住。业务异常和系统异常要区分处理,业务异常的错误提示可以直接展示给用户,系统异常则记录日志后统一跳转到错误页面。
退房结算流程和入住是反向的。前台点击退房按钮,输入房间号,系统查出这个人住了多久,根据房价乘天数计算出总金额,展示确认。确认后在一个事务里完成两步:把t_checkin记录的状态改成已退房并填上实际退房时间和总金额;把t_room的房间状态改回空闲。结算金额的计算要注意头尾日期的处理,比如当天中午入住第二天中午退房算一天,精准到小时的计算逻辑要写清楚,否则金额会有分歧。
5.4 web.xml与部署配置的几个关键点
虽然Servlet 3.0之后可以用注解,不用在web.xml里一个一个配置Servlet了,但web.xml里仍然有几个东西要设置好。首先是欢迎页welcome-file-list,指定访问项目根路径时默认打开哪个页面,一般配置成login.jsp或者一个跳转到登录页的servlet映射。不配置的话你输入http://localhost:8080/hotel会显示404或者目录结构,体验很不好。
其次是session超时时间的配置,在web.xml里加一段:
<session-config> <session-timeout>30</session-timeout> </session-config>单位是分钟,30分钟算是比较合理的值。登录状态存在session里,如果用户长时间不操作,session过30分钟后自动失效,再次访问需要重新登录。如果不配置session超时时间,Tomcat默认是30分钟,大多数场景是够用的,但了解这个配置可以让你在需要调整时知道去哪里改。
还有编码过滤器。虽然每个Servlet里都设置了请求编码response编码,但这种方式容易漏。更好的方案是写一个Filter,给所有请求统一设置UTF-8编码,再在web.xml里配置filter映射到/*。这也是JavaWeb里面Filter最经典的应用场景。你这套项目做下来,对Filter的理解会比看十遍教材都深。
6. 常见问题与排查技巧实录
6.1 HTTP 404和500到底怎么快速定位
404是资源找不到,500是服务器内部错误。这两种报错在JavaWeb项目中几乎每天都会遇到,但定位思路完全不同。404首先检查URL路径是否拼写正确,包括大小写,Servlet映射路径是区分大小写的;再看Servlet类上是否有@WebServlet注解,注解路径是否和访问路径一致;然后确认Tomcat是否成功部署了项目,看控制台有没有deploy相关的日志有没有报错。如果用的是Eclipse或者IDEA部署,还要检查项目是否被加入到了Tomcat的部署列表中。
500错误最常见的两层原因:一层是Java代码异常,比如空指针NPE、SQL语法错误、ClassNotFoundException;另一层是JSP页面编译失败,比如页面里引用了不存在的属性、JSTL标签写错。排查500的第一步不是看代码,而是打开浏览器的开发者工具,看Network面板里这个请求的Response,Tomcat会把完整的异常堆栈信息返回给浏览器,这里面会明确告诉你异常是哪一行抛出来的。如果你嫌浏览器显示不完整,直接去Tomcat的logs目录看localhost.log和catalina.out日志文件,最原始的堆栈都在那个文件中。看到堆栈信息后,按图索骥找到代码对应行,大多数问题都能在几分钟内定位到。
6.2 中文乱码问题的系统性解决方案
中文乱码是整个JavaWeb新手期最折磨人的问题,没有之一。乱码产生的原因只有一个:数据在不同环节之间传输时用了不同的编码。比如页面用UTF-8提交表单,Tomcat默认按ISO-8859-1解码,数据到Servlet里就已经是乱码了;再比如数据库表字段是latin1编码,你往里面存中文,必然变成问号。
我总结了一套"三层编码统一"的方案。第一层是Tomcat层面,如果使用Tomcat 8.5及以上版本,POST请求参数的编码默认已经是UTF-8,但GET请求参数在server.xml的Connector上需要配置URIEncoding="UTF-8"。第二层是Servlet层面,每个Servlet处理POST请求前都加request.setCharacterEncoding("UTF-8"),或者像前面说的用一个全局Filter统一设置。第三层是数据库层面,建库的时候指定utf8mb4字符集,连接URL里带上characterEncoding=utf8,两者缺一不可。页面顶部的meta标签也要设置charset=UTF-8,且JSP文件本身要保存成UTF-8编码。这三层都对了,项目里基本不会再有乱码出现。
这里有一个人容易忽略的点:如果你用上面的三层方案都设置好了,但是旧数据已经以错误编码写入数据库了,你改完代码之后查出来的旧数据还是乱码。这种情况没办法靠编码配置修复,只能重新导入数据或者在读取时做转码。所以数据库一开始就设置utf8mb4字符集特别重要,中途改编码是很痛苦的。
6.3 无法连接MySQL和处理时区报错
刚把项目部署到Tomcat,启动后运行登录功能,控制台突然报一个类似The server time zone value 'UTC' is unrecognized的错误。这个就是MySQL 8.0和JDBC驱动之间的时区兼容性问题。解决方式很简单,连接URL里加上serverTimezone=Asia/Shanghai。如果还有人用老版本的mysql-connector-java驱动,还要在驱动类名上注意,MySQL 8.0对应的驱动类是com.mysql.cj.jdbc.Driver,旧的com.mysql.jdbc.Driver在新版本驱动里可能已经被移除了。
另外一个典型的连接数据库问题是在新电脑上搭建环境时经常遇到的:MySQL服务没有启动,或者密码输入错误,或者MySQL的端口被占用。排查顺序是:先用命令行或者MySQL Workbench确认能正常连接数据库,排除数据库本身的问题;再检查项目里druid.properties中的用户名密码和实际MySQL的一致;最后看防火墙是否拦了3306端口。这套排查流程里最快的一步其实是把druid.properties里的配置和实际环境一条一条对比,大多数时候问题就出在配置和实际环境不一致。
6.4 前端静态资源加载和路径问题排查
页面打开之后发现完全没有样式,图片全部裂开,这基本可以判断是静态资源路径错了。打开浏览器F12开发者工具切到Network面板,刷新页面,找到bootstrap.min.css这条请求,看它的URL是不是404了。常见原因是页面里的引用路径少了项目上下文路径,比如我前面说的写死了/css/bootstrap.min.css。解决办法是使用${pageContext.request.contextPath}动态拼接项目路径。
还有一点是在JSP里用相对路径时要注意当前页面的位置。比如你访问的是/hotel/admin/room_list.jsp,页面里一个相对路径是css/bootstrap.min.css,那它实际请求的是/hotel/admin/css/bootstrap.min.css,这个目录下当然没有文件。无论是绝对路径还是相对路径都要心里清楚最终拼出来的URL到底是什么。经验法则就是,JSP页面里统一用开头是斜杠的绝对路径,再配合contextPath动态前缀,基本不会再踩路径坑。
6.5 常见问题排查速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 访问Servlet返回404 | @WebServlet路径写错或者项目未部署成功 | 检查注解路径和访问URL,查看Tomcat部署日志 |
| 页面报500且堆栈有空指针异常 | 查询结果是null但没有判空 | 查看堆栈定位到具体行,增加null判断 |
| 中文表单提交后台乱码 | Servlet没设置请求编码 | 统一增加request.setCharacterEncoding("UTF-8") |
| 数据库中文显示为问号 | 表或字段字符集不是utf8mb4 | 建库时指定utf8mb4,修改已有表的字符集 |
| 页面没有样式 | 静态资源路径少了contextPath | 使用${pageContext.request.contextPath}拼接路径 |
| 时间比本地时间差8小时 | MySQL时区和本地不一致 | 连接URL添加serverTimezone=Asia/Shanghai |
| 刷新页面重复提交表单 | 用了forward而非sendRedirect | 写成功后重定向到列表页 |
| JSP显示找不到JSTL标签 | 缺少jstl.jar和standard.jar | 导入JSTL依赖到WEB-INF/lib目录 |
7. 项目做完之后的复盘与扩展方向
7.1 一个小细节:登录状态拦截器的实现思路
项目功能全部做完之后,我发现在一个很小的安全点上可以进一步加深对Filter的理解。现在管理员登录后直接访问某个JSP页面,比如在地址栏输入http://localhost:8080/hotel/admin/room_list.jsp,是可以直接访问的,因为JSP本身也是一种资源,它不会自动判断用户有没有登录。解决这个问题的方式就是再增加一个登录拦截过滤器LoginFilter。在doFilter方法里先判断当前session里有没有用户信息,如果没有就重定向到登录页,拦截所有admin目录下的请求。写这个过滤器的时候,你会深刻体会到什么是"横切关注点",这就是Spring Security和Shiro这类安全框架在Servlet层面的底层逻辑。
7.2 如何从这套项目平滑过渡到主流框架
做完这个jsp+servlet项目之后,你已经亲手实现了Web开发的完整底层链路,这时候去学Spring Boot会轻松非常多。你先回头看看项目里写的那些Servlet、DAO、Service,你会发现Spring MVC做的事情就是把Servlet那一层封装掉了,@Controller就是Servlet,@RequestMapping就是@WebServlet映射,DispatcherServlet就是统一入口的中央控制器。Spring Boot又解决了项目配置繁琐的问题,把Tomcat嵌入进来,连部署都不用单独做了。
DAO那一层可以用MyBatis或者JPA替换,连接池可以从Druid换成HikariCP,但核心思路完全一致。所以这个老项目不是过时的东西,而是你学习主流框架最好的铺垫。我自己的体会是,先写一个原生的Servlet项目,再学Spring Boot,你会知道框架帮你做了什么,也知道框架背后发生了什么,出了问题不会一头雾水。如果你现在正卡在"学完JavaSE和MySQL,不知道下一步怎么走"这个阶段,我非常推荐就把这个酒店管理系统完整地敲一遍。不要只看文章,一定要自己动手建项目、建表、写代码、部署、测试,把每个功能都跑通。踩坑的过程就是你真正理解JavaWeb的过程。