ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ADO2.20 Class 封装实战:连接字符串、参数化查询与事务避坑指南

ADO2.20 Class 封装实战:连接字符串、参数化查询与事务避坑指南

简介:ADO2.20 Class 是一套面向 Visual C++ 开发者的 ActiveX Data Objects 类库,由非中文开发者在 CodeProject 上发布,旨在简化 VC 程序对 SQL Server、Oracle、Access 等数据源的访问,让开发者无需深入底层 ADO 接口即可完成数据库操作。资源包共 3 个文件,包含 1 个 cpp 实现文件、1 个 h 头文件以及 1 个 mht 说明文档,压缩包约 105KB,体积轻巧便于集成到现有工程。其中头文件提供类定义与函数声明,cpp 文件封装了 Connection、Command、Recordset 等核心对象的常用操作,mht 文档则保留了原版使用说明与示例,方便对照查阅。目前已有 126 人学习下载,适合不熟悉 ADO 或需要快速为 VC 项目添加数据库访问能力的开发者参考,可借此理解连接字符串配置、SQL 执行与结果集遍历的封装思路,并直接复用源码缩短开发周期。

1. ADO2.20 Class:从连接字符串到记录集,一条被忽视的稳定链路

很多人第一次接触 ADO2.20 Class,是在维护一套老系统的数据访问层。代码里到处是ADODB.Connection、ADODB.Recordset,没有 ORM,没有连接池封装,甚至连参数化查询都是手写的。你可能会觉得这套东西早该淘汰了,但现实是:大量工业控制、医疗设备、财务对账类软件至今仍跑在 ADO 2.20 之上,原因很简单——它足够薄,薄到你能完全掌控每一次数据库交互的字节流向。

ADO2.20 Class 并不是某个具体类库的名字,而是围绕 ADO 2.20 版本组件封装出来的一套数据访问类。它解决的核心问题是:把 Connection、Command、Recordset 三个对象的生命周期管理、错误处理和类型转换收敛到一个可复用的类里,避免在每个业务函数里重复写Open、Close、Set conn = Nothing。适合谁?适合那些需要维护或重构 VB6、VBScript、早期 ASP、Delphi 遗留系统,又不想引入重型框架的工程师。接下来的内容,我会按“先跑通最小链路,再拆参数,再讲避坑,最后给一个验证技巧”的顺序展开,每一步都能直接抄。

2. 把 ADO2.20 Class 跑起来:最小可用封装与连接字符串的四种写法

2.1 为什么不用原生 ADODB 而要多封一层

原生 ADODB 用起来不难,难在一致性。一个项目里如果有二十个地方打开连接,就有二十种错误处理风格:有的用On Error Resume Next吞掉异常,有的在Err.Number判断后直接Resume,还有的干脆不关连接。ADO2.20 Class 的价值不在于性能提升,而在于把“打开-执行-关闭”这个固定动作变成不可绕过的模板。

我一般会封一个CDataAccess类,暴露三个方法:Execute用于无返回集的增删改,Query返回一个二维数组或 Recordset,BeginTrans/CommitTrans/RollbackTrans用于事务。内部持有ADODB.Connection和可选的ADODB.Command。这样做的好处是,当你要从 SQL Server 换到 Access 或 Oracle 时,只需要改连接字符串和少量方言,业务层代码不动。

2.2 最小可用类的代码骨架

下面是一个 VB6 风格的类模块骨架,去掉了具体业务逻辑,只保留 ADO2.20 的核心调用。如果你用 VBScript 或 VBA,把Private换成Dim,把Class_Initialize换成普通初始化函数即可。

' CDataAccess.cls Private m_conn As ADODB.Connection Private m_cmd As ADODB.Command Private Sub Class_Initialize() Set m_conn = New ADODB.Connection Set m_cmd = New ADODB.Command m_conn.CursorLocation = adUseClient ' 客户端游标,便于断开后继续用 Recordset m_conn.CommandTimeout = 30 ' 秒,防止长查询卡死界面 End Sub Public Sub OpenConnection(ByVal connStr As String) On Error GoTo ErrHandler If m_conn.State = adStateOpen Then m_conn.Close m_conn.Open connStr Exit Sub ErrHandler: Err.Raise Err.Number, "CDataAccess.OpenConnection", Err.Description End Sub Public Function Query(ByVal sql As String) As ADODB.Recordset Dim rs As ADODB.Recordset Set rs = New ADODB.Recordset rs.CursorLocation = adUseClient rs.Open sql, m_conn, adOpenStatic, adLockReadOnly Set Query = rs End Function Public Sub Execute(ByVal sql As String) m_conn.Execute sql, , adExecuteNoRecords End Sub Private Sub Class_Terminate() If Not m_conn Is Nothing Then If m_conn.State = adStateOpen Then m_conn.Close Set m_conn = Nothing End If End Sub

逻辑说明:CursorLocation设为adUseClient是关键一步。ADO 2.20 默认使用服务器端游标,Recordset 一旦断开连接就不可用;改成客户端游标后,Recordset 会一次性把数据拉到本地,断开后仍可遍历。代价是内存占用上升,所以只适合结果集不大的场景。CommandTimeout默认是 30 秒,但很多老系统在慢查询时直接卡死,显式设成 30 或 60 能避免无限等待。

参数说明:adOpenStatic表示静态游标,适合只读遍历;adLockReadOnly表示不锁定记录,减少数据库压力。如果你需要更新,改成adLockOptimistic,但要注意并发冲突。

2.3 连接字符串的四种写法与选择依据

连接字符串是 ADO2.20 Class 最容易翻车的地方。同一个数据库,不同写法在超时、编码、权限上表现完全不同。下面这张表是我在维护老系统时总结的对比,以 SQL Server 为例,Access 和 Oracle 的差异在备注里说明。

写法示例片段适用场景注意点
OLE DB ProviderProvider=SQLOLEDB;Data Source=...传统 ADO 2.20 默认需要装 MDAC,Win10 以上可能缺失
ODBC DriverDriver={SQL Server};Server=...兼容性最好性能略低,但几乎不会因组件缺失失败
DSN 文件FileDSN=xxx.dsn多环境切换部署时要带 dsn 文件,路径不能写死
无 DSN 的 ODBCDriver={SQL Server Native Client 11.0};...新驱动驱动版本要和目标机器一致

Access 数据库用Provider=Microsoft.Jet.OLEDB.4.0或Microsoft.ACE.OLEDB.12.0,后者支持 64 位但需要单独安装。Oracle 用Provider=OraOLEDB.Oracle,注意Data Source写的是 TNS 别名而不是 IP。

提示:连接字符串里的User ID和Password不要硬编码在类里,用配置文件或环境变量传入。老系统常见做法是写在一个.ini里,类初始化时读取。

2.4 用 Command 对象做参数化查询

直接拼接 SQL 是 ADO2.20 Class 最大的安全隐患。正确做法是用ADODB.Command加Parameters。下面是一个按 ID 查询的示例:

Public Function QueryById(ByVal id As Long) As ADODB.Recordset Dim rs As ADODB.Recordset Set rs = New ADODB.Recordset With m_cmd .ActiveConnection = m_conn .CommandText = "SELECT * FROM Orders WHERE OrderID = ?" .CommandType = adCmdText .Parameters.Refresh ' 让 Provider 推断参数类型 .Parameters(0).Value = id End With rs.CursorLocation = adUseClient rs.Open m_cmd, , adOpenStatic, adLockReadOnly Set QueryById = rs End Function

逻辑说明:Parameters.Refresh会向数据库请求参数元数据,对于 SQL Server 和 Oracle 都有效,但对某些 Access 驱动可能返回空。如果 Refresh 失败,就手动CreateParameter并指定类型。?是 OLE DB 的占位符,不是所有驱动都支持命名参数,所以统一用问号最稳。

参数说明:adCmdText表示命令文本是 SQL 语句;如果调用存储过程,改成adCmdStoredProc,参数用@p1或直接按顺序传。注意Parameters(0)的索引从 0 开始,不是 1。

3. ADO2.20 Class 的四个必调参数与事务处理

3.1 CursorLocation 选客户端还是服务器端

这是 ADO2.20 里最容易被忽略、又最影响稳定性的参数。服务器端游标(adUseServer)在连接断开后 Recordset 立即失效,而且对数据库的锁持有时间更长。客户端游标(adUseClient)把数据缓存在本地,断开后仍可读,适合“取数据-关连接-慢慢处理”的模式。

但客户端游标不是银弹。如果结果集超过几万行,内存会飙升,而且RecordCount虽然准确,但代价是全部拉取。我的经验是:报表类查询用客户端游标,逐行处理的大批量更新用服务器端游标加只进游标(adOpenForwardOnly)。

3.2 LockType 与并发冲突的取舍

adLockReadOnly最安全,不锁记录。adLockOptimistic只在更新瞬间锁记录,适合并发不高的场景。adLockBatchOptimistic配合UpdateBatch可以批量提交,减少网络往返,但冲突时要自己处理Status字段。

老系统里常见的一个坑是:用adLockOptimistic打开 Recordset,然后遍历修改,最后UpdateBatch。如果中途有人改了同一行,UpdateBatch会报错,但错误信息很模糊。解决方法是遍历后检查rs.Status,对adRecConcurrencyViolation的记录重新读取再合并。

3.3 CommandTimeout 与 ConnectionTimeout 的区别

ConnectionTimeout控制建立连接的超时,默认 15 秒。CommandTimeout控制命令执行超时,默认 30 秒。很多开发者只设了前者,结果查询慢的时候界面卡死几十秒。正确做法是两个都显式设置,并且CommandTimeout根据业务设成 60 或 120。

注意:CommandTimeout设为 0 表示无限等待,生产环境不要这么干,否则一个慢查询能把线程池占满。

3.4 事务处理的正确姿势

ADO2.20 的事务用Connection.BeginTrans、CommitTrans、RollbackTrans。关键点是:事务必须绑定在同一个 Connection 上,不能跨连接。下面是一个转账的伪代码示例:

Public Sub Transfer(ByVal fromId As Long, ByVal toId As Long, ByVal amount As Currency) On Error GoTo ErrHandler m_conn.BeginTrans m_conn.Execute "UPDATE Accounts SET Balance = Balance - " & amount & " WHERE AccountID = " & fromId m_conn.Execute "UPDATE Accounts SET Balance = Balance + " & amount & " WHERE AccountID = " & toId m_conn.CommitTrans Exit Sub ErrHandler: m_conn.RollbackTrans Err.Raise Err.Number, "CDataAccess.Transfer", Err.Description End Sub

逻辑说明:这里为了演示简化了 SQL 拼接,实际应该用 Command 参数化。BeginTrans之后的所有Execute都在同一事务里,任何一步出错就RollbackTrans。注意RollbackTrans本身也可能出错,所以最好再包一层On Error Resume Next。

参数说明:BeginTrans返回一个隔离级别,默认是adXactReadCommitted。如果需要更高级别,传adXactSerializable,但会降低并发。

4. ADO2.20 Class 避坑记录:五个让老系统半夜报警的细节

4.1 现象:Recordset 关闭后仍占用内存,进程越来越大

原因:客户端游标把数据缓存在本地,Recordset.Close只是释放了游标,但底层Fields集合和Properties集合可能还持有引用。如果循环里反复创建 Recordset 而不显式Set rs = Nothing,内存不会及时回收。

解决:每次用完 Recordset 后,先rs.Close,再Set rs = Nothing。在类模块里,把 Recordset 作为局部变量而不是成员变量,让它在函数退出时自动释放。

4.2 现象:连接字符串在开发机正常,部署到客户机报“未找到提供程序”

原因:开发机装了 MDAC 或 SQL Native Client,客户机只有系统自带的 ODBC 驱动。Provider=SQLOLEDB在 Win10 以上可能不存在。

解决:改用 ODBC 写法Driver={SQL Server};...,或者把驱动安装包一起部署。更稳妥的做法是在类初始化时用On Error尝试多种连接字符串,取第一个成功的。

4.3 现象:参数化查询报“参数类型不匹配”

原因:Parameters.Refresh推断的类型和实际传入的值不一致。比如数据库字段是Decimal,你传了Double,某些驱动会拒绝。

解决:手动CreateParameter并指定adDecimal或adNumeric,设置Precision和Scale。或者统一用adVarWChar传字符串,让数据库隐式转换,但这样会失去索引优势。

4.4 现象:事务提交后数据没生效,或者部分生效

原因:Connection在执行BeginTrans之前已经隐式开启了一个事务,或者Execute语句里包含了COMMIT。另外,如果用了连接池,CommitTrans可能被池化层拦截。

解决:确保BeginTrans之前没有未提交的操作。不要在 SQL 里写BEGIN TRAN。如果用了 MTS 或 COM+,事务要交给容器管理,不要手动BeginTrans。

4.5 现象:多线程环境下 ADO2.20 Class 随机崩溃

原因:ADO 的 Connection 和 Recordset 不是线程安全的。VB6 的单元线程模型里,每个线程要自己创建 Connection,不能共享。

解决:每个线程独立创建CDataAccess实例,或者用对象池加锁。如果必须共享,用ADODB.Connection的Marshal接口,但复杂度很高,不如改成每个线程一个连接。

5. 用 ADO2.20 Class 做一次可复现的验证:从建表到断连遍历

5.1 验证目标与准备

这一章给一个完整的验证流程,用来确认你的 ADO2.20 Class 封装是否真的能在断开连接后继续使用 Recordset。准备一个空的 Access 数据库或 SQL Server 库,建一张TestItems表,字段ID自增,Name文本。

CREATE TABLE TestItems ( ID INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) ); INSERT INTO TestItems (Name) VALUES ('Alpha'), ('Beta'), ('Gamma');

5.2 断开连接后遍历 Recordset 的代码

Public Sub TestDisconnectedRecordset() Dim da As New CDataAccess Dim rs As ADODB.Recordset da.OpenConnection "Provider=SQLOLEDB;Data Source=.;Initial Catalog=TestDB;Integrated Security=SSPI;" Set rs = da.Query("SELECT * FROM TestItems ORDER BY ID") ' 关键:关闭连接,模拟断开 da.CloseConnection Do While Not rs.EOF Debug.Print rs.Fields("ID").Value, rs.Fields("Name").Value rs.MoveNext Loop rs.Close Set rs = Nothing Set da = Nothing End Sub

逻辑说明:Query内部用了adUseClient,所以CloseConnection之后 Recordset 仍然可用。如果这里报“连接已关闭”,说明 CursorLocation 没设对。Debug.Print在 VB6 里输出到立即窗口,VBA 里同样可用。

参数说明:Integrated Security=SSPI表示用 Windows 身份验证,不需要用户名密码。如果要用 SQL 验证,改成User ID=sa;Password=xxx。

5.3 验证事务回滚是否彻底

再写一个测试:插入一条记录,然后故意让第二条插入失败,检查第一条是否回滚。

Public Sub TestRollback() Dim da As New CDataAccess da.OpenConnection "Provider=SQLOLEDB;Data Source=.;Initial Catalog=TestDB;Integrated Security=SSPI;" On Error GoTo ErrHandler da.BeginTrans da.Execute "INSERT INTO TestItems (Name) VALUES ('Delta')" da.Execute "INSERT INTO TestItems (ID, Name) VALUES (1, 'Epsilon')" ' 主键冲突 da.CommitTrans Exit Sub ErrHandler: da.RollbackTrans Debug.Print "Rolled back: " & Err.Description End Sub

运行后查TestItems表,应该只有 Alpha、Beta、Gamma 三条,没有 Delta。如果 Delta 还在,说明RollbackTrans没生效,检查是否在BeginTrans之前有未提交的操作。

5.4 一个容易被忽略的收尾习惯

每次验证完,把CDataAccess实例置空,并且确认数据库里的连接数没有残留。在 SQL Server 里可以查sys.dm_exec_sessions,看有没有状态为sleeping的会话。如果有,说明Class_Terminate里的Close没执行,可能是对象被循环引用卡住了。

我自己的习惯是:在类里加一个Dispose方法,显式关闭连接和清空对象,然后在业务代码的Finally块里调用。不要依赖Class_Terminate,因为 VB6 的引用计数在循环引用时会失效。这个习惯帮我省掉了无数次半夜被告警叫醒的麻烦。希望帮到你。

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

RELATED READING

延伸阅读

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