简介:一套用于 VC 开发的 ADO 类库封装,源自国外程序员在技术社区发布的早期开源项目,目的是把 SQL Server、Oracle、Access 等数据库的访问操作封装成简洁的 C++ 接口,避免开发者直接面对繁琐的 COM 底层细节。压缩包非常精简,共 3 个文件:两个源代码文件分别存放类声明和功能实现,另一份 MHT 网页存档格式的文档保留了原始的使用说明与示例,整体仅有 105KB,可轻松放入 VC 工程中按需修改。目前已有 125 人学习或下载。通过阅读源码和文档,可以学习如何封装数据库连接、命令执行、结果集遍历等常见能力,并迁移到自己开发的工具或业务系统中,例如用更少的代码建立连接、运行 SQL 语句、读取字段值。对于具备一定 C++ 基础、希望减少样板代码并快速为桌面应用添加数据读写功能的开发者,这是一个有参考价值的实现样例。
1. 被当成“老古董”的数据库访问方案:ADO 2.20 的 Class 到底还能干什么
在很多遗留系统的服务器上,数据库访问代码仍然写着ADODB.Connection、ADODB.Recordset这样的 ProgID,它们对应的就是 ADO 2.20 的 Class 对象模型。这套类库定义了连接、命令、结果集、字段、错误等一组 COM 类,专门解决 Windows 平台上“怎么连数据库、怎么发 SQL、怎么取数据”这三个问题。我接手过好几个无人敢动的报表服务和数据迁移工具,底层全靠这套类模型在撑着,不是不想换新框架,而是业务逻辑和排期都绑死在上面。适合谁看?你自己在维护这类老服务,或者需要在 Windows 脚本、Python、C# 里快速操作数据库且不想引入重量级 ORM 的人。读完这篇,你能照着把连接、查询、更新、调存储过程这条路走通,也知道哪些参数一动就翻车。
2. 从 Connection 到 Recordset:ADO 2.20 的五个核心类与它们的协作方式
2.1 类的顶层设计:为什么先建 Connection 再建 Recordset
ADO 2.20 的对象模型说起来不复杂,真正天天打交道的类就五个:Connection、Command、Recordset、Field、Parameter,外加一个 Error。新手最容易犯的错是跳过 Connection 直接去 Open Recordset,确实能跑通,因为 Recordset 会自动创建一条隐式连接,但这条连接的生命周期完全不受你控制,关闭时机、事务边界、连接复用全部变成黑匣子。我一般会显式创建 Connection,再把它作为参数传给 Recordset 或 Command。
理解这套模型有个类比:Connection 是会话通道,Command 是预编译好的指令,Recordset 是执行结果的内存视图,Field 是视图里的列,Parameter 是给指令填的参数位。这个分层跟 python 中 class 函数的用法思路一致,每个类各自管好自己的状态,通过方法参数协作,而不是把所有逻辑塞在一个大函数里。把连接建好、把命令对象准备好、最后拿结果集,这条链路是 ADO 所有编码方式的主干。
2.2 用 Python + pywin32 跑通最小连接代码
很多人以为 ADO 只能从 VBScript 或 C# 里用,实际上 Python 通过 pywin32 的 COM 接口调用起来非常顺,排查问题也比脚本语言舒服。下面是连接数据库并读取一张表的最小闭环,我日常排查连接问题时就从这段开始改。
import win32com.client # 创建连接对象,这一步相当于拿到 ADODB.Connection 类的实例 conn = win32com.client.Dispatch("ADODB.Connection") conn.ConnectionString = ( r"Provider=SQLOLEDB;Server=127.0.0.1;" r"Database=demodb;UID=sa;PWD=Passw0rd!" ) conn.Open() # 创建结果集对象,打开时直接绑定连接和 SQL rs = win32com.client.Dispatch("ADODB.Recordset") rs.Open("SELECT id, name FROM users", conn, 1, 1) # 第三个参数是游标类型,第四个是锁类型 # 遍历打印结果 while not rs.EOF: print(rs.Fields("id").Value, rs.Fields("name").Value) rs.MoveNext() rs.Close() conn.Close()逐段说明:Dispatch 的作用是启动 COM 组件,传入的字符串就是 ADO 2.20 类库对外暴露的 ProgID,类名必须是ADODB.开头。Open的第三、第四个参数分别对应 CursorType 和 LockType,这里用了 1 和 1,表示键集游标加只读锁,是只读查询最省事的组合。遍历时判断EOF属性是否到达末尾,Fields("列名").Value取当前行字段值,最后务必按先结果集后连接的顺序关闭,顺序反了容易出现“对象未打开”的假异常。
2.3 参数说明:游标类型与锁类型的选择
Open 方法里那两个数字,是 ADO 里最容易让人懵的参数。游标类型决定结果集能往前还是往后走、能否看到别人提交的新数据;锁类型决定更新时数据库以什么方式保护数据。下表是我自己常用的速查口径。
| 游标类型 | 常量值 | 能力与代价 |
|---|---|---|
| adOpenForwardOnly | 0 | 只能向下走,性能最好,适合一次性报表 |
| adOpenKeyset | 1 | 可前后移动,看不到别人新增的行,适合带书签的翻页 |
| adOpenDynamic | 2 | 可前后移动且实时看到变动,代价是连接压力大 |
| adOpenStatic | 3 | 数据快照,移动自由,适合排序和缓存 |
| 锁类型 | 常量值 | 适用场景 |
|---|---|---|
| adLockReadOnly | 1 | 只读查询,最安全 |
| adLockPessimistic | 2 | 编辑时立即锁行,写操作首选 |
| adLockOptimistic | 3 | 只在 Update 时锁行,适合批量修改 |
| adLockBatchOptimistic | 4 | 配合批量更新,适合离线改完一起提交 |
组合上有个原则:要更新就选乐观或悲观锁,不要用只读锁去调用 Update,否则报错没商量;要翻页就避开 ForwardOnly,不然 MovePrevious 直接抛异常。参数写完之后再动代码,能省掉一半排错时间。
3. 动手操作数据:Command、Recordset 与存储过程的三种标准玩法
3.1 用 Command 执行参数化 SQL,避免字符串拼接
SQL 注入和中文引号错乱,几乎都是字符串拼接惹的祸。ADO 里处理参数化查询的正规姿势是用 Command 类,给 SQL 里的占位符准备 Parameter 对象,再把值绑定进去。这样既安全,数据库还会缓存执行计划,重复执行时性能更好。
cmd = win32com.client.Dispatch("ADODB.Command") cmd.ActiveConnection = conn # 复用前面创建的连接 cmd.CommandText = "SELECT * FROM users WHERE dept_id = ? AND age > ?" # 创建两个参数对象并追加到 Command 的参数集合里 p1 = cmd.CreateParameter("dept_id", 3, 1, 8, 10) # 类型 3 是整型,方向 1 是输入 p2 = cmd.CreateParameter("age", 3, 1, 8, 25) cmd.Parameters.Append(p1) cmd.Parameters.Append(p2) rs = cmd.Execute() while not rs.EOF: print(rs.Fields("name").Value) rs.MoveNext() rs.Close()代码逻辑不复杂:先设CommandText为带问号占位符的 SQL,然后按占位符顺序创建参数。CreateParameter的五个参数分别是名称、数据类型、方向、长度、值。类型 3 是 adInteger,方向 1 是 adParamInput,长度 8 对应 4 字节整型宽度。如果你用了 Oracle 或别的数据库,类型编号会不一样,最稳妥的办法是先查驱动文档再填。参数化之后,SQL 文本里不再直接出现具体值,注入和转义问题从根上断掉。
3.2 Recordset 遍历与批量更新:三种常见误用
Recordset 的用法很灵活,但常见误用也特别多,我列三个翻车频率最高的。
第一种是在只读游标上做更新。有人图省事,用rs.Open(sql, conn, 1, 1)查出数据后,直接调rs.Fields("age").Value = 30再rs.Update(),结果抛“当前记录集不支持更新”。原因就是锁类型选了只读,解决方法是把第四个参数改成 2 或 3。
第二种是在EOF状态下读字段。结果集为空时,rs.Fields也能取对象,但.Value会抛“列不存在或不可用”。很多人排查半天不知道数据是空的,其实是先判断rs.EOF再取值,顺序倒过来就会踩坑。
第三种是在循环里反复Open同一个结果集对象。每次 Open 都会重新向数据库申请游标和锁,循环一千次就建立一千次会话,服务端压力直接拉满。正确做法是一次性 Open,用MoveNext走完,或者用GetRows把数据全部拉进数组再循环。这三种误用也是我在帮别人 review 老代码时最常见的三个问题。
3.3 调存储过程:返回多个结果集时如何拿到第二个 Recordset
存储过程里常有“先查汇总,再查明细”的逻辑,一次调用返回多个结果集。ADO 处理这种情况要用NextRecordset方法,它会关闭当前结果集,把连接切换到下一个结果集上。
proc = win32com.client.Dispatch("ADODB.Command") proc.ActiveConnection = conn proc.CommandText = "get_user_and_orders" # 这里假设存储过程已存在 proc.CommandType = 4 # adCmdStoredProc,告诉 ADO 这是存储过程 rs = proc.Execute() print("第一个结果集:") while not rs.EOF: print(rs.Fields(0).Value) rs.MoveNext() # 切换到第二个结果集 rs2 = rs.NextRecordset() if rs2 is not None: print("第二个结果集:") while not rs2.EOF: print(rs2.Fields(0).Value) rs2.MoveNext() rs2.Close() rs.Close()CommandType = 4是关键,它让 ADO 不再把 CommandText 当普通 SQL 解析,而是直接按存储过程名调用。NextRecordset返回 None 时表示没有更多结果集。需要注意:即使第一个结果集没读完,调 NextRecordset 也会强制把当前游标推完并跳到下一个,所以如果你只关心第二个结果集,也要先过一遍第一个,否则驱动行为可能不符合预期。多结果集处理在报表类需求里非常常见,这条路径值得记熟。
4. 性能与边界:游标、锁、连接池的取舍,以及该调的几个参数
4.1 客户端游标与服务端游标的性能差异
游标位置是 ADO 性能调优里最被忽略的参数,它由 Connection 的CursorLocation属性控制,取值为 2 表示服务端游标,3 表示客户端游标。服务端游标把游标状态放在数据库进程里,每次 MoveNext 都可能发生一次网络往返,但内存占用小;客户端游标会把整个结果集拉到应用进程内存中,后续翻页和排序都在本地完成,网络压力小但内存开销大。
大数据量查询,比如几十万行导出场景,我一般选客户端游标,因为在服务端保持一个几十万行的游标,数据库服务器的临时空间和锁资源都会非常难看。小数据量且需要实时更新的场景,服务端游标更合适。一个容易踩的坑是:客户端游标的记录集在连接关闭后仍然可以读取字段,服务端游标在连接关闭后立即失效。这直接关系到代码里关闭连接的顺序。
4.2 LockType 与 CursorType 的组合关系表
游标类型和锁类型不是自由组合的,某些组合在特定驱动下直接不支持。下面是我在 SQLOLEDB 驱动下验证过的可用组合,作为默认起点。
| 游标类型 | 只读锁 1 | 悲观锁 2 | 乐观锁 3 | 批量乐观锁 4 |
|---|---|---|---|---|
| ForwardOnly 0 | 可用 | 驱动相关 | 可用 | 不可用 |
| Keyset 1 | 可用 | 可用 | 可用 | 可用 |
| Dynamic 2 | 可用 | 可用 | 可用 | 不可用 |
| Static 3 | 可用 | 可用 | 可用 | 可用 |
这里说的“可用”指大多数驱动能正常执行,不代表逻辑正确。以只读锁为例,它能和所有游标类型配合,但在它下面执行 Update 一定失败,所以选型时先问自己:这块数据是只读还是可写?可写就不要用只读锁。批量乐观锁配合 Keyset 游标,适合网格控件里一次性改一堆行再统一提交,配合 UpdateBatch 使用,但 ForwardOnly 和 Dynamic 游标不支持这种模式,用了就会出现“操作不被支持”的错误。拿不准时,Keyset 游标加乐观锁是我最常用的组合,能读能改,性能也在可接受范围。
4.3 排查“类未注册”与“类型不匹配”:把 -verbose:class 思维带进 COM 排查
熟悉 Java 的同学知道,-verbose:class能打印类加载过程,帮你看清楚某个类到底从哪个 jar 里来、是否被覆盖。排查 ADO 组件时也应该有同样的“类加载”意识:程序报“ActiveX 组件不能创建对象”或“类未注册”,十有八九是注册表里 ADODB 相关组件的 ProgID 指向出了问题。高版本组件覆盖、系统精简、32 位与 64 位注册表重定向,都会让 ADO 2.20 类库“失踪”。
我一般先手动创建对象,看具体卡在哪一层:
# 直接创建连接对象,复现完整报错 $conn = New-Object -ComObject "ADODB.Connection" $conn.ConnectionString = "Provider=SQLOLEDB;Server=127.0.0.1;Database=demodb;UID=sa;PWD=Passw0rd!" $conn.Open() Write-Output "连接成功"如果创建对象这一步就失败,再去注册表里检查类是否真的存在:
# 查看 ADODB.Connection 的注册项是否存在 Get-Item "HKLM:\SOFTWARE\Classes\ADODB.Connection" -ErrorAction SilentlyContinue | Format-List * # 查看本机可用的 OLEDB Provider 清单 Get-ChildItem "HKLM:\SOFTWARE\Classes" | Where-Object { $_.PSChildName -like "ADODB.*" } | Select-Object -ExpandProperty PSChildName第一段代码用 PowerShell 的New-Object -ComObject在独立进程里复现问题,能区分是代码问题还是环境问题。如果创建成功但 Open 失败,说明组件正常,问题在连接字符串参数上。第二段代码直接查看注册表项,确认 ADODB 类是否真实存在,以及是否被其他版本覆盖。这里还能发现一个老坑:32 位 ADO 组件在 64 位环境下,注册表路径是在HKLM:\SOFTWARE\WOW6432Node\Classes下,查错路径就会误判成组件丢失。
5. ADO 2.20 避坑指南:类型不匹配、游标失效与内存泄放的真实案例
5.1 现象:结果集打开后马上关闭连接,读取字段报“对象关闭或不可用”
一段看起来很合理的代码,conn.Open()、rs.Open()、conn.Close(),然后再去遍历 rs 取名,结果抛“对象关闭或不可用”。原因是 Recordset 默认依赖连接提供数据服务,连接一关,游标就断了。解决方法是分情况取舍:数据行数不大时,在关连接前调rs.GetRows()把数据复制到数组里再关;数据量大时,用客户端游标(CursorLocation=3),它会把数据缓存到本地内存,关连接后仍可读取。这个错误的迷惑性在于,不是每次都会稳定复现,取决于驱动和游标类型,容易被当成玄学,实际上是连接生命周期管理没做好。
5.2 现象:读出来的中文字段变成问号或乱码
从数据库读出的中文变成“???”,或者显示成明显错位的字符。原因通常是 OLEDB 驱动版本与数据库排序规则不匹配,或者 Python 进程的默认编码与 ADO 返回的 BSTR 字符串转换出问题。这条路我走过很多次,最后发现多数情况不是 ADO 的锅,而是输出环节的编码不对。解决方法是先把取到的值打印一下类型和 Unicode 编码,确认数据在 ADO 层面是否正常;如果数据正常,再检查终端或日志的编码设置,例如 Python 里sys.stdout.reconfigure(encoding='utf-8')。如果把值写进文件是乱码,那就是写入文件时用的编码问题,跟 ADO 无关。判断数据源乱码还是输出乱码,最笨但最有效的方法是print(repr(rs.Fields(0).Value)),看它内部表示是否包含\u开头的正常转义。
5.3 现象:程序跑一段时间后连接超时,数据库连接数被打满
这是血泪经验。某个定时任务最初运行正常,跑了半个多月后开始频繁报连接超时,数据库端连接数满。定位发现代码里每次查询都 new 一个 Connection 和 Recordset,循环结束后既不 Close 也不置空,COM 对象在 Python 的垃圾回收机制下不会立刻释放,连接就被占着不放。解决方法是把连接和结果集的关闭逻辑放到try/finally里,确保无论如何都会 Close;并且循环内尽量复用同一个连接,不要每次查询都新建。还有一点,Python 用 win32com 创建 COM 对象时,部分对象会在进程退出时才真正释放,所以在长时间运行的服务里,显式调Close()比依赖 GC 可靠得多。我把这个习惯固化成了模板,所有写库逻辑必须带 finally 关闭,宁可多写两行也不赌回收时机。
5.4 现象:重写包装类方法后调用父类报“参数数目错误”,类似 overrider 注解丢失的问题
有人喜欢把 ADO 封装成自己的数据层类,比如继承一个 Recordset 包装类去扩展功能。重写open方法时只写了sql参数,复用旧代码调用时却报“参数数目错误”。这很像 class 文件 overrider 注解为什么会丢失那个场景:源文件里明明写好了重写注解,编译后却找不到了。COM 对象的方法签名是通过 IDispatch 接口暴露的,调用方按签名顺序传参,一旦重写后的方法参数列表比原方法少,派发就会失败。解决方法是重写时保留原方法的完整参数签名,哪怕某些参数在子类里用不到也要接着;更稳妥的做法是不重写父类方法,而是新增一个命名不同的方法,避免覆盖内部派发表。这个坑提醒我:操作 COM 组件时,不要用普通面向对象的直觉去猜方法重写行为,接口签名就是合同,少一个参数等于违约。
6. 进阶技巧:用 ADODB.Stream 处理二进制字段,以及把 ADO 封装成自己的数据访问类
6.1 用 ADODB.Stream 存取图片文件
数据库里存图片或附件时,字段类型通常是 varbinary 或 image,直接常规读写会遇到类型转换问题。ADO 处理二进制数据的标准工具是 ADODB.Stream 类,它的 Type 属性置 1 表示二进制模式,置 2 表示文本模式。下面是一段把图片字段读出来存成文件的示例。
stream = win32com.client.Dispatch("ADODB.Stream") stream.Type = 1 # adTypeBinary stream.Open() # rs 是已打开并定位到目标行的 Recordset data = rs.Fields("photo").Value if data: stream.Write(data) stream.SaveToFile("C:\\demo\\out_image.bin", 1) # 1 表示覆盖已存在文件 stream.Close()逻辑说明:Stream 对象先设成二进制模式,Open 之后没有任何数据;接着把 Recordset 里 photo 字段的值直接 Write 进去;最后 SaveToFile 落盘。第二个参数 1 是 adSaveCreateOverWrite,表示文件存在就直接覆盖,如果要保留历史版本可以改成 0。反过来写库也是一样的思路,先用 Stream 从文件 LoadFromFile,再读出来赋给字段。二进制的坑主要在字段值为空时直接调 Write 会报错,所以加了一层if data判断。
6.2 把常用操作封装成类:像定义 python class 一样组织 ADO 调用
代码写多了之后,我习惯把连接管理和查询逻辑收拢成一个类,避免每个脚本里重复写 Dispatch、Open、遍历、Close 这一套。封装思路跟定义普通 Python 类完全一致,只是内部通过 COM 调 ADO。
class AdoHelper: def __init__(self, conn_str): self.conn = win32com.client.Dispatch("ADODB.Connection") self.conn.ConnectionString = conn_str self.conn.Open() def query_all(self, sql): rs = win32com.client.Dispatch("ADODB.Recordset") rs.Open(sql, self.conn, 1, 1) rows = [] try: while not rs.EOF: row = [rs.Fields[i].Value for i in range(rs.Fields.Count)] rows.append(row) rs.MoveNext() finally: rs.Close() return rows def close(self): if self.conn: self.conn.Close()这个类只做两件事:构造时建连接,query_all 里查数据并返回二维列表。关键点在 finally 里关闭 Recordset,保证异常时也能释放。用的时候先db = AdoHelper(conn_str),再rows = db.query_all("SELECT * FROM users"),最后db.close()。参数化查询、多结果集还能继续往这个类里加方法,但注意前面说的重写签名问题,扩展用新增方法不要覆盖。我自己的习惯是每个工具脚本里都内置一个简化版 Helper,连接字符串放配置,绝不明文写死在代码里;数据量大时再单独加分页方法,不要在一个方法里既查数据又写日志。这样维护老系统时,新功能可以快速落地,老逻辑也不会被反复重写。希望帮到你。
本文还有配套的精品资源,点击获取