☰
基于C# WinForm与SQL Server的三层架构图书管理系统实现
2026/10/19 5:34:28 网站建设 项目流程

简介:面向初学者的C# Windows窗体图书信息管理系统源码包,采用SQL Server作为后台数据库,代码基于三层架构组织,完整实现图书信息的新增、删除、修改与查询,适合正在学习WinForm界面开发、C#与数据库交互以及分层设计的读者用作课程设计或入门练习。配套演示视频可直观查看各功能运行效果;由于不同版本数据库环境存在差异,压缩包特意给出表结构代码,按说明生成数据库即可运行,调试简单,容易排查。压缩包共135个文件,整体约901KB,主要包括44个C#源文件、13个DLL与14个PDB调试符号、9个resources/resx资源文件,以及sln/csproj工程文件、config配置和说明文档,目录结构清晰,便于对照三层架构分层阅读。已有3087人学习下载。学习后可掌握从数据库建表到C#窗体完成增删查改的完整流程,理解三层架构中表示层、业务层与数据访问层的代码组织方式,可在其基础上扩展新功能,对初次接触WinForm数据库开发者有实际参考价值。

1. 用 C# WinForm 和 SQL Server 三层架构,把这个图书信息管理系统的增删查改跑起来

拿到这个项目源码时发现一个有意思的事:项目名叫图书信息管理系统,但最核心的功能模块是学生信息维护。后来一想这不难理解,图书管理系统里最先要管的就是读者(学生)档案,没有读者信息,借还书就无从谈起。整个系统用 Visual Studio 编写,数据库选用 SQL Server,代码被拆成 Model、DAL、BLL、UI 四层,覆盖了最基本的增删查改操作。对它感兴趣的同学,多半是刚开始学 C# WinForm 或三层架构,想找一个结构干净、没有复杂业务规则、能一键跑起来的例子。这个项目正好合适:数据库只要按脚本生成,改一下连接字符串就能运行,调试也简单,适合做知识点拓展和二次开发底座。

2. 三层架构拆解:从 UI 控件到数据库的调用链是怎么设计的

2.1 解决方案结构:BSM.Models、BSM.DAL、BSM.BLL、BSM 四工程各管什么

打开解决方案后,能看到四个项目,对应标准的三层架构。BSM.Models 存放实体类,比如 Student、Book,每个类对应一张表;BSM.DAL 数据访问层,负责写 SQL、执行命令、返回 DataTable 或实体;BSM.BLL 业务逻辑层,接收来自 UI 的请求,做校验,再调用 DAL;BSM 是 WinForm 主程序,也就是 UI 层,放窗体、按钮、DataGridView。

项目命名里 BSM 是解决方案前缀,BLL、DAL、Models 分别是三层,层与层之间单向依赖:UI 引用 BLL,BLL 引用 DAL,DAL 引用 Models。这样设计的最大好处是,如果哪天把数据库从 SQL Server 换成 MySQL,只需要改 DAL 层,UI 和 BLL 不用动。实际开发中这种依赖方向是硬约束,不要在 BLL 里直接写 SqlConnection,也不要让 UI 层碰到任何 SQL 字符串。

2.2 实体类与数据访问:从 Student 模型到参数化 SQL

先看 BSM.Models 里的 Student 类,它对应数据库里的学生表,属性基本是字段名直接映射:

namespace BSM.Models { public class Student { public int StudentId { get; set; } public string StudentNo { get; set; } public string StudentName { get; set; } public string Gender { get; set; } public string ClassName { get; set; } public string Phone { get; set; } } }

这里 StudentId 是主键,自增列,所以插入数据时不需要给它赋值。StudentNo 是学号,建议在数据库里建唯一约束,避免重复录入。实体类只做属性封装,不包含任何数据库操作,这也是分层的基本要求,很多二次开发卡壳就是因为有人图省事,把 SQL 直接写进了实体类。

接着看 BSM.DAL 里的 StudentDAL,这是真正和数据库打交道的地方。为了让连接字符串不散落各处,我先写了一个简单的 DbHelper 静态类:

using System.Configuration; namespace BSM.DAL { public static class DbHelper { public static string ConnectionString => ConfigurationManager.ConnectionStrings["SqlServer"].ConnectionString; } }

这样一个 DAL 里所有方法都通过 DbHelper.ConnectionString 取连接字符串。万一要改服务器地址,只需要动 App.config 一处。下面是查询全部学生的方法:

public DataTable GetAllStudents() { string sql = "SELECT StudentId, StudentNo, StudentName, Gender, ClassName, Phone FROM Student"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) using (SqlDataAdapter adapter = new SqlDataAdapter(sql, conn)) { DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } }

这里用了 SqlDataAdapter 的 Fill 方法,它会自动打开和关闭连接,不需要手动调用 Open。查询语句里列名全部写清楚,没有用 SELECT *,这样即使表里加了新字段,结果集的列顺序也稳定。在 DataGridView 绑定时,列名还可以直接对应。

插入操作则用了参数化查询,这是必须养成的习惯:

public bool AddStudent(Student s) { string sql = "INSERT INTO Student(StudentNo, StudentName, Gender, ClassName, Phone) " + "VALUES(@No, @Name, @Gender, @Class, @Phone)"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@No", s.StudentNo); cmd.Parameters.AddWithValue("@Name", s.StudentName); cmd.Parameters.AddWithValue("@Gender", s.Gender); cmd.Parameters.AddWithValue("@Class", s.ClassName); cmd.Parameters.AddWithValue("@Phone", s.Phone); conn.Open(); return cmd.ExecuteNonQuery() > 0; } }

AddWithValue 虽然方便,但有时候会因为传入的字符串长度超过字段长度,或者类型与数据库列不完全匹配而在运行时抛错。更稳妥的做法是用 new SqlParameter("@No", SqlDbType.NVarChar, 20) 这种方式显式指定类型。另外 ExecuteNonQuery 返回受影响行数,大于 0 说明插入成功。

修改和删除的代码模式与添加类似,只是 SQL 语句换成 UPDATE 和 DELETE,条件一律用主键 StudentId,不要用学号或其他字段:

public bool UpdateStudent(Student s) { string sql = "UPDATE Student SET StudentNo=@No, StudentName=@Name, Gender=@Gender, " + "ClassName=@Class, Phone=@Phone WHERE StudentId=@Id"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@Id", s.StudentId); cmd.Parameters.AddWithValue("@No", s.StudentNo); cmd.Parameters.AddWithValue("@Name", s.StudentName); cmd.Parameters.AddWithValue("@Gender", s.Gender); cmd.Parameters.AddWithValue("@Class", s.ClassName); cmd.Parameters.AddWithValue("@Phone", s.Phone); conn.Open(); return cmd.ExecuteNonQuery() > 0; } } public bool DeleteStudent(int studentId) { string sql = "DELETE FROM Student WHERE StudentId=@Id"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@Id", studentId); conn.Open(); return cmd.ExecuteNonQuery() > 0; } }

这里最关键的教训是:修改和删除永远以主键为条件,而不是用学号。因为学号可能被用户改掉,也可能有人误输入成相同的值,只有主键才能保证唯一匹配。

2.3 业务逻辑层:BLL 里做了什么,没做什么

StudentBLL 是 UI 和 DAL 之间的桥梁,代码很简单:

public class StudentBLL { private StudentDAL dal = new StudentDAL(); public DataTable GetAllStudents() { return dal.GetAllStudents(); } public bool AddStudent(Student s) { if (string.IsNullOrEmpty(s.StudentName)) throw new Exception("学生姓名不能为空"); if (string.IsNullOrEmpty(s.StudentNo)) throw new Exception("学号不能为空"); return dal.AddStudent(s); } }

BLL 层不写 SQL,也不创建 SqlConnection,它的职责是业务规则。比如这里校验姓名和学号是否为空,空则直接拒绝。你也可以在这里加更多规则,比如学号是否已存在、重名是否允许等。UI 层调用 BLL,感觉就像调用一个普通的方法,完全不用考虑数据库细节。

再加一个查询学生的方法,比如按学号或姓名模糊搜索,这在图书管理里很常用:

public DataTable SearchStudents(string keyword) { if (string.IsNullOrEmpty(keyword)) return GetAllStudents(); return dal.SearchStudents(keyword); }

搜索逻辑放在 DAL,BLL 负责判断空值,这比在按钮事件里拼字符串更符合分层原则。

2.4 UI 层:DataGridView 绑定与按钮事件的核心写法

WinForm 主窗体里,通常放一个 DataGridView 用于展示学生列表,再加几个按钮和文本框。加载数据的典型代码是这样:

private void LoadData() { StudentBLL bll = new StudentBLL(); DataTable dt = bll.GetAllStudents(); dataGridView1.DataSource = dt; } private void btnAdd_Click(object sender, EventArgs e) { Student s = new Student(); s.StudentNo = txtNo.Text.Trim(); s.StudentName = txtName.Text.Trim(); s.Gender = cmbGender.Text.Trim(); s.ClassName = txtClass.Text.Trim(); s.Phone = txtPhone.Text.Trim(); StudentBLL bll = new StudentBLL(); if (bll.AddStudent(s)) { MessageBox.Show("添加成功"); LoadData(); } }

这段代码演示了标准的三层调用链路:UI 构造实体对象,传给 BLL,BLL 校验后传给 DAL,DAL 执行 SQL 返回结果。注意 LoadData 写在按钮事件里,这样每次增删改后刷新列表才能看到最新数据。如果想提升体验,可以在 DataGridView 的 CellEndEdit 事件里实时改库,但初学者不建议一步跨到那么复杂。

删除按钮先取当前选中行的 StudentId:

private void btnDelete_Click(object sender, EventArgs e) { if (dataGridView1.CurrentRow == null) return; int studentId = Convert.ToInt32(dataGridView1.CurrentRow.Cells["StudentId"].Value); StudentBLL bll = new StudentBLL(); if (bll.DeleteStudent(studentId)) { MessageBox.Show("删除成功"); LoadData(); } }

修改按钮的逻辑类似,把选中行的值回填到文本框,用户改完后点保存触发 UpdateStudent。这里有一个值得注意的点:DataGridView 的列名必须和 SQL 查询的别名一致,否则取不到值。我在第一次做这个项目时,把列名写成了 "SID",但 SQL 返回的是 "StudentId",运行时报索引超出范围,浪费了近半天才排查出来。

2.5 为什么这样分层:依赖倒置在小型项目里的落地

很多新手会问,UI 直接调用 DAL 不也能跑吗,为什么非要在中间加一个 BLL?我给出的理由是:当业务规则增加时,DAL 的方法会被多个窗体复用,如果 UI 直接调 DAL,每个窗体都写一遍校验逻辑,代码会高度冗余。有了 BLL,校验、计算、流程控制都被收拢到一个地方。比如同样一个 AddStudent,在“管理员录入”和“Excel 导入”两个场景都要用,BLL 里的校验可以统一生效。项目虽小,但这个结构为后面扩展留下了干净的口子。

此外,层与层之间通过类直接 new 来调用,简单直观,适合教学和中小型系统。等到项目规模变大,可以换成接口 + 依赖注入,不过在目前这个资源包里,直接 new 是最容易读懂的写法。从调试角度说,这种分层也友好:当 UI 报错时,先看按钮事件,再进 BLL,最后进 DAL,每一层都能用断点卡住,很快就能定位到是哪一行 SQL 出了问题。

3. 数据库设计:从建表脚本到连接字符串,把 SQL Server 端一次配好

3.1 表结构设计:学生表的主键、唯一约束和字段类型

这个系统的核心表是 Student,建表脚本如下:

CREATE DATABASE BSM_Library; GO USE BSM_Library; GO CREATE TABLE Student ( StudentId INT IDENTITY(1,1) PRIMARY KEY, StudentNo NVARCHAR(20) NOT NULL, StudentName NVARCHAR(50) NOT NULL, Gender NVARCHAR(10) NULL, ClassName NVARCHAR(50) NULL, Phone NVARCHAR(20) NULL, CONSTRAINT UQ_Student_StudentNo UNIQUE (StudentNo) ); GO

在设计这张表时有几个考虑:StudentId 用 IDENTITY 自增,数据库自己维护主键,避免并发插入时主键重复;StudentNo 学号用 NVARCHAR 而不是 INT,因为学号经常有前导零;Gender 和 Phone 允许 NULL,因为有些学生信息不全。唯一约束 UQ_Student_StudentNo 保证学号不能重复,这是业务规则,写在数据库里最保险,即使应用程序漏了校验,数据库也会拦下来。

3.2 生成数据库的两种方式:SQL 脚本和可视化操作

资源包内只给了表结构代码,没有附加 .mdf 数据库文件,这是因为不同机器上的 SQL Server 版本差异较大,附加数据库经常遇到权限问题。开发者只要复制脚本,在 SQL Server Management Studio(SSMS)里新建查询,执行这段脚本即可。执行时注意要先选中要连接的实例,再执行,否则可能把脚本跑到了别的实例上。

如果你的环境没有 SSMS,也可以用命令行工具 sqlcmd 执行脚本:

sqlcmd -S localhost -E -i path\to\create_tables.sql

参数解释:-S 指定 SQL Server 实例名,localhost 表示本机;-E 表示使用 Windows 身份验证;-i 指定脚本文件路径。执行完成后,可以用以下语句验证表是否存在:

SELECT name FROM sys.tables WHERE type='U';

执行结果里应该能看到 Student 表。如果想要快速造几条测试数据,可以直接执行几个 INSERT:

INSERT INTO Student(StudentNo, StudentName, Gender, ClassName, Phone) VALUES ('2025001', N'张同学', N'男', N'计科一班', '13800000000'), ('2025002', N'李同学', N'女', N'计科二班', '13900000000');

注意中文前面加 N 前缀,否则在中文环境下可能出现乱码。这也是一个容易踩的小坑:SQL 脚本里的中文字符串尽量用 N'' 包裹。

3.3 连接字符串配置:App.config 里的那一行是所有问题的源头

WinForm 项目的数据库连接字符串一般放在 App.config 中,方便后期修改:

<connectionStrings> <add name="SqlServer" connectionString="Data Source=.;Initial Catalog=BSM_Library;Integrated Security=True" providerName="System.Data.SqlClient" /> </connectionStrings>

Data Source 的 “.” 代表本机默认实例。如果你的 SQL Server 是命名实例,比如 SQLEXPRESS,这里就要写成 “.\SQLEXPRESS”;如果是远程服务器,就写 “服务器IP,端口号”。Initial Catalog 就是数据库名,这里对应刚才创建的 BSM_Library。Integrated Security=True 表示用 Windows 身份登录,不需要写账号密码;如果要改用 SQL Server 账号,则写成 “User ID=sa;Password=你的密码”。

连接字符串位置很集中,但经常出错。最典型的情况是:明明数据库建好了,程序却报 “Cannot open database” 或 “Login failed”,十有八九是 Initial Catalog 写错,或者 Data Source 指向的实例根本没启动。调试时可以先用 Visual Studio 的服务器资源管理器手动测试连接,能连上再复制连接字符串到代码里,这样能缩小排查范围。

3.4 当字段出现变更:从数据库到实体类的联动修改

很多初学者改动表结构后,只改了数据库脚本,忘了改 Models 里的实体,结果 BLL 层还是给的一两个参数,运行时报 “列名无效”。我一般会在项目里做一个简单约定:数据库表的每一列都能在实体类里找到对应属性,属性名尽量和列名相同。这样从模型一眼就能看出和表的对应关系。如果某天要在 Student 表里增加一个 Email 字段,需要做三步:一是在 Student 表加列,二是在 Student 类加 Email 属性,三是在 DAL 的增删查改 SQL 中补上 Email 列。三步都完成,UI 层才能正常读写。

这个联动过程看起来很机械,但恰恰是初学者最容易漏掉的环节。我自己见过不少人在数据库里加了列,然后只在某个窗体的文本框里加了控件,结果运行到保存时就报列名无效,原因就是 DAL 层的 SQL 没更新。养成“改表必改实体,改实体必改 SQL”的习惯后,这类问题基本不会再犯。

4. 运行避坑指南:第一次跑这个系统最常见的 4 个坑

4.1 连接字符串报错:登录失败或找不到数据库

现象:程序启动后,点查询按钮,弹出一个 SqlException,提示 “Cannot open database ...” 或 “Login failed for user ...”。

原因:连接字符串里 Data Source 与 SQL Server 实例名不匹配,或者 Initial Catalog 里的数据库名创建错了。我在第一次跑类似项目时,把数据库名写成了 BSM,但脚本里创建的是 BSM_Library,结果一直报找不到库。

解决:先用 SSMS 确认本地实例名和数据库实际名称,再修改 App.config 里的连接字符串。建议先打开 Visual Studio 的服务器资源管理器,右键数据连接添加 SQL Server,如果图形界面能连接成功,连接字符串基本不会有问题。另外需要注意,修改了 App.config 后要重新生成项目,有时候改完直接运行,调试器加载的还是旧的配置文件,这也容易造成错觉。

4.2 AddWithValue 参数推断导致数据类型转换失败

现象:执行插入操作时,明明文本框输入的数字或日期看起来正常,却报出 “Conversion failed when converting date and/or time from character string” 或 “Error converting data type nvarchar to int”。

原因:AddWithValue 会根据传入的 .NET 类型自动推断数据库类型。如果文本框输入的日期是字符串 “2025-01-15”,而数据库列是 datetime,在特定区域设置下有概率转换失败;同理,把 NVarChar 列和 INT 列比较时也会出问题。

解决:尽量避免用 AddWithValue,改用显式 SqlParameter 指定类型。比如日期类型:

cmd.Parameters.Add(new SqlParameter("@Birthday", SqlDbType.DateTime) { Value = DateTime.Parse(txtBirthday.Text) });

这样做虽然多写几行,但能规避掉很多魔幻的转换错误。尤其是中文环境下,日期格式容易被 OS 的区域设置影响,显式指定类型能让 SQL Server 不再自作主张。如果项目里已经用了大量 AddWithValue,一个临时补救是统一把文本框输入先转成明确类型,比如 int.TryParse、DateTime.TryParse,再传给参数。

4.3 DataGridView 新增/修改后列表不刷新

现象:点完添加按钮,提示“添加成功”,但界面上 DataGridView 还是原来那几行,看不到新增的记录,重启程序才有。

原因:DataGridView 绑定的是内存中的 DataTable,添加成功后没有重新查询数据源。这是 WinForm 新手最容易遇到的“假象”——数据库里其实已经有数据了,只是界面没更新。

解决:每次增删改成功后,重新调用 LoadData() 方法,重新填充 DataTable 并赋值给 DataSource。如果希望保留当前选中行或滚动位置,可以在 LoadData 前后记录当前选中行的索引,恢复后再赋值。另外,如果用了 BindingSource,要调用 ResetBindings 方法,否则 DataSource 虽然是新对象,但绑定源可能还缓存着旧结构。

4.4 修改或删除数据时,影响到了错误的行

现象:在 DataGridView 中选中张三那行,点删除,结果数据库里李四那行被删掉了。

原因:删除按钮的 SQL 语句以学号作为 WHERE 条件,而学号虽然唯一,但用户可能在编辑时把张三学号改成和李四一样(违背业务),或者界面上显示的行顺序与数据库主键不一致。更常见的是,DataGridView 的 CurrentRow 拿到的是选中行索引,但 SQL 里误用了另一个字段作为条件。

解决:所有修改和删除操作,一律以主键 StudentId 作为条件。在按钮事件里从当前行拿到主键列的值,再传给 DAL。UI 的 DataGridView 要确保主键列存在,并且不要显示成可编辑状态,避免用户误改。删除之前最好弹窗确认,提示“确认删除该学生信息吗?此操作不可恢复”,防止手滑点到错误行。

5. 二次开发建议:把学生增删查改扩展成完整的借书还书流程

5.1 先梳理实体关系,再决定改哪一层

现在系统里只有 Student 表,要实现借书,还需要 Book 表和 Borrow 表。Book 至少要有 BookId、BookName、Stock 字段;Borrow 记录一次借阅,包含 BorrowId、StudentId、BookId、BorrowDate、ReturnDate。关系很简单:一个学生可以借多本书,一本书可以被多个学生借过,所以 Borrow 是典型的两头外键表。

不要急着写 UI,先在 DAL 层把借书方法写出来,并且用事务保证库存扣减和借阅记录同时成功:

public bool BorrowBook(int studentId, int bookId) { string sql = "BEGIN TRANSACTION; " + "UPDATE Book SET Stock = Stock - 1 WHERE BookId=@BookId AND Stock > 0; " + "IF @@ROWCOUNT = 0 BEGIN ROLLBACK; RETURN; END " + "INSERT INTO Borrow(StudentId, BookId, BorrowDate) VALUES(@StudentId, @BookId, GETDATE()); " + "COMMIT;"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@StudentId", studentId); cmd.Parameters.AddWithValue("@BookId", bookId); conn.Open(); return cmd.ExecuteNonQuery() > 0; } }

这里有几点值得学习:一是 UPDATE 语句加了 Stock > 0 条件,防止库存为负;二是用 @@ROWCOUNT 判断是否更新到了行,没有库存就回滚;三是把多条 SQL 放入一个字符串里由 SQL Server 作为事务执行,要比在 C# 里手动开事务更简洁。当然你也可以用 TransactionScope 做,但初学者理解这个内联事务会更直观。

5.2 改造 UI 时的三个可复用技巧

第一,把连接字符串的读取统一封装到 DAL 层的一个静态类里,而不是每个方法都写一遍,否则换成数据库实例时要改的地方太多。第二,窗体内的输入校验放在 BLL 层,而不是按钮事件里,这样以后加一个批量导入功能,校验逻辑还能复用。第三,所有列表的列名都通过常量引用,避免字符串拼写错误。

完成这些改造后,用三组数据自测:正常借书能看到库存减一;库存为 0 时借书会失败并提示;同一本书反复借还后,库存仍能回到原始值。只有这三个场景都通过,才说明事务和更新逻辑是对的。

说实话,我自己第一次基于这种教学项目做二次开发时,就是在借书事务上翻了车:没有加 Stock > 0 的条件,导致库存变成负数,被测试的同学吐槽了好久。从那以后,我每写一段 UPDATE 库存的 SQL,都会强制自己在条件里加上库存校验。希望这个系统也能帮你少走同样的弯路。

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

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

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

立即咨询