简介:这是一套基于Java MVC架构的学生选课系统完整项目包,适合正在学习Java Web开发、需要课程设计或毕设参考的开发者。系统以JSP + Servlet + JavaBean实现经典三层架构,业务逻辑、数据访问与视图展示分离,覆盖学生登录、课程浏览、选课退课、已选课程查询,以及管理员的课程维护、用户管理等完整流程。压缩包共82个文件,包括29个JSP页面、19个Java源码与对应class文件,还有用于存储课程数据的dat文件、Web配置用的XML以及界面图片等,整体仅320KB,目录按src、WebContent、classes等标准结构组织,便于快速部署和逐模块阅读。目前已有190人学习下载,适合借此理解MVC分层设计、JSP动态页面生成、Servlet控制器跳转,以及表单校验、随机验证码等实战细节。将项目部署到Tomcat并配置好数据源后,就能运行体验完整的学生选课管理流程,为后续独立开发类似Web系统提供良好的起步模板。
1. 一个名为 myweb.rar 的 Java MVC 选课系统,到底值不值得解压研究
很多做课程设计或毕业设计的同学,都在网盘里见过 myweb.rar 或者“mvc选课系统 java”“mvc.zip”这种命名随意的压缩包。解压后你会看到一堆 .java、.jsp、.xml 和零散的 jar 包,导入 IDEA 后不是缺依赖就是启动报错。其实这类项目背后几乎都是同一个套路:用 Java MVC 三层架构做出来的选课系统,前端 JSP 展示,Controller 接收请求,Service 处理选课规则,DAO 访问数据库。它不高级,但业务完整,适合拿来学 MVC、做课设,也适合你把它改造成简历里的真实项目。这篇文章会用一个最小可运行的 Spring MVC + MyBatis 工程,带着你把选课系统跑通,并把参数、坑位、改造思路一次讲明白。
2. 先把 MVC 架子搭起来:选课系统的分层设计与核心流转
2.1 为什么选课系统用 MVC 而不是直接 JSP 写逻辑
早几年的学生项目很喜欢在 JSP 里直接写 JDBC 和 if/else,页面顶部全是<% %>,看起来也能跑,但一旦需求变了,比如加一个“限选 3 门课”,你得在所有写着选课逻辑的页面里找,改一处漏一处。选课系统虽然业务不算复杂,但牵扯学生、课程、选课记录三张表的联动,还有“课程满员”“不能重复选”这些规则,如果用 JSP 一把梭,调试一个 bug 的时间够你重新写一遍。
MVC 的做法是把请求处理拆成三层:Controller 负责接收参数、调用业务、返回视图;Service 负责业务规则,比如满员判断、重复判断;DAO/Mapper 负责 SQL 读写。这样每一层的职责非常清晰,Controller 不需要知道 SQL 怎么写,Service 不需要管页面长什么样。你给答辩老师讲“为什么用 MVC”的时候,可以用一个具体例子:课程满员了,前端要提示“选课失败”,这时候只改 Service 里的规则,页面和数据库都不用动。这才是分层带来的实际价值。
从 myweb.rar 这类包里,你能看到的最好的东西就是这种分层痕迹。如果解压后发现一个 Servlet 类里又是 JSON 输出又是 JDBC 操作,那说明原作者根本没有按 MVC 写,那种项目改造起来比重写还累。
2.2 最小可跑的 Spring MVC 工程:依赖、配置与启动
我选择 Spring MVC + MyBatis 这套组合来讲,因为这是 Java 工程师日常接触最多的 Web 框架组合,也是把“MVC”三个字母落到代码里最标准的姿势。你先创建一个 Maven Web 工程,pom.xml 里最关键的依赖长这样:
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.27</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> <scope>runtime</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies>这里有两个版本上的选择理由。Spring 选 5.3.x 而不是 6.x,因为 Spring 6 强制 JDK 17,而很多课程设计的部署机器还停留在 JDK 8 或 11,没必要为了追新把自己卡在环境上。mysql-connector 选 8.0.x 是因为 5.x 的驱动类名过时,配置也麻烦。依赖里的 servlet-api 必须带上 provided 作用域,否则 Tomcat 加载时会和它自己带的版本冲突,容器都起不来。
Maven 编译插件也要锁 JDK 版本,不然 IDEA 默认的编译级别可能和你的 JDK 不一致,编译倒是过了,运行时提示 UnsupportedClassVersionError:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.10.1</version> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> </build>encoding 参数一样值得写死。Maven 在 Windows 上默认使用 GBK 编译,如果不指定 UTF-8,带中文注释的源码编译后会乱码,那种问题看起来像代码错误,实际是编码问题。
然后配置 DispatcherServlet,这是 Spring MVC 的入口。我用 web.xml 的方式来配,兼容性最好,也方便你在老项目里对照:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/dispatcher-servlet.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>contextConfigLocation 指定 Spring MVC 的配置文件位置,classpath:spring/dispatcher-servlet.xml 表示在 resources 目录下的 spring 文件夹里。load-on-startup 必须设成正整数,让容器启动时就初始化 DispatcherServlet,否则第一次请求才初始化,配置错误会被延迟到访问时才暴露,很难排查。url-pattern 用 / 而不是 /,这一点非常关键。/会拦截所有请求,包括 JSP,导致浏览器请求的 .jsp 页面也被 DispatcherServlet 接管,最终返回的是 404 或乱码。
dispatcher-servlet.xml 里要开启注解驱动、扫包、视图解析器和 MyBatis:
<mvc:annotation-driven/> <context:component-scan base-package="com.course"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/course_select?useUnicode=true&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.course.dao"/> </bean>注意 url 里的 & 是 XML 转义,直接写 & 会报 “The reference to entity characterEncoding must end with ';'”。DriverManagerDataSource 只是课程设计里图简单用,生产环境一般换成 HikariCP 或 Druid,但原理一样。sqlSessionFactory 的 mapperLocations 指向 resources/mapper 下的所有 XML 文件,MapperScannerConfigurer 则扫描接口包生成代理,这样你只需要写接口和 XML,不用手动实现 DAO 类。
提示:如果你的项目不是 Maven 而是手动拷 jar 包,最容易缺的其实是 spring-jdbc 和 spring-tx。MyBatis 和 Spring 整合后,事务管理和数据源都依赖这两个库,缺了会报 ClassNotFoundException: org.springframework.jdbc.datasource.DataSourceTransactionManager。
2.3 选课核心流程的时序:从 Controller 到 Service 再到 DAO
选课系统最核心的一条链路是“展示可选课程”和“提交选课”。Controller 里只做参数接收和视图跳转:
@Controller @RequestMapping("/course") public class CourseController { @Autowired private CourseService courseService; @GetMapping("/list") public String list(Model model) { model.addAttribute("courseList", courseService.findAvailableCourses()); return "course/list"; } @PostMapping("/select") public String select(@RequestParam("courseId") Integer courseId, @RequestParam("studentId") Integer studentId, RedirectAttributes ra) { boolean ok = courseService.selectCourse(courseId, studentId); if (!ok) { ra.addFlashAttribute("error", "选课失败:课程已满或已选过"); } return "redirect:/course/list"; } }这里 @GetMapping / @PostMapping 是 @RequestMapping 的简化,method=GET 或 POST 的请求才会进对应方法。RedirectAttributes 用来在重定向后携带一次性提示信息,比 session 干净,刷新也不会重复显示。返回 redirect:/course/list 是 PRG 模式,后面避坑章节会专门讲为什么这里不能直接返回 JSP。
真正的业务规则在 Service 里:
@Service @Transactional public class CourseService { @Autowired private CourseDao courseDao; public boolean selectCourse(Integer courseId, Integer studentId) { Course course = courseDao.findById(courseId); if (course == null || course.getSelectedCount() >= course.getMaxCount()) { return false; } if (courseDao.existsSelection(courseId, studentId)) { return false; } return courseDao.insertSelection(courseId, studentId) > 0; } }这段代码作为入门例子没问题,但它有一个明显的并发缺口:两个请求同时查到 selected_count=49,同时通过判断,然后都执行 insert,最后课程超员。这个问题的解法要等到数据库设计那一章,用一条原子 UPDATE 解决。这里要强调的是 @Transactional 为什么要放在 Service 类上。Spring 事务是基于 AOP 代理实现的,只有外部调用 Service 方法时事务才生效。如果 @Transactional 写在 DAO 上,而 Service 里连续调用了两个 DAO 方法,这两个方法各自开启事务,可能互相独立,中间任何一步失败都没法整体回滚。面试里问事务失效,十次有八次是这个原因。
Mapper 接口和 XML 的对应关系很直接:
public interface CourseDao { Course findById(Integer id); boolean existsSelection(Integer courseId, Integer studentId); int insertSelection(Integer courseId, Integer studentId); }只要接口方法名和 XML 里的 id 一致,参数类型和 resultType 对得上,MyBatis 会自动完成映射。这种约定优于配置的方式大大减少了样板代码,也是 MyBatis 能在企业里普及的原因。
3. 数据库设计与 MyBatis 映射:选课表的三个关键约束
3.1 学生、课程、选课记录三张表怎么建
不管代码怎么写,选课系统的根基是数据库表结构。表建得不合理,后面不管怎么改代码都别扭。我给一个最小但完整的建表方案:
CREATE DATABASE course_select DEFAULT CHARACTER SET utf8mb4; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL ); CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, max_count INT NOT NULL DEFAULT 50, selected_count INT NOT NULL DEFAULT 0 ); CREATE TABLE 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, CONSTRAINT fk_sel_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_sel_course FOREIGN KEY (course_id) REFERENCES course(id), CONSTRAINT uk_sel_student_course UNIQUE KEY (student_id, course_id) );三个关键点。student_no 加 UNIQUE,保证一个学号只能注册一次;selection 表的 (student_id, course_id) 联合唯一索引,在数据库层面挡住重复选课,这个约束比 Java 代码里的 if 判断靠谱得多;外键约束保证选课记录不会指向不存在的学生或课程。有人为了“性能”不建外键,做课设可以,但答辩时老师一问数据完整性你怎么保证,回答就会很尴尬。
还有一个设计决策:selected_count 这个字段是冗余的,因为通过 SELECT COUNT(*) FROM selection WHERE course_id=? 也能拿到已选人数。为什么要冗余?因为选课列表页要高频展示“已选/满员”,每次实时 count 在百万级数据下会拖慢页面。课程设计不需要这么纠结性能,但把冗余字段单独列出来,恰恰说明你理解“空间换时间”的思路。
3.2 选课唯一约束与事务控制的落地写法
回到 Service 的选课方法。现在数据库有了联合唯一索引,重复插入时 MySQL 会直接拒绝并抛 DuplicateKeyException。这样,并发下的重复选课至少有一个兜底。MyBatis 的 insert 写法和普通插入一样:
<insert id="insertSelection"> INSERT INTO selection (student_id, course_id) VALUES (#{studentId}, #{courseId}) </insert>但只靠唯一索引,还不够处理“课程满员”的并发。Service 需要改成先更新人数,再插入选课记录:
@Service @Transactional(rollbackFor = Exception.class) public class CourseService { public boolean selectCourse(Integer courseId, Integer studentId) { int rows = courseDao.increaseSelectedCount(courseId); if (rows == 0) { return false; } try { courseDao.insertSelection(courseId, studentId); return true; } catch (DuplicateKeyException e) { throw new RuntimeException("重复选课", e); } } }increaseSelectedCount 对应的 SQL 是:
<update id="increaseSelectedCount"> UPDATE course SET selected_count = selected_count + 1 WHERE id = #{id} AND selected_count < max_count </update>这条 UPDATE 是原子的,数据库的行锁保证同一门课同一时刻只有一个请求能成功把人数加一。affected rows 为 0,说明课程满了,直接返回 false。如果 insert 选课记录时因为唯一索引失败,比如用户重复提交,此时人数已经加一,必须靠事务回滚把人数恢复,所以整个方法要放在一个事务里,并且 rollbackFor 要能覆盖 RuntimeException。这里我写的是 @Transactional(rollbackFor = Exception.class),虽然 DuplicateKeyException 本身是 RuntimeException 默认会回滚,但显式写出来更清晰,防止以后把异常改成 checked 后忘了配置。
3.3 Mapper XML 里最容易写错的动态 SQL
选课列表页经常会加一个按课程名模糊搜索的框,Mapper XML 要写动态 SQL。这里有两个高频翻车点,我一个个说。
第一个是 XML 里不能直接写 < 或 >。下面的 SQL 是“可选课程”,条件是已选人数小于课程容量:
<select id="findAvailableCourses" resultType="com.course.entity.Course"> SELECT * FROM course WHERE selected_count <![CDATA[ < ]]> max_count <if test="keyword != null and keyword != ''"> AND course_name LIKE CONCAT('%', #{keyword}, '%') </if> ORDER BY id </select>如果没有 ,XML 解析器会把 < 当成标签开始,直接报 “The content of elements must consist of well-formed character data”。常见做法是像上面这样把比较符号包在 CDATA 里。注意 CDATA 里不能有字符串拼接,它只负责告诉解析器这一段是普通文本。
第二个坑是模糊查询的拼接方式。很多人喜欢在 Java 里写成 "like '%" + keyword + "%'",然后用 ${keyword} 传入,这是 SQL 注入的经典入口。正确做法是用 CONCAT 函数在 SQL 侧拼接,配合 #{keyword} 预编译参数。LIKE 的优先级不高,所以你看到条件里 AND 写在上一行,那是因为如果 where 后面没有其他条件, 会自动去掉开头的 AND,但你这里第一个条件固定是 selected_count < max_count,所以 AND 可以放心写在 内部。
第三个坑是 update 语句里的逗号。MyBatis 提供
<update id="updateCourse"> UPDATE course <set> <if test="courseName != null and courseName != ''"> course_name = #{courseName}, </if> <if test="maxCount != null"> max_count = #{maxCount}, </if> </set> WHERE id = #{id} </update>4. 把 myweb.rar 里那套前后端交互跑通:表单、路由与参数绑定
4.1 Spring MVC 的请求映射:从 web.xml 到注解
老旧的 Java MVC 项目会在 web.xml 里写一堆 servlet-mapping,每个 URL 对应一个 Servlet 类。Spring MVC 用注解替代了这种配置,一个 Controller 可以处理多个 URL,方法上直接标注映射:
@Controller @RequestMapping("/student") public class StudentController { @Autowired private CourseService courseService; @GetMapping("/dashboard") public String dashboard(@SessionAttribute("studentId") Integer studentId, Model model) { model.addAttribute("selectedCourses", courseService.findSelectedByStudent(studentId)); return "student/dashboard"; } }@RequestMapping("/student") 放在类上相当于给所有方法加了一个前缀,实际访问路径是 /student/dashboard。@SessionAttribute 是 Spring 4 以后提供的注解,直接从 session 里取属性绑定到方法参数,省掉了 request.getSession().getAttribute("studentId") 这种样板代码。它有一个坑:session 里没这个属性时,Spring 会直接抛 MissingRequestValueException。所以这个注解只适合用在“必定登录过”的页面里,如果用户没登录直接访问这个 URL,会得到 500。更稳的做法是写一个 HandlerInterceptor,在拦截器里判断 session 有没有 studentId,没有就重定向到登录页,这比在每个 Controller 里都判断要干净。
4.2 选课页面表单提交与后端校验
课程列表页我一般用 JSP 渲染。每个课程一行,表单用一个隐藏的 studentId 和每个课程的按钮提交:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ page contentType="text/html;charset=UTF-8" %> <html> <body> <form action="${pageContext.request.contextPath}/course/select" method="post"> <input type="hidden" name="studentId" value="${sessionScope.studentId}"/> <c:forEach items="${courseList}" var="c"> <div> ${c.courseName} | 已选 ${c.selectedCount}/${c.maxCount} <button type="submit" name="courseId" value="${c.id}">选课</button> </div> </c:forEach> </form> </body> </html>这里 action 用了 EL 表达式取上下文路径,这样部署到不同路径下都不会失效。c:forEach 是 JSTL 标签,页面必须引入 taglib,否则 JSP 会把 <c:forEach> 当作 HTML 标签原样输出到页面,你看到一堆奇怪的尖括号,但页面不报错,特别容易忽略。表单 method 必须是 post,避免把选课操作暴露在 URL 里。
后端接收参数时,如果参数名和表单 name 完全一致,@RequestParam 可以省略 value。但为了可读性和防错,我习惯显式写出来:
@PostMapping("/select") public String select(@RequestParam("courseId") Integer courseId, @RequestParam("studentId") Integer studentId) { // 参数校验 if (courseId == null || studentId == null) { return "redirect:/course/list"; } courseService.selectCourse(courseId, studentId); return "redirect:/course/list"; }注意一个容易掉进去的细节:@RequestParam 默认 required=true,如果请求里没有该参数,Spring 会直接报 400。对于可见的表单来说这通常没问题,但如果你做了多选按钮,某个课程没选中就不提交任何 name,那就得设 @RequestParam(value="courseId", required=false) 并处理 null。校验逻辑放在 Controller 是防参数畸形,放在 Service 是防业务违规,两者职责不同,不要混在一起。
4.3 用 IDEA 基于 Maven 创建 Spring MVC 项目时的版本坑
从 myweb.rar 里导出的项目,十个里面有六个跑不起来是因为工程配置不对,不是代码问题。我想重点说 IDEA 里最容易踩的三个版本坑。
第一个坑是 Archetype。很多人在 IDEA 里用 maven-archetype-webapp 创建 Web 项目,这个模板默认的 web.xml 还是 Servlet 2.3,连 EL 表达式都不支持。创建后要手动把 web.xml 头改成 4.0,并把目录结构补成 src/main/java、src/main/resources、src/main/webapp。IDEA 新版本默认不会帮你把 java 目录建出来,你需要自己右键 Mark Directory as Sources Root。
第二个坑是 Artifacts。项目能编译,但运行 Tomcat 时 404,或者点击运行报 “Selected directory does not exist”。解决办法是在 Project Structure 里把 Artifacts 设为 Web Application Exploded,并在 Run Configuration 的 Deployment 里加上这个 Artifact。如果你用的是 Tomcat 的 Deployment 方式,还要确认 Application context 是 /course_select 还是 /,不匹配会导致页面请求全部 404。
第三个坑是依赖版本锁不定。IDEA 的 Spring 初始化向导可能给你 Spring 6 + JDK 17 的组合,但本机 JDK 是 8。此时启动 Tomcat 会在加载 Context 时抛 UnsupportedClassVersionError。我的经验是:在 pom.xml 里直接把 Spring 版本定为 5.3.x,Maven 编译器设为 1.8,然后 Reimport。如果你的 JDK 确实只有 17,那就把 Tomcat 换成 Tomcat 10 + Jakarta EE 命名空间,但老项目几乎全是 javax.servlet,你会改到怀疑人生。对课设来说,JDK 8 + Spring 5.3 + Tomcat 8.5 是最稳的三角组合。
还有一个静态资源 404 的问题,也在这里一并说。DispatcherServlet 的 url-pattern 是 /,它会拦截所有非 JSP 请求,所以 /css/style.css、/js/jquery.js 这些静态资源也会被当成 Controller 映射找不到而 404。解决方式是在 dispatcher-servlet.xml 里加:
<mvc:resources mapping="/css/**" location="/css/"/> <mvc:resources mapping="/js/**" location="/js/"/>或者更省事:
<mvc:default-servlet-handler/>让 Tomcat 的默认 Servlet 去处理静态资源。这一步不加,你花在调样式上的时间会比调代码还长,而且看起来像个谜。
5. 选课系统避坑指南:启动失败、中文乱码、并发选同一门课
5.1 Tomcat 启动失败:jar 包冲突与 JDK 版本不匹配
现象:点击 Tomcat Run 后,控制台疯狂滚动异常,启动停在某个“Application context”阶段,常见错误是 NoClassDefFoundError、BeanCreationException 或者 LinkageError。项目 Artifact 没有出现在 Tomcat 的 webapps 目录里,浏览器永远打不开。
原因:第一,pom.xml 里把 javax.servlet-api、javax.el-api 这些 Tomcat 本身带有的 jar 打进了 WAR,导致容器加载时类重复;第二,Spring 版本和 JDK 不匹配,字节码版本太高 JVM 不认;第三,多个依赖传递引入了不同的 Spring 版本,比如 spring-webmvc 是 5.3,spring-jdbc 却是 4.2,运行时接口签名不一致。
解决:先看 pom.xml,所有 javax.servlet / jsp-api / el-api 全加 provided 作用域。然后在 IDEA 的 Maven 面板里运行 dependency:tree,把 Spring 相关依赖检查一遍,确保只有一个 spring-core 版本。如果你用的是 Spring Boot 起步依赖,它会帮你统一版本,但这是传统 Spring MVC 项目,就老老实实把 spring-webmvc、spring-jdbc、spring-tx、spring-context 的版本写成一个常量,比如<spring.version>5.3.27</spring.version>。最后确认 IDEA 的 Project SDK、Maven Runner 的 JRE、Tomcat 的 JRE 三处都是 JDK 8。这个“三处一致”的问题,至少浪费了我两个下午,属于典型的黑匣子。
5.2 控制台和页面的中文乱码:编码过滤器与连接串
现象:Tomcat 控制台输出中文变成乱码或问号,页面提交的中文名称存进 MySQL 后变成???,课程列表页上显示的是乱码。
原因:编码问题涉及三个环节,缺一不可。第一,JSP 文件本身保存格式不是 UTF-8;第二,请求参数没有经过 UTF-8 解码;第三,MySQL 连接串没指定 characterEncoding,或者表结构不是 utf8mb4。
解决:三层一起改。web.xml 里加 Spring 提供的 CharacterEncodingFilter,并设置 forceEncoding=true,这会同时影响 request 和 response:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>这个过滤器必须放在最前面,也就是 web.xml 中 filter-mapping 顺序在最上方,否则如果其他过滤器先读了参数,编码就来不及生效。JSP 页面第一行务必写<%@ page contentType="text/html;charset=UTF-8" %>,同时确保 IDEA 的 File Encoding 设置为 UTF-8。数据库连接串加上 characterEncoding=utf8,并在建库时指定 utf8mb4。有时候你发现页面显示正常,但 Excel 或控制台乱码,那一般是读取端的字符集没对齐,不是 Web 的锅。
5.3 并发选课导致超选:用悲观锁还是唯一索引?
现象:我实际压测过一个课设,用 JMeter 模拟 20 个并发请求同时选一门只剩 2 个名额的课程,结果选课成功了 5 个人。虽然 selection 表里只有 2 条记录,但 course 表的 selected_count 变成了 5,数据对不上了。
原因:Service 原先的逻辑是先查 count、再判断、再 insert,三个操作之间没有原子性。并发情况下所有请求都读到 count=2,全部通过判断,然后一起插入。数据库的唯一索引能挡住同一个学生的重复选课,但挡不住不同学生同时选同一门课。
解决:按第 3 章说的,用一条带条件的 UPDATE 原子地把 selected_count+1,并利用受影响行数判断是否满员。SQL:
UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < max_count;这个 UPDATE 会获取 course 行的锁,其他线程的相同 UPDATE 会等待,所以同一时刻只有一个请求能成功。成功后再插入 selection 记录,插入失败则事务回滚。要不要用SELECT ... FOR UPDATE悲观锁?也可以,但它在事务一开始就锁住课程行,直到事务提交才释放,持有锁时间更长,并发度更低。课程设计用原子 UPDATE + 唯一索引就够了,如果面试官追问,你还能说出“乐观锁”的升级路径:给 course 表加 version 字段,更新时校验 version。但我觉得,先讲清楚原子性,比堆一堆锁术语更能体现工程判断。
注意:原子 UPDATE 依赖的是行锁,如果事务隔离级别是读未提交,或者 MySQL 的隔离级别设置不当时,极端情况下会有问题。但 InnoDB 默认的 REPEATABLE READ 配合索引条件更新,行为是可靠的。别为了这个去改隔离级别。
5.4 浏览器回退重复提交:PRG 模式与幂等处理
现象:选课成功后被自动重定向到列表页,但用户按 F5 刷新,或者返回上一页重新提交,发现重复选课。如果没加唯一索引,数据库会多出记录;加了唯一索引,页面会直接报 500 DuplicateKeyException。
原因:表单是 POST 提交的,浏览器默认在刷新时会重新发送最后一次 POST 请求。如果 Controller 返回的是视图名(比如 return "course/list"),POST 请求没有经历重定向,刷新就会重放。
解决:使用 Post/Redirect/Get 模式,Controller 处理完成后返回 redirect: 开头的地址,让浏览器发一个 GET 请求。我的代码里已经写了:
return "redirect:/course/list";这样一来,F5 刷新的是 GET 请求,不再携带表单数据。同时,选课动作要做成“幂等”的,也就是同一个请求重复发多次,效果和发一次一样。幂等的保障就是第 3 章的唯一索引和重复判断。如果你发现用户连点两次提交按钮,第二次请求到达时,Service 里的 DuplicateKeyException 需要被 catch 并转向一个友好提示,而不是直接抛 500。我一般在异常处理上用 @ControllerAdvice 写一个全局异常处理器,把 DuplicateKeyException 映射成 409 状态码,页面统一弹提示。这样既堵住了重复提交,也不至于让用户看到丑陋的异常堆栈。
6. 进阶:给选课系统加一个“选课结果导出”功能,验证你的 MVC 功底
6.1 导出 CSV 的 Controller 写法
当你把基本的“选课/退课/列表”跑通后,我推荐加一个“导出选课结果”的功能。它很轻量,但能验证你是否真的理解了 Controller 如何操作 HTTP 响应、Service 边界在哪。这比再堆一个 CRUD 有区分度。代码可以放在 CourseController 里:
@GetMapping("/export") public void export(HttpServletResponse response, @SessionAttribute("studentId") Integer studentId) throws IOException { List<Course> courses = courseService.findSelectedByStudent(studentId); response.setContentType("text/csv;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=my_courses.csv"); Writer writer = response.getWriter(); writer.write("课程名,授课教师,学分\n"); for (Course c : courses) { writer.write(c.getCourseName() + "," + c.getTeacher() + "," + c.getCredit() + "\n"); } writer.flush(); }注意这里 Controller 方法返回值是 void,也没有 @ResponseBody,因为一切交给了 response。Content-Disposition 设成 attachment 会触发浏览器下载,filename 里的文件名不要用中文,老浏览器会乱码。CSV 的列如果有逗号或换行,需要加双引号转义,课程名一般不会,但我见过有老师名字里带逗号的,稳妥的做法是写一个简单的 CSV 转义函数:把每个字段包在双引号里,内部的引号转成两个双引号。
还有一个细节:用 Excel 打开导出的 CSV,中文大概率是乱码,因为 Excel 默认以 GBK 解析无 BOM 的 UTF-8 文件。解决办法很简单,在写第一行之前先写一个 BOM 字符:
writer.write('\ufeff');这样 Excel 会识别 UTF-8 编码。这个坑我帮同事排查过两次,每次都能看到领导眉头紧锁,属于“看起来小但很影响观感”的典型。你主动在答辩时提一句,老师会觉得你有实战细节。
6.2 用 AOP 记录操作日志
另一个值得加的功能是操作日志。选课是敏感写操作,直接在每个 Service 方法里手写日志,会污染业务代码。Spring AOP 可以无侵入地记录:
@Aspect @Component public class OperationLogAspect { @Around("execution(* com.course.service.CourseService.selectCourse(..))") public Object logSelect(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); Integer courseId = (Integer) args[0]; Integer studentId = (Integer) args[1]; long start = System.currentTimeMillis(); try { Object result = pjp.proceed(); System.out.println("选课成功 studentId=" + studentId + ", courseId=" + courseId + ", 耗时=" + (System.currentTimeMillis() - start) + "ms"); return result; } catch (Exception e) { System.out.println("选课失败 studentId=" + studentId + ", courseId=" + courseId); throw e; } } }切点表达式execution(* com.course.service.CourseService.selectCourse(..))代表任何返回值、CourseService.selectCourse 方法、任意参数。@Around 可以同时做前置和后置处理,这里还能顺便统计耗时,是一个很实用的性能观测手段。要注意的是,AOP 代理默认对 Controller 里的直接调用生效,但同一类内部方法自调用不会走代理,所以日志切面要放在 Service 类上,并且不要在 Service 里通过 this 调用自己的其他方法。如果发现日志没打印,第一反应检查切点表达式写的是不是真的包名+方法名,第二反应检查是否配置了<aop:aspectj-autoproxy/>或 @EnableAspectJAutoProxy。课程设计里,把日志打到控制台够了,生产环境可以换 logback 写文件。
做课设也好,自己练手也好,我最后都会做一次减法:删掉没用的注释,把 magic number 抽成常量,把 Controller 里超过三行的逻辑塞回 Service。拿到 myweb.rar 这种项目,第一件事先看分层和数据约束,再决定是保留还是重构。这个习惯帮我在新公司接手一堆老项目时少走了很多弯路。希望这篇笔记能让你的选课系统顺利跑起来,更希望你能从“能跑”走到“敢讲清楚为什么这么写”。如果你也在折腾 Java MVC,希望帮到你。
本文还有配套的精品资源,点击获取