通過C#實現OPC-UA服務端(二)
前言
通過我前面的一篇檔案,我們已經能夠搭建一個OPC-UA服務端了,並且也擁有了一些基礎功能。這一次咱們就來了解一下OPC-UA的服務註冊與發現,如果對服務註冊與發現這個概念不理解的朋友,可以先百度一下,由於近年來微服務架構的興起,服務註冊與發現已經成為一個很時髦的概念,它的主要功能可分為三點:
1、服務註冊;
2、服務發現;
3、心跳檢測。
如果執行過OPC-UA原始碼的朋友們應該已經發現了,OPC-UA服務端啟動之後,每隔一會就會輸出一行錯誤提示資訊,大致內容是"服務端註冊失敗,xxx毫秒之後重試",通過檢視原始碼我們可以知道,這是因為OPC-UA服務端啟動之後,會自動呼叫"opc.tcp://localhost:4840/"的RegisterServer2方法註冊自己,如果註冊失敗,則會立即呼叫RegisterServer方法再次進行服務註冊,而由於我們沒有"opc.tcp://localhost:4840/"這個服務,所以每隔一會兒就會提示服務註冊失敗。
一、服務配置
Discovery Server的服務配置與普通的OPC-UA服務配置差不多,只需要注意幾點:
1、服務的型別ApplicationType是DiscoveryServer而不是Server;
2、服務啟動時application.Start()傳入的例項化物件需要實現IDiscoveryServer介面。
配置程式碼如下:
var config = new ApplicationConfiguration() { ApplicationName = "Axiu UA Discovery", ApplicationUri = Utils.Format(@"urn:{0}:AxiuUADiscovery", System.Net.Dns.GetHostName()), ApplicationType = ApplicationType.DiscoveryServer, ServerConfiguration = new ServerConfiguration() { BaseAddresses = { "opc.tcp://localhost:4840/" }, MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200 }, DiscoveryServerConfiguration = new DiscoveryServerConfiguration() { BaseAddresses = { "opc.tcp://localhost:4840/" }, ServerNames = { "OpcuaDiscovery" } }, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\MachineDefault", SubjectName = Utils.Format(@"CN={0}, DC={1}", "AxiuOpcua", System.Net.Dns.GetHostName()) }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Certificate Authorities" }, TrustedPeerCertificates = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\UA Applications" }, RejectedCertificateStore = new CertificateTrustList { StoreType = @"Directory", StorePath = @"%CommonApplicationData%\OPC Foundation\CertificateStores\RejectedCertificates" }, AutoAcceptUntrustedCertificates = true, AddAppCertToTrustedStore = true }, TransportConfigurations = new TransportConfigurationCollection(), TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, TraceConfiguration = new TraceConfiguration() }; config.Validate(ApplicationType.DiscoveryServer).GetAwaiter().GetResult(); if (config.SecurityConfiguration.AutoAcceptUntrustedCertificates) { config.CertificateValidator.CertificateValidation += (s, e) => { e.Accept = (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted); }; } var application = new ApplicationInstance { ApplicationName = "Axiu UA Discovery", ApplicationType = ApplicationType.DiscoveryServer, ApplicationConfiguration = config }; //application.CheckApplicationInstanceCertificate(false, 2048).GetAwaiter().GetResult(); bool certOk = application.CheckApplicationInstanceCertificate(false, 0).Result; if (!certOk) { Console.WriteLine("證書驗證失敗!"); } var server = new DiscoveryServer(); // start the server. application.Start(server).Wait();
二、實現IDiscoveryServer介面
下面我們就來看看前面Discovery服務啟動時傳入的例項化物件與普通服務啟動時傳入的物件有什麼不一樣,在我們啟動一個普通OPC-UA服務時,我們可以直接使用StandardServer的物件,程式不會報錯,只不過是沒有任何節點和內容而已,而現在,如果我們直接使用DiscoveryServerBase類的物件,啟動Discovery服務時會報錯。哪怕是我們實現了IDiscoveryServer介面仍然會報錯。為了能啟動Discovery服務我們還必須重寫ServerBase中的兩個方法:
1、EndpointBase GetEndpointInstance(ServerBase server),預設的GetEndpointInstance方法返回的型別是SessionEndpoint物件,而Discovery服務應該返回的是DiscoveryEndpoint;
protected override EndpointBase GetEndpointInstance(ServerBase server) { return new DiscoveryEndpoint(server);//SessionEndpoint }
2、void StartApplication(ApplicationConfiguration configuration),預設的StartApplication方法沒有執行任何操作,而我們需要去啟動一系列與Discovery服務相關的操作。
protected override void StartApplication(ApplicationConfiguration configuration) { lock (m_lock) { try { // create the datastore for the instance. m_serverInternal = new ServerInternalData( ServerProperties, configuration, MessageContext, new CertificateValidator(), InstanceCertificate); // create the manager responsible for providing localized string resources. ResourceManager resourceManager = CreateResourceManager(m_serverInternal, configuration); // create the manager responsible for incoming requests. RequestManager requestManager = new RequestManager(m_serverInternal); // create the master node manager. MasterNodeManager masterNodeManager = new MasterNodeManager(m_serverInternal, configuration, null); // add the node manager to the datastore. m_serverInternal.SetNodeManager(masterNodeManager); // put the node manager into a state that allows it to be used by other objects. masterNodeManager.Startup(); // create the manager responsible for handling events. EventManager eventManager = new EventManager(m_serverInternal, (uint)configuration.ServerConfiguration.MaxEventQueueSize); // creates the server object. m_serverInternal.CreateServerObject( eventManager, resourceManager, requestManager); // create the manager responsible for aggregates. m_serverInternal.AggregateManager = CreateAggregateManager(m_serverInternal, configuration); // start the session manager. SessionManager sessionManager = new SessionManager(m_serverInternal, configuration); sessionManager.Startup(); // start the subscription manager. SubscriptionManager subscriptionManager = new SubscriptionManager(m_serverInternal, configuration); subscriptionManager.Startup(); // add the session manager to the datastore. m_serverInternal.SetSessionManager(sessionManager, subscriptionManager); ServerError = null; // set the server status as running. SetServerState(ServerState.Running); // monitor the configuration file. if (!String.IsNullOrEmpty(configuration.SourceFilePath)) { var m_configurationWatcher = new ConfigurationWatcher(configuration); m_configurationWatcher.Changed += new EventHandler<ConfigurationWatcherEventArgs>(this.OnConfigurationChanged); } CertificateValidator.CertificateUpdate += OnCertificateUpdate; //60s後開始清理過期服務列表,此後每60s檢查一次 m_timer = new Timer(ClearNoliveServer, null, 60000, 60000); Console.WriteLine("Discovery服務已啟動完成,請勿退出程式!!!"); } catch (Exception e) { Utils.Trace(e, "Unexpected error starting application"); m_serverInternal = null; ServiceResult error = ServiceResult.Create(e, StatusCodes.BadInternalError, "Unexpected error starting application"); ServerError = error; throw new ServiceResultException(error); } } }
三、註冊與發現服務
服務註冊之後,就涉及到服務資訊如何儲存,OPC-UA標準裡面好像是沒有固定要的要求,應該是沒有,至少我沒有發現...傲嬌.jpg。
1.註冊服務
這裡我就直接使用一個集合來儲存服務資訊,這種方式存在一個問題:如果Discovery服務重啟了,那麼在服務重新註冊之前這段時間內,所有已註冊的服務資訊都丟失了(因為OPC-UA服務的心跳間隔是30s,也就是最大可能會有30s的時間服務資訊丟失)。所以如果對服務狀態資訊敏感的情況,請自行使用其他方式,可以儲存到資料庫,也可以用其他分散式快取來儲存。這些就不在我們的討論範圍內了,我們先看看服務註冊的程式碼。
public virtual ResponseHeader RegisterServer2( RequestHeader requestHeader, RegisteredServer server, ExtensionObjectCollection discoveryConfiguration, out StatusCodeCollection configurationResults, out DiagnosticInfoCollection diagnosticInfos) { configurationResults = null; diagnosticInfos = null; ValidateRequest(requestHeader); // Insert implementation. try { Console.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ":服務註冊:" + server.DiscoveryUrls.FirstOrDefault()); RegisteredServerTable model = _serverTable.Where(d => d.ServerUri == server.ServerUri).FirstOrDefault(); if (model != null) { model.LastRegistered = DateTime.Now; } else { model = new RegisteredServerTable() { DiscoveryUrls = server.DiscoveryUrls, GatewayServerUri = server.GatewayServerUri, IsOnline = server.IsOnline, LastRegistered = DateTime.Now, ProductUri = server.ProductUri, SemaphoreFilePath = server.SemaphoreFilePath, ServerNames = server.ServerNames, ServerType = server.ServerType, ServerUri = server.ServerUri }; _serverTable.Add(model); } configurationResults = new StatusCodeCollection() { StatusCodes.Good }; return CreateResponse(requestHeader, StatusCodes.Good); } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("客戶端呼叫RegisterServer2()註冊服務時觸發異常:" + ex.Message); Console.ResetColor(); } return CreateResponse(requestHeader, StatusCodes.BadUnexpectedError); }
前面有說到,OPC-UA普通服務啟動後會先呼叫RegisterServer2方法註冊自己,如果註冊失敗,則會立即呼叫RegisterServer方法再次進行服務註冊。所以,為防萬一。RegisterServer2和RegisterServer我們都需要實現,但是他們的內容其實是一樣的,畢竟都是幹一樣的活--接收服務資訊,然後把服務資訊儲存起來。
public virtual ResponseHeader RegisterServer( RequestHeader requestHeader, RegisteredServer server) { ValidateRequest(requestHeader); // Insert implementation. try { Console.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ":服務註冊:" + server.DiscoveryUrls.FirstOrDefault()); RegisteredServerTable model = _serverTable.Where(d => d.ServerUri == server.ServerUri).FirstOrDefault(); if (model != null) { model.LastRegistered = DateTime.Now; } else { model = new RegisteredServerTable() { DiscoveryUrls = server.DiscoveryUrls, GatewayServerUri = server.GatewayServerUri, IsOnline = server.IsOnline, LastRegistered = DateTime.Now, ProductUri = server.ProductUri, SemaphoreFilePath = server.SemaphoreFilePath, ServerNames = server.ServerNames, ServerType = server.ServerType, ServerUri = server.ServerUri }; _serverTable.Add(model); } return CreateResponse(requestHeader, StatusCodes.Good); } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("客戶端呼叫RegisterServer()註冊服務時觸發異常:" + ex.Message); Console.ResetColor(); } return CreateResponse(requestHeader, StatusCodes.BadUnexpectedError); }
2.發現服務
服務註冊之後,我們的Discovery服務就知道有哪些OPC-UA服務已經啟動了,所以我們還需要一個方法來告訴客戶端這些已啟動的服務資訊。FindServers()方法就是來幹這件事的。
public override ResponseHeader FindServers( RequestHeader requestHeader, string endpointUrl, StringCollection localeIds, StringCollection serverUris, out ApplicationDescriptionCollection servers) { servers = new ApplicationDescriptionCollection(); ValidateRequest(requestHeader); Console.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ":請求查詢服務..."); string hostName = Dns.GetHostName(); lock (_serverTable) { foreach (var item in _serverTable) { StringCollection urls = new StringCollection(); foreach (var url in item.DiscoveryUrls) { if (url.Contains("localhost")) { string str = url.Replace("localhost", hostName); urls.Add(str); } else { urls.Add(url); } } servers.Add(new ApplicationDescription() { ApplicationName = item.ServerNames.FirstOrDefault(), ApplicationType = item.ServerType, ApplicationUri = item.ServerUri, DiscoveryProfileUri = item.SemaphoreFilePath, DiscoveryUrls = urls, ProductUri = item.ProductUri, GatewayServerUri = item.GatewayServerUri }); } } return CreateResponse(requestHeader, StatusCodes.Good); }
3.心跳檢測
需要注意一點,在OPC-UA標準中並沒有提供單獨的心跳方法,它採用的心跳方式就是再次向Discovery服務註冊自己,這也就是為什麼服務註冊失敗之後會重試;服務註冊成功了,它也還是會重試。所以在服務註冊時,我們需要判斷一下服務資訊是否已經存在了,如果已經存在了,那麼就執行心跳的操作。
至此,我們已經實現的服務的註冊與發現,IDiscoveryServer介面要求的內容我們也都實現了,但是有沒有發現我們還少了一樣東西,就是如果我們的某個普通服務關閉了或是掉線了,我們的Discovery服務還是儲存著它的資訊,這個時候理論上來講,已離線的服務資訊就應該刪掉,不應該給客戶端返回了。所以這就需要一個方法來清理那些已經離線的服務。
private void ClearNoliveServer(object obj) { try { var tmpList = _serverTable.Where(d => d.LastRegistered < DateTime.Now.AddMinutes(-1) || !d.IsOnline).ToList(); if (tmpList.Count > 0) { lock (_serverTable) { foreach (var item in tmpList) { Console.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ":清理服務:" + item.DiscoveryUrls.FirstOrDefault()); _serverTable.Remove(item); } } } } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("清理掉線服務ClearNoliveServer()時觸發異常:" + ex.Message); Console.ResetColor(); } }
我這裡以一分鐘為限,如果一分鐘內都沒有心跳的服務,我就當它是離線了。關於這個一分鐘需要根據自身情況來調整。
補充說明
OPC-UA服務預設是向localhost註冊自己,當然,也可以調整配置資訊,把服務註冊到其他地方去,只需在ApplicationConfiguration物件中修改ServerConfiguration屬性如下:
ServerConfiguration = new ServerConfiguration() { BaseAddresses = { "opc.tcp://localhost:8020/", "https://localhost:8021/" }, MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200, RegistrationEndpoint = new EndpointDescription() { EndpointUrl = "opc.tcp://172.17.4.68:4840", SecurityLevel = ServerSecurityPolicy.CalculateSecurityLevel(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256), SecurityMode = MessageSecurityMode.SignAndEncrypt, SecurityPolicyUri = SecurityPolicies.Basic256Sha256, Server = new ApplicationDescription() { ApplicationType = ApplicationType.DiscoveryServer }, } },
最新的Discovery Server程式碼在我的GitHub上已經上傳,地址:
https://github.com/axiu233/AxiuOpcua.ServerDemo
程式碼檔案為:
Axiu.Opcua.Demo.Service.DiscoveryManagement;
Axiu.Opcua.Demo.Service.DiscoveryServer。