ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# WinForms学生信息管理系统(选课):数据库设计与并发避坑实践

C# WinForms学生信息管理系统(选课):数据库设计与并发避坑实践 简介面向C# Windows窗体初学者的学生信息管理与选课系统完整源码包基于SQL Server数据库采用经典三层架构。系统设有学生端与教师端双登录入口覆盖管理员对学生信息录入、修改、查询、删除教师发布课程学生上传个人资料并选课等完整业务闭环同时演示图片存储与常见增删查改操作可直接用于课程设计、毕业设计或自学练手。资源共141个文件以45个C#源文件为核心并附DLL、PDB、配置文件与资源文件等压缩包仅1.5MB轻量易下载。包内提供数据库表结构代码和简要说明可快速生成数据库并规避版本差异。目前已有4416人学习适合希望通过完整项目掌握WinForms与数据库交互、梳理UI层/业务层/数据访问层调用关系理解三层架构思想的开发者。1. C# windows窗体学生信息管理系统选课(含数据库)抄作业也得知道哪部分是宝哪部分是坑很多 C# 学习者第一次接触 windows窗体 数据库都是从学生信息管理系统这类题目开始的。这个标题看起来平平无奇——一张学生表、一张课程表、一张选课记录表再加几个窗体能增删改查就算完事。但真正动手以后你会发现卡死、数据错乱、选课超员、换台电脑连不上库全卡在数据库设计、事务边界和控件绑定时机上。这套系统名义上练窗体骨子里练的是三层架构、SQL 约束与异常处理。它适合刚学完 C# 基础、想用完整案例把知识串起来的初学者也适合在企业内部做学员选课、培训排课工具的从业者——拿它当骨架改改字段就能交付。2. 从表结构开始三张表把“选课”的边界一次锁死2.1 为什么先把数据库设计定下来而不是先画窗体我见过不少新手的做法先把 Form1 拖一堆控件再回头建数据库最后发现字段对不上窗体改得面目全非。另一个常见做法是只建两张表——学生表和课程表选课记录用逗号拼在课程表的一个字段里比如把选课学生学号拼成 “1001,1002,1003”。这种设计在数据量小的时候勉强能跑一旦有人退课字符串的拆分和拼接就成了事故多发点统计人数也极不自然。先设计表的根本原因是选课业务有三个天然边界必须由数据库守住——一个人不能重复选同一门课、选课人数不能超过课程容量、删除学生或课程时不能留下孤儿记录。这三个边界如果在窗体代码里靠if去判断等于把保险箱钥匙放在保险箱旁边。表结构定下来后面的窗体、数据访问层、部署全都有章可循。常见做法是三张表学生表负责基本信息课程表负责容量和已选人数选课记录表负责“谁在什么时间选了哪门课”。课程表上单独放一个SelectedCount字段而不是每次统计选课记录表原因后面展开——它和Capacity放在同一行可以让数据库在一条 UPDATE 语句里完成“判断容量 占用名额”的原子操作避免并发抢课超员。2.2 建库脚本学生表、课程表、选课记录表以 SQL Server 为例初版建库脚本如下/* 学生信息管理系统选课建库脚本 */ CREATE DATABASE StudentDB; GO USE StudentDB; GO -- 学生表 CREATE TABLE Student ( StudentID INT NOT NULL IDENTITY(1001, 1), -- 内部主键自增 StudentNo VARCHAR(20) NOT NULL UNIQUE, -- 对外学号可能含字母 Name NVARCHAR(20) NOT NULL, Gender NCHAR(1) CHECK (Gender IN (男, 女)), ClassName NVARCHAR(40) NULL, Phone VARCHAR(20) NULL, PRIMARY KEY (StudentID) ); GO -- 课程表 CREATE TABLE Course ( CourseID INT NOT NULL IDENTITY(1, 1), CourseName NVARCHAR(50) NOT NULL, TeacherName NVARCHAR(20) NULL, Capacity INT NOT NULL DEFAULT 30, -- 课程容量 SelectedCount INT NOT NULL DEFAULT 0, -- 已选人数 ClassTime NVARCHAR(50) NULL, -- 例如“周一 3-4节” PRIMARY KEY (CourseID) ); GO -- 选课记录表 CREATE TABLE SC_Record ( RecordID INT IDENTITY(1,1) PRIMARY KEY, StudentID INT NOT NULL, CourseID INT NOT NULL, SelectTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT UQ_SC_Student_Course UNIQUE (StudentID, CourseID), CONSTRAINT FK_SC_Student FOREIGN KEY (StudentID) REFERENCES Student(StudentID), CONSTRAINT FK_SC_Course FOREIGN KEY (CourseID) REFERENCES Course(CourseID) ); GO这里有几个参数值得细说。StudentID用IDENTITY自增当内部主键StudentNo单独做唯一约束是因为实际学号可能带字母、前导零不适合当主键对外展示用StudentNo内部关联用StudentID两边互不干扰。Gender的CHECK约束把性别限定在「男/女」就算窗体端漏校验数据库也能兜底。SC_Record的RecordID自增主键是为了退课和补退记录有据可查真正防重复的是UQ_SC_Student_Course这个唯一约束——它保证同一个学生同一门课只能有一条记录靠代码反复查重永远不如这个约束可靠。2.3 选课的核心规则容量、唯一性与事务边界选课操作最难的不是“插入一条记录”而是“先判断再插入”这个过程不能被打断。先查人数再插入这种方式在单机练习时看不出问题一旦多个学生同时选课两个请求都读到“还剩 1 个名额”然后都执行插入课程就超员了。所以选课必须在一个事务里完成而且要反过来写先让数据库尝试“占名额”占不到就回滚而不是先读再写。最小可用的事务 SQL 是BEGIN TRANSACTION; UPDATE Course SET SelectedCount SelectedCount 1 WHERE CourseID CourseID AND SelectedCount Capacity; IF ROWCOUNT 0 BEGIN ROLLBACK TRANSACTION; RETURN; -- 容量已满或课程不存在 END INSERT INTO SC_Record (StudentID, CourseID) VALUES (StudentID, CourseID); COMMIT TRANSACTION;这段脚本的关键在于UPDATE语句的WHERE条件。Capacity和SelectedCount在同一行UPDATE会在行级别加锁第二个并发请求只能等第一个请求提交或回滚后再执行。如果SelectedCount已经等于CapacityUPDATE影响行数为 0事务直接回滚根本不进入插入环节。把容量判断写进 UPDATE 而不是写在 C# 的 if 里是我做这类系统最强烈的一条建议。时间冲突的问题则不建议用数据库约束硬做。ClassTime是NVARCHAR描述的“周一 3-4节”这种人类可读文本跨表比较上课时间冲突需要解析字符串放在 SQL 里代价太高。常见做法是在选课窗体里把学生已选课程的ClassTime查出来加载可选课程时直接过滤掉时间冲突的课程C# 端一行字符串比较就解决数据库保持简单。3. 数据访问层两步走连接串、参数化查询与泛型委托封装3.1 连接串先写对区分开发环境与部署环境窗体项目里的连接串一般放在App.config的connectionStrings节点程序运行时通过ConfigurationManager读取。常见写法是connectionStrings add nameStudentDB connectionStringData Source.;Initial CatalogStudentDB;User Idsa;Password123456; providerNameSystem.Data.SqlClient / /connectionStrings参数含义Data Source.表示本机 SQL Server 默认实例如果本机装的是 Express要写成.\SQLEXPRESSInitial Catalog指定数据库名User Id和Password是 SQL Server 登录账号。开发时也可以用 Windows 身份验证把后面两项换成Integrated SecuritySSPI。这里最容易埋雷的是本机开发用的实例名、账号密码到了另一台电脑上基本全失效。所以在项目一开始就要约定好部署形式常见做法是开发用.\SQLEXPRESS部署时目标机器安装 SQL Server Express 并创建同样账号或者直接改用 Windows 身份验证减少密码问题。连接串写死本机默认实例到交付阶段就是第一个翻车点。3.2 一个够用的 SqlHelper参数化查询是底线很多初学 C# 的人会把 SQL 直接拼在窗体按钮事件里例如string sql DELETE FROM Student WHERE StudentID txtId.Text;。这种写法最大的问题不是难看而是 SQL 注入用户在文本框里输入1; DROP TABLE Student; --整张表就没了。另一个问题是参数和字符串混在一起SQL 报错时很难分清是语法错还是类型错。我一般会在数据访问层放一个极简的SqlHelper只封装连接、执行和读取三件事public class SqlHelper { private static readonly string _connStr ConfigurationManager.ConnectionStrings[StudentDB].ConnectionString; public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (SqlConnection conn new SqlConnection(_connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } public static DataTable GetDataTable(string sql, params SqlParameter[] ps) { DataTable dt new DataTable(); using (SqlConnection conn new SqlConnection(_connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) using (SqlDataAdapter da new SqlDataAdapter(cmd)) { if (ps ! null) cmd.Parameters.AddRange(ps); da.Fill(dt); } return dt; } }using保证连接和命令对象在方法结束前被释放这是 ADO.NET 最容易漏掉的资源管理。调用端写new SqlParameter(id, txtId.Text.Trim())而不是把值拼进字符串SQL Server 会把参数当作数据而非代码来解析注入路径就被堵死了。ExecuteNonQuery返回受影响行数可以用来判断删除、更新是否真的作用到了数据。3.3 用泛型委托把查询结果直接映射成实体只靠GetDataTable的话每个窗体都要写dt.Rows[i][CourseName].ToString()取错列名要等运行时才报错。更进一步的做法是利用 C# 泛型委托FuncIDataReader, T写一个通用的查询映射方法public static ListT QueryT(string sql, FuncIDataReader, T map, params SqlParameter[] ps) { ListT list new ListT(); using (SqlConnection conn new SqlConnection(_connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { while (reader.Read()) { list.Add(map(reader)); } } } return list; }调用端把每一行的读取逻辑通过委托传进来public ListCourse GetAllCourses() { string sql SELECT CourseID, CourseName, TeacherName, Capacity, SelectedCount FROM Course; return SqlHelper.Query(sql, r new Course { CourseID r.GetInt32(0), CourseName r.GetString(1), TeacherName r.IsDBNull(2) ? string.Empty : r.GetString(2), Capacity r.GetInt32(3), SelectedCount r.GetInt32(4) }); }注意r.IsDBNull(2)的判断如果 SQL 查询结果里某个字段是 NULL直接调用GetString会抛异常所以可空列必须先判断再取值这是手写映射最常踩的坑。泛型委托的收益在于每个实体的取值逻辑只写一次窗体端拿到的直接是ListCourse完全不需要知道DataTable怎么用。过去用设计器拖出来的强类型 DataSet 看着方便一旦数据库字段改了要重新生成一遍运行时报错时整个链路像个黑匣子手写数据访问层反而更好控制。4. 把窗体做活登录、列表与选课界面的核心交互4.1 登录窗体参数化验证与进入主窗体登录窗体做的是最基础的“查询用户是否存在”重点不是界面多漂亮而是校验逻辑要完整。用户名和密码都要走参数化查询密码一般不在数据库里存明文常见做法是 MD5 或 SHA256 哈希后存储。登录按钮的代码框架如下private void btnLogin_Click(object sender, EventArgs e) { string sql SELECT COUNT(*) FROM Admin WHERE LoginName name AND LoginPwd pwd; SqlParameter[] ps { new SqlParameter(name, txtUser.Text.Trim()), new SqlParameter(pwd, HashHelper.Md5(txtPwd.Text)) }; int count (int)SqlHelper.ExecuteScalar(sql, ps); if (count 0) { MainForm main new MainForm(); main.Show(); this.Hide(); } else { MessageBox.Show(用户名或密码错误); } }ExecuteScalar取的是结果集第一行第一列这里就是COUNT(*)的值。注意txtUser.Text.Trim()去掉首尾空格避免用户手滑多敲一个空格导致登录失败。密码哈希后比对数据库里即使被拖库也不会直接泄露明文密码。登录成功后this.Hide()而不是Close()是为了保留登录窗体的资源退出主窗体时再统一释放如果直接Close()MainForm 会因为没有消息循环而闪退。4.2 主窗体加载学生列表绑定 DataSource别在循环里 AddRow主窗体最常见的功能是展示学生列表。很多卡顿问题都出在加载方式上——在foreach里一行一行调用dgv.Rows.Add()每加一行 DataGridView 都要重绘一次控件一多窗口拖起来像幻灯片。正确做法是先准备好DataTable或ListT一次性赋给DataSourceprivate void LoadStudentList() { string sql SELECT StudentNo, Name, Gender, ClassName FROM Student; DataTable dt SqlHelper.GetDataTable(sql); dgvStudent.AutoGenerateColumns false; dgvStudent.DataSource dt; dgvStudent.Columns[colNo].DataPropertyName StudentNo; dgvStudent.Columns[colName].DataPropertyName Name; dgvStudent.Columns[colGender].DataPropertyName Gender; dgvStudent.Columns[colClass].DataPropertyName ClassName; }AutoGenerateColumns false是为了禁用自动生成列然后手动把设计器里加好的列与DataPropertyName对应起来。好处是列名、列宽、显示顺序完全可控数据库字段改名时只需要改映射行不用动控件。每次刷新数据时重新GetDataTable再赋值DataSourceDataGridView 会整体替换数据比逐行增删稳定得多。如果数据量确实大还可以给 DataGridView 开双缓冲用反射把DoubleBuffered属性设为true减少重绘闪烁。这个技巧在小工具里不一定要用但控件多的窗体上它往往是最后一根救命稻草。4.3 选课窗体容量刷新、防双击与事务提交选课窗体是所有逻辑最集中的地方。界面一般是左侧学生信息、右上课程下拉框、右下已选人数与容量中间一个按钮“选课”。加载课程到下拉框的写法private void LoadCourses() { ListCourse courses courseDao.GetAllCourses(); cmbCourse.DataSource courses; cmbCourse.DisplayMember CourseName; // 下拉框显示课程名 cmbCourse.ValueMember CourseID; // SelectedValue 取课程ID RefreshCourseInfo(); } private void RefreshCourseInfo() { if (cmbCourse.SelectedValue null) return; int courseId Convert.ToInt32(cmbCourse.SelectedValue); Course course courseDao.GetCourseById(courseId); lblCapacity.Text course.Capacity.ToString(); lblSelected.Text course.SelectedCount.ToString(); }DisplayMember和ValueMember是 ComboBox 绑定的两个关键属性前者决定用户看到什么后者决定代码取到什么。RefreshCourseInfo在下拉框切换时也要调用挂到SelectedIndexChanged事件上保证用户换课程立刻看到最新人数。选课按钮的提交逻辑里最重要的一件事是防双击。Click 事件第一次触发后如果里面执行了 SQL 事务耗时可能有几百毫秒用户手一抖再点一下就会发两次请求。最简单可靠的办法是在进入提交逻辑的第一行把按钮禁用在 finally 里恢复private void btnSelect_Click(object sender, EventArgs e) { btnSelect.Enabled false; try { int studentId CurrentStudent.StudentID; int courseId Convert.ToInt32(cmbCourse.SelectedValue); int result courseDao.SelectCourse(studentId, courseId); if (result -1) { MessageBox.Show(该课程已满员); } else { MessageBox.Show(选课成功); RefreshCourseInfo(); } } catch (Exception ex) { MessageBox.Show(选课失败 ex.Message); } finally { btnSelect.Enabled true; } }注意防双击不能只靠界面禁用按钮因为两个请求可能来自两个不同的窗体实例甚至两个不同用户。真正的兜底是SC_Record表上的唯一约束和 2.3 节那个事务 SQL。C# 端调用的SelectCourse直接执行那一段事务批处理用ExecuteScalar拿返回值判断成功还是满员。事务必须写在一条 SQL 批处理里不能拆成多次连接因为 SQL Server 的事务不能跨多个SqlConnection生命周期。4.4 退课删除记录必须同时回补名额退课比选课更隐蔽的坑是只删SC_Record记录忘了把Course.SelectedCount减回去。结果就是课程明明没人选人数还挂着。退课和回补名额必须放在同一个事务里BEGIN TRANSACTION; DELETE FROM SC_Record WHERE StudentID StudentID AND CourseID CourseID; IF ROWCOUNT 1 UPDATE Course SET SelectedCount SelectedCount - 1 WHERE CourseID CourseID; COMMIT TRANSACTION;先删选课记录删成功才回补人数如果记录根本不存在ROWCOUNT为 0人数不更新也不会产生负数。这个逻辑放在 C# 端也不难写但同样要注意删除和更新必须走同一个事务否则程序中途异常就会出现人数和记录对不上的脏数据。5. 选课系统避坑清单5 个把新手卡到凌晨的典型翻车现场5.1 卡顿DataGridView 绑几十行就把窗口拖僵现象窗体加载学生列表后拖拽窗口明显掉帧CPU 占用居高不下点击行还要卡一下。原因加载数据时用了for循环配合dgv.Rows.Add()每加一行、每赋一个单元格值DataGridView 都触发一次重绘。如果窗体上再堆几十个控件UI 线程被重绘任务拖死这就是典型的控件多导致 WinForms 卡顿。另一个隐藏原因是加载查询放在了 UI 线程里数据库响应慢时界面直接假死。解决数据先装入DataTable或ListT一次性绑定DataSource耗时查询放到Task.Run或BackgroundWorker里回调时再赋值给控件。列表操作全部面向内存数据不要频繁访问数据库。这是血泪经验DataGridView 在数据量小时看不出差别一旦数据量上来绑定方式决定生死。5.2 并发超选同一门课同一秒被两个人选走现象课程容量设 30放出去几小时后后台一查选了 31 个人。原因选课代码是“先 SELECT 已选人数if 小于容量再 INSERT”。两个客户端同时执行 SELECT 时都读到 29都判断“还可以选”然后双双 INSERT超员就发生了。问题本质是检查与写入之间存在时间窗口这个窗口在并发下必然被钻空子。解决把容量判断写进 UPDATE 语句的条件里用UPDATE Course SET SelectedCount SelectedCount 1 WHERE CourseID cid AND SelectedCount Capacity数据库行锁保证同一时刻只有一个请求能成功把人数加 1影响行数为 0 的请求直接回滚。SELECT 再判断的写法只适合单机演示不适合任何真实选课场景。5.3 双击重复点一下按钮插进去两条选课记录现象手一抖双击“选课”学生选课记录里出现两条相同课程记录课程人数也加了 2。原因按钮的 Click 事件没做防抖第一次点击提交事务时按钮仍可点击第二次点击又完整走了一遍插入逻辑。界面层没有拦截数据层也没有唯一约束兜底。解决进入事件处理时立刻把按钮Enabled设为falsefinally里恢复这是第一道防线。第二道防线是SC_Record表上的UQ_SC_Student_Course唯一约束即使两个请求同时到达数据库也只允许插入一条第二条会抛唯一键冲突事务回滚后人数也不会多加。唯一约束不是可有可无的装饰它是防重复的后悔药。5.4 外键拦截删除学生信息时提示“被引用”现象在学生列表里选一个已有选课记录的学生点删除程序直接报错提示无法删除因为SC_Record里有外键引用。原因FK_SC_Student外键约束的存在就是为了防止删除父表记录后留下孤儿选课记录这是数据库在保护数据完整性不是系统出 bug。解决两种方案。第一种是物理删除前先删选课记录并用事务同时回补对应课程的人数第二种更稳妥业务上把“删除”改成“停用”给学生表加IsActive字段删除操作只是把状态置 0历史选课记录全部保留数据可回溯。培训类系统我倾向第二种因为选课记录涉及成绩、考勤物理删除后历史账目说不清。不加思考地给外键加ON DELETE CASCADE是最危险的做法一删一大片数据恢复几乎不可能。5.5 部署环境差异换台电脑就连不上数据库现象程序在自己电脑上跑得好好的拷到同事电脑或客户电脑上打开就报“无法连接到数据库”或“登录失败”。原因连接串里写的是本机默认实例Data Source.目标机器根本没有 SQL Server或者只有 Express 实例就算装有 SQL Serversa密码和 TCP/IP 协议也不一定对得上。数据库文件.mdf直接双击附加不适用于所有版本LocalDB 实例名也要匹配。解决部署前把连接串改为明确的 SQL Server Express 实例名例如Data Source.\SQLEXPRESS;Initial CatalogStudentDB;User Idsa;Password...并在目标机器上确认 SQL Server 服务已启动、TCP/IP 协议已开启。比这更省心的做法是不要把数据库当文件拷而是交付一个初始化脚本在目标机器上执行建库和建表语句保证表结构、账号、权限都是一次性创建的。连接串单独放配置文件的另一个好处是现场部署时改配置文件就可以不用重新编译程序。6. 交付前的最后三件事统计报表、Excel 批量导入与部署脚本系统能选课、能退课只是及格真正能交付还要过三关管理端要看得见整体选课情况录学生信息不能靠一条一条敲部署时不能环境不适配。统计报表最实用的一条 SQL 是按课程统计已选人数与容量占比SELECT c.CourseName, c.TeacherName, c.SelectedCount, c.Capacity, CAST(c.SelectedCount * 100.0 / c.Capacity AS DECIMAL(5,1)) AS FillRate FROM Course c ORDER BY c.SelectedCount DESC;SelectedCount * 100.0里乘一个小数是为了让 SQL Server 按浮点运算而不是整数除法否则 29/30 会算出 0。排序后一眼看出哪些课选满、哪些课该加场次管理端报表放这个查询基本够用。批量导学生信息时常见做法是先用 NPOI 把 Excel 读成DataTable再调SqlBulkCopy一次性写入 SQL Server。它比逐条 INSERT 快一个量级但写入前要自己先做重复学号检查数据库的唯一约束会拦截重复可报错信息不友好批量导入前先清洗数据是必须的。部署脚本我一般写成简单的初始化工具先检查服务器上是否存在StudentDB不存在就执行建库脚本最后校验管理员账号存在且密码可登录。这样换机器交付时执行一次工具即可不用开着 SQL Server Management Studio 手动操作。我最早给单位做选课工具时把容量判断放在 C# 端觉得这样才“看得见逻辑”结果抢课那天人数超了 8 个。后来把事务整体挪进 SQL一次改动根除问题。从那以后我养成一个习惯凡是涉及计数、余额、名额这类写操作判断条件一定写进 UPDATE 语句里而不是先查再写——界面可以崩数据不能错。希望帮到你。本文还有配套的精品资源点击获取
返回列表