
简介面向VC数据库开发初学者的一份ADO Command对象实践示例围绕在MFC/VC工程中创建_CommandPtr、设置ActiveConnection与CommandText、调用存储过程、绑定参数及执行命令等核心用法展开适合希望从连接对象进阶到命令对象、理解带参查询与存储过程调用的读者。压缩包共11个文件约8KB以cpp与h源码为主配有vcxproj、sln等Visual Studio工程文件及说明文本可直接打开解决方案对照编译调试。当前已有525人学习下载说明其作为入门练习具有一定的参考价值。示例代码把Command对象的属性设置、参数追加、结果集获取和异常处理串成一条完整实践线便于在VC项目中模仿改造。读者可据此理解adCmdText与adCmdStoredProc的使用差异并借助工程文件快速定位入口、头文件声明和事件处理代码同时积累常见错误排查思路用于小型数据库访问练习。1. 一次下单接口从 80 行降到 12 行靠的是 Command 对象去年接手一个 VC 的老客户端里面有段下单逻辑拼接 SQL、判断字段是不是字符串、手动加引号、碰到单引号再转义、最后sprintf拼成一条长串丢给Connection::Execute。一个字段加错位置整条语句就报语法错日志里还看不出是哪个参数的问题。后来引入 ADO 的_CommandPtr把这些散落各处的拼接全部换成?占位符加参数绑定那段代码从 80 多行压到 12 行注入漏洞和中文乱码也一起消失了。这个 Ado 实例项目做的就是这件事用 VC 调 ADO把 Command 对象的参数化查询、存储过程调用、输出参数取值这三条主线跑通附带一份能编译的最小工程。适合手上还维护着 MFC 或 Win32 客户端、需要跟关系型数据库打交道的开发者也适合刚接触 COM 智能指针、被_com_error报错绕晕的人。2. 工程结构与技术选型ADO 三个核心对象怎么落进 VC 工程ADO 本身是一套 COM 组件VC 用它的方式跟 C# 完全不是一回事没有 NuGet 包没有强类型实体靠的是#import把类型库翻译成.tlh/.tli两个头文件然后在代码里用_ConnectionPtr、_CommandPtr、_RecordsetPtr这三个智能指针。理解这一点后面所有的报错和写法就都有了解释。2.1 目录结构一个能跑的最小工程长什么样项目刻意做得很小为的是把干扰项去掉让人一眼看清哪部分是 ADO 的哪部分是业务的。AdoCommandDemo/ ├── AdoCommandDemo.sln ├── AdoCommandDemo/ │ ├── stdafx.h // 全局只在这里 #import msado15.dll │ ├── stdafx.cpp │ ├── AdoEnv.h // CoInitialize 的 RAII 封装 │ ├── DbConfig.h // 连接串集中管理不散落在业务代码里 │ ├── OrderRepository.h // 业务数据访问层 │ ├── OrderRepository.cpp // Command 对象的全部用法都在这里 │ ├── main.cpp // 控制台入口按顺序跑一遍示例 │ └── sql/ │ ├── 01_schema.sql // 建表 │ └── 02_usp_create_order.sql // 带输入、输出参数的存储过程 └── README.md这种分层在业务项目里可能显得啰嗦但它把「ADO 怎么用」和「业务怎么跑」隔开了。OrderRepository.cpp是唯一出现_CommandPtr的文件改数据库、换连接串、换存储过程都不用动main.cpp。我一般建议刚上手的人先照这个结构抄一遍跑通之后再往里塞业务比一上来就丢进现有工程里快得多。依赖方面只有一项系统盘里的msado15.dllWindows 自带不需要额外安装。所以这个工程拷到任何一台能装 VS 的机器上都能直接编译。2.2 头文件里的那一行 #importno_namespace 与 rename 的来由整套 ADO 用法的基础就在这一行写错了后面全是编译错误。// stdafx.h —— 全工程只引入一次放在预编译头里 #import C:\Program Files\Common Files\System\ado\msado15.dll \ no_namespace \ rename(EOF, adoEOF) \ rename(BOF, adoBOF)#import干的事情是读取 DLL 里的类型库生成msado15.tlh和msado15.tli编译器再把它当成普通头文件包含进来。三个参数各有各的用处no_namespace不加这个所有 ADO 类型会被塞进一个叫ADODB的命名空间代码里就得写ADODB::_CommandPtr。业务代码里到处是这种前缀读起来很难受。rename(EOF, adoEOF)这一条纯粹是为了避免名称冲突。stdio.h里有个EOF宏ADO 的_Recordset也有个EOF属性两者相撞就是一堆C2059: 语法错误: 常量。改名之后遍历结果集要写spRs-adoEOFMoveNext也用adoBOF判断。#import生成的路径默认在Debug/或Release/目录下clean 之后重新生成一次就行不用手动管。提示如果编译报「无法打开源文件 msado15.tlh」先确认路径里那个反斜杠是续行符而不是注释再确认#import只出现了一次。多次引入同一个类型库会产生重定义错误。2.3 连接与生命周期CoInitialize、ConnectionTimeout 与智能指针COM 有个硬规矩任何 COM 调用之前当前线程必须先初始化套间。MFC 工程里AfxOleInit()在InitInstance里就干完了纯 Win32 或控制台程序得自己来。项目里用了个极简的 RAII 包装保证进入和退出成对// AdoEnv.h —— 作用域内自动初始化/反初始化 COM class CAdoEnv { public: CAdoEnv() { m_hr ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); } ~CAdoEnv() { if (SUCCEEDED(m_hr)) ::CoUninitialize(); } private: HRESULT m_hr; };用COINIT_APARTMENTTHREADED而不是COINIT_MULTITHREADED是因为 ADO 的连接对象在套间模型下行为更可预测跨线程传_ConnectionPtr也更容易出问题——这一点在后面的避坑章节还会提。连接的打开集中在一个函数里超时参数一定要设默认值在断网或者数据库挂掉的时候能让人等到怀疑人生_ConnectionPtr OpenDb(const wchar_t* connStr) { _ConnectionPtr spConn; spConn.CreateInstance(__uuidof(Connection)); // 创建 COM 对象 spConn-ConnectionTimeout 5; // 建连超时秒 spConn-CommandTimeout 30; // 单条 Command 执行超时秒 spConn-Open(_bstr_t(connStr), _bstr_t(L), _bstr_t(L), adConnectUnspecified); return spConn; // 返回智能指针AddRef 自动处理 }Open的四个参数分别是连接串、用户名、密码、选项。用户名密码在连接串里给了就传空_bstr_tadConnectUnspecified表示同步打开。连接串本身放在DbConfig.h里形式大概这样// DbConfig.h —— 实际项目里从 ini/注册表读别硬编码 inline const wchar_t* kConnStr LProviderSQLOLEDB;Data Source127.0.0.1,1433; LInitial CatalogDemoDB;UIDapp_user;PWD******;;三个参数值的注意Provider决定了后面参数占位符的规则SQLOLEDB只认?不认nameInitial Catalog是默认库省得每条 SQL 都写库名Data Source后面跟端口要用逗号不是冒号。3. Command 对象拆解从 CreateParameter 到 Execute 的完整链路_CommandPtr的价值全在「参数化」这三个字上。它把 SQL 文本和参数值分成两条通道传给数据库文本先被解析成执行计划值再按类型填进去所以既不会有注入也不会因为值是数字还是字符串而拼错引号。3.1 用 ? 占位符写参数化 SQL先看一段完整的插入这是项目里出现频率最高的写法// OrderRepository.cpp —— 参数化插入返回受影响行数 long InsertOrder(_ConnectionPtr spConn, const std::wstring no, long custId, double amount) { _CommandPtr spCmd; spCmd.CreateInstance(__uuidof(Command)); spCmd-ActiveConnection spConn; // 绑定连接 spCmd-CommandType adCmdText; // 告诉驱动这是 SQL 文本 spCmd-CommandText LINSERT INTO t_order(order_no, cust_id, amount, create_time) LVALUES(?, ?, ?, GETDATE()); // 参数必须严格按 ? 出现的顺序 Append名字只是给人看的 spCmd-Parameters-Append(spCmd-CreateParameter( Lorder_no, adVarWChar, adParamInput, 64, _variant_t(no.c_str()))); spCmd-Parameters-Append(spCmd-CreateParameter( Lcust_id, adInteger, adParamInput, sizeof(long), _variant_t(custId))); spCmd-Parameters-Append(spCmd-CreateParameter( Lamount, adDouble, adParamInput, sizeof(double), _variant_t(amount))); _variant_t vtAffected; spCmd-Execute(vtAffected, NULL, adExecuteNoRecords); return vtAffected.lVal; // 受影响行数 }逐段说明CreateInstance(__uuidof(Command))创建 Command 对象__uuidof是编译器从#import生成的类型库里取 CLSID不需要自己写 GUID。ActiveConnection直接赋_ConnectionPtr靠的是_com_ptr_t的重载运算符自动取接口指针。传NULL也行那样就得在Execute的第一个参数里传连接。CreateParameter的五个参数是参数名、数据类型、方向、长度、初值。长度对字符串类型必须给足adVarWChar的 64 表示最多 64 个宽字符给 0 会在某些驱动下截断成空串。第三个参数adExecuteNoRecords很关键。INSERT/UPDATE/DELETE 不返回记录集带上这个标志能让驱动少建一个空的 Recordset 对象几千次批量插入下来性能差距肉眼可见。Execute的第一个参数是VARIANT*接收受影响行数第二个参数是「替换参数数组」基本用不上固定传NULL。类型选择上有几个高频对照我一般直接查这张表C 侧值ADO 类型长度参数说明longadIntegersizeof(long)int 列LONGLONG/__int64adBigIntsizeof(LONGLONG)bigint 列、自增主键doubleadDoublesizeof(double)金额、比例wchar_t*/_bstr_tadVarWChar字符数上限nvarchar中文必须用这个char*adVarChar字节数上限varchar用错中文会变问号booladBooleansizeof(VARIANT_BOOL)bit 列注意adVarChar配中文字符串是最常见的乱码来源。数据库列是nvarchar就必须用adVarWChar反过来也成立两边编码对不上时驱动不会报错只会默默塞进去一串问号。3.2 存储过程与输出参数Parameters 集合的索引取值存储过程是 Command 对象真正拉开差距的地方因为只有它能干净地处理输出参数和返回值。// 调用带输入、输出参数的存储过程返回新订单 ID LONGLONG CreateOrder(_ConnectionPtr spConn, long custId, const wchar_t* remark) { _CommandPtr spCmd; spCmd.CreateInstance(__uuidof(Command)); spCmd-ActiveConnection spConn; spCmd-CommandType adCmdStoredProc; // 关键声明这是存储过程 spCmd-CommandText Lusp_create_order; spCmd-Prepared VARIANT_TRUE; // 请求驱动缓存执行计划 // 索引 0 固定是返回值方向必须是 adParamReturnValue spCmd-Parameters-Append(spCmd-CreateParameter( LRET, adInteger, adParamReturnValue, sizeof(long))); spCmd-Parameters-Append(spCmd-CreateParameter( Lcust_id, adInteger, adParamInput, sizeof(long), _variant_t(custId))); spCmd-Parameters-Append(spCmd-CreateParameter( Lremark, adVarWChar, adParamInput, 200, _variant_t(remark))); spCmd-Parameters-Append(spCmd-CreateParameter( Lorder_id, adBigInt, adParamOutput, sizeof(LONGLONG))); spCmd-Execute(NULL, NULL, adExecuteNoRecords); // 用索引取值最稳名称匹配在不同驱动下行为不一致 long ret spCmd-Parameters-GetItem(0L)-Value.lVal; LONGLONG orderId spCmd-Parameters-GetItem(3L)-Value.llVal; if (ret ! 0) throw std::runtime_error(usp_create_order failed); return orderId; }几个必须说清的点adCmdStoredProc和adCmdText的差别不只是「告诉驱动这是存储过程」它还会影响参数怎么派发。设成adCmdText去调存储过程驱动会把整串当 SQL 文本解析参数绑定大概率失效。返回值参数必须第一个Append方向是adParamReturnValue。这是 ADO 的硬约定顺序错了取到的就是别的参数值。adParamOutput的参数在Append时不需要给初值但长度要给。adBigInt给sizeof(LONGLONG)字符串输出参数则要给足字符数否则会被截断成前几个字符。取值时用GetItem(索引)而不是GetItem(名称)。SQL Server 的 OLE DB 驱动在adCmdStoredProc下通常支持按名取但adCmdText场景下参数名是驱动自动生成的Parameter1、Parameter2按名取会直接抛异常。统一用索引代码挪到别的驱动上也不会崩。Value返回_variant_t它继承自tagVARIANT所以.lVal、.llVal、.dblVal都能直接点出来。类型读错不会报错只会得到一个垃圾数字这也是排查时最费劲的一类问题。3.3 Prepared 与 CommandTimeout 的取舍Prepared VARIANT_TRUE会让驱动在第一次执行后缓存执行计划后续同样的 Command 复用。但它不是无脑开就好只在「同一个 Command 对象被反复执行、只有参数值变化」的场景下才有收益。如果每次查询都新建一个_CommandPtrPrepared只会白白多一次往返。我在项目里只在批量循环里开它// 批量插入Command 建一次参数改值循环 Execute _CommandPtr spCmd BuildInsertCommand(spConn); // 内部已设 Prepared true for (const auto item : items) { spCmd-Parameters-GetItem(1L)-Value _variant_t(item.no.c_str()); spCmd-Parameters-GetItem(2L)-Value _variant_t(item.custId); spCmd-Parameters-GetItem(3L)-Value _variant_t(item.amount); spCmd-Execute(NULL, NULL, adExecuteNoRecords); }直接给Parameter-Value赋值比重新Append一遍快一个数量级因为省掉了参数集合的重建。这里有个前提参数的类型和长度在第一次Append时就定死了循环里只能改值不能改形状否则驱动会抛「参数类型不匹配」。CommandTimeout是另一个必须显式设的参数。它继承自 Connection但单条慢查询想单独放宽时在 Command 上覆盖更合适——比如报表导出给 300 秒其余保持 30 秒。不设的后果是长事务被默认超时打断然后事务挂在那里不回滚连接池慢慢被吃干净。4. 记录集与事务Command 返回的 _RecordsetPtr 怎么用Command 的Execute在查询场景下返回一个_RecordsetPtr这个对象是有状态的打开、遍历、关闭必须成对否则连接会一直被它占着。4.1 遍历结果集与字段取值// 按客户查订单遍历并打印 void PrintOrders(_ConnectionPtr spConn, long custId) { _CommandPtr spCmd; spCmd.CreateInstance(__uuidof(Command)); spCmd-ActiveConnection spConn; spCmd-CommandType adCmdText; spCmd-CommandText LSELECT order_no, amount FROM t_order LWHERE cust_id ? ORDER BY create_time DESC; spCmd-Parameters-Append(spCmd-CreateParameter( Lcust_id, adInteger, adParamInput, sizeof(long), _variant_t(custId))); _RecordsetPtr spRs spCmd-Execute(NULL, NULL, adCmdUnspecified); try { while (!spRs-adoEOF) { _bstr_t no spRs-Fields-GetItem(Lorder_no)-Value; double amt spRs-Fields-GetItem(Lamount)-Value; printf(%-20s %.2f\n, (const char*)no, amt); spRs-MoveNext(); } spRs-Close(); } catch (_com_error) { if (spRs ! nullptr spRs-State adStateOpen) spRs-Close(); throw; // 交给上层统一记日志 } }Execute的第三个参数这里给adCmdUnspecified意思是「按 Command 对象上已经设好的CommandType走」。前面设过adCmdText就够了重复指定反而容易在驱动之间产生分歧。Fields-GetItem按列名取字段返回_FieldPtr它的Value是_variant_t赋给_bstr_t或double时会自动转换。取不到列名会抛_com_error错误码是0x8002000B找不到成员看到这个先去核对 SELECT 列表。catch块里的Close是血泪经验。异常抛出后栈会展开但_RecordsetPtr的析构只负责 Release不保证把游标关掉服务端那边的连接就一直挂着。显式关一次再往上抛。4.2 事务把多条 Command 绑在同一条连接上ADO 的事务是连接级的BeginTrans/CommitTrans/RollbackTrans三个方法挂在_Connection上。这带来一个容易被忽略的约束参与同一事务的所有 CommandActiveConnection必须指向同一个连接对象。// 转账示例两条 Command一个事务 void Transfer(_ConnectionPtr spConn, long from, long to, double money) { spConn-IsolationLevel adXactReadCommitted; spConn-BeginTrans(); try { _CommandPtr spOut MakeCommand(spConn, LUPDATE t_account SET balance balance - ? WHERE id ?); spOut-Parameters-GetItem(1L)-Value _variant_t(money); spOut-Parameters-GetItem(2L)-Value _variant_t(from); _variant_t vAffected; spOut-Execute(vAffected, NULL, adExecuteNoRecords); if (vAffected.lVal ! 1) throw std::runtime_error(source account not found); _CommandPtr spIn MakeCommand(spConn, LUPDATE t_account SET balance balance ? WHERE id ?); spIn-Parameters-GetItem(1L)-Value _variant_t(money); spIn-Parameters-GetItem(2L)-Value _variant_t(to); spIn-Execute(NULL, NULL, adExecuteNoRecords); spConn-CommitTrans(); } catch (...) { spConn-RollbackTrans(); // 任何异常都回滚 throw; } }IsolationLevel必须在BeginTrans之前设之后改无效。RollbackTrans放在catch里而不是析构里是因为回滚本身也可能失败连接已断失败了要往上抛让调用方知道数据没落地。判断受影响行数是事务里最实用的一招vAffected.lVal ! 1立刻能发现「账户不存在」这类静默失败不然事务照样提交账就错了。4.3 客户端游标与批量更新需要「读一批、改一批、最后一次性提交」的场景用服务端游标会一行一个往返慢得离谱。切到客户端游标加批量乐观锁把往返压成一次_RecordsetPtr spRs(__uuidof(Recordset)); spRs-CursorLocation adUseClient; // 游标拉回本地 spRs-Open(_variant_t(LSELECT order_id, amount FROM t_order WHERE cust_id 1), _variant_t((IDispatch*)spConn), // 传连接对象本身 adOpenStatic, adLockBatchOptimistic, adCmdText); while (!spRs-adoEOF) { spRs-Fields-GetItem(Lamount)-Value _variant_t(1.05 * (double)spRs-Fields-GetItem(Lamount)-Value); spRs-MoveNext(); } spRs-UpdateBatch(adAffectAll); // 一次性提交所有改动 spRs-Close();adLockBatchOptimistic是批量更新的前提配adLockPessimistic会在读取时就锁行循环里改到一半被别人卡住。UpdateBatch内部走的是批量协议几百行的更新耗时通常只有逐行Update的十分之一。代价是冲突检测推迟到提交那一刻出现并发修改时会抛_com_error得自己决定是重试还是放弃。5. 避坑与排查Command 对象最容易翻车的五个地方这一节按「现象 → 原因 → 解决」来写都是实际调试出来的。现象编译期报C2059: 语法错误: 常量位置在 msado15.tlh 里。原因是EOF与stdio.h的宏冲突。解决#import那行必须带rename(EOF, adoEOF)和rename(BOF, adoBOF)然后把代码里所有spRs-EOF改成spRs-adoEOF。现象运行时抛_com_error错误码0x800401F0描述是「尚未调用 CoInitialize」。原因是在工作线程里直接用 ADO 了。CoInitialize是线程级的主线程初始化过不代表子线程也初始化了。解决把所有碰 ADO 的代码收进一个带 RAII 的入口或者在线程函数开头CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)、结尾CoUninitialize()。顺带提醒跨线程传_ConnectionPtr在套间模型下是不安全的正确做法是每个线程自己开一条连接。现象Execute抛「参数不足」或「参数类型不匹配」SQL 里明明写了三个?。原因有两类。一是Append的顺序和?的顺序对不上或者漏了返回值参数。二是误以为SQLOLEDB支持name形式的命名参数——它只认按位置的?写成cust_id会被当成字面量参数集合里的值永远填不进去。解决SQL 文本一律用?参数严格按顺序Append取值一律用索引不按名。现象输出参数取出来是空串或者 0存储过程本身跑着没问题。原因是CreateParameter的方向参数写成了adParamInput或者长度给了 0。字符串输出参数的长度必须给足字符数adBigInt必须给sizeof(LONGLONG)给sizeof(long)会读到高位截断的值。解决先把参数方向改成adParamOutput打印一遍Parameters-Count确认个数和顺序再逐个核对长度取值时用.llVal而不是.lVal。现象跑一段时间后连接数暴涨或者程序退出时卡在CoUninitialize。原因是循环里反复CreateInstance却没收尾_Recordset没Close_Command一直持有连接引用。智能指针只管引用计数不管游标状态。解决把 Recordset 和 Command 都放进作用域Close放在catch里也放一份长时间运行的服务用一个连接池管理类统一持有别让业务代码自己CreateInstance。提示_com_error::Description()返回的是驱动的原始报错比ErrorMessage()有用得多。日志里两个都打能省掉很多猜的时间。6. 二次开发把散落的 Command 调用收敛成一个 CAdoCommand 类这个实例跑通之后我最先动的地方不是加功能而是把OrderRepository.cpp里重复了十几遍的「CreateInstance → 设 ActiveConnection → 设 CommandType → Append 参数」抽成一个类。原因很简单这些样板代码一多谁都会在某处漏设一个CommandType然后花半小时查为什么参数没生效。一个够用的封装大概长这样重点在析构里自动Close和统一的错误翻译// CAdoCommand.h —— 链式构造 RAII 释放 class CAdoCommand { public: explicit CAdoCommand(_ConnectionPtr spConn, long type adCmdText) { m_spCmd.CreateInstance(__uuidof(Command)); m_spCmd-ActiveConnection spConn; m_spCmd-CommandType type; m_spCmd-CommandTimeout 30; } CAdoCommand Sql(const wchar_t* text) { m_spCmd-CommandText text; return *this; } CAdoCommand In(const wchar_t* name, DataType type, int size, const _variant_t v) { m_spCmd-Parameters-Append( m_spCmd-CreateParameter(name, type, adParamInput, size, v)); return *this; } CAdoCommand Out(const wchar_t* name, DataType type, int size) { m_spCmd-Parameters-Append( m_spCmd-CreateParameter(name, type, adParamOutput, size)); return *this; } _variant_t Param(long idx) const { return m_spCmd-Parameters-GetItem(idx)-Value; } long ExecuteNoRecord() { _variant_t n; m_spCmd-Execute(n, NULL, adExecuteNoRecords); return n.lVal; } private: _CommandPtr m_spCmd; // 析构自动 Release无需手动清理 };调用侧就变成一行链式long affected CAdoCommand(spConn) .Sql(LUPDATE t_order SET status ? WHERE order_id ?) .In(Lstatus, adInteger, sizeof(long), _variant_t(2L)) .In(Lorder_id, adBigInt, sizeof(LONGLONG), _variant_t(orderId)) .ExecuteNoRecord();改造的时候建议按这个顺序验证一步一停别一口气全换完步骤验证方法通过标准1. 抽公共构造用封装后的类重跑一条最简单的 UPDATE受影响行数与手写版一致2. 换存储过程调用同一个存储过程新旧两版各跑一次输出参数、返回值完全相同3. 加错误翻译故意传一个不存在的表名日志里能看到驱动原始 Description4. 压测释放循环调一万次观察连接数与句柄数曲线平稳退出不卡最容易被忽略的是第 4 步。封装之后Execute调用变短了反而更容易在循环里随手构造临时对象句柄数悄悄爬升。我一般在循环外面套一个GetGuiResources或者任务管理器的句柄列看着跑涨幅超过阈值就说明有对象没放掉。还有一点别在封装层里做「自动重连」。我见过一段代码_com_error一出现就重新Open结果在一个已经失败的事务里反复重连脏数据写了两遍。连接断了就让异常往上抛重试策略放在业务层那里才知道幂等边界在哪。从那以后我每次接这种 ADO 老项目都强制先把参数收集和错误翻译走一遍最小验证哪怕只是改一个字段类型也要拿真实数据库跑一遍再提交。跳过这一步省下的十分钟通常会在上线后还成半天。希望帮到你。本文还有配套的精品资源点击获取