
简介一套基于 Java MVC 架构的学生选课系统源码包面向正在学习 Java Web 开发或需要课程设计参考的开发者完整展示了 JSP 实现 MVC 三层框架的落地方式。压缩包可部署于 Tomcat 等 Java Web 服务器包含学生端和管理员端两条主线覆盖登录注册、随机验证码、课程浏览、选课退选、后台用户管理、课程信息增删改查等可运行功能模块结构上按 servlets、javabeans、services、util 分层便于理解模型、视图与控制器的边界。资源共 82 个文件以 29 个 JSP 页面、19 个 Java 源文件、19 个 class 编译文件为主另有 XML 配置、数据文件、图片和 Eclipse 工程文件整体约 320KB是一个能直接导入开发工具的小型完整项目。目前已有 190 人学习下载。读者可以通过这套源码同时学习 JSP 动态页面、Servlet 请求处理、JavaBean 数据封装和工具类复用的配合方式也能从登录校验、验证码、分页浏览等细节中积累排错与编码经验在本地部署运行后还可对照页面效果反推各层职责是快速入门 Java Web 与 MVC 开发的实用素材。1. Java MVC 选课系统到底要解决什么问题看到“myweb.rar_java MVC_mvc_mvc.zip_mvc选课_选课系统 java”这种命名第一反应就是课程设计而且是 Java Web 方向最常见的 JSP Servlet JavaBean 形态。它要解决的问题很具体让学生登录、浏览课程列表、选课、退课再让管理员维护课程数据。难点从来不在功能多少而在“用 MVC 把页面、业务逻辑、数据访问拆开”拆好了后面加学分统计、导出名单都顺手拆不好就是一个 2000 行的 JSP 文件跑起来没问题答辩一问就露馅。这篇文章不讲 Spring Boot 那套全家桶就沿着大多数课程设计真正使用的原生 Servlet JSP 路径把三层架构边界、数据库设计、核心代码、常见踩坑一次说清楚。新手能照着落地上交熟手也能当一份快速检核清单。2. 选课系统的 MVC 拆分与数据表设计先定边界再定三张表2.1 三个层到底在哪切Servlet 控制层、Service 业务层、JDBC 数据层很多同学把 MVC 理解成“有 JSP 就是视图有 Servlet 就是控制器弄个 JavaBean 就是模型”这没错但太粗。一个可以答辩的 MVC 选课系统至少要把下面四个角色分清楚JSP 只负责展示和收集输入里面不写 Java 的小脚本% %Servlet 只做参数接收、调用业务、决定跳转哪个页面不直接写 SQLService 层存放业务规则比如选课前判断课程容量、判断是否重复选课DAO 层只做数据库的增删改查返回对象或集合。这么切的原因很现实一是答辩时老师大概率会问“选课容量校验写在哪儿”如果你回答“写在 JSP 里”印象分会受影响。二是后续改需求比如要加一个“选课时间窗口”你只需要改 Service不用把所有页面翻一遍。我见过的课程设计代码里最痛苦的不是功能实现不了而是所有逻辑堆在 Servlet 里一个方法三四百行改一个查询条件要连带改三个文件。体现到包结构上建议直接按层建包com.myweb.servlet // 控制器 com.myweb.service // 业务逻辑 com.myweb.dao // 数据访问 com.myweb.entity // 学生、课程、选课记录实体 com.myweb.util // 数据库连接工具、过滤器写代码时严格要求上层只依赖下层Servlet 依赖 ServiceService 依赖 DAO不反向调用。这个约定一开始很别扭但改两天需求之后你会感谢它。2.2 三张核心表的设计学生、课程、选课记录选课系统的业务本质就是“学生”和“课程”之间的多对多关系所以要拆成三张表学生表、课程表、选课记录表。选课记录是中间表它承担了关系维护和业务扩展两种职责不仅可以记录“谁选了哪门课”还能加选课时间、成绩等后续字段。常见设计如下-- 学生表 CREATE TABLE student ( student_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, real_name VARCHAR(32) NOT NULL, major VARCHAR(64), grade VARCHAR(16) ); -- 课程表 CREATE TABLE course ( course_id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(64) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0, teacher VARCHAR(32), capacity INT NOT NULL DEFAULT 50, selected_count INT NOT NULL DEFAULT 0 ); -- 选课记录表 CREATE TABLE course_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) );这里有三个关键点。第一course_selection表必须加UNIQUE KEY这是避免重复选课的第一道防线后面讲并发问题还要再说一次。第二selected_count这个冗余字段是故意加的它让“判断容量”变成一次普通查询不用每次COUNT选课记录。第三密码字段不要明文存储课程设计阶段可以用 SHA-256 摘要至少比明文看起来专业。选课记录表的外键我建议不加物理外键约束逻辑上保持关联即可。原因是删除课程、批量导入数据时物理外键会带来一堆麻烦而且课程设计阶段老师更看重逻辑设计而不是把数据库搞得难以改动。2.3 DAO 层的最小封装省掉重复的 JDBC 代码早年写 JDBC 最痛苦的就是大量重复代码获取连接、创建PreparedStatement、遍历ResultSet、关闭资源。课程设计里我一般做一个极简封装用泛型和RowMapper把查询结果映射成实体对象让调用方只关心 SQL 和映射逻辑。public class BaseDao { protected T ListT query(String sql, RowMapperT rowMapper, 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(rowMapper.map(rs)); } } } catch (SQLException e) { throw new RuntimeException(查询失败: sql, e); } return list; } protected int update(String sql, Object... params) { try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(更新失败: sql, e); } } }接口RowMapper自己定义就行只有一个方法T map(ResultSet rs) throws SQLException。这样写的好处是后续 CourseDao 再查询课程列表只需要关注“把 ResultSet 的列映射到 Course 对象”这一件事。注意代码里的try-with-resources语法它保证了连接、语句、结果集在方法结束后自动关闭。如果你是 JDK 7 以下版本需要手动在finally里关闭资源不过现在课程设计基本都用 JDK 8 以上了。这段封装有一个明显的边界query和update各自独立获取连接导致它不能直接支持事务因为两次调用的连接不是同一个。事务问题我会在第三章专门处理。3. 核心流程复现登录、选课、退课怎么穿透三层3.1 编码过滤器所有请求的默认值就是 UTF-8中文乱码在选课系统里几乎是必现问题根源是 Tomcat 默认按 ISO-8859-1 解析请求参数。解决思路不是在每个 Servlet 里写req.setCharacterEncoding(UTF-8)而是用一个过滤器统一处理。下面这个过滤器可以放在com.myweb.filter包下WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }WebFilter(/*)表示拦截所有请求路径 filter 不写业务逻辑只做预处理然后通过chain.doFilter把请求放行。这里有个细节req.setCharacterEncoding(UTF-8)必须放在读取任何请求参数之前一旦参数被提前读取编码就来不及改了。如果你用 Tomcat 8 以上版本还需注意 GET 请求参数的编码由URIEncodingUTF-8控制在server.xml的 Connector 里配置即可和过滤器是两回事。3.2 登录流程一个请求从 Servlet 走到 DAO 的完整链路登录是选课系统最典型的“三层穿透”场景用它的完整流程演示最直观。先看控制器代码WebServlet(/login) public class LoginServlet extends HttpServlet { private StudentService studentService new StudentService(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); Student student studentService.login(username, password); if (student null) { req.setAttribute(errorMsg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } else { req.getSession().setAttribute(loginUser, student); resp.sendRedirect(req.getContextPath() /courseList); } } }这个 Servlet 只做三件事拿参数、调业务、决定跳转。注意失败时用的forward成功时用的sendRedirect。跳转方式有讲究登录失败时forward可以带着errorMsg回到登录页面回显错误登录成功后一定要sendRedirect避免用户刷新页面时表单被重复提交。Service 层的逻辑也很简单但要注意它不直接操作数据库而是调用 DAOpublic class StudentService { private StudentDao studentDao new StudentDao(); public Student login(String username, String password) { Student student studentDao.findByUsername(username); if (student ! null DigestUtils.sha256Hex(password).equals(student.getPassword())) { return student; } return null; } }DigestUtils.sha256Hex是 Apache Commons Codec 提供的方法如果项目里没有引入这个依赖也可以用 JDK 自带的MessageDigest替代。这里真正想强调的是密码比对放在 Service数据库查询只按用户名查这样就算密码泄露也只是密文。DAO 层就只剩一条查询语句public Student findByUsername(String username) { ListStudent list query( SELECT student_id, username, password, real_name, major, grade FROM student WHERE username ?, rs - { Student s new Student(); s.setStudentId(rs.getInt(student_id)); s.setUsername(rs.getString(username)); s.setPassword(rs.getString(password)); s.setRealName(rs.getString(real_name)); s.setMajor(rs.getString(major)); s.setGrade(rs.getString(grade)); return s; }, username); return list.isEmpty() ? null : list.get(0); }把RowMapper写成 Lambda 表达式是这一段的关键它让映射逻辑和查询逻辑分开看起来也很直观。注意?占位符的顺序必须和参数列表一致这里只有username一个参数不容易错。3.3 选课与退课事务边界一定要放在 Service 层选课操作涉及两步插入选课记录、更新课程已选人数。这两步如果分开执行中间一旦出错就会出现“记录插了但人数没加”或者反过来。所以必须放在同一个事务里。事务边界放哪里是一个高频考点正确做法是 Service 层开事务DAO 层只执行 SQL。public boolean chooseCourse(int studentId, int courseId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); CourseDao courseDao new CourseDao(); SelectionDao selectionDao new SelectionDao(); // 1. 检查课程容量并尝试插入选课记录受影响行数为 0 表示无容量或课程不存在 int inserted selectionDao.insertIfCapacityAvailable(conn, studentId, courseId); if (inserted 0) { conn.rollback(); return false; } // 2. 更新课程已选人数 int updated courseDao.increaseSelectedCount(conn, courseId); if (updated ! 1) { conn.rollback(); return false; } conn.commit(); return true; } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException(选课失败, e); } finally { closeQuietly(conn); } }注意这里的 DAO 方法都多了个Connection conn参数这和第二章的BaseDao不同因为事务要求两步操作使用同一个连接。如果你的 DAO 方法内部自己重新getConnection()那么事务会在一半时悄悄断开这就是很多人事务不生效的根源。insertIfCapacityAvailable的 SQL 可以直接用一条INSERT INTO ... SELECT把容量判断带进去INSERT INTO course_selection (student_id, course_id, select_time) SELECT ?, ?, NOW() FROM course WHERE course_id ? AND selected_count capacity这种写法的好处是数据库层面就把容量判断和插入操作合并成原子动作比先查后插更不容易出并发问题。退课逻辑正好相反先删选课记录再减人数同样放在 Service 层一个事务里思路一模一样。4. 视图层的正确写法JSTL 渲染、表单回显与分页4.1 为什么要在 JSP 里用 JSTL / EL课程设计里的页面通常不多登录页、课程列表页、已选课程页基本够用。但页面质量天差地别差别不在 CSS 好不好看而在 JSP 里有没有大段 Java 代码。用% for (...) { %写循环虽然也能跑页面一长Tomcat 编译 JSP 时就会很难受维护起来更是灾难。EL 表达式负责输出数据${student.realName}等价于request.getAttribute(student).getRealName()。JSTL 的c:forEach负责循环。两者配合可以把页面里的 Java 代码清干净。要用 JSTL 需要引入依赖在 JSP 顶部声明标签库引用然后就可以在页面里写标签了。很多人卡在这一步是因为jstl.jar没放进WEB-INF/lib或者 Tomcat 版本和 JSTL 版本不匹配后面避坑章节会详说。4.2 选课列表与表单回显的最小代码课程列表页是选课系统的门面。它要展示课程名称、学分、教师、容量和已选人数还要提供选课按钮。用 JSTL 写一个完整的表格并不长% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table thead tr th课程号/th th课程名/th th学分/th th教师/th th已选/容量/th th操作/th /tr /thead tbody c:forEach varcourse items${courseList} tr td${course.courseId}/td td${course.courseName}/td td${course.credit}/td td${course.teacher}/td td${course.selectedCount} / ${course.capacity}/td td form action${pageContext.request.contextPath}/choose methodpost input typehidden namecourseId value${course.courseId} / button typesubmit选课/button /form /td /tr /c:forEach /tbody /table三个细节值得说明。第一items${courseList}里的courseList是 Servlet 通过request.setAttribute(courseList, list)放进去的属性名必须完全一致。第二${pageContext.request.contextPath}是获取项目根路径的标准写法用它拼表单提交地址项目改名后页面不用改。第三用表单而不是a链接提交选课是为了避免用户不小心刷新页面时重复选课表单提交一般是 POST语义上也更合适。如果课程多还需要分页。分页的做法不复杂Servlet 接收pageNo参数传给 ServiceService 用LIMIT offset, pageSize查询当页数据再查总数计算总页数。页面底部放上一页和下一页的链接只需注意拼接查询条件时 URL 编码要处理即可。这算得上课程设计里比较亮眼的细节有精力的同学建议加上。5. 选课系统避坑从 404 到并发重复选课的 5 个排查点5.1 Servlet 明明存在却报 404现象源码里LoginServlet写得好好的启动 Tomcat 访问/login却 404。原因绝大多数情况下不是代码问题而是Servlet没有被容器识别。如果用的是WebServlet注解需要确认 Web 项目是否启用了注解扫描老版本 Eclipse 导出的 Web 项目有时候配置有问题。如果你用web.xml方式声明检查url-pattern是否写成了/login.do而请求路径是/login二者必须一致。解决先访问项目名/servlet页面确认有哪些映射再用curl -v看返回状态最后检查 Tomcat 的webapps目录里有没有把你最新改的 class 同步过去。排到后面就是玄学先看部署目录不要上来就改代码。5.2 页面和数据库的中文乱码一团糟现象页面显示“???”或者数据库里存入的是乱码。原因这类问题往往是同一批乱象叠加而成。Tomcat 解析请求参数用了一默认的编码JSP 页面本身没指定 UTF-8数据库表字符集是latin1三处只要有一处不是 UTF-8结果就是乱码。解决页面顶部加% page contentTypetext/html;charsetUTF-8 %表结构统一用utf8mb4JDBC URL 上追加characterEncodingutf8再配合第三章的过滤器。改完后重启 Tomcat别再问为什么改一处不行字符集链路只要有环节断开就会乱。5.3 事务没生效看着有 commit一断电数据就错现象选课操作偶尔出现“选课记录存在但已选人数没变”或者反过来。原因最常见的情况是 DAO 方法里自己获取了新的连接commit提交的是另一个连接上的操作或者是Conn.setAutoCommit(false)之后某一步抛了异常但没有rollback。这两个本质上都是事务边界设错了。解决把getConnection、setAutoCommit、commit、rollback、close全部收敛到 Service 层一个方法里DAO 只使用传入的连接执行 SQL。在代码里打log.info(conn conn)确认两次操作用的是同一个连接对象。5.4 并发下重复选课先查后插根本不安全现象两个学生同时选一门只剩 1 个名额的课最终俩人都选上了超员。原因代码写的是先SELECT查容量再INSERT选课记录。两个请求同时通过第一步查询都看到还有名额然后同时插入事务隔离级别默认的READ COMMITTED下这种竞态就会出现。解决在course_selection表上加UNIQUE KEY (student_id, course_id)挡住重复同时把容量判断写进INSERT ... SELECT WHERE selected_count capacity让数据库做原子判断。如果你的数据库已经上线修改表结构前先确认没有脏数据堵住唯一键。5.5 MySQL 8.0 驱动不匹配导致 ClassNotFoundException现象项目在本机跑得好好的换一台机器后启动直接报ClassNotFoundException: com.mysql.jdbc.Driver。原因项目原来用的是 MySQL 5.x 的驱动Class.forName写的是老驱动类名新机器装的是 MySQL 8.0驱动已经改名成com.mysql.cj.jdbc.Driver。这种问题看起来像代码问题实际是环境差异。解决统一用 MySQL 8.0 驱动类名改成新驱动JDBC URL 加useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue。这几个参数分别关掉 SSL 警告、修正时区、允许公钥检索缺哪个都会在运行时报新错。6. 从能跑到能答辩三个增强点与一套验收清单如果时间允许别急着交“能跑”的版本下面三个增强点投入产出比很高加了它们答辩时你说话底气完全不一样。增强点做法验收方式选课时间窗口在课程表增加start_time、end_time字段选课前在 Service 判断当前时间是否在窗口内手动改数据库时间验证窗口外选课被拒绝导出已选名单用 Servlet 输出 CSV 文件响应头设为application/octet-stream中文需加 UTF-8 BOM下载后用 Excel 打开中文不乱码且数字列完整访问日志在编码过滤器之后增加一个 Filter打印请求路径、耗时控制台看到每次选课/退课的耗时挑一个操作确认日志内容对应验收清单方面我一般按这五条过一遍第一学生登录、错误密码提示、退出登录是否都正常第二课程列表分页与筛选条件是否保留第三重复选课是否被拦截课程满员是否提示第四退课后人数是否回退数据库数据是否与页面一致第五直接访问/choose接口不携带参数时页面是否报 500。第五点很多同学忽略老师只要按一下 F12 就能发现。最后说一个我自己的习惯项目交出去前一定要把数据库脚本、部署说明和源码包放在同一个目录SQL 脚本里写好初始账号和密码。每次检查项目我都会先跑一遍“新机器能不能跑起来”的测试因为大多数翻车都发生在换环境之后。这个习惯救过我不少次课程设计和团队合作。希望帮到你。本文还有配套的精品资源点击获取