☰
C++连接Oracle的ADO封装类CAdoDatabase:核心接口与实战避坑
2026/10/20 14:41:24 网站建设 项目流程

简介:这是一份面向C++开发者的ADO数据库编程练习工程,主题是基于ActiveX Data Objects与Oracle数据库交互。压缩包内容以完整的演示项目为主,核心源码包括CAdoDatabase类封装与具体实现,可直接对照学习Connection对象建立连接、Command对象执行SQL、Recordset对象遍历结果集,以及Parameter参数化查询等核心操作,并涉及事务回滚、错误处理与连接池配置等进阶内容。整个包共40个文件,以12个.h头文件和8个.cpp源文件作为主体,配合ADO类型库定义文件、工程配置文件(.dsp/.dsw/.clw)与说明文档,整体仅209KB,结构清晰,适合在Visual Studio环境中快速导入查看。目前已有130人学习,很适合刚开始接触C++数据库操作、希望快速理解ADO访问Oracle方式的读者。通过研读示例代码并尝试运行,可以掌握数据库连接字符串设置、SQL执行、结果集读取等实用技能,也能获得参数化查询与事务控制的典型写法,为后续开发数据访问模块提供参考。

1. CAdoDatabase 是什么:一个把 C++ 连 Oracle 的 ADO 封装类

C++ 项目连 Oracle,很多人第一反应是 OCI 或 OCCI,但这套 CAdoDatabase 源码包给的是 ADO 方案:一个类同时封装了 Connection、Recordset、Command 三个 COM 对象,调用方只需要初始化一次、写一条连接串、执行一句 SQL,就能拿到结果集跑遍历。比起 OCI 那一堆句柄和数据绑定宏,ADO 的上手成本低得多,尤其适合 MFC/ATL 桌面程序和老系统改造。它能解决的问题很具体:不想被 OCI 数据结构折磨、又需要快速稳定访问 Oracle 的 C++ 开发者,可以直接把这类封装拿进工程改改就用。新手照着 Demo 能跑通,熟手能改连接和参数绑定,不亏。

2. 为什么选 ADO 而不是 OCI:选型逻辑与 CAdoDatabase 的接口设计

2.1 ADO 在 C++ 世界里仍然能打

接触过 OCI 的人都知道,入门曲线陡。OCI 要求你手动管理环境句柄、服务句柄、语句句柄,绑定变量时要计算缓冲区、类型码,一行查询下来十几行代码起步。OCCI 好一点,但编译时得带 Oracle 头文件和库,版本稍微对不上就报错。ADO 属于另一条路线:它建立在 COM 之上,编译期不依赖 Oracle 的任何头文件,只需要#import "msado15.dll"就能拿到智能指针类型。程序运行时才去加载 OLEDB 驱动,链接库也不需要 Oracle 客户端 SDK。

对一些维护中的老项目来说,这个优势很实际。我见过不少遗留的 MFC 程序,数据库从 SQL Server 换到 Oracle,业务代码不想大改,这时候 ADO 这类封装是成本最低的迁移路径。SQL 语句基本不变,改连接串、改个别类型转换就能跑。CAdoDatabase 本质上就是把这层重复劳动收敛成一组方法,让你在业务层只看到ExecuteSQL和GetFieldValue。

2.2 CAdoDatabase 的核心接口拆解

这类封装的类名各有差异,但接口地图基本一致。我习惯先把类骨架画出来:

class CADODatabase { public: CADODatabase(); virtual ~CADODatabase(); void OnInit(); // 初始化 COM 环境 void UnInit(); // 反初始化 BOOL Open(LPCTSTR lpszConnStr); // 建立连接 void Close(); // 关闭连接 BOOL Execute(LPCTSTR lpszSQL); // 执行无返回集的 SQL _RecordsetPtr ExecuteSQL(LPCTSTR lpszSQL); // 执行查询并返回记录集 CString GetFieldValue(_RecordsetPtr pRs, int nIndex); CString GetFieldValue(_RecordsetPtr pRs, LPCTSTR lpszName); _ConnectionPtr GetConnection(); // 供 Command 等对象复用连接 private: _ConnectionPtr m_pConnection; };

这里的核心是ExecuteSQL返回_RecordsetPtr而不是CADORecordset的包装类,设计上是有意的:记录集的遍历、移动、取字段仍然用 ADO 原生方式,封装只负责“减少连接和命令的样板代码”。这样做的边界很清楚,如果你需要复杂的记录集操作,直接操作_RecordsetPtr就行,不会被类限死。常见实现里还会有一个BOOL Execute,内部把Execute和ExecuteSQL分开,因为ConnectionPtr->Execute返回记录集,没有返回集时这个返回值会被忽略,分开更清晰。

2.3 Oracle 连接串与 Provider 的选择

CAdoDatabase 的Open参数是一条连接串,这是整个封装里最值得先研究透的地方。常见写法有两种:

Provider连接串特征适用场景
MSDAORAProvider=MSDAORA.Oracle;Data Source=ORCL;User Id=user01;Password=pwd;老环境、Oracle 10g 前后
OraOLEDBProvider=OraOLEDB.Oracle;Data Source=ORCL;User Id=user01;Password=pwd;Oracle 11g+ 推荐

MSDAORA 这个驱动在 Oracle 11g 之后经常出现兼容问题,表现是连接可以建、但某些类型的字段读取会抛异常,或者干脆报未指定的错误。OraOLEDB 是 Oracle 官方 OLE DB 驱动,支持更完整的数据类型,NUMBER、DATE这些字段的转换更干净。我一般在 Oracle 11g 以上一律用OraOLEDB.Oracle。

连接串里的Data Source在 Oracle 这边可以是 TNS 服务名、也可能是 EZCONNECT 写法//host:1521/service。如果用的是 TNS 服务名,客户端tnsnames.ora配置必须先就位,否则报ORA-12154: TNS:could not resolve the connect identifier,这个错误和 CAdoDatabase 本身无关,优先查 TNS 配置。

3. 从初始化到结果集:CAdoDatabase 的完整调用流程

3.1 初始化 COM 环境与 Open 连接

拿到 CAdoDatabase 源码后,第一步不是写业务,而是把连接跑通。标准流程是初始化 COM、开连接、跑一条 SQL、关连接:

#include "CAdoDatabase.h" CADODatabase db; db.OnInit(); // 内部调用 CoInitialize(NULL) BOOL bOpen = db.Open(_T("Provider=OraOLEDB.Oracle;Data Source=ORCL;User Id=user01;Password=pwd;")); if (!bOpen) { // 这里应该记录连接失败原因 return; } TRACE(_T("Oracle 已连接")); db.UnInit();

这段代码的逻辑顺序别改动:OnInit必须在Open之前,因为 ADO 的CreateInstance依赖当前线程的 COM 环境。Open内部一般会调用m_pConnection->Open(_bstr_t(strConn), "", "", adConnectUnspecified),返回值只是确认调用未抛异常,真正的连接错误会以_com_error形式抛出来,需要外层 catch。

参数方面,Data Source=ORCL是 TNS 服务名,User Id和Password不用多解释。这里有一个容易困惑的点:adConnectUnspecified表示让 ADO 自己决定连接属性,如果不上锁、不设超时,这个参数保持默认即可。连接超时可以在m_pConnection->ConnectionTimeout上单独设置,单位是秒,我一般设 10 秒。

3.2 执行查询并遍历结果集

连接正常后,查询是使用频率最高的路径。ExecuteSQL返回_RecordsetPtr,遍历时要注意 ADO 的 EOF 标志被改名的问题:

_RecordsetPtr pRs = db.ExecuteSQL(_T("SELECT DEPT_ID, DEPT_NAME FROM DEPT ORDER BY DEPT_ID")); if (pRs == NULL || pRs->adoEOF) { return; // 查询无结果 } while (!pRs->adoEOF) { CString strId = db.GetFieldValue(pRs, 0); CString strName = db.GetFieldValue(pRs, 1); // 业务处理 pRs->MoveNext(); } pRs->Close();

逻辑上,adoEOF是 EOF 的别名。#import "msado15.dll"时如果不重命名,EOF 会和 C++ 标准库冲突,所以类源码里一般都会rename("EOF", "adoEOF"),这个细节决定了你写pRs->EOF还是pRs->adoEOF。如果你拿到的包没有做 rename,编译时会报一堆关于 EOF 的重定义错误,不用慌,改 import 语句即可。

GetFieldValue(pRs, 0)按索引取字段值,常见实现内部使用pRs->Fields->Item[nIndex]->GetValue(),返回_variant_t,然后转成CString。这个转换是下一节的重点,也是坑最密集的地方。

3.3 字段取值与类型转换

GetFieldValue里的转换,很多实现直接调VARIANT转字符串:

CString CADODatabase::GetFieldValue(_RecordsetPtr pRs, int nIndex) { if (pRs->adoEOF || nIndex < 0 || nIndex >= pRs->Fields->Count) return _T(""); _variant_t vtValue = pRs->GetFields()->GetItem((long)nIndex)->GetValue(); // 处理 NULL 值,避免 Convert 抛异常 if (vtValue.vt == VT_NULL || vtValue.vt == VT_EMPTY) return _T(""); if (vtValue.vt == VT_BSTR) return CString(vtValue.bstrVal); // 数字与日期统一走 VARIANT 转换 _variant_t vtConverted; VariantChangeType(&vtConverted, &vtValue, 0, VT_BSTR); return CString(vtConverted.bstrVal); }

这里的核心逻辑是:先判断vt的变体类型,VT_NULL和VT_EMPTY单独处理,否则VariantChangeType碰到空值会抛E_INVALIDARG。VT_BSTR直接取字符串,数值和日期类型转成字符串。

注意,Oracle 的NUMBER字段通过 OraOLEDB 读出来通常是VT_DECIMAL或VT_I4,走 VariantChangeType 转成字符串没问题;但如果你拿到的是VT_ARRAY | VT_UI1(通常来自 BLOB/RAW 字段),这个函数会失败。遇到这种情况得走专门的二进制读取接口。Field是 Oracle 里常见字段名,但 SQL 里最好别拿它当别名,容易在调试时混淆。字段索引从 0 开始,和Fields->Count比较时务必用>=,索引越界会抛异常。

4. 存储过程与参数绑定:把拼 SQL 换成参数化调用

4.1 用 _CommandPtr 执行存储过程

CAdoDatabase 的连接封装已经就位,但很多业务场景需要调用存储过程。直接把 SQL 拼成BEGIN pkg.proc(...); END;用Execute跑也不是不行,但参数多、有返回值时容易乱。标准化做法是用_CommandPtr配合Parameters集合:

_CommandPtr pCmd; pCmd.CreateInstance(__uuidof(Command)); pCmd->ActiveConnection = db.GetConnection(); pCmd->CommandText = _T("{call PKG_DEMO.GET_EMP_BY_ID(?, ?)}"); pCmd->CommandType = adCmdText; pCmd->Parameters->Append( pCmd->CreateParameter(_T("p_emp_id"), adInteger, adParamInput, sizeof(long), (long)1001)); pCmd->Parameters->Append( pCmd->CreateParameter(_T("p_emp_name"), adVarChar, adParamOutput, 100, _variant_t(""))); pCmd->Execute(NULL, NULL, adCmdText); _variant_t vtOut = pCmd->Parameters->Item[_T("p_emp_name")]->GetValue(); CString strName = vtOut.bstrVal;

注意CommandText里用了{call ...}语法,这是 ODBC/JDBC 风格的转义序列,ADO 对 Oracle 存储过程会正确解析。如果不用{call},直接写BEGIN ... END;匿名块,参数顺序可能变得不可控。CommandType设adCmdText即可,不用adCmdStoredProc,因为这里的写法本质还是命令文本。

4.2 绑定输入输出参数

上面代码里,CreateParameter的参数顺序是名称、类型、方向、大小、值。容易踩的细节集中在两个地方:第一是字符串输出参数的长度,必须给足够余量,比如实际字段长度 50,你设 50 也会偶发截断,我一般给 2 倍余量;第二是adVarChar在 Oracle 的参数模式下,只能用于输出或读写参数,输入参数用adVarChar有时会被驱动强制转成定长字符类型,如果要精确控制输入,用adBSTR更稳。

参数绑定后,不再需要字符串转义。以前拼 SQL 时代,员工姓名里带一个单引号就能让整个语句挂掉;参数化之后,驱动会帮你处理引号和隐式转换。这个改变在 C++ 工程里尤其值得,因为 C 字符串拼接 SQL 没有像PreparedStatement那么明显的边界提醒。

4.3 参数方向和 NULL 传参的正确姿势

参数方向最容易混淆的是adParamInput和adParamInputOutput。只在存储过程里用到的输入变量,用adParamInput即可。但如果你传 NULL 给输入参数,直接把_variant_t("")塞进去会变成空字符串,而不是数据库的 NULL。正确做法是创建一个VT_NULL:

_variant_t vtNull; vtNull.vt = VT_NULL; pCmd->Parameters->Append( pCmd->CreateParameter(_T("p_note"), adVarChar, adParamInput, 200, vtNull));

这个坑在业务层很致命:数据库端IS NULL判断会失效,数据默认值也不会触发。只有显式传VT_NULL,驱动才真正给 Oracle 发送 NULL。日期类型参数同理,不要传字符串让 Oracle 隐式转换,创建参数时类型用adDBTimeStamp,值用COleDateTime转_variant_t,避免NLS_DATE_FORMAT影响。

5. 避坑:初始化、连接串与游标类型的高频翻车

5.1 CoInitialize 未调用,_com_error 追到怀疑人生

现象:程序启动后第一次调db.Open()直接抛异常,日志里只有_com_error,代码定位到CreateInstance。

原因:当前线程没有调用CoInitialize或CoInitializeEx。ADO 的 COM 对象创建需要线程先进入兼容的初始化状态。很多 MFC 程序在主线程其实初始化过,但新开的工作线程里没有。

解决:在CADODatabase::OnInit()里判断当前线程 COM 状态。我一般这样处理:

void CADODatabase::OnInit() { HRESULT hr = CoInitialize(NULL); m_bNeedUninit = (hr == S_OK || hr == S_FALSE); }

注意S_FALSE表示该线程已初始化过,此时仍可正常创建 COM 对象,但UnInit时不能重复调用CoUninitialize,否则会破坏原有平衡。m_bNeedUninit用hr == S_OK判断就错了,会让正常复用线程的程序在退出时莫名崩溃。

5.2 Provider 选错,Oracle 11g 之后连不上

现象:连接串用Provider=MSDAORA.Oracle,在 Oracle 11g 客户端上偶发报ORA-12154或未指定的错误;某些环境能连上,但读取TIMESTAMP字段时数据变成乱码或抛类型错误。

原因:MSDAORA 是较老的 OLE DB provider,对 Oracle 新版本的数据类型支持不完整。Oracle 11g 后官方推荐的是OraOLEDB.Oracle。

解决:连接串统一替换 Provider。替换后注意Data Source写法也可以升级为 EZCONNECT 风格:

db.Open(_T("Provider=OraOLEDB.Oracle;Data Source=//192.168.1.10:1521/orcl;User Id=user01;Password=pwd;"));

如果不依赖 TNSNAMES 配置,这种方式更直接,少了ORA-12154的排查路径。唯一要留意的是//前缀不能省,这是 EZCONNECT 的语法标志,写错会变成无效连接标识符。

5.3 NUMBER 字段在 _variant_t 里的类型陷阱

现象:查询结果里某个NUMBER(12,2)字段,GetFieldValue返回的字符串少了小数位,比如 1234.50 变成 1234.5;个别时候抛库没有注册。

原因:Oracle 的NUMBER通过 OraOLEDB 可能映射成多种 VARIANT 类型。VT_DECIMAL转字符串时,小数尾部的零被裁剪。更隐蔽的是VT_CY类型,VariantChangeType转换时如果区域设置不对,小数点符号会被替换成其他字符。

解决:字段读取不要依赖单一路径。对VT_CY单独处理,先转double再用固定格式化输出:

if (vtValue.vt == VT_CY) { double dVal = (double)vtValue.cyVal.int64 / 10000.0; CString strVal; strVal.Format(_T("%.2f"), dVal); return strVal; }

这种问题在测试环境几乎不会暴露,因为测试数据通常用整型。到生产环境一跑,金额小数、统计报表全是批量翻车。做法是在GetFieldValue里对不同类型的 VARIANT 写清楚分支,而不是一股脑丢给VariantChangeType。

5.4 游标与锁定类型不能乱写

现象:ExecuteSQL跑完一条查询,调用MovePrev()或GetRecordCount()得到 -1,或者MoveFirst()抛错。

原因:ADO 默认游标类型是adOpenForwardOnly,记录集只能向后滚动,而且GetRecordCount对这种游标返回 -1。锁定类型默认adLockReadOnly,无法更新。

解决:如果需要双向滚动或统计条数,执行时显式建游标:

pRs = db.GetConnection()->Execute( _bstr_t(strSQL), NULL, adCmdText | adOpenKeyset | adLockReadOnly);

adOpenKeyset允许双向滚动。如果需要原地修改数据,adLockOptimistic配合Update操作。这里有一个性能权衡:adOpenKeyset比adOpenForwardOnly开销更大,大结果集场景别为了“通用”而全面用 keyset,只在确实需要滚动时才升。CAdoDatabase 的ExecuteSQL一般是基础版本,默认只支持前向,这是源码设计中常见的边界,别当成 bug。

5.5 连接泄漏与 Close 时序

现象:程序长时间运行,Oracle 会话数持续增长,最终达到 processes 上限报ORA-00020。

原因:每次Open创建了新的物理连接,但Close没有被调用。智能指针析构虽然会释放 COM 引用计数,但连接池没有及时归还。

解决:Close不只是释放指针,还要显式关闭记录集和连接。我习惯在CADODatabase::UnInit()里先关记录集:

void CADODatabase::UnInit() { if (m_pRecordset != NULL) { if (m_pRecordset->State == adStateOpen) m_pRecordset->Close(); m_pRecordset = NULL; } if (m_pConnection != NULL) { if (m_pConnection->State == adStateOpen) m_pConnection->Close(); m_pConnection = NULL; } if (m_bNeedUninit) CoUninitialize(); }

特别提醒:State判断必须做。如果连接没打开直接Close,事务状态、连接池状态不一致时会抛ADO 错误。还有一个容易被忽视的点:ExecuteSQL返回的pRs在业务代码里用完要自行Close,否则 UnInit 里只能关掉最近一个记录集,旧记录集连接被占着,连接池耗尽问题依旧。

6. 进阶:批量写入与 CLOB 读写的可靠姿势

6.1 批量参数化写入

单条循环Execute插入 10 万条数据,慢且容易中途失败。ADO 的UpdateBatch机制可以先在本地缓存多条记录,再一次性提交。这里以 DATA 表为例:

_RecordsetPtr pRs; pRs.CreateInstance(__uuidof(Recordset)); pRs->Open(_T("SELECT ID, NAME FROM DATA WHERE 1=0"), db.GetConnection().GetInterfacePtr(), adOpenKeyset, adLockBatchOptimistic, adCmdText); for (long i = 0; i < batchCount; i++) { pRs->AddNew(); pRs->Fields->GetItem(_T("ID"))->PutValue(_variant_t(i)); pRs->Fields->GetItem(_T("NAME"))->PutValue(_variant_t(CString(_T("name_")) + ...)); pRs->Update(); } pRs->UpdateBatch(adAffectAll);

关键参数是adLockBatchOptimistic:它把Update缓存在客户端,直到UpdateBatch才真正发到 Oracle。批量越大,单条提交的网络往返越少,但内存占用也高,我一般每攒 1000 条提交一次,避免长事务锁表。如果用adLockOptimistic,每条Update都实时写库,UpdateBatch形同虚设,这是最常见的无效用法。

6.2 CLOB/BLOB 的读写

Oracle 的大字段在 ADO 里不像普通VARCHAR2那样直接取字符串,需要GetChunk分段读:

FieldPtr pField = pRs->Fields->GetItem(_T("CLOB_COL")); long lDataLen = pField->ActualSize; char* pBuf = new char[lDataLen + 1]; long lOffset = 1, lRead = 0; while (lRead < lDataLen) { long lChunk = min(1024, lDataLen - lRead); VARIANT vtChunk; vtChunk.vt = VT_ARRAY | VT_UI1; vtChunk.parray = pField->GetChunk(lChunk, lOffset); // 拷贝 vtChunk.parray 到 pBuf + lRead lRead += lChunk; lOffset += lChunk; } pBuf[lDataLen] = 0;

写入时对应使用AppendChunk,同样按块追加。这个机制是对VT_ARRAY | VT_UI1类型字段的唯一可靠读写路径。

最后说一个我自己的教训:最早我图省事,让 CAdoDatabase 的GetFieldValue把字段类型全转成字符串,遇到 CLOB 字段时它返回了该操作不支持此类型。从那以后我每接一个 Oracle 新库,都会先查一遍目标表的字段类型,凡是出现CLOB、BLOB、RAW的字段列出来,单独写专门的处理函数,不再走通用取值路径。做数据库封装类,边界不是封装得越多越好,而是把最常见的路径做稳、把不常见的类型路径亮出来。希望这份 CAdoDatabase 源码包和这些经验,能帮你在 C++ 连 Oracle 的路上少走几个来回。

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

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

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

立即咨询