分享一个与硬件通讯的分布式监控与远程控制程序的设计(下:通讯协议设计与实现)
4 基于RoundTrip(往返)的通讯协议设计
通讯服务器插件的核心为3部分:(1)与通讯方式、业务逻辑无关的通讯协议实现;(2)和通讯方式、业务逻辑有关的通讯业务逻辑的实现;(3)远程通讯消息队列。在这里我将重点描述通讯协议的实现。这个通讯协议的实现比较灵巧。
4.1 通讯协议基本单元——消息
通讯协议的通讯单元是消息,以下是来自硬件开发工程师编写的协议,消息包由前导符、起始符、消息头、校验码、消息体、结束符等部分组成。不同的通讯指令,发出的消息和接收到消息均不相同。
通讯协议必须能够发出正确的消息和解析响应的消息包,此外,硬件能接受的消息是字节格式,而通讯服务器软件能够正确识别的则是各个有意义的字段。为此,我们为消息设计了如下的基类。消息较小的单元是一个MessagePart,它提供了ToContent和ToMessage方法分别用于转换成字节码和字符串,此外,它还定义了TryParse方法用于将字节码解析成有意义的MessagePart对象。这里定义了ParseMessageException异常,当消息解析失败时,抛出该异常。下面是消息头、消息体以及消息基类的定义。消息基类由前缀、起始、头、体和后缀部分组成。
接着我们根据硬件开发工程师提供的SCATA 3.0协议,定义与通讯协议相关的消息基类Scata30Message。这个消息提供了一个默认的消息头的实现,但是消息体则需要根据指令进一步实现。下图是通讯协议涉及的大部分消息体的实现,消息体基本都是一对的,即消息体和响应消息体。
消息体一般是指服务器发出给硬件的指令,这样的消息体需要构造所有的字段,并要实现ToContent方法,将消息转换成字节码,发送给硬件;而响应消息一般是由硬件发送给服务器的消息,它至少需要实现TryParse方法,将硬件字节码解析成有意义的字段,供业务逻辑层访问。
下面是一个消息的定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.CommServerService.Utility; using System.ComponentModel; namespace UIShell.CommServerService.Protocol.Scata30.Message { [Description( "读取单一表" )] public class Scata30ReadMeterMessageBody : Scata30MessageBody { public byte MeterProtocolCategory; public byte Channel; public byte [] MeterAddressBCD; public long MeterAddress; internal Scata30ReadMeterMessageBody() { } public Scata30ReadMeterMessageBody( byte meterProtocol, byte channel, long meterAddress) { MeterProtocolCategory = meterProtocol; Channel = channel; MeterAddress = meterAddress; MeterAddressBCD = ProtocolUtility.MeterAddressFromLong(meterAddress, true ); } protected override bool TryParseWithoutCheckCode( byte [] bodyContent) { throw new NotImplementedException(); } protected override byte [] ToContentWithoutCheckCode() { return new byte [] { MeterProtocolCategory, Channel }.Concat(MeterAddressBCD).ToArray(); } public override string ToString() { return string .Format( "协议类型={0},通道号={1},表地址={2}" , MeterProtocolCategory, Channel, MeterAddress); } } } |
下面则是响应消息的实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.CommServerService.Utility; using System.ComponentModel; namespace UIShell.CommServerService.Protocol.Scata30.Message { [Description( "读取单一表响应" )] public class Scata30ReadMeterResponseMessageBody : Scata30MessageBody { public Scata30ResponseStatus ResponseStatus; /// <summary> /// 表数据,同读取多表的数据类似。 /// </summary> public byte [] MeterBodyContent; public Scata30ReadMeterResponseMessageBody() { } protected override bool TryParseWithoutCheckCode( byte [] bodyContent) { if (bodyContent == null || bodyContent.Length == 0) { _log.Error( string .Format(UIShell.CommServerService.Properties.Resources. ParseMessageBodyFailed, ProtocolUtility.BytesToHexString(bodyContent))); return false ; } if (bodyContent.Length == 1) { if (bodyContent[0] != ( byte )Scata30ResponseStatus.Failed) { _log.Error( string .Format(UIShell.CommServerService.Properties.Resources. ParseMessageBodyFailed, ProtocolUtility.BytesToHexString(bodyContent))); return false ; } else { ResponseStatus = (Scata30ResponseStatus)bodyContent[0]; } } else { ResponseStatus = Scata30ResponseStatus.Success; MeterBodyContent = bodyContent; } return true ; } protected override byte [] ToContentWithoutCheckCode() { if (ResponseStatus == Scata30ResponseStatus.Failed) { return new byte [] { ( byte )ResponseStatus }; } return MeterBodyContent; } public override string ToString() { return string .Format( "状态={0},表数据={1}" , EnumDescriptionHelper.GetDescription(ResponseStatus), ProtocolUtility.BytesToHexString(MeterBodyContent)); } } } |
4.2 通讯协议的组成——RoundTrip(往返)
通讯服务器与硬件的通讯过程是由一组的对话来实现的,每一组对话都是问答式的方式来完成。我们把一次问答式的对话用RoundTripBase这个类型来表示。问答式的对话又分成主动式(ActiveRoundTrip)和被动式(PassiveRoundTrip),即服务器发起然后硬件响应,或者硬件发起服务器响应。有时,一次问答式的对话可能需要由若干组的子对话来实现,我们称其为组合对话(CompositeRoundTripBase)。有关通讯协议对话过程涉及的基类设计如下。
对话RoundTripBase的详细设计如下所示,它由优先级、时间戳属性组成,提供了Start方法表示会话开始,以及OnCompleted和OnError事件。RoundTripQueue则是对话队列,它严格限制通讯协议每次只能执行一个RoundTrip,不能交叉运行,这个RoundTripQueue是一个线程安全的,因为通讯协议会被远程通讯线程、协议线程、UI线程等线程来访问。
4.3 协议的RoundTrip实现
在本系统中,我们使用SCATA 3.0通讯协议,这里我们实现了2个基类:Scata30ActiveRoundTrip和Scata30PassiveRoundTrip。
在Scata30ActiveRoundTrip中,它在Start方法中,将利用StreamAdapter来从通讯信道中获取一条消息,一旦消息解析成功后,将发送响应消息包。这个对话,一旦中间发生错误或者超时,将重试若干次。同理,Scata30PassiveRoundTrip也是如此实现。
接下来,我们根据通讯协议,定义了如下的对话。
下面我们来看一个对话的实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.CommServerService.Protocol.Scata30.Message; using UIShell.CommServerService.Utility; using System.ComponentModel; namespace UIShell.CommServerService.Protocol.Scata30.RoundTrip { [Description( "读取指定时间点表数据" )] public class Scata30ReadHistoricalMeterRoundTrip : Scata30HasNextActiveRoundTrip<Scata30ReadHistoricalMeterMessageBody, Scata30ReadHistoricalMeterResponseMessageBody> { public DateTime HistoricalDateTime; public Scata30ReadHistoricalMeterRoundTrip( ushort destinationAddress, ushort destinationZigbeeAddress, DateTime timeStamp, Scata30Protocol protocol) : base (destinationAddress, destinationZigbeeAddress, new Scata30Message<Scata30ReadHistoricalMeterMessageBody>(Scata30MessageType.ReadMeterByDate, protocol.MasterStationAddress, destinationAddress, 0, DateTime.Now, new Scata30ReadHistoricalMeterMessageBody(timeStamp)), Scata30MessageType.ReadMeterByDateResponse, protocol) { HistoricalDateTime = timeStamp; } public override void ReceiveResponseMessages() { base .ReceiveResponseMessages(); foreach ( var message in ReceivedResponseMessages) { if (!message.Body.HistoricalDateTime.Equals(HistoricalDateTime)) { _log.Error( string .Format( "Read the historical meter content error since the date time mismatched. The require date time is '{0}', return by concentrator is '{1}'" , HistoricalDateTime.ToString( "yyyy-MM-dd HH:mm:ss" ), message.Body.HistoricalDateTime.ToString( "yyyy-MM-dd HH:mm:ss" ))); // throw new Exception("Parse message error since historical date time mismatched."); } } } } } |
4.4 通讯协议的实现
通讯协议的实现类图如下所示,由于通讯协议与通讯方式、业务逻辑无关,因此,在这里我们引入StreamAdapter和StreamProvider来屏蔽这些上下文。StreamAdapter的功能是获取一条消息和发送一条消息,StreamProvider则是为不同通讯方式提供通讯流。
下面我来描述协议类的关键实现。协议类内部有一个线程来实现与硬件的通讯。这个线程会一直运行,然后从对话队列中不停获取RoundTrip,一旦获取的RoundTrip不会空,则运行这个RoundTrip,否则线程进入休眠状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | public bool Start() { if (_started) { return true ; } FireOnStarting(); try { CommStreamProvider.Start(); } catch (Exception ex) { _log.Error( "Start the communication provider failed." , ex); return false ; } _thread = new Thread(() => { RoundTripBase roundTrip; while (!_exited) { Monitor.Enter(_queue.SyncRoot); roundTrip = Dequeue(); if (roundTrip != null ) { try { Monitor.Exit(_queue.SyncRoot); OnRoundTripStartingHandler( this , new RoundTripEventArgs() { RoundTrip = roundTrip }); roundTrip.Start(); } catch (ThreadAbortException) { Trace( "通讯线程被终止。" ); throw ; } catch (Scata30StreamException ex) // 无法获取Stream的时候,直接退出 { _exited = true ; roundTrip.Trace( "会话失败,因为:连接已经关闭。" ); } catch (Exception ex) { string error = GetErrorMessage(ex); roundTrip.Trace( string .Format( "会话失败,因为:{0}。" , error)); } 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(); } else { Monitor.Exit(_queue.SyncRoot); OnIdleHandler( this , new RoundTripEventArgs()); _autoResetEvent.WaitOne(); } } }); _thread.Start(); _started = true ; FireOnStarted(); return true ; } |
执行对话,是以异步的方式来进行,通过事件进行通知。如下所示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | using System; using System.Collections.Generic; using System.Linq; using System.Text; using UIShell.CommServerService.Protocol.Scata30.RoundTrip; using UIShell.CommServerService.Protocol.Scata30.Message; namespace UIShell.CommServerService.Protocol.Scata30 { public partial class Scata30Protocol { public Scata30SetConcentratorTimeRoundTrip SetConcentratorTime( ushort concentratorAddress, ushort concentratorZigbeeAddress, DateTime timeStamp, EventHandler<RoundTripEventArgs> onMessageSend, EventHandler<RoundTripEventArgs> onCompleted, EventHandler<RoundTripEventArgs> onError) { var roundTrip = new Scata30SetConcentratorTimeRoundTrip( concentratorAddress, concentratorZigbeeAddress, timeStamp, this ); if (onMessageSend != null ) { roundTrip.OnMessageSend += onMessageSend; } if (onCompleted != null ) { roundTrip.OnCompleted += onCompleted; } if (onError != null ) { roundTrip.OnError += onError; } Enqueue(roundTrip); return roundTrip; } } } |
这个通讯协议的实现非常优雅,在维护的过程中,通讯指令的变更和通讯方式的转变,都不需要再修改协议和RoundTrip本身,只需要对消息体进行变更并增加新的StreamProvider,并在上层的业务逻辑进行实现。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· SQL Server 2025 AI相关能力初探
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库