一款工业物联网高性能通讯中间件-服务端实现篇
在与远控操作台、自动化作业指令、人机交互、设备实时监控等工业物联网领域专家深入交流后,我了解到数据通讯方面的特殊要求。
请教了一些同事,我熟悉了工业标准协议。
经过三个版本的迭代,四个月的设计、编码、测试,上生产验证,一款由我独立研发的工业物联网高性能通讯中间件交付了。
本着交流合作,共同进步的精神,我决定把中间件的研发分享出来。由于内容较多,篇幅较长,将分三篇文章全面介绍这款中间件。
《一款工业物联网高性能通讯中间件-架构篇》:介绍中间件设计思想,技术方案,协议与标准,风险与应对
《一款工业物联网高性能通讯中间件-服务端实现篇》:介绍中间件服务端项目结构,开源组件,自封组件,PLC测点配置,数据采集,数据交互,OpcUa断线重连,常见问题及解决
《一款工业物联网高性能通讯中间件-客户端实现及封装篇》:内容待定
本次分享的是《一款工业物联网高性能通讯中间件-服务端实现篇》。服务端是中间件的核心,承担了底层数据采集,清洗,节点业务处理,IEC和Node接口交互,实时数据发送,历史数据发送的作用。
1 服务端项目结构
类库说明如下:
DataCenter.Server:控制台启动程序,注册组件
DataCenter.Server.Service:服务层,解析OpuUa协议,提供IEC及Node接口,与客户端进行Socket通讯
DataCenter.Server.Model:实体层,包含请求实体,响应实体,配置实体等等
DataCenter.Server.Utility:公共调用层,包含了一些配置读取类,OpcUa数据清洗类等等
HPSocketCS:HPSocket开源源码
类库之间关系如下:
HPSocketCS:供DataCenter.Server.Service调用
DataCenter.Server.Utility:供DataCenter.Server,DataCenter.Server.Service调用
DataCenter.Server.Model:供DataCenter.Server,DataCenter.Server.Service,DataCenter.Server.Utility调用
DataCenter.Server.Service:供DataCenter.Server调用
1.1 DataCenter.Server
1.1.1 windows服务
关键代码
/// <summary> /// 入口程序 /// </summary> /// <param name="args"></param> static void Main(string[] args) { bool runAsService = ConfigUtil.GetBool("RunAsService", false); if (runAsService) { ServiceBase[] ServicesToRun; ServicesToRun = new ServiceBase[] { new SanyEDPipeServer() }; ServiceBase.Run(ServicesToRun); } else { Log.Info("数据同步服务启动..."); SetConsoleCtrlHandler(cancelHandler, true); new EDPipeServerInit().Start(); } }
用一个install.bat文件安装服务,内容如下
sc create "DataCenter.Server" binpath= "%~dp0DataCenter.Server.exe" start= auto sc description DataCenter.Server "设备管道Socket服务及数据中心" sc failure "DataCenter.Server" reset= 3600 actions= restart/500/restart/5000/restart/30000 sc start "DataCenter.Server"
用一个uninstall.bat文件卸载服务,内容如下
sc stop "DataCenter.Server" sc delete "DataCenter.Server"
1.1.2 类图
1.1.2.1 Program类-启动类
1 using DataCenter.Server.Service; 2 using DataCenter.Server.Service.Iec; 3 using Infrastructure.Log; 4 using DataCenter.Server.Service.Cache; 5 using DataCenter.Server.Utility; 6 using System; 7 using System.Runtime.InteropServices; 8 using System.ServiceProcess; 9 10 namespace DataCenter.Server 11 { 12 public class Program 13 { 14 private static readonly string BasePath = AppDomain.CurrentDomain.BaseDirectory; 15 16 /// <summary> 17 /// 控制台关闭事件 18 /// </summary> 19 /// <param name="ctrlType"></param> 20 /// <returns></returns> 21 public delegate bool ControlCtrlDelegate(int ctrlType); 22 [DllImport("kernel32.dll")] 23 private static extern bool SetConsoleCtrlHandler(ControlCtrlDelegate HandlerRoutine, bool Add); 24 private static ControlCtrlDelegate cancelHandler = new ControlCtrlDelegate(HandlerRoutine); 25 26 public static bool HandlerRoutine(int ctrlType) 27 { 28 switch (ctrlType) 29 { 30 //Ctrl+C关闭 31 case 0: 32 //按控制台关闭按钮关闭 33 case 2: 34 OnStop(); 35 break; 36 } 37 return true; 38 } 39 40 /// <summary> 41 /// 入口程序 42 /// </summary> 43 /// <param name="args"></param> 44 static void Main(string[] args) 45 { 46 bool runAsService = ConfigUtil.GetBool("RunAsService", false); 47 if (runAsService) 48 { 49 ServiceBase[] ServicesToRun; 50 ServicesToRun = new ServiceBase[] 51 { 52 new EDPipeServer() 53 }; 54 ServiceBase.Run(ServicesToRun); 55 } 56 else 57 { 58 Log.Info("数据同步服务启动..."); 59 SetConsoleCtrlHandler(cancelHandler, true); 60 new EDPipeServerInit().Start(); 61 } 62 } 63 64 /// <summary> 65 /// 关闭执行事件 66 /// </summary> 67 private static void OnStop() 68 { 69 Log.Info("设备管道服务停止开始..."); 70 try 71 { 72 Log.Info("关闭OpcUa连接功能 - 开始..."); 73 DataCollectionService.DisConnectOpcUa(); 74 Log.Info("关闭OpcUa连接功能 - 结束..."); 75 Log.Info("关闭IEC最新值的记录功能 - 开始..."); 76 RealtimeMemoryService.Stop(); 77 Log.Info("关闭IEC最新值的记录功能 - 成功"); 78 Log.Info("持久化客户端节点订阅 - 开始..."); 79 NodeSubscribeClientCache.Persist(); 80 Log.Info("持久化客户端节点订阅 - 成功"); 81 } 82 catch (Exception ex) 83 { 84 Log.Error("设备管道服务停止报错,错误原因:" + ex.Message, ex); 85 } 86 Log.Info("设备管道服务停止成功"); 87 } 88 } 89 }
1.1.2.2 EDPipeServer类-服务自动生成类
1 namespace DataCenter.Server 2 { 3 partial class EDPipeServer 4 { 5 /// <summary> 6 /// 必需的设计器变量。 7 /// </summary> 8 private System.ComponentModel.IContainer components = null; 9 10 /// <summary> 11 /// 清理所有正在使用的资源。 12 /// </summary> 13 /// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param> 14 protected override void Dispose(bool disposing) 15 { 16 if (disposing && (components != null)) 17 { 18 components.Dispose(); 19 } 20 base.Dispose(disposing); 21 } 22 23 #region 组件设计器生成的代码 24 25 /// <summary> 26 /// 设计器支持所需的方法 - 不要修改 27 /// 使用代码编辑器修改此方法的内容。 28 /// </summary> 29 private void InitializeComponent() 30 { 31 components = new System.ComponentModel.Container(); 32 this.ServiceName = "DataCenter.Service"; 33 } 34 35 #endregion 36 } 37 }
1.1.2.3 EDPipeServerInit类-数据采集,Socket注册类
1 using DataCenter.Server.Service; 2 using System; 3 4 namespace DataCenter.Server 5 { 6 public class EDPipeServerInit 7 { 8 public void Start() 9 { 10 11 //数据采集注册 12 new DataCollectionService().DataCollectionServiceRegister(); 13 14 //Socket注册 15 new HPSocketServerService().HPSocketServerServiceRegister(); 16 17 //阻塞线程,防止退出,控制台消失 18 Console.ReadLine(); 19 } 20 } 21 }
1.2 DataCenter.Server.Model
可供中间件的客户端共用
1.2.1 文档分类
Enum:枚举文件,存放指令类型
/// <summary> /// 指令类型 /// </summary> public enum CmdTypeEnum { 心跳检测 = 1, 握手 = 2, 客户端测试消息 = 3, 浏览数据 = 4, //Cmd:4 查询点 = 5, //Cmd:5,NodeId:ns=1;i=73 修改点 =6, 订阅点 = 7, 推送点 = 8, 退订点 = 9 }
Iec:Iec标准实体类
Mq:Mq实体类
Persistence:持久化实体类
Plc:PLC实体类
Receive:中间件-服务端接受应用客户端消息实体类
Send:中间件-服务端发送到应用客户端消息实体类
Ua:Opu Ua自定义实体类
1.2.2 类图
1.3 DataCenter.Server.Service
数据采集,清洗,协议解析,HpSocket通讯,MQ转发业务
1.3.1 文档分类
Cache:11个并发缓存类。包含浏览节点目录缓存,应用客户端标识与长连接标识关系缓存,应用客户端需订阅节点缓存,设备与IEC点关系缓存,节点Id与全路径缓存,节点Id与Iec点关系缓存,节点Id与浏览名称缓存,节点Id与值缓存,服务端连接客户端点持久化缓存,服务端订阅点缓存,OpcUaClient实例化缓存
Cmd:指令服务类。包含Socket心跳服务,Socket握手服务,Socket消息测试服务,节点目录浏览服务,点推送服务,点查询服务,点订阅服务,点退订服务,点修改服务
Consumers:MQ消息消费类。包含推送时发布的历史数据的消息消费服务
Iec:包含Iec格式组装及解析
Proxy:协议代理服务。包含基于OpcUa协议的解析
DataCollectionSevice:数据采集服务类
HPSoceketServerService:HpSocket服务端实现类
OpcUaServer:应用客户端请求数据交互的服务端基于OpuUa协议的服务类
1.3.2 类图
1.4 DataCenter.Server.Utility
帮助类,文档结构如下
1.5 HPSocketCS
HPSocket源码,文档结构如下
2 开源组件
2.1 Opc Ua 基金会源码
开源地址:https://github.com/camais/UA-.NETStandard
版本:1.4.365.23
2.2 OpcUaHelper源码
开源地址:https://github.com/dathlin/OpcUaHelper
版本:1.4.365.0
2.3 HpSocket源码
开源地址:https://github.com/ldcsaa/HP-Socket
版本:5.4.2.4
3 自封组件
自封组件包含日志组件,MQ组件,UA组件,可独立成篇介绍
日志组件实现分级分类的日志功能
MQ组件实现fanout,direct消息发布与订阅功能
UA实现PLC服务器连接,节点目录浏览,节点查询,节点修改,节点订阅及节点退订功能
4 PLC测点配置
通过配置工具读取数据库的测点信息、Plc信息,设备信息,IEC版本信息,生成PLC测点配置的json文件,文件格式如下
1 { 2 "Head": "port", 3 "Collection": "change", 4 "PlcList": [{ 5 "Index": 1, 6 "DeviceId": 10001001, 7 "Name": "P1", 8 "OpcUaServerAddress": "opc.tcp://192.168.1.11:4840", 9 "BrowseDisplayNamespace": false, 10 "RootNodeId": "i=85", 11 "RootNodeName": "Root", 12 "IecPathList": [{ 13 "DeviceId": 10001001, 14 "IecPath": "SPRD.Pos.YC.F32.X", 15 "DataPath": "ns=3;s=\"publicInfoSet\".\"spreaderInfo\".\"X\"", 16 "Type": "2", 17 "IsArray": false 18 }, { 19 "DeviceId": 10001001, 20 "IecPath": "SPRD.Pos.YC.F32.Z", 21 "DataPath": "ns=3;s=\"publicInfoSet\".\"spreaderInfo\".\"Z\"", 22 "Type": "2", 23 "IsArray": false 24 }] 25 }] 26 }
读取配置文件的代码如下
1 /// <summary> 2 /// 获取Json文件 3 /// </summary> 4 /// <param name="filePath"></param> 5 /// <returns></returns> 6 public static OpcUaConfigInfo ReadJsonFile(string filePath) 7 { 8 if (string.IsNullOrWhiteSpace(filePath)) 9 { 10 return null; 11 } 12 var fullPath = GetPath(filePath); 13 var opcUaInfoList = new List<OpcUaInfo>(); 14 var opcUaConfigInfo = new OpcUaConfigInfo() { OpcUaInfoList = opcUaInfoList }; 15 using (StreamReader file = File.OpenText(fullPath)) 16 { 17 using (JsonTextReader reader = new JsonTextReader(file)) 18 { 19 JObject jsonObject = (JObject)JToken.ReadFrom(reader); 20 var head = (string)jsonObject["Head"]; 21 var collection = (string)jsonObject["Collection"]; 22 opcUaConfigInfo.Head = head; 23 opcUaConfigInfo.Collection = collection; 24 var plcList = jsonObject["PlcList"]; 25 foreach (JObject jobjectPlc in plcList) 26 { 27 var opcUaInfo = new OpcUaInfo(); 28 opcUaInfo.Index = (int)jobjectPlc["Index"]; 29 opcUaInfo.Name = (string)jobjectPlc["Name"]; 30 opcUaInfo.OpcUaServerAddress = (string)jobjectPlc["OpcUaServerAddress"]; 31 opcUaInfo.Head = head; 32 opcUaInfo.Collection = collection; 33 var iecPathObject = jobjectPlc["IecPathList"]; 34 var iecPathList = new List<IecPathInfo>(); 35 foreach (JObject iecJobject in iecPathObject) 36 { 37 var iecPathInfo = new IecPathInfo(); 38 iecPathInfo.DeviceId = (int)iecJobject["DeviceId"]; 39 iecPathInfo.IecPath = (string)iecJobject["IecPath"]; 40 iecPathInfo.DataPath = (string)iecJobject["DataPath"]; 41 iecPathInfo.IsArray = (bool)iecJobject["IsArray"]; 42 //IEC配置,类型默认是0,最大值、最小值、系数、小数位、单位默认无, zyj 2021-08-31 43 iecPathInfo.Type = string.IsNullOrEmpty(iecJobject["Type"]?.ToString()) ? 0 : Convert.ToInt32(iecJobject["Type"]); 44 if (!string.IsNullOrEmpty(iecJobject["MaxValue"]?.ToString())) 45 { 46 iecPathInfo.MaxValue = Convert.ToDouble(iecJobject["MaxValue"]); 47 } 48 if (!string.IsNullOrEmpty(iecJobject["MinValue"]?.ToString())) 49 { 50 iecPathInfo.MinValue = Convert.ToDouble(iecJobject["MinValue"]); 51 } 52 if (!string.IsNullOrEmpty(iecJobject["Coefficient"]?.ToString())) 53 { 54 iecPathInfo.Coefficient = Convert.ToDouble(iecJobject["Coefficient"]); 55 } 56 if (!string.IsNullOrEmpty(iecJobject["DecimalDigit"]?.ToString())) 57 { 58 iecPathInfo.DecimalDigit = Convert.ToInt32(iecJobject["DecimalDigit"]); 59 } 60 if (!string.IsNullOrEmpty(iecJobject["IecUnit"]?.ToString())) 61 { 62 iecPathInfo.IecUnit = Convert.ToString(iecJobject["IecUnit"]); 63 } 64 iecPathList.Add(iecPathInfo); 65 } 66 opcUaInfo.IecPathList = iecPathList; 67 68 opcUaInfoList.Add(opcUaInfo); 69 } 70 } 71 } 72 return opcUaConfigInfo; 73 }
5 数据采集
通过OpcUaHelper开源框架实现数据采集,实现了连接,关闭OpcUaServer端,节点目录浏览,节点查询,节日修改,节点订阅,节点退订功能,开放了OpcUa断线重连接口。在OpcUaHelper的基础上再次封装了Infrastructure.UA组件。
5.1 文档格式
5.2 类图
6 数据交互
数据交互提供了应用客户端调用接口,提供了节点查询,修改,订阅,退订,推送功能,代码如下
1 using Newtonsoft.Json; 2 using DataCenter.Server.Service.Cache; 3 using DataCenter.Server.Service.Proxy; 4 using Infrastructure.Log; 5 using DataCenter.Server.Model.Ua; 6 using DataCenter.Server.Utility; 7 using System; 8 using System.Collections.Concurrent; 9 using System.Collections.Generic; 10 using System.Diagnostics; 11 using System.Linq; 12 13 namespace DataCenter.Server.Service 14 { 15 public class OpcUaServer 16 { 17 /// <summary> 18 /// 是否打印日志 19 /// </summary> 20 private static bool PrintLog { get { return ConfigUtil.GetBool("PrintLog", false); } } 21 22 #region 浏览 23 /// <summary> 24 /// 缓存查询 25 /// </summary> 26 /// <param name="clientNodeIdStrList"></param> 27 /// <returns></returns> 28 public static List<BrowseResultTree> QueryBrowseListFromCacheByNodeId(List<NodeIdStr> clientNodeIdStrList) 29 { 30 var result = new List<BrowseResultTree>(); 31 var clientNodeIdList = ConvertToNodeIdRequest(clientNodeIdStrList); 32 if (clientNodeIdList == null || !clientNodeIdList.Any() || clientNodeIdList.Any(o => o == null || o.PlcKey <= 0)) 33 { 34 Log.Info("节点Id为空或Plc键非法,已从缓存中查询"); 35 var opcUaProxyCache = OpcUaProxyCache.GetDic(); 36 if (opcUaProxyCache == null || opcUaProxyCache.Keys == null || !opcUaProxyCache.Keys.Any()) 37 { 38 return null; 39 } 40 foreach (var keyValue in opcUaProxyCache) 41 { 42 var opcUaProxy = OpcUaProxyCache.Get(keyValue.Key); 43 var tree = opcUaProxy.QueryBrowseFromCache(null); 44 result.Add(tree); 45 } 46 return result; 47 } 48 foreach(var clientNodeId in clientNodeIdList) 49 { 50 var tree = QueryBrowseFromCacheByNodeId(clientNodeId); 51 if (tree != null) 52 { 53 result.Add(tree); 54 } 55 } 56 return result; 57 } 58 59 #endregion 60 61 #region 节点查询 62 /// <summary> 63 /// 查点值集合 64 /// </summary> 65 /// <param name="nodeIdStrList"></param> 66 /// <param name="needSort">是否需要排序</param> 67 /// <returns></returns> 68 public static List<NodeInfo> QueryNodeList(List<NodeIdStr> nodeIdStrList, bool needSort = true) 69 { 70 var nodeInfoList = new List<NodeInfo>(); 71 if (nodeIdStrList == null || !nodeIdStrList.Any()) 72 { 73 return null; 74 } 75 var plcKeyValidate = nodeIdStrList.Any(o => o.Id == null || OpcUaConfigHelper.NodeIdRequestFromStr(o.Id)?.PlcKey <= 0); 76 if (plcKeyValidate) 77 { 78 return nodeInfoList; 79 } 80 var dicReadNode = NodeToClass(nodeIdStrList); 81 if (dicReadNode == null || dicReadNode.Keys == null || dicReadNode.Keys.Count == 0) 82 { 83 return nodeInfoList; 84 } 85 double cacheCost = 0; 86 foreach (var keyValue in dicReadNode) 87 { 88 var opcUaProxy = OpcUaProxyCache.Get(Convert.ToInt32(keyValue.Key)); 89 if (opcUaProxy == null) 90 { 91 continue; 92 } 93 var nodeIdRequestList = ConvertToNodeIdRequest(keyValue.Value); 94 var nodeIdList = nodeIdRequestList.Select(o => o.NodeId).ToList(); 95 var stopwatch = new Stopwatch(); 96 stopwatch.Start(); 97 var query = opcUaProxy.QueryNodeList(nodeIdList); 98 nodeInfoList.AddRange(query); 99 stopwatch.Stop(); 100 cacheCost += stopwatch.ElapsedMilliseconds; 101 } 102 if (!needSort) 103 { 104 Log.Warn($"【查询】数据中心<-->opc耗时总:{cacheCost}"); 105 return nodeInfoList; 106 } 107 var result = ReSort(nodeIdStrList, nodeInfoList); 108 Log.Warn($"【查询】数据中心<-->opc耗时总:{cacheCost}"); 109 return result; 110 } 111 #endregion 112 113 #region 节点写入 114 115 /// <summary> 116 /// 写值 117 /// </summary> 118 /// <param name="nodes">节点修改集合(节点Id包含了OpcUa的Name)</param> 119 /// <param name="statusCodeList">状态码集合</param> 120 /// <returns></returns> 121 public static bool WriteNode(List<NodeUpdateBase> nodes, out List<string> statusCodeList) 122 { 123 statusCodeList = new List<string>(); 124 var errorMsg = string.Empty; 125 if (nodes == null || !nodes.Any()) 126 { 127 errorMsg = "值为空"; 128 Log.Info(errorMsg, "WriteNode"); 129 return false; 130 } 131 var plcKeyValidate = nodes.Any(o => o.Id == null || OpcUaConfigHelper.NodeIdRequestFromStr(o.Id)?.PlcKey <= 0); 132 if (plcKeyValidate) 133 { 134 errorMsg = "PlcKey Invalid"; 135 foreach (var nodeUpdateBase in nodes) 136 { 137 statusCodeList.Add(errorMsg); 138 } 139 Log.Info(errorMsg, "WriteNode"); 140 return false; 141 } 142 143 var dicWriteNode = NodeToClass(nodes); 144 if (dicWriteNode == null || dicWriteNode.Keys == null || dicWriteNode.Keys.Count == 0) 145 { 146 errorMsg = "Data Null"; 147 foreach (var nodeUpdateBase in nodes) 148 { 149 statusCodeList.Add(errorMsg); 150 } 151 Log.Info(errorMsg, "WriteNode"); 152 return false; 153 } 154 var nodeUpdateResultList = new List<NodeUpdateResult>(); 155 var result = true; 156 foreach (var keyValue in dicWriteNode) 157 { 158 var opcUaProxy = OpcUaProxyCache.Get(Convert.ToInt32(keyValue.Key)); 159 if (opcUaProxy == null) 160 { 161 errorMsg = "Plc Example Null"; 162 foreach (var node in keyValue.Value) 163 { 164 var nodeUpdateResult = new NodeUpdateResult() { Id = node.Id, StatusCode = errorMsg }; 165 nodeUpdateResultList.Add(nodeUpdateResult); 166 result = false; 167 } 168 continue; 169 } 170 var writeResult = opcUaProxy.WriteNode(keyValue.Value, ref nodeUpdateResultList); 171 if (!writeResult) 172 { 173 result=false; 174 } 175 } 176 #region 重排 177 var needResort = result == false || dicWriteNode.Count > 1; 178 statusCodeList = ReSort(nodes, nodeUpdateResultList, needResort); 179 #endregion 180 return result; 181 } 182 183 #endregion 184 185 #region 客户端点订阅、退订 186 187 /// <summary> 188 /// 客户端订阅点 189 /// </summary> 190 /// <param name="clientId">客户端Id</param> 191 /// <param name="nodeIdStrList">订阅节点列表</param> 192 /// <param name="nodeQueryList">节点列表查询信息</param> 193 /// <param name="successSubNodeList">成功订阅的节点列表(乱序,只包含成功订阅的节点)</param> 194 /// <returns></returns> 195 public static bool ClientNodeSubscribe(string clientId, List<NodeIdStr> nodeIdStrList, ref List<NodeInfo> nodeQueryList,ref List<NodeOperateResult> successSubNodeList) 196 { 197 if(successSubNodeList == null) 198 { 199 successSubNodeList = new List<NodeOperateResult>(); 200 } 201 //非空校验 202 if (nodeIdStrList == null || !nodeIdStrList.Any()) 203 { 204 Log.Warn("无订阅点"); 205 return false; 206 } 207 //加入到待订阅的客户端缓存,防止断线重连未订阅到客户端订阅的点 208 ClientNeedSubscribeNodeCache.Set(clientId, nodeIdStrList); 209 //订阅点信息 210 nodeQueryList = QueryNodeList(nodeIdStrList); 211 if (nodeQueryList == null || !nodeQueryList.Any()) 212 { 213 Log.Warn("点信息查询失败"); 214 return false; 215 } 216 var nodeIds = new List<NodeInfo>(); 217 for(int i = 0; i < nodeQueryList.Count; i++) 218 { 219 if (nodeQueryList[i] == null || string.IsNullOrWhiteSpace(nodeQueryList[i].Id)) 220 { 221 if (PrintLog) 222 { 223 Log.Warn($"第{i + 1}项节点Id为空,已忽略此节点订阅"); 224 } 225 continue; 226 } 227 var idRequest = OpcUaConfigHelper.NodeIdRequestFromStr(nodeQueryList[i].Id); 228 if (idRequest == null || idRequest.PlcKey<=0 || idRequest.NodeId==null|| string.IsNullOrWhiteSpace(idRequest.NodeId.ToString())) 229 { 230 Log.Warn($"第{i + 1}项节点PlcKey非法,已忽略此节点订阅,{JsonConvert.SerializeObject(nodeQueryList[i])}"); 231 continue; 232 } 233 nodeIds.Add(nodeQueryList[i]); 234 235 } 236 if (!nodeIds.Any()) 237 { 238 Log.Warn("无合法订阅点"); 239 return false; 240 } 241 //节点分类 242 var dicWriteNode = NodeToClass(nodeIds); 243 if (dicWriteNode == null || dicWriteNode.Keys == null || dicWriteNode.Keys.Count == 0) 244 { 245 Log.Warn("节点分类数据为空"); 246 return false; 247 } 248 //根据各自Opc实例订阅 249 var readyNumber = 0; //待订阅个数 250 var successNodeList = new List<NodeIdStr>(); 251 foreach (var keyValue in dicWriteNode) 252 { 253 readyNumber += keyValue.Value.Count; 254 var opcUaProxy = OpcUaProxyCache.Get(Convert.ToInt32(keyValue.Key)); 255 if (opcUaProxy == null) 256 { 257 Log.Warn($"订阅点-获取不到OpcUa实例,PlcKey:{keyValue.Key}"); 258 continue; 259 } 260 List<NodeOperateResult> subResult = null; 261 var success = opcUaProxy.SubscribeNode(keyValue.Value, ref subResult); 262 if (success) 263 { 264 if (subResult != null && subResult.Any()) 265 { 266 successSubNodeList.AddRange(subResult); 267 subResult.ForEach(o => 268 { 269 if (o.Success) 270 { 271 successNodeList.Add(new NodeIdStr { Id = o.Id }); 272 } 273 }); 274 } 275 } 276 else 277 { 278 Log.Warn($"订阅点失败:点{JsonConvert.SerializeObject(keyValue.Value)},subResult{JsonConvert.SerializeObject(subResult)}"); 279 } 280 } 281 if(successNodeList.Count != readyNumber) 282 { 283 Log.Warn($"订阅个数不一致:共计{readyNumber}个点订阅,实际订阅{successNodeList.Count}个"); 284 } 285 var addResult = NodeSubscribeClientCache.Set(clientId, successNodeList); 286 //客户端待订阅缓存清除订阅成功的点 287 if (addResult) 288 { 289 ClientNeedSubscribeNodeCache.Remove(clientId, successNodeList); 290 } 291 var result = successNodeList.Any(); 292 return result; 293 } 294 295 /// <summary> 296 /// 客户端退订点 297 /// </summary> 298 /// <param name="clientId"></param> 299 /// <param name="nodeIdStrList"></param> 300 /// <param name="unSubResult">退订结果(只有退订有结果的的节点)</param> 301 /// <returns></returns> 302 public static bool ClientNodeUnSubscribe(string clientId, List<NodeIdStr> nodeIdStrList, ref List<NodeOperateResult> unSubResult) 303 { 304 if(unSubResult == null) 305 { 306 unSubResult = new List<NodeOperateResult>(); 307 } 308 //非空校验 309 if (nodeIdStrList == null || !nodeIdStrList.Any()) 310 { 311 Log.Warn("无退订点"); 312 return false; 313 } 314 //节点分类 315 var dicWriteNode = NodeToClass(nodeIdStrList); 316 if (dicWriteNode == null || dicWriteNode.Keys == null || dicWriteNode.Keys.Count == 0) 317 { 318 Log.Warn("节点分类数据为空"); 319 return false; 320 } 321 //根据各自Opc实例退订 322 var readyNumber = 0; //待退订个数 323 var successNumber = 0;//退订成功个数 324 var stopwatch = new Stopwatch(); 325 stopwatch.Start(); 326 foreach (var keyValue in dicWriteNode) 327 { 328 readyNumber += keyValue.Value.Count; 329 var opcUaProxy = OpcUaProxyCache.Get(Convert.ToInt32(keyValue.Key)); 330 if (opcUaProxy == null) 331 { 332 Log.Warn($"退订点-获取不到OpcUa实例,PlcKey:{keyValue.Key}"); 333 continue; 334 } 335 var success = opcUaProxy.UnSubscribeNode(keyValue.Value.Select(o=>o.Id)?.ToList()); 336 if (success) 337 { 338 successNumber += keyValue.Value.Count; 339 unSubResult.AddRange(keyValue.Value.Select(o => new NodeOperateResult { Id = o.Id, Success = true })); 340 } 341 else 342 { 343 Log.Warn($"退订点失败:点{JsonConvert.SerializeObject(keyValue.Value)}"); 344 } 345 } 346 stopwatch.Stop(); 347 Log.Warn($"【退订】数据中心<-->opc耗时(ms)缓存:{stopwatch.ElapsedMilliseconds}"); 348 if (successNumber != readyNumber) 349 { 350 Log.Warn($"退阅个数不一致:共计{readyNumber}个点退订,实际退订{successNumber}个"); 351 } 352 if (!unSubResult.Any()) 353 { 354 return false; 355 } 356 var successNodes = unSubResult.Select(o => new NodeIdStr { Id = o.Id }).ToList(); 357 var result = NodeSubscribeClientCache.Remove(clientId, successNodes); 358 //客户端待订阅缓存清除订阅成功的点 359 if (result) 360 { 361 ClientNeedSubscribeNodeCache.Remove(clientId, successNodes); 362 } 363 return result; 364 } 365 #endregion 366 367 #region 私有方法 368 369 370 /// <summary> 371 /// 重排 372 /// </summary> 373 /// <param name="nodeIdStrList">传入的值</param> 374 /// <param name="nodeIdList">结果集</param> 375 /// <returns></returns> 376 private static List<NodeInfo> ReSort(List<NodeIdStr> nodeIdStrList, List<NodeInfo> nodeIdList) 377 { 378 var result = new List<NodeInfo>(); 379 if (nodeIdList == null || !nodeIdList.Any()) 380 { 381 return result; 382 } 383 nodeIdStrList.ForEach(o => 384 { 385 var nodeInfo = nodeIdList.Find(p => p.Id == o.Id); 386 result.Add(nodeInfo); 387 }); 388 return result; 389 } 390 391 /// <summary> 392 /// 重排序 393 /// </summary> 394 /// <param name="nodes">原始节点列表</param> 395 /// <param name="nodeUpdateResultList">修改后节点结果列表</param> 396 /// <param name="needResort">是否需要重排序</param> 397 /// <returns></returns> 398 private static List<string> ReSort(List<NodeUpdateBase> nodes, List<NodeUpdateResult> nodeUpdateResultList, bool needResort) 399 { 400 if (!needResort) 401 { 402 var result = nodeUpdateResultList.Select(o => o.StatusCode).ToList(); 403 return result; 404 } 405 var statusCodeList = new List<string>(); 406 foreach (var node in nodes) 407 { 408 var nodeUpdateResult = nodeUpdateResultList.Find(o => o.Id == node.Id); 409 var statusCode = nodeUpdateResult == null ? "未获取到写入信息" : nodeUpdateResult.StatusCode; 410 statusCodeList.Add(statusCode); 411 } 412 return statusCodeList; 413 } 414 415 /// <summary> 416 /// 节点分类 417 /// </summary> 418 /// <param name="nodes"></param> 419 /// <returns></returns> 420 private static ConcurrentDictionary<int, List<T>> NodeToClass<T>(List<T> nodes) where T : NodeIdStr 421 { 422 if (nodes == null || !nodes.Any()) 423 { 424 return null; 425 } 426 var result = new ConcurrentDictionary<int, List<T>>(); 427 for (int i = 0; i < nodes.Count; i++) 428 { 429 if (nodes[i] == null || string.IsNullOrWhiteSpace(nodes[i].Id)) 430 { 431 Log.Warn($"写入点第{i + 1}项节点Id为空,已忽略此节点,写入集合{JsonConvert.SerializeObject(nodes)}", "NodeToClass"); 432 continue; 433 } 434 var nodeIdRequest = OpcUaConfigHelper.NodeIdRequestFromStr(nodes[i].Id); 435 var key = nodeIdRequest.PlcKey; 436 if (result.ContainsKey(key)) 437 { 438 var value = result[key]; 439 value.Add(nodes[i]); 440 result.AddOrUpdate(key, value, (tKey, existingVal) => { return value; }); 441 } 442 else 443 { 444 var value = new List<T> { nodes[i] }; 445 result.AddOrUpdate(key, value, (tKey, existingVal) => { return value; }); 446 } 447 } 448 return result; 449 } 450 451 /// <summary> 452 /// 缓存查询 453 /// </summary> 454 /// <param name="clientNodeId"></param> 455 /// <returns></returns> 456 private static BrowseResultTree QueryBrowseFromCacheByNodeId(NodeIdRequest clientNodeId) 457 { 458 if (clientNodeId == null && clientNodeId.NodeId == null || string.IsNullOrWhiteSpace(clientNodeId.NodeId.ToString())) 459 { 460 Log.Warn("客户端点为空"); 461 return null; 462 } 463 if (clientNodeId.PlcKey <= 0) 464 { 465 Log.Warn("plc标识错误"); 466 return null; 467 } 468 var opcUaProxy = OpcUaProxyCache.Get(clientNodeId.PlcKey); 469 if(opcUaProxy == null) 470 { 471 Log.Warn($"没有获取到Plc实例:Plc:{clientNodeId.PlcKey}"); 472 return null; 473 } 474 var cacheNodeId = OpcUaConfigHelper.KeyToCache(clientNodeId.NodeId.ToString(), clientNodeId.PlcKey); 475 var result = opcUaProxy.QueryBrowseFromCache(cacheNodeId); 476 return result; 477 } 478 479 /// <summary> 480 /// 转为NodeIdRequest集合 481 /// </summary> 482 /// <param name="clientNodeIdList"></param> 483 /// <returns></returns> 484 private static List<NodeIdRequest> ConvertToNodeIdRequest(List<NodeIdStr> clientNodeIdList) 485 { 486 if (clientNodeIdList == null || !clientNodeIdList.Any()) 487 { 488 return null; 489 } 490 var result = new List<NodeIdRequest>(); 491 clientNodeIdList.ForEach(o => 492 { 493 var request = OpcUaConfigHelper.NodeIdRequestFromStr(o.Id); 494 result.Add(request); 495 }); 496 return result; 497 } 498 499 #endregion 500 } 501 }
7 OpcUa断线重连
Opc Ua Server更新,网络断开会触发Infrastructure.UA组件断线重连,重连成功后会通知OpcUa实体,然后重新建立连接,重新订阅点。实现如下
1 /// <summary> 2 /// 断线重连成功通知注册 3 /// </summary> 4 /// <returns></returns> 5 public bool ReconnectSuccessNoticeRegister() 6 { 7 OpcUaClient.reconnectSuccessNoticeEvent.OnNotice += OnNotice; 8 return true; 9 }
1 /// <summary> 2 /// 订阅断线重连 3 /// </summary> 4 /// <returns></returns> 5 public bool OnNotice() 6 { 7 try 8 { 9 Log.Warn("Opc Ua触发了断线重连通知!"); 10 DisConnect(); 11 Thread.Sleep(500); 12 Connect(); 13 if (OpcUaClient.IsConnected) 14 { 15 Log.Warn("Opc Ua断线重连成功了!"); 16 //停止订阅推初值 17 OpcUaClient.Sub_Stop(); 18 //注册订阅 19 NodeSubscribeRegister(); 20 //订阅 21 var cacheNodeInfoList = NodeSubscribeServerCache.GetAll(); 22 //服务端订阅所有缓存 23 if (cacheNodeInfoList != null && cacheNodeInfoList.Any()) 24 { 25 List<NodeOperateResult> subResult = null; 26 Subscribe(cacheNodeInfoList, ref subResult, false); 27 Log.Warn($"Opc Ua断线重连:服务端点订阅缓存为{JsonConvert.SerializeObject(cacheNodeInfoList.Select(o => o.Id).ToList())}!"); 28 } 29 else 30 { 31 Log.Warn("Opc Ua断线重连:服务端点订阅缓存为空,无需订阅!"); 32 } 33 //客户端订阅 34 ClientSubscribe(); 35 } 36 return true; 37 } 38 catch(Exception ex) 39 { 40 Log.Error($"断线重连结果异常:{ex.Message}", ex); 41 return false; 42 } 43 }
8 常见问题及解决
8.1 批量操作(查询,订阅,修改)出现ToManyOperation错误
问题原因:PLC硬件性能不一致,比如西门子PLC-1500系列,订阅批次数不能超过20,每批次不能超过1000个节点
问题解决:批量操作时分批处理,通过Infrastructure.UA组件查询,修改节点每批次数设置成500。订阅时通过key来关联批次,每个key是一个批次,一个批次1000个节点,保存key与节点的缓存关系,超过20个批次的节点共计2万个点时,超过部分丢弃,写异常日志。
8.2 启动服务时报错,报InvalidSession错误
问题原因:上一次关闭时OPC UA基金会有缓存,还未到2分钟的过期时间,Session还保存了订阅点信息,订阅时出错
问题解决:每次程序关闭时主动关闭OpcUa的连接
8.3 OpcUA短线重连20次后重连不起作用
问题原因:OpcUaServer端默认对每个Session有重连次数的限制且不可更改
问题解决:每次重连时关闭原来的连接,重新启动新的连接
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异
· 三行代码完成国际化适配,妙~啊~