OPCUA探讨(三)——客户端代码解读
本系列文章:
OPCUA 探讨(一)——测试与开发环境搭建
OPCUA 探讨(二)——服务器节点初探
OPCUA 探讨(三)——客户端代码解读
OPCUA 探讨(四)——客户端代码解读2
本文开始讨论OPCUA客户端源码的构造。
项目地址:https://gitee.com/zuoquangong/opcuaapi
一、项目结构说明
我们在Visual Studio2022中打开项目文件(.sln),查看“解决方案资源管理器”:
该客户端核心功能在OpcUaAPI.cs。
上述结构与我们的使用流程相对应:
下面我们逐个步骤进行讨论。
注意,这里我们使用了OPC基金会提供的第三方包(NuGet管理器中可查看具体信息):
点击查看代码
using Opc.Ua.Configuration; using Utils=Opc.Ua.Utils; using Opc.Ua;
二、应用全局配置
2.1 应用实例(Application Instance)
OPCUA服务器和OPCUA客户端都称作OPCUA应用(OPCUA Application)。我们的客户端软件相当于是客户端的一个实例。
实例化一个Opc.Ua.Configuration.ApplicationInstance
类对象m_appInstance
,可以使用该对象为我们的客户端配置应用参数。
m_appInstance
有一个成员ApplicationConfiguration
,其内部包含了各种应用参数。
点击查看代码
/// <summary> /// 通过应用实例ApplicationInstance创建应用配置 /// </summary> public async void buildConfig() { string clientName = "myApp"; //客户端应用名称 // 应用实例 m_appInstance = new ApplicationInstance() { ApplicationType = ApplicationType.Client, //定义应用类型。此处定义为客户端,也可以定义成服务器等 ApplicationName = clientName, }; Assert.NotNull(m_appInstance); // 判定内存分配成功;如果不成功。。。 m_appInstance.ApplicationConfiguration= new Opc.Ua.ApplicationConfiguration(); //进行应用配置 CreateConfig(); //配置证书验证过程 certificateValidator = new CertificateValidator(); m_appInstance.ApplicationConfiguration.CertificateValidator = certificateValidator; certificateValidator.CertificateValidation += certClient; //设置 证书验证过程 处理函数 return; }
2.2 应用配置(Application Configuration)
在buildConfig
函数中我们调用了CreateConfig
函数,对m_appInstance.ApplicationConfiguration
进行了详细设置。
点击查看代码
private void CreateClientConfiguration() { // 应用程序配置可以从任何文件加载。 // ApplicationConfiguration.Load()方法通过在App.config中查找文件路径来加载配置。 // 这种方法允许应用程序共享配置文件并对其进行更新。 // 此示例使用其默认构造函数创建最小ApplicationConfiguration。 Opc.Ua.ApplicationConfiguration configuration = m_appInstance.ApplicationConfiguration; //地址赋值,两个变量指向同一个存储区。对configuration的设置等价于对m_appInstance.ApplicationConfiguration设置 // Step 1 - 指定客户端标识. configuration.ApplicationName = m_appInstance.ApplicationName; configuration.ApplicationType = m_appInstance.ApplicationType; configuration.ApplicationUri = "urn:MyClient"; configuration.ProductUri = "myApp1.0"; // Step 2 - 进行安全配置,并指定客户端的应用程序实例证书。 configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true; configuration.SecurityConfiguration.RejectSHA1SignedCertificates = false; // 应用程序实例证书必须放在windows证书存储中,因为这是保护私钥的最佳方式。存储中的证书由4个参数标识: configuration.SecurityConfiguration = new SecurityConfiguration(); configuration.SecurityConfiguration.ApplicationCertificate = new CertificateIdentifier(); configuration.SecurityConfiguration.ApplicationCertificate.StoreType = CertificateStoreType.X509Store; configuration.SecurityConfiguration.ApplicationCertificate.StorePath = "CurrentUser\\My"; configuration.SecurityConfiguration.ApplicationCertificate.SubjectName = configuration.ApplicationName; // 为服务器证书检查定义受信任的根存储 configuration.SecurityConfiguration.TrustedIssuerCertificates.StoreType = CertificateStoreType.X509Store; configuration.SecurityConfiguration.TrustedIssuerCertificates.StorePath = "CurrentUser\\Root"; configuration.SecurityConfiguration.TrustedPeerCertificates.StoreType = CertificateStoreType.X509Store; configuration.SecurityConfiguration.TrustedPeerCertificates.StorePath = "CurrentUser\\Root"; // 在存储中查找客户端证书 Task<X509Certificate2> clientCertificate = configuration.SecurityConfiguration.ApplicationCertificate.Find(true); // 如果找不到,请创建一个新的自签名证书 if (clientCertificate.Result == null) { CreateCertificateAndAddToStore(configuration.ApplicationUri, configuration.ApplicationName, configuration.SecurityConfiguration.ApplicationCertificate.StoreType, configuration.SecurityConfiguration.ApplicationCertificate.StorePath); } // Step 3 - 指定支持的传输配额 // 传输配额用于设置对消息内容的限制,并用于防止DOS攻击和流氓客户端。它们应该设置为合理的值。 configuration.TransportQuotas = new TransportQuotas(); configuration.TransportQuotas.OperationTimeout = 360000; configuration.TransportQuotas.SecurityTokenLifetime = 86400000; configuration.TransportQuotas.MaxStringLength = 67108864; configuration.TransportQuotas.MaxByteStringLength = 16777216; //Needed, i.e. for large TypeDictionarys // Step 4 - 指定客户端特定的配置 configuration.ClientConfiguration = new ClientConfiguration(); configuration.ClientConfiguration.DefaultSessionTimeout = 360000; // Step 5 - 验证配置 // 此步骤检查配置是否一致,并分配SDK使用的一些内部变量。如果使用ApplicationConfiguration.Load()方法从文件加载配置,则会自动调用此函数。 _ = configuration.Validate(ApplicationType.Client); return; }
左边四个参数类似本应用的身份ID,当前可以任意设置。
右边的客户端配置(ClientConfiguration
)配置了一个DefalutSessionTimeOut=360000
,意为默认情况下,超时360000ms
(6分钟)无回应则会话自动断开。
安全配置(SecurityConfiguration
)配置内容如下:
- 如果需要较高安全性,建议不要自动接收不信任证书,设置
AutoAcceptUntrustedCertificates = False
; - 之所以有
RejectSHA1SignedCertificates
这个参数,是因为SHA1算法安全性不高,如果在安全上有较高要求,建议设置RejectSHA1SignedCertificates = True
; - 通常公钥/私钥长度越长,越难暴力破解,安全性越高,因此
MinimumCertificateKeySize
可以设置为较大的数,但同时加密、解密时间会变长; - 最重要的是我们要通过设置
ApplicationCertification
确保我们的客户端有证书可用(没有则自动创建自签名证书),这个证书用于服务器确认通信者的身份,是建立安全通道的前提。
这里简单说明下,在OPCUA里,服务器与客户端建立信息安全通道有三种安全模式(Security Mode):
None
,无安全策略,不检验对方通信者的身份,也不对通信内容进行加密,安全性为零,仅用于测试,实际情况不要使用;
Sign
,仅签名模式,通过证书签名验证对方通信者身份,但通信内容不加密,签名可以保证信息完整未经篡改;
SignAndEncrypt
,签名且信息内容加密,最安全的模式。
我们这里配置的应用证书是实现Sign
和SignAndEncrypt
安全模式的基础。
通常使用自签名证书作为我们的客户端应用证书,以下为证书生成代码:
点击查看代码
/// <summary> /// 创建一个新的自签名证书并存储,用于建立安全数据通道过程中的身份验证 /// </summary> /// <param name="applicationUri">应用ID</param> /// <param name="applicationName">应用名称</param> /// <param name="storeType">存储类型</param> /// <param name="storePath">存储路径</param> private void CreateCertificateAndAddToStore(string applicationUri, string applicationName, string storeType, string storePath) { List<string> localIps = GetLocalIpAddressAndDns(); // Get local interface ip addresses and DNS name ushort keySize = 2048; //must be multiples of 1024 ushort lifeTimeInMonths = 24; //month till certificate expires ushort hashSizeInBits = 256; //0 = SHA1; 1 = SHA256 var startTime = System.DateTime.Now; //starting point of time when certificate is valid var certificateBuilder = CertificateFactory.CreateCertificate( applicationUri, applicationName, null, localIps); X509Certificate2 clientCertificate2 = certificateBuilder .SetNotBefore(startTime) .SetNotAfter(startTime.AddMonths(lifeTimeInMonths)) .SetHashAlgorithm(X509Utils.GetRSAHashAlgorithmName(hashSizeInBits)) .SetRSAKeySize(keySize) .CreateForRSA(); clientCertificate2.FriendlyName = m_appInstance.ApplicationName; clientCertificate2.AddToStore( storeType, storePath, null ); } /// <summary> /// 获取本地IP地址,用于创建证书 /// </summary> /// <returns></returns> /// <exception cref="Exception"></exception> private List<string> GetLocalIpAddressAndDns() { List<string> localIps = new List<string>(); var host = Dns.GetHostEntry(Dns.GetHostName()); foreach (var ip in host.AddressList) { if (ip.AddressFamily == AddressFamily.InterNetwork) { localIps.Add(ip.ToString()); } } if (localIps.Count == 0) { throw new Exception("Local IP Address Not Found!"); } localIps.Add(Dns.GetHostName()); return localIps; }
传输配额(TransportQuotas),用的不多,按默认的设置,先不说了。
2.3 证书验证器(CertificateValidator)
在需要签名的通讯方式中,客户端和服务器双方都要验证对方的身份,因此我们需要为我们的客户端设置验证服务器证书(Server Certification)的过程。
m_appInstance.ApplicationConfiguration.CertificateValidator
是应用配置的证书验证器,为其添加我们自定义的验证过程certClient
。
点击查看代码
/// <summary> /// 处理证书认证事件 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void certClient(object sender, CertificateValidationEventArgs e) { //常规认证流程: if (certStep == 0) { X509Store store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadOnly); // 先找本机是否有现成的证书 X509CertificateCollection certCol = store.Certificates.Find(X509FindType.FindByThumbprint, e.Certificate.Thumbprint, true); store.Close(); if (certCol.Capacity > 0) { e.Accept = true; } //如果本机没有保存证书,则开启证书详情窗口 else { FormCertClient formCertClient = new FormCertClient(e); formCertClient.ShowDialog(); } if (e.Accept == true) { certStep++; } } //这里设置了一个certStep,因为发现在认证过程中会出现两次弹窗,第一次确认后,会弹出第二个证书认证窗口 //而第二个窗口不会影响认证结果,因此certStep=1时直接跳过即可,然后将certStep归零 else { e.Accept = true; certStep = 0; } }
其中 FormCertClient
是一个自定义的验证窗口类:
2.4 在windows电脑上查看已安装的证书
win + R
组合键,唤出运行窗口,输入certmgr.msc,进入证书管理器。
我们的客户成功运行过一次后,可以查看到自签名证书:
如果与服务器进行过连接,则可以看到服务器证书。
西门子Sinumerik的证书:
Prosys的证书:
三、建立会话
OPCUA的大部分功能(变量浏览、读写、监控等)都建立在会话(Sessions)基础上:
下面探讨如何建立会话。
3.1 端点选择与会话建立
首先我们得知道OPCUA服务器的IP和端口号,例如是opc.tcp://192.168.215.1:4840
。
之后,建立会话流程如下:
端点(Endpoint)是指可与服务器建立安全连接的一个方案,不同端点给出不同的安全策略。
例如,某OPCUA服务器提供以下端点(Endpoint):
(这里的Basic128Rsa15
、Basic256
、Basic256Sha256
是加密算法。)
在第一次连接到服务器时,无法建立会话(Session),但此时可以获取端点描述(EndpointDescriptions,一个端点描述可以理解为一个具体安全策略,包括采用什么样的安全模式、什么样的加密方式等),然后断开连接;第二次连接时,按照端点描述使用相应安全策略,这时建立的连接才能创建会话。因此,建立会话需要建立两次连接。
我们在客户端代码里先进行会话的基本配置,然后再开始建立连接,代码如下:
点击查看代码
/// <summary> /// 创建会话 /// 给出Url即 创建一个会话(Session) /// (是否需要支持多个会话标签,像浏览器一样?) /// 目前仅支持一个Session /// </summary> /// <param name="url"></param> /// <returns></returns> public async Task CreateSession(Uri url) { var endpointsDescription = SelectEndpoint(url, true); //第一次连接:获取端点(Endpoint)信息,并选择一个合适的端点(默认选择安全性最高的端点) try { Opc.Ua.Client.Session new_session = await Opc.Ua.Client.Session.Create( //第二次连接:使用所选端点建立会话 configuration: m_appInstance.ApplicationConfiguration, //配置endpoint相关设置 endpoint: new ConfiguredEndpoint( collection: null, description: endpointsDescription, configuration: Opc.Ua.EndpointConfiguration.Create(applicationConfiguration: m_appInstance.ApplicationConfiguration) ), updateBeforeConnect: false, checkDomain: false, sessionName: "Session" + DateTime.Now.ToString(), //会话名称默认为Session+时间戳(精确到秒) sessionTimeout: 60000U,//SessionTimeout identity: UserIdentity, preferredLocales: new string[] { "zh-CN" } //首选地区 ); //MessageBox.Show(new_session.Connected.ToString()); current_session = new_session; //设置客户端当前会话 m_sessions.Add(new_session); //将新会话加入会话列表 } catch ( Exception ex ) { //MessageBox.Show( ex.ToString() ); return; } return; } /// <summary> /// 选择连接时使用的Endpoint /// 默认选择安全性最高的Endpoint /// </summary> /// <param name="discoveryUrl"></param> /// <param name="useSecurity"></param> /// <returns></returns> private EndpointDescription SelectEndpoint(Uri discoveryUrl, //服务器Url bool useSecurity //是否使用安全措施(SecurityMode) ) { var configuration = Opc.Ua.EndpointConfiguration.Create(); configuration.OperationTimeout = 5000; // 操作超时限制(5s)(为了不长期占用网络资源) EndpointDescription endpointDescriptionMain = null; //最终返回的endpoint的描述 try { using (var discoveryClient = DiscoveryClient.Create(discoveryUrl, configuration)) { m_endpoints = discoveryClient.GetEndpoints(null); //int count = 0; foreach (var endpointDescriptionAlternate in m_endpoints.Where(endpointDescriptionAlternate => endpointDescriptionAlternate.EndpointUrl.StartsWith(discoveryUrl.Scheme)) //选择scheme前缀和discoveryUrl的scheme(例如http,ftp等)相匹的endpoint ) // 遍历所有endpoint,选择符合安全要求的,安全等级最高的 { string securityPolicyTMP = endpointDescriptionAlternate.SecurityPolicyUri.Remove(0, 42); //MessageBox.Show(ecount.ToString()+count.ToString()+securityPolicy); string keyTMP = "[" + m_appInstance.ApplicationName + "] " + " [" + endpointDescriptionAlternate.SecurityMode + "] " + " [" + securityPolicyTMP + "] " + " [" + endpointDescriptionAlternate.EndpointUrl + "]"; //MessageBox.Show((count++).ToString()+". "+keyTMP); if (useSecurity) //是否使用信息安全措施 { //禁用安全策略None //if (endpointDescriptionAlternate.SecurityMode == MessageSecurityMode.None) // continue; } else if (endpointDescriptionAlternate.SecurityMode != MessageSecurityMode.None) continue; //如果当前没有主方案,则初始化一个主方案 if (endpointDescriptionMain == null) // endpointDescriptionMain初始化 { endpointDescriptionMain = endpointDescriptionAlternate; //MessageBox.Show("我初始化了"); } //每次比较当前方案和主方案的安全系数,使用更安全的方案代替主方案 //因此最终方案为最安全方案 //if (endpointDescriptionAlternate.SecurityLevel < endpointDescriptionMain.SecurityLevel) //自动选择最高安全等级 if (endpointDescriptionAlternate.SecurityMode > endpointDescriptionMain.SecurityMode || (endpointDescriptionAlternate.SecurityMode == endpointDescriptionMain.SecurityMode && endpointDescriptionAlternate.SecurityLevel > endpointDescriptionMain.SecurityLevel) ) { //MessageBox.Show(endpointDescriptionAlternate.SecurityMode.ToString() + " > " + endpointDescriptionMain.SecurityMode.ToString() + "\r\n" // + endpointDescriptionAlternate.SecurityLevel.ToString() + " > " + endpointDescriptionMain.SecurityLevel.ToString() // + "\r\n我升级了"); endpointDescriptionMain = endpointDescriptionAlternate; } }//结束遍历foreach if (endpointDescriptionMain == null) { if (m_endpoints.Count > 0) //找不到方案(scheme)相匹配的,直接拿第一个endpoint来用 { //MessageBox.Show("没有满足条件的endpoint"); endpointDescriptionMain = m_endpoints[0]; } } } } catch(Exception ex) { MessageBox.Show("获取接入点(endpoints)时出现错误:\r\n" + ex.ToString()); return null; } var uri = Utils.ParseUri(endpointDescriptionMain.EndpointUrl); //返回一个Uri(url)实例 //到这里,uri的取值可以是null,和discoveryUrl的scheme一致的uri,和discoveryUrl的scheme不一致的uri if (uri != null && uri.Scheme == discoveryUrl.Scheme) //scheme指http,file,git,ftp之类 endpointDescriptionMain.EndpointUrl = new UriBuilder(uri) { Host = discoveryUrl.DnsSafeHost, Port = discoveryUrl.Port }.ToString(); string securityPolicy = endpointDescriptionMain.SecurityPolicyUri.Remove(0, 42); //显示使用的Endpoint的详细信息 //string key = "[" + m_appInstance.ApplicationName + "] " + // " [" + endpointDescriptionMain.SecurityMode + "] " + // " [" + securityPolicy + "] " + // " [" + endpointDescriptionMain.EndpointUrl + "]"; //MessageBox.Show(key); return endpointDescriptionMain; }
代码里的discoveryUrl
指第一次连接获取端点描述(EndpointDescriptions)时使用的Url。
3.2 结束当前会话
结束会话之前需要把里面的订阅任务(监控任务)先删除掉。
点击查看代码
/// <summary> /// 2.2 断开当前连接 /// </summary> public void Disconnect() { if (current_session != null) { if(current_session.Connected) { string name = current_session.SessionName; current_session.RemoveSubscriptions(current_session.Subscriptions.ToList()); //删除会话中的全部订阅任务(监控任务) current_session.Close(); m_sessions.Remove( current_session ); MessageBox.Show(name+"会话结束"); if(m_sessions.Count > 0) { current_session=m_sessions.First(); } } else { MessageBox.Show("当前会话未连接"); } } else { MessageBox.Show("当前无会话"); } }
总结
本文探讨了客户端应用配置和建立/结束会话(Session)的过程,下一次我们将了解如何在与服务器建立会话的基础上,实现服务器内容的浏览。
*附言
由于作者水平有限,可能在文章中出现错误或不当描述,如有发现此类情况希望您能及时提供反馈,非常感谢!
如果感觉本文对您有所帮助,希望为文章点个推荐,谢谢。
作者联系方式,163邮箱:zuoquangong@163.com
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 2分钟学会 DeepSeek API,竟然比官方更好用!
· .NET 使用 DeepSeek R1 开发智能 AI 客户端
· 10亿数据,如何做迁移?
· 推荐几款开源且免费的 .NET MAUI 组件库
· c# 半导体/led行业 晶圆片WaferMap实现 map图实现入门篇