前言
通過我前面的一篇文件,我們已經能夠搭建一個OPC-UA服務端了,並且也擁有了一些基礎功能。這一次咱們就來了解一下OPC-UA的服務注冊與發現,如果對服務注冊與發現這個概念不理解的朋友,可以先百度一下,由於近年來微服務架構的興起,服務注冊與發現已經成為一個很時髦的概念,它的主要功能可分為三點:
1、服務注冊;
2、服務發現;
3、心跳檢測。
如果運行過OPC-UA源碼的朋友們應該已經發現了,OPC-UA服務端啟動之后,每隔一會就會輸出一行錯誤提示信息,大致內容是"服務端注冊失敗,xxx毫秒之后重試",通過查看源碼我們可以知道,這是因為OPC-UA服務端啟動之后,會自動調用"opc.tcp://localhost:4840/"的RegisterServer2方法注冊自己,如果注冊失敗,則會立即調用RegisterServer方法再次進行服務注冊,而由於我們沒有"opc.tcp://localhost:4840/"這個服務,所以每隔一會兒就會提示服務注冊失敗。
現在我們就動手來搭建一個"opc.tcp://localhost:4840/"服務,在OPC-UA標准中,它叫Discovery Server。
一、服務配置
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。
