基于C#的三层架构学生信息管理系统:从增删查改到参数化SQL
2026/9/17 1:32:58 网站建设 项目流程

简介:这套基于C# Windows与SQL Server数据库的学生信息管理系统源码,采用经典三层架构组织,面向需要课程设计、毕业设计训练,或想理解分层开发思路的C#初学者。系统完整实现了用户登录注册、学生信息的添加、删除、查找和修改,并支持学号精确查询与姓名模糊查询,功能贴合日常教务管理场景,适合作为教学型项目研读。资源整体为zip压缩包,共127个文件,大小仅2.26MB;内部以C#源文件为主,覆盖窗体界面、业务逻辑与数据访问三个层次,同时包含动态链接库、调试符号、可执行程序、配置文件及界面资源等,项目结构清晰,可直接用Visual Studio打开运行。目前已有367人学习下载,具有一定参考价值。开发者还可借助包内附带的演示录屏链接,直观查看登录、注册、增删查改等各项功能的实际运行效果,从而更快掌握源码结构并完成二次开发。

1. 学生信息管理系统里谈三层架构,不是小题大做

很多人在做数据库课程设计时,最先接触的C# windows程序就是学生信息管理。最常见写法是:窗体上拖一个DataGridView,再拖几个TextBox,按钮点击事件里new一个SqlConnection,拼一条SQL,ExecuteNonQuery一下,然后重新查一遍表再绑定一次。功能当时是跑通了,但等到老师说要加一个“班级管理”或“导出Excel”,界面和SQL混在一起的代码就开始失控。这里说的“C# windows基于三层架构的学生信息管理系统(常规-增删查改)(带数据库)”,其实就是把增删查改按“界面层—业务层—数据访问层”拆开,再加一个实体类专门搬运数据。三层不解决性能问题,解决的是“改动成本”问题:数据库字段变了,可能只需要动数据访问层;校验规则变了,动业务层;输入形式变了,动界面层。这篇文章给出一个能直接复用的项目骨架、完整的参数化SQL增删查改代码,以及Windows窗体里最容易翻车的几个绑定细节。刚入门C#的人可以照着敲,写过一段时间的也可以看看自己在分层时把代码放错了位置没有。

2. 三层架构的项目结构、实体类与数据库连接串,先把地基打对

2.1 搭建解决方案:五个项目而不是三个

理论上的三层架构是表示层(UI)、业务逻辑层(BLL)、数据访问层(DAL),但实际做C# WindowsForms课程设计时,我一般会再加两个项目:一个放实体类(Model/Entities),一个放公共工具(Common)。原因很直接:DAL查询出来的一组字段,如果每次都塞进DataTable,那么BLL和UI就仍然依赖字符串形式的列名,等于换了个方式耦合。实体类把表和对象之间做了一次映射,BLL和UI看到的都是强类型的Student对象。

新建项目的推荐顺序是:

  1. 新建WindowsForms应用作为UI层。
  2. 在解决方案里依次新建类库:Model、DAL、BLL、Common。
  3. 设置引用关系:UI引用BLL和Model;BLL引用DAL和Model;DAL引用Model和Common。Common可以被DAL、BLL、UI共同引用。
  4. 把UI项目设为启动项目,再把DAL和Common的输出目录设置与UI一致,避免运行时找不到DLL。

在VS2019或VS2022里,直接右键解决方案,添加新建项目,选择“类库(.NET Framework)”或者“.NET类库”都行。如果是老式的课程设计环境,多半是.NET Framework 4.x,对应System.Configuration.dll里的ConfigurationManager。如果你用的是.NET 6以上的WindowsForms,读取连接串的写法略有区别,后面代码里我会标注。

2.2 实体类与数据库表字段的映射

学生表至少要有这些字段:StudentId主键、StudentNo学号、StudentName姓名、Gender性别、BirthDate出生日期、ClassName班级、Phone联系电话。实体类写成:

public class Student { public int StudentId { get; set; } public string StudentNo { get; set; } public string StudentName { get; set; } public string Gender { get; set; } public DateTime? BirthDate { get; set; } public string ClassName { get; set; } public string Phone { get; set; } }

注意BirthDate用了DateTime?,也就是可空日期。道理很简单:不是每个学生的出生日期都会被录入,数据库里的BirthDate列也可能是NULL。如果用DateTime,查出来NULL时,reader.GetDateTime会直接抛异常。可空类型配合.IsNull判断,才是稳妥做法。

实体类里可以写属性,但不要写SQL,不要写窗体逻辑。有人喜欢在Student类里放一个Insert()方法,这在三层架构里属于坏味道。实体只负责数据形态,不负责数据行为。

2.3 数据库连接串写进App.config,不要new一个写死

很多入门代码里有这种写法:

string connStr = "server=.;database=StudentDB;uid=sa;pwd=123456";

这条串在一个项目里出现七八次。等到换机器部署,数据库密码改一下,就得全文替换,还容易漏。常规做法是放进App.config:

<configuration> <connectionStrings> <add name="Default" connectionString="Data Source=.;Initial Catalog=StudentDB;Integrated Security=True" providerName="System.Data.SqlClient" /> </connectionStrings> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" /> </startup> </configuration>

在DAL里统一通过一个静态类读取:

using System.Configuration; public class DbHelper { public static string ConnString { get { return ConfigurationManager.ConnectionStrings["Default"].ConnectionString; } } }

这样写有三个好处:连接串只有一个来源;换服务器改配置不用重新编译;如果将来改用不同数据库,连接串和Provider可以分离。真到那时,DAL里的SqlConnection也需要换掉,但至少不用在界面层找连接串。

3. 数据访问层:增删查改必须写成参数化SQL,从根上避免SQL注入

3.1 为什么拼字符串一定会有风险

学生信息管理系统的增删查改,看起来只有四个操作,但最常被忽视的就是SQL生成方式。最常见的错误写法是:

string sql = "INSERT INTO Student VALUES('" + txtNo.Text + "', '" + txtName.Text + "')";

假如用户在学号框里输入:

1'); DELETE FROM Student; --

拼出来之后就是两条语句,后果不用多说。即使不考虑恶意输入,姓名里有英文单引号时也会报错。三层架构的数据访问层就是为了把这类操作收敛到DAL一个地方,在DAL里用参数化SQL解决。

参数化SQL的本质不复杂:把SQL文本和参数值分开传送给SQL Server,由数据库驱动负责对值做类型化处理。写出来是这样的:

string sql = "INSERT INTO Student(...) VALUES(@StudentNo, @StudentName, ...)"; cmd.Parameters.AddWithValue("@StudentNo", s.StudentNo);

这样用户输入的单引号、百分号、连字符都会被当成普通值处理,而不是SQL语法的一部分。

3.2 Insert/Update/Delete方法的实现细节

DAL层写了增删查改的四个方法。插入和更新看起来差不多,但参数绑定上有细节区别。以插入为例,完整方法是:

using System; using System.Data; using System.Data.SqlClient; using Student.Model; namespace Student.DAL { public class StudentDal { private string _connStr = DbHelper.ConnString; public int Insert(Student s) { const string sql = @" INSERT INTO Student (StudentNo, StudentName, Gender, BirthDate, ClassName, Phone) VALUES (@StudentNo, @StudentName, @Gender, @BirthDate, @ClassName, @Phone)"; using (SqlConnection conn = new SqlConnection(_connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@StudentNo", SqlDbType.NVarChar, 20).Value = s.StudentNo; cmd.Parameters.Add("@StudentName", SqlDbType.NVarChar, 50).Value = s.StudentName; cmd.Parameters.Add("@Gender", SqlDbType.NVarChar, 10).Value = s.Gender; if (s.BirthDate.HasValue) cmd.Parameters.Add("@BirthDate", SqlDbType.Date).Value = s.BirthDate.Value; else cmd.Parameters.Add("@BirthDate", SqlDbType.Date).Value = DBNull.Value; cmd.Parameters.Add("@ClassName", SqlDbType.NVarChar, 50).Value = s.ClassName; cmd.Parameters.Add("@Phone", SqlDbType.NVarChar, 20).Value = s.Phone; conn.Open(); return cmd.ExecuteNonQuery(); } } } }

这里不推荐AddWithValue,原因在于很多新手踩过这种坑:参数值是C#的string,而数据库列是varchar(50)AddWithValue会自动按推断类型传参,偶尔会因为长度或类型不匹配导致索引失效、查询变慢,甚至把日期存错。Add方法显式指定SqlDbType和长度,虽然在代码上多写一点,但排查问题的时候会舒服很多。

BirthDate可空参数的处理是最容易漏的地方:直接把s.BirthDate传进去,很可能变成String.Empty或者类型转换异常。标准写法是判断HasValue,没有值就传DBNull.Value。这不是SQL Server特有要求,只要数据库字段允许NULL,就要把空缺显式告诉数据库。

Update和Delete的结构几乎一致。Update时注意WHERE条件用@StudentId,而且主键参数不应该被更新。Delete方法更简单:

public int Delete(int studentId) { const string sql = "DELETE FROM Student WHERE StudentId = @StudentId"; using (SqlConnection conn = new SqlConnection(_connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@StudentId", SqlDbType.Int).Value = studentId; conn.Open(); return cmd.ExecuteNonQuery(); } }

返回受影响行数,通常建议用这个而不是ExecuteScalar去查@@ROWCOUNT。如果返回0,界面层可以直接提示“没有删除任何记录”,这样比捕获异常来判断存在性更自然。

3.3 查询方法:返回List 而不是拼好字符串传回UI

查询的形态,在界面层往往有两种诉求:按学号精确查,按姓名模糊查。把它们合成一个搜索词参数,可以少写一个方法:

public List<Student> Search(string keyword) { string sql = @" SELECT StudentId, StudentNo, StudentName, Gender, BirthDate, ClassName, Phone FROM Student WHERE (StudentNo LIKE @kw OR StudentName LIKE @kw) ORDER BY StudentNo"; var list = new List<Student>(); using (SqlConnection conn = new SqlConnection(_connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.Add("@kw", SqlDbType.NVarChar, 50).Value = "%" + keyword + "%"; conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new Student { StudentId = reader.GetInt32(reader.GetOrdinal("StudentId")), StudentNo = reader.GetString(reader.GetOrdinal("StudentNo")), StudentName = reader.GetString(reader.GetOrdinal("StudentName")), Gender = reader.GetString(reader.GetOrdinal("Gender")), BirthDate = reader.IsDBNull(reader.GetOrdinal("BirthDate")) ? null : reader.GetDateTime(reader.GetOrdinal("BirthDate")), ClassName = reader.GetString(reader.GetOrdinal("ClassName")), Phone = reader.IsDBNull(reader.GetOrdinal("Phone")) ? null : reader.GetString(reader.GetOrdinal("Phone")) }); } } } return list; }

注意模糊匹配的参数值是%+ keyword +%,不是SQL里写LIKE '%@kw%'。很多人在这里会写错,写成后一种形式后,SQL Server会把@kw当作字符串内容的一部分,永远查不到结果。

GetOrdinal可以避免直接写“列索引数字”。如果SQL里SELECT顺序调整了,比如把Phone放到前面,代码不会因为固定索引而读错列。读NULL的空字段用IsDBNull判断,和前面实体类的可空日期对应上。

参数设置里,SqlDbType.NVarChar, 50的50要和表结构定义匹配。这里只是为了给参数规定一个类型长度,不是规定数据库列长度。就算表里列是nvarchar(200),参数设成50也能工作,但最好按真实列长写,避免拼接时隐式转换。

3.4 增删查改方法压测:一次构造多行,检查主键和索引

方法SQL模式参数数量主要风险点
InsertINSERT INTO ... VALUES6可空字段传值、主键冲突
UpdateUPDATE ... SET ... WHERE StudentId7WHERE漏掉主键导致全表更新
DeleteDELETE ... WHERE StudentId1误删、外键约束
SearchSELECT ... WHERE LIKE1关键字为空时全表扫描

写完后,至少在数据库里插入几百行测试数据,看两个点:一是主键是否自增,二是插入同一学号是否会因为唯一索引报错。很多学生信息管理系统把学号StudentNo也设成唯一键,但主键却是自增StudentId,插入时如果没有判断学号重复而直接Insert,数据库抛主键/唯一键冲突异常,界面层就闪崩。这个校验通常放在BLL层做,数据访问层不管规则。

4. 业务层与界面层的联动:校验、绑定和DataGridView刷新

4.1 BLL层存在的意义:承上但不能只是转发

有些人的三层架构,BLL做得比较薄,就是DAL方法包了一层转发,这不能算错,但会让“业务规则”无处安放。学生信息管理系统里常见的业务规则是:

  • 学号不能为空,且长度不超过10位;
  • 姓名不能为空;
  • 手机号如果不填,更新时不要覆盖已有数据;
  • 删除学生前,先确认是否存在成绩表关联。

这些规则放在窗体里,会散落在各个按钮事件,导致同一个规则在添加和编辑时要写两遍。放到BLL层后,UI只调用一个方法:

using System; using Student.DAL; using Student.Model; namespace Student.BLL { public class StudentManager { private StudentDal _dal = new StudentDal(); public int AddStudent(Student s) { if (string.IsNullOrWhiteSpace(s.StudentNo)) throw new ArgumentException("学号不能为空"); if (s.StudentNo.Length > 20) throw new ArgumentException("学号长度不能超过20位"); if (string.IsNullOrWhiteSpace(s.StudentName)) throw new ArgumentException("姓名不能为空"); return _dal.Insert(s); } public int UpdateStudent(Student s) { if (s.StudentId <= 0) throw new ArgumentException("学生ID不存在"); return _dal.Update(s); } public List<Student> SearchStudents(string keyword) { return _dal.Search(keyword ?? string.Empty.Trim()); } } }

UI层不再直接new SqlConnection,而是new一个StudentManager,然后调用方法。这样将来如果数据库换成Oracle或达梦,只要DAL层实现替换,BLL的校验逻辑完全不用动。这里抛ArgumentException是轻量做法,异常从BLL传到UI层,界面层统一用try-catch捕获并显示MessageBox。很多人不喜欢在业务层抛异常,但课程设计这种规模,抛异常是最清晰的错误传递方式。

4.2 Form里的数据绑定:DataGridView的三种刷新姿势

窗体上通常有一个查询按钮、一个DataGridView、一组TextBox。查询按钮的写法要避免直接访问DAL:

private void btnSearch_Click(object sender, EventArgs e) { try { string keyword = txtKeyword.Text.Trim(); dataGridView1.DataSource = null; dataGridView1.DataSource = _manager.SearchStudents(keyword); } catch (Exception ex) { MessageBox.Show(ex.Message, "查询失败"); } }

这里必须先设DataSource = null再重新赋值,尤其是上一次查询有数据,下一次查询结果为空的情况。如果直接赋值,界面有时不会自动重绘。另一个更推荐的绑定容器是BindingSource:

private BindingSource bds = new BindingSource(); private void RefreshData() { bds.DataSource = _manager.SearchStudents(txtKeyword.Text.Trim()); dataGridView1.DataSource = bds; }

BindingSource在增删改之后的优势是,可以调用bds.ResetBindings(false)触发界面刷新,还能在删除后改正当前行索引,避免DataGridView出现“当前行号越界”的提示。常规的增删查改程序用BindingSource是性价比最高的方式。

4.3 ComboBox绑定数据源后SelectedValue取不到值

学生信息里如果有“班级”字段,很多人会把ComboBox绑定到一张班级表,再通过班级名反填到Student.ClassName。常见坑是运行时看到下拉框有数据,但comboBox.SelectedValue始终是null。

问题出在绑定顺序。看这段写法:

comboBox1.DataSource = classList; comboBox1.DisplayMember = "ClassName"; comboBox1.ValueMember = "ClassId"; comboBox1.SelectedValue = 2;

如果classList是List<Class>,只要Class对象里确实有ClassId属性,这段代码能工作。但如果list里的ClassId是int,而SelectedValue赋值的是字符串“2”,就会因为类型不匹配而返回null。更隐蔽的情况是,在Form_Load里先给SelectedValue赋值,后来又执行了comboBox1.DataSource = ...,就会把选择状态重置掉。正确顺序是:

comboBox1.DataSource = classList; comboBox1.DisplayMember = "ClassName"; comboBox1.ValueMember = "ClassId"; comboBox1.SelectedValue = currentClassId;

SelectedValue的类型必须与ValueMember属性的原始类型一致。通用排查方法是把赋值改成:

comboBox1.SelectedValue = Convert.ToInt32(currentClassId);

同时检查ClassId是否为int或Nullable 。如果是Nullable ,SelectedValue的匹配逻辑也会变得非常挑剔,建议在实体类里直接设成int。

4.4 查询结果的空行与自动列名

DataGridView绑定List 时,默认列名会显示属性名,列顺序按属性声明的顺序。如果觉得StudentId这种英文列名不好看,有两个选择:一个是在实体类属性上写特性[DisplayName("编号")],需要引入System.ComponentModel;另一个是设置DataGridView列模板:

dataGridView1.Columns["StudentId"].HeaderText = "编号"; dataGridView1.Columns["StudentNo"].HeaderText = "学号"; dataGridView1.Columns["StudentName"].HeaderText = "姓名";

这句一定要在绑定数据源之后写,否则Columns里还没有对应列,会报错“无法找到列”。如果不想被自动生成的列气到,也可以直接在DataGridView设计器里把AutoGenerateColumns设成False,然后手动添加每一列,再把DataPropertyName分别映射到实体属性。这样英文属性名永远不会暴露给用户。

5. 三层架构项目调试时,先把SQL和参数打出来看看

5.1 用一条Debug指令代替猜测

排错的关键,是确认DAL层真正传给数据库的SQL是什么。比如查询条件没有结果,不能只怀疑SQL写法,先要确认参数值是不是带了空格。常见做法是在cmd.ExecuteNonQuery()之前加一段调试输出:

System.Diagnostics.Debug.WriteLine(cmd.CommandText); foreach (SqlParameter p in cmd.Parameters) { System.Diagnostics.Debug.WriteLine(p.ParameterName + " = " + p.Value); }

在Visual Studio的“输出”窗口里能看到完整SQL和参数值。注意这里输出的不是“拼好的SQL字符串”,因为参数化后SQL是带占位符的。如果想看到替换后的完整SQL,可以用SQL Server Profiler,或者用SqlCommandCommandText再加上各个参数值手动模拟。

这样做的好处是,当一条增删改语句没影响任何行时,你能立刻区分是“参数没传对”还是“WHERE条件没匹配到”,不用在窗体层瞎试。

5.2 验证三层是否真的拆对了:改一个字段试试

一个快速验证方法是:假设要把数据库里的Phone字段改成Telephone。理想状况下,需要改的地方只有实体类属性名、DAL的SQL语句、UI上绑定列名,最多再改BLL里一个赋值。如果改完后要么界面找不到Phone,要么查询报错“列名无效”,就说明还有地方直接写了SQL字符串或字符串列名。检查范围集中在DAL和DataGridView的DataPropertyName上。

更进一步,可以做一个简单的替身测试。在DAL和BLL之间定义一个接口:

public interface IStudentRepository { List<Student> Search(string keyword); int Insert(Student s); int Update(Student s); int Delete(int studentId); }

然后让StudentDal实现它。BLL只依赖IStudentRepository,不在构造函数里直接new。平时可以传真正的Dal,测试时可以传一个内存集合的替身,界面层完全无感知。这套做法比把整个程序改造成控制台更容易验证三层边界,也方便老师抽查代码时看出来你确实理解“高层不依赖低层”这个原则。

5.3 最后还能扩展什么

学生信息管理系统做完了,最常见的接续需求是分页和Excel导出。前者不建议在DataGridView里做虚拟模式,而是给DAL查询方法增加pageIndexpageSize参数,SQL里用ORDER BY StudentId OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY。后者可以直接在UI层读取BLL返回的List ,再写入DataTable作为Excel导出的数据源。这种扩展之所以轻松,还是因为三层之间没有直接依赖窗体控件。把这些前置做法都落实了,后续加功能就不再是推倒重写,而是往现有层里补方法。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询