学生选课管理系统:Java Web练手项目的完整实战

发布时间:2026/9/1 2:48:38
学生选课管理系统:Java Web练手项目的完整实战 简介本资源是一套完整的Java Web学生选课管理系统实战项目面向Java初学者与Web开发入门者解决高校教务场景中课程管理、学生选课、成绩录入等核心业务流程的工程化实现问题。项目采用经典Java EE技术栈以JSP实现动态页面展示Servlet处理请求与业务逻辑MySQL存储学生、课程、选课记录等结构化数据并通过JDBC完成持久层交互完整体现MVC分层架构思想。压缩包共408个文件含43个Java源码、25个JSP页面、50个JS脚本、43个编译后Class文件、162个GIF资源及配套CSS、SQL、配置文件等总大小6.59MB目录组织清晰便于理解前后端协作与模块划分。已有6897人学习下载提供可直接部署运行的完整工程包含登录权限控制、课程CRUD、选课冲突校验、成绩管理等真实功能模块是掌握Servlet生命周期、JSP内置对象、数据库事务处理及Web应用调试的优质实践范例。 如果你让我给刚学完Java基础的人推荐一个练手项目我大概率会说做个学生选课管理系统。这个项目太经典了经典到很多高校实训课、课程设计和毕业设计都在用它。但经典不意味着简单它麻雀虽小五脏俱全——用户登录、角色区分、课程列表分页、关键字搜索、选课退课、容量控制、事务处理把Java Web开发里你该亲手过一遍的东西基本都串起来了。这套系统采用的是Java JSP Servlet MySQL的传统Web开发组合。我做了不少版本也帮人改过不少简历上的选课系统可以负责任地说只要把这个项目完整走通你对HTTP请求处理、Servlet生命周期、JDBC操作、数据库设计、会话管理这些核心知识点的理解会比看十遍教程都要牢。适合的人群也很明确刚学完Java语法、准备入坑Java Web的初学者以及那些打算用这个项目作为面试谈资但还没有完全吃透代码的同学。1. 为什么拿这个项目练手功能拆解与技术选型1.1 一个学生选课系统到底要管哪些事先聊需求需求不清晰是很多人项目做到一半就烂尾的根本原因。学生选课管理系统通常要管这四件事学生端注册、登录、浏览课程列表、按名称或教师搜索课程、选课、退课、查看已选课程。管理员端登录、维护课程信息增删改查、管理学生账号重置密码、禁用、查看某门课的选课名单、统计选课人数。业务规则课程有容量限制比如最多50人达到容量就不能再选同一门课同一学生不能重复选退课后名额要释放。基础体验登录后保持会话状态未登录不能访问业务页面列表要分页操作完之后要有成功或失败提示。你看这个功能列表没有一个复杂点全是Web开发里最常见、最基础的CRUD和状态管理但它覆盖的知识面足够广。等这些功能全部实现完你对一个Web应用从浏览器到数据库的完整链路就有了整体认识。1.2 ServletJSPMySQL这套组合为什么值得学经常有人问我老师现在外面都Spring BootMyBatis我为什么还要花时间学Servlet和JSP我也带过手里拿着Spring Boot入门教程却连HTTP状态码都说不清的求职者问题就出在直接学框架跳过了底层请求处理过程。Servlet是Java Web请求处理的正经入口浏览器发来的每个HTTP请求最终都会映射到一个Servlet的service方法里。JSP本质就是Servlet页面里的HTML最终会被容器转换成Java代码再编译成class。MySQL的JDBC操作更是Spring框架里那些华丽注解背后的真实逻辑。你把这些原生东西搞明白了后面学Spring MVC、MyBatis时就是看图说话因为你已经知道它们封装的每一步是在干什么。直接上Spring Boot的问题在于它把这些底层细节藏得过于干净。依赖注入、自动配置、内嵌容器每个都帮你做完了你写代码时效率高但一旦报错面对一屏深不见底的异常栈完全无从下手。我见过不少同学在Spring Boot项目里遇到数据库连接失败连是驱动没加载还是URL写错都分辨不了。1.3 分层设计Controller、Service、Dao先分明白虽然项目本身不大但我强烈建议从一开始就做分层别把代码全堆在Servlet里。我的习惯分法是entity包数据库表的实体类比如Student、Course、CourseSelection。dao包数据库操作每个表一个Dao类只负责SQL执行注释里写上SQL语句的用途。service包业务逻辑比如选课时的容量检查、事务提交一个方法对应一个完整业务操作。servlet包或者叫controller包接收请求、解析参数、调用Service、跳转页面。filter包登录校验、统一编码处理。util包数据库连接工具类、字符串处理工具。新手最容易犯的错误是把业务逻辑写在Servlet里甚至写在JSP的Scriptlet标签里导致代码没法复用也没法测试。我自己早期就是这么干的后面改需求时一个页面改到怀疑人生尤其当多个Servlet需要同一段逻辑时只能复制粘贴然后各自改出不同bug。Service层存在的意义就是把这些公共逻辑收拢Servlet只做请求的分发和参数的解析。2. 数据库设计先画表再写代码2.1 五张核心表别把关系搞错数据库设计是学生选课系统的地基很多同学写代码写到一半回头改表结构痛苦的根源就是早期表设计太随意。我的建议是开工之前先花一个小时把表画清楚。这个项目我通常设计五张表表名主要字段说明studentid, stu_no, stu_name, stu_password, stu_major, stu_class学生表stu_no是学号adminid, admin_name, admin_password管理员表数量和业务简单单独建表即可teacherid, tea_no, tea_name, tea_title教师表课程表用teacher_id关联courseid, course_no, course_name, credit, teacher_id, capacity, selected_count, schedule课程表capacity是容量selected_count是当前已选人数course_selectionid, student_id, course_id, select_time选课记录表一个学生选一门课生成一条记录为什么选课关系一定要单独建表因为学生和课程是多对多关系一个学生能选多门课一门课能被多个学生选。多对多关系必须引入中间表course_selection不能把课程ID塞到学生表的一个字段里存一长串逗号分隔的数字那样查询、统计、去重都会变成噩梦。字段设计时还要注意几个细节。第一主键建议用自增id业务编号学号、课程号另外加唯一索引这样排序、分页、外键关联都方便。第二编码统一用utf8mb4不要用utf8utf8mb4是utf8的超集可以正确存表情符号和生僻字。第三selected_count这个冗余字段看起来违背数据库范式但实际非常有用查询课程列表时直接展示已选人数不用每次都去选课表做count这是典型的空间换时间。2.2 唯一约束与联合索引先把坑堵死建表时一定要给course_selection表加上联合唯一约束UNIQUE KEY uk_student_course (student_id, course_id)。也就是说同一个学生对同一门课只能有一条选课记录这是防止重复选课的最后一道保险。这个约束为什么重要很多同学只依赖代码逻辑判断先查一下这个学生有没有选过这门课如果没有就插入。但Web应用是并发的两个请求同时进来都查不到记录都会执行插入数据就重复了。加唯一约束后第二个插入会直接报错数据库在底层帮你拦截不管请求怎么并发脏数据都进不去。当然业务代码里仍然保留先查再插的逻辑因为它能给出友好的错误提示但绝不能只靠它。同理student表的stu_no要加唯一索引course表的course_no也要加唯一索引。另外course_selection表里对course_id单独建索引因为系统经常要查“某门课有哪些人选了”。联合索引也不要把顺序搞反(student_id, course_id)这个顺序是针对“某学生选的所有课”的查询场景。如果还想经常查“某门课的所有选课人”可以再补一个独立索引。2.3 分页、排序与统计的SQL细节课程列表页通常是数据量最大的页面一次性把几千条课程全部查出来渲染到页面上体验极差。所以必学的一个My SQL知识点就是分页LIMIT ? OFFSET ?。第一页查前10条第二页查第11到20条。-- 课程列表分页每页10条page从0开始 SELECT * FROM course ORDER BY course_id DESC LIMIT 10 OFFSET 0;搜索功能一般用模糊匹配课程名或者教师姓名包含关键字。但注意带条件的SQL占位符写法不要直接字符串拼接。用PreparedStatement的?占位符把关键字作为参数传进去。-- 带搜索的分页关键字部分用占位符 SELECT c.*, t.tea_name FROM course c LEFT JOIN teacher t ON c.teacher_id t.id WHERE c.course_name LIKE CONCAT(%, ?, %) OR t.tea_name LIKE CONCAT(%, ?, %) ORDER BY c.course_id LIMIT 10 OFFSET 0; -- 同时查总数用于计算总页数 SELECT COUNT(*) FROM course c LEFT JOIN teacher t ON c.teacher_id t.id WHERE c.course_name LIKE CONCAT(%, ?, %) OR t.tea_name LIKE CONCAT(%, ?, %);另一个常见的需求是排序。按学号、课程号、选课人数排序都可以直接在SQL中做。但我不建议课程列表按选课人数selected_count排序时每行都去实时count选课表直接用course表的冗余字段排序就很快。LIMIT分页还可以延伸一下如果你发现OFFSET很大时查询变慢比如跳到第10000条之后可以改成“WHERE id 上一页最后一条id ORDER BY id LIMIT 10”这种键集分页方式这是后面可以优化的点。3. 环境搭建与项目初始化3.1 版本选型JDK、Tomcat、MySQL怎么搭才不折腾版本选型是新手遇到的第一个大坑网上教程版本五花八门照着教程配置却怎么都跑不起来的情况太常见了。我给出的组合是这样的JDK1.8或11都可以。如果你用JDK 17及以上也能跑但有些老项目和插件会不兼容。Tomcat9.0.x。这是重点如果你的教程或代码里写的是javax.servlet大部分老书和老教程都是这个千万别用Tomcat 10以上版本。Tomcat 10开始把javax包改名成了jakarta包包名对不上程序一启动就会报ClassNotFoundException: javax.servlet...好多同学折腾半天不知道原因。MySQL5.7或8.0都可以。如果新学直接用8.0驱动名和URL参数和5.7不一样这个我下面单独说。IDEA社区版或旗舰版都行但旗舰版自带Web开发支持和Tomcat集成社区版要手动配置也能跑。用这个组合的好处是稳定、教程多、网上随便搜到的问题一般都能对得上。不要一上来就追新的技术版本项目能顺利跑起来比什么都重要。3.2 用IDEA创建Web项目这几点需要注意创建项目我推荐用Maven结构不是那种手动建目录再加Framework Support的方式。Maven能自动管理依赖你这个项目要用到MySQL驱动、JSTL标签库加依赖就是加几行坐标的事比手动下载jar包再复制到lib目录靠谱太多。在IDEA中新建项目时左侧选Maven勾选Create from archetype如果有maven-archetype-webapp模板就选它。不过新版I DEA2024版本左右不再默认提供这个archetype列表了很多人卡在这里。我的经验是直接创建一个最普通的Java Maven项目然后手动补Web项目需要的文件结构再把pom.xml的packaging改成war最后右键项目选择Add Framework Support勾选Web Application。等目录结构出来后检查一下有没有WEB-INF目录如果没有就手动创建并把web.xml或纯注解方式则不需要放进去。依赖坐标这里直接给一个可以复用的版本dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependencyjavax.servlet-api的scope必须是provided因为Tomcat容器里已经带了Servlet API你打war包时别把它打进去否则可能和容器版本冲突。还有个常见小问题IDEA里打开JSP文件发现没有语法高亮或者方法跳转失效。一般是因为项目没有正确识别Web Facet右键项目Open Module Settings在Facets里加Web把Web Resource Directory指到src/main/webapp。至于函数点击引用无法跳转那是IDEA对JSP内嵌JavaScript的支持本来就弱函数定义和调用在同一个JSP文件里有时也识别不出别死磕直接CtrlF搜函数名就行把大段JS抽取到独立.js文件后跳转会好很多。3.3 JDBC连接配置驱动、URL、连接池JDBC连接数据库是整套系统的数据命脉。MySQL 8.x的驱动类名是com.mysql.cj.jdbc.Driver和MySQL 5.x的com.mysql.jdbc.Driver不一样千万别写错否则启动时报class not found。连接URL建议这样写jdbc:mysql://localhost:3306/course_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue参数解释一下useSSLfalse是为了本地开发不启用SSL加密不然会报警告serverTimezone指定时区防止驱动拿到错误时间characterEncodingutf8保证写入数据库的字符是UTF-8allowPublicKeyRetrievaltrue是MySQL 8在客户端首次连接caching_sha2_password认证用户时需要的参数不加可能报Public Key Retrieval is not allowed。紧接着就是连接池。如果每次都DriverManager.getConnection()新建连接性能很差高并发场景下连接数能瞬间把数据库拖垮。项目学习阶段可以先用一个简单的工具类但正式练手建议引入Druid连接池在pom.xml加com.alibaba:druid依赖然后在src/main/resources下建druid.properties文件。driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/course_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue usernameroot password你的密码 initialSize5 maxActive20写一个简单的工具类从连接池拿连接之后所有Dao都从它这里取Connection整个项目的数据源就稳定了。4. Servlet核心代码实现4.1 Servlet生命周期与登录会话很多人把Servlet当成一个简单的请求接收器但面试时问到Servlet生命周期又答不全。Servlet的完整生命周期是容器启动时或第一次请求时加载并实例化Servlet然后调用init()方法做初始化每当请求到来容器分配一个线程调用service()方法service()根据请求类型调用doGet()或doPost()容器关闭时调用destroy()释放资源。关键点在于Servlet是单实例多线程的所有请求共享同一个实例所以Servlet类里的成员变量千万别存请求相关的数据否则并发时会互相覆盖这是线程安全问题。学生登录功能的实现我习惯这样设计登录页面login.jsp提交学号和密码到StudentLoginServletServlet调用StudentService的login方法用PreparedStatement查询student表验证通过就把student对象存到session里key叫loginStudent然后重定向到学生首页。不通过就回登录页并通过request.setAttribute(error, 学号或密码错误)回显错误提示。登录之后每个业务页面都要判断session里有没有loginStudent。手动一个个判断太累所以用Filter做统一拦截。Filter也是一个Web组件过滤器会拦截符合url-pattern的请求在请求到达Servlet之前做检查。public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws ServletException, IOException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); if (session null || session.getAttribute(loginStudent) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); }注册Filter时要配置url-pattern一般写/然后在Filter内部放行登录相关接口、注册接口、静态资源路径。管理员专属路径比如/admin/还要再检查是管理员角色否则学生登录后手输管理员URL就进去看到后台数据了这是严重的越权问题。4.2 选课退课的事务控制怎么做才算稳选课是整个项目业务逻辑最核心、最容易出错的地方因为一个选课操作涉及两步往course_selection表插入一条记录同时把course表的selected_count加1。这两步要么都成功要么都失败不能出现记录插进去了人数没加或者人数加了记录没有的情况。这正是数据库事务要解决的。原生JDBC的事务写起来比较原始但能让你真正理解事务的边界Connection conn null; try { conn JdbcUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 CourseSelectionDao selectionDao new CourseSelectionDao(); CourseDao courseDao new CourseDao(); // 1. 扣减课程容量使用原子UPDATE保证并发安全 int rows courseDao.decreaseOrThrow(conn, courseId, 1); if (rows 0) { throw new RuntimeException(课程容量已满或课程不存在); } // 2. 插入选课记录唯一约束兜底 selectionDao.insert(conn, studentId, courseId); conn.commit(); // 提交事务 } catch (Exception e) { if (conn ! null) { conn.rollback(); // 回滚 } throw new RuntimeException(选课失败, e); }注意一个细节这里Dao的方法都需要接收Connection参数不能让Dao内部自己去连接池拿连接。如果Dao内部各自拿连接事务就不是同一个事务commit和rollback都不起作用。所以要在Service层统一管理连接然后通过参数把Connection传给Dao。扣减容量的SQL值得单独拿出来说。不要先查capacity再判断有没有余量然后在Java代码里算好新值再UPDATE。那样在多线程并发下会出大问题两个线程同时查到剩余1个名额都判断可以选然后都去插入结果超员。正确做法是把“判断”和“扣减”合并成一条原子UPDATEUPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity;受影响行数为1说明扣减成功为0说明容量已满或课程不存在。这样就算10个请求同时来数据库行锁也会让它们排队逐个执行不会出现超卖。退课同理是反向操作删除选课记录同时把selected_count减1同样放一个事务里。减1时注意别减成负数SQL里可以加selected_count 0条件。4.3 JSP页面渲染EL表达式与JSTL代替ScriptletJSP页面是前端展示层。我在实际评审别人项目时最常见的坏味道就是JSP里大量使用% %Scriptlet整个页面被Java代码塞满看起来像八九年前的代码。不要这么写JSP有更好的方式EL表达式和JSTL标签库。EL表达式用${}来取值比如登录之后在页面上显示当前学生姓名可以写欢迎你${loginStudent.stuName}如果你的JSP页面要循环输出课程列表能用c:forEach标签而不是在页面里写一段for循环Java代码。JSTL的使用步骤是在pom.xml引入jstl依赖在JSP页面最顶部加taglib指令。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:forEach items${courseList} varcourse varStatusstatus tr td${course.courseNo}/td td${course.courseName}/td td${course.credit}/td td${course.teacherName}/td td${course.selectedCount} / ${course.capacity}/td td c:if test${course.selectedCount course.capacity} a hrefselectCourse?id${course.id}选课/a /c:if /td /tr /c:forEach把Servlet里setAttribute到request的课程列表用${courseList}取出来再通过c:forEach循环渲染。这样页面里基本上看不到一行Java代码改样式的时候不会动到业务逻辑维护起来舒服得多。关于JSP引入公共页头页尾比如导航栏有两种方式% include fileheader.jsp %是静态包含在编译阶段把内容合并成一个Servletjsp:include pageheader.jsp /是动态包含运行时单独编译再合并输出。导航栏这种固定模块用静态包含就够编译快遇到变量冲突的概率也更小。5. 踩坑实录报错排查与避坑指南5.1 404/500/ClassNotFoundException的排查思路Web项目跑起来之后报错是常态不报错才奇怪。但很多初学者看到报错就慌其实排查有固定的套路。404错误最常见的原因有三个一是浏览器访问的URL路径和Servlet配置的url-pattern不一致二是应用上下文路径Application context不对三是文件放在WEB-INF目录下但是URL写成了直接访问路径。拿登录功能举例如果Servlet配置是WebServlet(/studentLogin)那么表单的action应该写${pageContext.request.contextPath}/studentLogin如果忘了加contextPath部署时应用上下文是/student-course访问时就会变成/studentLogin而不是/student-course/studentLogin此时必然404。500错误的排查思路完全不一样。控制台会打出一大堆异常栈我看过太多人只看第一行就慌其实应该往下翻找到Caused by那一段那才是真正出错的原因。一个很典型的连锁报错是Servlet.service() for servlet [StudentLoginServlet] in context with path [/student-course] threw exception下面跟着一堆最后caused by是java.lang.NullPointerException。这时候大概率是request.getParameter(stuNo)返回了null然后你直接调用了null的东西或者是你查数据库返回null后没有判空就调getAttr。空指针的处理原则是先判空再操作。ClassNotFoundException也一样如果报找不到com.mysql.cj.jdbc.Driver多半是mysql-connector依赖只写在了pom.xml里但Maven没有把jar包同步部署到Tomcat的WEB-INF/lib里。解决办法是打开IDEA右侧Maven面板点刷新Reload All Maven Projects确认Artifacts里有lib element或者干脆clean以后重新部署问题就解决了。5.2 中文乱码请求、响应、数据库三个层面中文乱码是Java Web的老大难但把三个层面都理顺就再也不会被它困扰。第一层是请求乱码。POST请求的中文参数必须在读取任何参数之前执行request.setCharacterEncoding(UTF-8)比如在Filter里统一设置。GET请求的参数在URL里Tomcat 8以上默认URI编码已经是UTF-8一般不会乱码但如果你用的老版本Tomcat要在server.xml的Connector上加URIEncodingUTF-8。第二层是响应乱码。JSP页面最顶部要有% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 %Servlet里往响应写中文时要设置response.setContentType(text/html; charsetUTF-8)不要用text/html; charsetGBK。第三层是数据库乱码。建库时指定字符集CREATE DATABASE course_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接URL里也要带characterEncodingutf8。三层都对了数据库里存的就是正常中文。你如果发现数据库里全是问号??先去查连接URL有没有指定编码再去查表和库的字符集十有八九是这两个地方漏了。5.3 并发选课压测与容量超卖修复选课系统上线后真正考验它的是选课高峰期的并发。用JMeter或者Postman的Runner模式模拟20个用户同时对同一门只剩1个名额的课程发起选课请求你会发现没做过并发处理的系统大概率会产生多条选课记录capacity明明只剩1结果实验成功选课的人有3个。这就是超卖。解决思路在上文说过了核心就是那条原子更新语句UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity;这是数据库层面一行语句级别的原子操作InnoDB默认对UPDATE加行锁两个并发请求会排队第二个更新时要么容量已经满了受影响行数为0整个事务回滚要么正常减掉。我用JMeter实测过把这条SQL放进事务里之后20个并发请求最终只有1个成功不会超卖。这个实验强烈建议你自己复现一遍能把事务和并发的理解提升一个档次。再补一个查询性能优化点课程列表页面如果每门课都去查一次选课表判断“当前学生是否已选”会产生N1查询问题。正确做法是先查出当前学生所有已选课程ID放到一个Set里然后遍历课程列表时直接用Set判断这样查询次数从N1降到了2。我的实操心得做完这个项目后我自己最大的改变是养成了“先设计后编码”的习惯尤其是数据库表设计。第一次做选课系统时我没建唯一约束测试时手动往库里插了重复选课记录页面显示逻辑就乱了排查了很久才发现是数据脏了。从那以后凡是重要的业务关系我都会在数据库层面加约束兜底因为代码逻辑可能漏但数据库约束不会骗人。另一个体会是JSP页面里真的不要写Java代码我当时为了图快把选课判断逻辑写在JSP里后面需求一改翻页找代码特别痛苦后来全改成Service层处理维护起来省心太多。这个项目还有很多可以扩展的方向。比如注册时用Ajax异步校验学号是否已存在表单提交体验会流畅很多课程列表接口可以做成JSON返回前端用Vue或原生JS渲染管理员导出选课名单为Excel用POI工具包就能实现再进阶一点把课程列表加一层Redis缓存锻炼缓存更新策略。这些都是很好的加分项但前提是先把基础功能做稳、做透。如果你正卡在某个报错调试不出来我的建议是不要急着复制粘贴答案先看完整异常栈定位到Caused by再顺着报错去对照代码通常问题都不大。选课系统这个项目就是一个很好的练兵场把它吃透了Java Web面试里关于Servlet、事务、并发、SQL的问题你都能聊得有理有据而不是背答案。本文还有配套的精品资源点击获取