☰
C#实战:用WinForms+SQLite+Dapper从零开发图书管理系统
2026/9/26 11:42:15 网站建设 项目流程

1. 从"学完C#不知道做什么"到第一行业务代码

如果你正在学C#,大概率会经历这样一个尴尬期:跟着教程敲过控制台计算器、写过猜数字游戏、弄过几个窗体界面,语法看着都眼熟,可一旦让你独立做一个"像样点的东西",脑子就开始空白。图书管理系统恰好是绕开这个尴尬期的绝佳项目——它不涉及复杂的数学算法,没有高深的设计模式,却把C#开发中最常用、最该练熟的东西几乎全串起来了:集合、委托、字符串处理、数据库操作、面向对象、UI与逻辑分离。难度曲线缓,覆盖面广,做完之后你对这门语言的感觉会和之前完全不一样。

这篇博文不是给你贴一段完整的源码就完事,而是把我自己做图书管理系统从设计思路到代码落地的全过程拆开讲。内容覆盖了需求边界怎么划、技术选型怎么定、数据库表怎么设计、核心功能怎么编码,以及真实开发中一连串踩过的坑。适合两类人看:

  • C#语法基础刚学完、正在找第一个完整项目的初学者,跟着走可以少走很多弯路;
  • 用C#做过零散小工具、但没系统梳理过"业务系统"开发流程的开发者,这篇能帮你把项目结构的观念立起来。

顺带说一下,我选的实践路径是 WinForms + SQLite + Dapper,这个组合在轻量级桌面管理系统里非常能打,后面会详细解释为什么这么选。现在从头开始说。

2. 动手之前:图书管理系统的功能边界和核心难点在哪

很多教程上来就贴数据库脚本和窗体代码,但真正写项目的人都知道,想清楚"系统到底要管什么"才是第一道坎。图书管理,往大了说可以做成图书馆全套解决方案,往小了说其实就是围绕"书"和"人"的关系做增删改查。我给自己定的边界很简单:面向小型图书室或个人学习的场景,核心闭环是图书入库、图书检索、借书、还书、借阅记录查询这五件事。

2.1 需求拆解:把一句话描述变成可落地的功能清单

先别急着写代码,拿张纸把业务场景走一遍。一本书到了管理员手里要录入系统,读者来了要查书、借书,过一段时间要把书还回来,管理员要能知道哪本书借出去了、被谁借的、借了多久。基于这个流程,功能点拆出来就这些:

  • 图书管理:新增图书、编辑图书信息、下架/删除图书。这里注意,图书的唯一标识不能用"书名",因为同名书可能有很多版本。
  • 读者管理:新增读者、编辑读者信息、禁用/启用借书权限。没有读者管理,借阅记录就失去关联对象。
  • 图书检索:按书名、作者、ISBN、分类任意组合查询。这一块是考验字符串处理和SQL拼接技巧的好地方。
  • 借书操作:选择读者、选择图书、登记借出日期和应还日期。核心校验逻辑是"这本书是否在馆"和"这个读者是否有权限借书"。
  • 还书操作:根据借阅记录归还图书,计算是否逾期。
  • 借阅记录查询:分历史记录和在借列表两种视图,按读者或按图书维度查询。

这就是MVP(最小可行产品)思维——第一版不追求权限分级、批量导入、图书封面识别这些花哨功能,把核心链路跑通,后面再慢慢扩展。我见过太多人第一版就想做角色权限,结果项目做了两个月还没跑起来,其实对学习项目来说,复杂度是逐步长出来的,一上来就上重量级设计只会让你中途放弃。

2.2 新手最容易忽略的建模难点

真正动手写过之后你会发现,图书管理系统最纠结的地方不在于"界面上放几个按钮",而在于三个容易想岔的建模问题:

  • 图书和库存的关系:一本《C#高级编程》可能买了三本,每本都有独立编号(流水号或条码号)。所以在设计上,"图书"是书目信息(书名、作者、出版社、ISBN),"馆藏副本"才是具体某一本实体书。借书还书操作的对象是副本,不是书目。第一次做的人如果忽略了这层关系,后面处理同书多册时会非常痛苦。
  • 借阅状态谁维护:最直接的做法是在读者和图书上都加一个"是否借出"字段,但这样会埋坑。正确做法是借阅状态由借阅记录表派生——查一下是否有未归还的记录,就能知道书在哪。冗余状态字段只在查询性能有明显问题时才考虑加,且必须用事务保证一致。
  • 日期与逾期:应还日期通常是借出日期加默认天数(比如30天),逾期判断不能只看日期大小,还要考虑还书当天是否算逾期。这个听起来小,但实际编码时很多人会因为在时间边界上的失误被测试用例卡住。

把这些问题想透了,后面的数据库设计和编码就会顺很多。设计阶段多花一小时,编码阶段少加三天班。

3. 技术选型的取舍逻辑:WinForms、SQLite和Dapper为什么是黄金组合

技术选型这件事,很多教程直接默认了"用什么什么",但实际开发中每个选择都是权衡。这里我把我的选型逻辑完整说一遍,你以后换技术栈时这个思考路径也能复用。

桌面端框架我选了WinForms而不是WPF,原因非常朴素:上手曲线低。WinForms的控件拖拽模型和事件驱动模式,跟控制台程序的学习路径衔接特别自然,Form和控件的事件就是Core知识里委托和事件的最佳实战载体。WPF的MVVM、数据绑定、样式模板虽然上限更高,但对第一个项目来说认知负担太大。如果你已经熟练WPF,那这套思路完全可以平移过去,只是UI层的写法会不一样。至于说C#能不能拿来开发上位机、连接西门子OPC,那是老话题了,WinForms至今在工业上位机领域仍是主力,侧面说明这个技术栈生命周期足够长。

数据库选了SQLite,而不是SQL Server或MySQL。核心原因有三:第一,部署成本为零。SQLite是以文件形式存在的嵌入式数据库,不需要单独安装服务,程序跑起来库文件自动就在那里,这对给学校或小图书室做演示项目极其友好;第二,数据就是一个.db文件,备份和迁移就是拷贝文件,完全没有DBA的负担;第三,SQLite语法兼容标准SQL的绝大部分,你在这上面写的SQL知识后面切到MySQL、SQL Server几乎没有迁移成本。缺点当然也有:并发写入能力弱、不支持存储过程,但对单机桌面系统这都不算事。

数据访问层用了Dapper而不是EF Core或原生ADO.NET。很多人学C#数据库操作时一上来就被推荐EF Core,但我的经验是老实用好轻量ORM更重要。原因如下:EF Core的实体映射和LINQ表达式虽然强大,可它把"SQL是怎么执行"这件事包装得太深了,初学者遇到性能问题或异常时往往一脸懵;而原生ADO.NET又太过底层,代码里反复写Connection、Command、DataReader,样板代码太多。Dapper处在正中间,让你写原生的SQL语句,同时又帮你自动完成查询结果到对象的映射。SQL看得见摸得着,实体映射又不用手写,这样你在项目里既能练好SQL基本功,又不会因为重复劳动失去耐心。而且Dapper在GitHub上有十几万Star,几乎所有.NET项目都能见到它,学它绝对不亏。

下面用表格直接对比一下三套方案的特点:

对比项ADO.NETDapperEF Core
SQL控制力完全手写完全手写由LINQ生成,难以完全掌控
学习曲线较低但样板代码多低高,涉及迁移、导航属性、状态跟踪
执行效率最高但代码冗余接近原生有额外开销
开发速度慢快快,但出问题难排查
适合场景对性能极敏感中小型项目、需要灵活SQL大型复杂域模型、快速CRUD

所以最终的技术栈就锁定为:C# + WinForms + SQLite + Dapper,开发工具用Visual Studio 2022 Community版,数据库可视化工具我用的是DBeaver(免费开源,跨平台,也可以直接用VS自带的Server Explorer)。NuGet安装三个包就够:System.Data.SQLite(或Microsoft.Data.Sqlite)、Dapper,以及顺手加一个Dapper.Contrib用于简化插入和更新操作。这里提个细节,System.Data.SQLite和Microsoft.Data.Sqlite选哪个都行,前者功能全包含加密扩展,是官方为ADO.NET生态维护的;后者更现代、轻量,是微软自己出品的。我习惯用Microsoft.Data.Sqlite,因为后续如果做跨平台(MAUI或ASP.NET Core)能少踩坑,但桌面开发差距不大,你随缘选即可。

4. 数据库设计的细节:三张核心表和一个最容易出错的外键关系

技术栈定了,接下来是数据库设计。起步阶段我不建议用工具画ER图,直接在代码里建库建表反而更有掌控感。一个标准的图书管理系统,表结构大致是这样:

4.1 书目表、副本表、读者表和借阅记录表的设计思路

第一张表是图书书目表(Books),存书的基本元信息。我把字段设计成:BookId(自增主键)、Title(书名)、Author(作者)、Publisher(出版社)、ISBN(国际标准书号)、Category(分类)、Price(定价)、PublishDate(出版日期)、Description(简介)。Title、Author、ISBN这三项应该建索引,因为检索场景几乎都走这几个条件。ISBN要特别注意:它不是每本实体书的唯一标识,而是每种出版物的唯一标识——同一种书印1000本,ISBN是一样的,所以它不能做主键。

第二张表是馆藏副本表(BookCopies),这才是每一本具体实体书的登记表。字段是:CopyId(自增主键)、BookId(外键关联书目表)、Barcode(条码号,对用户来说是一本书的身份证)、Status(在馆/借出/维修/下架)、ShelfLocation(馆藏位置,比如"A区-3排-2列")。为什么非要多这么一张表?我再强调一次:没有它,买三本《C#高级编程》就只能创建三条一模一样的书目记录,后续借还时根本分不清读者还的是哪一本。有了Copies表,借阅操作就变成了对CopyId的状态流转。

第三张表是读者表(Readers),字段有:ReaderId(自增主键)、ReaderNo(借书证号,可以自己按规则生成)、Name、Gender、Phone、Email、RegisterDate、Status(正常/禁用)。借书证号这么设计:系统当前年份加上四位流水号,比如2025-0001。这种编号一定要在代码里生成而不是让用户填,既保证唯一性又显得专业。

第四张表是借阅记录表(BorrowRecords),承载整个系统的核心业务。字段设计为:RecordId(自增主键)、CopyId(外键关联副本表)、ReaderId(外键关联读者表)、BorrowDate(借出日期)、DueDate(应还日期)、ReturnDate(实际归还日期,初始为空)、OperatorId(操作员,先留个字段以后扩展)、Remark(备注)。注意,这张表不需要存书名和读者名,全部用外键关联,查询时JOIN出来即可。这既避免了数据冗余,也是关系型数据库设计的核心习惯。

4.2 逻辑外键和级联删除的坑

表关系上还有两个容易忽略的决策点。一个是外键约束:SQLite用PRAGMA foreign_keys = ON才能启用物理外键约束,连接串或连接对象创建后第一件事就要开这个开关,否则建了外键也会被无视。Dapper封装了连接对象,你可以这样写:

using var connection = new SqliteConnection("Data Source=book_manager.db"); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = "PRAGMA foreign_keys = ON;"; command.ExecuteNonQuery();

建议把这句封装到获取连接的方法里,每次新建连接自动执行,避免忘掉。

另一个是级联删除策略的取舍。我的做法是不启用级联删除,而是手动控制。举个例子:删除一个读者之前,必须检查他有没有未归还的借阅记录;如果直接执行DELETE FROM Readers WHERE ReaderId=1,假设有外键约束,SQLite会抛异常;没有外键约束则借阅记录成了孤儿数据。更稳妥的方案是在删除前先查一下,有未还记录时给用户弹提示"该读者名下存在未归还图书,不能删除",有历史记录但全部已归还时,可以选择物理删除读者并保留借阅记录,这时CRUD代码里要显示地先删BorrowRecords再删Readers。原因很简单:借阅记录是审计数据,不能因为读者被删除就消失。所以哪怕删了读者,记录里仍然要能通过冗余的读者快照或直接禁止删除来保证历史可追踪。我在项目中选了"历史记录保留、物理删除读者但不删记录"的做法,实际运行时再通过联合查询显示"读者已注销"。

初始化表结构的SQL这里给一个精简版本,方便你直接落地:

CREATE TABLE IF NOT EXISTS Books ( BookId INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT NOT NULL, Author TEXT NOT NULL, Publisher TEXT, ISBN TEXT UNIQUE, Category TEXT, Price REAL, PublishDate TEXT, Description TEXT ); CREATE TABLE IF NOT EXISTS BookCopies ( CopyId INTEGER PRIMARY KEY AUTOINCREMENT, BookId INTEGER NOT NULL, Barcode TEXT UNIQUE, Status TEXT DEFAULT '在馆', ShelfLocation TEXT, FOREIGN KEY (BookId) REFERENCES Books(BookId) ); CREATE TABLE IF NOT EXISTS Readers ( ReaderId INTEGER PRIMARY KEY AUTOINCREMENT, ReaderNo TEXT UNIQUE NOT NULL, Name TEXT NOT NULL, Gender TEXT, Phone TEXT, Email TEXT, RegisterDate TEXT, Status TEXT DEFAULT '正常' ); CREATE TABLE IF NOT EXISTS BorrowRecords ( RecordId INTEGER PRIMARY KEY AUTOINCREMENT, CopyId INTEGER NOT NULL, ReaderId INTEGER NOT NULL, BorrowDate TEXT NOT NULL, DueDate TEXT NOT NULL, ReturnDate TEXT, Remark TEXT, FOREIGN KEY (CopyId) REFERENCES BookCopies(CopyId), FOREIGN KEY (ReaderId) REFERENCES Readers(ReaderId) ); CREATE INDEX IF NOT EXISTS idx_borrow_copy ON BorrowRecords(CopyId); CREATE INDEX IF NOT EXISTS idx_borrow_reader ON BorrowRecords(ReaderId);

日期字段我用TEXT存储,格式统一为yyyy-MM-dd HH:mm:ss。这是SQLite的一个"反常识"点——它其实没有真正的日期时间类型,存TEXT或INTEGER,比较时用字符串排序或转为时间戳。为了显示方便,我直接用TEXT存标准格式,这样既可直接显示又可字符串比较,排序也符合直觉。如果你需要做日期加减运算,SQLite提供了date('now')、julianday()这些函数,比在C#里操作再拼进SQL要方便得多,后面计算逾期天数时会用到。

5. 从界面到数据库的分层架构:把代码组织得不像新手写的

很多初学者做WinForms项目习惯把所有逻辑塞进Form的按钮事件里,一个MainForm.cs写上几千行。这样短期看很快,但当你需要增加第二个窗口共享同一个数据访问逻辑时,就会开始到处复制粘贴。我的建议是哪怕项目很小,也用三层结构:UI层(WinForms窗体)+ 业务逻辑层(服务类)+ 数据访问层(仓储类)。

5.1 三个层的职责划分和文件夹组织

具体划分是这样的:

  • Models层:实体类,对应数据库表结构,比如Book、BookCopy、Reader、BorrowRecord。这些类就是Dapper自动映射的目标。字段命名尽量和数据库列名一致,或者用特性标注映射关系,这里用简单的属性即可。
  • Data层(数据访问):每个实体对应一个Repository类,只负责SQL语句的发送和结果映射,比如BookRepository.GetById(int id)、BorrowRecordRepository.GetUnreturnedByReader(int readerId)。方法按功能命名,不掺任何界面逻辑。
  • Services层(业务逻辑):真正的规则判断在这里。比如借书时,Service层负责检查读者状态、检查副本状态、插入借阅记录、更新副本状态,这一个业务流程对应一个Service方法。如果直接用仓储层拼装这些操作,很容易出现一个按钮事件里连写六个SQL方法的灾难现场。
  • UI层:Form只做两件事——收集用户输入、调用Service层方法、把结果显示在列表或文本框里。

文件夹组织成这样:

BookManager/ ├── Models/ │ ├── Book.cs │ ├── BookCopy.cs │ ├── Reader.cs │ └── BorrowRecord.cs ├── Data/ │ ├── DatabaseHelper.cs │ ├── BookRepository.cs │ ├── BookCopyRepository.cs │ ├── ReaderRepository.cs │ └── BorrowRecordRepository.cs ├── Services/ │ ├── BookService.cs │ ├── BorrowService.cs │ └── ReturnService.cs └── Views/ ├── MainForm.cs ├── BookManageForm.cs ├── ReaderManageForm.cs ├── BorrowForm.cs └── ReturnForm.cs

实体类的写法最简单直接:

public class Book { public int BookId { get; set; } public string Title { get; set; } public string Author { get; set; } public string Publisher { get; set; } public string ISBN { get; set; } public string Category { get; set; } public decimal Price { get; set; } public string PublishDate { get; set; } public string Description { get; set; } }

这里有个真实开发经验:不要在实体类里写构造函数的业务逻辑,不要写ToString重载来拼显示文本。显示层的拼接放到Item的ToString或格式化方法里,实体类保持纯净,这也是Dapper映射时最省心的状态。

5.2 让查询代码优雅的秘诀:参数化和动态SQL拼接

图书管理系统最见功力的代码其实是检索功能。用户可能在界面上任意组合书名、作者、ISBN、分类,你要写一个能适配不同输入组合的SQL,这就是动态SQL。很多人直接用字符串拼接,容易出错而且有SQL注入风险。Dapper配参数化查询,既保证安全,又能让SQL逻辑清晰。

比如这个场景:界面上有四个查询条件,用户可能填其中任意几个。我的Repository方法用可选参数实现动态条件,核心代码如下:

public IEnumerable<Book> SearchBooks(string title, string author, string isbn, string category) { using var connection = DatabaseHelper.GetConnection(); var sql = "SELECT * FROM Books WHERE 1=1"; var parameters = new DynamicParameters(); if (!string.IsNullOrWhiteSpace(title)) { sql += " AND Title LIKE @Title"; parameters.Add("Title", $"%{title}%"); } if (!string.IsNullOrWhiteSpace(author)) { sql += " AND Author LIKE @Author"; parameters.Add("Author", $"%{author}%"); } if (!string.IsNullOrWhiteSpace(isbn)) { sql += " AND ISBN LIKE @Isbn"; parameters.Add("Isbn", $"%{isbn}%"); } if (!string.IsNullOrWhiteSpace(category)) { sql += " AND Category = @Category"; parameters.Add("Category", category); } return connection.Query<Book>(sql, parameters); }

这段代码里有两个关键细节值得展开。

第一个细节是WHERE 1=1。很多教程都这么写,看起来别扭,但它是动态拼接条件时的经典技巧——不用去判断"这是第一个条件还是后面追加的条件",永远用AND开头即可。当年我刚开始写动态SQL时一直纠结要不要先拼WHERE再判断,写了七八个if嵌套后代码丑到没法看,后来看到这个写法直接豁然开朗。

第二个细节是模糊查询的占位符写法。LIKE的条件不是直接传title,而是把%拼在参数值里,这是很多新手搞反的地方。如果有人告诉你"Dapper自动处理参数化",你要知道它只负责参数值的注入安全,不负责帮你加通配符。另外,字段较多时用DynamicParameters很清晰,如果只有一两个参数,直接传匿名对象new { title }也可以,但多个可选参数时DynamicParameters的可读性好得多。

5.3 业务规则放对位置:借书服务的正确姿势

借书是整个系统中最核心的服务方法。我把它写在BorrowService里,而不是直接写在BorrowForm的按钮事件里。这样做的好处是,后续如果加一个控制台入口或REST API,业务规则可以复用。借书方法的逻辑顺序是固定的:

  1. 校验读者存在且状态为"正常"
  2. 校验副本存在且状态为"在馆"
  3. 生成借阅记录,BorrowDate为当前时间,DueDate加30天
  4. 更新副本状态为"借出"
  5. 两个操作必须在一个事务里完成

这里最考验功力的就是事务处理。如果插入借阅记录成功但更新副本状态失败,数据就不一致了——记录显示书借出去了,但状态还是在馆。写事务的代码如下:

public bool BorrowBook(int copyId, int readerId) { using var connection = DatabaseHelper.GetConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { var reader = connection.QueryFirstOrDefault<Reader>( "SELECT * FROM Readers WHERE ReaderId = @ReaderId AND Status = '正常'", new { ReaderId = readerId }, transaction); if (reader == null) throw new InvalidOperationException("读者不存在或已被禁用"); var copy = connection.QueryFirstOrDefault<BookCopy>( "SELECT * FROM BookCopies WHERE CopyId = @CopyId AND Status = '在馆'", new { CopyId = copyId }, transaction); if (copy == null) throw new InvalidOperationException("图书不存在或不在馆"); var borrowDate = DateTime.Now; var dueDate = borrowDate.AddDays(30); connection.Execute( @"INSERT INTO BorrowRecords (CopyId, ReaderId, BorrowDate, DueDate, ReturnDate) VALUES (@CopyId, @ReaderId, @BorrowDate, @DueDate, NULL)", new { CopyId = copyId, ReaderId = readerId, BorrowDate = borrowDate.ToString("yyyy-MM-dd HH:mm:ss"), DueDate = dueDate.ToString("yyyy-MM-dd HH:mm:ss") }, transaction); connection.Execute( "UPDATE BookCopies SET Status = '借出' WHERE CopyId = @CopyId", new { CopyId = copyId }, transaction); transaction.Commit(); return true; } catch { transaction.Rollback(); throw; } }

注意Dapper的BeginTransaction出来后,所有Execute或Query方法都要把这个transaction对象作为第三个参数传进去,否则默认用的是连接上的隐式事务,根本不在你开的事务范围内。这个细节很容易踩,我一个同事当年写过一个类似的系统,事务开了等于没开,查了一个下午最后发现就是漏传了transaction参数。

还有一个体现C#基础功力的点——connection要用using声明。虽然Dapper内部会自动开启和关闭连接,但显式控制更安全。我个人习惯是每个Repository方法内部用using var connection = DatabaseHelper.GetConnection();,方法结束连接自动释放。事务场景下特别注意,一定要用connection.Open()显式打开连接,因为事务要求连接必须处于打开状态,Dapper的懒连接在这里不适用。

6. 核心功能的编码实战:检索、统计、界面细节一网打尽

架构和数据库都备好了,接下来就是把各个功能逐个实现。这里我不会把每个窗体的每个控件都贴一遍代码,而是挑出几个真正有含金量、能学到东西的部分展开。

6.1 还书时的逾期天数计算:SQLite日期函数与C#日期处理的协作

还书操作的逻辑比借书要多一个逾期判断。先更新借阅记录的ReturnDate,再把副本状态改回"在馆",最后判断是否逾期。逾期判断有两种实现方式,一种是把借阅记录查出来在C#里算,一种是在SQL里用SQLite的日期函数直接计算。我实际项目中用了更稳妥的方式:先查出借阅记录,在C#里算完逾期天数,再决定是否弹出提示,最后统一更新。

具体做法是这样:

public (bool Success, string Message, int OverdueDays) ReturnBook(int recordId) { using var connection = DatabaseHelper.GetConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { var record = connection.QueryFirstOrDefault<BorrowRecord>( "SELECT * FROM BorrowRecords WHERE RecordId = @RecordId AND ReturnDate IS NULL", new { RecordId = recordId }, transaction); if (record == null) throw new InvalidOperationException("借阅记录不存在或已归还"); var now = DateTime.Now; var dueDate = DateTime.ParseExact(record.DueDate, "yyyy-MM-dd HH:mm:ss", null); var overdueDays = (now.Date - dueDate.Date).Days; var returnDateStr = now.ToString("yyyy-MM-dd HH:mm:ss"); connection.Execute( "UPDATE BorrowRecords SET ReturnDate = @ReturnDate WHERE RecordId = @RecordId", new { ReturnDate = returnDateStr, RecordId = recordId }, transaction); connection.Execute( "UPDATE BookCopies SET Status = '在馆' WHERE CopyId = @CopyId", new { CopyId = record.CopyId }, transaction); transaction.Commit(); return (true, overdueDays > 0 ? $"还书成功,逾期{overdueDays}天" : "还书成功。", overdueDays > 0 ? overdueDays : 0); } catch { transaction.Rollback(); throw; } }

这里有个体现细节的点:(now.Date - dueDate.Date).Days用的是Date属性,不是now - dueDate。原因很好理解——借书当天还书不算逾期,如果直接相减会把时间差也算进来,比如借出是上午10点、还书是下午3点,时间差超过一天就会误判为逾期1天。所以先取日期部分再相减,才能保证边界天的正确性。类似的坑还有DateTime.ParseExact的格式字符串要和数据库里存的格式完全一致,一个字母错了就是运行时异常。

顺带提一个界面上的问题:还书时怎么找到要还的记录?我是在还书窗体上放一个文本框输入借书证号,按查询后DataGridView列出该读者名下所有未归还记录,选中某一条点击还书按钮。那查询SQL就是SELECT * FROM BorrowRecords WHERE ReaderId=@ReaderId AND ReturnDate IS NULL,进一步还可以JOIN出书名和条码号,显示为用户可读的信息。

6.2 在DataGridView上显示关联字段:数据的格式化是UI层的事

借阅记录表里存的是CopyId和ReaderId,但界面上不能给用户显示ID,必须显示书名、条码号、读者姓名这些可读信息。于是查询SQL就要JOIN多张表。这里我把一个借阅列表的查询写成视图,方便多个窗体复用:

CREATE VIEW IF NOT EXISTS V_BorrowList AS SELECT br.RecordId, br.CopyId, r.ReaderId, r.ReaderNo, r.Name AS ReaderName, b.Title AS BookTitle, bc.Barcode, br.BorrowDate, br.DueDate, br.ReturnDate, CASE WHEN br.ReturnDate IS NULL AND br.DueDate < datetime('now','localtime') THEN '已逾期' WHEN br.ReturnDate IS NULL THEN '借出中' ELSE '已归还' END AS StatusText FROM BorrowRecords br JOIN BookCopies bc ON br.CopyId = bc.CopyId JOIN Books b ON bc.BookId = b.BookId JOIN Readers r ON br.ReaderId = r.ReaderId;

视图是SQLite支持的,最方便的一点是Dapper可以直接把它当成表来查询:

public IEnumerable<dynamic> GetBorrowList(int? readerId = null, bool? onlyUnreturned = null) { using var connection = DatabaseHelper.GetConnection(); var sql = "SELECT * FROM V_BorrowList WHERE 1=1"; var parameters = new DynamicParameters(); if (readerId.HasValue) { sql += " AND ReaderId = @ReaderId"; parameters.Add("ReaderId", readerId.Value); } if (onlyUnreturned.HasValue && onlyUnreturned.Value) { sql += " AND ReturnDate IS NULL"; } return connection.Query(sql, parameters); }

这里StatusText的生成是视图里的一个亮点,用SQL直接算出当前状态展示,C#那边就不用再写一堆if else来判断了。datetime('now','localtime')是SQLite拿本地时间的函数,与直接存字符串比较时,因DueDate存的是本地时间字符串,用date('now','localtime')比较更稳妥。之所以在视图里用datetime,是因为DueDate带了时间部分,全用date切割后再比也没毛病。视图还有一个隐藏好处:窗体加载时只要一行代码绑定DataGridView即可:

dataGridView1.DataSource = _borrowRepository.GetBorrowList().ToList();

再配合DataSource的自动列生成,界面代码瞬间就简洁了。当然,如果不想用视图,也可以在C#里用BorrowRecord对象拼一个DTO(Data Transfer Object),但明显视图的SQL更直观,而且SQL的重用性是C#代码比不了的。

6.3 借阅排行与分类统计:用聚合查询练手很重要

图书管理系统不只是增删改查,加一两个统计功能不仅让系统显得完整,也是对SQL聚合查询的一次实战训练。我加了两个统计面板:

第一个是热门图书排行,按借阅次数从高到低排序,取前10:

public IEnumerable<dynamic> GetHotBooks(int topN = 10) { using var connection = DatabaseHelper.GetConnection(); const string sql = @" SELECT b.Title, b.Author, COUNT(br.RecordId) AS BorrowCount FROM BorrowRecords br JOIN BookCopies bc ON br.CopyId = bc.CopyId JOIN Books b ON bc.BookId = b.BookId GROUP BY b.BookId ORDER BY BorrowCount DESC LIMIT @TopN"; return connection.Query(sql, new { TopN = topN }); }

第二个是分类数量统计,直接在柱状图或列表里显示每个分类的藏书量。界面里用DataGridView显示结果就行,不需要复杂的图表库。

统计的另一个常见需求是逾期记录列表。那就在视图基础上加WHERE条件:

SELECT * FROM V_BorrowList WHERE StatusText = '已逾期'

当然这依赖视图里算好的状态,如果状态判断逻辑变了,改视图即可,C#代码完全不用动,这就是分层设计的好处。聚合查询这块如果你能熟练写出来,SQL水平就算脱离了"只会SELECT * FROM 单表"的新手段位了。

6.4 界面交互的细节打磨:让读者借书时能实时看到已借数量

WinForms界面的交互,除了按钮事件和数据绑定之外,还有很多细节决定系统好不好用。我做借书窗体时,添加了一个"查询读者"的交互:输入证号按回车,窗体上就显示读者姓名、状态和当前已借未还的图书数量。已借数量的SQL就是:

public int GetBorrowedCount(int readerId) { using var connection = DatabaseHelper.GetConnection(); const string sql = @" SELECT COUNT(*) FROM BorrowRecords WHERE ReaderId = @ReaderId AND ReturnDate IS NULL"; return connection.ExecuteScalar<int>(sql, new { ReaderId = readerId }); }

ExecuteScalar是Dapper返回单值的方法,比QueryFirstOrDefault再转型更方便。界面上还可以顺手限制借阅数量上限,比如读者最多借5本,在借书按钮点击时判断GetBorrowedCount是否达到上限,超了就弹窗提示。这就是把业务规则落到Service层而不是散落在UI里的活例子。

类似的细节还有:DataGridView的列宽和标题友好显示、双击行时自动把信息带入编辑窗体、删除操作前统一弹确认框。这些不涉及高深技术,但决定了你的系统是不是"能用"和"好用的距离"。

7. 真实项目跑起来的五个拦路虎:我的排查经验和解决方案

任何项目在编码路上都不会一帆风顺。我把开发过程中真正卡过我比较久的问题列出来,每个都是我亲自排查过的,按可见程度从明显到隐蔽排序。

7.1 中文乱码和数据格式不一致问题

首次用SQLite数据库时,很多人会遇到插入中文后读出来乱码。SQLite本身是UTF-8存储的,乱码原因通常不在数据库,而在于连接串没指定编码或控制台/控件显示字体不对。解决方式是统一三处编码:数据库连接串加UTF8=1;参数;WinForms控件尽量使用默认字体,像DataGridView的中文显示基本无问题;如果还有乱码,检查VS源文件编码是否被改成了GBK。

格式不一致问题在日期上高发。我在数据库中统一用yyyy-MM-dd HH:mm:ss,但有些地方写DST(夏令时缩写,我开玩笑的,指不同格式字符串)时少写了秒位,查询时字符串比较就不对。这类问题的排查思路是:写个Console小窗口把所有日期字段统一打印出来,用肉眼核对格式,你会发现根本不用等程序报错,一眼就看出来了。另外,尽量不要用DateTime.Now.ToString()不指定格式就存进库,结果随系统区域设置变化,换台电脑就变成另一种格式,兼容性极差。

7.2 SQLite库文件被占用:明明关闭了连接还是报错

在WinForms里连续打开关闭窗体,有时会抛出"database is locked"异常。这是因为Dapper的某个查询可能没有正确释放连接。排查思路是——别依赖using的自动释放,检查是否有静态连接对象被多个方法共用。我见过有人图省事把连接做成静态字段,所有方法都用它,结果一个事务没完就去做查询,锁直接卡住。SQLite的锁机制是数据库级别的,不像SQL Server那样行级锁,只要有一个连接处于写入状态,其他写入就等待甚至直接报错。

我最终的方案很简单:每个数据库操作都在局部作用域内创建连接,方法结束立即释放。数据库操作密集时用事务批量执行,减少不必要的开关次数。按这个原则改造后,锁库问题再没出现过。

7.3 Dapper多表JOIN映射的误区:一次查询无法自动填充三个实体

借阅列表视图返回的是动态类型,但如果你某些场景需要强类型对象,就会遇到Dapper多表JOIN的映射问题。很多人以为这样写就能自动填充:

connection.Query<BorrowRecord, Book, Reader, BorrowRecord>(sql, (record, book, reader) => {...});

但要注意,Dapper的分表映射要求SQL里的列顺序和实体属性对得上,而且起别名也很讲究。我在这里花过不少时间,最后发现最省心的方式仍然是直接用视图查询返回动态类型,或者定义一个单独的DTO类。DTO是专门为展示层定义的桩类,里面的属性正好对齐界面需要的列,不依赖ORM映射的复杂机制。比如定义这样一个类:

public class BorrowListView { public int RecordId { get; set; } public string ReaderNo { get; set; } public string ReaderName { get; set; } public string BookTitle { get; set; } public string Barcode { get; set; } public string BorrowDate { get; set; } public string DueDate { get; set; } public string ReturnDate { get; set; } public string StatusText { get; set; } }

然后把SQL改成SELECT RecordId, ReaderNo, ReaderName, BookTitle, Barcode, BorrowDate, DueDate, ReturnDate, StatusText FROM V_BorrowList,Dapper就能自动按列名映射到DTO。这个方法简单可靠,也好维护。

7.4 DataGridView刷新数据源后筛选条件丢失

这是一个很隐蔽的界面问题。DataGridView绑定数据源后,如果你在代码里去改数据源的Filter或做二次筛选,经常发现界面不刷新或者筛选无效。原因是DataGridView的DataSource绑定的是List或BindingSource时,行为不一样。List不会自动感知变化,BindingSource才会。所以我用DataGridView时明确区分:静态展示用List直接绑定,需要筛选、排序、多表联动刷新用BindingSource包一层,再绑定给DataGridView:

var list = _borrowRepository.GetBorrowList(readerId).ToList(); bindingSource1.DataSource = list; dataGridView1.DataSource = bindingSource1;

之后修改bindingSource1的Filter属性,DataGridView会跟着变。Filter字符串的语法类似SQL,如StatusText = '借出中'。这个细节对界面交互的流畅度影响极大,我也是做了图书管理系统的第二个版本才意识到。

7.5 搜索框回车事件和按钮默认事件冲突

WinForms的默认行为是:在窗体上按下回车会触发AcceptButton按钮的Click。如果你在文本框的KeyDown事件里写了查询逻辑,回车按下时会同时触发文本框的查询和按钮的查询,导致两次查询、界面闪烁。解决方式是设置KeyPress或KeyDown里判断e.KeyCode == Keys.Enter后,把e.Handled = true或e.SuppressKeyPress = true再执行查询。另外,如果回车也希望执行查询而不是点按钮,在窗体的AcceptButton属性中设置为主查询按钮也能统一体验。这几个小点,网上资料往往不提,只有实际敲过才会遇到。

8. 项目还能往哪个方向扩展:从练手项目到简历作品

核心功能做完,这个系统已经是一个拿得出手的完整项目了。但如果你想让它在简历上更亮眼,或者给自己多一些挑战,我按性价比从高到低排几个扩展方向:

  • 角色登录与权限控制:最简单的做法是加一张User表存账号密码和角色,登录后给主窗体的按钮设置Enabled状态。进阶一点用自定义Attribute标记权限、反射扫描窗体,这个能引出很多C#反射和特性的知识。
  • 书籍封面图片与OCR识别:给书目表加封面图片字段,用PictureBox展示;更进一步,用Tesseract OCR识别ISBN条码或拍照识别书名,自动填充表单。热搜词里c#使用tesseract,说明这条路不少人在走。做出来的效果很闪亮,而且能学到图像处理的基本套路。
  • 借阅到期提醒:启动程序时扫描所有未归还且DueDate小于当前时间的记录,弹窗或列表提醒。这在业务上非常实用,也是C#队列和定时器(System.Windows.Forms.Timer)的典型应用场景。
  • 把单机版改造成C/S架构:把数据访问层换成Web API或gRPC客户端,WinForms只做界面。到这一步你就是在过渡到真正的企业应用开发了,之后衔接上位机、工业通讯、物联网设备(热搜词里c#连接西门子OPC、c# CAN通讯那些方向)也更有底。
  • 引入NLog组件做日志记录:所有增删改查操作写进日志文件,这是提高系统可维护性的好抓手。热搜词里c#如何用nlog,说明这也是高频问题。日志组件用起来很简单,但引入它会让你开始思考"系统除了干活还要留下痕迹"这件事。

我个人的使用体会是:图书管理系统最大的价值不在于"图书管理"这几个字,而在于它逼着你把C#的各个知识点串成一条完整的生产链路。从界面到数据库,从单条记录到事务一致性,从硬编码SQL到分层架构,这些是任何商业项目都躲不开的基础功。你把这个项目吃透了,后面再接触Web开发、上位机、移动端,触类旁通的速度会快很多。

最后分享一个小技巧:写这个项目时给每个Repository方法都加上清晰注释,说明参数含义和返回结果。等以后你代码写多了回头看,会发现注释不是写给别人看的,是写给三个月后的自己看的。项目可以不完美,但结构一定要清楚——这比功能多写俩按钮重要得多。

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

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

立即咨询