能耗监测平台GPRS通讯服务器的架构设计
在这个文章里面我将用一个实际的案例来分享如何来构建一个能够接受3000+个连接的GPRS通讯服务器软件。在这里,我将分享GPRS通讯服务器设计过程中面临的问题,分享通讯协议的设计,分享基于异步事件的设计,分享避免内存泄露的解决方案,分享软件的发布与升级方法,分享通讯协议的单元测试构建等。
1 GPRS通讯服务器软件介绍
首先我们来看一下这个通讯服务器软件,如下图所示。通讯服务器软件的作用是遵循国家能耗平台技术导则的数据传输导则,与GPRS硬件进行通讯,实现数据的远程传输和远程实时控制。
这个软件的主要功能有:
(1)接收GPRS采集器的连接,实现对采集器的控制;
(2)实现能耗A~D类数据库的管理。
下面我来介绍通讯服务器的设计方法和思路,接着再介绍如何实现。
2 通讯服务器的设计模型
2.1 通讯服务器架构
通讯服务器架构采用的是异步事件 + 分层的体系结构。通过异步事件实现不同职责代码的分离,利用分层将相同职责的代码组织到同一个层次。整体设计如下所示。
通讯服务器使用EventDispatcher实现不同事件类型的异步路由。通讯服务器是整个系统的中心,它接收来自硬件层GPRS的连接(实际是HTTP的连接),为每一个连接创建一个会话(CommProtocol),每一个会话使用一个线程(也支持线程池)进行通讯,HttpCommServer实现会话的管理,此外,与领域层实现事件传递。领域层实现与上层应用的通讯,包括:(1)将数据结果存储到数据库;(2)通过消息队列接受来自硬件的通讯指令;(3)与通讯服务器打交道。通讯协议层实现与不同连接的采集器进行通讯,它是整个系统的难点。
2.2 通讯协议层的设计
通讯协议层核心类为CommProtocol,它使用线程来与硬件通讯,与硬件的通讯过程被拆分一个个的对话,每一个对话用一个RoundTrip类来表示,CommProtocol使用一个RoundTrip来存储所有的对话,利用线程不停的轮询存储的对话,然后一个个的按顺序/按优先级来执行对话。下图是通讯协议层的设计模型。
在与硬件的通讯过程中,通讯以对话作为单位的,以消息作为对话的基石,一次对话实现一组消息的传递。通讯协议中,消息有两种类型:(1)服务器发送给硬件的消息称为主动消息;(2)硬件发送给服务器的消息称为被动消息。对话则有三种类型:(1)服务器发送消息给硬件,然后等待硬件的回复消息或者不等待回复,我们称之为主动对话;(2)服务器等待硬件发送的数据,收到数据后给硬件回复或者不回复,我们称之为被动对话;(3)以上二者的组合,来实现一组控制指令的传递,我们称之为组合对话。在这个模型中,服务器需要来控制硬件时,会调用CommProtocol的一个方法,比如QueryConfig方法,用于查询硬件的配置信息,此时,将创建一个主动会话,然后发送到对话队列中,对话处理线程将从对话队列中按序取出对话,并执行;当对话队列为空时,对话处理线程将会使用被动对话类型注册表,尝试从通讯链路获取一条完整消息,然后创建一个被动对话并执行。在对话处理线程处理一个主动对话时,它通常是:(1)使用消息适配器发送一个消息,如果失败后,会重试几次;接着使用消息适配器来获取一条响应或者直接返回,当消息发送时会抛出OnMessageSend事件,当对话成功时会发出OnCompleted事件,当失败时抛出OnError事件。类似的,被动对话的设计也相似,不同的是,其消息已经提前收到了。下面我们就来看看通讯协议层详细的设计。
2.3 通讯协议层详细设计
2.3.1 消息的设计
首先我们先来看看公共建筑数据传输规范里面的消息定义方式。
下面我们来看看消息类型的设计
在上述的消息定义中,MessageBase表示所有消息的基类由消息头、消息体组成,它们都从MessagePart派生。每一个消息头由MessageHeader,它定义了能耗建筑的建筑物ID、采集器ID和消息类型。MessageSerializer消息序列化静态类用于实现消息的解析与反解析。
以下XML格式是服务器配置数据采集器时的消息格式。通讯时,服务器发送一个period类型的消息,用于配置采集器定时上报数据的间隔,然后数据采集器响应一条period_ack消息。
<?xml version="1.0" encoding="utf-8" ?> <root> <!-- 通用部分 --> <!-- building_id:楼栋编号 gateway_id:采集器编号 type:配置信息数据包的类型 --> <common> <building_id>XXXXXX</building_id > <gateway_id>XXX</gateway_id > <type>以2种操作类型之一</type> </common> <!-- 配置信息 --> <!--操作有2种类型 period:表示服务器对采集器采集周期的配置,period子元素有效 period_ack:表示采集器对服务器采集周期配置信息的应答 --> <config operation="period/period_ack"> <period>15</period> </config> </root>
根据规范的消息格式,我们定义的配置消息由主动消息体、主动消息、被动消息体和被动消息四个类构成。
主动消息体定义如下。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Serialization; namespace UIShell.EcmCommServerService.Protocol.Message { [XmlRoot("config", Namespace = "", IsNullable = false)] public class ConfigActiveMessageBody : MessagePart { [XmlAttribute("operation")] public string Operation { get; set; } [XmlElement("period")] public int Period { get; set; } public ConfigActiveMessageBody() { Operation = "period"; } } }
主动消息定义如下。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Serialization; using UIShell.EcmCommServerService.Utility; namespace UIShell.EcmCommServerService.Protocol.Message { [XmlRoot("root", Namespace = "", IsNullable = false)] public class ConfigActiveMessage : MessageBase { public static ConfigActiveMessage New(string buildingId, string gatewayId, int period) { var message = new ConfigActiveMessage(); message.Header.BuildingId = buildingId; message.Header.GatewayId = gatewayId; message.Body.Period = period; return message; } [XmlElement("config")] public ConfigActiveMessageBody Body { get; set; } public ConfigActiveMessage() : base(StringEnum.GetStringValue(MessageType.Config_Period)) { Body = new ConfigActiveMessageBody(); } } }
被动消息体定义如下。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Serialization; namespace UIShell.EcmCommServerService.Protocol.Message { [XmlRoot("config", Namespace = "", IsNullable = false)] public class ConfigAckPassiveMessageBody : MessagePart { [XmlAttribute("operation")] public string Operation { get; set; } public ConfigAckPassiveMessageBody() { Operation = "period_ack"; } } }
被动消息定义如下。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Serialization; using UIShell.EcmCommServerService.Utility; namespace UIShell.EcmCommServerService.Protocol.Message { [XmlRoot("root", Namespace = "", IsNullable = false)] public class ConfigAckPassiveMessage : MessageBase { public static ConfigAckPassiveMessage New(string buildingId, string gatewayId) { var message = new ConfigAckPassiveMessage(); message.Header.BuildingId = buildingId; message.Header.GatewayId = gatewayId; return message; } [XmlElement("config")] public ConfigAckPassiveMessageBody Body { get; set; } public ConfigAckPassiveMessage() : base(StringEnum.GetStringValue(MessageType.Config_Period_Ack)) { Body = new ConfigAckPassiveMessageBody(); } } }
根据以上的模式,我们为能耗平台定义的所有消息如下。
2.3.2 RoundTrip的设计
RoundTrip表示一次对话,由一组消息的交换来实现。RoundTrip有三种类型,其设计如下所示。RoundTripBase是对话的基类,它定义了OnCompleted、OnError异步事件、Start方法和其它基本属性;ActiveRoundTripBase表示主动对话基类,表示服务器发送给采集器消息,然后等待或者不等待消息,这个类在RoundTripBase基础上定义了OnMessageSend异步事件;PassiveRoundTripBase表示被动对话基类,定义了OnMessageReceived事件,表示已经从采集器接收到消息。这些基类都与领域知识无关,只是定义了对话基类所需的方法、属性、事件。
ActiveRoundTrip则是针对能耗平台定义的所有主动消息的基类,它定义了领域相关的属性,实现了Start方法,并定义了相关抽象类。我们来看一下Start方法,它首先调用Send方法来发送消息,然后抛出OnMessageSend异步事件,接着调用ReceiveResponseMessage尝试从采集器收取消息然后抛出OnCompleted异步事件,这个过程如果失败了,能够重试,不过如果重试也失败,则抛出OnError事件。
public override void Start() { Trace(string.Format("开始与集中器{0}会话。", ToBeSentMessage.Header.GatewayId)); // 如果发送失败,则重试。 // 尝试次数为: 1 + 失败时重复次数 for (int i = 0; i <= MessageConstants.RetryTimesOnTimeout; i++) { try { Send(); OnRoundTripMessageSend(new RoundTripEventArgs() { RoundTrip = this }); try { Trace(string.Format("开始第{0}次消息接收。", i + 1)); ReceiveResponseMessages(); OnRoundTripCompleted(new RoundTripEventArgs() { RoundTrip = this }); break; } catch (Exception ex) { Trace(string.Format("第{0}次接收消息失败,因为:{1},继续尝试。", i + 1, CommProtocol.GetErrorMessage(ex))); throw; } } catch (Exception ex) { Trace(string.Format("第{0}次发送命令失败,因为:{1},继续尝试。", i + 1, CommProtocol.GetErrorMessage(ex))); if (i == MessageConstants.RetryTimesOnTimeout) { Trace(string.Format("第{0}次发送命令失败,因为:{1},停止尝试。", i + 1, CommProtocol.GetErrorMessage(ex))); OnRoundTripError(new RoundTripEventArgs() { RoundTrip = this, Exception = ex }); throw; } } } Completed = true; if (ReceivedResponseMessages != null) { Trace(string.Format("当前会话'{0}'接收了{1}个响应消息,详细如下:", RoundTripDescription, ReceivedResponseMessages.Length)); foreach (var message in ReceivedResponseMessages) { //Trace("响应命令内容:" + ProtocolUtility.BytesToHexString(message.ToContent())); Trace("响应消息:" + message.ToString()); } } else { Trace("当前会话接收的响应消息为空。"); } Trace(string.Format("与集中器{0}会话成功。", ToBeSentMessage.Header.GatewayId)); }
发送消息Send方法实现如下,它使用StreamAdapter来发送一条原始消息。
public override void Send() { StreamAdapter.SendRawMessage(ToBeSentMessage.ToContent(), ToBeSentMessage.ToXmlContent()); }
而ReceiveResponseMessages方法则是一个抽象方法。
public abstract void ReceiveResponseMessages();
同理,PassiveRoundTrip则是针对能耗平台定义的所有被动消息的基类,它定义了领域相关的属性,实现了Start方法和相应的抽象方法。
public abstract TResponseMessage CreateResponseMessage(); public override void Receive() { if (ReceivedMessage == null) { MessageHeader header; var receivedMessageContent = StreamAdapter.ReceiveRawMessage(BuildingId, GatewayId, ReceivedMessageType, out header); try { ReceivedMessage = MessageSerialiser.DeserializeRaw<TReceivedMessage>(receivedMessageContent); } catch (Exception ex) { throw new ReceiveMessageException("Parse the received message failed.", ex) { ErrorStatus = ReceiveMessageStatus.Failed }; } } } public void SendResponseMessage() { ResponsedMessage = CreateResponseMessage(); if (ResponsedMessage != null) { //Trace("开始发送的响应消息内容:" + ProtocolUtility.BytesToHexString(ResponsedMessage.ToContent())); Trace("开始发送的响应消息:" + ResponsedMessage.ToXmlContent()); StreamAdapter.SendRawMessage(ResponsedMessage.ToContent(), ResponsedMessage.ToString()); } else { Trace("不发送响应消息。"); } } public override void Start() { Trace(string.Format("开始尝试与集中器{0}进行被动式会话。", GatewayId)); try { Receive(); OnRoundTripMessageReceived(new RoundTripEventArgs() { RoundTrip = this }); try { //Trace("接收到消息内容:" + ProtocolUtility.BytesToHexString(ReceivedMessage.ToContent())); Trace("接收到消息:" + ReceivedMessage.ToXmlContent()); SendResponseMessage(); Completed = true; OnRoundTripCompleted(new RoundTripEventArgs() { RoundTrip = this }); } catch (Exception ex) { Trace(string.Format("尝试发送响应消息到集中器{0}失败,因为:{1}。", GatewayId, CommProtocol.GetErrorMessage(ex))); throw; } } catch (Exception ex) { Trace(string.Format("尝试从集中器{0}接收消息失败,因为:{1}。", GatewayId, CommProtocol.GetErrorMessage(ex))); OnRoundTripError(new RoundTripEventArgs() { RoundTrip = this, Exception = ex }); throw; } Trace(string.Format("与集中器{0}进行被动式会话成功。", GatewayId)); }
组合对话CompositeRoundTrip是根据能耗平台设计的,它比较简单,主要是控制每条对话的执行时序,默认的实现就是按顺序来执行每一个对话。
public override void Start() { int i = 1; int roundTripsCount = RoundTrips.Count; Trace(string.Format("开始组合会话,由{0}个子会话组成。", roundTripsCount)); RoundTripBase roundTrip; while (RoundTrips.Count > 0) { roundTrip = RoundTrips.Dequeue(); try { Trace(string.Format("开始执行第{0}个子会话。", i)); roundTrip.Start(); Trace(string.Format("第{0}个子会话执行完成。", i)); } catch (Exception ex) { Trace(string.Format("组合会话失败,第{0}个子会话执行失败。", i)); OnRoundTripError(new RoundTripEventArgs() { RoundTrip = roundTrip, Exception = ex }); throw; } finally { roundTrip.Dispose(); } i++; } Trace(string.Format("组合会话完成,由{0}个子会话组成。", roundTripsCount)); OnRoundTripCompleted(new RoundTripEventArgs() { RoundTrip = this }); }
接下来我们看看一个主动对话的实现,以ConfigActiveRoundTrip为例。
using System; using System.Collections.Generic; using System.Linq; using System.Net.Sockets; using System.Text; using UIShell.EcmCommServerService.Protocol.Message; using UIShell.EcmCommServerService.Utility; namespace UIShell.EcmCommServerService.Protocol.RoundTrip.Active { public class ConfigActiveRoundTrip : ActiveRoundTrip<ConfigActiveMessage, ConfigAckPassiveMessage> { public ConfigActiveRoundTrip( string buildingId, string gatewayId, int period, MessageConstants messageConstants, TcpClient client) : base(buildingId, gatewayId, StringEnum.GetStringValue(MessageType.Config_Period_Ack), ConfigActiveMessage.New(buildingId, gatewayId, period), messageConstants, client) { } public override void ReceiveResponseMessages() { MessageHeader header; var messageContent = ReceiveRawMessage(out header); var message = MessageSerialiser.DeserializeRaw<ConfigAckPassiveMessage>(messageContent); ReceivedResponseMessages = new ConfigAckPassiveMessage[] { message }; } } }
下面再看看被动对话的实现,这是一条心跳检测消息,由采集器定时发送给服务器来保持通讯链路。
using System; using System.Collections.Generic; using System.Linq; using System.Net.Sockets; using System.Text; using UIShell.EcmCommServerService.Protocol.Message; using UIShell.EcmCommServerService.Utility; namespace UIShell.EcmCommServerService.Protocol.RoundTrip.Passive { public class HeartBeatPassiveRoundTrip : PassiveRoundTrip<HeartBeatNotifyPassiveMessage, HeartBeatTimeActiveMessage> { public HeartBeatPassiveRoundTrip( string buildingId, string gatewayId, MessageConstants messageConstants, TcpClient client) : base(buildingId, gatewayId, StringEnum.GetStringValue(MessageType.HeartBeat_Notify), messageConstants, client) { IsKeepAliveRoundTrip = true; } public HeartBeatPassiveRoundTrip( HeartBeatNotifyPassiveMessage receiveMessage, MessageConstants messageConstants, TcpClient client) : this(receiveMessage.Header.BuildingId, receiveMessage.Header.GatewayId, messageConstants, client) { ReceivedMessage = receiveMessage; } public override HeartBeatTimeActiveMessage CreateResponseMessage() { return HeartBeatTimeActiveMessage.New(BuildingId, GatewayId, DateTime.Now); } } }
下面我们看看一个比较复杂的对话,文件传输。文件传输的过程为:(1)将文件分包,然后一包一包传输;(2)查询缺包情况;(3)如果有缺包,则继续发送缺失的包,直至成功;如果没有缺包,则传输完成。
public override void Start() { // 检查离线存储区是否存在未传输完成的文件 var item = ContinuousFileStorage.GetNotCompletedFile(BuildingId, GatewayId); if (item == null && Content == null) // 说明被调用的是protected的构造函数,用于检测是否需要进行断点续传。 { Trace(string.Format("集中器{0}不需要进行文件断点续传。", GatewayId)); return; } try { if (item == null) // 从头开始传输文件 { Trace(string.Format("集中器{0}开始进行文件传输。", GatewayId)); Trace(string.Format("文件名称:{0},文件长度:{1},包大小:{2},包数:{3}。", FileName, Content.Length, PackageSize, PackageCount)); // 创建离线存储区 ContinuousFileStorage.StartFileTransfer(BuildingId, GatewayId, FileType, FileName, Content, PackageSize); List<int> indexes = new List<int>(); for (int index = 1; index <= PackageCount; index++) { indexes.Add(index); } SendFilePackage(indexes); } else // 开始断点续传 { Trace(string.Format("集中器{0}上次文件传输未完成,继续进行断点续传。", GatewayId)); Trace(string.Format("断点续传的文件名称:{0},文件长度:{1},包大小:{2},包数:{3}。", FileName, Content.Length, PackageSize, PackageCount)); } // 查询丢失的包并重传 if (QueryLostPackageAndResend()) { // 删除离线存储区 ContinuousFileStorage.EndFileTransfer(BuildingId, GatewayId, FileType, FileName, PackageSize); Trace(string.Format("集中器{0}文件传输成功。", GatewayId)); OnRoundTripCompleted(new RoundTripEventArgs() { RoundTrip = this }); } else { Trace(string.Format("集中器{0}文件传输未完成。", GatewayId)); OnRoundTripError(new RoundTripEventArgs() { RoundTrip = this, Exception = new Exception(string.Format("集中器{0}文件传输未完成。", GatewayId)) }); ContinuousFileStorage.IncrementFileTransferFailedCount(BuildingId, GatewayId); } } catch(Exception ex) { OnRoundTripError(new RoundTripEventArgs() { RoundTrip = this, Exception = ex }); ContinuousFileStorage.IncrementFileTransferFailedCount(BuildingId, GatewayId); } }
最后我们看一下能耗平台的对话类型,它由主动、被动和组合对话构成。
2.3.3 通讯协议类的设计
通讯协议类是系统的一个核心类,它为每一个通讯连接创建了一个独立的通讯线程和对话队列,并在队列空闲的时候一直尝试从链路中获取被动消息,一旦有被动消息获取,则创建被动对话,然后发送到队列中。
以下方法是实现的核心,通讯线程首先从对话队列中获取对话,然后运行该对话,如果对话抛出了CommStreamException,说明链路关闭,则需要停止当前通讯协议;如果抛出了ThreadAboutException,说明被终止,则需要直接抛出异常;另外,如果对话队列为空时,则尝试检查被动对话。
public bool Start() { if (_started) { return true; } FireOnStarting(); _commThread = new Thread(() => { RoundTripBase roundTrip; while (!_exited) { Monitor.Enter(_queue.SyncRoot); roundTrip = Dequeue(); if (roundTrip != null) { try { try { Monitor.Exit(_queue.SyncRoot); OnRoundTripStartingHandler(this, new RoundTripEventArgs() { RoundTrip = roundTrip }); roundTrip.Start(); } catch (ThreadAbortException) { Trace("通讯线程被终止。"); throw; } catch (CommStreamException ex) // 无法获取Stream的时候,直接退出??需要加一个标志位 // 需要抛出事件,通知后续处理,如将RoundTrip另存 { _exited = true; roundTrip.Trace("会话失败,因为:链路已经关闭。"); _log.Error(string.Format("Start the round trip '{0}' error.", roundTrip), ex); } catch (Exception ex) { string error = GetErrorMessage(ex); roundTrip.Trace(string.Format("会话失败,因为:{0}。", error)); _log.Error(string.Format("Start the round trip '{0}' error.", roundTrip), ex); } if (!_exited) { roundTrip.Trace(Environment.NewLine); OnRoundTripStartedHandler(this, new RoundTripEventArgs() { RoundTrip = roundTrip }); } else { // 1 将当前失败的RoundTrip保存入队 FailedRoundTrips.Enqueue(roundTrip); // 2 保存其它没有处理的RoundTrip do { roundTrip = _queue.Dequeue(); if (roundTrip != null) { FailedRoundTrips.Enqueue(roundTrip); } } while (roundTrip != null); // 3 停止当前协议 Stop(); } // 执行完RoundTrip后,开始清理资源 roundTrip.Dispose(); } catch (ThreadAbortException) { Trace("通讯线程被终止。"); throw; } catch (Exception ex) { _log.Error("Unhandled exception in CommProtocol.", ex); } } else { Monitor.Exit(_queue.SyncRoot); OnIdleHandler(this, new RoundTripEventArgs()); ContinuousFileTransfer(); try { CheckPassiveRoundTripAvailableAndEnqueue(); if (_queue.Count == 0) { Thread.Sleep((int)MessageConstants.PassiveRoundTripCheckInterval.TotalMilliseconds); } } catch (ThreadAbortException) { Trace("通讯线程被终止。"); throw; } catch (CommStreamException ex) // 无法获取Stream的时候,直接退出??需要加一个标志位 // 需要抛出事件,通知后续处理,如将RoundTrip另存 { _exited = true; Trace("检查被动消息失败,因为:链路已经关闭。"); _log.Error("Check the passive message error.", ex); } catch (Exception ex) { string error = GetErrorMessage(ex); Trace(string.Format("检查被动消息失败,因为:{0}。", error)); _log.Error("Check the passive message error.", ex); } //_autoResetEvent.WaitOne(); } } }); _commThread.Start(); _started = true; FireOnStarted(); return true; }
检查被动对话的方法实现如下,首先检查链路是否关闭,如果关闭,则直接停止协议;接着从共享缓存获取被动消息,如果查找到被动消息,则创建被动对话,然后加入对话队列;最后,尝试从链路中读取一条被动消息。
public void CheckPassiveRoundTripAvailableAndEnqueue() { Trace("开始检查被动通讯。"); if (!NetworkUtility.IsConnected(Client)) { Trace("被动通讯检测时,链路已经关闭,关闭会话。"); Stop(); return; } // 1 Check ShardInputBuffer UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]> tuple; foreach (var pair in _passiveRoundTripFactoryRegistry) { while ((tuple = SharedInputBuffer.FindAndThenRemove(BuildingId, GatewayId, pair.Key)) != null) { Trace(string.Format("从共享缓冲区获取到一条被动消息,消息头为:'{0}'。", pair.Key)); Enqueue(pair.Value(tuple.Item1, tuple.Item2)); } } // 2 Check StreamAdapter if (Client.Available == 0) { Trace("通讯链路没有可用数据。"); if (!NetworkUtility.IsConnected(Client)) { Trace("被动通讯检测时,链路已经关闭,关闭会话。"); Stop(); return; } } else { while (Client.Available > 0) { MessageHeader header; var content = CommStreamAdapter.ReceiveOneRawMessage(out header); if (content != null && content.Length > 0 && header != null) { // 1 如果当前消息在被动检测时收到,但不属于注册的被动消息,则放弃该消息。 if (header.BuildingId.Equals(BuildingId) && header.GatewayId.Equals(GatewayId) && !_passiveRoundTripFactoryRegistry.ContainsKey(header.MessageType)) { Trace(string.Format("从通讯链路获取到一条被动消息,该消息不是注册的被动消息,忽略它,被忽略的消息头为:'{0}'。", header)); continue; } // 2 如果当前消息在被动检测时收到,并不属于当前通讯线程处理的范围,则添加到共享缓冲区。 // TODO: 这可能会产生一个Bug,如果接收到其它线程的消息时怎么办? else if (!header.BuildingId.Equals(BuildingId) || !header.GatewayId.Equals(GatewayId) || !_passiveRoundTripFactoryRegistry.ContainsKey(header.MessageType)) { Trace(string.Format("从通讯链路获取到一条被动消息,添加到共享缓冲区,消息头为:'{0}'。", header)); SharedInputBuffer.AddSharedBufferItem(new OSGi.Utility.Tuple<MessageHeader, byte[]> { Item1 = header, Item2 = content }); } else // 3 如果是当前可以处理的被动消息,则创建一个被动RoundTrip { CreateRoundTripDelegate createRoundTrip; if (_passiveRoundTripFactoryRegistry.TryGetValue(header.MessageType, out createRoundTrip)) { Trace(string.Format("从通讯链路获取到一条被动消息,添加到通讯队列,消息头为:'{0}'。", header)); Enqueue(createRoundTrip(header, content)); } else { Trace(string.Format("从通讯链路获取到一条被动消息,添加到共享缓冲区,消息头为:'{0}'。", header)); SharedInputBuffer.AddSharedBufferItem(new OSGi.Utility.Tuple<MessageHeader, byte[]> { Item1 = header, Item2 = content }); } } } } if (!NetworkUtility.IsConnected(Client)) { Trace("被动通讯检测时,链路已经关闭,关闭会话。"); Stop(); return; } } Trace("检查被动通讯完成。"); }
通讯协议使用RoundTripQueue来保存所有的对话,它是一个线程安全类,以下是Enqueue方法的实现。
public RoundTripQueue FailedRoundTrips = new RoundTripQueue(); public void Enqueue(RoundTripBase roundTrip) { if (!_started) { throw new Exception("The protocol is not started yet or exited."); } _queue.Enqueue(roundTrip); OnRoundTripEnquedHandler(this, new RoundTripEventArgs() { RoundTrip = roundTrip }); if (!(roundTrip.IsKeepAliveRoundTrip) || MessageConstants.ShowKeepAliveMessage) { roundTrip.OnTraceMessageAdded += DispatchAsyncTraceMessageAddedEvent; } try { _autoResetEvent.Set(); } catch { } }
上述3个方法实现了整个通讯模型。对于主动对话,我们还会为通讯协议创建一个相应的方法,并将对话加入到队列中。下面是CommProtocol通讯协议类中Config的方法实现,Public方法为向领域层暴露的功能,而Internal方法则为了内部的单元测试,其实现非常简单。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.EcmCommServerService.Protocol.RoundTrip.Active; namespace UIShell.EcmCommServerService.Protocol { public partial class CommProtocol { public void Config( int period, EventHandler<RoundTripEventArgs> onMessageSend, EventHandler<RoundTripEventArgs> onCompleted, EventHandler<RoundTripEventArgs> onError) { ConfigActiveRoundTrip roundTrip; Config(period, onMessageSend, onCompleted, onError, out roundTrip); } internal void Config( int period, EventHandler<RoundTripEventArgs> onMessageSend, EventHandler<RoundTripEventArgs> onCompleted, EventHandler<RoundTripEventArgs> onError, out ConfigActiveRoundTrip roundTrip) { var configRoundTrip = new ConfigActiveRoundTrip(BuildingId, GatewayId, period, MessageConstants, Client); if (onMessageSend != null) { configRoundTrip.OnMessageSend += onMessageSend; } if (onCompleted != null) { configRoundTrip.OnCompleted += onCompleted; } if (onError != null) { configRoundTrip.OnError += onError; } Enqueue(configRoundTrip); roundTrip = configRoundTrip; } } }
2.3.4 通讯服务的实现
通讯服务器HttpCommServer用于打开一个TCP端口,接受TCP连接,当连接登录成功后,为每一个连接创建一个会话,并与领域层业务逻辑关连。下面我们看一下它的实现,其核心方法为ListenGprsRequest,在该方法中,首先为每一个连接进行一次身份验证,验证通过后,创建一个会话,然后添加到会话列表中。
public partial class HttpCommServer : TrackableBase { public MessageConstants MessageConstants { get; private set; } public string IPAddressString { get; private set; } public int Port { get; private set; } public ThreadSafeList<CommProtocol> Sessions { get; private set; } private Thread _listenerThread; private TcpListener _listener; private volatile bool _exited; private ILog _log; private object _syncRoot = new object(); public HttpCommServer(string ipaddress, int port, MessageConstants messageConstants) { IPAddressString = ipaddress; Port = port; Sessions = new ThreadSafeList<CommProtocol>(); MessageConstants = messageConstants; _log = BundleActivator.LogService.CreateLog(BundleActivator.Bundle, GetType()); RegisterDomainHandlerCreationDelegates(); } public CommProtocol GetSession(string buildingId, string gatewayId) { return Sessions.Find(s => s.BuildingId.Equals(buildingId) && s.GatewayId.Equals(gatewayId)); } public void Start() { lock (_syncRoot) { IPEndPoint local = new IPEndPoint(IPAddress.Parse(IPAddressString), Port); _listener = new TcpListener(local); _listener.Start(); _listenerThread = new Thread(new ThreadStart(ListenGprsRequest)); _listenerThread.Start(); } OnSessionChanged += OnSessionChangedForDomain; } private void ListenGprsRequest() { while (!_exited) { // 接受一次连接 if (!_exited) { try { TcpClient tcpClient = null; try { tcpClient = _listener.AcceptTcpClient(); Trace(string.Format("接收到来自IP地址'{0}'的连接。", (tcpClient.Client.RemoteEndPoint as IPEndPoint).Address)); _log.Info(string.Format("Accept new connection from ip '{0}'.", (tcpClient.Client.RemoteEndPoint as IPEndPoint).Address)); lock (_syncRoot) { if (!_exited) { var loginRoundTrip = new LoginCompositeRoundTrip(MessageConstants, tcpClient); loginRoundTrip.ParentTracker = this; loginRoundTrip.Start(); loginRoundTrip.Dispose(); var session = new CommProtocol(loginRoundTrip.BuildingId, loginRoundTrip.GatewayId, MessageConstants, tcpClient); session.ParentTracker = this; session.Start(); AddSession(session); // 清空离线存储区 ContinuousDataStorage.Reset(loginRoundTrip.GatewayId); _log.Info(string.Format("Start the session for gateway '{0}' of the building '{1}'.", session.GatewayId, session.BuildingId)); } } } catch (ThreadAbortException) { throw; } catch (Exception ex) { try { if (tcpClient != null) // 登录失败,断开连接 { tcpClient.Close(); } } catch { } // Trace(string.Format("登录失败,失败IP地址为'{0}'。", tcpClient != null ? (tcpClient.Client.RemoteEndPoint as IPEndPoint).Address.ToString() : "N/A")); _log.Error("The connection login failed.", ex); } } catch (ThreadAbortException) { throw; } catch (Exception ex) { _log.Error("Can not listen any more.", ex); break; } } } } public void Stop() { if (_exited) { return; } _log.Info("The server is stopping."); lock (_syncRoot) { _log.Info("The sessions are stopping."); // 不能使用 Sessions.ForEach(s => s.Stop()),这是因为s.Stop将会删除Sessions // 从而改变ForEach的行为,造成Session泄露。 var sesions = Sessions.ToArray(); foreach (var session in sesions) { session.Stop(); } OnSessionChanged -= OnSessionChangedForDomain; _log.Info("The sessions are stopped and cleared."); _listener.Stop(); _log.Info("The listener is stopped."); Thread.Sleep(1000); try { _listenerThread.Abort(); } catch { } _log.Info("The listener thread is stopped."); _exited = true; } _log.Info("The server is stopped."); SharedInputBuffer.ClearSharedBuffer(); } public event EventHandler<SessionChangedEventArgs> OnSessionChanged; public void AddSession(CommProtocol session) { var oldSession = Sessions.Find(p => p.BuildingId.Equals(session.BuildingId) && p.GatewayId.Equals(session.GatewayId)); if (oldSession != null) { RemoveSession(oldSession); _log.Info(string.Format("The session for gateway '{0}' of building '{1}' already existed, it will be deleted first.", session.GatewayId, session.BuildingId)); } Sessions.Add(session); session.OnStopped += OnSessionStopped; _log.Info(string.Format("Add the session for gateway '{0}' of building '{1}'.", session.GatewayId, session.BuildingId)); Trace(string.Format("为采集器'{0}'创建通讯会话,目前会话数目为'{1}'。", session.GatewayId, SessionNumber)); if (OnSessionChanged != null) { OnSessionChanged(this, new SessionChangedEventArgs() { ChangedAction = CollectionChangedAction.Add, BuildingId = session.BuildingId, GatewayId = session.GatewayId, Session = session }); } } private void OnSessionStopped(object sender, EventArgs e) { RemoveSession(sender as CommProtocol); } public void RemoveSession(CommProtocol session) { session.OnStopped -= OnSessionStopped; Sessions.Remove(session); _log.Info(string.Format("Remove the session for gateway '{0}' of building '{1}'.", session.GatewayId, session.BuildingId)); Trace(string.Format("集中器'{0}'通讯会话已经断开,目前会话数目为'{1}'。", session.GatewayId, SessionNumber)); if (OnSessionChanged != null) { OnSessionChanged(this, new SessionChangedEventArgs() { ChangedAction = CollectionChangedAction.Remove, BuildingId = session.BuildingId, GatewayId = session.GatewayId, Session = session }); } } }
关于通讯服务器核心的实现已经介绍完成了,下面我们来看看领域层的实现。
3 领域层的实现
本系统的核心设计是基于事件 + 分层的体系结构。通讯服务器与数据采集器硬件的通讯都与领域相关逻辑有关。为了使程序设计更加简单化,引入事件对各个层次的代码解耦,通过事件来关联领域知识与硬件的通讯过程,这样也方便通讯协议层的测试。这里HttpCommServer管理了所有的通讯会话实例和对话-领域处理器管理。
以下是领域逻辑关联的代码。它的作用为:1 监听SessionChanged事件,为每一个Session的OnRoundTripEnqueued创建领域处理事件; 2 在领域处理事件中,为RoundTrip关联相应的领域处理类,领域处理类订阅了RoundTrip的OnCompleted和OnError事件,在里面进行相应处理。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.EcmCommServerService.Domain; using UIShell.EcmCommServerService.Protocol; using UIShell.EcmCommServerService.Protocol.RoundTrip.Passive; using UIShell.OSGi.Utility; namespace UIShell.EcmCommServerService.Server { public partial class HttpCommServer { /// <summary> /// 跟踪每一个Session的RoundTripEnqueued事件,当有RoundTrip注册时,便注册事件,处理领域知识。 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void OnSessionChangedForDomain(object sender, SessionChangedEventArgs e) { if (e.ChangedAction == OSGi.CollectionChangedAction.Add) { e.Session.OnRoundTripEnqued += OnSessionRoundTripEnqued; } else { e.Session.OnRoundTripEnqued -= OnSessionRoundTripEnqued; } } private Dictionary<Type, CreateDomainHandlerDelegate> _handlers = new Dictionary<Type, CreateDomainHandlerDelegate>(); private void RegisterDomainHandlerCreationDelegates() { _handlers.Add(typeof(DataReportPassiveRoundTrip), roundTrip => new DataReportDomainHandler() { RoundTrip = roundTrip }); } private void OnSessionRoundTripEnqued(object sender, RoundTripEventArgs e) { CreateDomainHandlerDelegate del; if (_handlers.TryGetValue(e.RoundTrip.GetType(), out del)) { del(e.RoundTrip); _log.Info(string.Format("Create handler for RoundTrip '{0}' completed.", e.RoundTrip.GetType().FullName)); } else { _log.Info(string.Format("The handler for RoundTrip '{0}' not found.", e.RoundTrip.GetType().FullName)); } } } }
下面是领域处理类RoundTripDomainHandler的基类。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.EcmCommServerService.Protocol; using UIShell.OSGi.Utility; namespace UIShell.EcmCommServerService.Domain { /// <summary> /// RoundTrip领域处理器,当RoundTrip操作成功时,将相应结果保存到数据库; /// 相反,如果操作失败,则需要做异常处理。 /// </summary> /// <typeparam name="TRoundTrip">RoundTrip类型</typeparam> public abstract class RoundTripDomainHandler { private RoundTripBase _roundTrip; public RoundTripBase RoundTrip { get { return _roundTrip; } set { if (_roundTrip == null) { AssertUtility.NotNull(value); _roundTrip = value; _roundTrip.OnCompleted += OnCompleted; _roundTrip.OnError += OnError; } } } public abstract void OnCompleted(object sender, RoundTripEventArgs e); public abstract void OnError(object sender, RoundTripEventArgs e); } public delegate RoundTripDomainHandler CreateDomainHandlerDelegate(RoundTripBase roundTrip); }
以下则是数据上报对话的相关领域处理。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.EcmCommServerService.Protocol; using UIShell.EcmCommServerService.Protocol.RoundTrip.Passive; using UIShell.EcmCommServerService.Server; namespace UIShell.EcmCommServerService.Domain { public class DataReportDomainHandler : RoundTripDomainHandler { public override void OnCompleted(object sender, RoundTripEventArgs e) { var dataReportRoundTrip = RoundTrip as DataReportPassiveRoundTrip; e.RoundTrip.Trace("开始将会话结果持久化到数据存储。"); // ... e.RoundTrip.Trace("将会话结果持久化到数据存储成功。"); } public override void OnError(object sender, RoundTripEventArgs e) { // 为采集器关联的Building创建一条失败记录 // ... } } }
领域处理类将会调用数据访问模型来操作数据库。整个通讯服务器的大致实现已经介绍完成,接下来我将介绍一些非常有意思的技术细节。
4 通讯服务器有意思的技术细节
4.1 共享缓存
按照我的理解,GPRS通讯服务器指定端口的网络存储保存了与所有硬件设备的通讯数据,因此,我们需要来区分数据是由哪个采集器发送过来的;此外,在通讯过程中,我们需要处理好通讯的时序,就是说服务器向采集器发送配置主动消息时,期望采集器响应一条结果,此时返回的消息可能是其它消息,因为整个通讯是双工的,采集器也可以主动向服务器发送消息。因此,我们使用一个SharedInputBuffer来处理上述两个问题。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.OSGi.Collection; namespace UIShell.EcmCommServerService.Protocol { /// <summary> /// 添加共享输入缓冲区的原因如下: /// 1 对于GPRS服务器,所有的会话都将从同一个网络数据缓冲区中读取数据; /// 2 每一个集中器对应一个通讯会话; /// 3 这样集中器A从网络数据缓冲区读取数据时,可能读取到来自集中器B的数据, /// 因此,我们需要使用缓冲区将集中器B的数据缓存起来,并继续读取直到读取到 /// A的数据或者读取失败; /// 4 此外,每一个集中器读取数据时,都先尝试从共享缓冲区读取数据,然后再 /// 尝试从网络数据缓冲区读取。 /// </summary> public static class SharedInputBuffer { private static ThreadSafeList<UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]>> _sharedBuffer = new ThreadSafeList<OSGi.Utility.Tuple<MessageHeader, byte[]>>(); public static ThreadSafeList<UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]>> SharedBuffer { get { return _sharedBuffer; } } public static int Count { get { using (var locker = SharedBuffer.Lock()) { return locker.Count; } } } public static UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]> FindAndThenRemoveByMessageType(string type) { return FindAndThenRemove(p => p.Item1.MessageType.Equals(type)); } public static UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]> FindAndThenRemove(string buildingId, string gatewayId, string type) { return FindAndThenRemove(p => p.Item1.BuildingId.Equals(buildingId) && p.Item1.GatewayId.Equals(gatewayId) && p.Item1.MessageType.Equals(type)); } public static UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]> FindAndThenRemove(Predicate<UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]>> predicate) { using (var locker = SharedBuffer.Lock()) { if (locker.Count > 0) { // 查找缓冲项 var item = SharedBuffer.Find(predicate); if (item != null) { // 删除并返回 RemoveSharedBufferItem(item); return item; } } return null; } } public static void AddSharedBufferItem(UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]> item) { SharedBuffer.Add(item); } public static void RemoveSharedBufferItem(UIShell.OSGi.Utility.Tuple<MessageHeader, byte[]> item) { SharedBuffer.Remove(item); } public static void ClearSharedBuffer() { SharedBuffer.Clear(); } } }
4.2 内存泄露
在整个通讯服务器中,整个通讯过程中,创建了大量的RoundTrip实例,通讯服务器的运行是7 × 24小时 × 365天不间断的运行,如何保证这个通讯服务器在持久的运行中,内存不会持续增加/CPU不会持续增长,从而保证系统不会崩溃,是必须解决的一个问题。在系统运行初期,我们很快就面临这个问题的威胁,就是在系统运行中,内存一直在增长。因此,在初期,我们使用CLR Profiler来调试系统的内存和CPU使用情况,初步的调优记录如下所示。
经过分析,发现新建的RoundTrip实例在对话执行完成后,并没有被CLR回收,从而导致与RoundTrip关联的类型都一直存留在内存中。了解.NET GC垃圾回收原理的同志应该知道,GC的条件是引用计数为0。经过分析,发现RoundTrip没有被释放的原因在于,我们的UI订阅了每一个新建的RoundTrip的OnMessageSend/OnCompleted/OnError事件,用于打印通讯过程中的交互的所有消息,这些事件在RoundTrip执行完成后,没有释放,从而导致RoundTrip的引用计数始终不是0。
因此,我们为RoundTrip实现了IDisposable接口,在其实现中,来释放所有的事件句柄。
private List<EventHandler<RoundTripEventArgs>> _onCompletedEventHandlers = new List<EventHandler<RoundTripEventArgs>>(); /// <summary> /// 这是一个异步事件,避免在处理事件时,阻塞其它RoundTrip的运行。 /// </summary> public event EventHandler<RoundTripEventArgs> OnCompleted { add { _onCompletedEventHandlers.Add(value); } remove { _onCompletedEventHandlers.Remove(value); } } public override void Dispose() { _onCompletedEventHandlers.Clear(); _onErrorEventHandlers.Clear(); base.Dispose(); }
在CommProtocol通讯协议类中,每一个RoundTrip执行完成后,都将调用Dispose方法。
try { try { Monitor.Exit(_queue.SyncRoot); OnRoundTripStartingHandler(this, new RoundTripEventArgs() { RoundTrip = roundTrip }); roundTrip.Start(); } catch (ThreadAbortException) { Trace("通讯线程被终止。"); throw; } catch (CommStreamException ex) // 无法获取Stream的时候,直接退出??需要加一个标志位 // 需要抛出事件,通知后续处理,如将RoundTrip另存 { _exited = true; roundTrip.Trace("会话失败,因为:链路已经关闭。"); _log.Error(string.Format("Start the round trip '{0}' error.", roundTrip), ex); } catch (Exception ex) { string error = GetErrorMessage(ex); roundTrip.Trace(string.Format("会话失败,因为:{0}。", error)); _log.Error(string.Format("Start the round trip '{0}' error.", roundTrip), ex); } // ...... // 执行完RoundTrip后,开始清理资源 roundTrip.Dispose(); } catch (ThreadAbortException) { Trace("通讯线程被终止。"); throw; } catch (Exception ex) { _log.Error("Unhandled exception in CommProtocol.", ex); }
4.3 单元测试
该通讯协议的单元测试,有三个步骤:(1)在静态类中启动OSGi.NET插件框架;(2)在Setup方法中启动服务器,服务器IP为本机IP——127.0.0.1,然后创建一个TCP连接,模拟连接操作,首先先登录;(3)执行一个RoundTrip测试,模拟服务器和GPRS连接客户端的行为。
以下方法用于启动OSGi.NET插件框架。
[TestFixture] public partial class ProtocolTest { private HttpCommServer _commServer; private TcpClient _tcpClient; private CommProtocol _currentSession; private CommStreamAdapter _clientStremAdapter; public AutoResetEvent AutoResetEvent { get; set; } public const string BuildingId = "b001"; public const string GatewayId = "g001"; static ProtocolTest() { // 加载插件运行时,准备运行环境 if (BundleRuntime.Instance == null) { BundleRuntime bundleRuntime = new BundleRuntime("../../../"); bundleRuntime.Start(); } } }
启动通讯服务器并模拟用户登录,_commServer为服务器,_tcpClient为模拟客户端连接,_clientStreamAdapter为客户端连接适配器。
[SetUp] public void Setup() { AutoResetEvent = new AutoResetEvent(false); MessageConstants.GprsMessageConstants.Timeout = new TimeSpan(0, 0, 5); MessageConstants.GprsMessageConstants.RetryTimesOnTimeout = 1; _commServer = new HttpCommServer("127.0.0.1", 39999, MessageConstants.GprsMessageConstants); _commServer.OnTraceMessageAdded += (sender, e) => { Debug.WriteLine(e.Message); }; _commServer.Start(); _tcpClient = new TcpClient(); _tcpClient.Connect("127.0.0.1", 39999); _clientStremAdapter = new CommStreamAdapter(_commServer.MessageConstants, _tcpClient); _clientStremAdapter.ParentTracker = _commServer; var request = ValidateRequestPassiveMessage.New(BuildingId, GatewayId); _clientStremAdapter.SendRawMessage(request.ToContent(), request.ToXmlContent()); var prefix = ProtocolUtility.BytesToHexString(MessageConstants.XmlMessagePrefixBytes); var root = ProtocolUtility.BytesToHexString(MessageConstants.XmlMessageRootStartBytes); var common = ProtocolUtility.BytesToHexString(MessageConstants.XmlMessageCommonStartBytes); var commonend = ProtocolUtility.BytesToHexString(MessageConstants.XmlMessageCommonEndBytes); var rootend = ProtocolUtility.BytesToHexString(MessageConstants.XmlMessageRootEndBytes); MessageHeader header; var messageContent = _clientStremAdapter.ReceiveOneRawMessage(out header); Assert.AreEqual(header.BuildingId, BuildingId); Assert.AreEqual(header.GatewayId, GatewayId); Assert.AreEqual(header.MessageType, StringEnum.GetStringValue(MessageType.Validate_Sequence)); var sequenceMessage = MessageSerialiser.DeserializeRaw<ValidateSequenceActiveMessage>(messageContent); string md5 = CreateSequenceAndHash(sequenceMessage.Body.Sequence); var md5Message = ValidateMd5PassiveMessage.New(BuildingId, GatewayId, md5); _clientStremAdapter.SendRawMessage(md5Message.ToContent(), md5Message.ToXmlContent()); messageContent = _clientStremAdapter.ReceiveOneRawMessage(out header); Assert.AreEqual(header.BuildingId, BuildingId); Assert.AreEqual(header.GatewayId, GatewayId); Assert.AreEqual(header.MessageType, StringEnum.GetStringValue(MessageType.Validate_Result)); var resultMessage = MessageSerialiser.DeserializeRaw<ValidateResultActiveMessage>(messageContent); Assert.AreEqual(resultMessage.Body.Result, "pass"); while (_currentSession == null) { _currentSession = _commServer.Sessions.Find(s => s.BuildingId.Equals(BuildingId) && s.GatewayId.Equals(GatewayId)); Thread.Sleep(1000); } }
接着就可以来定义一个测试。这个测试在OnMessageSend事件中,客户端将模拟通讯协议,发送一个响应消息。由于通讯过程是基于异步方式,我们需要使用AutoResetEvent来等待对话完成信号。等对话执行完成时,再来检查结果。
[Test] public void ConfigRoundTrip() { bool completed = false; Exception ex = null; MessageHeader receivedMessageHeader = null; ConfigActiveMessage receivedMessage = null; _currentSession.Config(10, (sender, e) => { var configMessage = _clientStremAdapter.ReceiveOneRawMessage(out receivedMessageHeader); receivedMessage = MessageSerialiser.DeserializeRaw<ConfigActiveMessage>(configMessage); var configAckMessage = ConfigAckPassiveMessage.New(BuildingId, GatewayId); _clientStremAdapter.SendRawMessage(configAckMessage.ToContent(), configAckMessage.ToXmlContent()); }, (sender, e) => { ex = e.Exception; completed = true; AutoResetEvent.Set(); }, (sender, e) => { ex = e.Exception; completed = false; AutoResetEvent.Set(); }); AutoResetEvent.WaitOne(); Assert.IsTrue(completed); Assert.AreEqual(receivedMessageHeader.MessageType, StringEnum.GetStringValue(MessageType.Config_Period)); Assert.AreEqual(receivedMessage.Body.Period, 10); }
以下是单元测试的输出消息。
------ Test started: Assembly: UIShell.EcmCommServerService.dll ------
[Id:1, 2013-07-07 19:17:16]接收到来自IP地址'127.0.0.1'的连接。
[Id:1, 2013-07-07 19:17:16]开始清空缓冲区。
[Id:1, 2013-07-07 19:17:16]清空缓冲区成功。
[Id:1, 2013-07-07 19:17:16]开始发送命令:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>request</type></common><id_validate operation="request"></id_validate></root>
[Id:1, 2013-07-07 19:17:16]正在读取消息,目前没有可用数据,等待数据。
[Id:1, 2013-07-07 19:17:16]登录组合会话开始。
[Id:1, 2013-07-07 19:17:16]开始尝试与集中器N/A进行被动式会话。
[Id:4, 2013-07-07 19:17:16]接收到消息,消息头为:<?xml version="1.0" encoding="utf-8"?><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>request</type></common>。
[Id:1, 2013-07-07 19:17:16]接收到消息:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>request</type></common><id_validate operation="request"></id_validate></root>
[Id:1, 2013-07-07 19:17:16]开始发送的响应消息:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>sequence</type></common><id_validate operation="sequence"><sequence>14b12261-5182-47a6-bdfa-cf21e4e5cfd7-bcc63da5-5694-427f-b358-9a113125f74d</sequence></id_validate></root>
[Id:4, 2013-07-07 19:17:16]开始清空缓冲区。
[Id:4, 2013-07-07 19:17:16]清空缓冲区成功。
[Id:4, 2013-07-07 19:17:16]开始发送命令:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>sequence</type></common><id_validate operation="sequence"><sequence>14b12261-5182-47a6-bdfa-cf21e4e5cfd7-bcc63da5-5694-427f-b358-9a113125f74d</sequence></id_validate></root>
[Id:1, 2013-07-07 19:17:16]与集中器g001进行被动式会话成功。
[Id:1, 2013-07-07 19:17:16]收到请求消息并发送序列'14b12261-5182-47a6-bdfa-cf21e4e5cfd7-bcc63da5-5694-427f-b358-9a113125f74d',该序列计算的MD5值为'8224D3FC5FCC21E45E82FF5F9AB364CD'。
[Id:1, 2013-07-07 19:17:16]开始尝试与集中器g001进行被动式会话。
[Id:6, 2013-07-07 19:17:16]共享缓冲区的消息数量:0。
[Id:6, 2013-07-07 19:17:16]正在读取消息,目前没有可用数据,等待数据。
[Id:1, 2013-07-07 19:17:19]接收到消息,消息头为:<?xml version="1.0" encoding="utf-8"?><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>sequence</type></common>。
[Id:1, 2013-07-07 19:17:19]开始清空缓冲区。
[Id:1, 2013-07-07 19:17:19]清空缓冲区成功。
[Id:1, 2013-07-07 19:17:19]开始发送命令:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>md5</type></common><id_validate operation="md5"><md5>8224D3FC5FCC21E45E82FF5F9AB364CD</md5></id_validate></root>
[Id:1, 2013-07-07 19:17:19]正在读取消息,目前没有可用数据,等待数据。
[Id:6, 2013-07-07 19:17:19]接收到消息,消息头为:<?xml version="1.0" encoding="utf-8"?><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>md5</type></common>。
[Id:1, 2013-07-07 19:17:19]接收到消息:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>md5</type></common><id_validate operation="md5"><md5>8224D3FC5FCC21E45E82FF5F9AB364CD</md5></id_validate></root>
[Id:1, 2013-07-07 19:17:19]开始发送的响应消息:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>result</type></common><id_validate operation="result"><result>pass</result></id_validate></root>
[Id:6, 2013-07-07 19:17:19]开始清空缓冲区。
[Id:6, 2013-07-07 19:17:19]清空缓冲区成功。
[Id:6, 2013-07-07 19:17:19]开始发送命令:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>result</type></common><id_validate operation="result"><result>pass</result></id_validate></root>
[Id:1, 2013-07-07 19:17:19]与集中器g001进行被动式会话成功。
[Id:1, 2013-07-07 19:17:19]登录组合会话完成,登录结果为:成功。
[Id:1, 2013-07-07 19:17:19]为采集器'g001'创建通讯会话,目前会话数目为'1'。
[Id:13, 2013-07-07 19:17:33]开始与集中器g001会话。
[Id:13, 2013-07-07 19:17:33]开始清空缓冲区。
[Id:13, 2013-07-07 19:17:33]清空缓冲区成功。
[Id:13, 2013-07-07 19:17:33]开始发送命令:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>period</type></common><config operation="period"><period>10</period></config></root>
[Id:1, 2013-07-07 19:17:36]接收到消息,消息头为:<?xml version="1.0" encoding="utf-8"?><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>pack_lost</type></common>。
[Id:1, 2013-07-07 19:17:36]接收到消息,消息头为:<?xml version="1.0" encoding="utf-8"?><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>pack_lost</type></common>。
[Id:1, 2013-07-07 19:17:36]接收到消息,消息头为:<?xml version="1.0" encoding="utf-8"?><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>period</type></common>。
[Id:1, 2013-07-07 19:17:44]开始清空缓冲区。
[Id:1, 2013-07-07 19:17:44]清空缓冲区成功。
[Id:1, 2013-07-07 19:17:44]开始发送命令:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>period_ack</type></common><config operation="period_ack"></config></root>
[Id:13, 2013-07-07 19:17:46]开始第1次消息接收。
[Id:13, 2013-07-07 19:17:46]共享缓冲区的消息数量:0。
[Id:1, 2013-07-07 19:17:46]开始检查被动通讯。
[Id:1, 2013-07-07 19:17:49]通讯链路没有可用数据。
[Id:13, 2013-07-07 19:17:46]当前会话'未知'接收了1个响应消息,详细如下:
[Id:13, 2013-07-07 19:17:46]响应消息:<?xml version="1.0" encoding="utf-8"?><root><common><building_id>b001</building_id><gateway_id>g001</gateway_id><type>period_ack</type></common><config operation="period_ack"></config></root>
[Id:13, 2013-07-07 19:17:46]与集中器g001会话成功。
[Id:1, 2013-07-07 19:17:49]检查被动通讯完成。
[Id:1, 2013-07-07 19:17:50]通讯线程被终止。
对于被动对话,其测试方法需要稍作改变,因为被动消息的发起在在通讯会话检测到有被动消息时才会创建一个被动对话的,因此,我们首先需要先模拟客户端发送一条被动消息,并监听当前会话的OnRoundTripStarted事件,如下所示。
[Test] public void HeartBeatPassiveRoundTrip() { bool completed = false; Exception ex = null; HeartBeatTimeActiveMessage responseMessage = null; _currentSession.OnRoundTripStarted += (sender, e) => { if (e.RoundTrip is HeartBeatPassiveRoundTrip) { var roundTrip = e.RoundTrip as HeartBeatPassiveRoundTrip; responseMessage = roundTrip.ResponsedMessage; ex = e.Exception; completed = true; AutoResetEvent.Set(); } }; var heartbeatMessage = HeartBeatNotifyPassiveMessage.New(BuildingId, GatewayId); _clientStremAdapter.SendRawMessage(heartbeatMessage.ToContent(), heartbeatMessage.ToXmlContent()); AutoResetEvent.WaitOne(); Assert.IsTrue(completed); Assert.NotNull(responseMessage); Assert.AreEqual(responseMessage.Header.MessageType, StringEnum.GetStringValue(MessageType.HeartBeat_Time)); }
4.4 程序的部署与升级
通讯服务器软件由软件团队开发,硬件团队测试,并且需要部署到多个点。为了避免手工部署和升级麻烦,整个通讯服务器基于开放工厂(http://www.iopenworks.com/)平台开发,程序使用开放工厂提供的OSGi.NET插件框架(http://www.iopenworks.com/Products/SDKDownload)构建,使用开放工厂私有插件仓库实现应用程序的自动升级。
以下是整个通讯服务器的代码。核心是一个CommServerService插件,实现了通讯服务。
整个应用程序由12个插件构成,其它插件均为开放工厂提供的插件和Web界面应用程序插件。
在发布通讯服务插件的时候,右键,点击“发布插件”菜单,即可将插件及其升级版本发布到插件仓库。
接着,在后面的页面中输入私有仓库用户名/密码即可发布。
发布完成后,你可以在私有仓库中,查看到该插件的发布版本。
发布完成后,进入该系统插件管理页面的私有仓库,即可下载到最新升级包。如下所示。
下面就可以下载安装升级包了。
好了,整个GPRS通讯服务器的构建方法就分享到这。
5 总结
(1)为通讯协议设计了一个好的模型,这个模型以消息、对话为基础;
(2)采用了不错的架构,基于事件 + 分层,事件非常适用于异步处理和解耦,分层易于代理的理解和组织;
(3)非常OO,整个设计采用比较优雅的面向对象设计,遵守OO的设计原则SRP、OCP等;
(4)使用插件化的方法,进行模块化开发;
(5)引用单元测试保证通讯协议可测试性,避免与硬件联调。