简介:这是一套面向高校计算机相关专业学生和JavaWeb初学者的课程设计项目,围绕学生信息管理场景实现登录验证、增删改查等核心业务,采用MVC分层组织代码,适合课程设计参考、Servlet与JSP入门练习以及答辩前的功能复盘。压缩包共35个文件,约2.15MB,包含9个Java源文件、8个HTML页面,以及CSS、JS、XML配置、图片和Word说明文档等,覆盖后端业务、前端界面、配置与设计草图等部分。项目已有4376人学习下载,具备一定的参考基础。读者可从中了解登录校验与背景轮播、学生信息新增、按学号查询与全量查询、修改和删除等功能的实现方式,也可结合界面原型和说明文档梳理目录结构,并在此基础上进行二次开发或改造成自己的课程设计。
1. 答辩前一周还在改连接串:这套 JavaWeb 学生管理系统到底解决什么问题
答辩前一周,隔壁寝室那位同学把他做的 javaweb 课程设计——学生管理系统——拷给我,说登录进去学生列表是空的,改了数据库密码还是空。我打开一看,JDBC 连接串在三个 DAO 里各写了一份,他改了两份,漏了第三份。这类基于 MVC 的 Java Web 学生管理系统,功能本身不玄学:登录鉴权、学生信息增删改查、按姓名、学号和班级条件分页检索,外加班级或成绩的基础维护。价值在于,它把 Servlet、JSP、JDBC、Filter、Session、连接池这几样东西串成一条完整的请求链路,是课程设计里最经得起追问的一套骨架:老师问 request 怎么转发、session 存哪、过滤器执行顺序,你都能指着代码答。
它适合两类人:正在做课程设计、需要一份能跑又能讲清楚的作品的学生;以及刚接触 Java Web、想弄明白“一个请求到底怎么走到数据库再回到页面”的开发者。如果你只想快速上线业务,这套东西确实笨重;但如果你想搞清楚 MVC 每一层在干什么,它比任何框架都直白。
2. 技术选型与工程结构:Servlet + JSP + JDBC 怎么撑起一个 MVC
2.1 为什么不用 Spring Boot,以及三层架构的边界在哪
课程设计场景下我一般不推荐上 Spring Boot。不是因为不好用,而是因为它会把要讲的东西藏起来。自动配置把数据源、事务、视图解析器全接管了,答辩时被问“这个请求进来先经过谁”,答“框架自动处理的”是很掉分的。原生 Servlet + JSP + JDBC 的组合,每一环都摆在明面上:web.xml或@WebServlet决定路由,Filter决定拦截顺序,Service决定事务边界,DAO决定 SQL 长什么样。
这套项目常见的技术栈大致如下,版本不用卡死,但同一个项目里别混:
| 层次 | 技术选型 | 在项目里的职责 |
|---|---|---|
| 视图层 | JSP + JSTL + EL | 只做渲染,不写 Java 逻辑脚本 |
| 控制层 | Servlet 3.1(@WebServlet) | 收参数、校验、调 Service、转发或重定向 |
| 业务层 | 普通 Java 类(Service + Impl) | 事务、业务校验、多 DAO 编排 |
| 持久层 | 原生 JDBC +PreparedStatement | SQL 与结果集映射 |
| 连接池 | Druid 或 C3P0 | 复用连接,避免每次DriverManager建连 |
| 数据库 | MySQL 5.7 / 8.0 | 学生表、用户表、班级表 |
| 容器 | Tomcat 8.5 / 9.0 | Servlet 容器,注意与 JDK 版本匹配 |
| 前端 | Bootstrap + jQuery | 表格、模态框、表单校验 |
分层的边界要守住一条:Servlet 里不写 SQL,DAO 里不出现HttpServletRequest。这条线一破,代码就会长成“一个 800 行的 Servlet”,后面加需求基本等于重写。我见过太多课程设计最后变成StudentServlet里既查库又拼 HTML,改一个字段名要动五处。
Service 层存在的意义在多表操作时才显出来。比如删除一个班级前要先查该班级下有没有学生,这个判断放 Servlet 里就会和参数解析搅在一起,放 Service 里,将来加“删除学生同时删成绩”也只是多调一个 DAO。
2.2 目录结构与一次请求的完整链路
标准的 Maven Web 工程目录大致长这样,src/main/webapp是最终会被打成 war 的根:
student-management/ ├── pom.xml └── src/main/ ├── java/com/example/sms/ │ ├── controller/ # Servlet:LoginServlet、StudentServlet、ClassServlet │ ├── service/ # 接口 + impl,业务编排 │ ├── dao/ # 接口 + impl,纯 SQL │ ├── entity/ # Student、User、ClassInfo、PageBean │ ├── filter/ # EncodingFilter、LoginFilter │ └── util/ # DBUtil、MD5Util、StringUtil ├── resources/ │ └── db.properties # 数据库四要素,别写死在代码里 └── webapp/ ├── WEB-INF/ │ ├── web.xml # 过滤器顺序、欢迎页 │ └── lib/ # 手动部署时三方 jar 必须放这里 ├── jsp/ # list.jsp、edit.jsp、login.jsp ├── css/ js/ img/ └── index.jsp一次“查询学生列表”的请求,链路是这样的:浏览器发起GET /sms/student?action=list&pageNo=2→EncodingFilter设置编码 →LoginFilter校验 session →StudentServlet解析action分发到list()→StudentServiceImpl调StudentDao.findByPage()和count()→DBUtil从连接池取连接执行 SQL → 结果封装成PageBean塞进 request →forward到list.jsp→ JSTL 遍历渲染。理解这条线,后面所有排查都有落点。
控制层的分发我一般这么写,一个 Servlet 管一个实体的所有操作,避免 Servlet 类爆炸:
@WebServlet("/student") public class StudentServlet extends HttpServlet { // Service 做成无状态单例即可,Servlet 本身也是单例,别在这里存成员状态 private final StudentService studentService = new StudentServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); // 查询类请求统一走 doPost 的分发逻辑 } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.trim().isEmpty()) { action = "list"; // 直接访问 /student 时兜底到列表页 } switch (action) { case "list": list(req, resp); break; case "toEdit": toEdit(req, resp); break; case "save": save(req, resp); break; case "update": update(req, resp); break; case "delete": delete(req, resp); break; default: resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "未知的 action: " + action); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword = req.getParameter("keyword"); int pageNo = parseInt(req.getParameter("pageNo"), 1); int pageSize = parseInt(req.getParameter("pageSize"), 10); PageBean<Student> page = studentService.findByPage(keyword, pageNo, pageSize); req.setAttribute("page", page); req.setAttribute("keyword", keyword); // 回填搜索框,否则翻页后条件丢了 req.getRequestDispatcher("/jsp/student/list.jsp").forward(req, resp); } private int parseInt(String s, int def) { try { return Integer.parseInt(s); } catch (Exception e) { return def; } } }action走的是请求参数而不是路径,好处是表单提交和链接跳转能共用一套;坏处是 URL 不够 RESTful,但对课程设计来说可读性更重要。parseInt那个私有方法看着土,却是必须的——用户手改 URL 把pageNo改成abc,没有兜底就是 500。forward而不是sendRedirect,是因为要把PageBean放进 request 域,重定向会丢。
3. 核心模块拆解:登录鉴权、学生 CRUD、分页检索三条主线
3.1 登录与会话拦截:从密码校验到 LoginFilter
登录这块最容易出两个问题:密码明文入库,以及只在前端做了校验。正确做法是密码存 MD5(课程设计级别够用,真实项目必须加盐或用 BCrypt),后端必须再校验一次。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); // 空值兜底:别把 null 直接传进 DAO 拼 SQL if (username == null || password == null || username.trim().isEmpty() || password.isEmpty()) { req.setAttribute("msg", "用户名和密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 前端传明文,后端转 MD5 再比对 User user = userService.login(username.trim(), MD5Util.md5(password)); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // session 里只放必要字段,别把带密码的整个 User 塞进去 user.setPassword(null); HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30 分钟无操作失效 // 登录成功用重定向,防止刷新重复提交表单 resp.sendRedirect(req.getContextPath() + "/student?action=list"); } }这里有个细节:失败用forward,成功用sendRedirect。失败要保留msg给页面显示,成功要避免浏览器刷新时重复 POST。req.getContextPath()一定要拼,否则项目部署在/sms而不是根路径时,跳转会跑到容器默认页去,这个坑后面还会提。
拦截器我写成注解版,但顺序不可控,所以编码过滤器必须靠web.xml保证在登录过滤器之前执行:
@WebFilter(urlPatterns = "/*") public class LoginFilter implements Filter { // 白名单:登录页、登录接口、静态资源一律放行 private static final String[] WHITE = {"/login.jsp", "/login", "/css/", "/js/", "/img/"}; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // getRequestURI 带 contextPath,截掉之后才是应用内路径 String path = req.getRequestURI().substring(req.getContextPath().length()); for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); return; } } // 传 false:没有 session 就不要新建,否则每次请求都造一个空会话 HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }getSession(false)这个参数很关键。默认的getSession()等价于getSession(true),未登录用户访问任意页面都会新建一个 session,几十个爬虫扫一遍就能把内存顶上去。白名单用startsWith而不是equals,是为了让/css/、/js/下所有文件一次覆盖。
3.2 学生信息的增删改查:一个 Servlet 分发多个 action
新增和修改可以合成一个save分支,靠id是否为空判断,这样前端只维护一张表单。但我不建议这么做——新增和修改的字段校验规则经常不一样(新增要查学号是否重复,修改要排除自己),拆成两个方法反而清爽。
DAO 层的关键是用PreparedStatement占位符,绝不用字符串拼接。这既是安全要求(防 SQL 注入),也是性能要求(同一条 SQL 可以被数据库缓存执行计划)。
public class StudentDaoImpl implements StudentDao { @Override public int insert(Student s) { String sql = "INSERT INTO student(sno, name, gender, phone, major, class_name) " + "VALUES (?, ?, ?, ?, ?, ?)"; // try-with-resources 保证连接、语句、结果集三层都能关掉 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, s.getSno()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setString(4, s.getPhone()); ps.setString(5, s.getMajor()); ps.setString(6, s.getClassName()); return ps.executeUpdate(); // 返回受影响行数,Service 用它判断成败 } catch (SQLException e) { throw new DaoException("新增学生失败, sno=" + s.getSno(), e); } } @Override public Student findBySno(String sno) { String sql = "SELECT * FROM student WHERE sno = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, sno); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapRow(rs) : null; } } catch (SQLException e) { throw new DaoException("按学号查询失败", e); } } // 结果集到实体的映射集中在一处,字段名改了只改这里 private Student mapRow(ResultSet rs) throws SQLException { Student s = new Student(); s.setId(rs.getInt("id")); s.setSno(rs.getString("sno")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setPhone(rs.getString("phone")); s.setMajor(rs.getString("major")); s.setClassName(rs.getString("class_name")); s.setCreateTime(rs.getTimestamp("create_time")); return s; } }DaoException是自己包的一层运行时异常,作用是把SQLException这种受检异常挡在 Service 之外,让上层不必写一堆throws。构造里把原始异常作为cause传进去,日志打出来能看到完整的Caused by链路,排查时是救命稻草。
删除要做二次确认,前端confirm()只是体验,后端仍然要校验id合法性:
private void delete(HttpServletRequest req, HttpServletResponse resp) throws IOException { int id = parseInt(req.getParameter("id"), -1); if (id <= 0) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "id 非法"); return; } studentService.deleteById(id); // 删完回列表,带上原来的页码和搜索词,体验上少一次手动翻页 resp.sendRedirect(req.getContextPath() + "/student?action=list&pageNo=" + req.getParameter("pageNo") + "&keyword=" + req.getParameter("keyword")); }重定向把pageNo和keyword带上是个小技巧,不然删掉第 3 页最后一条记录后会被弹回第 1 页,用户得重新点。注意这里keyword是中文,重定向后要靠编码过滤器处理,否则会变乱码,5.1 会专门讲。
3.3 分页与条件检索:PageBean 和两条 SQL
分页的核心就两条 SQL:一条count(*)算总数,一条LIMIT offset, size取当页数据,两条必须用完全相同的 where 条件。见过太多项目 count 忘了带搜索条件,导致总共 3 条数据却显示 10 页。
PageBean只做数据承载和派生属性计算,不碰数据库:
public class PageBean<T> { private int pageNo = 1; // 当前页码,从 1 开始 private int pageSize = 10; // 每页条数 private int total; // 总记录数 private List<T> rows; // 当页数据 public int getTotalPages() { // 向上取整,total=0 时返回 0 页 return total == 0 ? 0 : (total + pageSize - 1) / pageSize; } public boolean isHasPrev() { return pageNo > 1; } public boolean isHasNext() { return pageNo < getTotalPages(); } public int getOffset() { // 直接给 DAO 用,避免各处重复算 return (pageNo - 1) * pageSize; } // getter / setter 省略 }getTotalPages()用(total + pageSize - 1) / pageSize而不是Math.ceil,是为了避免 double 精度问题;total == 0单独判,返回 0 页比返回 1 页更符合直觉,页面上也不会出现“第 1 页 / 共 0 页”。
DAO 里条件拼装要注意,WHERE 1=1是常用写法,省去判断第一个条件要不要加AND:
@Override public List<Student> findByPage(String keyword, int pageNo, int pageSize) { StringBuilder sql = new StringBuilder( "SELECT * FROM student WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { // 姓名或学号模糊匹配,占位符用 ?,不要拼字符串 sql.append("AND (name LIKE ? OR sno LIKE ?) "); String like = "%" + keyword.trim() + "%"; params.add(like); params.add(like); } sql.append("ORDER BY id DESC LIMIT ?, ?"); params.add((pageNo - 1) * pageSize); // offset 从 0 开始 params.add(pageSize); List<Student> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); // 下标从 1 开始,容易错 } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } catch (SQLException e) { throw new DaoException("分页查询失败", e); } return list; }ps.setObject(i + 1, ...)那个+1是 JDBC 下标从 1 开始的约定,写错就是“参数索引超出范围”,报错信息还很含糊。列表页用 JSTL 渲染,不在 JSP 里写<% %>脚本:
<c:forEach items="${page.rows}" var="s" varStatus="st"> <tr> <td>${st.index + page.offset + 1}</td> <td>${s.sno}</td> <td>${s.name}</td> <td>${s.gender}</td> <td>${s.className}</td> <td> <a href="${pageContext.request.contextPath}/student?action=toEdit&id=${s.id}">编辑</a> <a href="javascript:del(${s.id})">删除</a> </td> </tr> </c:forEach>${st.index + page.offset + 1}让序号跨页连续,而不是每页都从 1 开始,这个小细节在演示时加分不少。
4. 跑起来:JDK、Tomcat、MySQL 的配置顺序与验证点
4.1 环境版本对齐与依赖清单
跑不起来的项目,八成是版本没对齐。这套组合我建议直接锁死:JDK 8(Servlet 3.1 的甜点区,Tomcat 9 也支持)、Tomcat 8.5 或 9.0(不要用 Tomcat 10,它把包名从javax.servlet换成了jakarta.servlet,老代码全部编译不过)、MySQL 5.7 或 8.0。
Maven 依赖最少这几样,scope必须是provided,因为 Tomcat 自带 Servlet API:
<dependencies> <!-- Servlet API 由容器提供,打进 war 反而冲突 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL 必须打进 war,容器不带 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> </dependencies>不写 Maven 的手动工程,就把jstl-1.2.jar、mysql-connector-java-8.0.28.jar、druid-1.2.8.jar一起扔进WEB-INF/lib。光在 IDEA 里 Add as Library 是不够的,那只影响编译期,运行时容器找不到,这是 5.3 那条坑的根源。
4.2 建库建表与初始化数据
先把库和表建好再谈启动,脚本一次跑完:
CREATE DATABASE IF NOT EXISTS sms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE sms; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(20) NOT NULL COMMENT '姓名', gender CHAR(1) DEFAULT '男', phone VARCHAR(20), major VARCHAR(50), class_name VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password CHAR(32) NOT NULL COMMENT 'MD5 32 位', role TINYINT DEFAULT 0 COMMENT '0 管理员 1 普通用户' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 初始管理员,密码明文是 admin,MD5 后是下面这串 INSERT INTO sys_user(username, password, role) VALUES ('admin', '21232f297a57a5a743894a0e4a801fc3', 0); INSERT INTO student(sno, name, gender, phone, major, class_name) VALUES ('20230101', '张三', '男', '13800000001', '软件工程', '软工2301'), ('20230102', '李四', '女', '13800000002', '软件工程', '软工2301'), ('20230103', '王五', '男', '13800000003', '计算机科学', '计科2301');字符集必须是utf8mb4,utf8在 MySQL 里是老的三字节版本,存 emoji 或部分生僻字会报错。连接串里也要同步写characterEncoding=utf8,两边不一致时中文会变成问号,而且不会报错,只是数据悄悄坏了。
配置文件放在resources/db.properties,不要写死在DBUtil里:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/sms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,不是 5.x 的com.mysql.jdbc.Driver;serverTimezone不配就会报时区错误,这是新手最常见的第一个 500。
DBUtil用 Druid 建池,静态块里加载一次:
public class DBUtil { private static DataSource DS; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties p = new Properties(); p.load(in); // 用类加载器读,打成 war 后路径才对 DruidDataSource ds = new DruidDataSource(); ds.setDriverClassName(p.getProperty("jdbc.driver")); ds.setUrl(p.getProperty("jdbc.url")); ds.setUsername(p.getProperty("jdbc.username")); ds.setPassword(p.getProperty("jdbc.password")); ds.setInitialSize(5); // 初始连接数 ds.setMaxActive(20); // 最大连接数,课程设计够用 ds.setMaxWait(3000); // 取不到连接最多等 3 秒 ds.setValidationQuery("SELECT 1"); // 防止拿到被 MySQL 断掉的死连接 DS = ds; } catch (Exception e) { throw new ExceptionInInitializerError("数据源初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DS.getConnection(); } }getResourceAsStream用的是类加载器,读的是 classpath,Maven 打完包db.properties在WEB-INF/classes下,正好能被读到;用new FileInputStream("db.properties")则依赖工作目录,换台机器就找不到。validationQuery和maxWait这两行,是服务器跑了一夜第二天第一个请求必然失败的免疫针。
4.3 部署路径、访问地址与第一轮冒烟验证
IDEA 里配 Tomcat 时,Deployment那一栏的Application context建议显式写成/sms,别用默认的/。定了这个前缀,所有页面里的跳转都必须带${pageContext.request.contextPath},代码里用req.getContextPath(),两边保持一致。用默认/在本地很舒服,一换环境就全挂。
启动后按这个顺序验证,每步都能定位到具体层:
- 访问
http://localhost:8080/sms/login.jsp,页面能出来说明静态资源和 JSP 编译没问题。 - 用错误密码登录,页面应该显示“用户名或密码错误”,说明 Servlet 路由和 Service 调用通了。
- 用
admin/admin登录,跳转到列表页,说明 session 和重定向都对。 - 新增一个学生,刷新列表能看到,说明 DAO 写入和事务提交正常。
- 搜索框输入中文姓名,能查出来,说明编码链路完整。
- 停掉 MySQL 再点查询,页面应该报友好错误而不是白屏,说明异常处理到位。
第 6 步很多人不做,但它是答辩时最容易被追问的点:“数据库挂了你页面怎么办”。有统一的错误页,回答起来就从容。
5. 避坑与排查:这套组合最容易翻车的五个地方
5.1 中文乱码:GET、POST、响应体三个位置各有一处
现象:表单里输入“张三”,存进数据库变成“??”,或者查询时搜不到但数据库里明明有。
原因:Tomcat 8 之后 GET 默认已经是 UTF-8,但 POST 请求体仍按 ISO-8859-1 解析;响应头没设Content-Type时浏览器按本地编码猜;数据库连接串没写characterEncoding,三层里任何一层断链都会乱。
解决:过滤器统一设请求和响应编码,且必须排在登录过滤器之前。
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 必须在取参数前 resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }注解式过滤器的执行顺序由容器决定,不保证先后。稳妥做法是在web.xml里用<filter-mapping>显式排序,EncodingFilter写在LoginFilter前面。另外 JSP 页面头部<%@ page contentType="text/html;charset=UTF-8" %>不能省。
5.2 404 和 500:先看日志的第一行 Caused by
现象:访问/student?action=list报 404,或者页面直接 500 白屏。
原因:404 通常是路径问题——@WebServlet("/student")少了前导斜杠,或者前端链接没拼contextPath,或者web.xml里metadata-complete="true"导致注解不被扫描;500 则要看异常栈,最常见的是空指针和 SQL 语法错。
解决:404 先确认三件事,访问地址里的应用前缀、Servlet 注解的值、web.xml的metadata-complete属性。500 不要盯着页面看,去 Tomcat 控制台找第一个Caused by,那是真正的错误位置,后面的栈都是包装出来的。我习惯在web.xml里配一个全局错误页,把异常信息打到日志而不是浏览器:
<error-page> <error-code>500</error-code> <location>/jsp/error/500.jsp</location> </error-page>5.3 依赖没进 WEB-INF/lib:编译通过、运行报 ClassNotFound
现象:IDEA 里代码全是绿的,右键运行,控制台抛java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。
原因:IDEA 的Add as Library只把 jar 加进了编译类路径,Maven 的provided也不会打包进 war。运行时是 Tomcat 的类加载器在找类,它只看WEB-INF/lib和WEB-INF/classes。
解决:非 Maven 工程手动把 jar 复制进WEB-INF/lib并在Project Structure → Artifacts里确认输出包含它们;Maven 工程把驱动、Druid、JSTL 的scope去掉或设为compile,然后mvn clean package重新打 war。验证方法很直接:解压 war 看WEB-INF/lib里有没有那几个 jar,没有就是没打进去。
同一类问题还有 JSTL:页面上的<c:forEach>原样输出成文本,不是循环,就是jstl-1.2.jar没进 lib,或者页面忘了<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。
5.4 连接没关:Tomcat 跑半天就卡死
现象:刚启动一切正常,点了几十次之后页面越来越慢,最后所有请求都卡住,日志里出现wait millis 3000, active 20。
原因:Connection、PreparedStatement、ResultSet有任何一个没关,连接就回不到池里。池子最大 20 个,漏 20 次之后就再也拿不到连接了。
解决:所有 JDBC 操作无条件用 try-with-resources,三层资源逐层声明:
try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } catch (SQLException e) { throw new DaoException("查询失败", e); }三个资源在同一个 try 里声明,关闭顺序是rs → ps → conn,正好和打开顺序相反,不会出现“连接关了结果集还在读”的问题。检查办法:把 Druid 的maxActive临时改成 1,跑一遍所有功能,能全部通过说明没有泄漏。
5.5 分页总数和列表对不上:count 和 limit 用了两套条件
现象:搜索“张三”,列表只显示 1 条,但页面底部写着“共 50 条 / 共 5 页”,翻到第 2 页是空的。
原因:count(*)那条 SQL 忘了拼搜索条件,只统计了全表;而LIMIT那条拼了。两个 DAO 方法各拼各的,改一处漏一处。
解决:把 where 子句和参数列表抽成一个共用方法,两条 SQL 都调它。
private String buildWhere(String keyword, List<Object> params) { StringBuilder where = new StringBuilder(" WHERE 1=1 "); if (keyword != null && !keyword.trim().isEmpty()) { where.append("AND (name LIKE ? OR sno LIKE ?) "); String like = "%" + keyword.trim() + "%"; params.add(like); params.add(like); } return where.toString(); }count用"SELECT COUNT(*) FROM student" + buildWhere(...),列表用"SELECT * FROM student" + buildWhere(...) + " ORDER BY id DESC LIMIT ?, ?"。条件只有一处定义,参数列表也是同一个,物理上不可能再错位。这个坑我踩过一次,调了四十分钟,最后发现是复制粘贴时少拼了一个if。
6. 二次开发:把它从「能跑」推到「能讲、能加需求」
课程设计的代码大多有个共同毛病:每个 DAO 里都重复写着取连接、设参数、关资源,十张表就有十份一模一样的模板。想加张表就得复制一遍,改连接池配置要动十个文件。
我一般第一步先抽一个泛型基类,把重复部分收干:
public abstract class BaseDao<T> { // 查询列表:子类只需给 SQL、参数和行映射规则 protected List<T> queryList(String sql, RowMapper<T> mapper, Object... params) { List<T> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapper.map(rs)); } } } catch (SQLException e) { throw new DaoException("查询失败: " + sql, e); } return list; } // 增删改:返回受影响行数 protected int update(String sql, Object... params) { /* 同上,略 */ } @FunctionalInterface public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; } }抽完之后StudentDaoImpl只剩 SQL 和一行mapRow:
public List<Student> findByPage(String kw, int pageNo, int pageSize) { List<Object> params = new ArrayList<>(); String where = buildWhere(kw, params); params.add((pageNo - 1) * pageSize); params.add(pageSize); return queryList("SELECT * FROM student" + where + " ORDER BY id DESC LIMIT ?, ?", this::mapRow, params.toArray()); }RowMapper用函数式接口加方法引用,this::mapRow直接把现成的映射方法传进去,不用写匿名内部类。这一改,新增一个“班级管理”模块大概只要写三个文件、二十分钟。
第二步是加权限分级。sys_user表里已经有role字段,插入点就在LoginFilter里:放行之后判断role,普通用户禁掉/student?action=delete这类写操作。别在前端用c:if藏按钮就当权限做了,改个 URL 照样能删。
验证方法我用一个笨但有效的办法:改完立刻反向验证。抽完 BaseDao,先故意把某条 SQL 写错一个字段名,确认异常栈能准确定位到是哪个 DAO 的哪一行;再跑一遍 5.4 那个“把 maxActive 改成 1”的测试,确认没有新增泄漏。改造最怕的是“看起来能跑”,实际埋了新坑,等答辩当天才炸。
从那以后我每次动这类老项目的持久层,都强制先跑一遍「单连接压测 + 错误 SQL 定位」这两步,再去看业务页面。这套流程谈不上高明,但救过我至少两次现场演示。希望帮到你。
本文还有配套的精品资源,点击获取