C# ADO.NET 学生管理系统项目——实战经历分享

0 阅读14分钟

目录

  1. 项目速览
  2. 知识准备:ADO.NET 核心概念
  3. 从笔记到架构:系统设计
  4. 功能实现:理论如何落地
  5. 实战中的坑与解法
  6. 写在最后

一、项目速览

这是一个面向学校的学员(学生)与教师管理系统,管理员登录后可以对学员、教师进行新增、查询、修改、删除,全程基于 ADO.NET 直连 SQL Server,不依赖任何 ORM。

项目说明
形态C# WinForms 桌面程序(.NET Framework 4.7.2)
数据库SQL Server,7 张业务表,启动时自动建表
数据访问ADO.NET System.Data.SqlClient,参数化 SQL
开发周期约 2 天(2026-08-12 → 08-14)

功能窗口一览:

窗口功能
登录窗口管理员账号密码验证
主窗口(MDI)菜单 + 工具栏 + 状态栏,统一调度所有子窗口
学员管理列表展示、姓名模糊查询、修改、删除
学员添加 / 学员列表新增学员(班级下拉、单选按钮)、按性别筛选
学员修改信息回填、保存后主列表即时刷新
教师管理列表展示、姓名查询 + 年级筛选、修改、删除
教师添加 / 教师列表新增教师(含生日合法性校验)、姓名模糊查询
教师修改信息回填、保存后主列表即时刷新
关于程序信息展示

二、知识准备:ADO.NET 核心概念

动手之前,先把我课堂笔记(Day19 ADO 编程)里的知识框架过一遍。ADO.NET 是 .NET Framework 内置的数据访问技术,无论后端是 SQL Server、MySQL 还是 Oracle,它都提供统一的对象模型,只需要换掉对应的驱动(SqlClient / MySqlClient)即可。整个技术栈可以浓缩成五大对象 + 两个封装。

2.1 Connection:建立连接的起点

一切数据库操作的第一步都是拿到连接。连接字符串是关键,它告诉 ADO.NET"连谁、用什么账号、连哪个库":

string sqlstr = "server=localhost;uid=root;pwd=root;database=db1;charset=utf8;port=3306";
SqlConnection con = new SqlConnection(sqlstr);
​
try
{
    con.Open();               // 打开连接
    Console.WriteLine(con.State);          // ConnectionState.Open
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}
finally
{
    con.Close();              // 释放连接
}

两个常用的属性:

  • ConnectionString:连接字符串,可读可写;
  • State:当前连接状态,ConnectionState 枚举有 6 个值——Broken(连接断开)、Closed(已关闭)、Connecting(正在连接)、Executing(正在执行)、Fetching(正在取数据)、Open(已打开)。

规范用法是 try / catch / finally:打开连接可能失败,必须捕获异常;无论成败,finally 里都要关闭连接。

2.2 Command:执行 SQL 的三种姿势

Command 对象负责把 SQL 交给数据库执行,核心是 CommandText(SQL 语句)和 Connection(所属连接)。它有三种执行方式,按"返回值形态"区分:

方法返回值适用场景
ExecuteNonQuery()int(受影响行数)Update / Insert / Delete;注意 select 语句返回 -1
ExecuteScalar()object(第一行第一列)聚合查询,如 select count(*)
ExecuteReader()DataReader(只进只读流)select 查询结果集

增删改走 ExecuteNonQuery,用返回的行数判断是否成功:

string sql = "insert into users values(3,'lisi','abc')";
MySqlCommand com = new MySqlCommand(sql, con);
int count = com.ExecuteNonQuery();
​
if (count > 0)
{
    Console.WriteLine("插入成功");
}

统计条数走 ExecuteScalar,拿回单个值后转类型:

string sql = "select count(*) from users";
MySqlCommand com = new MySqlCommand(sql, con);
int count = Convert.ToInt32(com.ExecuteScalar());   // 注意要 Convert

2.3 DataReader:只进只读的数据流

ExecuteReader 返回的 DataReader 是只读、只进的流式读取器——它像水管一样一行行"流过"结果集,不能回退、不能修改,因此内存占用极低,适合大结果集。常用成员:

  • Read():前进到下一行,有数据返回 true,读完返回 false
  • HasRows:是否包含行(Bool);
  • FieldCount:字段数量;
  • GetName(i):按索引取列名。

标准读取套路:

MySqlDataReader dr = com.ExecuteReader();
while (dr.Read())
{
    Console.WriteLine(dr["id"] + "\t" + dr["username"] + "\t" + dr["password"]);
}
dr.Close();   // 先关 DataReader

操作顺序永远是:建连接 → 打开 → 写 SQL → 建 Command → ExecuteReader → while 循环 Read → 关闭 DataReader → 关闭连接

2.4 DataSet:内存中的数据库

DataReader 是"流",DataSet 则是整块内存——它像一个微型数据库,可以包含多张 DataTable,每张表由 DataRow(行)和 DataColumn(列)组成。因为数据全部驻留内存,可以断开连接后继续操作,还支持 GetChanges()(取修改内容)、Merge()(合并)、AcceptChanges()/RejectChanges()(提交/回滚变更)等"数据库式"操作。

手工构造一个 DataSet:

DataSet ds = new DataSet("MyDataSet");
DataTable table = new DataTable("MyDataTable");
ds.Tables.Add(table);
​
table.Columns.Add("ID", typeof(int));
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Age", typeof(int));
​
table.Rows.Add(1, "John", 25);
table.Rows.Add(2, "Jane", 30);

2.5 DataAdapter:DataSet 与数据库之间的桥

DataSet 自己不会连数据库,它需要一个"摆渡人"——DataAdapter。适配器把 SQL 查询结果灌进 DataSet,也能把 DataSet 里的修改写回数据库:

MySqlDataAdapter da = new MySqlDataAdapter(sql, con);
​
DataSet ds = new DataSet();
da.Fill(ds, "useInfo");                 // 结果存进名为 useInfo 的表
​
DataTable table = ds.Tables["useInfo"]; // 按名字取表
foreach (DataRow row in table.Rows)
{
    Console.WriteLine(row["id"] + "\t" + row["username"]);
}

小结一下分工:SqlCommand 负责"执行单条 SQL",SqlDataAdapter 负责"把结果集灌进内存" ;如果只是查一遍,DataReader 更快;如果要断开连接后反复操作、绑定控件展示,DataSet + DataAdapter 更合适。

2.6 事务:多条 SQL 的原子性保证

转账是最经典的例子:张三扣 500,李四加 500,两步必须同时成功或同时失败,中间不能断。用事务把多条语句包起来:

using (MySqlConnection con = new MySqlConnection(sqlstr))
{
    con.Open();
    MySqlTransaction transaction = con.BeginTransaction(IsolationLevel.Serializable);
​
    try
    {
        MySqlCommand comA = new MySqlCommand("update account set Balance = Balance - 500 where name = 'zhangsan'", con);
        comA.Transaction = transaction;
        comA.ExecuteNonQuery();
​
        MySqlCommand comB = new MySqlCommand("update account set Balance = Balance + 500 where name = 'lisi'", con);
        comB.Transaction = transaction;
        comB.ExecuteNonQuery();
​
        transaction.Commit();       // 全部成功 → 提交
    }
    catch (Exception ex)
    {
        transaction.Rollback();     // 任一出错 → 回滚
        Console.WriteLine(ex.Message);
    }
    finally
    {
        con.Close();
    }
}

要点:BeginTransaction 开启事务后,每条 Command 都要把 Transaction 属性指向同一个事务对象,否则报错。

2.7 DBHelper:把重复代码收进一个类

笔记的最后一步,是把"开连接 → 执行 → 关连接"的样板代码收敛成一个 DBHelper 工具类,对外只暴露"给我 SQL,还你结果"的简单接口:

public class DBHelper
{
    private string connectionString;
    public DBHelper(string connectionString)
    {
        this.connectionString = connectionString;
    }
​
    public int ExecuteNonQuery(string sql) { /* 开连接 → 执行 → 关闭 */ }
    public object ExecuteScalar(string sql) { /* 返回第一行第一列,失败返回 null */ }
    public SqlDataReader ExecuteReader(string sql) { /* 返回 DataReader */ }
}

有了它,登录验证从"每次手写 20 行连接代码"变成一句话:

string sql = "select count(*) from users where username='" + username + "' and password='" + password + "'";
int index = Convert.ToInt32(db.ExecuteScalar(sql));
if (index > 0) { /* 登录成功 */ }

三、从笔记到架构:系统设计

理论学完了,接下来是重头戏:把这些对象塞进一个真实系统的骨架里

3.1 三层结构:服务层 / 模型层 / 辅助层

项目虽然是一个 WinForms 程序,但代码不是全糊在窗体事件里,而是分了三层:

  • 模型层(Model)StudentTeacherGradeClassUserState 等实体类,与表结构一一对应;
  • 服务层(Service) :每个业务对象一个 XxxDataBaseService,封装该对象所有的 SQL 操作;
  • 辅助层(Helper)DBHelper(数据库访问)、JudgeHelper(校验)、LogHelper(开发期工具)。

窗体只和服务层打交道,永远不直接写 SQL——界面代码立刻变得很干净:

// StuAddForm 中保存学员
bool b = studentDataBaseService.saveStudent(new Student()
{
    LoginId = loginId,
    LoginPwd = password,
    StudentName = stuName,
    ClassName = stuClass,   // 界面传"班级名",服务层负责换成 ID
    Sex = sex,
    Phone = phone,
    Address = address,
    Comment = stuRemark,
    State = status,
});
if (b) MessageBox.Show("添加成功");

3.2 泛型基类:把"建表检查"做成公共能力

7 张表、7 个服务类,它们的"开场白"完全一样:确保数据库里表存在。这个共性被抽成了一个泛型基类:

internal abstract class DataBaseService<T> where T : DataBaseService<T>, new()
{
    protected static string DATABASE_NAME = "MySchool";
    public static string CONNECT_SQL = "server=.;password=xxx;uid=sa;database=" + DATABASE_NAME;
​
    protected DBHelper dbHelper => new DBHelper(CONNECT_SQL);
    public static T Instance => new T();
​
    public DataBaseService()
    {
        InitializeDatabase();   // 每次实例化都保证表存在
    }
​
    public void InitializeDatabase()
    {
        using (SqlConnection connection = new SqlConnection(CONNECT_SQL))
        {
            createTable(connection);
        }
    }
​
    public abstract void createTable(SqlConnection connection);
}

每个服务类只需要实现自己的 createTable,用幂等的 SQL 保证"表不存在才创建":

public override void createTable(SqlConnection connection)
{
    connection.Open();
    string sql =
        " IF OBJECT_ID('Grade', 'U') IS NULL " +
        "     BEGIN                          " +
        "         CREATE TABLE Grade         " +
        "         (                          " +
        "             GradeId INT IDENTITY(1,1) PRIMARY KEY, " +
        "             GradeName VARCHAR(30) NULL             " +
        "         );                         " +
        "     END                            ";
    using (SqlCommand command = connection.CreateCommand())
    {
        command.CommandText = sql;
        command.ExecuteNonQuery();
    }
}

效果:程序第一次运行就自动把 7 张表建好,部署时不需要任何手工建库脚本。

3.3 参数化 SQL:杜绝拼接注入

笔记里的登录示例用的是字符串拼接("where username='" + username + "'"),这在作业里没问题,但真实项目里必须改成参数化查询——项目里所有 SQL 一律走 @参数 占位:

internal bool login(string loginId, string loginPwd)
{
    using (SqlConnection connection = new SqlConnection(CONNECT_SQL))
    {
        connection.Open();
        string sql = "SELECT * FROM Admin WHERE LoginId=@loginId AND LoginPwd=@loginPwd";
        using (SqlCommand command = new SqlCommand(sql, connection))
        {
            command.Parameters.AddWithValue("@loginId", loginId);
            command.Parameters.AddWithValue("@loginPwd", loginPwd);
            using (SqlDataReader reader = command.ExecuteReader())
            {
                return reader.HasRows;   // 有行即登录成功
            }
        }
    }
}

参数化的另一个好处是类型由 ADO.NET 处理,SQL 里的单引号、特殊字符不再需要手工转义。

3.4 项目版 DBHelper:比笔记多三个东西

课堂版 DBHelper 只有三个方法;项目版做了三处升级:

  1. 支持参数列表executeQuery(sql, List<(string, string)> paramList),用元组列表统一传参;
  2. 返回 DataTable:查询用 SqlDataAdapter.Fill 填充 DataSet 再取表,正好接住笔记里"DataAdapter 灌 DataSet"的思路;
  3. 返回受影响行数:非查询语句把 ExecuteNonQuery 的行数传回上层,方便判断成功失败。
internal DataTable executeQuery(string sql, List<(string, string)> paramList)
{
    using (SqlConnection connection = new SqlConnection(ConnectionSQL))
    {
        connection.Open();
        using (SqlCommand command = connection.CreateCommand())
        {
            command.CommandText = sql;
            foreach (var param in paramList)
            {
                command.Parameters.AddWithValue(param.Item1, param.Item2);
            }
            SqlDataAdapter adapter = new SqlDataAdapter(command);
            DataSet set = new DataSet();
            adapter.Fill(set, "result");
            return set.Tables["result"];
        }
    }
}

3.5 数据库设计:7 张表、两套外键

说明外键关系
Admin管理员账号
Grade年级字典
Class班级字典GradeID → Grade
UserState用户状态字典(活动/非活动)
Student学员ClassId → ClassUserStateId → UserState
Subject科目GradeId → Grade
Teacher教师GradeID → GradeUserStateId → UserState

"字典表 + 外键"的设计让业务数据很干净:学员表里只存 UserStateId = 1,而不是重复的字符串"活动"。代价是展示时要多一次 JOIN——这正是 DataReader / DataAdapter 的用武之地。


四、功能实现:理论如何落地

4.1 登录:HasRows 判断成败

登录窗口拿到输入后调用 adminService.login(),服务层用 ExecuteReader + reader.HasRows 判断账号密码是否匹配。成功则隐藏登录窗口、打开主窗口:

if (adminService.login(loginId, password))
{
    this.Hide();
    MainForm mainForm = new MainForm(loginId);
    mainForm.ShowDialog();
    this.Close();
}
else
{
    MessageBox.Show("用户名或密码错误");
}

4.2 主窗口:MDI 单子窗口策略

主窗口是 MDI 父窗体,菜单/工具栏负责调度所有子窗体。为了避免子窗口层层堆叠,封装了一个统一的入口:打开新窗口前先把旧子窗口关掉,保证同一时刻只有一个子窗口

private void initMidWindow(Form form)
{
    if (this.MdiChildren.Length > 0)
    {
        this.MdiChildren[0].Close();   // 先关旧的
    }
    form.MdiParent = this;
    form.Show();
}

状态栏还会用 Timer(1 秒间隔)实时刷新当前时间,并把登录用户名显示出来,运行起来很有"系统感"。

4.3 列表展示:JOIN 出可读数据 + BindingSource 筛选

列表要显示"状态""年级"这类字典名称而不是外键 ID,所以查询时直接 JOIN 字典表并取别名:

string sql = " SELECT t1.*, t2.UserState AS State, t3.GradeName AS Grade " +
             " FROM Teacher t1 " +
             " INNER JOIN UserState t2 ON t1.UserStateId = t2.UserStateId " +
             " INNER JOIN Grade t3 ON t1.GradeID = t3.GradeId ";
​
// 返回的 DataTable 直接作为 DataSource 绑定
binding = new BindingSource() { DataSource = teacherDataBaseService.getListTable() };
dataGridView1.DataSource = binding;

查询不用重连数据库——BindingSource.Filter 在内存里干活,输入框一变化立即过滤:

private void stuNameBox_TextChanged(object sender, EventArgs e)
{
    binding.Filter = $"StudentName LIKE '%{stuNameBox.Text}%'";
}

教师管理窗口更进一层:年级下拉框筛选和姓名模糊查询共用同一个 Filter,交互反馈几乎零延迟。

4.4 增删改:名字 ↔ ID 的转换

界面上的下拉框显示的是"一班""二年级",数据库里存的是 ID。保存学员时,服务层内部做一次"名称反查 ID":

internal bool saveStudent(Student student)
{
    string sql = "INSERT INTO Student (LoginId, LoginPwd, StudentName, Sex, ClassId, Phone, Address, Comment, UserStateId) " +
                 "VALUES (@loginId, @loginPwd, @studentName, @sex, @classId, @phone, @address, @comment, @userStateId)";
​
    return dbHelper.executeNonQuery(sql, new List<(string, string)>
    {
        ("@loginId", student.LoginId),
        ("@classId", classDataBaseService.getByName(student.ClassName).ClassId.ToString()),
        ("@userStateId", userStateDataBaseService.getByName(student.State).StateId.ToString()),
        // ...
    }) > 0;
}

删除操作同样走 executeNonQuery 返回行数判断是否成功,成功后把当前行从 DataGridView 移除,界面与数据库保持一致。

4.5 修改窗口:保存后主列表即时刷新

修改窗口(StuUpdateForm / TeacherUpdateForm)保存成功后,主列表怎么立刻显示新数据?方案是让修改窗口持有主列表的那一行:构造时传入 DataGridViewRow,保存成功直接回写这一行的单元格:

// 修改保存成功后
row.Cells["LoginId"].Value = stuLoginIdBox.Text;
row.Cells["StudentName"].Value = stuNameBox.Text;
row.Cells["Sex"].Value = sexManRadio.Checked ? sexManRadio.Text : sexWomanRadio.Text;
row.Cells["State"].Value = activeRadio.Checked ? activeRadio.Text : inActiveRadio.Text;
​
MessageBox.Show("修改成功");
this.Close();   // 关窗返回主列表,数据已经是新的

不需要重新查库,主列表所见即所得。

4.6 表单校验:一个方法解决所有判空

添加/修改窗口要校验一堆输入框,写 10 个 if (xxx.Text == "") 太丑了。一个 params 数组参数的方法搞定:

internal class JudgeHelper
{
    public static bool isNullOrEmpty(params string[] values)
    {
        foreach (var item in values)
        {
            if (string.IsNullOrEmpty(item)) return true;
        }
        return false;
    }
}
​
// 用法:一行校验七个字段
if (JudgeHelper.isNullOrEmpty(loginId, loginPwd, status, teacherName, sex, birthday, grade))
{
    MessageBox.Show("请填写完整信息");
    return;
}

教师生日额外用 DateTime.Parse + try/catch 兜底,填了非法日期直接提示,不让脏数据进库。


五、实战中的坑与解法

整个开发过程踩了不少坑,挑几个典型的分享:

5.1 坑一:首次运行报表不存在

现象:数据库里没有表,程序一运行就报"对象名无效"。 解法:把建表逻辑放进服务基类的构造函数,每次初始化都执行 IF OBJECT_ID(...) IS NULL 判断后再建表。幂等 + 自动,从此部署零步骤,还顺带解决了表与表之间外键依赖的建表顺序问题。

5.2 坑二:界面上想显示"状态名",库里只有 ID

现象:学员表存的是 UserStateId,列表却要显示"活动/非活动"。 解法:查询时 INNER JOIN 字典表并 AS State 取别名,DataTable 直接绑定;保存时用 getByName() 反查 ID 再写入。展示层用 JOIN 展开,写入层用反查收敛,双向闭环。

5.3 坑三:修改保存后列表还是旧数据

现象:修改窗口关掉后,主列表里那行数据没变。 解法:修改窗口保存成功后直接操作传入的 DataGridViewRow,逐格回写单元格值,主列表立即生效,也避免了"再查一遍全表"的浪费。

5.4 坑四:MDI 子窗口越开越多

现象:菜单点几次,子窗口堆了一摞,界面乱成一团。 解法:统一走 initMidWindow() 入口,打开新窗口前 MdiChildren[0].Close(),保证单子窗口。

5.5 坑五:日期格式五花八门

现象:教师生日有人填 2026/8/1,有人填 2026-08-01,入库格式不统一。 解法:提交前统一 DateTime.Parse(...).ToString("yyyy/MM/dd") 格式化,非法的直接 catch 住弹提示。

5.6 坑六:SQL 拼接的注入隐患

现象:最初版本照笔记写法拼接字符串,username = 'xxx' or 1=1 就能绕过登录。 解法:全部改参数化查询,@参数 占位 + AddWithValue 绑定,登录、增删改查无一例外。


六、写在最后

回顾这趟旅程,最大的感受是:笔记里的每个知识点,都能在项目里找到它的位置

课堂笔记知识项目中的落点
Connection 连接管理DataBaseService 统一封装,using 确保释放
Command 三种执行方式ExecuteNonQuery(增删改)、ExecuteScalar(登录验证)、ExecuteReader(查询)各司其职
DataReader登录验证用 HasRows 快速判断
DataSet + DataAdapter列表查询统一 Fill 成 DataTable 绑定 DataGridView
事务笔记中的转账案例;本项目均为单语句操作,暂未用到,但 DBHelper 的封装为将来多语句业务留好了扩展点
DBHelper 封装升级为支持参数列表的版本,全项目唯一数据入口

有趣的细节是:课堂笔记用的是 MySQL(MySql.Data.MySqlClient),项目实战换成了 SQL Server(System.Data.SqlClient)——切换成本几乎为零,因为 ADO.NET 的对象模型完全一致,这正是它最大的价值:学会一次,到处可用

两天时间,从一张笔记 PPT 到一个能跑的系统。如果你也在学 ADO.NET,建议别只看笔记——照着上面的思路,给自己写一个"能落地"的小系统,遇到的所有问题都会变成真正的经验。

技术栈:C# / .NET Framework 4.7.2 / WinForms / ADO.NET / SQL Server 文中代码为项目源码节选,可运行于 Visual Studio + 本机 SQL Server 环境。