VC++用_CommandPtr执行SQL:参数化查询与避坑实践
简介一套面向VC开发者的ADO Command对象实操示例工程配套完整源码与阅读说明帮助掌握通过_CommandPtr执行SQL查询、调用存储过程以及带参数命令等数据库核心操作可直接作为学习ADO数据访问的入门项目。压缩包共11个文件以cpp源代码、h头文件为主体另含Visual Studio工程配置文件vcxproj/sln/filters/user与txt说明文档整体仅8KB结构紧凑便于快速查看与调试。已有524人学习下载适合初学数据库编程或需要快速上手ADO的开发者对照实践。示例代码覆盖Command对象的创建与初始化以及ActiveConnection、CommandText、CommandType等关键属性的设置并详细演示了调用存储过程时adCmdStoredProc的使用同时通过Parameter对象展示了带参数查询的完整写法涉及Execute执行与try-catch错误处理等环节。配合配套事件处理头文件还能进一步理解ADO事件机制便于在真实项目中迁移使用或作为技术笔记参考。1. 为什么VC里要用_CommandPtr执行SQL从Recordset翻车说起在VC里折腾数据库操作绕不开ADO的两根指针_ConnectionPtr和_CommandPtr。很多人习惯用Recordset一把梭拿SELECT结果集没问题但一旦要执行INSERT、UPDATE、调用存储过程、或者给SQL传参数Recordset就变得极其别扭字符串拼接写出来的代码又长又容易出注入隐患。Command对象的意义在于它把SQL语句、连接、参数集合封装在一个对象里执行后既能拿受影响行数也能直接返回记录集是控制力最强的那种选择。这篇文章从一个维护中的老系统数据库模块出发把_CommandPtr从初始化、创建、执行到参数绑定的全流程拆开讲最后附上几位同事实际踩过的五个坑。2. 搭建ADO运行环境导入库、初始化COM与_CommandPtr的多种创建方式要用_CommandPtr先得让编译器认识ADO还要把COM环境准备好。这一章把最容易出错的前置步骤讲透。2.1 #import指令与ADO库的引入路径和属性参数常见做法是在stdafx.h或者工程的主头文件里加这一行#import C:\Program Files\Common Files\System\ado\msado15.dll \ no_namespace rename(EOF, adoEOF)这里两个属性很关键no_namespace让编译器不把ADO类型放进命名空间后续直接用_ConnectionPtr、_CommandPtr少写一层前缀rename(EOF, adoEOF)是因为ADO的EOF枚举常量和C标准库的EOF宏通常是-1冲突不重命名的话编译阶段会报一堆“重定义”错误。如果你的工程里恰好有别的模块也定义了PARAMETERS建议再加一个rename(PARAMETERS, adoPARAMETERS)。路径要小心开发机上如果装了不同版本的Office或数据库客户端msado15.dll不一定在默认路径。我平时会先敲一句where msado15.dll确认一下。要是找不到去注册表搜HKEY_CLASSES_ROOT\ADODB.CommandCLSID下的值名对应模块路径。路径写错#import会在编译时直接报“无法打开文件”或者后面使用时提示类型未定义。还有个容易被忽略的点#import生成的.tlh和.tli文件默认放在工程输出目录Debug/Release。第一次编译后可以打开.tlh看看里面列出了_CommandPtr类的所有方法签名和重载形式。比如Execute在.tlh里能看到三种参数组合。遇到编译错误翻这个文件比上网搜半天更快。2.2 CoInitialize与连接对象先过Connection这关_CommandPtr不会自己去连数据库它需要跟一个_ConnectionPtr绑定。而且所有ADO对象都建立在COM规则上所以使用前必须在当前线程初始化COM::CoInitialize(NULL); _ConnectionPtr pConn(__uuidof(Connection)); pConn-ConnectionString ProviderSQLOLEDB;Data SourcemyServer;Initial Catalogtestdb;User Idappuser;Passwordpass123;; pConn-ConnectionTimeout 3; HRESULT hr pConn-Open(, , , adConnectUnspecified); if (FAILED(hr)) { // 记录 _com_error 的描述别直接往下走 }CoInitialize(NULL)用的是单线程套间STA模式对绝大多数MFC对话框程序够用。如果你在后台线程里执行数据库操作可以用CoInitializeEx(NULL, COINIT_MULTITHREADED)但每个线程必须单独初始化而且不能把一个连接对象跨线程共享。连接字符串里的Provider是OLEDB驱动名SQLOLEDB是常见的一种换成别的数据库就对应别的驱动。Data Source是服务器实例名Initial Catalog是库名。如果走Windows集成认证把User Id和Password去掉改成Integrated SecuritySSPI。注意先设置ConnectionTimeout再Open默认15秒在服务器不可达时能把界面卡死。有人图省事把连接串直接写给_CommandPtr的ActiveConnection属性pCmd-ActiveConnection ProviderSQLOLEDB;Data Source...;这样能运行但每次执行都会隐式创建一条新连接压力大时连接数会翻倍涨最后报连接池耗尽。所以规范做法是先建好_ConnectionPtr再赋值给Command。2.3 CreateInstance与CreateCommand创建_CommandPtr的多种姿势_CommandPtr是智能指针不能直接new常见有三种创建方式// 方式一先定义后CreateInstance _CommandPtr pCmd1; pCmd1.CreateInstance(__uuidof(Command)); // 方式二定义时直接构造 _CommandPtr pCmd2(__uuidof(Command)); // 方式三通过已有连接对象创建 _CommandPtr pCmd3 pConn-CreateCommand();前两种本质相同都会调用CreateInstance内部拿到Command的IUnknown接口。第三种是从连接对象直接生成生成的Command已经绑定了那个连接省去手动设置ActiveConnection很适合在同一个连接上连续处理多条命令。_CommandPtr是_com_ptr_t模板包装出来的智能指针它自己管理AddRef和Release。所以你不能用delete也不需要手动Release超出作用域时计数减一归零则自动释放。但这带来一个隐蔽问题如果两个_CommandPtr指向同一个COM对象修改其中一个的CommandText另一个也会跟着变。有同事在业务循环里用局部变量接收全局Command改完没改回去差点把线上配置都改了。所以别轻易把Command对象复制给临时变量用除非你明确知道自己在干嘛。另外如果机器上有多个ADO影响版本__uuidof(Command)的CLSID可能不是你预期的。必要时可以CreateInstance指定字符串形式的CLSIDpCmd-CreateInstance({0000051E-0000-0010-8000-00AA006D2EA4});一般开发环境不用这样但碰到“接口版本不匹配”时可以留一手。3. 用Command对象执行非查询命令CommandText与Execute的返回值环境搭好后重点就是配置命令和执行。很多人在这一步踩坑因为在VBA或者脚本里用ADO很方便VC里却要处理_variant_t和HRESULT稍微不注意就翻车。3.1 设置ActiveConnection和CommandText别把连接串反复写给Command绑定连接并指定要执行的命令pCmd-ActiveConnection pConn; pCmd-CommandText INSERT INTO user_info(user_name, age) VALUES(A同学, 25); pCmd-CommandType adCmdText;ActiveConnection既可以接收连接对象也可以接收连接字符串。用连接对象时命令执行会复用pConn不会新建连接用字符串时ADO每次都会创建并打开新连接所以代码里尽量用对象。另外从一个连接对象创建的Command方式三会自动绑定这步可以省掉。CommandType的值有几个常用项CommandType值适用场景adCmdText1普通SQL文本adCmdTable2直接查询整张表adCmdStoredProc4调用存储过程adCmdUnknown8让ADO自己猜显式指定CommandType很重要。如果设成adCmdUnknownADO需要通过元数据去探测CommandText到底是什么既慢又可能猜错比如表名恰好是SQL保留字时就会出妖蛾子。所以我一般至少写adCmdText或adCmdStoredProc从不偷懒。还有一种情况有人把CommandText写成“SELECT * FROM t; DELETE FROM t”这种多语句组合。ADO不保证所有驱动都支持SQL Server的OLEDB驱动会把两句都发过去但执行后影响的只是第一条语句的行数。最好还是拆成两次Execute别依赖这种连招。3.2 Execute执行增删改affected参数如何拿到执行非查询命令的典型代码_variant_t vAffected; pCmd-Execute(vAffected, NULL, adCmdText); // 此时vAffected里保存受影响行数 long nRows 0; if (vAffected.vt VT_I4) { nRows vAffected.lVal; }Execute的第一个参数是作用域外的受影响行数第二个参数是参数数组这里传NULL第三个参数可以覆盖CommandType。注意返回值的类型不固定某些驱动返回VT_I8或VT_UI4直接拿lVal会得到乱码。我第一版代码就是直接vAffected.lVal插入10行读出来一个几十万的数字后来改成先ChangeTypevAffected.ChangeType(VT_I4); long nRows vAffected.lVal;这样不管原类型是什么都会尽力转换为有符号32位整数。如果你的驱动返回的是VT_EMPTY说明该语句不产生行数统计做个判空保护就好。Execute的第二个参数是_variant_t的数组用来在Execute时直接传参。但更推荐的方式是先用Parameters集合追加参数后面那一章专门讲。如果你在Execute级别传参格式是一个二维数组写起来很别扭而且调试时很难确认参数顺序和类型。别忘了Execute返回值如果CommandText是SELECTExecute会返回一个_RecordsetPtr。你要是像执行UPDATE一样忽略返回值等于白查记录集直接被丢弃。所以查询和更新要分开处理。3.3 执行查询并读取结果集Command与Recordset的分工Command执行SELECT时返回一个有数据的_RecordsetPtr不用再手动Open_RecordsetPtr pRs; pCmd-CommandText SELECT user_id, user_name FROM user_info WHERE age 20; pCmd-CommandType adCmdText; pRs pCmd-Execute(NULL, NULL, adCmdText); while (!pRs-adoEOF) { CString strName (LPCTSTR)(_bstr_t)pRs-Fields-GetItem(user_name)-Value; int nId pRs-Fields-GetItem(user_id)-Value.lVal; // 处理记录 pRs-MoveNext(); } pRs-Close();字段值取回来都是_variant_t。字符串列必须用(_bstr_t)转到LPCSTR再赋给CString整数列则直接读取lVal但同样建议先判vt类型尤其是Access等驱动下可能返回VT_UI4。读完后养成习惯调用Close因为记录集不关闭连接上的其他命令可能一直被锁阻塞。4. 参数化查询与存储过程_ParameterPtr的完整操作流程Command对象最值钱的功能就是参数化。这一章把创建参数、追加参数、调用存储过程的所有细节讲清楚。4.1 为什么必须用参数化查询注入与性能以前很多人写SQL靠字符串拼接CString strSQL; strSQL.Format(SELECT * FROM user_info WHERE name%s, userInput);当userInput里带着单引号时要么语法错要么被注入。我接手过一个老系统登录接口就是用拼接写SQL结果被字符串逃逸搞到整张表被删。参数化的核心价值就是把数据和指令分开SQL文本固定参数值单独走二进制通道数据库不会把参数内容当成可执行部分。这从根上解决注入问题。另一个更实际的收益是预编译。同一个CommandText配合不同参数值反复执行时数据库会把执行计划缓存起来下次看到完全相同的文本直接复用计划。批量插入1000条参数化只编译一次字符串拼接则每条都要从词法分析做起性能差距能有几十倍。我们在一个跑批任务里就用这个特性把处理从半小时压到三分钟。4.2 创建参数并追加到CommandCreateParameter的七个参数_CommandPtr提供了CreateParameter方法原型参数不少但常用就前五个_ParameterPtr pParam; pParam pCmd-CreateParameter( _T(name), // 参数名 adVarChar, // 参数类型 adParamInput, // 方向输入 20, // 最大长度 _variant_t(_T(A同学)) // 参数值 ); pCmd-Parameters-Append(pParam); pCmd-CommandText INSERT INTO user_info(user_name) VALUES(?); pCmd-Execute(NULL, NULL, adCmdText);第5个参数Value必须是_variant_t。CString要先转成LPCTSTR再构造_variant_t否则会按字符指针处理。Size参数对于varchar/nvarchar必须指定字段长度否则某些驱动会按0长度处理插入时报“值不能为空”。对于int、date类型Size传0即可。常用类型对应表参数类型常量值对应数据库类型adInteger3intadVarChar200varcharadVarWChar202nvarcharadDate7datetimeadBoolean11bitadDouble5floatDirection参数是很多人搞不清的地方adParamInput1输入参数adParamOutput2输出参数adParamInputOutput3既可输入也可输出adParamReturnValue4存储过程的RETURN返回值。方向设错执行存储过程时不是报“参数类型不匹配”就是拿到错误的值。追加参数后如果要复用同一个Command直接用Parameters集合改Value不需要再CreateParameterpCmd-Parameters-Item[name]-Value _variant_t(_T(B同学)); pCmd-Execute(NULL, NULL, adCmdText);这里有个顺序陷阱CommandText里的?占位符和Parameters集合的顺序一一对应。先追加参数再执行没问题如果先写了CommandText又分两次追加参数顺序搞反不会报错数据会静默写错列。我在一次数据导入程序里就是这么把“电话”写到了“年龄”列排查了整整一天。4.3 调用带输出参数的存储过程拿到返回值调用存储过程时CommandText只写过程名不要写exec也不要写括号pCmd-CommandText proc_get_count_by_age; pCmd-CommandType adCmdStoredProc; _ParameterPtr pParamIn; pParamIn pCmd-CreateParameter(_T(minAge), adInteger, adParamInput, 0, _variant_t(18L)); pCmd-Parameters-Append(pParamIn); _ParameterPtr pParamOut; pParamOut pCmd-CreateParameter(_T(count), adInteger, adParamOutput, 4, _variant_t(0L)); pCmd-Parameters-Append(pParamOut); pCmd-Execute(NULL, NULL, adCmdStoredProc); long nCount pParamOut-Value.lVal;输出参数在CreateParameter时也可以给一个初始值执行完后ADO会把结果写回Value。Size必须给够adInteger是4所以传4。如果是字符串输出参数Size要设置为允许的最大长度否则可能被截断。存储过程的RETURN值要用adParamReturnValue类型_ParameterPtr pRet; pRet pCmd-CreateParameter(_T(ret), adInteger, adParamReturnValue, 4, _variant_t(0L)); pCmd-Parameters-Append(pRet); // 执行后读取 pRet-Value.lVal注意ReturnValue参数一般要最先追加且一个Command只能有一个。如果既有输入参数又有返回值先追加返回值再追加输入参数这样最不容易出错。5. Command对象使用避坑指南五个最容易翻车的地方5.1 连接对象过早CloseCommand还活着连接却没了现象执行完一次命令后手动调用pConn-Close()然后再次用同一个_CommandPtr执行时抛“对象已关闭”或“连接无效”。原因_CommandPtr虽然还持有连接接口指针但pConn已经显式关闭了底层会话。COM引用计数只能保证接口对象不销毁保证不了物理连接不关闭。解决不要在Command还绑定时手动Close连接。如果确实需要关闭先让Command脱离连接pCmd-ActiveConnection NULL; pConn-Close();或者反过来把连接对象设计成和Command同生命周期避免中途Close。我见过最极端的案例是一条命令里先执行SELECT然后立刻Close再去读取记录集结果记录集变成了不可用的空壳。记住记录集和它依赖的连接是绑定的连接关闭结果集也就废了。5.2 Execute返回的affected类型不固定lVal读出的值像疯了一样现象插入10行数据vAffected.lVal读出来是负数或者一个完全没道理的大数字。原因定义的Variant类型不是VT_I4可能是VT_UI4或VT_I8你却按有符号32位访问也就是用lVal。底层字节解释错乱自然得到垃圾值。解决先强制转成VT_I4再读取_variant_t vAffected; pCmd-Execute(vAffected, NULL, adCmdText); vAffected.ChangeType(VT_I4); long nRows vAffected.lVal;ChangeType会尝试做类型转换如果原类型是VT_EMPTY它会转成VT_EMPTY实际上ChangeType遇到空值会失败最好先判断vAffected.vt ! VT_EMPTY再转换。我们项目里统一封装了一个小函数从_variant_t提取长整型所有affected读取都走它避免每次写一遍判断。5.3 重复使用Command时没清参数参数集合越挂越多现象同一个_CommandPtr每次执行前都CreateParameter再Append第二次执行时报“参数对象已存在”或者执行计划越来越大。原因Parameters集合是累积的同名的参数再次Append会直接抛出异常不同名参数也会一直堆积占内存且拖慢命令解析。解决如果参数名固定且数量不变应该在第一次执行前创建参数后续只改Value。如果参数结构每次不一样执行前清空集合pCmd-Parameters-DeleteAll();注意DeleteAll和Parameters集合自己的释放不同它会释放所有参数对象。执行完再重新追加。另外如果你改了CommandText旧参数可能失效最好也先DeleteAll再Append。5.4 NULL参数传成了空字符串写入数据库的不是NULL现象一个允许NULL的字段在代码里给参数传了NULL结果入库后变成了空字符串后来统计时总被当成有值处理。原因CreateParameter的Value参数如果用_variant_t(_T())或者_variant_t()其vt是VT_EMPTY或字符串空很多数据库驱动会把这种值当成空字符串写入而不是NULL。解决显式构造Variant为NULL_variant_t vNull; vNull.vt VT_NULL; pParam pCmd-CreateParameter(_T(remark), adVarChar, adParamInput, 50, vNull); pCmd-Parameters-Append(pParam);VT_NULL才是数据库NULL的正确表示。如果参数值是整数想传NULL也一样的做法把vt设成VT_NULL。这个坑太隐蔽了因为程序不报错只是数据不对往往上线几个月后才发现。5.5 CoInitialize/CoUninitialize不配对退出时莫名卡住现象程序启动正常执行SQL也正常但关闭时卡在某个析构函数里或者调试器停在COM内部代码进程不退出。原因ADO依赖COM如果线程初始化为STA就必须在结束前调用CoUninitialize。MFC程序里很多人只在InitInstance调用CoInitialize忘了在ExitInstance调用CoUninitialize或者后台线程里CoInitializeEx成功线程退出前没清理。解决每个线程负责自己清理// 线程函数里 ::CoInitializeEx(NULL, COINIT_MULTITHREADED); // ... 使用ADO ... ::CoUninitialize();MFC程序中在InitInstance调用CoInitialize对应的在ExitInstance调用CoUninitialize。注意如果CoInitialize失败比如重复初始化返回S_FALSE就不能随意CoUninitialize。可以用返回值判断HRESULT hr ::CoInitialize(NULL); if (SUCCEEDED(hr)) { // 使用ADO ::CoUninitialize(); }这个习惯救过我好几次。有一回一个服务程序退出时挂起就是后台线程的CoInitializeEx之后没配对CoUninitialize导致线程的COM状态一直没有清理。6. 把Command用出花复用连接、事务与超时设置的完整实践6.1 Prepared属性让重复执行的SQL飞起来如果你在循环里执行同一个CommandText只是参数值不同记得打开PreparedpCmd-CommandText UPDATE user_info SET age age 1 WHERE user_id ?; pCmd-Prepared VARIANT_TRUE; for (int i 0; i 1000; i) { pCmd-Parameters-Item[user_id]-Value _variant_t(i); pCmd-Execute(NULL, NULL, adCmdText); }Prepared为TRUE时第一次Execute后数据库会缓存执行计划后续Execute直接复用。但注意一旦你修改了CommandTextPrepared状态会重置需要重新赋值一次。另外不是所有数据库驱动都支持Prepared像某些轻量数据库可能忽略这个属性不影响正确性但也不会有性能提升。我在实际跑批中习惯在批量循环前执行一次空参数验证让驱动把命令编译好然后再进入循环。这样第一轮循环就不会因为编译而慢半拍。6.2 事务处理把多步操作装进一个原子块当一系列命令必须同时成功或同时失败时用Connection的BeginTrans/CommitTrans/RollbackTransHRESULT hr pConn-BeginTrans(); try { pCmd-ActiveConnection pConn; pCmd-CommandText UPDATE accounts SET balance balance - 100 WHERE id 1; pCmd-Execute(NULL, NULL, adCmdText); pCmd-CommandText UPDATE accounts SET balance balance 100 WHERE id 2; pCmd-Execute(NULL, NULL, adCmdText); pConn-CommitTrans(); } catch (_com_error e) { pConn-RollbackTrans(); // 记下 e.Description() }这里有几个注意点事务必须基于同一个连接。如果_CommandPtr的ActiveConnection不是pConn而是另一个连接串BeginTrans就管不到它。事务中任何一个命令失败包括参数错误、超时、约束冲突都会跳出catch块此时必须RollbackTrans把连接恢复到事务前状态。BeginTrans之后连接上不能再用别的方式隐式开启新事务否则嵌套语义会很乱。有一个同事把一段插入代码包在事务里但Command是用pConn-CreateCommand创建的他以为一定安全结果在另一个函数里误给Command重新赋了连接字符串导致那条命令脱离了事务。最后的血泪教训是执行事务内的Command前每次都显式设置一遍pCmd-ActiveConnection pConn;别偷懒。6.3 超时设置别让界面卡死在长查询上数据库假死、网线松了、锁竞争激烈这些情况如果没超时保护MFC界面就永远停在那里。Command和Connection都有超时属性pConn-ConnectionTimeout 5; // 建立连接超时单位秒 pCmd-CommandTimeout 10; // 命令执行超时单位秒ConnectionTimeout在Open之前设置CommandTimeout在Execute之前设置。设为0表示无限等待但生产环境千万别这么做。曾经有人为了等一个跑批存储过程把CommandTimeout设成0结果存储过程因为死循环跑了一晚上界面假死一晚上。后来强制要求所有Command都设明确超时并在超时catch块里记录日志。另外CommandTimeout对某些异步执行模式可能不生效因为异步返回之后函数就退了超时由驱动在后台处理。我们在老系统里发现调用带长事务的存储过程时CommandTimeout设再低也可能不触发最后要么用数据库端的会话超时要么靠事务本身的手动回滚。所以超时是最后一道保险不能作为唯一依赖。有一次我上线一个定时任务因为没设CommandTimeout数据库端索引重建卡了40分钟界面假死用户直接重启了系统。从那以后我每次写ADO命令都强制检查三件事连接是否显式绑定、参数是否清空、超时是否设置。这三件事全都确认无误才敢把代码提交给测试。希望这些经验能帮到你。本文还有配套的精品资源点击获取