简介:这份C/C++静态库面向需要在Windows下接入OPC DA的开发者,提供连接远程与本地OPC服务器的客户端实现基础,适合已有一定C++与COM基础、希望快速搭建数据采集或工业通信程序的技术人员。压缩包共32个文件,约33KB,以14个头文件、10个cpp源文件和3个c文件为主,并附带Visual Studio工程与说明文本,涵盖客户端、服务器、组、项、属性及事务等模块,便于直接纳入现有工程编译和二次开发。作者针对原GitHub版本远程调用不通及调试遗留异常做了较多修正,代码中若遇到wLog()可先行注释以通过编译。现有281人学习,适合用作OPC DA本地/远程连通性验证、接口封装参考与排错思路借鉴。
1. 一台老工控机上的 OPC DA,和一个不想再手写 COM 的下午
某条产线的采集程序换过三个人维护,服务器侧那台老工控机上跑的仍然是只认 OPC DA 的软件,能拿到的资料只有一份翻烂的接口说明。第一次上手时,在 C++ 里手写IOPCServer那一串QueryInterface,调到自己怀疑人生。OPCClientToolKit 这类 C/C++ 库就是为这个场景准备的:把 COM 那层样板代码、连接点、组和项的生命周期全包起来,本地连和远程连走同一套调用,业务代码只管拿点号和取值。它适合做产线数采上位机、边缘网关、必须嵌进已有 C++ 工程又不想引 .NET 运行时的团队;不适合只想跑个脚本抓两个数的人,那点开销不值得。
2. 把库拉进 VS 工程:COM 初始化与 32/64 位这把锁
2.1 它封装的是三层 COM 对象,不是一层
OPC DA 规范里的对象模型就三层:OPCServer 管连接和创建组,OPCGroup 管刷新率、死区和数据变化回调,OPCItem 对应具体的一个标签。每一层在 COM 里都是一组接口——服务器层是IOPCServer、IOPCCommon、IOPCBrowseServerAddressSpace;组层是IOPCGroupStateMgt、IOPCSyncIO、IOPCAsyncIO2,外加一对IConnectionPointContainer;项层主要是IOPCItemMgt。手写的话,把接口按正确顺序QueryInterface下来、再处理好引用计数和每一条异常分支,一个下午基本就没了,还得留出调试时间。
库做的事情就是把这套过程收敛成几个 C++ 类:一个客户端入口类负责整体初始化和创建服务器对象,服务器对象负责枚举、连接、建组,组对象负责读写和回调注册,项对象持有一个服务器句柄。选它的理由很实际:源码在手上,报错能一路追进去看是哪个HRESULT冒出来的;不依赖 .NET 的 Interop 层,直接链ole32、oleaut32就能跑;对老工程友好,VC6 时代留下来的项目也能塞进去编译。
代价也得说清楚。OPC DA 本身是 Windows 专属的 COM 技术,出了 Windows 就跑不了;它也不是 OPC UA,没有加密、证书、跨平台那一层,安全边界完全靠部署环境和 DCOM 配置兜住。所以这份库的定位是“把存量 DA 服务器接出来”,不是“新建一套现代通信架构”。想清楚这一点,后面的选型和配置才不会被带偏。
2.2 工程配置里的四个必改项
代码拉下来直接编译大概率是能过的,塞进自己工程就容易翻车。下面这几项是我每次新建工程都要先改的,改完再谈写业务代码。
# CMakeLists.txt —— 最小可用的链接配置 cmake_minimum_required(VERSION 3.15) project(opc_da_client CXX) set(CMAKE_CXX_STANDARD 11) # 关键点1:OPC DA 服务器绝大多数是 32 位 COM 服务器,进程位数必须对齐 set(CMAKE_GENERATOR_PLATFORM "Win32" CACHE STRING "" FORCE) # 关键点2:库源码按目录加进来,不要图省事去链一个来源不明的静态库 add_subdirectory(third_party/OPCClientToolKit) add_executable(demo main.cpp) target_include_directories(demo PRIVATE third_party/OPCClientToolKit/include) # 关键点3:COM 的两个系统库必须显式链上 target_link_libraries(demo PRIVATE ole32 oleaut32 opcclienttoolkit) # 关键点4:_WIN32_DCOM 宏影响部分 COM 头文件的声明路径 target_compile_definitions(demo PRIVATE _WIN32_DCOM UNICODE _UNICODE)四个关键点逐条说。位数对齐那一条最容易被忽略,Win32不是“随便选的默认值”,是因为 OPC DA 的CLSID注册在 32 位视图里,64 位进程调用CoCreateInstance会直接返回0x80040154,后面第 5 章还会展开。链接ole32和oleaut32是因为CoInitializeEx、CLSIDFromProgID、VARIANT相关符号全在这两个库里,漏一个就是满屏的LNK2019。_WIN32_DCOM这个宏在部分工具链上是CoInitializeEx声明可见的前提,加上不亏。
用 Visual Studio 的工程文件同理,改的地方是:平台选x86,预处理器加_WIN32_DCOM;UNICODE;_UNICODE,链接器附加依赖项加ole32.lib;oleaut32.lib,字符集选 Unicode。这几项都在工程属性里,改一次记下来,下次直接抄。
提示:如果手上的库源码在 x64 下编译报“找不到
IOPCServer定义”之类的错误,先别急着改源码,回到位数这条上,九成是平台选错了。
2.3 最小可运行骨架:从初始化到第一次读值
光编译过不算数,得让它真的读出一个值。下面这段是最小闭环,去掉所有业务逻辑,只留必须的调用顺序。
// main.cpp —— 最小闭环:初始化 → 枚举 → 连接 → 建组 → 加项 → 同步读 → 清理 #include <windows.h> #include <iostream> #include <vector> #include "OPCClient.h" // 头文件名以仓库里实际的文件为准 int main() { // 1) COM 套间初始化。OPC DA 的服务器对象走 STA,别用 COINIT_MULTITHREADED 图快 HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) { std::cerr << "CoInitializeEx failed\n"; return -1; } // 2) 库自身的初始化,内部会建 COM 工厂缓存,整个进程只调一次 COPCClient::init(); // 3) 枚举指定主机的 OPC 服务器 ProgID,空字符串代表本机 std::vector<std::string> serverList; if (!COPCClient::getOPCServers("", serverList)) { std::cerr << "enumerate failed\n"; return -2; } for (size_t i = 0; i < serverList.size(); ++i) std::cout << " [" << i << "] " << serverList[i] << "\n"; // 4) 用 ProgID 创建服务器对象,再发起连接 COPCServer* server = COPCClient::makeOPCServer("Vendor.DemoServer.1", ""); if (server == nullptr || !server->connect()) { std::cerr << "connect failed\n"; return -3; } // 5) 建组:刷新率 1000ms、死区 0、Active 打开 COPCGroup* group = server->addGroup("g1", true, 1000, 0.0f); if (group == nullptr) { std::cerr << "addGroup failed\n"; return -4; } // 6) 加项。ItemID 的格式完全由服务器厂商决定,常见是"通道.设备.标签" std::vector<std::string> itemIDs; itemIDs.push_back("Channel1.Device1.Tag1"); group->addItems(itemIDs); // 7) 同步读,结果以 VARIANT + 质量位 + 时间戳的形式回来 OPCItemState* state = group->readSync(itemIDs, OPC_DS_DEVICE); if (state) { std::cout << "value=" << state->vDataValue << " quality=0x" << std::hex << state->wQuality << std::dec << "\n"; } // 8) 清理顺序不能反:先摘项、再删组、最后断服务器 group->removeItems(itemIDs); server->removeGroup(group); server->disconnect(); COPCClient::stop(); CoUninitialize(); return 0; }调用顺序是有讲究的,不是随便排的。CoInitializeEx放在最前,因为后面每一个 COM 调用都依赖套间;COPCClient::init()在整个进程里只调一次,重复调用在某些分支上会导致内部工厂缓存重复注册,表现出来是第二次连接莫名失败。getOPCServers的空字符串参数就是“本机”,换成主机名或 IP 就是远程,这是后面第 3 章的主题。
addGroup的三个参数分别是组名、是否激活、刷新率(毫秒)、死区(百分比)。激活状态决定这个组会不会收到数据变化回调,同步读不受这个影响。readSync的第二个参数OPC_DS_DEVICE表示直接从设备读,不让服务器返回缓存值——这个参数在第 4 章会详细对比,它是“读到旧值”这类问题的根源之一。
返回值里wQuality是质量位,不是可有可无的附加信息。0xC0是 Good,0x40是 Uncertain,0x00是 Bad。很多工程只取vDataValue不看质量位,等现场发现画面上的数字三天没变才回头查,那时候已经很难判断是网络断了还是设备停了。这段骨架能跑通,说明编译、链接、COM 初始化、位数这四关都过了,剩下的才是业务问题。
3. 本地连与远程连:从枚举服务器到第一个 Item
3.1 本地连接:枚举、创建、连接,三步各有各的坑
本地连接是最省事的场景,但也不是复制粘贴就能跑。三个调用各有各的前提:枚举依赖OPCEnum这个枚举服务在本机注册过,创建依赖目标 ProgID 的CLSID在注册表里,连接依赖服务器程序真的能起来。
// 本地连接的稳健写法:枚举失败不等于连不上,ProgID 可以直接硬编码 std::vector<std::string> list; bool enumerated = COPCClient::getOPCServers("", list); if (!enumerated) { // 枚举服务没装或没起,这是常见情况,不要就此认为环境有问题 std::cout << "enumerate unavailable, fallback to direct ProgID\n"; } // 直接指定 ProgID,绕开枚举服务 COPCServer* server = COPCClient::makeOPCServer("Vendor.DemoServer.1", ""); if (server == nullptr) { /* CLSIDFromProgID 失败,ProgID 拼错或没注册 */ } if (!server->connect()) { /* 服务器进程起不来,去看它的日志或事件查看器 */ }这里有个很典型的误判:把“枚举不到服务器”当成环境坏了,然后开始重装软件、改权限,折腾半天。实际上枚举服务和目标服务器是两套东西,前者只是个便利工具,装不装都不影响你用已知 ProgID 直连。我一般会先用直连验证通路,能连上再回头补枚举,路子清楚很多。
makeOPCServer的第二个参数是主机名,空字符串等价于本机。返回值是nullptr时,问题通常出在CLSIDFromProgID这一步,也就是 ProgID 拼错了或者软件没装全;connect()返回false则是 CLSID 找到了但进程起不来,得去看服务器端自己的日志。把这两个失败点分开,排查效率会高一个量级。
3.2 远程连接:DCOM 不是玄学,是一张配置表
远程连不上是 OPC DA 最经典的求助话题。它的本质不复杂:客户端通过 DCOM 让远端起一个进程,起进程要权限,跨机器传数据要过防火墙,两个账号还得对得上。把这几件事拆成表格,按行勾就行,比凭感觉试快得多。
| 配置项 | 位置 | 建议取值 | 不配的典型后果 |
|---|---|---|---|
| 默认身份验证级别 | 组件服务 → 我的电脑 → 默认属性 | 连接或无 | 0x80070005拒绝访问 |
| 默认模拟级别 | 同上 | 标识 | 匿名访问被拒 |
| 我的电脑访问权限 | 组件服务 → 我的电脑 → COM 安全 | 加入采集账号 | 0x80070005 |
| 启动与激活权限 | 同上 | 加入采集账号 | 0x80070005,服务器起不来 |
| 目标服务器的标识 | 组件服务 → DCOM 配置 → 对应 ProgID | 交互式用户或指定账号 | 激活失败,进程秒退 |
| 防火墙入站规则 | 高级防火墙 | 放行 TCP 135 与动态端口段 | 0x800706BA |
| 两端账号一致性 | 系统账号 | 同用户名同密码 | 时好时坏,最难受 |
| 远端枚举服务 | 目标机安装项 | 装并可远程访问 | 能连上但枚举为空 |
最后一行值得单独说。远程枚举需要目标机上那个枚举服务允许远程访问,允许的方式是在它的 DCOM 配置里改安全设置;如果目标机根本没装,或者安全设置没放开,你会看到“枚举一片空白但用 ProgID 直连能成”的现象。这个现象非常容易被误判成“库有问题”,因为同一份代码在本机枚举是正常的。
账号一致性那条也是麻烦点。两端用户名的字面值相同、密码也相同,DCOM 才认;只是“看起来一样”不够。曾经有一台机器是本地账号,另一台是域账号,名字碰巧一样,结果时通时不通,查了两天才发现是认证方式不同。这种问题没有巧办法,只能统一成同一种账号体系。
还有一个常被忽略的前提:远程连接要求客户端进程有足够的权限去访问网络资源,跑在服务里的采集程序如果用了LocalSystem,在跨机器场景下会以机器账号身份出去,远端根本不认识。常见做法是给服务单独配一个普通账号,别图省事用系统账号。
3.3 建组与加项:UpdateRate、Deadband、ItemID 三个参数决定成败
组和项这两个对象承载了几乎所有业务参数,参数设错不会报错,只会表现为“数据不对”或者“CPU 高”,查起来很费劲。
// 建组与加项:参数取值直接影响回调频率和服务端负载 COPCGroup* group = server->addGroup( "collector", // 组名,同一服务器内唯一 true, // Active:true 才启用数据变化回调 500, // UpdateRate:服务端建议刷新周期,单位毫秒 0.0f // Deadband:死区百分比,0 表示任何变化都上报 ); std::vector<std::string> items; items.push_back("Channel1.Device1.Temp"); items.push_back("Channel1.Device1.Pressure"); group->addItems(items); // 一次性加,别在循环里逐个加 // 建完组之后读一次实际生效的参数,服务端可能按自己的能力调整 DWORD actualRate = 0; group->getGroupState(&actualRate, nullptr, nullptr, nullptr); std::cout << "actual update rate = " << actualRate << " ms\n";UpdateRate是“建议值”不是“保证值”。老服务器可能把 500ms 调成 1000ms,也可能因为点数太多直接拉长到几秒。所以建完组必须回读一次实际值,把日志打出来,否则后面做数据对齐时会一直差一个说不清的偏移量。
Deadband是死区,单位是百分比。设成 0 意味着只要数值有任何变化就上报,点数一多回调会非常密;设成 1.0 表示变化幅度小于量程 1% 就不上报,能显著降低回调频率。这个参数要和上层业务一起定:做趋势分析的可以放宽,做联锁判断的必须设 0,不能一刀切。
ItemID是最容易出错的一环,因为它的格式完全由服务器厂商定义,常见的有通道.设备.标签、设备.标签、纯标签名三种。同一台机器上不同软件格式都不一样,没有任何通用规则可循。我的习惯是先拿一个现成的客户端工具把点表导出来,再按导出的字符串原样填进代码,绝不手敲。手敲的结果往往是少一个点、多一个空格,加项时返回失败但错误信息含糊,最后靠逐字符比对才发现。
addItems要批量调用,不要在for循环里一个一个加。每次调用都是一次跨进程往返,一百个点就是一百次往返,启动时间能差出好几秒。批量加完之后再统一检查哪些项失败了,库一般会把失败项的下标标出来,按失败列表单独处理。
4. 读写的两种姿势:同步 IO 与订阅回调
4.1 同步读:简单,但有明确的天花板
同步读的代码最短,也最好理解——调一次,等结果,返回值就在手上。适合做初始化自检、单点调试、低频轮询这类场景。
// 同步读:一次取回一批点的值、质量位、时间戳 std::vector<std::string> ids; ids.push_back("Channel1.Device1.Temp"); ids.push_back("Channel1.Device1.Pressure"); // OPC_DS_DEVICE 直接读设备,OPC_DS_CACHE 读服务器缓存 OPCItemState* states = group->readSync(ids, OPC_DS_DEVICE); if (states == nullptr) { /* 整批失败,通常是连接已断 */ } for (size_t i = 0; i < ids.size(); ++i) { const OPCItemState& s = states[i]; if (s.wQuality == 0xC0) { // Good std::cout << ids[i] << " = " << s.vDataValue << "\n"; } else { // 质量位不是 Good 时,值可能是上一次的残留,别直接拿去用 std::cout << ids[i] << " bad quality 0x" << std::hex << s.wQuality << std::dec << "\n"; } }OPC_DS_DEVICE和OPC_DS_CACHE的区别是同步读最关键的一个选择。前者要求服务器立刻去设备取一次,数据新鲜但耗时取决于设备响应;后者直接返回服务器内存里最近一次采集的值,快但可能是几百毫秒甚至几秒前的。做报警判断必须用DEVICE,做画面刷新用CACHE就够。见过不少工程全程用CACHE,然后抱怨“数据延迟大”,其实是一开始选错了。
同步读的天花板在于它是串行的。点数上到几百,每次读都要等一次跨进程往返,累积起来会把采集周期撑爆。而且同步读会阻塞调用线程,放在 UI 线程里就是卡界面。它的合理定位是启动自检和低频场景,高频采集应该交给订阅。
4.2 异步订阅:回调不在你的线程里
订阅是 OPC DA 做数据采集的正路:告诉服务器“这些点我关心,有变化就通知我”,然后服务器按自己的节奏推过来。代价是回调用的是 COM 提供的线程,不是你的主线程,这一点没处理好就是各种莫名其妙的崩溃。
// 订阅:继承回调类,重写数据变化处理,再注册到组上 class Collector : public COPCDataCallback { public: void OnDataChange(OPCGROUPHANDLE /*hGroup*/, DWORD /*dwTransid*/, HRESULT /*hr*/, DWORD dwCount, OPCITEMSTATE* pItemStates) override { // 注意:这个函数在 COM 的线程里跑,不要在这里直接碰 UI for (DWORD i = 0; i < dwCount; ++i) { std::lock_guard<std::mutex> lock(m_queueMutex); m_queue.push_back(pItemStates[i]); // 拷贝进线程安全队列 } } private: std::mutex m_queueMutex; std::vector<OPCITEMSTATE> m_queue; }; Collector cb; group->setDataCallback(&cb); // 内部会走连接点,建立回调通道 group->setGroupState(true, 500); // 激活组并设置刷新周期回调里只做一件事:把数据拷贝进队列,然后立刻返回。任何耗时操作——写日志文件、发网络请求、更新界面——都不能放在这里,因为 COM 的调用线程被占住会导致后续回调排队,表现出来就是“数据越跑越慢,最后停住”。
setGroupState的激活标志和刷新周期要一起设。有些实现里组建好了但没激活,回调一次都不来,新手容易误以为是订阅没注册成功。判断标准很简单:注册回调之后打一行日志,如果十秒内没有任何回调,先查激活状态,再查点是否真的在变化(一个恒定的值本来就不会触发变化回调)。
还有一个数据量的问题。OnDataChange一次可能带回很多项,也可能一次只带回一个,取决于服务器实现和变化的时间分布。上层处理逻辑必须按“批量”写,不能假设每次只有一个点。
4.3 写入与写后回读
写入是很多人不敢碰的部分,怕把现场设备写坏。技术上它不复杂,纪律上要求很高。
// 写单个点,然后立刻读回确认 COPCItem* item = group->getItem("Channel1.Device1.SetPoint"); if (item != nullptr) { // 写之前先看当前值和类型,避免把浮点写成整型 OPCItemState* before = group->readSync({"Channel1.Device1.SetPoint"}, OPC_DS_DEVICE); COPCVariant value; value.SetValue(35.5); // 类型必须和点定义一致 bool ok = item->writeSync(value); if (!ok) { /* 写入失败:权限不足、点只读、类型不匹配,三者占多数 */ } // 回读确认,不要相信写入返回值就等于值生效了 OPCItemState* after = group->readSync({"Channel1.Device1.SetPoint"}, OPC_DS_DEVICE); }写入返回值只代表“服务器接受了这次写请求”,不代表设备端值已经变成目标值。中间可能被 PLC 逻辑覆盖、可能被量程限幅、也可能因为设备离线而根本没执行。所以写后回读不是多此一举,是必须的一步,尤其是写设定值这种操作。
类型匹配是写入失败的高频原因。VARIANT的类型标记必须和点定义一致,把VT_R4写成VT_I4服务器会直接拒绝,报错信息通常只是一句笼统的失败。写之前先读一次,看返回的VARIANT是什么类型,照抄就行,这个习惯能省掉大量猜谜时间。
5. 排查:五类报错码与它们背后的配置
5.10x80070005拒绝访问
现象:本机连得好好的,换成远程主机名,connect()立刻失败,日志里是一个干净的0x80070005,没有任何额外信息。
原因:DCOM 的启动与访问权限没放开,或者客户端进程用的账号在远端不存在。这个错误码是权限问题的通用出口,具体是哪一层权限被拒,它不说。
解决:按 3.2 那张表逐行核对,重点看“我的电脑”的访问权限和启动权限里有没有加采集账号,以及目标服务器 ProgID 的 DCOM 标识是不是设成了“交互式用户”。交互式用户的意思是需要有人登录在那台机器上,机器重启后没人登录,服务就起不来——如果现场是无人值守的,这一项必须改成指定账号。改完不用重启整机,重启对应的 DCOM 服务或者干脆重启服务器进程就能生效。
5.20x800706BARPC 服务器不可用
现象:连接请求发出去之后卡住十几秒,然后返回0x800706BA,有时候是间歇性的,重试几次又能连上。
原因:TCP 135 端口没通,或者 DCOM 使用的动态端口段被防火墙拦了。135 只是端点映射,真正的数据传输走的是后续协商出来的动态端口,只放行 135 是不够的。
解决:两条路。一是把动态端口段固定下来——在注册表里限定 RPC 使用的端口范围,然后在防火墙上放行这个范围和 135;二是在内网环境下干脆把两台机器之间的防火墙规则放宽。前者更规范,后者更快,看现场条件选。间歇性通的情况多半是动态端口碰巧落在已放行的区间里,这种情况最容易误判成“网络不稳定”。
5.30x80040154类未注册
现象:代码没改过,昨天能跑,今天编译出来就创建失败,返回0x80040154。
原因:进程位数和目标CLSID的注册视图不匹配。OPC DA 服务器绝大多数是 32 位,注册信息写在 32 位视图里,64 位进程去查就是“类未注册”。另一种可能是工程重新生成时平台从 x86 切成了 x64。
解决:把工程平台改回Win32/x86,重新编译。如果确实需要 64 位进程(比如要对接 64 位的其他库),那就得走进程外代理,或者用一个 32 位的中间进程转发,别指望在同一个进程里解决。这一条我吃过不止一次亏,后来在工程属性里把平台锁死,不允许随手切。
5.4 值读回来了,质量位却是 Bad
现象:数值看着合理,业务逻辑也在跑,但偶尔(或者一直)质量位是0x00,报警系统按质量位判断,于是疯狂误报。
原因:三个可能——设备侧确实没数据(离线、通信故障);读了OPC_DS_CACHE而服务器很久没刷新;时间戳过期,服务器标记为 Bad 但保留了最后一次的值。
解决:先看时间戳,如果时间戳停在几分钟前,说明是缓存陈旧,改成OPC_DS_DEVICE或者检查服务器的采集配置;如果时间戳是新的但质量仍是 Bad,那就是设备侧的问题,跟客户端无关。业务代码里必须把质量位当成一级判断条件,Good才用值,其他情况一律按“无效”处理——只取数值不看质量位的代码,在现场迟早出事。
5.5 跑十几个小时后,调用开始随机失败
现象:程序启动后一切正常,连续跑十几个小时甚至一两天,开始出现随机的 RPC 调用失败,重启进程又能好一阵。
原因:COM 引用计数泄漏,或者回调对象在组销毁前被析构了。前者表现为句柄数缓慢上涨,后者表现为偶发的访问违例。
解决:检查每一处addGroup是否都有对应的removeGroup,每个makeOPCServer是否都有disconnect。回调对象的生命周期必须比组对象长,常见错误是把回调对象定义在某个函数栈上,函数返回后组还在往一个已经销毁的对象上回调。把回调对象提升成成员变量或者全局单例,这个问题基本就消失了。另外,长时间运行的程序建议加一个定时的心跳读——每隔几分钟读一个固定点,既能验证连接还活着,也能让 DCOM 的连接不被中间设备回收。
6. 上线前的三个动作:批量、重连、对照验证
采集程序写完能跑,和能上线,中间隔着一段距离。三个动作建议在交付前固定走一遍。
第一个是批量读的句柄缓存。getItem这类按名字取项的操作每次都要在服务器端做一次查找,点数多了很慢。做法是在初始化阶段把所有项句柄一次性取出来存进std::map,后续读写全部用缓存句柄,只在重连之后重建缓存。
// 初始化阶段建缓存,重连后重建,业务路径不再做字符查找 std::map<std::string, COPCItem*> m_itemCache; void buildCache(COPCGroup* group, const std::vector<std::string>& ids) { m_itemCache.clear(); for (const auto& id : ids) { COPCItem* item = group->getItem(id); if (item) m_itemCache[id] = item; } }第二个是重连状态机。不要在网络异常时无限重试,也不要一失败就退出。我的做法是三级退避:1 秒、5 秒、30 秒,连续失败超过十次就发一条告警但不退出进程。重连成功后必须做三件事——重建组和项、重建句柄缓存、把断线期间积压的告警标记为“数据缺失”而不是“值为 0”。最后这一点最容易漏,断线期间补 0 会让历史曲线出现一段假的归零。
第三个是对照验证。上线前拿一个成熟的客户端工具连同一个服务器、读同一批点,和自己的程序并排跑一小时,逐点比对数值、质量位和更新频率。差异超过一个刷新周期的点,逐个查是参数设错还是服务器本身就这样。这一步花的时间不多,但能挡掉绝大多数“上线后才发现”的问题。
最后说个教训。早年做第一个远程采集项目,本地调通就直接上了现场,结果远程一直0x80070005,我在现场耗了一整天,从权限查到账号查到防火墙,最后发现是服务器那台机器重启后没人登录,DCOM 标识还是“交互式用户”,服务压根没起来。从那以后我每次远程部署,都强制走一遍“断网重连、重启后无人登录、连续跑 24 小时”这三个场景,一样不落。希望帮到你。
本文还有配套的精品资源,点击获取