首页 > 其他分享 >OPCUA探讨(三)——客户端代码解读

OPCUA探讨(三)——客户端代码解读

时间:2024-12-07 10:12:38浏览次数:7  
标签:appInstance 证书 endpointDescriptionMain 解读 new configuration OPCUA 客户端

本文开始讨论OPCUA客户端源码的构造。
项目地址:https://gitee.com/zuoquangong/opcuaapi

一、项目结构说明

我们在Visual Studio2022中打开项目文件(.sln),查看“解决方案资源管理器”:

该客户端核心功能在OpcUaAPI.cs。

上述结构与我们的使用流程相对应:
初始化客户端(1-应用全局配置)-> 连接服务器(2-连接相关) -> 浏览服务器(3-浏览变量) ->监控特定变量数据(4-订阅变量)
下面我们逐个步骤进行讨论。
注意,这里我们使用了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)配置内容如下:

主要是确保我们自身的应用有证书可用(没有则自动创建自签名证书),这个证书用于服务器确认通信者的身份,是建立安全通道的前提。
这里简单说明下,在OPCUA里,服务器与客户端建立信息安全通道有三种安全模式(Security Mode):
1.None,无安全策略,不检验对方通信者的身份,也不对通信内容进行加密,安全性为零,仅用于测试,实际情况不要使用;
2.Sign,仅签名模式,通过证书签名验证对方通信者身份,但通信内容不加密,签名可以保证信息完整未经篡改;
3.SignAndEncrypt,签名且信息内容加密,最安全的模式。
我们这里配置的应用证书是实现SignSignAndEncrypt安全模式的基础。
通常使用自签名证书作为我们的客户端应用证书,以下为证书生成代码:

点击查看代码
 /// <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`是一个自定义的验证窗口类:

三、建立会话

OPCUA的大部分功能(变量浏览、读写、监控等)都建立在会话(Sessions)基础上,下面探讨如何建立会话。

3.1 端点选择与会话建立

首先我们得知道OPCUA服务器的IP和端口号,例如是opc.tcp://192.168.215.1:4840
之后,建立会话流程如下:


端点(Endpoint)是指可与服务器建立安全连接的一个方案,不同端点给出不同的安全策略。
例如,某OPCUA服务器提供以下端点(Endpoint):


(这里的Basic128Rsa15Basic256Basic256Sha256是加密算法。)
在第一次连接到服务器时,无法建立会话(Session),但此时可以获取端点描述(EndpointDescriptions),然后断开连接;第二次连接时,按照端点描述使用相应安全策略,这时建立的连接才能创建会话。因此,建立会话需要建立两次连接。


我们的客户端里先进行会话的基本配置,然后再开始建立连接,代码如下:

点击查看代码
/// <summary>
/// 创建会话
/// 给出Url即 创建一个会话(Session)
/// (是否需要支持多个会话标签,像浏览器一样?)
/// 目前仅支持一个Session
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
public async Task CreateSession(Uri url)
{
    var endpointsDescription = SelectEndpoint(url, true); //url,useMessageSecurity
    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。

结束当前会话

结束会话之前需要把里面的订阅任务(监控任务)先删除掉。

点击查看代码
        /// <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

标签:appInstance,证书,endpointDescriptionMain,解读,new,configuration,OPCUA,客户端
From: https://www.cnblogs.com/gongzuoquan/p/18590174

相关文章

  • 模型 正则化方法(通俗解读)
    系列文章分享 模型,了解更多......
  • ssm毕设新闻客户端app程序+论文+部署
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容一、研究背景随着互联网的普及和移动终端技术的飞速发展,人们获取信息的方式发生了巨大变革。新闻客户端app已成为大众获取新闻资讯的重要途径。当前,中国手机......
  • JAVA实现客户端通过服务端对话(NET)
    通过使用java.net实现客户端向服务端发送信息内容以及发送目标地址ID,服务端通过寻找对应ID转发给目标客户端。代码仅供参考,其中有很多地方仍需要优化,比如:ID无法注册、没有添加密码、代码优化不够等问题java版本:17服务端代码:importjava.io.IOException;importjava.io.Pri......
  • OPCUA 探讨(二)——服务器节点初探
    一、回顾前文中我们获取到了一份现成的OPCUA客户端代码,通过该客户端和ProsysOPCUA服务器建立了连接,并浏览了其中服务器上的内容(多层的树状节点结构)。OPCUA探讨(一)二、服务器节点结构以下是对OPCUA服务器节点结构的简要讨论。2.1根目录结构前文中我们建立会话连接后,首次点......
  • 为什么我们需要一个新的Git客户端
    随着Git仓库的厂商推出的很多新功能,原有的Git客户端已显得能力不足。要获得Git厂商提供的新能力,客户端需要通过OAuth方式获得个人令牌(也可生成个人令牌),从而获取这些数据。GitHub的官方客户端就是一个例子,除了传统的Git客户端能力外,还能操作codespace,gist,issue,pr,actions......
  • 文献解读:大气条件对风电机组次声测量的影响
    题目:大气条件对风电机组次声测量的影响关键词:风电机组次声;叶片通过频率等级;预测模型;大气湍流;室外次声测量;1中文摘要  随着风电机组的规模和数量不断增加,过去十年中,人们对风电机组低频和次声的研究更加关注。但大气条件对风电机组噪声影响的研究较少,如大气边界层的湍流特征......
  • 解读 110页 大型集团IT治理体系规划详细解决方案可编辑PPT
    该文档是大型集团IT治理体系规划详细解决方案,涵盖信息化蓝图架构、管控体系规划、治理规划方法论、IT治理目标体系架构设计、IT运维及演进规划等内容,旨在助力集团构建完善、高效且适应发展需求的IT治理体系,以下是详细总结:###信息化蓝图架构与管控体系规划1.信息化蓝图架构......
  • ComfyUI V1 桌面客户端终于来啦!支持 Mac/Win 一键安装(附安装包和使用指南)
    10月底的时候ComfyUI官方宣布将发布一款桌面客户端,它最大的特点是同时兼容Mac和Windows系统,也就是说苹果用户也可以实现一键安装ComfyUI了,很多小伙伴最担心的安装问题迎刃而解。网盘下载地址这份完整版的comfyui整合包已经上传CSDN,朋友们如果需要可以微信扫描......
  • SpringBoot中HTTP高性能客户端实现
    目录1、引入OKHTTP依赖2、配置OkHttpClient客户端实例3、请求调用1、引入OKHTTP依赖 <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency>2、配置Ok......
  • 3D点云-Pointnet++模型解读(附源码+论文)
    3D点云-Pointnet++模型代码链接:pointnet2-pytorch-study(关键部分代码注释详细,参考Pointnet_Pointnet2_pytorch)论文链接:PointNet++:DeepHierarchicalFeatureLearningonPointSetsinaMetricSpace官方链接:pointnet2(源码基于TensorFlow)公开3D点云数据集:modelnet4......