目录
一、项目速览
这是一个面向学校的学员(学生)与教师管理系统,管理员登录后可以对学员、教师进行新增、查询、修改、删除,全程基于 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) :
Student、Teacher、Grade、Class、UserState等实体类,与表结构一一对应; - 服务层(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 只有三个方法;项目版做了三处升级:
- 支持参数列表:
executeQuery(sql, List<(string, string)> paramList),用元组列表统一传参; - 返回 DataTable:查询用
SqlDataAdapter.Fill填充 DataSet 再取表,正好接住笔记里"DataAdapter 灌 DataSet"的思路; - 返回受影响行数:非查询语句把
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 → Class,UserStateId → UserState |
Subject | 科目 | GradeId → Grade |
Teacher | 教师 | GradeID → Grade,UserStateId → 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 环境。