简介:在Java Web开发领域,Servlet和JSP构成了经典MVC架构的基础模型。Servlet作为服务器端程序,负责处理HTTP请求和业务逻辑,而JSP则用于动态生成HTML页面,实现数据展示。这种技术组合的价值在于清晰地分离了控制逻辑与视图呈现,为理解Web应用的数据流转提供了直观范例。在实际工程实践中,通过JDBC操作MySQL数据库完成数据持久化是核心环节,其中使用PreparedStatement能有效防范SQL注入,保障系统安全。连接池技术(如HikariCP)的引入则解决了数据库连接频繁创建销毁的性能瓶颈,是提升应用吞吐量的关键优化。本文以火车票查询这一典型应用场景为切入点,详细阐述了如何从环境搭建、数据库设计到功能编码,完整实现一个基于JSP与Servlet的Web系统,帮助开发者深入理解Java Web经典开发模式的核心原理与实践要点。
1. 项目缘起与核心价值:为什么现在还需要一个JSP火车查询系统?
最近在整理硬盘里的老项目,翻到了一个十多年前用JSP+Java+MySQL做的火车查询系统。说实话,第一反应是“这玩意儿现在还有人看吗?”。毕竟现在主流都是Spring Boot + Vue/React前后端分离,JSP听起来像是上个时代的产物。但仔细想想,这个“古董”项目恰恰是理解Java Web开发从经典模式到现代架构演进的最佳标本。对于很多在校学生、刚入行的Java开发者,或者想从零开始理解一个完整Web应用数据流转的人来说,它比一个用满了最新框架但封装过度的“黑盒”项目,更有教学和参考价值。
这个系统的核心很简单:用户通过浏览器访问JSP页面,输入出发地、目的地、日期,点击查询,后台Java代码(Servlet)连接MySQL数据库,执行SQL查询,将结果集封装成Java对象,再传递回JSP页面渲染成HTML表格展示出来。整个过程涉及了Java Web开发最经典的MVC雏形(虽然可能不那么标准)、JDBC数据库操作、JSP的脚本和标签使用,以及最基本的前后端交互。理解了这些,你再去看MyBatis、Spring MVC,甚至任何Web框架,都会有一种“哦,原来它是在解决这个痛点”的豁然开朗感。
所以,这篇内容不是教你做一个能上线商用的系统,而是带你亲手搭建、并彻底吃透一个经典的JSP项目。我们会从环境搭建、数据库设计、核心功能编码,一路讲到那些老鸟才知道的调试技巧和性能坑点。你会发现,很多所谓的“Java八股文”面试题,比如Servlet生命周期、JDBC连接池、SQL注入防范,在这个项目里都是活生生的实践。
2. 环境准备与项目骨架搭建:告别“配置地狱”
很多人学Java Web倒在了第一步:环境配置。各种版本不兼容、jar包冲突、Tomcat启动报404,足以劝退新手。我们这里用最主流、最稳定的组合,确保你能一次跑通。
2.1 开发环境选型与安装
Java环境:选择JDK 8或JDK 11(LTS长期支持版)。不建议用最新的JDK 17+,因为一些老旧的库或教程可能兼容性有问题。安装后务必配置JAVA_HOME环境变量。检查方法是在命令行输入java -version和javac -version,都能正确显示版本信息即可。
集成开发环境(IDE):强烈推荐使用IntelliJ IDEA Ultimate版(社区版对JSP和Java EE支持不完整)。很多人用Eclipse,但IDEA在智能提示、代码导航和项目管理上更胜一筹。特别是处理JSP页面时,IDEA的语法高亮和错误提示能省很多事。
Web服务器:使用Apache Tomcat 9。这是最广泛使用的Servlet/JSP容器,成熟稳定。去官网下载zip包,解压到一个没有中文和空格的路径下,比如D:\dev\tomcat9。
数据库:使用MySQL 8.0。同样从官网下载安装包,安装过程中记住你设置的root密码。同时,建议安装MySQL Workbench,这是一个图形化的数据库管理工具,比命令行更直观,方便我们建表、导入数据和调试SQL。
2.2 在IDEA中创建并配置Web项目
打开IDEA,选择“New Project”。
- 左侧选择“Java Enterprise”。
- 在“Project SDK”处选择你安装的JDK。
- 在“Application Server”这里点击“New...” -> “Tomcat Server”,然后定位到你解压的Tomcat目录。
- “Additional Libraries and Frameworks”这里,只勾选“Web Application”,并且确保勾选了“Create web.xml”。我们不用任何框架,就从最原始的Servlet和JSP开始。
- 给项目起个名字,比如
TrainQuerySystem,选择项目位置,点击Finish。
项目创建好后,你会看到一个标准的目录结构:
TrainQuerySystem ├── src │ └── (这里放Java源代码,主要是Servlet) ├── web │ ├── WEB-INF │ │ ├── web.xml (部署描述符,核心配置文件) │ │ └── lib (存放项目依赖的jar包,如MySQL驱动) │ └── index.jsp (默认首页) └── 其他配置文件...第一个关键配置:添加MySQL驱动。去Maven仓库或MySQL官网下载mysql-connector-java的jar包(版本建议8.0.x)。然后,将这个jar包复制到web/WEB-INF/lib/目录下。IDEA会自动将这个目录下的jar包添加到项目的构建路径中。这是很多新手会忽略的一步,导致后面代码里Class.forName("com.mysql.cj.jdbc.Driver")报错。
第二个关键配置:配置Tomcat运行。点击IDEA右上角的“Add Configuration...”,点击“+”号,选择“Tomcat Server” -> “Local”。
- 在“Server”标签页,确认Tomcat Home路径正确。
- 在“Deployment”标签页,点击“+” -> “Artifact”,选择你项目生成的
war exploded包。注意下方“Application context”可以设置为/,这样访问地址就是http://localhost:8080/。 配置完成后,点击运行按钮,如果能在浏览器看到Tomcat的默认页面或你的index.jsp内容,说明环境搭建成功。
注意:如果你遇到“404 - 未找到”错误,大概率是“Application context”没配置对,或者
index.jsp没放在web目录根下,而是放到了WEB-INF里面(WEB-INF下的内容客户端无法直接访问)。
3. 数据库设计与数据建模:如何设计一张“聪明”的车次表
系统核心是数据。火车车次信息该怎么存?这直接决定了查询的效率和功能的可扩展性。一个糟糕的表设计会让后续编码举步维艰。
3.1 核心表结构设计
我们至少需要两张表:train(车次表)和station(车站表)。这里采用一种经典且实用的设计:在train表中存储车次的完整运行线路信息。
station表(车站基础信息)
CREATE TABLE `station` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '车站ID,主键', `station_name` varchar(50) NOT NULL COMMENT '车站名', `city` varchar(50) DEFAULT NULL COMMENT '所属城市', `pinyin_code` varchar(10) DEFAULT NULL COMMENT '拼音简码,用于快速搜索', PRIMARY KEY (`id`), UNIQUE KEY `uniq_station_name` (`station_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车站信息表';设计理由:单独建车站表是为了数据一致性。避免在车次表中重复存储“北京南”、“北京南站”这类不统一的数据。pinyin_code字段(如BJN代表北京南)是为后续做车站名拼音搜索预留的,这是一个提升用户体验的小技巧。
train表(车次信息表)这是核心,设计上需要一点技巧。我们不仅存储车次号,还存储它经过的所有车站及时间。
CREATE TABLE `train` ( `id` int(11) NOT NULL AUTO_INCREMENT, `train_no` varchar(20) NOT NULL COMMENT '车次号,如G101', `start_station_id` int(11) NOT NULL COMMENT '始发站ID', `end_station_id` int(11) NOT NULL COMMENT '终点站ID', `departure_time` time NOT NULL COMMENT '始发站发车时间', `arrival_time` time NOT NULL COMMENT '终点站到达时间', `duration` time GENERATED ALWAYS AS (TIMEDIFF(`arrival_time`, `departure_time`)) STORED COMMENT '运行时长(计算列)', `seat_info` json DEFAULT NULL COMMENT '座位信息,JSON格式,如{"business": 20, "first": 100, "second": 500}', PRIMARY KEY (`id`), KEY `idx_train_no` (`train_no`), KEY `idx_stations` (`start_station_id`,`end_station_id`), CONSTRAINT `fk_train_start` FOREIGN KEY (`start_station_id`) REFERENCES `station` (`id`), CONSTRAINT `fk_train_end` FOREIGN KEY (`end_station_id`) REFERENCES `station` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次基础信息表';设计理由:
start_station_id和end_station_id通过外键关联station表,确保车站存在。duration使用了MySQL 5.7+的生成列(GENERATED COLUMN),自动计算运行时间,避免应用层重复计算。seat_info使用JSON类型存储动态的座位类型和数量,比设计多个固定的seat_business、seat_first字段更灵活,方便后续增加新的座位类型。查询时可以使用JSON_EXTRACT(seat_info, '$.business')来获取具体数量。
但是,这只能解决“从A到B”的直达查询。用户更常见的需求是“从中间的C站到D站”。这就需要另一张表来记录车次经过每个车站的详情。
train_schedule表(车次时刻表)
CREATE TABLE `train_schedule` ( `id` int(11) NOT NULL AUTO_INCREMENT, `train_id` int(11) NOT NULL COMMENT '关联train.id', `station_id` int(11) NOT NULL COMMENT '关联station.id', `station_sequence` int(11) NOT NULL COMMENT '站序,从1开始', `arrival_time` time DEFAULT NULL COMMENT '到达时间(始发站此项为NULL)', `departure_time` time DEFAULT NULL COMMENT '出发时间(终点站此项为NULL)', `stay_duration` time GENERATED ALWAYS AS (TIMEDIFF(`departure_time`, `arrival_time`)) STORED COMMENT '停靠时长', PRIMARY KEY (`id`), UNIQUE KEY `uniq_train_station` (`train_id`,`station_id`), KEY `idx_train_seq` (`train_id`,`station_sequence`), CONSTRAINT `fk_schedule_train` FOREIGN KEY (`train_id`) REFERENCES `train` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_schedule_station` FOREIGN KEY (`station_id`) REFERENCES `station` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次时刻详情表';这才是查询功能的灵魂。它记录了车次train_id在station_sequence第几站停靠station_id,以及具体的到达和出发时间。通过这张表,我们可以轻松查出任意车次经过任意两个车站的信息。
3.2 初始化测试数据
设计好表之后,需要插入一些真实的测试数据。这里以京沪高铁G1次为例:
- 先在
station表插入:北京南、济南西、南京南、上海虹桥。 - 在
train表插入:G1,始发站北京南(ID:1),终点站上海虹桥(ID:4),发车时间09:00,到达时间13:30。 - 在
train_schedule表插入:- (train_id: G1的ID, station_id:1, sequence:1, departure_time:09:00)
- (train_id: G1的ID, station_id:2, sequence:2, arrival_time:10:30, departure_time:10:33)
- (train_id: G1的ID, station_id:3, sequence:3, arrival_time:12:00, departure_time:12:03)
- (train_id: G1的ID, station_id:4, sequence:4, arrival_time:13:30)
有了这些数据,我们就能回答:“从济南西到南京南,有G1次吗?几点发车几点到?” 这样的问题了。查询SQL的核心思路是:从train_schedule表中找到同一train_id下,station_sequence在起始站和到达站之间的所有记录。
4. 后端核心:Servlet与JDBC的经典搭配
前端JSP负责展示,后端的业务逻辑和数据库交互则由Servlet来完成。这是Java Web最经典的模式。
4.1 创建数据库连接工具类
直接在每一个Servlet里写连接代码是灾难性的,会导致资源泄露和难以维护。我们必须创建一个工具类来管理数据库连接。
// DBUtil.java package com.train.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/train_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; // 务必改成你自己的密码 static { try { // 加载驱动,MySQL 8.0+ 驱动类名是这个 Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); throw new RuntimeException("找不到MySQL数据库驱动!"); } } // 获取数据库连接 public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 关闭连接、Statement、ResultSet等资源 public static void close(AutoCloseable... closables) { for (AutoCloseable closable : closables) { if (closable != null) { try { closable.close(); } catch (Exception e) { e.printStackTrace(); // 生产环境应使用日志记录,而非打印 } } } } }关键点解析:
static静态代码块:在类加载时执行一次,用于注册驱动。这是老式写法,JDBC 4.0之后可以省略,但显式声明更清晰。- 连接参数:
useUnicode=true&characterEncoding=UTF-8确保正确处理中文。serverTimezone=Asia/Shanghai解决MySQL 8.0时区问题,否则可能插入或查询时间出错。这是非常容易踩的坑。 close方法:使用可变参数和AutoCloseable接口,可以优雅地关闭任何实现了该接口的资源(Connection, Statement, ResultSet),避免在finally块里写冗长的多层try-catch。
4.2 实现查询功能的Servlet
我们创建一个QueryServlet,它负责接收前端传来的出发地、目的地,查询数据库并返回结果。
// QueryServlet.java package com.train.servlet; import com.train.util.DBUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.sql.*; import java.util.ArrayList; import java.util.List; @WebServlet("/query") // 通过注解配置Servlet映射路径,替代web.xml配置 public class QueryServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); // 通常查询操作也允许GET,这里简单处理,统一由doPost处理 } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取请求参数(来自JSP表单) String fromStationName = req.getParameter("fromStation"); String toStationName = req.getParameter("toStation"); String date = req.getParameter("date"); // 暂时先留着,后续可以用于筛选 // 2. 参数校验(防止SQL注入的第一道防线) if (fromStationName == null || fromStationName.trim().isEmpty() || toStationName == null || toStationName.trim().isEmpty()) { req.setAttribute("errorMsg", "出发地和目的地不能为空!"); req.getRequestDispatcher("/index.jsp").forward(req, resp); return; } Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; List<TrainInfo> trainList = new ArrayList<>(); try { // 3. 获取数据库连接 conn = DBUtil.getConnection(); // 4. 编写SQL查询语句(核心逻辑) // 查询逻辑:找到同时包含出发站和到达站,且出发站顺序在到达站之前的车次 String sql = "SELECT t.train_no, t.departure_time as start_time, t.arrival_time as end_time, " + " ts1.departure_time as actual_departure, ts2.arrival_time as actual_arrival, " + " TIMEDIFF(ts2.arrival_time, ts1.departure_time) as travel_duration " + "FROM train t " + "JOIN train_schedule ts1 ON t.id = ts1.train_id " + "JOIN station s1 ON ts1.station_id = s1.id AND s1.station_name = ? " + "JOIN train_schedule ts2 ON t.id = ts2.train_id " + "JOIN station s2 ON ts2.station_id = s2.id AND s2.station_name = ? " + "WHERE ts1.station_sequence < ts2.station_sequence " + "ORDER BY ts1.departure_time"; // 5. 使用PreparedStatement,防止SQL注入! pstmt = conn.prepareStatement(sql); pstmt.setString(1, fromStationName.trim()); pstmt.setString(2, toStationName.trim()); // 6. 执行查询 rs = pstmt.executeQuery(); // 7. 遍历结果集,封装成对象 while (rs.next()) { TrainInfo info = new TrainInfo(); info.setTrainNo(rs.getString("train_no")); info.setStartTime(rs.getTime("start_time")); info.setEndTime(rs.getTime("end_time")); info.setActualDeparture(rs.getTime("actual_departure")); info.setActualArrival(rs.getTime("actual_arrival")); info.setTravelDuration(rs.getTime("travel_duration")); trainList.add(info); } // 8. 将结果列表存入请求属性,传递给JSP req.setAttribute("trainList", trainList); req.setAttribute("fromStation", fromStationName); req.setAttribute("toStation", toStationName); } catch (SQLException e) { e.printStackTrace(); req.setAttribute("errorMsg", "数据库查询出错:" + e.getMessage()); } finally { // 9. 务必关闭资源,防止内存泄漏 DBUtil.close(rs, pstmt, conn); } // 10. 转发到结果展示页面 req.getRequestDispatcher("/result.jsp").forward(req, resp); } } // 一个简单的JavaBean,用于封装车次信息 class TrainInfo { private String trainNo; private Time startTime; private Time endTime; private Time actualDeparture; private Time actualArrival; private Time travelDuration; // 省略getter和setter... }代码深度解析与避坑指南:
@WebServlet注解:这是Servlet 3.0之后的方式,比在web.xml里配置<servlet>和<servlet-mapping>更简洁。但要注意,有些老旧的教程或项目可能还在用XML配置,两者等价。- 参数校验:在接触数据库之前进行校验是必须的。这里只是简单的非空检查,实际还应包括长度、字符合法性等。
- SQL注入防御:绝对不要使用字符串拼接来构造SQL!这是安全红线。我们使用了
PreparedStatement,并用?作为占位符。pstmt.setString(1, ...)方法会将参数进行转义处理,从根本上杜绝SQL注入。这是本项目最重要的安全实践。 - SQL查询逻辑:这是核心。我们通过两次
JOIN将train_schedule和station表关联起来,分别定位出发站(s1)和到达站(s2)。条件ts1.station_sequence < ts2.station_sequence确保了出发站在到达站之前。这个查询能高效地找出所有符合条件的车次。 - 资源关闭:在
finally块中关闭ResultSet、Statement和Connection是铁律。即使发生异常,也要保证连接被释放回连接池(我们目前是直接关闭,后续优化为连接池)。我们的DBUtil.close()方法让这个操作变得简洁。 - 请求转发:使用
req.getRequestDispatcher("/result.jsp").forward(req, resp);将处理结果(trainList)和请求对象一起“转发”给result.jsp页面去渲染。这是服务器端跳转,地址栏不会变。
5. 前端交互:JSP页面的动态渲染与表单处理
JSP页面负责两件事:一是提供查询表单,二是展示查询结果。我们将创建两个JSP文件:index.jsp(首页/查询页)和result.jsp(结果页)。
5.1 首页查询表单 (index.jsp)
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>火车票查询系统</title> <style> /* 简单的内联样式,让页面看起来不那么丑 */ body { font-family: Arial, sans-serif; margin: 40px; } .container { width: 500px; margin: 0 auto; padding: 20px; border: 1px solid #ccc; border-radius: 5px; } .form-group { margin-bottom: 15px; } label { display: inline-block; width: 80px; } input[type="text"], input[type="date"] { padding: 8px; width: 200px; } button { padding: 10px 20px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; } .error { color: red; margin-top: 10px; } </style> </head> <body> <div class="container"> <h2>火车车次查询</h2> <form action="query" method="post"> <%-- 表单提交到QueryServlet --%> <div class="form-group"> <label for="fromStation">出发地:</label> <input type="text" id="fromStation" name="fromStation" placeholder="例如:北京南" required> </div> <div class="form-group"> <label for="toStation">目的地:</label> <input type="text" id="toStation" name="toStation" placeholder="例如:上海虹桥" required> </div> <div class="form-group"> <label for="date">日期:</label> <input type="date" id="date" name="date"> </div> <button type="submit">查询</button> </form> <%-- 显示错误信息 --%> <c:if test="${not empty errorMsg}"> <div class="error"> <strong>错误:</strong> ${errorMsg} </div> </c:if> <%-- 一个小提示,告诉用户可以怎么查 --%> <div style="margin-top: 20px; font-size: 0.9em; color: #666;"> <p>提示:目前数据库中有 G1(北京南->济南西->南京南->上海虹桥)、G2(上海虹桥->...)等测试车次数据。</p> </div> </div> <%-- 引入JSTL核心标签库,用于条件判断、循环等 --%> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> </body> </html>关键点解析:
<%@ page ... %>:这是JSP指令,设置页面属性,contentType和charset必须正确设置以支持中文。<form action="query" method="post">:表单的action属性值query,对应我们Servlet中配置的@WebServlet("/query")。method="post"对应Servlet的doPost方法。name属性:name="fromStation",这个值就是Servlet中通过req.getParameter("fromStation")获取的键。- JSTL标签:我们使用了
<c:if>标签来动态显示错误信息。${errorMsg}是EL表达式,用于获取Servlet通过req.setAttribute("errorMsg", ...)设置的值。要使用JSTL,需要将jstl.jar和standard.jar两个包放到WEB-INF/lib下。这是JSP开发中比直接写<% ... %>脚本更优雅、更安全的方式。 - 内联CSS:为了简化,样式直接写在
<style>里。实际项目建议使用外部CSS文件。
5.2 结果展示页面 (result.jsp)
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>查询结果</title> <style> table { border-collapse: collapse; width: 100%; margin-top: 20px; } th, td { border: 1px solid #ddd; padding: 12px; text-align: center; } th { background-color: #f2f2f2; } tr:hover { background-color: #f5f5f5; } .no-data { text-align: center; color: #999; padding: 40px; } .back-link { display: block; margin-top: 20px; text-align: center; } </style> </head> <body> <div class="container"> <h2>从 <span style="color: #4CAF50;">${fromStation}</span> 到 <span style="color: #4CAF50;">${toStation}</span> 的车次查询结果</h2> <c:choose> <%-- 判断是否有查询结果 --%> <c:when test="${not empty trainList}"> <table> <thead> <tr> <th>车次</th> <th>出发站发车时间</th> <th>到达站到达时间</th> <th>区间发车时间</th> <th>区间到达时间</th> <th>区间运行时间</th> <th>操作</th> </tr> </thead> <tbody> <%-- 使用JSTL的forEach循环遍历List --%> <c:forEach var="train" items="${trainList}"> <tr> <td><strong>${train.trainNo}</strong></td> <td>${train.startTime}</td> <td>${train.endTime}</td> <td>${train.actualDeparture}</td> <td>${train.actualArrival}</td> <td>${train.travelDuration}</td> <td> <%-- 一个模拟的“预订”按钮,点击后跳转到详情页(暂未实现) --%> <a href="detail?trainNo=${train.trainNo}&from=${fromStation}&to=${toStation}" style="padding: 5px 10px; background: #008CBA; color: white; text-decoration: none; border-radius: 3px;"> 查看详情 </a> </td> </tr> </c:forEach> </tbody> </table> </c:when> <c:otherwise> <%-- 没有查询到结果时的显示 --%> <div class="no-data"> <p>很抱歉,未找到从 <strong>${fromStation}</strong> 到 <strong>${toStation}</strong> 的直达车次。</p> <p>您可以尝试查询中转方案,或检查车站名称是否正确。</p> </div> </c:otherwise> </c:choose> <a href="index.jsp" class="back-link">返回重新查询</a> </div> </body> </html>关键点解析:
- JSTL与EL表达式:这是JSP的精髓。
<c:choose>,<c:when>,<c:otherwise>用于条件判断,替代了复杂的<% if ... %>脚本。<c:forEach>用于循环遍历Servlet传过来的trainList。${train.trainNo}这种EL表达式直接调用JavaBean的getTrainNo()方法,非常简洁。 - 数据展示:我们将查询结果以表格形式清晰展示。注意时间类型的显示,直接从
java.sql.Time对象输出,格式可能不是最友好的,实际项目中可以用JSTL的格式化标签<fmt:formatDate>进行美化。 - 用户体验:通过
<c:otherwise>处理了无结果的情况,并给出了友好提示。这是专业应用必备的细节。 - 动态链接:
detail?trainNo=${train.trainNo}&from=${fromStation}&to=${toStation}构造了一个带参数的URL,为后续实现车次详情页留下了扩展接口。
6. 性能优化与实战踩坑指南
一个能跑通的Demo和一个健壮的系统之间,隔着无数个坑。下面分享几个在这个项目中必然会遇到,且必须解决的典型问题。
6.1 数据库连接池:为什么不用DriverManager?
我们之前的DBUtil每次请求都创建新连接,用完后关闭。这在低并发下没问题,但一旦访问量上来,频繁创建和销毁TCP连接(数据库连接底层是TCP)将带来巨大的性能开销。连接池是解决这个问题的标准方案。
我们可以使用像HikariCP或Druid这样的高性能连接池。以HikariCP为例,首先在WEB-INF/lib下添加它的jar包,然后修改DBUtil:
// DBUtil.java (使用HikariCP版本) package com.train.util; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { private static final HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/train_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("your_password"); config.setMaximumPoolSize(10); // 连接池最大连接数 config.setMinimumIdle(5); // 最小空闲连接数 config.setConnectionTimeout(30000); // 获取连接超时时间(毫秒) config.setIdleTimeout(600000); // 连接空闲超时时间 config.setMaxLifetime(1800000); // 连接最大生命周期 config.addDataSourceProperty("cachePrepStmts", "true"); config.addDataSourceProperty("prepStmtCacheSize", "250"); config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); // 这些是MySQL性能优化参数 dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); // 从池中获取连接 } public static void close(AutoCloseable... closables) { // 关闭方法不变,但注意:Connection.close()在这里的含义是“将连接归还给连接池”,而非真正关闭。 // ... 同上 ... } // 在应用关闭时(如ServletContextListener中)需要关闭数据源 public static void shutdownDataSource() { if (dataSource != null && !dataSource.isClosed()) { dataSource.close(); } } }改动要点:我们从直接使用DriverManager,改为从HikariDataSource这个连接池获取连接。连接池会预先建立好一些连接并维护起来,当应用需要时快速分配,用完后回收,避免了重复创建的开销。getConnection()和close()的调用代码完全不用改,这是连接池的透明性带来的好处。
6.2 中文乱码问题:从数据库到浏览器的完整链条
中文乱码是Java Web开发的老大难问题,必须确保整个数据流编码一致。
- 数据库层面:建表时使用
utf8mb4字符集(支持所有Unicode字符,包括emoji)。连接URL中指定characterEncoding=UTF-8。 - Servlet层面:在
doPost方法最开头,设置请求和响应的编码。
对于GET请求,参数在URL中,Tomcat 8.5+默认是UTF-8,但旧版本需要在req.setCharacterEncoding("UTF-8"); // 解决POST请求参数乱码 resp.setContentType("text/html;charset=UTF-8"); // 设置响应编码server.xml的<Connector>标签中增加URIEncoding="UTF-8"属性。 - JSP页面层面:页面指令
<%@ page pageEncoding="UTF-8" contentType="text/html;charset=UTF-8" %>必须设置。
6.3 JSP开发中的常见“坑”
JSP file [/xxx.jsp] not found错误:这通常是因为文件路径不对,或者文件没有放在web目录或它的子目录下。WEB-INF目录下的文件不能被客户端直接访问,只能通过请求转发(forward)或包含(include)来访问。- 脚本片段与表达式:尽量避免使用
<% ... %>写大量Java代码(称为Scriptlet),这会使页面难以维护。优先使用JSTL和EL表达式。如果必须用,确保代码简洁。 - 页面缓存:在开发阶段,浏览器可能会缓存旧的JSP页面。如果你修改了JSP但刷新浏览器没变化,可以尝试强制刷新(Ctrl+F5),或者在IDEA中配置Tomcat的“On frame deactivation”为“Update classes and resources”。
- IDEA中JSP语法高亮或跳转失效:检查File -> Project Structure -> Facets,确保Web资源目录被正确识别。有时需要手动将
web目录标记为“Web Resource Directory”。
6.4 SQL查询性能与索引优化
我们的核心查询SQL涉及多表JOIN和条件过滤,如果数据量很大(比如几十万条车次记录),性能可能成为瓶颈。
- 为关联字段和查询条件字段添加索引:我们已经为
train_schedule表的(train_id, station_sequence)和station表的station_name创建了索引。这是最重要的优化手段。 - 避免
SELECT *:在SQL中明确指定需要的字段,减少网络传输和数据解析开销。 - 分析执行计划:在MySQL Workbench中,对复杂SQL使用
EXPLAIN命令,查看是否用到了索引,是否有全表扫描。根据结果调整索引或SQL写法。
7. 项目扩展与进阶思考
完成基础查询后,这个系统还有很多可以深化和扩展的地方,这也是面试中常被问到的“你的项目有什么亮点”。
7.1 实现车次详情与座位查询
在result.jsp中,我们预留了“查看详情”的链接。可以创建一个DetailServlet,接收trainNo、from、to参数,查询更详细的信息,比如:
- 车次全程时刻表(所有经停站)。
- 基于
seat_infoJSON字段,动态展示不同席别的余票和价格(这里需要引入一个“余票”的概念,涉及到更复杂的库存扣减逻辑,是另一个大课题)。 - 车次类型(高铁G、动车D、直达Z等)、车型、是否提供Wi-Fi等附加信息。
这需要你设计更多的表和更复杂的业务逻辑,是练习数据库关联查询和业务封装的好机会。
7.2 引入MVC框架思想
我们这个项目虽然Servlet处理逻辑、JSP负责展示,有点MVC的样子,但还不够清晰。尤其是当业务复杂后,Servlet会变得非常臃肿(被称为MVC Model 1的缺点)。
- 进阶方向:可以尝试引入真正的MVC框架,如Spring MVC。将
QueryServlet中的业务逻辑抽离到Service层,数据库操作放到Dao层,Servlet(或Spring的Controller)只负责参数接收和视图跳转。JSP则彻底变为纯视图。 - 带来的好处:代码结构清晰,易于维护和测试,各层职责分离。
7.3 前后端分离的演进
JSP技术最大的问题是前后端耦合太紧,前端工程师需要懂JSP语法,后端改动也容易影响页面。现代开发早已转向前后端分离。
- 技术选型:后端提供RESTful API(可以用Spring Boot快速搭建),返回JSON数据。前端使用Vue.js、React等框架,通过Ajax调用API获取数据并渲染页面。
- 对于本项目的意义:你可以保留现有的Servlet作为API接口(修改
doPost方法,将trainList转换成JSON字符串,通过resp.getWriter().write(jsonString)输出),然后单独做一个HTML+JavaScript的前端项目来调用它。这样就能直观地体会到前后端分离的优劣势。
7.4 应对“Java面试八股文”
这个项目几乎涵盖了Java Web基础面试题的所有实操场景:
- Servlet生命周期:
init(),service(),doGet()/doPost(),destroy()。你的QueryServlet就是一个活例子。 - GET vs POST:我们的表单用了POST,但Servlet里也处理了GET。可以思考什么情况下用GET(幂等、安全),什么情况下用POST(修改数据、有副作用)。
- JDBC流程:加载驱动、获取连接、创建Statement、执行SQL、处理ResultSet、关闭资源。我们的代码完整走了一遍。
- PreparedStatement vs Statement:我们用了
PreparedStatement防止SQL注入,并且预编译SQL能提升性能。 - MVC模式:虽然简单,但你能说出哪部分是Model(TrainInfo JavaBean,数据库),哪部分是Controller(Servlet),哪部分是View(JSP)吗?
- 连接池原理:我们提到了HikariCP。面试官可能会问为什么用连接池,它的工作原理是什么。
- 中文乱码解决方案:我们给出了从浏览器到数据库的完整链条。这是一个经典的综合性问题。
通过这个项目,你不仅仅是“写了一个查询系统”,而是亲手搭建并理解了Java Web应用从数据存储、业务处理到界面展示的完整闭环。下次面试被问到“请谈谈你对Servlet和JSP的理解”或者“你如何设计一个查询功能”时,你完全可以把这个项目作为案例娓娓道来,这比干巴巴地背概念要生动和深刻得多。
本文还有配套的精品资源,点击获取