ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# WinForms+SQLite图书管理系统实战:三层架构与事务处理全解析

C# WinForms+SQLite图书管理系统实战:三层架构与事务处理全解析 做图书管理系统可以说是C#桌面端开发者绕不开的一个经典实战项目。这个项目不大不小刚好能把WinForms、SQLite、三层架构、事件驱动这些最常用的基本功全部串起来做一次完整的技术体检。市面上讲这个题目的教程很多但大多数要么只给一段段代码让读者自己拼要么停在“照着敲就能跑”的层面很少说清楚每个环节为什么这么设计、踩过的坑长什么样。这篇文章我想用一次完整的实操过程把从需求拆解、数据库设计、核心代码实现到界面交互的整条链路走一遍没有废话全是能直接用起来的东西。这套系统适合谁看如果你刚学完C#语法、想找一个不依赖视频教程也能独立完成的练手项目或者你在工作中需要快速搭一个简单的单机管理工具再或者你正在做课程设计、毕业设计需要一套结构清楚、可复现的代码都可以照着我这条路走。我会尽量把关键决策背后的理由讲透——比如为什么用SQLite而不是SQL Server、为什么数据访问要单独抽一层、为什么借书还书必须放在同一个逻辑里处理——这些才是真正让你从“会写代码”到“会设计程序”的分水岭。1. 项目目标与整体设计思路1.1 为什么用C#写图书管理系统而不是Web方案很多新手会纠结图书管理系统这种东西用Java Web、用PHP、用Vue做不是更好看吗确实Web方案在界面展示和多端访问上有先天优势但如果你是奔着补C#基础去的用WinForms做桌面版反而是更有针对性的一种练法。WinForms的学习曲线比WPF平滑得多没有复杂的XAML和数据绑定体系控件拖上去就能用。它的核心编程模型是事件驱动——你点按钮按钮触发Click事件你在事件处理函数里写业务逻辑。这个模型跟用户操作的真实流程是一一对应的对初学者来说非常直观。而且WinForms自带的那套控件在图书管理这种典型CRUD场景里效率极高DataGridView一拖一绑数据表格就出来了不需要自己写分页组件和表格渲染逻辑。C#做这种事还有一个隐性优势代码可读性。C#的语法本来就偏向结构化加上强类型特性变量是什么类型、方法返回什么一眼就清楚。同一个项目用弱类型语言写可能跑起来很快但半年后回头看就是一团麻。C#写出来的代码哪怕你注释写得少凭着命名和方法签名也能把逻辑捋顺。从工程维护的角度来说这种特性对个人学习项目尤其重要。1.2 需求到功能的映射不做大而全做够用图书管理系统听起来功能很多但如果真照着商业图书馆的系统去规划你会发现根本做不完也没必要做完。我在动手前做了一次需求收敛核心只保留了四块内容。第一块是图书管理也就是图书信息的增删改查书名、作者、ISBN、出版社、库存总量这些基础字段。第二块是读者管理读者信息的登记和维护。第三块是借书还书这是整个系统的业务核心涉及库存扣减、归还后库存恢复、借阅记录留存。第四块是查询统计按书名模糊搜索、查询某本书当前有几本在馆、谁的借阅还没还。这四块对应到数据库就是三张核心表加一张辅助表图书表、读者表、借阅记录表再加一个管理员表。功能边界一旦划清后续的代码结构就非常清楚不会写着写着就膨胀到不可维护。我特意把逾期费用、预约借书、多级权限这些东西砍掉了。倒不是这些功能有多难而是在一个学习项目里它们会稀释主线。你先把核心的CRUD和借还流程吃透这些进阶功能将来加起来只是多几张表、多几个页面的工作量。1.3 技术选型WinForms SQLite为什么放弃SQL Server这一节可能是很多人最想抄作业的部分。我先说结论项目用WinForms做界面SQLite做数据库数据访问层用ADO.NET手写没上ORM。数据库这块我知道很多人第一反应是SQL Server。但你要想清楚SQL Server的部署成本摆在那——你得装实例、配账号、处理连接字符串里的一大堆权限参数学完这一套下来真正学图书系统业务的时间反而被挤占了。SQLite是一个嵌入式数据库它就是一个文件不需要安装任何服务。你用连接串指到那个文件就能执行标准的SQL语句行为跟关系型数据库基本一致。对一个单机版桌面应用来说这是最务实的方案。如果将来你想换到SQL Server核心代码的改动也没有想象中那么大。因为我会把所有数据库操作集中在数据访问层界面上没有一条SQL语句。只要把这层里的连接串和参数前缀改一改再适配一下数据类型整个系统就能迁移过去。这正是分层设计最大的价值。界面这边我没选WPF原因很实际WPF的MVVM模式在刚接触时容易一头雾水数据绑定出了bug你不知道是绑定的问题还是ViewModel的问题。WinForms就不存在这个复杂度逻辑写在事件里控件操作直来直去。这个项目本来就该用80%的时间学业务逻辑而不是跟框架角力。2. 数据库设计与数据访问层2.1 表结构设计三张表之间的关系数据库设计是整个项目的地基表结构如果设计得不好后面写代码会处处别扭。我设计的三张核心表加上一个管理员表关系用一句话就能说清一本书可以被多个读者借阅一个读者可以借多本书所以图书和读者之间是多对多关系这个关系需要借阅记录表来承接。图书表Books的字段我这样定Id作为主键自增Title存书名Author存作者ISBN存书号Publisher存出版社TotalCopies存总库存AvailableCopies存当前可借数量。这里需要注意一点我同时存了总库存和可借数量两个字段这个设计是有意的。如果只存一个总量那借书时你得知道当前借出去多少才能算出剩多少可借每次查询都要做一次聚合计算。拆成两个字段后借书时改AvailableCopies减一还书时加一性能虽然没本质差异但代码逻辑简单直白不容易出错。读者表Readers就简单多了Id、Name、Phone、RegDate记录基本信息和注册日期。借阅记录表BorrowRecords有四个关键字段Id、BookId、ReaderId、BorrowTime、ReturnTime。ReturnTime允许为空为空就表示这本书还没还。这种用空值表达状态的做法在数据库里很常见查询“未归还记录”时只要写WHERE ReturnTime IS NULL就行比用一个整数状态字段去维护几号是借出几号是还回来得更直观。创建表的SQL放在SQLiteStudio里执行或者直接用代码里的初始化方法执行都可以。我这里贴出书表的部分其他表照此思路扩展。CREATE TABLE Books ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT NOT NULL, Author TEXT NOT NULL, ISBN TEXT, Publisher TEXT, TotalCopies INTEGER NOT NULL DEFAULT 1, AvailableCopies INTEGER NOT NULL DEFAULT 1 );2.2 数据库连接串与Helper类封装数据库访问层我建议封装成一个叫DbHelper的静态类避免每个窗体都重复写连接、打开、执行。连接串单独拎出来放配置里别写死在代码各处。SQLite连接串很简单一个Data Source搞定。但如果直接用相对路径你会踩一个隐藏很深的坑程序编译后当前工作目录可能不在代码目录导致数据库文件找不到或者出不来。正确做法是用AppDomain.CurrentDomain.BaseDirectory拼出绝对路径再配合Path.Combine把它和数据库文件名合并这样不管程序从哪个目录启动都能准确定位数据库文件。using System; using System.Data.SQLite; public static class DbHelper { private static string dbPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, LibraryDB.db); private static string connStr $Data Source{dbPath};Version3;; public static SQLiteConnection CreateConnection() { var conn new SQLiteConnection(connStr); conn.Open(); return conn; } public static int ExecuteNonQuery(string sql, params SQLiteParameter[] parameters) { using (var conn CreateConnection()) using (var cmd new SQLiteCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteNonQuery(); } } public static object ExecuteScalar(string sql, params SQLiteParameter[] parameters) { using (var conn CreateConnection()) using (var cmd new SQLiteCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteScalar(); } } public static SQLiteDataReader ExecuteReader(string sql, params SQLiteParameter[] parameters) { var conn CreateConnection(); var cmd new SQLiteCommand(sql, conn); if (parameters ! null) cmd.Parameters.AddRange(parameters); var reader cmd.ExecuteReader(); return reader; } }这里要特别强调一个细节ExecuteReader方法不能像前两个方法那样用using把connection包起来因为reader需要连接保持打开状态才能逐行读取数据。如果连接被提前释放reader会直接抛异常。这个坑我在新手阶段踩过不止一次后面在常见问题小结里会单独讲。2.3 为什么所有查询必须用参数化而不是字符串拼接凡是讲过这个项目的教程多少会提一句“防SQL注入”但很多人其实不知道SQL注入在这个场景里有多真实。假如你写的是这种代码string sql SELECT * FROM Books WHERE Title txtTitle.Text ;如果用户在文本框里输入 OR 11拼出来就是WHERE Title OR 11条件恒成立整张表的数据全被查出来。如果输入更刁钻的内容甚至可以执行任意SQL语句后果不堪设想。这不是理论上的威胁这种输入方式在公开的桌面应用和Web应用里被反复利用过。参数化查询的原理是把SQL语句的结构和数据分离开来。你先写SELECT * FROM Books WHERE Title title再把title作为参数传给命令对象。数据库在执行时会把参数值当作一个纯数据来看待永远不会被解释成SQL代码的一部分。就算用户输入 OR 11它也只是一个普通的字符串常量查询结果要么匹配不到要么正常匹配绝不至于整表拉出。string sql SELECT * FROM Books WHERE Title LIKE keyword; SQLiteParameter[] ps { new SQLiteParameter(keyword, % txtKeyword.Text.Trim() %) };LIKE模糊查询的参数化写法稍微有点反直觉因为通配符%是写在参数值里而不是SQL里的。这也是正确的数据库会把%abc%当成一个完整的模式串去匹配而不是把参数值当成纯文本。这么写既安全又实现了模糊搜索的需求。2.4 基于SQLite的初始化与连接管理技巧一个单机桌面应用首次部署时数据库文件可能并不存在。所以我在程序启动时做了一次检查如果数据库文件不存在就自动创建数据库文件并执行建表SQL。这段逻辑放在Program.cs的Main方法里在Application.Run之前调用一个InitDatabase方法。[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); InitDatabase(); Application.Run(new MainForm()); } static void InitDatabase() { if (!File.Exists(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, LibraryDB.db))) { DbHelper.ExecuteNonQuery( CREATE TABLE Books (...); CREATE TABLE Readers (...); CREATE TABLE BorrowRecords (...); CREATE TABLE Admins (...); INSERT INTO Admins (Username, PasswordHash) VALUES (admin, ...); ); } }这个设计的好处是发布程序时你不需要附带任何数据库文件拷贝一个exe和相关DLL到目标机器第一次启动就会自动把环境搭好。对不想折腾部署细节的桌面应用来说这是最省心的做法。连接管理这一块我给自己定了一条铁律能用using就绝不用裸连接用完即关。SQLite的连接虽然不像SQL Server那么吃资源但连接不释放会占住文件句柄。最典型的症状就是你在程序里删除了数据库文件或者用外部工具打开它系统告诉你文件被占用其实就是某个连接没有关闭。所有短查询让using包一层异常也能自动释放这是零成本的保险。3. 核心业务模块实战拆解3.1 登录模块验证流程与密码存储系统不能裸奔先做个简单的登录。登录窗体的逻辑不复杂用户输入账号密码程序去Admins表里比对用户名和密码哈希值匹配通过就打开主窗体否则弹提示。密码存储这一节很多人会偷懒用明文。我劝你哪怕只是学习项目也养成好习惯至少做一次哈希再入库。怎么算一次SHA256并转成16进制字符串下面这段代码可以直接抄。using System.Security.Cryptography; using System.Text; public static string HashPassword(string rawPassword) { using (SHA256 sha SHA256.Create()) { byte[] bytes sha.ComputeHash(Encoding.UTF8.GetBytes(rawPassword)); StringBuilder sb new StringBuilder(); foreach (byte b in bytes) sb.Append(b.ToString(x2)); return sb.ToString(); } }登录点击事件里做两层判断第一层是非空校验第二层是SQL查询匹配。查询用参数化WHERE Usernameu AND PasswordHashp能查到记录就说明账号密码正确。这个写法比先查用户再在C#里比对密码更简洁也能避免把用户表整个拉出来。有一点值得说明真正的生产系统里建议用带盐的慢哈希算法比如BCrypt或者PBKDF2SHA256的强度在现代算力下不太够。但我在这里用SHA256仅仅是为了让你能在不引入第三方NuGet包的情况下把示例跑起来。工程落地时请务必换成专门的密码哈希方案。3.2 图书管理模块DataGridView绑定与增删改查图书管理是系统的核心页面。界面布局很直白上方一个GroupBox放录入控件书名、作者、ISBN、出版社、总量中间放四个按钮新增、修改、删除、清空下方一个DataGridView显示所有图书。新增图书的逻辑和修改图书是共用的——区别只在于SQL语句是INSERT还是UPDATE。我先说新增。要把用户填写的字段收集起来做非空校验然后判断ISBN是否已存在最后执行插入。private void btnAdd_Click(object sender, EventArgs e) { string title txtTitle.Text.Trim(); string author txtAuthor.Text.Trim(); string isbn txtISBN.Text.Trim(); string publisher txtPublisher.Text.Trim(); if (title || author ) { MessageBox.Show(书名和作者不能为空); return; } string existsSql SELECT COUNT(*) FROM Books WHERE ISBNisbn; SQLiteParameter[] checkPs { new SQLiteParameter(isbn, isbn) }; int count Convert.ToInt32(DbHelper.ExecuteScalar(existsSql, checkPs)); if (count 0) { MessageBox.Show(该ISBN已存在请勿重复添加); return; } int totalCopies (int)numTotalCopies.Value; string insertSql INSERT INTO Books (Title, Author, ISBN, Publisher, TotalCopies, AvailableCopies) VALUES (title, author, isbn, publisher, total, total); SQLiteParameter[] ps { new SQLiteParameter(title, title), new SQLiteParameter(author, author), new SQLiteParameter(isbn, isbn), new SQLiteParameter(publisher, publisher), new SQLiteParameter(total, totalCopies) }; DbHelper.ExecuteNonQuery(insertSql, ps); LoadBookList(); ClearBookInput(); }新增的初始库存两个字段同时赋值TotalCopies和AvailableCopies都是用户填的总数这个操作没什么玄机。重点在于LoadBookList方法的设计。我把它单独抽出来每次增删改之后调用让DataGridView重新拉数据刷新。这个模式贯穿整个系统几乎每个数据变动操作最后都跟一句LoadBookList或者LoadReaderList。修改操作的逻辑更特殊一些。用户先选中DataGridView的一行这行的数据会自动填充到上面的输入框。要注意的是系统必须记住当前编辑的是哪本书我用一个私有字段currentEditingBookId来保存。修改时先检查是否处于编辑状态如果没选中任何行点修改会弹提示。private void btnEdit_Click(object sender, EventArgs e) { if (currentEditingBookId 0) { MessageBox.Show(请先选中要修改的图书); return; } string updateSql UPDATE Books SET Titletitle, Authorauthor, ISBNisbn, Publisherpublisher, TotalCopiestotal WHERE Idid; SQLiteParameter[] ps { new SQLiteParameter(title, txtTitle.Text.Trim()), new SQLiteParameter(author, txtAuthor.Text.Trim()), new SQLiteParameter(isbn, txtISBN.Text.Trim()), new SQLiteParameter(publisher, txtPublisher.Text.Trim()), new SQLiteParameter(total, (int)numTotalCopies.Value), new SQLiteParameter(id, currentEditingBookId) }; DbHelper.ExecuteNonQuery(updateSql, ps); LoadBookList(); ClearBookInput(); }Update语句里我只更新了TotalCopies没有动AvailableCopies。这是个刻意的选择。因为如果某本书当前有借出未还你直接把总量改少了AvailableCopies可能大于TotalCopies数据就自相矛盾了。实际项目里这种情况应该做更严谨的约束但学习阶段我先用接口限制——在更新总量前弹一个提示让用户意识到库存变动可能影响借出状态。你可以在自己的代码里加这个检查。3.3 借书还书模块事务的必要性借书还书是整个系统里最需要严谨处理的部分。借一本书牵扯到两个数据变动插入一条借阅记录ReturnTime为空、Books表的AvailableCopies减一。这两个操作必须保证都成功或都失败否则会出现记录插进去了但库存没减或者库存减了但查不到借阅记录的脏数据状态。数据库事务就是干这个的。我把借书操作封装成一个方法开启事务后执行两条SQL语句任何一条抛异常就回滚。public bool BorrowBook(int bookId, int readerId) { using (var conn DbHelper.CreateConnection()) { using (var tx conn.BeginTransaction()) { try { string checkSql SELECT AvailableCopies FROM Books WHERE IdbookId; using (var checkCmd new SQLiteCommand(checkSql, conn, tx)) { checkCmd.Parameters.AddWithValue(bookId, bookId); int available Convert.ToInt32(checkCmd.ExecuteScalar()); if (available 0) { tx.Rollback(); return false; } } string updateSql UPDATE Books SET AvailableCopiesAvailableCopies-1 WHERE IdbookId; using (var updateCmd new SQLiteCommand(updateSql, conn, tx)) { updateCmd.Parameters.AddWithValue(bookId, bookId); updateCmd.ExecuteNonQuery(); } string insertSql INSERT INTO BorrowRecords (BookId, ReaderId, BorrowTime, ReturnTime) VALUES (bookId, readerId, borrowTime, NULL); using (var insertCmd new SQLiteCommand(insertSql, conn, tx)) { insertCmd.Parameters.AddWithValue(bookId, bookId); insertCmd.Parameters.AddWithValue(readerId, readerId); insertCmd.Parameters.AddWithValue(borrowTime, DateTime.Now); insertCmd.ExecuteNonQuery(); } tx.Commit(); return true; } catch { tx.Rollback(); return false; } } } }这里有个容易忽略的细节事务命令必须指定事务对象。你在创建SQLiteCommand时如果只传了连接串和SQL它会自己开一个独立操作不在你的事务控制范围内。所以代码里new SQLiteCommand(sql, conn, tx)这个构造函数的三参重载是必须的。新手在这里翻车的时候现象是事务里第一条SQL执行了第二条没执行但事务的回滚根本没拦住第一条——因为你第二条命令压根不在事务里。还书操作是借书的镜像找到对应的未归还记录把ReturnTime更新为当前时间同时把库存加回来。同样用事务包住不再贴重复代码。3.4 搜索与统计功能把SQL写清楚搜索功能是图书管理的刚需。我的做法是在搜索框的TextChanged事件里做实时过滤用户每输入一个字符数据表就跟着刷新。private void txtSearch_TextChanged(object sender, EventArgs e) { string keyword txtSearch.Text.Trim(); string sql SELECT * FROM Books WHERE Title LIKE kw OR Author LIKE kw OR ISBN LIKE kw; SQLiteParameter[] ps { new SQLiteParameter(kw, % keyword %) }; DataTable dt DbHelper.ExecuteQuery(sql, ps); dgvBooks.DataSource dt; }这种实时过滤体验上很爽快但因为每敲一个字就查一次库在数据量非常大的时候可能会卡。对图书管理系统这种场景几千条数据不会有任何压力放心用。统计功能我放在系统里做了一个简单的概览页用三个标签显示总藏书量、当前在馆数量和未归还的借阅笔数。这三个数字对应的SQL一个比一个简单总藏书量用SELECT COUNT(*) FROM Books在馆数量用SELECT SUM(AvailableCopies) FROM Books未归还笔数用SELECT COUNT(*) FROM BorrowRecords WHERE ReturnTime IS NULL。用ExecuteScalar一行就能查出结果别用DataReader去读一个单值。4. 界面交互与数据绑定细节4.1 DataGridView的三种操作陷阱DataGridView是WinForms里最常用的数据表格控件但这个控件有几个特性不提前摸清写出来的代码会非常别扭。第一个是自动生成列不会按你的中文习惯命名。你从数据库拉出来的字段是Title、Author这类英文名直接绑定到DataGridView列标题就是英文。解决办法有两个要么在SQL语句里用别名写Title AS 书名要么绑定后在代码里手工改列头。我用的是第一种SQL直接定义别名省事。第二个是选中行取数据不能用Cells[0]想当然。书籍表第一列是Id但显示给用户时你可能希望隐藏Id列。如果隐藏了Cells[0]对应的就不是书名而是实际的第一列下标经常对不上。稳妥做法是绑定后给每列设置DataPropertyName用列名取值。如下面这样dgvBooks.DataSource dt; dgvBooks.Columns[Id].Visible false; dgvBooks.Columns[Title].HeaderText 书名; dgvBooks.Columns[Author].HeaderText 作者; // ... 其他列设置选中行读取数据时用dgvBooks.CurrentRow.Cells[Title].Value而不是Cells[1]代码可读性和健壮性都更好。第三个是编辑状态下的刷新问题。DataGridView如果绑定了DataSource你每次LoadBookList调用都要重新设置DataSource这个操作会触发控件的刷新和滚动条回位。如果用户正在浏览中间某一行刷新后跳回第一行体验不好。但这个问题在小型系统里不太明显如果你在乎可以在刷新前记录FirstDisplayedScrollingRowIndex刷新后再恢复代码会稍复杂一点。4.2 主窗体的菜单与多页面组织图书管理系统的功能页其实可以放在一个窗体里用TabControl划分也可以用MDI多文档界面或者用独立窗体在菜单里打开。我采用的是TabControl方案。原因很实际功能就四五个页面用一个窗体承载用户不用在多个窗口之间跳来跳去逻辑清晰代码也好组织。主窗体从上到下分三块顶部是标题栏中间是TabControl每个Tab页对应一个功能模块——图书管理、读者管理、借阅管理、统计概览。底部放一个状态栏显示当前登录用户和当前时间。这种布局对桌面管理员应用来说是最稳的用户不会迷路。菜单栏我还是放了一个用MenuStrip实现。里面放“刷新数据”“退出系统”“关于”几个简单菜单项。别小看这些零碎功能它能让界面看起来完整也能练到MenuStrip的Click事件处理和按钮事件没什么本质区别。4.3 事件处理与跨窗体传参借阅管理页面需要同时选择读者和图书我用了两个DataGridView并排显示的方式。左边列出所有读者右边列出所有图书底下显示借阅记录的表格。用户操作流程是左边选中读者右边选中图书点借书按钮完成借阅。选中当前行后我通过RowIndex定位到对应的Id。要留意的是读者列表和图书列表的DataSource都绑定过被选中行的Id列可能被隐藏了所以取值时要通过ColumnName来取。private void btnBorrow_Click(object sender, EventArgs e) { if (dgvReaders.CurrentRow null || dgvBooks.CurrentRow null) { MessageBox.Show(请先选择读者和图书); return; } int readerId Convert.ToInt32(dgvReaders.CurrentRow.Cells[Id].Value); int bookId Convert.ToInt32(dgvBooks.CurrentRow.Cells[Id].Value); if (BookService.BorrowBook(bookId, readerId)) { MessageBox.Show(借书成功); LoadBorrowRecords(); LoadBookList(); } else { MessageBox.Show(借书失败可能库存不足); } }把借书逻辑抽到BookService业务逻辑层界面上只处理传参和结果展示这就是简单版的三层思想。将来想加一条“借书前先检查读者是否已超过最大借阅数”的规则只需要在BorrowBook方法里加判断界面一行代码都不用改。这种可维护性是从一开始就设计出来的不是写完再重构。5. 常见问题与排查技巧实录5.1 SQLite数据库文件找不到或者路径错误症状五花八门程序在本机跑得好好的换了一台电脑就提示“unable to open database file”或者你在代码目录里明明看到LibraryDB.db程序启动后却重新建了一个空库。这类问题九成是路径拼接的锅。工作目录和exe所在目录是两回事尤其是在Visual Studio里调试时工作目录取决于项目配置。反过来当你双击bin\Debug下的exe启动时工作目录才是exe目录。所以最靠谱的写法就是我在Helper类里用的那套用AppDomain.CurrentDomain.BaseDirectory拼数据库路径别用Environment.CurrentDirectory也别用简单的写死LibraryDB.db。注意如果数据库文件被手动放在项目根目录并使用相对路径连接调试时可能正常发布后找不到。统一用绝对路径以exe目录为基准是单机应用最省心的方案。5.2 SqliteConnection没关闭导致文件被占用表现是你想用数据库管理工具打开LibraryDB.db或者想删除它重新建库系统提示文件正被另一个进程使用。逐行排查下来代码里明明每个查询都用了using为什么还占用问题往往出在DataReader上。我前面特意留了个伏笔ExecuteReader返回reader时如果连接是在方法栈里创建的你把它包在using里的话连接在方法返回时就释放了reader读到的就是无效数据。很多人为了修这个异常把连接改成静态字段结果连接永远不关文件就被占用了。正确做法是reader用完后手动关闭reader再关闭连接。我建议设计一个DataTableAdapter方法把reader读进DataTable后就立刻释放连接这样上层代码根本接触不到reader也不会有连接泄漏的顾虑public static DataTable ExecuteQuery(string sql, params SQLiteParameter[] parameters) { DataTable dt new DataTable(); using (var conn CreateConnection()) using (var cmd new SQLiteCommand(sql, conn)) using (var da new SQLiteDataAdapter(cmd)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); da.Fill(dt); } return dt; }SQLiteDataAdapter会自动管理连接生命周期填充完DataTable后连接直接关闭。这是最省心的一招推荐把ExecuteReader方法替换成这个方案。5.3 删除数据库中的一行后DataGridView不刷新这是新手最常见的抱怨“我明明把数据删了界面上还在。”原因很好理解——你执行了Delete SQL但没有重新绑定数据源。DataGridView显示的是内存中的数据副本数据库变了它不会自动知道。解决方法是给自己立个规矩所有增删改操作执行成功后必执行LoadXxxList刷新方法。我在BookService里封装了增删改查如果任何一步失败要弹提示但无论如何都要保证刷新这一步执行。这么做不仅仅为了显示正确也是在维护界面和数据库的一致状态。如果刷新方法里有异常说明数据库层面的数据已经出现了你没想到的状态提前暴露出来反而好修。顺便说一个更隐蔽的点删除图书时如果这本书有未归还的借阅记录直接删掉Book会导致借阅记录变成孤儿数据。我建议删除前先查BorrowRecords表如果有未归还记录就阻止删除提示用户先处理借阅关系。这个检查写两行SQL但能保证数据完整性值得加。5.4 SQLite参数化还是出了奇怪错误怎么办SQLite的参数化写法有两种风格一种是name另一种是$name或者:name。如果你在代码里用了前缀但连接串里没开特定开关大多数情况下也能正常工作。不过有一个容易翻车的点参数名不要和字段名重复。比如你这句SQL是UPDATE Books SET TitleTitle WHERE Idid如果SQLite在某些版本里对大小写不敏感Title和字段Title可能产生歧义。规避方法很简单参数名统一用简写比如title、author、bookId。参数和字段名刻意区分开从源头上消灭歧义。另外AddWithValue方法虽然方便但它会推断参数类型。如果你的字段是INTEGER类型而C#端传了一个字符串数字SQLite大多数情况会自动转换但有些边界情况会产生警告甚至错误。稳妥做法是使用SQLiteParameter的构造函数时指定DbType或者在插入前转换好类型再传参。5.5 DateTime存储与显示格式坑SQLite没有原生的日期时间类型它存的是TEXT。所以你在数据库里看到的日期是类似2025-01-01 12:00:00的字符串。这也意味着用ORDER BY BorrowTime排序时字符串比较规则和日期比较规则在ISO格式下是一致的可以放心用。但如果你存的时候是MM/dd/yyyy这类格式排序就会乱套。因此我的规矩是所有写入数据库的日期一律用DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss)保持统一格式。从DataGridView里显示日期的字段时如果直接绑定它显示出来的可能是原始字符串带不带T分隔符取决于存储格式。要是觉得不好看可以在格式化事件里处理或者SQL查询时用strftime函数转换格式。一般情况下只要写入格式统一直接显示就够用了。最后的几点个人体会这个项目我前前后后带各种朋友师兄师弟写过不下十遍每次写都会发现一些可以改进的小地方。这里挑三个最有价值的经验说给你听。第一个是面向过程到面向对象的跨度不要一步到位。如果你现在还是写一个窗体放所有逻辑的阶段不要急着非要把Service层分出来因为你会为了设计而设计把事情搞复杂。先把功能跑通代码能看再逐步提取公共方法、抽公共类。况且这个项目我帮你已经把那条路铺好了你照着这个结构走就能感受分层的好处。第二个经验是数据库什么时候都别裸拼SQL。哪怕你只是写着玩也请养成参数化的肌肉记忆。这个习惯一旦养成将来写Web项目时能帮你挡掉大量的安全漏洞。第三个经验更偏心态别怕重构。我第一次写这个项目时新增功能花了两天改bug花了一周整个结构改了三轮。但正因为改过我才真正理解为什么要在数据访问层接一个Service为什么界面代码要尽量瘦身。你写完第一版之后强烈建议你自己动手拆一遍哪怕拆出来看着没变化这个过程也会让很多设计的理由变得明白起来。如果你照着这篇思路动手做出一版了下一步可以试着给系统加上导出Excel报表、逾期自动提醒、条形码扫描借书这类扩展功能。骨架是正确的加功能只是往里面填肉的事。希望这篇实操记录能帮你把这个项目真正啃下来。
返回列表