首页 / 资讯中心 / 文章详情

JavaWeb学生管理系统:Servlet+JSP+JDBC与MVC避坑

JavaWeb学生管理系统:Servlet+JSP+JDBC与MVC避坑 ★ FEATURED ARTICLE
简介这是一套面向高校计算机相关专业学生和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 怎么撑起一个 MVC2.1 为什么不用 Spring Boot以及三层架构的边界在哪课程设计场景下我一般不推荐上 Spring Boot。不是因为不好用而是因为它会把要讲的东西藏起来。自动配置把数据源、事务、视图解析器全接管了答辩时被问“这个请求进来先经过谁”答“框架自动处理的”是很掉分的。原生 Servlet JSP JDBC 的组合每一环都摆在明面上web.xml或WebServlet决定路由Filter决定拦截顺序Service决定事务边界DAO决定 SQL 长什么样。这套项目常见的技术栈大致如下版本不用卡死但同一个项目里别混层次技术选型在项目里的职责视图层JSP JSTL EL只做渲染不写 Java 逻辑脚本控制层Servlet 3.1WebServlet收参数、校验、调 Service、转发或重定向业务层普通 Java 类Service Impl事务、业务校验、多 DAO 编排持久层原生 JDBC PreparedStatementSQL 与结果集映射连接池Druid 或 C3P0复用连接避免每次DriverManager建连数据库MySQL 5.7 / 8.0学生表、用户表、班级表容器Tomcat 8.5 / 9.0Servlet 容器注意与 JDK 版本匹配前端Bootstrap jQuery表格、模态框、表单校验分层的边界要守住一条Servlet 里不写 SQLDAO 里不出现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/ # ServletLoginServlet、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?actionlistpageNo2→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); PageBeanStudent 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?actionlist); } }这里有个细节失败用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?actionlistpageNo 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 PageBeanT { private int pageNo 1; // 当前页码从 1 开始 private int pageSize 10; // 每页条数 private int total; // 总记录数 private ListT rows; // 当页数据 public int getTotalPages() { // 向上取整total0 时返回 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 11是常用写法省去判断第一个条件要不要加ANDOverride public ListStudent findByPage(String keyword, int pageNo, int pageSize) { StringBuilder sql new StringBuilder( SELECT * FROM student WHERE 11 ); ListObject 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); ListStudent 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} vars varStatusst 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?actiontoEditid${s.id}编辑/a a hrefjavascript:del(${s.id})删除/a /td /tr /c:forEach${st.index page.offset 1}让序号跨页连续而不是每页都从 1 开始这个小细节在演示时加分不少。4. 跑起来JDK、Tomcat、MySQL 的配置顺序与验证点4.1 环境版本对齐与依赖清单跑不起来的项目八成是版本没对齐。这套组合我建议直接锁死JDK 8Servlet 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 APIdependencies !-- Servlet API 由容器提供打进 war 反而冲突 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSTL 必须打进 war容器不带 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 普通用户 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 初始管理员密码明文是 adminMD5 后是下面这串 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);字符集必须是utf8mb4utf8在 MySQL 里是老的三字节版本存 emoji 或部分生僻字会报错。连接串里也要同步写characterEncodingutf8两边不一致时中文会变成问号而且不会报错只是数据悄悄坏了。配置文件放在resources/db.properties不要写死在DBUtil里jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/sms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password123456MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver不是 5.x 的com.mysql.jdbc.DriverserverTimezone不配就会报时区错误这是新手最常见的第一个 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用的是类加载器读的是 classpathMaven 打完包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;charsetUTF-8); chain.doFilter(req, resp); } }注解式过滤器的执行顺序由容器决定不保证先后。稳妥做法是在web.xml里用filter-mapping显式排序EncodingFilter写在LoginFilter前面。另外 JSP 页面头部% page contentTypetext/html;charsetUTF-8 %不能省。5.2 404 和 500先看日志的第一行 Caused by现象访问/student?actionlist报 404或者页面直接 500 白屏。原因404 通常是路径问题——WebServlet(/student)少了前导斜杠或者前端链接没拼contextPath或者web.xml里metadata-completetrue导致注解不被扫描500 则要看异常栈最常见的是空指针和 SQL 语法错。解决404 先确认三件事访问地址里的应用前缀、Servlet 注解的值、web.xml的metadata-complete属性。500 不要盯着页面看去 Tomcat 控制台找第一个Caused by那是真正的错误位置后面的栈都是包装出来的。我习惯在web.xml里配一个全局错误页把异常信息打到日志而不是浏览器error-page error-code500/error-code location/jsp/error/500.jsp/location /error-page5.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 prefixc urihttp://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, ListObject params) { StringBuilder where new StringBuilder( WHERE 11 ); 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 BaseDaoT { // 查询列表子类只需给 SQL、参数和行映射规则 protected ListT queryList(String sql, RowMapperT mapper, Object... params) { ListT 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 RowMapperT { T map(ResultSet rs) throws SQLException; } }抽完之后StudentDaoImpl只剩 SQL 和一行mapRowpublic ListStudent findByPage(String kw, int pageNo, int pageSize) { ListObject 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?actiondelete这类写操作。别在前端用c:if藏按钮就当权限做了改个 URL 照样能删。验证方法我用一个笨但有效的办法改完立刻反向验证。抽完 BaseDao先故意把某条 SQL 写错一个字段名确认异常栈能准确定位到是哪个 DAO 的哪一行再跑一遍 5.4 那个“把 maxActive 改成 1”的测试确认没有新增泄漏。改造最怕的是“看起来能跑”实际埋了新坑等答辩当天才炸。从那以后我每次动这类老项目的持久层都强制先跑一遍「单连接压测 错误 SQL 定位」这两步再去看业务页面。这套流程谈不上高明但救过我至少两次现场演示。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站