ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#增删改查源码:OleDb与SqlDataAdapter双链路

C#增删改查源码:OleDb与SqlDataAdapter双链路

简介:一份面向 C# 与 ASP.NET 初学者及需要快速搭建数据库访问层的开发者的增删改查源码文档,围绕 Access 与 SQL Server 场景演示连接数据库、执行 INSERT、DELETE、UPDATE、SELECT 的完整写法。内容以固定模板组织,重点展示 OleDbConnection 创建连接、OleDbCommand 执行语句、OleDbParameter 参数化传值以及 OleDbDataReader 读取结果,并补充 SqlConnection、SqlDataAdapter 与 DataSet 绑定 DataGridView 的示例,便于理解 WinForms 下的数据查询与展示流程。压缩包内含 1 个 doc 文件,约 203KB,适合作为课堂示例、课程设计或小型管理系统的参考底稿。已有 821 人学习,说明其在基础数据库操作练习中具有一定参考价值。读者可从中获得可套用的连接字符串写法、增删改查语句模板、参数化查询防注入思路以及查询结果处理的对照代码,用于快速排查语法与连接配置问题。

1. 从两个输入框到一张表格:这份 C# 增删改查源代码解决的是什么问题

接手一套跑了七八年的老窗体程序,登录窗口两个输入框,主界面一个 DataGridView 加四个按钮,数据躺在同目录的db1.mdb里,改密码要人肉打开 Access——这种活儿真正难受的地方不是写不出来,而是不知道从哪儿下手。这份 C# 增删改查源代码正好卡在这个位置上:它把 Access 的 OleDb 单条执行链路和 SQL Server 的 SqlDataAdapter 批量更新链路各写了一遍,增删改查四个动作都有完整可跑的模板,参数化查询、DataSet 绑定、SourceVersion这些容易含糊的细节也没省。适合两类人:一是要维护老 WinForms 项目、必须先把数据访问这层摸熟的开发者;二是刚学 C# 数据库操作、想找一份能照着敲、敲完能跑通的源代码对照练习的人。下面按文件结构、执行链路、批量更新、踩坑排查、改造成数据访问层的顺序拆开讲。

2. 项目结构与技术选型:OleDb 与 SqlClient 两条链路的骨架

2.1 代码组织:一个 Form1.cs 撑起全部逻辑

这份源码不是那种带分层架构的完整工程,它更接近一份“现场记录”:一个窗体类,所有数据库代码都写在按钮事件和窗体加载事件里,中间穿插几段可以单独拎出来复用的 SQL 模板。真实项目的目录大致长这样,把它还原出来对理解后面的代码很关键。

文件作用是否需要手改
Program.cs程序入口,Application.Run(new Form1())一般不动
Form1.cs全部业务逻辑:连接、增删改查、界面事件主要改动区
Form1.Designer.cs控件声明与布局,由设计器生成不要手改
App.config连接字符串、启动配置建议把连接串挪进来
db1.mdbAccess 数据库文件,与 exe 同目录换成自己的库
bin\Debug\输出目录,Application.StartupPath指向这里注意权限

Form1.cs这种“一锅端”的写法在老项目里极常见,好处是阅读成本低,从上往下顺着看就能明白数据怎么流动;坏处是任何一个按钮要改逻辑,都得回到这个几百行的文件里翻。真正要落地的第一步不是重构,而是先搞清楚里面其实是两套完全独立的数据访问方式:上半部分用OleDbConnection操作.mdb,下半部分用SqlConnection加SqlDataAdapter操作 SQL Server。它们共用一个窗体,但依赖的提供程序、参数类型、执行方法都不一样,混着看很容易晕。

我一般会把这两条链路在纸上先画成两列:左列是 Access,右列是 SQL Server,然后逐个方法往里填。填完你会发现,同一个“增”动作,左边是OleDbCommand.ExecuteNonQuery(),右边是SqlDataAdapter.InsertCommand加da.Update(ds),语义差得很远——前者是立即执行一条 SQL,后者是把内存里 DataTable 的行状态变更同步回数据库。这个差别是后面所有坑的源头。

2.2 两种数据访问提供程序:Access 用 OleDb,SQL Server 用 SqlClient

选型的判断标准其实很朴素:数据库是什么,就用对应的提供程序。.mdb是 Access 的 Jet 格式,只能用System.Data.OleDb;SQL Server 用System.Data.SqlClient,性能和类型支持都更好。有人为了“统一”硬把 SQL Server 也走 OleDb,能跑,但拿不到SqlDbType的精确类型映射,日期和decimal容易出偏差,得不偿失。

对比项OleDb(Access)SqlClient(SQL Server)
命名空间System.Data.OleDbSystem.Data.SqlClient
连接字符串Provider=Microsoft.Jet.OLEDB.4.0;Data Source=...server=.;database=...;uid=...;pwd=...
参数类型OleDbType,常直接传值由驱动推断SqlDbType,建议显式指定
占位符位置参数?,也支持命名参数但按顺序绑定命名参数@name
典型执行方法ExecuteNonQuery/ExecuteReader同左,另有SqlDataAdapter.Fill/Update
位数限制Jet 4.0 只有 32 位版本跟随进程位数

提示:Microsoft.Jet.OLEDB.4.0只提供 32 位版本。项目目标平台设成AnyCPU且运行在 64 位系统上时,会直接抛“未在本地计算机上注册”的异常,这个坑在第 5 章会单独展开。

还有一处需要点明:源码注释里写着“C# 在 ASP.NET 开发中所必备的增删改查源代码”,但代码本身是标准的 WinForms——Form1、textBox1、MessageBox.Show、DataGridView,一个 Web 控件都没有。这不是缺陷,反而说明它的数据访问部分是“可平移”的:把MessageBox.Show换成Response.Write或返回 JSON,把DataGridView.DataSource换成GridView.DataSource或前端表格,OleDbConnection、OleDbCommand、SqlDataAdapter这些核心对象一行都不用改。所以看这份代码时,把界面层当成“数据访问的演示壳子”,重点收在中间那层,迁移到 Web 项目时才有价值。

3. 单条执行链路:参数化 SQL 把增删改查收敛成同一个模板

3.1 连接串与连接打开时机

源码给的是一个固定模板,所有 OleDb 操作都从这三行开始。它出现在每个按钮事件里,属于典型的“复制粘贴式复用”,先看懂再谈优化。

// 数据库文件与 exe 同目录,用 StartupPath 拼绝对路径 string path = Application.StartupPath + "\\db1.mdb"; // Provider 指定驱动,Data Source 指向文件;Persist Security Info=False 表示连接打开后不再回传密码类信息 OleDbConnection con = new OleDbConnection( @"Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + path + ";Persist Security Info=False"); con.Open(); // 真正建立连接,此时才会校验文件是否存在、驱动是否注册

Application.StartupPath返回的是可执行文件所在目录,调试时是bin\Debug,发布后是安装目录。用相对路径"db1.mdb"也能跑,但它的解析基准是“当前工作目录”,从快捷方式启动、或者被别的程序以不同工作目录拉起时就会指向别处,报“找不到文件”却怎么看目录里都有——这就是路径玄学最常见的来源,直接拼StartupPath能规避掉一大半。

连接字符串里三个参数各有分工:Provider决定用哪个驱动,写错就是“未注册”异常;Data Source是文件绝对路径,路径里有空格不需要额外转义,ADO.NET 会自己处理;Persist Security Info=False是安全默认值,设成True会让连接对象在打开后仍然保留敏感信息,没必要。

连接对象的生命周期是这份代码里最值得改的地方。模板里con是方法内局部变量,Open()之后没有任何Close()或Dispose()。表面上看程序关掉进程资源就回收了,实际上连接池里的物理连接会一直挂着,.mdb文件被独占,你在 Access 里打开它改数据会提示“文件已被占用”。合格的做法是using:

// using 保证即使中途抛异常,连接也会被关闭并归还连接池 using (OleDbConnection con = new OleDbConnection(connStr)) { con.Open(); // 在这里执行命令 } // 离开作用域自动 Dispose

using不是语法糖那么简单,它编译成try/finally,Dispose里会调用Close。对于 OleDb 这种基于文件的数据库,连接不关的后果比 SQL Server 更明显——SQL Server 有连接池兜着,Access 是文件锁,锁住就是锁住了。

3.2 增删改三个动作:OleDbCommand + OleDbParameter 的固定四步

三个动作的骨架完全一致,都是“写带占位符的 SQL → 造参数 → 造命令 → 执行”。先看“增”:

// SQL 里用 @a、@b 占位,绝不把 textBox 的值拼进字符串 string sql = "insert into t1(name,pwd) values (@a,@b)"; // 参数值与占位符一一对应,OleDb 按添加顺序绑定 OleDbParameter p1 = new OleDbParameter("@a", textBox1.Text); OleDbParameter p2 = new OleDbParameter("@b", textBox2.Text); OleDbCommand com = new OleDbCommand(sql, con); com.Parameters.Add(p1); com.Parameters.Add(p2); com.ExecuteNonQuery(); // 返回受影响行数,insert 成功通常是 1

这里最关键的一句是“绝不拼接”。如果写成"insert into t1(name,pwd) values ('" + textBox1.Text + "','" + textBox2.Text + "')",用户在输入框里敲一个'); drop table t1; --,SQL 就变成了两条语句。参数化的意义不只是防注入,它还顺带解决了单引号转义问题:名字里带O'Brien这种,拼串版本直接语法错误,参数版本一点事没有。

OleDbParameter的构造函数重载(string parameterName, object value)只给了名字和值,类型交给驱动推断。推断在字符串和数字上够用,但遇到日期、decimal、空值就容易翻车——null会被推断成“没有值”,某些驱动版本会报参数未赋值。稳妥写法是显式给类型:

// 显式指定类型和长度,避免驱动推断出错 OleDbParameter p1 = new OleDbParameter("@a", OleDbType.VarWChar, 50); p1.Value = textBox1.Text; // 空值要显式用 DBNull.Value,不能直接赋 null OleDbParameter p2 = new OleDbParameter("@b", OleDbType.VarWChar, 50); p2.Value = string.IsNullOrEmpty(textBox2.Text) ? (object)DBNull.Value : textBox2.Text;

“删”和“改”只是 SQL 换了个动词,结构照抄:

// 删:按 name 定位,仍然走参数 string sql = "delete from t1 where name = @a"; OleDbParameter p1 = new OleDbParameter("@a", textBox1.Text); OleDbCommand com = new OleDbCommand(sql, con); com.Parameters.Add(p1); int rows = com.ExecuteNonQuery(); // rows 为 0 说明没有匹配行,别当成执行失败 // 改:set 用 @a,where 用 @b,两个参数顺序不能反 string sql2 = "update t1 set pwd = @a where name = @b"; OleDbParameter q1 = new OleDbParameter("@a", textBox1.Text); OleDbParameter q2 = new OleDbParameter("@b", textBox2.Text); OleDbCommand com2 = new OleDbCommand(sql2, con); com2.Parameters.Add(q1); com2.Parameters.Add(q2); com2.ExecuteNonQuery();

ExecuteNonQuery的返回值是受影响行数,这个返回值被大量新手忽略。删除时返回 0 不是异常,是“where 条件一行都没匹配上”,界面该提示“未找到该记录”而不是“删除失败”;更新时返回 0 同理。把返回值接住并分类处理,是从“能跑”到“能维护”的分水岭。

还有一个隐蔽点:OleDb 的占位符虽然写成@a、@b这种命名形式,但底层是按位置绑定的。也就是说select * from t1 where name=@a and pwd=@b里,第一个Parameters.Add的值给@a,第二个给@b,名字只是给人看的。同一个参数如果在 SQL 里出现两次,就得Add两次。原素材里那句whereName=@aandPwd=@b之所以不能直接用,一半原因是空格被吞了,另一半原因就是位置绑定下参数名和语句对不上,运行时直接报“参数 @aandPwd 未提供”。

3.3 查询为什么必须换 ExecuteReader

查询是四个动作里唯一需要把数据取回来的,所以执行方法也得换:

// 注意 SQL 里的空格:where Name=@a and Pwd=@b,粘连会变成不存在的参数名 string sql = "select * from t2 where Name=@a and Pwd=@b"; OleDbParameter p1 = new OleDbParameter("@a", textBox1.Text); OleDbParameter p2 = new OleDbParameter("@b", textBox2.Text); OleDbCommand com = new OleDbCommand(sql, con); com.Parameters.Add(p1); com.Parameters.Add(p2); // ExecuteReader 返回只进只读的游标,适合逐行判断 using (OleDbDataReader dr = com.ExecuteReader()) { if (dr.Read()) // 有行返回 true,指针移到第一行 { MessageBox.Show("登录通过!"); } else { MessageBox.Show("用户名或密码不正确"); } }

dr.Read()返回布尔值这个设计很实用:登录校验场景只需要知道“有没有匹配行”,不需要具体字段,一次Read()就够。如果要多行遍历,写成while (dr.Read()) { ... };如果只取第一行第一列(比如select count(*)),用ExecuteScalar()更省事。

执行方法返回类型适用场景
ExecuteNonQueryint,受影响行数insert / update / delete
ExecuteReaderOleDbDataReader多行多列,需要逐行处理
ExecuteScalarobject,首行首列count、sum、取单个字段

提示:DataReader在使用期间会独占连接,必须Close。用using包住是最省心的写法,尤其是登录失败要弹框、弹框会阻塞线程的场景,连接一直被 reader 占着,其他操作就会排队。

登录查询还有一个不该照抄的细节:select *只为了判存在,把整行数据都取回来是浪费;更重要的是,真实项目里密码不应该明文存储和明文比对,正确做法是存盐值哈希,查询时按用户名取出哈希再在代码里比对。这份源码是教学模板,照抄到生产环境前这块必须改。

4. 断开式绑定链路:SqlDataAdapter 与 DataSet 的批量更新

4.1 Form_Load:Fill 一次就把表格填上

第二条链路走的是“断开式”模型:从数据库把数据一次性拉到内存的DataSet里,界面绑内存,改动攒着,最后统一提交。窗体加载时完成的就是“拉”这一步。

// 类级字段:整个窗体生命周期内共用 private DataSet ds = new DataSet(); private SqlConnection conn = null; private SqlDataAdapter da = null; // 连接串硬编码在常量里,实际项目应放 App.config private const string DRIVER = "server=.;database=demoDB;uid=sa;pwd=***"; private const string sql_select = "select * from region"; private void Form1_Load(object sender, EventArgs e) { conn = new SqlConnection(DRIVER); // 只创建,不 Open da = new SqlDataAdapter(sql_select, conn); // 适配器持有查询语句和连接 da.Fill(ds, "table"); // 打开连接、执行查询、填充、关闭,一条龙 // 用 DefaultView 绑定,后续排序筛选不需要重新查库 this.dataGridView1.DataSource = ds.Tables["table"].DefaultView; }

Fill这个方法名起得很准:它内部会自己Open连接、执行SelectCommand、把结果灌进DataTable、再Close。所以上面conn创建了却没Open,是对的,不要手贱补一句conn.Open()——补了反而会多一次无意义的连接占用。Fill的第二个参数"table"是内存表的名字,后面ds.Tables["table"]靠它取;如果省略,会自动生成"Table"、"Table1"这种名字,多表场景下极容易取错。

绑定用DefaultView而不是ds.Tables["table"]本身,是为了拿到DataView这层包装,后面做排序、筛选、RowFilter都方便,而且DataGridView的排序行为也更自然。

4.2 三个 Command 的装配与 SourceVersion 的用意

断开式的核心在于:DataTable里的每一行都记着自己的RowState(Added/Modified/Deleted/Unchanged),da.Update(ds)会按状态挑对应的 Command 去执行。所以三个 Command 必须提前装好,而且参数的“来源列”要写对。

private bool BtnInsert() { // CreateCommand 复用连接,省去再 new 一个 SqlCommand da.InsertCommand = conn.CreateCommand(); da.InsertCommand.CommandText = "insert into region values(@id,@ption)"; // Add(参数名, 类型, 长度, 源列名):第 4 个参数是 DataTable 里的列名,不是 SQL 里的 da.InsertCommand.Parameters.Add("@id", SqlDbType.Int, 4, "regionid"); da.InsertCommand.Parameters.Add("@ption", SqlDbType.VarChar, 10, "regiondescription"); int count = da.Update(ds); // 把 ds 里所有 Added 行写回库 return count > 0; }

Parameters.Add(name, type, size, sourceColumn)这个四参重载是这套代码里最容易被读错的一行。第四个参数"regionid"指的是ds.Tables["table"]里的列名,Update执行时会把当前行的这一列值取出来填进@id。如果列名写错,运行时抛“列不属于表”,而报错信息不会告诉你是哪一行参数配错了,只能一个个对。

删除和更新要多一个SourceVersion:

private bool BtnDelete() { SqlParameter sp = new SqlParameter(); da.DeleteCommand = conn.CreateCommand(); da.DeleteCommand.CommandText = "delete from region where regionid=@id"; // 第四个参数仍指向源列,但这个值取的是“原始版本” sp = da.DeleteCommand.Parameters.Add("@id", SqlDbType.Int, 4, "regionid"); sp.SourceVersion = DataRowVersion.Original; // 关键:用删除前的旧值做 where 条件 // 先把内存行标记为 Deleted,Update 才会认它 ds.Tables["table"].Rows[this.dataGridView1.CurrentRow.Index].Delete(); int count = da.Update(ds); return count > 0; }

DataRowVersion有Original、Current、Proposed、Default四个取值。删除场景必须用Original,因为行被Delete()之后Current版本已经不存在了,SQL 里的 where 需要的是“这行原来是什么”。更新场景更典型:

private bool BtnUpdate() { SqlParameter sp = new SqlParameter(); da.UpdateCommand = conn.CreateCommand(); // set 用新值(Current),where 用旧值(Original) da.UpdateCommand.CommandText = "update region set regionid=@id, regiondescription=@ption where regionid=@oldid"; da.UpdateCommand.Parameters.Add("@id", SqlDbType.Int, 4, "regionid"); da.UpdateCommand.Parameters.Add("@ption", SqlDbType.VarChar, 10, "regiondescription"); // 同一个源列 regionid 被用了两次,第二次必须声明取 Original sp = da.UpdateCommand.Parameters.Add("@oldid", SqlDbType.Int, 4, "regionid"); sp.SourceVersion = DataRowVersion.Original; int count = da.Update(ds); return count > 0; }

主键被修改时,where 里如果也用Current版本,条件就变成“把主键改成新值的那一行”,而库里还是旧主键,永远匹配不到,Update返回 0,界面看着“没报错但也没改成功”。@oldid那一行就是这个问题的解药,也是乐观并发控制最朴素的形态:用原始值定位,用新值写入。

4.3 da.Update 的返回值、行状态与事务边界

da.Update(ds)的返回值是三个 Command 影响行数的总和。返回 0 有两种完全不同的含义:一是没有任何待提交的行(行状态全是Unchanged),二是提交了但一行都没匹配上。这两种情况在界面上必须区分开,否则就会出现“点删除提示删除失败,其实数据早没了”的怪现象。

行状态是这套机制的驱动核心,值得单独记一张表:

RowState触发时机Update 时调用的 Command
AddedRows.Add()或绑定后新增行InsertCommand
Modified修改了某列的值UpdateCommand
Deleted调用了Rows[i].Delete()DeleteCommand
UnchangedFill之后、或AcceptChanges之后忽略

Update成功执行后,默认会自动对已提交的行调用AcceptChanges(),把Modified改回Unchanged、把Deleted行从内存表里真正移除。这个默认行为带来一个坑:如果你在循环里逐行Update或者中途抛异常,前面已经提交的行状态被重置了,后面重试时无法判断哪些已成功——这正是需要显式事务的地方。

// 多条写操作放在一个事务里,要么全成功要么全回滚 using (SqlTransaction tran = conn.BeginTransaction()) { da.InsertCommand.Transaction = tran; da.DeleteCommand.Transaction = tran; da.UpdateCommand.Transaction = tran; try { int count = da.Update(ds); tran.Commit(); return count > 0; } catch (DBConcurrencyException ex) { // 并发冲突:库里那行被别人改过或删过 tran.Rollback(); MessageBox.Show("数据已被他人修改,请刷新后重试:" + ex.Message); return false; } }

注意三个 Command 都要挂上Transaction,漏一个就会抛“当分配给命令的连接处于挂起状态时,必须有一个有效的事务”——这个报错信息非常不直观,第一次遇到基本要靠搜。

5. 避坑与排查:这类代码最容易翻车的五处

5.1 报“未在本地计算机上注册 Microsoft.Jet.OLEDB.4.0”

现象:代码完全照抄,con.Open()一行就抛异常,提示提供程序未注册,但系统里明明装了 Office。

原因是 Jet 4.0 驱动只有 32 位版本,而 VS 默认新建的 WinForms 项目目标平台是AnyCPU,在 64 位系统上跑成了 64 位进程,加载 32 位驱动自然失败。解决方式二选一:把项目属性里的“目标平台”改成x86并重新生成;或者改用Microsoft.ACE.OLEDB.12.0提供程序,它同时有 32/64 位版本,但要求目标机器装了对应位数的 Access 数据库引擎,部署时多一个依赖,得权衡。改完记得清一次bin目录,有时候配置改了但旧的 exe 还在跑。

5.2 界面改了数据,点保存提示成功,重开一看没变

现象:da.Update返回大于 0,MessageBox也弹了“修改成功”,可重新加载数据还是老样子。

原因多半是DataGridView的单元格还处在编辑状态——光标没离开输入框,值还在编辑控件里,没有提交到DataTable。Update提交的是DataTable里的数据,编辑中的值不在里面。解决是在提交前强制结束编辑:

// 把当前单元格的编辑值提交到绑定的 DataTable this.dataGridView1.EndEdit(); this.BindingContext[this.dataGridView1.DataSource].EndCurrentEdit();

另外要确认UpdateCommand的CommandText里 where 用的是@oldid且SourceVersion = Original,用Current版本做主键条件同样会导致“返回 0 但没报错”。

5.3 删除报 DBConcurrencyException:并发冲突

现象:单机测试一切正常,两个人同时操作就抛DBConcurrencyException,提示“并发冲突:DeleteCommand 影响 0 行”。

这是乐观并发的正常表现,不是 bug。Update按Original值去 where,如果那行已经被别人删掉了,自然匹配不到。处理方式是捕获这个异常,提示用户刷新,而不是让整个程序崩掉。上面 4.3 的事务代码已经演示了catch (DBConcurrencyException)的写法。要彻底避免,可以在表里加一个rowversion或时间戳列,where 条件带上它,冲突检测更精确。别图省事把异常 catch 掉什么都不做,数据会静默丢失。

5.4 行索引对不上:删错了行

现象:DataGridView里点第三行删除,实际删掉的是别的行。

源码里ds.Tables["table"].Rows[this.dataGridView1.CurrentRow.Index].Delete()这行看着很自然,其实埋了雷。DataGridView.CurrentRow.Index是显示行的索引,而Rows[...]要的是DataTable 行的索引。一旦用户点了列头排序、或者给DefaultView设了RowFilter,两个索引就错位了,删掉的是内存表里另一条记录。

正确做法是从绑定行反查数据对象:

// 从 DataGridViewRow 的 DataBoundItem 拿到真实的 DataRowView var rowView = this.dataGridView1.CurrentRow?.DataBoundItem as DataRowView; if (rowView == null) { MessageBox.Show("请先选中一行"); return false; } rowView.Row.Delete(); // 直接操作真实数据行,与显示顺序无关

另外CurrentRow在表格为空时是null,源码没做判空,空表点删除直接空引用异常。

5.5 mdb 放在 Program Files 下写不进去

现象:开发机上增删改查全正常,装到别人机器上只有查询能用,写操作一律失败,报“操作必须使用一个可更新的查询”。

原因是程序装到了Program Files,普通用户对目录没有写权限,Access 是基于文件的数据库,写入时驱动会尝试在目录下建.ldb锁文件,权限不够就失败。解决是把.mdb放到%APPDATA%或用户可写的目录,连接字符串在启动时动态拼;或者干脆迁移到 SQL Server / SQLite,别在权限受限的目录里放文件型数据库。这个问题在部署阶段才暴露,血泪经验是:凡是涉及文件路径的功能,开发时就别用管理员权限跑,用普通用户身份测一遍。

6. 二次开发改造:把散落在事件里的 SQL 抽成数据访问层

这份源码能跑,但直接往上加业务会很快失控——每加一个表就要复制一遍连接串和参数拼装,改一次数据库类型要动几十处。我一般分三步改,代价不大但收益明显。

第一步,连接串从代码里挪出去,App.config加一行connectionStrings,用ConfigurationManager.ConnectionStrings["main"].ConnectionString读。第二步,把四个动作抽象成一个接口,OleDb 和 SqlClient 各写一个实现,上层只依赖接口。第三步,把Form1里的事件方法压成“取值 → 调接口 → 提示”,SQL 全部沉到实现类里。

// 统一的数据访问契约:参数用字典传,避免上层感知具体驱动 public interface IRepository { int Execute(string sql, IDictionary<string, object> args); // 增删改 DataTable Query(string sql, IDictionary<string, object> args); // 查 } // OleDb 实现:连接、参数、命令的装配全在这里,上层不再碰 public class OleDbRepository : IRepository { private readonly string _connStr; public OleDbRepository(string connStr) => _connStr = connStr; public int Execute(string sql, IDictionary<string, object> args) { using (var con = new OleDbConnection(_connStr)) using (var com = new OleDbCommand(sql, con)) { foreach (var kv in args) com.Parameters.AddWithValue(kv.Key, kv.Value ?? DBNull.Value); con.Open(); return com.ExecuteNonQuery(); } } public DataTable Query(string sql, IDictionary<string, object> args) { using (var con = new OleDbConnection(_connStr)) using (var com = new OleDbCommand(sql, con)) using (var da = new OleDbDataAdapter(com)) { foreach (var kv in args) com.Parameters.AddWithValue(kv.Key, kv.Value ?? DBNull.Value); var dt = new DataTable(); da.Fill(dt); return dt; } } }

上层调用就变成两行:repo.Execute("update t1 set pwd=@a where name=@b", new Dictionary<string, object> { ["@a"] = txtPwd.Text, ["@b"] = txtName.Text })。换数据库时只要再写一个SqlRepository,窗体代码一行不改。

改造前后对比,收益一目了然:

维度改造前改造后
连接管理每个事件各自new,无using统一在实现类内using,自动释放
SQL 位置散落在按钮事件里集中在 Repository 或独立 SQL 常量类
换库成本改几十处换一个实现类,改一行注入
单元测试几乎无法测可对接口做 mock

验证改造是否成功,我的做法是写一个最小回归清单:新增一条 → 列表能查到;改主键 → 用旧主键查不到、新主键能查到;删一条 → 再删同一条返回 0 且不抛异常;两个人同时改同一行 → 后提交的收到并发异常提示。这四条走一遍,基本能覆盖断开式模型的所有状态迁移。至于DataGridView那套绑定,改造后建议保留BindingSource作为中间层,排序筛选和行定位都靠它,比直接操作DataTable稳。

从那以后我每次接手这类老窗体项目,都会先把连接串和 SQL 从事件里拔出来再动业务逻辑,哪怕只改一个小功能也强制走一遍这个流程——因为一旦放任复制粘贴,半年后连自己都不敢改那段代码。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进