我们当年做SSM学生选课系统,踩过的坑比踩过的雷还多。最近有同行在群里问“170920ssm学生选课系统”这个经典项目,源码倒是不难找,真正难的是你拿到手之后能不能让它跑起来、能不能讲清楚每一处设计。今天干脆把这个项目的完整思路、配置细节、核心实现和排查经验梳理一遍,既是给自己存档,也给正在做类似课设或毕设的朋友一份参考。
这个题目看起来不过是个增删改查的CRUD项目,但真要做完整,涉及到SSM三层的整合、数据库关系设计、跨表事务处理、前端页面交互还有各种中间件配置。很多人卡住不是卡在业务逻辑,而是卡在SSM框架本身——一启动就报错,一访问就404,一查错就两眼一抹黑。这也是我写这篇东西的初衷:把整个系统从架构到细节串起来,让拿到源码的人不只是能把项目跑起来,而是能把它讲透、用好、改得动。
1. 整体设计与思路拆解
1.1 为什么还要选SSM框架
现在很多新项目一上来就是Spring Boot + MyBatis Plus一条龙,学生选课这种系统用Spring Boot做确实更快。但SSM(Spring + SpringMVC + MyBatis)依然有它不可替代的价值:几乎所有Java岗位的面试题、几乎每本SSM相关的教材、几乎每个学校的JavaWeb课程设计,还都在用这套组合。换句话说,SSM是理解Spring核心思想最直观的途径——你亲手配过Bean、亲手处理过注解扫描、亲手解决过MyBatis绑定异常,你才能真正理解Spring Boot帮你封装的那些“魔法”。
另外,SSM在低配环境下跑起来特别轻量,一个Tomcat、一个MySQL、一份依赖就够,不像Spring Boot那样拖一大包嵌入式服务器。对于老机器、学校机房、或者只想跑通功能演示的场景,SSM反而舒服。从学习和维护的角度说,这个选型不算过时,也不算迂腐。
1.2 核心需求与角色划分
一个学生选课系统看标题就清楚,核心动作是“学生选课”,但围绕着选课必然要有几个基本前提:得有课程可以选,得有学生来选,得有人来管理课程和审核信息。所以整个系统的角色基本可以分成三类:
- 学生:浏览可选课程、选课、退课、查看已选课程和成绩
- 教师:维护自己授的课程信息、查看选课学生名单、录入成绩
- 管理员:管理学生账号、教师账号、课程档案、统计选课数据
栏目划分上,通常就是登录/注册模块、个人中心、选课中心、课程管理、成绩管理和用户管理这几个大块。每个模块之间不是孤立的,一门课程从被教师创建,到被学生选走,再到成绩录入,正好串成了一条完整的数据流。理解了这个链条,你再看数据库表和Mapper接口,就会顺很多。
1.3 数据库关系设计背后的逻辑
这个系统的数据量不大,但表关系很典型,属于“多对多”的经典场景:一个学生可以选多门课程,一门课程也可以被多个学生选择。关系表一定要单独拆出来,不能塞在学生表或课程表里。
核心表建议这几张:
- student:学号、姓名、院系、专业、密码等信息,主键是学号
- teacher:教师工号、姓名、职称、所属院系
- course:课程编号、课程名称、学分、课时、授课教师、上课时间、容量、已选人数
- stu_course(选课关系表):选课ID、学号、课程编号、选课时间、成绩字段
为什么要单独一张选课表?因为学生和课程的关联信息本身就包含了动态状态:什么时候选的、成绩多少、是否退过课。你把字段塞进课程表或者学生表里,系统一扩展就崩。参考这个设计,后续就算要加“选课限制”“学期字段”“课表时间冲突检测”,都还有地方放。
有一个特别值得注意的点:课程表一般会冗余一个“已选人数”字段,这个看似多余,其实是经典的空间换时间做法——查询可选课程列表时,不需要count选课表就能直接判断是否满员。但这样做的代价是要保证“已选人数”和选课表数据的一致,更新时得用事务,这一点下面详细讲。
2. 环境准备与SSM核心配置详解
2.1 开发工具与基础依赖
我这边习惯的组合是:JDK 1.8 + IntelliJ IDEA(或者Eclipse)+ Maven 3.6 + Tomcat 8.5 + MySQL 5.7。版本匹配非常重要,JDK版本过高或者Tomcat版本过新,很可能遇到兼容性问题,比如JDK 9以上用Tomcat 8会出反射相关的警告或异常。这个项目里我特意选JDK 1.8,稳。
Maven的pom.xml里,核心依赖是这些:
- spring-webmvc(4.x或5.x都可以,视JDK版本而定)
- mybatis(3.4.x系列)
- mybatis-spring(用于整合Spring和MyBatis)
- mysql-connector-java
- druid(连接池,可选,但推荐)
- jackson-databind(用于JSON数据返回)
- jstl / jsp-api(如果是做JSP页面)
- lombok(可选,减少getter/setter代码)
这里顺便提一句,Spring的版本不能乱配,Spring 5要求JDK 8+,如果你用的是JDK 1.8那没问题;如果你机子上是JDK 11,建议用Spring 5.2.x以上。MyBatis和mybatis-spring的版本也要对应,否则会出现AbstractMethodError这种难以定位的问题。
2.2 Spring与MyBatis整合的经典布局
SSM的项目结构一般按controller、service、mapper、entity、config这样分包,另外还有resources目录放配置文件和mapper映射XML。
核心的整合思路是:Spring负责管理Service和Mapper这些Bean,SpringMVC负责处理Web层的请求,MyBatis负责SQL执行。三者之间的结合点在于Spring的配置文件(applicationContext.xml)和SpringMVC的配置文件(spring-mvc.xml)。
我在applicationContext.xml里通常会做这些事:
- 开启注解扫描(配置
<context:component-scan base-package="com.example.service"/>) - 加载jdbc.properties数据库配置
- 配置数据源(Druid)
- 配置SqlSessionFactoryBean,将数据源和MyBatis核心配置关联起来
- 配置Mapper扫描器(也就是自动扫描Mapper接口并生成代理Bean的方式)
你要是看到项目里没有显式写#{}、${}引号包裹之类的问题,多半是Mapper XML没被正确加载到。
spring-mvc.xml里做的事是:
- 扫描controller层
- 开启注解驱动(
<mvc:annotation-driven/>),这样才能支持@RequestBody、@ResponseBody - 配置视图解析器(InternalResourceViewResolver),指定JSP前缀和后缀
- 配置静态资源放行(
<mvc:default-servlet-handler/>),否则CSS、JS会404 - 解决POST请求乱码的CharacterEncodingFilter(实际放在web.xml,但也可以放在MVC配置里)
2.3 关键配置细节与坑
这么多年总结下来,SSM配置遇到最多的坑,我直接列出来节省你的时间:
- Mapper XML的namespace必须和接口的全限定名一致,差了就报
Invalid bound statement (not found) - Mapper XML放在resources目录里时,路径扫描要设成
classpath*:mapper/*.xml,不然MyBatis根本找不到XML - 数据库连接URL建议加上
useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai,不然后面处理时间、中文编码都很麻烦,特别是MySQL 8版本必须指定serverTimezone - 事务管理要记得加
<tx:annotation-driven transaction-manager="transactionManager"/>,不然@Transactional形同虚设 - 使用Druid连接池时,注意要是引入了多个数据源依赖,属性的前缀(spring.datasource)在XML里得写对
拿这个选课项目举例,数据库连接配置大致是这个样子:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/course_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456SQL语句中千万注意不要用反引号括起表名,有些版本解析容易出问题;字段命名尽量用下划线风格(如stu_id,course_id),然后在MyBatis里配置驼峰映射,这样实体类的属性就叫stuId、courseId,省去resultMap一篇篇手写。
3. 数据库建表与核心模块实现
3.1 建表语句与初始化数据
为了让整篇文章能跟着操作,我给出一个能直接跑的建表结构,做的是最经典的那套三表加一关系表。有需要的可以直接拿去用,字段也可以按自己学校的格式自行加。
CREATE TABLE student ( stu_id VARCHAR(20) PRIMARY KEY, name VARCHAR(20) NOT NULL, password VARCHAR(64) NOT NULL, dept VARCHAR(30), major VARCHAR(30), grade VARCHAR(10) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE teacher ( tea_id VARCHAR(20) PRIMARY KEY, name VARCHAR(20) NOT NULL, title VARCHAR(20), dept VARCHAR(30) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE course ( course_id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(50) NOT NULL, credit DECIMAL(3,1), hours INT, tea_id VARCHAR(20), capacity INT DEFAULT 30, selected_count INT DEFAULT 0, schedule VARCHAR(50), CONSTRAINT fk_tea FOREIGN KEY (tea_id) REFERENCES teacher(tea_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE stu_course ( id INT PRIMARY KEY AUTO_INCREMENT, stu_id VARCHAR(20), course_id INT, score DECIMAL(5,1), select_time DATETIME, CONSTRAINT fk_stu FOREIGN KEY (stu_id) REFERENCES student(stu_id), CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES course(course_id), UNIQUE KEY uk_stu_course (stu_id, course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;选课表里设置联合唯一键uk_stu_course很关键,这是从数据库层面防止同一个人重复选同一门课的最后一道防线。程序里可以判断,但并发情况下光靠程序判断不够,这个唯一约束几乎能消灭脏数据。
再给课程表加上外键关联到教师表,体现“一门课一定有一个授课教师”的业务规则。外键在电商大流量场景可能会被摒弃,但这种课设项目,加上外键能让关系一目了然、写起来也省心。
3.2 学生端选课/退课的实现逻辑
学生端最核心的接口就是选课和退课。这里要说的重点是:选课不只是往stu_course表里插一条记录这么简单,它牵扯到两个表的修改。
选课的原子操作应该是这样:
- 判断当前课程是否已满(selected_count < capacity)
- 判断学生是否已经选过(联合唯一键兜底)
- 向stu_course表插入记录,记录当前时间
- 更新course表中该课程的selected_count:
set selected_count = selected_count + 1
这四步必须在一个事务里。假如你只做了前两步然后插入选课表,最后忘了更新课程表的已选人数,那下一次别的学生选课就只能通过count选课表来判断容量,而如果你又在页面上用selected_count显示“剩余名额”,两边就对不上了。
我写Service时一般这样组织:
@Transactional(rollbackFor = Exception.class) public void selectCourse(String stuId, Integer courseId) { Course course = courseMapper.selectById(courseId); if (course == null) { throw new RuntimeException("课程不存在"); } if (course.getSelectedCount() >= course.getCapacity()) { throw new RuntimeException("该课程已选满"); } // 检查是否已选 int count = stuCourseMapper.countByStuAndCourse(stuId, courseId); if (count > 0) { throw new RuntimeException("请勿重复选课"); } stuCourseMapper.insert(new StuCourse(stuId, courseId, new Date(), null)); courseMapper.increaseSelectedCount(courseId); }这段代码在单机、并发量低的情况下完全够用。如果非要考虑高并发,比如选课人数突然冲上来几千人同时抢课,你会遇到“超选”问题——多个请求都查到剩余名额为1,然后一起通过校验,最后多出好几个人选课。解法是乐观锁(update时加条件where selected_count < capacity)或者悲观锁(select ... for update)。学生选课系统的课设阶段不需要做成秒杀系统,但把这个思路写在论文或答辩里,面试官一定会另眼相看。
退课同理,事务里先删记录,再把selected_count减一。顺带记一笔:如果课程成绩已经录了,退课逻辑应当拦截;这个判断走的是业务完整性,一般由前端按钮状态加上后端判断双重控制来实现。
3.3 教师端课程与学生成绩管理
教师端的管理逻辑相比学生端要单纯一些,本质就两块:课程信息和成绩录入。
教师登录后默认只看得到自己名下的课程,这里通常有两种实现:
- 简单方案:查询课程时带条件
where tea_id = 当前登录教师id - 正规方案:做一个数据权限的概念,Service层从Session中拿当前用户,再根据身份拼装查询条件
课程管理就是增删改查,但有一处细节要注意:改了上课时间或地点后,如果这门课程已经有人选了,前端最好展示一下“当前已选xx人,是否确认修改”,避免课程变动引发学生不满意。而在课程删除时,如果有学生选了这门课,外键约束会直接把删除语句拦下来,页面就会报错。好的做法是先删除选课记录再删除课程,或者干脆禁止删除有选课记录的课程,只允许“停开”操作。
成绩录入就是按选课记录批量更新score字段。常见交互是:教师进入某门课程的名单页面,看到选课学生列表,旁边是一排输入框,填入成绩后一个“一键保存”批量提交。ServiceImpl里就是遍历列表逐条update,注意批量操作时最好放在同一个事务中进行,否则中途某一学生成绩更新失败,其余学生的成绩还存进去了,状态不一致很让人头大。
3.4 管理员端统计与发布
管理员端的重点是用户和课程管理,另外选课统计也是加分项,例如能出“每门课选课人数”“学生已选学分总和”这类报表。
课程发布时需要注意发布时间约束:管理员可以设置选课开始时间和结束时间,学生端页面根据当前时间和这两个时间点的比较,决定是否能点“选课”按钮。这里不要只在页面上做判断,后端Service里也要判断,因为绕过前端直接调接口的话,时间约束就失效了。
统计模块如果用SQL做得顺,效率会比在Java里遍历集合高很多,比如统计每门课选课人数:
SELECT c.course_id, c.course_name, COUNT(sc.id) AS stu_count FROM course c LEFT JOIN stu_course sc ON c.course_id = sc.course_id GROUP BY c.course_id, c.course_name;用LEFT JOIN而不是INNER JOIN,是为了把那些暂时没人选的课程统计为0,不然0人选课的直接消失,报表就不完整了。
3.5 前端页面与后端交互设计
前端技术本身没有太高门槛,JSP加上一些CSS就能搭起来。不过如果你是首次做,我建议页面风格走后台管理模板风,左侧菜单加右侧内容区,在线找一套免费admin模板套上去就非常体面。
交互上两个值得注意的点:
第一,跳转方式。表单提交用POST,页面跳转用GET,这个原则要守住。学生选课这种带状态变更的操作一定用POST,否则刷新页面时浏览器会自动重发一次请求,重复选课的脏数据就是这么产生的。
第二,异步刷新。选课后要更新剩余名额,两个常见方案:一个是用Ajax局部刷新,返回剩余人数并替换显示;另一个是操作后整体刷新页面。Ajax的方案体验好很多,用原生Ajax或者jQuery的$.post都可以,返回JSON数据,用Jackson自动转换就行。我一般把Controller方法标注@ResponseBody返回Map,放提示信息和最新剩余人数。
4. 常见问题与排查技巧实录
4.1 环境与启动阶段的高频报错
跑SSM项目,启动失败的痛苦每个人都经历过。我把这些年帮别人看项目时遇到的高频报错都整理出来了,按概率排序:
NoSuchBeanDefinitionException——启动容器时找不到某个Bean。绝大多数原因是<context:component-scan>的base-package路径写错了,或者忘了写某个包的扫描。还有就是Service实现类忘了加@Service、Mapper接口忘了加@Mapper。
Invalid bound statement (not found)——代码调Mapper接口方法时报错,定位为MyBatis没找到对应的SQL。逐个检查:Mapper接口和XML的namespace是否一致、方法名和XML中id是否一致、XML是否被扫到。上面提到的classpath*:mapper/*.xml就是这里的关键。
ClassNotFoundException: com.mysql.jdbc.Driver——MySQL驱动依赖没有打进lib目录,或者pom.xml中mysql-connector-java的scope被设成了provided导致运行时不在发布包里。查一下依赖scope,或者刷新Maven重新打包。
404 on Controller URL——SpringMVC的DispatcherServlet映射配置的是/,然后Controller类又没扫描到,或者注解忘了加@Controller。如果静态资源也404,检查<mvc:default-servlet-handler/>是否配置了。
数据库乱码——页面是中文,往数据库一存变成了?。先看数据库表字符集是不是utf8,再看连接URL有没有characterEncoding=utf8,最后看页面编码和Tomcat的URIEncoding。最常见的桶是只改了JSP的pageEncoding,没改数据库连接参数。
我把这些整理成了一个速查表,方便你打印贴墙上:
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 容器启动失败 | 包扫描路径不对/依赖缺失 | 核对component-scan和pom依赖 |
| Mapper方法不执行 | namespace或id不匹配 | 核对映射文件和接口 |
| 驱动找不到 | 依赖未打包 | mvn clean package重新构建 |
| 页面404 | 组件扫描缺失/静态资源拦截 | 检查注解与mvc放行配置 |
| 数据乱码 | 编码链路不一致 | 统一为utf8并配置连接参数 |
| 事务不回滚 | 缺注解驱动 | 检查tx:annotation-driven |
4.2 反复调试中容易忽略的细节
还有一些问题不直接报错,但会严重影响使用。比如用户会话失效后,点击“选课”直接500;我建议所有需要登录才能访问的Controller方法里都先判断session是否为空,空则重定向到登录页。写拦截器统一处理会比在每个方法里判断更优雅。
还有课程满员后的并发超选问题,单纯在程序里if判空会失灵。至少你要加数据库唯一约束,好一点的再补上乐观锁。我在这个系统里用的是UPDATE course SET selected_count=selected_count+1 WHERE course_id=? AND selected_count<capacity,返回的影响行数为0就说明竞争失败或已满员,直接抛异常。
另外,前后端传输时间或数据格式不一致也常见:选课时间字段用datetime,传到前端展示的是“2023-08-17 12:30:00.0”,可以加一个@JsonFormat注解统一格式,或者SQL里用DATE_FORMAT直接把时间格式化成字符串,这种小细节会直接提升演示好感度。
4.3 排查步骤的个人经验
面对一个跑不起来的SSM项目,按什么顺序查最有效?我给很多同学支过招,效率最高的顺序是:
- 先看启动日志,定位是Spring容器初始化失败还是Tomcat部署失败
- 再看数据库连接,用sqlyog或命令行直接连一下,排除库本身的问题
- 然后看MyBatis映射,一个个Mapper接口和XML对照
- 最后才去看Controller层和页面的路径
很多人一上来就翻Controller代码,方向反了。框架问题不解决,业务代码看了也白看。
5. 避坑与实战扩展建议
5.1 边边角角的总结
我做的过程中最大的体会就是:SSM这种“半自动”框架,所有配置差异都会被无限放大。数据库字段命名不同、XML路径不同、依赖版本不同,都有机会变成一次40分钟的debug。所以结构越清晰,后面越省心。我刚做完这个系统时,也在想干嘛不直接用Spring Boot,但讲道理,正是因为每个环节都亲手动过,现在看Spring Boot的自动配置,反而能看明白它到底在背后替你做掉了哪些工作,这个收益是长期存在的。
5.2 Simplicity的取舍
同时要提醒一句:不要为了炫技而硬加一些依赖。比如这个项目,有人非要在里面加Shiro做权限管理,其实用拦截器就能完成角色权限的简单控制,加Shiro反而把配置复杂度拉高好几个级别。做一个学生选课系统,把SSM本身整合好、事务处理好、表关系理清,比塞一堆框架给你带来的答辩价值大得多。
5.3 可扩展方向
如果想把这份代码作为底子继续扩展,比较实用的方向有两个。第一,接入Redis缓存课程列表和剩余名额,降低数据库压力,这个扩展点面试加分明显;第二,前端换成Vue或Element UI,利用SpringMVC的@RestController返回纯JSON数据,把前后端彻底分离,也能体现工程化思路。不管往哪个方向走,现有数据结构和Service层封装都已经为扩展预留了位置。
认真把这个项目从配置到部署过一遍,比刷十套卷子都管用。实践过的代码,才是真正属于自己的能力。