在本人之前的一篇博文中描写了如何使用OPC自定义接口开发OPCClient,并使用SignalR实现数据的远程实时传输。
但是在使用过程中发现仍有不足之处,本文就是对之前OPCClient的功能改进进行说明。
1.问题描述
原有的OPCClient在测试环境下可以正常运行,但是在实际生产环境下长时间运行后问题就逐渐暴露出来。主要的问题和不足之处有:
- 仅能连接一个OPCServer,不支持同时连接多个OPCServer。
- OPC标签增多时,界面刷新卡顿。
- 长时间运行导致OPCClient所在计算机内存占用率不断升高。
- 远程数据传输在物理网络断开重连后不能自动重连。
- 与OPCServer断开时会导致资源不能有效释放,从而使OPCServer所在计算机内存占用率不断升高。
2.原因分析
对于OPCClient在实际运行中出现的问题,通过在实际生产环境和测试环境中的交叉测试及对比分析,问题出现的原因主要有以下方面:
- 原有代码中仅定义了一个OPCServer类作为全局变量,不能多次复用,所以仅能连接一个OPCServer。
- OPC标签组的数据变化时对变化后的数据进行了一些处理,耗费了一定时间,同时监听数据变化的方法和刷新UI界面的方法在线程上有冲突,导致主线程也就是UI线程阻塞、界面卡顿。
- 原有代码中定义了一些数据刷新相关的变量,使得数据变化时不断实例化新的对象,导致分配的内存不断增大,最终导致计算机卡顿。
- 远程数据传输的连接被动断开时没有监听相应的事件,也没有进一步处理,使得SignalR连接端口后都要人工重启连接。
- OPC协议是基于COM进行通信的,OPCClient主动连接OPCServer时,OPCServer被唤醒并开启服务运行,如果OPCClient断开时没有主动释放COM资源或者释放COM资源失败,就会OPCServer所在计算机的相关服务一直运行。OPCClient再次连接就会再启用一个服务,连接的次数越多,启用的服务就越多而且占用的资源就越多。
3.改进方案
在确认了问题原因后,就开始针对每个问题进行具体的修改。
3.1 支持同时连接多个OPCServer
新建集合变量用于存储OPCServer连接实例
private Dictionary<string, OpcServer> dicServer = new Dictionary<string, OpcServer>();
提取连接OPCServer的方法,从数据库中查询所有OPCServer信息,循环连接。
//连接按钮点击事件
private void btnStart_Click(object sender, RoutedEventArgs e)
{
ShowAwaitProgress(() =>
{
OperationFun operation = new OperationFun();
DataTable dt = operation.GetServerHost();
foreach (DataRow dr in dt.Rows)
{
ConnectServer(dr["ServerId"].ToString(), dr["ServerName"].ToString(), dr["ServerHost"].ToString());
}
});
}
//连接服务器
private void ConnectServer(string serverId, string serverName, string serverHost = "127.0.0.1")
{
try
{
System.Type t = System.Type.GetTypeFromProgID(serverName, serverHost);
string prgID = t == null ? _programId : t.GUID.ToString();//部分未注册的OPCServer使用固定标识
OpcServer server = new OpcServer(prgID, serverHost, serverName,serverId);
server.Connect();
if (!server.IsConnected)
{
WriteToStatus(serverHost + "--" + serverName + "连接失败!");
return;
}
OperationFun operation = new OperationFun();
DataTable dtGroup = operation.GetGroup(serverId);
//增加标签组
foreach (DataRow drGroup in dtGroup.Rows)
{
string groupName = drGroup["GroupName"].ToString();
string groupId = drGroup["GroupId"].ToString();
OpcGroup group = server.AddGroup(groupName, _updateRate, true);
group.ServerId = serverId;
group.GroupId = groupId;
if (group != null)
{
group.DataChanged += Group_DataChanged;
//增加标签
DataTable dtItem = operation.GetItem(serverId, groupId);
ItemResult[] results = group.AddItems(dtItem);
foreach (ItemResult result in results)
{
if (result.ResultID.Failed())
{
string message = "Failed to add item \'" + result.ItemName + "\'" + " Error: " + result.ResultID.Name;
WriteToStatus(message);
}
}
}
}
dicServer.Add(serverId, server);//添加连接实例到集合中
isConnect = true;
this.Dispatcher.Invoke(() =>
{
btnStart.IsEnabled = false;
btnStop.IsEnabled = true;
txtStatus.Text = "连接";
});
WriteToStatus(serverHost + "--" + serverName + "已连接!");
}
catch (Exception ex)
{
WriteToStatus(ex.TargetSite+":" + ex.Message);
}
}
3.2 优化数据刷新时UI界面卡顿
本文中的OPCClient使用WPF开发的,UI线程即是主线程。原有代码中定义了一个数据类OPCValueModel,当标签组数据变化时,将变化的值(Array类型)转换为OPCValueModel类型,同时更新全局的集合并刷新前台DataGrid的数据源以达到更新UI的目的。
但是将Array类型转换为OPCValueModel类型和更新全局的数据集合都会花费较多时间,这些操作如果在主线程中进行必然会导致UI卡顿。
修改OPCItem类,并修改OPCGroup类的Data_Changed事件,使得数据变化值改为能直接绑定到前台表格的数据类型。
public interface IResult
{
/// <summary>
/// The error id for the result of an operation on an item.
/// </summary>
ResultID ResultID { get; set; }
/// <summary>
/// Vendor specific diagnostic information (not the localized error text).
/// </summary>
string DiagnosticInfo { get; set; }
}
public class ItemIdentifier : ICloneable
{
/// <summary>
/// The primary identifier for an item within the server namespace.
/// </summary>
public string ItemName
{
get { return m_itemName; }
set { m_itemName = value; }
}
/// <summary>
/// An secondary identifier for an item within the server namespace.
/// </summary>
public string ItemPath
{
get { return m_itemPath; }
set { m_itemPath = value; }
}
/// <summary>
/// A unique item identifier assigned by the client.
/// </summary>
public object ClientHandle
{
get { return m_clientHandle; }
set { m_clientHandle = value; }
}
/// <summary>
/// A unique item identifier assigned by the server.
/// </summary>
public object ServerHandle
{
get { return m_serverHandle; }
set { m_serverHandle = value; }
}
public string ItemId
{
get { return this.m_itemId; }
set { this.m_itemId = value; }
}
public string GroupId
{
get { return this.m_groupId; }
set { this.m_groupId = value; }
}
public string ServerId
{
get { return this.m_serverId; }
set { this.m_serverId = value; }
}
public string ItemDescribe
{
get { return this.m_itemDescribe; }
set { this.m_itemDescribe = value; }
}
/// <summary>
/// Create a string that can be used as index in a hash table for the item.
/// </summary>
public string Key
{
get
{
return new StringBuilder(64)
.Append((ItemName == null)?"null":ItemName)
.Append("\r\n")
.Append((ItemPath == null)?"null":ItemPath)
.ToString();
}
}
/// <summary>
/// Initializes the object with default values.
/// </summary>
public ItemIdentifier() {}
/// <summary>
/// Initializes the object with the specified item name.
/// </summary>
public ItemIdentifier(string itemName)
{
ItemPath = null;
ItemName = itemName;
}
/// <summary>
/// Initializes the object with the specified item path and item name.
/// </summary>
public ItemIdentifier(string itemPath, string itemName)
{
ItemPath = itemPath;
ItemName = itemName;
}
/// <summary>
/// Initializes the object with the specified item identifier.
/// </summary>
public ItemIdentifier(ItemIdentifier identify)
{
if (identify != null)
{
ItemPath = identify.ItemPath;
ItemName = identify.ItemName;
ClientHandle = identify.ClientHandle;
ServerHandle = identify.ServerHandle;
ServerId = identify.ServerId;
GroupId = identify.GroupId;
ItemId = identify.ItemId;
ItemDescribe = identify.ItemDescribe;
}
}
#region ICloneable Members
/// <summary>
/// Creates a shallow copy of the object.
/// </summary>
public virtual object Clone() { return MemberwiseClone(); }
#endregion
#region Private Members
private string m_itemName = null;
private string m_itemPath = null;
private object m_clientHandle = null;
private object m_serverHandle = null;
private string m_itemId = null;
private string m_groupId = null;
private string m_serverId = null;
private string m_itemDescribe = null;
#endregion
}
public class OpcItem : ItemIdentifier
{
/// <summary>
/// The data type to use when returning the item value.
/// </summary>
public System.Type ReqType
{
get { return m_reqType; }
set { m_reqType = value; }
}
/// <summary>
/// The oldest (in milliseconds) acceptable cached value when reading an item.
/// </summary>
public int MaxAge
{
get { return m_maxAge; }
set { m_maxAge = value; }
}
/// <summary>
/// Whether the Max Age is specified.
/// </summary>
public bool MaxAgeSpecified
{
get { return m_maxAgeSpecified; }
set { m_maxAgeSpecified = value; }
}
/// <summary>
/// Whether the server should send data change updates.
/// </summary>
public bool Active
{
get { return m_active; }
set { m_active = value; }
}
/// <summary>
/// Whether the Active state is specified.
/// </summary>
public bool ActiveSpecified
{
get { return m_activeSpecified; }
set { m_activeSpecified = value; }
}
/// <summary>
/// The minimum percentage change required to trigger a data update for an item.
/// </summary>
public float Deadband
{
get { return m_deadband; }
set { m_deadband = value; }
}
/// <summary>
/// Whether the Deadband is specified.
/// </summary>
public bool DeadbandSpecified
{
get { return m_deadbandSpecified; }
set { m_deadbandSpecified = value; }
}
/// <summary>
/// How frequently the server should sample the item value.
/// </summary>
public int SamplingRate
{
get { return m_samplingRate; }
set { m_samplingRate = value; }
}
/// <summary>
/// Whether the Sampling Rate is specified.
/// </summary>
public bool SamplingRateSpecified
{
get { return m_samplingRateSpecified; }
set { m_samplingRateSpecified = value; }
}
/// <summary>
/// Whether the server should buffer multiple data changes between data updates.
/// </summary>
public bool EnableBuffering
{
get { return m_enableBuffering; }
set { m_enableBuffering = value; }
}
/// <summary>
/// Whether the Enable Buffering is specified.
/// </summary>
public bool EnableBufferingSpecified
{
get { return m_enableBufferingSpecified; }
set { m_enableBufferingSpecified = value; }
}
#region Constructors
/// <summary>
/// Initializes the object with default values.
/// </summary>
public OpcItem() { }
/// <summary>
/// Initializes object with the specified ItemIdentifier object.
/// </summary>
public OpcItem(ItemIdentifier item)
{
if (item != null)
{
ItemName = item.ItemName;
ItemPath = item.ItemPath;
ClientHandle = item.ClientHandle;
ServerHandle = item.ServerHandle;
ItemId = item.ItemId;
GroupId = item.GroupId;
ServerId = item.ServerId;
ItemDescribe = item.ItemDescribe;
}
}
/// <summary>
/// Initializes object with the specified Item object.
/// </summary>
public OpcItem(OpcItem item)
: base(item)
{
if (item != null)
{
ReqType = item.ReqType;
MaxAge = item.MaxAge;
MaxAgeSpecified = item.MaxAgeSpecified;
Active = item.Active;
ActiveSpecified = item.ActiveSpecified;
Deadband = item.Deadband;
DeadbandSpecified = item.DeadbandSpecified;
SamplingRate = item.SamplingRate;
SamplingRateSpecified = item.SamplingRateSpecified;
EnableBuffering = item.EnableBuffering;
EnableBufferingSpecified = item.EnableBufferingSpecified;
}
}
#endregion
#region Private Members
private System.Type m_reqType = null;
private int m_maxAge = 0;
private bool m_maxAgeSpecified = false;
private bool m_active = true;
private bool m_activeSpecified = false;
private float m_deadband = 0;
private bool m_deadbandSpecified = false;
private int m_samplingRate = 0;
private bool m_samplingRateSpecified = false;
private bool m_enableBuffering = false;
private bool m_enableBufferingSpecified = false;
#endregion
}
public class ItemValueResult : ItemValue, IResult
{
#region Constructors
/// <summary>
/// Initializes the object with default values.
/// </summary>
public ItemValueResult() { }
/// <summary>
/// Initializes the object with an ItemIdentifier object.
/// </summary>
public ItemValueResult(ItemIdentifier item) : base(item) { }
/// <summary>
/// Initializes the object with an ItemValue object.
/// </summary>
public ItemValueResult(ItemValue item) : base(item) { }
/// <summary>
/// Initializes object with the specified ItemValueResult object.
/// </summary>
public ItemValueResult(ItemValueResult item)
: base(item)
{
if (item != null)
{
ResultID = item.ResultID;
DiagnosticInfo = item.DiagnosticInfo;
}
}
/// <summary>
/// Initializes the object with the specified item name and result code.
/// </summary>
public ItemValueResult(string itemName, ResultID resultID)
: base(itemName)
{
ResultID = resultID;
}
/// <summary>
/// Initializes the object with the specified item name, result code and diagnostic info.
/// </summary>
public ItemValueResult(string itemName, ResultID resultID, string diagnosticInfo)
: base(itemName)
{
ResultID = resultID;
DiagnosticInfo = diagnosticInfo;
}
/// <summary>
/// Initialize object with the specified ItemIdentifier and result code.
/// </summary>
public ItemValueResult(ItemIdentifier item, ResultID resultID)
: base(item)
{
ResultID = resultID;
}
/// <summary>
/// Initializes the object with the specified ItemIdentifier, result code and diagnostic info.
/// </summary>
public ItemValueResult(ItemIdentifier item, ResultID resultID, string diagnosticInfo)
: base(item)
{
ResultID = resultID;
DiagnosticInfo = diagnosticInfo;
}
#endregion
#region IResult Members
/// <summary>
/// The error id for the result of an operation on an property.
/// </summary>
public ResultID ResultID
{
get { return m_resultID; }
set { m_resultID = value; }
}
/// <summary>
/// Vendor specific diagnostic information (not the localized error text).
/// </summary>
public string DiagnosticInfo
{
get { return m_diagnosticInfo; }
set { m_diagnosticInfo = value; }
}
#endregion
#region Private Members
private ResultID m_resultID = ResultID.S_OK;
private string m_diagnosticInfo = null;
#endregion
}
public class IdentifiedResult : ItemIdentifier, IResult
{
/// <summary>
/// Initialize object with default values.
/// </summary>
public IdentifiedResult() { }
/// <summary>
/// Initialize object with the specified ItemIdentifier object.
/// </summary>
public IdentifiedResult(ItemIdentifier item)
: base(item)
{
}
/// <summary>
/// Initialize object with the specified IdentifiedResult object.
/// </summary>
public IdentifiedResult(IdentifiedResult item)
: base(item)
{
if (item != null)
{
ResultID = item.ResultID;
DiagnosticInfo = item.DiagnosticInfo;
}
}
/// <summary>
/// Initializes the object with the specified item name and result code.
/// </summary>
public IdentifiedResult(string itemName, ResultID resultID)
: base(itemName)
{
ResultID = resultID;
}
/// <summary>
/// Initialize object with the specified item name, result code and diagnostic info.
/// </summary>
public IdentifiedResult(string itemName, ResultID resultID, string diagnosticInfo)
: base(itemName)
{
ResultID = resultID;
DiagnosticInfo = diagnosticInfo;
}
/// <summary>
/// Initialize object with the specified ItemIdentifier and result code.
/// </summary>
public IdentifiedResult(ItemIdentifier item, ResultID resultID)
: base(item)
{
ResultID = resultID;
}
/// <summary>
/// Initialize object with the specified ItemIdentifier, result code and diagnostic info.
/// </summary>
public IdentifiedResult(ItemIdentifier item, ResultID resultID, string diagnosticInfo)
: base(item)
{
ResultID = resultID;
DiagnosticInfo = diagnosticInfo;
}
#region IResult Members
/// <summary>
/// The error id for the result of an operation on an item.
/// </summary>
public ResultID ResultID
{
get { return m_resultID; }
set { m_resultID = value; }
}
/// <summary>
/// Vendor specific diagnostic information (not the localized error text).
/// </summary>
public string DiagnosticInfo
{
get { return m_diagnosticInfo; }
set { m_diagnosticInfo = value; }
}
#endregion
#region Private Members
private ResultID m_resultID = ResultID.S_OK;
private string m_diagnosticInfo = null;
#endregion
}
修改OPCGroup的DataChanged事件
//委托
public delegate void DataChangedEventHandler(object subscriptionHandle, object requestHandle, ItemValueResult[] values);
//回调
private OpcCallback m_callback = null;
m_callback = new OpcCallback(this.ClientHandle, 0, this);
//事件
public event DataChangedEventHandler DataChanged
{
add { lock (this) { m_callback.DataChanged += value; Advise(); } }
remove { lock (this) { m_callback.DataChanged -= value; Unadvise(); } }
}
#region 数据变化事件
private void Group_DataChanged(object subscriptionHandle, object requestHandle, ItemValueResult[] values)
{
//ItemValueResult类可直接绑定至前台界面
//使用Dispatcher.Invoke()更新UI界面
this.Dispatcher.Invoke(() =>
{
dgData.ItemsSource = null;
dgData.ItemsSource = dataSource;
});
}
#endregion
3.3 有效释放COM资源
虽然C#有内存自动管理的机制,但是COM通信相关的资源并不在C#管理的范围内,需要自行管理释放。
OPCServer断开连接时,释放资源。
public partial class OPCServer
{
public void Disconnect()
{
try
{
// remove group first
if (this.m_groups.Count > 0)
{
ArrayList grps = new ArrayList(this.m_groups);
foreach (OpcGroup grp in grps)
{
((IOPCServer)m_server).RemoveGroup((int)grp.ServerHandle, 0);
this.m_groups.Remove(grp);
}
grps.Clear();
this.m_groups.Clear();
}
if (m_server != null)
{
Interop.ReleaseServer(m_server);
//Marshal.ReleaseComObject(m_server);
m_server = null;
}
m_isConnected = false;
}
catch (Exception ex)
{
throw new Exception("Could not disconnect to server.");
}
}
}
public partial class Interop
{
/// <summary>
/// 释放COM资源.
/// </summary>
public static void ReleaseServer(object server)
{
if (server != null && server.GetType().IsCOMObject)
{
int result = 0;
do
{
result = Marshal.ReleaseComObject(server);
}
while (result > 0);
}
}
}
提取断开服务方法,点击“断开”按钮或关闭窗口时断开所有OPCServer。
//断开服务方法
private void DisConnectServer(bool isShow=true)
{
if (dicServer != null && dicServer.Count > 0)
{
foreach (var kv in dicServer)
{
OpcServer server = kv.Value;
foreach (OpcGroup group in server.Groups)
{
group.DataChanged -= Group_DataChanged;//解绑数据变化事件
}
string serverId = kv.Key;
server.Disconnect();//断开服务连接
if (isShow)//是否在界面上显示消息
{
WriteToStatus(server.HostName + "--" + server.ServerName+ "已断开!");
}
}
dicServer.Clear();
}
}
注意应在OPCServer断开前将OPCGroup标签组上绑定的事件全部解绑掉。在实际实验中发现,OPCServer类内置的断开方法调用的Marshal.ReleaseComObject()方法始终无反馈,使得COM资源没有被释放,UI线程也被阻塞卡死。将OPCGroup上绑定的事件全部解绑后,OPCServer的Disconnect()就能顺利执行。现象如此,是否有必然联系及深层次原因还不清楚。
3.4 其他改进
除了以上功能的改进外,还引入了新的UI框架Mahapps.Metro,同时利用UI框架的特性,在后台进行耗时操作时,前台显示进度条来提醒用户。
#region 耗时操作提示
private async void ShowAwaitProgress(Action action)
{
var mySetting = new MetroDialogSettings()
{
AnimateShow = false,
AnimateHide = false
};
var controller = await this.ShowProgressAsync("提示", "正在处理数据中...");
controller.SetIndeterminate();
await Task.Run(action);//等待当前操作完成
await controller.CloseAsync();
}
#endregion
UI框架Mahapps.Metro的使用可以参考
4.下阶段改进方向
经过以上的修改完善,新的OPCClient不仅界面上更加漂亮,执行效率更高,资源占用率也稳定的保持在较低的水平。但通过最近查阅资料发觉现有的解决方案仍有很大的提升空间。
- 在OPC连接方面,目前采用的是OPC DA标准,而目前较新的OPC UA标准能够兼容OPC DA标准,同时在底层实现上不再受制于COM,适用范围更广。所以从OPC DA转向OPC UA是有必要的。
OPC DA自定义接口的封装参考了开源库TitaniumAS.Opc.Client,GitHub地址为TitaniumAS.Opc.Client
- 在实时数据传输方面,目前采用的是微软的SignalR解决方案,但是在实际使用范围并不是很广,跨语言平台的支持性也不够好。从各方面的反馈来看,MQTT是比较流行的传输协议之一,适用范围也更广,相关资源也更多些。所以可以考虑添加对MQTT协议的支持。
只有经过不断的思考、实验,才能真正有所收获。谨以自勉。