基于第三方开源库的OPC服务器开发指南(3)——OPC客户端
本篇将讲解如何编写一个OPC客户端程序测试我们在前文《基于第三方开源库的OPC服务器开发指南(2)——LightOPC的编译及部署》一篇建立的服务器。本指南的目的是熟悉OPC服务器的开发流程,所以客户端部分我就不做过多描述,只是简单讲解几个关键技术细节及其实现函数,完整工程源码请从如下地址获取:
https://github.com/Neo-T/OPCDASrvBasedOnLightOPC
OPC客户端的编写流程与涉及的技术细节跟本指南第一篇博文给出的DCOM客户端本质上没有什么不同,同样是模拟登录远程机器、获取操作接口然后调用就可以了。相较于普通的DCOM客户端,OPC客户端还需要枚举并读取远程机器上已经注册的OPC服务器的CLSID,我们需要根据这个CLSID来指定要使用远程机器上哪一个OPC服务器提供的远程操作接口。首先是如何枚举远程机器上所有已注册OPC服务器以及如何读取指定服务器的CLSID,有两种方法实现这个操作:
1、访问远程机器上的注册表,直接读取指定OPC服务器的CLSID;
2、使用OPC组织提供的OPCEnum服务,枚举所有已注册OPC服务并读取指定服务器的CLSID;
第一种方法不需要下载OPC组织提供的——目前看个人开发者已经无法通过OPC基金会网站免费获得的——“opc core components”支持包,只需要目标机器开通“RemoteRegistry”服务,同时我们拥有访问注册表的权限即可。可以说这种方式是最对我胃口的,不受别人限制。该方法的实现函数如下:
1 //* 获取指定名称的OPC服务器的CLSID 2 static INT __GetRemoteOPCSrvCLSIDByRegistry(CHAR *pszIPAddr, CHAR *pszUserName, CHAR *pszPassword, CHAR *pszOPCSrvProgID, CHAR *pszOPCSrvCLSID) 3 { 4 INT nRtnVal = 0; 5 6 //* 登录远程计算机 7 HANDLE hToken; 8 if (!LogonUser(pszUserName, pszIPAddr, pszPassword, LOGON32_LOGON_NEW_CREDENTIALS, LOGON32_PROVIDER_DEFAULT, &hToken)) 9 { 10 printf("无法连接IP地址为%s的计算机,错误码:%d", pszIPAddr, GetLastError()); 11 return -1; 12 } 13 14 //* 模拟当前登录的用户 15 ImpersonateLoggedOnUser(hToken); 16 { 17 do { 18 CHAR szKey[MAX_PATH + 1]; 19 DWORD dwLen = MAX_PATH; 20 DWORD dwIdx = 0; 21 CHAR szCLSID[100]; 22 LONG lSize; 23 HKEY hKey = HKEY_CLASSES_ROOT; 24 DWORD dwRtnVal = RegConnectRegistry(pszIPAddr, HKEY_CLASSES_ROOT, &hKey); 25 if (dwRtnVal != ERROR_SUCCESS) 26 { 27 printf("无法连接IP地址为%s的计算机,错误码:%d", pszIPAddr, dwRtnVal); 28 nRtnVal = -2; 29 break; 30 } 31 32 printf("成功连接IP地址为%s的计算机,开始枚举该计算机系统上的注册表...\r\n", pszIPAddr); 33 34 //* 读取指定键值 35 if (RegEnumKey(hKey, dwIdx, szKey, dwLen) == ERROR_SUCCESS) 36 { 37 HKEY hSubKey; 38 39 //* 打开指定名称的OPC服务器所在的键,在这里就是"OPC.LightOPC-exe" 40 sprintf(szKey, pszOPCSrvProgID); 41 42 //* 打开指定键值并取值 43 if (RegOpenKey(hKey, szKey, &hSubKey) == ERROR_SUCCESS) 44 { 45 memset(szCLSID, 0, sizeof(szCLSID)); 46 lSize = sizeof(szCLSID) - 1; 47 if (RegQueryValue(hSubKey, "CLSID", szCLSID, &lSize) == ERROR_SUCCESS) 48 { 49 if (RegQueryValue(hSubKey, "OPC", NULL, NULL) == ERROR_SUCCESS) 50 { 51 sprintf(pszOPCSrvCLSID, "%s", szCLSID); 52 printf("『%s』服务已找到:%s\r\n", pszOPCSrvProgID, pszOPCSrvCLSID); 53 } 54 else 55 { 56 printf("查询OPC键失败,错误码:%d\r\n", GetLastError()); 57 nRtnVal = -6; 58 } 59 } 60 else 61 { 62 printf("查询CLSID键失败,错误码:%d\r\n", GetLastError()); 63 nRtnVal = -5; 64 } 65 66 RegCloseKey(hSubKey); 67 } 68 else 69 { 70 printf("RegOpenKey()函数执行失败,错误码:%d\r\n", GetLastError()); 71 nRtnVal = -4; 72 } 73 } 74 else 75 { 76 printf("RegEnumKey()函数执行失败,错误码:%d\r\n", GetLastError()); 77 nRtnVal = -3; 78 } 79 80 } while (FALSE); 81 } 82 RevertToSelf(); //* 结束模拟 83 84 return nRtnVal; 85 }
这个函数对注册表的操作没什么可说的,使用的是标准API,重点是如何获取远程注册表的访问权限。在这里我们依然使用了模拟用户登录技术,利用远程机器为我们分配的某个具有注册表访问权限的用户,通过调用LogonUser()函数获取该用户成功登录后的访问令牌,通过这个令牌获取对注册表的访问权限,这才是这个函数的得以正常执行的关键。我们可以在main()函数中输入如下代码测试一下这个函数:
1 int main(int argc, CHAR* argv[]) 2 { 3 CHAR szCLSID[100]; 4 5 __GetRemoteOPCSrvCLSIDByRegistry(argv[1], argv[2], argv[3], argv[4], szCLSID); 6 7 return 0; 8 }
打开控制台输入测试指令,顺利的话我们会如愿得到LightOPC样例服务器的CLSID:
控制台输入的参数依次为:远程机器的IP地址、登录账户、密码以及样例服务器的ProgID。
需要注意的一点是,如果你的程序不能正常访问远程机器,请按顺序确定以下内容无误:
1、确保lanmanserver,也就是名称为“Server”的服务已经启动,如果你的远程机器是“Server 2008 R2”,且在服务管理器中没找到“Server”服务,请在我提供的源码工程“opc_core_components”目录下找到“lanmanServer.reg”文件,直接导入你的原机器即可解决“Server”服务不存在的问题;
2、确保Workstation服务启动;
3、确保RemoteRegistry服务启动;
4、确保Remote Procedure Call (RPC)服务启动;
5、确保TCP/IP NetBIOS Helper服务启动;
6、确保网卡属性中Microsoft网络的文件和打印机共享服务已勾选;
使用第二种方法利用OPCEnum服务获取CLSID的实现函数如下:
1 static INT __GetRemoteOPCSrvCLSIDByOPCEnum(CHAR *pszIPAddr, CHAR *pszUserName, CHAR *pszPassword, CHAR *pszOPCSrvProgID, CHAR *pszOPCSrvCLSID) 2 { 3 HRESULT hr; 4 INT nRtnVal = 0; 5 6 hr = CoInitializeEx(NULL, COINIT_MULTITHREADED); 7 if (!SUCCEEDED(hr)) 8 { 9 printf("CoInitializeEx()初始化失败:0x%08X\r\n", hr); 10 return -1; 11 } 12 13 do { 14 COSERVERINFO stCoServerInfo; 15 COAUTHINFO stCoAuthInfo; 16 COAUTHIDENTITY stCoAuthID; 17 INT nSize = strlen(pszIPAddr) * sizeof(WCHAR); 18 memset(&stCoServerInfo, 0, sizeof(stCoServerInfo)); 19 stCoServerInfo.pwszName = (WCHAR *)CoTaskMemAlloc(nSize * sizeof(WCHAR)); 20 if (!stCoServerInfo.pwszName) 21 { 22 printf("CoTaskMemAlloc()函数执行失败!\r\n"); 23 nRtnVal = -2; 24 break; 25 } 26 27 ZeroMemory(&stCoAuthID, sizeof(COAUTHIDENTITY)); 28 stCoAuthID.User = reinterpret_cast<USHORT *>(pszUserName); 29 stCoAuthID.UserLength = strlen(pszUserName); 30 stCoAuthID.Domain = reinterpret_cast<USHORT *>(""); 31 stCoAuthID.DomainLength = 0; 32 stCoAuthID.Password = reinterpret_cast<USHORT *>(pszPassword); 33 stCoAuthID.PasswordLength = strlen(pszPassword); 34 stCoAuthID.Flags = SEC_WINNT_AUTH_IDENTITY_ANSI; 35 36 ZeroMemory(&stCoAuthInfo, sizeof(COAUTHINFO)); 37 stCoAuthInfo.dwAuthnSvc = RPC_C_AUTHN_WINNT; 38 stCoAuthInfo.dwAuthzSvc = RPC_C_AUTHZ_NONE; 39 stCoAuthInfo.pwszServerPrincName = NULL; 40 stCoAuthInfo.dwAuthnLevel = RPC_C_AUTHN_LEVEL_CONNECT; 41 stCoAuthInfo.dwImpersonationLevel = RPC_C_IMP_LEVEL_IMPERSONATE; //* 必须是模拟登陆 42 stCoAuthInfo.pAuthIdentityData = &stCoAuthID; 43 stCoAuthInfo.dwCapabilities = EOAC_NONE; 44 45 mbstowcs(stCoServerInfo.pwszName, pszIPAddr, nSize); 46 stCoServerInfo.pAuthInfo = &stCoAuthInfo; 47 stCoServerInfo.dwReserved1 = 0; 48 stCoServerInfo.dwReserved2 = 0; 49 50 MULTI_QI stMultiQI; 51 ZeroMemory(&stMultiQI, sizeof(stMultiQI)); 52 stMultiQI.pIID = &IID_IOPCServerList; //* 参见opccomn_i.c 53 stMultiQI.pItf = NULL; 54 55 //* 初始化安全结构,模拟登录远程机器 56 hr = CoInitializeSecurity(NULL, -1, NULL, NULL, RPC_C_AUTHN_LEVEL_CONNECT, RPC_C_IMP_LEVEL_IMPERSONATE, NULL, EOAC_NONE, NULL); 57 if (!(SUCCEEDED(hr) || RPC_E_TOO_LATE == hr)) 58 { 59 printf("CoInitializeSecurity()函数执行失败,错误码:0x%08X\r\n", hr); 60 nRtnVal = -3; 61 break; 62 } 63 64 hr = CoCreateInstanceEx(CLSID_OpcServerList, 65 NULL, 66 CLSCTX_REMOTE_SERVER, //* 显式的指定要连接远程机器 67 &stCoServerInfo, 68 sizeof(stMultiQI) / sizeof(MULTI_QI), 69 &stMultiQI); 70 71 //* 无论成功与否,先释放刚才申请的内存 72 CoTaskMemFree(stCoServerInfo.pwszName); 73 74 //* 如果CoCreateInstanceEx()执行失败 75 if (FAILED(hr)) 76 { 77 printf("CoCreateInstanceEx()函数执行失败,错误码:0x%08X %s %s\r\n", hr, pszIPAddr, pszUserName); 78 nRtnVal = -4; 79 break; 80 } 81 82 //* 如果没有获取到DCOM组件的查询接 83 if (FAILED(stMultiQI.hr)) 84 { 85 printf("获取组件的查询接口失败,错误码:0x%08X\r\n", stMultiQI.hr); 86 nRtnVal = -5; 87 break; 88 } 89 90 //* 读取所有已注册的OPC服务器 91 CComPtr<IOPCServerList> pobjOPCSrvList = (IOPCServerList *)stMultiQI.pItf; 92 IEnumGUID *pobjEnumGUID = NULL; 93 CLSID stCLSID; 94 DWORD dwCeltFetchedNum; 95 LPOLESTR wszProgID, wszUserType; 96 CLSID stCatID = CATID_OPCDAServer20; 97 hr = pobjOPCSrvList->EnumClassesOfCategories(1, &stCatID, 1, &stCatID, &pobjEnumGUID); 98 if (FAILED(hr)) 99 { 100 printf("EnumClassesOfCategories()函数执行失败,错误码:0x%08X\r\n", hr); 101 nRtnVal = -6; 102 break; 103 } 104 105 //* 开始枚举服务器并获取指定ProgID的CLSID 106 while (SUCCEEDED(pobjEnumGUID->Next(1, &stCLSID, &dwCeltFetchedNum))) 107 { 108 if (!dwCeltFetchedNum) 109 break; 110 hr = pobjOPCSrvList->GetClassDetails(stCLSID, &wszProgID, &wszUserType); 111 if (FAILED(hr)) 112 { 113 printf("GetClassDetails()函数执行失败,错误码:0x%08X\r\n", hr); 114 nRtnVal = -7; 115 break; 116 } 117 118 CHAR szProgID[100]; 119 CString cstrProgID = wszProgID; 120 sprintf(szProgID, "%s", cstrProgID); 121 122 if(!strcmp(pszOPCSrvProgID, szProgID)) 123 { 124 BSTR wszCLSID; 125 StringFromCLSID(stCLSID, &wszCLSID); 126 CString cstrCLSID = wszCLSID; 127 128 sprintf(pszOPCSrvCLSID, "%s", cstrCLSID); 129 printf("『%s』服务已找到:%s\r\n", pszOPCSrvProgID, pszOPCSrvCLSID); 130 131 //* 释放占用的内存 132 CoTaskMemFree(wszProgID); 133 CoTaskMemFree(wszUserType); 134 135 break; 136 } 137 138 //* 释放占用的内存 139 CoTaskMemFree(wszProgID); 140 CoTaskMemFree(wszUserType); 141 } 142 } while (FALSE); 143 144 145 CoUninitialize(); 146 147 return nRtnVal; 148 }
这个函数处理流程与DCOM客户端基本相同,不多说了,重点是如何使用这个函数?首先我们必须下载“opc core components”支持包,64位的机器下载x64版本,32位的机器下载x86版本,可问题是在哪里下载呢?前面我不止一次说过,OPC基金会关闭了普通用户的下载通道,我们在基金会网站是找不到下载地址的。像CSDN、pudn之类的同样铜臭味十足的GoPi网站提供下载,可是要积分啊,我记得CSDN上有个64位版本支持包的下载链接竟然丧心病狂的要44积分,太WuChi了。还是要感谢github,感谢无私奉献的大神们,请去这个地址下载两个版本的支持包,顺便给该资源的主人点个星:
https://github.com/jmbeach/chocolatey-OpcClassicCoreComponents/tree/master/tools
下载下来后,远程机器和本地都要安装,远程机器安装完就不用管它了。本地安装完毕后,需要在安装路径下找到OPCEnum.h和OpcEnum_i.c两个文件将其添加到客户端工程中,同时把OPCEnum.h文件#include进来,否则会编译失败。然后修改main()函数:
1 int main(int argc, CHAR* argv[]) 2 { 3 CHAR szCLSID[100]; 4 5 __GetRemoteOPCSrvCLSIDByOPCEnum(argv[1], argv[2], argv[3], argv[4], szCLSID); 6 //__GetRemoteOPCSrvCLSIDByRegistry(argv[1], argv[2], argv[3], argv[4], szCLSID); 7 8 return 0; 9 }
控制台输入指令测试该函数,结果如下:
抛开与OPC业务有关的逻辑不说,以上两个函数针对远程OPC服务器的访问提供了一种更安全的访问方法,而不像相当一部分公开资料所描述的那样把服务器权限降低到任何人都可以访问的令人发指的地步。
OPC客户端的主业务逻辑可以参见源码的main()函数,以此按图索骥理解整个处理流程:
1 int main(int argc, CHAR* argv[]) 2 { 3 CHAR szCLSID[100]; 4 5 if (argc != 5) 6 { 7 printf("Usage:%s opcserver_ip username password OpcProgID\r\n", argv[0]); 8 return -1; 9 } 10 11 //* 设定该程序捕获控制台CTRL+C输入,以使程序能够正常退出 12 SetConsoleCtrlHandler((PHANDLER_ROUTINE)ConsoleHandler, TRUE); 13 14 //__GetRemoteOPCSrvCLSIDByOPCEnum(argv[1], argv[2], argv[3], argv[4], szCLSID); 15 if (__GetRemoteOPCSrvCLSIDByRegistry(argv[1], argv[2], argv[3], argv[4], szCLSID)) 16 { 17 printf("__GetRemoteOPCSrvCLSIDByRegistry()函数执行失败,进程退出!\r\n"); 18 return -2; 19 } 20 21 //* 连接成功则进行后续操作 22 if (!__ConnOPCServer(argv[1], argv[2], argv[3], szCLSID)) 23 { 24 do { 25 if (__AddDefaultGroup()) 26 { 27 printf("__AddDefaultGroup()函数执行失败,进程退出!\r\n"); 28 break; 29 } 30 31 //* 手动添加要操作的变量 32 //* ======================================================= 33 ST_OPC_ITEM staItem[2]; 34 sprintf(staItem[0].szItemName, "lulu"); 35 staItem[0].vtDataType = VT_I2; 36 if (__AddItemToLocalMgmtIf(staItem[0].szItemName, staItem[0].vtDataType, &staItem[0].ohItemSrv)) //* exe_samp工程之sample.cpp文件第573行“lulu”变量为VT_I2类型 37 { 38 printf("__AddItemToLocalMgmtIf()函数添加变量[lulu]执行失败,进程退出!\r\n"); 39 break; 40 } 41 42 sprintf(staItem[1].szItemName, "zuzu"); 43 staItem[1].vtDataType = VT_R8; 44 if (__AddItemToLocalMgmtIf(staItem[1].szItemName, staItem[1].vtDataType, &staItem[1].ohItemSrv)) //* exe_samp工程之sample.cpp文件第564行“zuzu”变量为VT_R8类型 45 { 46 printf("__AddItemToLocalMgmtIf()函数添加变量[zuzu]执行失败,进程退出!\r\n"); 47 break; 48 } 49 //* ======================================================= 50 51 //* 隐藏控制台光标 52 ShowConsoleCursor(FALSE); 53 54 //* 读取控制台当前光标位置,以便循环读取时固定输出位置,而不是整屏滚动输出 55 SHORT x, y; 56 GetConsoleCursorPosition(&x, &y); 57 58 time_t tPrevWriteTime = time(NULL); 59 ULONG ulWriteVal = 2009; 60 61 blIsRunning = TRUE; 62 while (blIsRunning) 63 { 64 //* 每次读取均设定在控制台同一输出位置 65 SetConsoleCursorPosition(x, y); 66 67 if (__ReadItem(staItem, sizeof(staItem) / sizeof(ST_OPC_ITEM))) 68 break; 69 70 Sleep(100); 71 72 if (time(NULL) - tPrevWriteTime > 1) 73 { 74 if (__WriteItem(&staItem[0], ulWriteVal++)) 75 break; 76 77 tPrevWriteTime = time(NULL); 78 } 79 } 80 81 //* 恢复控制台光标 82 ShowConsoleCursor(TRUE); 83 84 } while (FALSE); 85 86 //* 断开连接 87 __DisconnectOPCServer(); 88 } 89 else 90 { 91 printf("__ConnOPCServer()函数执行失败,进程退出!\r\n"); 92 return -3; 93 } 94 95 return 0; 96 } 97
整个流程很简单:获取CLSID,利用这个CLSID连接服务器,连接成功后调用__AddDefaultGroup()函数添加一个缺省组,添加成功则就获得了对OPC服务器指定变量进行管理、读、写等操作的具体操作接口,接着通过__AddItemToLocalMgmtIf()函数把我们要操作的样例服务器提供的两个变量添加到OPC的本地变量管理接口,然后就可以进入主循环进行读取操作了。主循环以100毫秒间隔读取“zuzu”变量的值,以1秒间隔写“lulu”变量的值。其实OPC样例服务器提供了多个变量,具体列表参见“sample.cpp”文件的第278-282行:
1 …… …… 2 3 /* Our data tags: */ 4 /* zero is resierved for an invalid RealTag */ 5 #define TI_zuzu (1) 6 #define TI_lulu (2) 7 #define TI_bandwidth (3) 8 #define TI_array (4) 9 #define TI_enum (5) 10 #define TI_quiet (6) 11 #define TI_quality (7) 12 #define TI_string (8) 13 #define TI_MAX (8) 14 15 static loTagId ti[TI_MAX + 1]; /* their IDs */ 16 static const char *tn[TI_MAX + 1] = /* their names */ 17 { "--not--used--", "zuzu", "lulu", "bandwidth", "array", "enum-localizable", 18 "quiet", "quality", "string" }; 19 static loTagValue tv[TI_MAX + 1]; /* their values */ 20 21 …… ……
这些变量的数据类型参见driver_init()函数:
1 int driver_init(int lflags) 2 { 3 …… …… 4 5 /* We needn't to VariantClear() for simple datatypes like numbers */ 6 V_R8(&var) = 214.1; /* initial value. Will be used to check types conersions */ 7 V_VT(&var) = VT_R8; //* 变量“zuzu”的数据类型 8 ecode = loAddRealTag_a(my_service, /* actual service context */ 9 &ti[TI_zuzu], /* returned TagId */ 10 (loRealTag)TI_zuzu, /* != 0 driver's key */ 11 tn[TI_zuzu], /* tag name */ 12 0, /* loTF_ Flags */ 13 OPC_READABLE | OPC_WRITEABLE, &var, 12., 1200.); 14 UL_TRACE((LOGID, "%!e loAddRealTag_a(zuzu) = %u ", ecode, ti[TI_zuzu])); 15 16 V_I2(&var) = 1000; 17 V_VT(&var) = VT_I2; //* 变量“lulu”的数据类型 18 ecode = loAddRealTag(my_service, /* actual service context */ 19 &ti[TI_lulu], /* returned TagId */ 20 (loRealTag) TI_lulu, /* != 0 driver's key */ 21 tn[TI_lulu], /* tag name */ 22 0, /* loTF_ Flags */ 23 OPC_READABLE | OPC_WRITEABLE, &var, 0, 0); 24 UL_TRACE((LOGID, "%!e loAddRealTag(lulu) = %u ", ecode, ti[TI_lulu])); 25 26 27 …… …… 28 }
进行客户端测试之前我们还需要对“sample.cpp”文件的代码做些调整,否则无法测试写操作。共有两个地方需要调整,其一,“simulate()”函数找到如下几句:
1 void simulate(unsigned pause) 2 { 3 …… …… 4 5 double zuzu = 6 (V_R8(&tv[TI_zuzu].tvValue) += 1./3.); /* main simulation */ 7 V_VT(&tv[TI_zuzu].tvValue) = VT_R8; 8 tv[TI_zuzu].tvState.tsTime = ft; 9 10 V_I2(&tv[TI_lulu].tvValue) = (short)zuzu; 11 V_VT(&tv[TI_lulu].tvValue) = VT_I2; 12 tv[TI_lulu].tvState.tsTime = ft; 13 14 V_I2(&tv[TI_enum].tvValue) = (short)((ft.dwLowDateTime >> 22) % 7); 15 V_VT(&tv[TI_enum].tvValue) = VT_I2; 16 tv[TI_enum].tvState.tsTime = ft; 17 18 …… …… 19 }
红色语句全部注释掉,不让OPC服务器更新“lulu”变量。然后是“WriteTags()”函数,增加如下几句:
1 int WriteTags(const loCaller *ca, 2 unsigned count, loTagPair taglist[], 3 VARIANT values[], HRESULT error[], HRESULT *master, LCID lcid) 4 { 5 …… …… 6 7 case TI_lulu: 8 hr = VariantChangeType(&tv[TI_lulu].tvValue, &values[ii], 0, VT_I2); 9 if (S_OK == hr) 10 { 11 lo_statperiod(V_I2(&tv[TI_lulu].tvValue)); /* VERY OPTIONAL, really */ 12 13 FILETIME ft;
14 GetSystemTimeAsFileTime(&ft); /* awoke */
15 tv[TI_lulu].tvState.tsTime = ft;
16 } 17 18 …… …… 19 }
红色部分为要增加的语句,这几条语句的作用是当客户端写入新的“lulu”变量值时同步更新该变量的时间戳。重新编译修改后的样例服务器,并覆盖远程机器上的旧文件,然后我们就可以启动客户端看看效果了:
最后,需要注意的一点是,OPC服务器所在的机器必须已经登录,否则OPC客户端是无法连接的,会报0x8000401A错误。这一点与普通的DCOM不同,普通的DCOM客户端无须DCOM组件所在的服务器登录即可正常使用。
至此,OPC系统的完整开发流程梳理完毕。本指南的最后一篇——《基于第三方开源库的OPC服务器开发指南(4)——后记:与另一个开源库opc workshop库相关的问题》将推荐另一个更加简单的开源库opc workshop。