C# 构建一个TCP和SerialPort通信的通用类(上)
背景
在使用C#进行开发的时候我们需要去和外部进行通信,而常见的通信协议主要是TCP和SerialPort和外部进行通信,在C#中我们可以使用一个通用的通信基类Communicator来将这两个集中到一个抽象基类中,这样我们就能实现和常用的外部系统进行通信,很多外部设备的厂家甚至提供了这两种协议同时支持,这样我们可以选择任何一种通信协议进行对接,今天这篇文章主要来分析如何构建这样一个扩展性良好的通信基类思路,注意整个系列分上下两篇进行介绍,整个程序的完整源码将在下篇的末尾完整附上,便于理解整个过程。
过程
一 基础设置
1.1 定义通信类型
/// <summary> /// Communication type /// </summary> [Serializable] public enum COMMUNICATION_TYPE { SERIAL, TCPIP }
这个自然不用多说,总共两种类型一种是串口一种是TCP
1.2 定义通信状态的枚举
/// <summary> /// Communication state definition /// </summary> private enum COMMUNICATION_STATE { DISABLED, DISCONNECTED, CONNECTING_RETRY_WAIT, IDLE, WAITING_AFTER_CMD_SEND, WAITING_CMD_RESPONSE, }
这个对于两者都是通用的,我们在里面将整个通信过程分作了6种,这个看枚举值就知道是什么意思。
1.3 定义串口通信的校验位和停止位
// // Summary: // Specifies the parity bit for a System.IO.Ports.SerialPort object. [Serializable] public enum sParity { // // Summary: // No parity check occurs. None = 0, // // Summary: // Sets the parity bit so that the count of bits set is an odd number. Odd = 1, // // Summary: // Sets the parity bit so that the count of bits set is an even number. Even = 2, // // Summary: // Leaves the parity bit set to 1. Mark = 3, // // Summary: // Leaves the parity bit set to 0. Space = 4 } // // Summary: // Specifies the number of stop bits used on the System.IO.Ports.SerialPort object. [Serializable] public enum sStopBits { // // Summary: // No stop bits are used. This value is not supported by the System.IO.Ports.SerialPort.StopBits // property. None = 0, // // Summary: // One stop bit is used. One = 1, // // Summary: // Two stop bits are used. Two = 2, // // Summary: // 1.5 stop bits are used. OnePointFive = 3 }
这个部分是按照标准的通信协议来定义也没有什么需要重点讲述的。
1.4 定义系统中可供外界进行配置的参数
这个主要是定义一些外部可以配置的一些参数,这些参数的配置可以通过一个界面配置来进行展示,通过这些配置我们就能够实时去配置我们通信系统中的配置,我们先来看看这个配置的截图,这里需要注意我们在当前的抽象类Communicator中为属性定义了一个命令PgConfig的标签,通过添加这些标签,我们读取这些属性并放置到下面的配置界面中。
图一 系统参数配置
二 核心过程
2.1 开启线程启动整个过程
后面的过程是整个过程最核心的部分,包括开启线程、开启连接、接收数据、解析数据、失败重连等一系列核心过程,我们先来看整体的代码,然后再分步骤进行说明。
/// <summary> /// Communication work thread /// </summary> private void do_work() { while (true) { Thread.Sleep(50); try { if(!IsEnabled) { state = COMMUNICATION_STATE.DISABLED; continue; } else { if(state == COMMUNICATION_STATE.DISABLED) { Log.Write(LogCategory.Debug, ComponentFullPath, "Re-establish communication when disabled -> enabled"); retryConnectTimer.Start(ConnectionRetryTimeInterval * 1000); state = COMMUNICATION_STATE.CONNECTING_RETRY_WAIT; } } Monitor(); TryReceive(); ProcessReceivedData(); switch (state) { case COMMUNICATION_STATE.DISABLED: break; case COMMUNICATION_STATE.DISCONNECTED: { bool isSucc = false; if (CommunicationType == COMMUNICATION_TYPE.TCPIP) { Log.Write(LogCategory.Debug, ComponentFullPath, "Start tcp connection .. "); isSucc = TryTcpConnect(out communicationFailReason); } else { Log.Write(LogCategory.Debug, ComponentFullPath, "Start serial port connection .. "); isSucc = TrySerialPortConnect(out communicationFailReason); } if (isSucc) { lock (commandQueueLock) { commandQueue.Clear(); } lock (recvBufferLock) { recvBufferSize = 0; } retryConnectCnt = 0; communicationFailReason = ""; Log.Write(LogCategory.Information, ComponentFullPath, "Communicaiton established"); OnConnected(); state = COMMUNICATION_STATE.IDLE; } else { retryConnectCnt++; communicationFailReason = $"{communicationFailReason}, {retryConnectCnt} times retry, waiting {ConnectionRetryTimeInterval} sec and start next retry"; if (retryConnectCnt == 1) { RaiseAlarm(CommunFail, communicationFailReason); } Log.Write(LogCategory.Debug, ComponentFullPath, communicationFailReason); retryConnectTimer.Start(ConnectionRetryTimeInterval * 1000); state = COMMUNICATION_STATE.CONNECTING_RETRY_WAIT; } } break; case COMMUNICATION_STATE.CONNECTING_RETRY_WAIT: if (retryConnectTimer.IsTimeout()) { state = COMMUNICATION_STATE.DISCONNECTED; } break; case COMMUNICATION_STATE.IDLE: { if (commandSentDelayTimer.IsTimeout() || commandSentDelayTimer.IsIdle()) { if (commandQueue.Count == 0) { GenerateNextQueryCommand(); } Command nextCommand = null; lock (commandQueueLock) { if (commandQueue.Count > 0) { nextCommand = commandQueue.Dequeue(); } } if (nextCommand != null) { bool isSucc = false; commandSentDelayTimer.Start(MinimalTimeIntervalBetweenTwoSending * 1000); if (CommunicationType == COMMUNICATION_TYPE.TCPIP) { isSucc = TryTcpSend(nextCommand, out communicationFailReason); } else { isSucc = TrySerialSend(nextCommand, out communicationFailReason); } if (isSucc) { if (nextCommand.NeedReply) { currentCommand = nextCommand; commandReplyTimer.Start(currentCommand.TimeoutSec * 1000); commandPreWaitTimer.Start(WaitingTimeAfterSendBeforeReceive * 1000); state = COMMUNICATION_STATE.WAITING_AFTER_CMD_SEND; } else { currentCommand = null; state = COMMUNICATION_STATE.IDLE; } } else { retryConnectCnt++; communicationFailReason = $"Sending data failed,{communicationFailReason},waiting {ConnectionRetryTimeInterval} sec and start next re-connection"; if (retryConnectCnt == 1) { RaiseAlarm(CommunFail, communicationFailReason); } Log.Write(LogCategory.Error, ComponentFullPath, communicationFailReason); retryConnectTimer.Start(ConnectionRetryTimeInterval * 1000); state = COMMUNICATION_STATE.CONNECTING_RETRY_WAIT; } } } } break; case COMMUNICATION_STATE.WAITING_AFTER_CMD_SEND: if (commandPreWaitTimer.IsTimeout()) { state = COMMUNICATION_STATE.WAITING_CMD_RESPONSE; } break; case COMMUNICATION_STATE.WAITING_CMD_RESPONSE: if(commandReplyTimer.IsTimeout()) { retryConnectCnt++; communicationFailReason = $"Waiting command response timeout"; if (retryConnectCnt >= WaitResponseFailCountSetting) { RaiseAlarm(CommunFail, communicationFailReason); Log.Write(LogCategory.Error, ComponentFullPath, communicationFailReason); currentCommand = null; retryConnectTimer.Start(ConnectionRetryTimeInterval * 1000); state = COMMUNICATION_STATE.CONNECTING_RETRY_WAIT; } else { Log.Write(LogCategory.Information, ComponentFullPath, communicationFailReason + $" retry {retryConnectCnt}"); currentCommand = null; state = COMMUNICATION_STATE.IDLE; } } break; } } catch (Exception e) { retryConnectCnt++; communicationFailReason = $"Code running exception: {e.Message}, waiting {ConnectionRetryTimeInterval} sec and start next re-connection"; Log.Write(LogCategory.Debug, ComponentFullPath, communicationFailReason); Log.WriteExceptionCatch(ComponentFullPath, e); retryConnectTimer.Start(ConnectionRetryTimeInterval * 1000); state = COMMUNICATION_STATE.CONNECTING_RETRY_WAIT; } } }
这个过程是单独放在一个线程中进行的,每一次循环完毕当前线程Sleep 50毫秒,进入这个循环以后第一步就是判断IsEnable属性,如果为false就直接continue,这个IsEnable属性就相当于整个通信类的总闸,第一次进入后会将当前的通信状态置为DISABLED,如果我们在后面某一个时刻将IsEnable又重新设置为true后,就会将状态设置为CONNECTING_RETRY_WAIT表示等待重连,在分析完这个过程后,接着有三个方法 Monitor、TryReceive()和ProcessReceivedData(),这几个都是和接收数据相关的,能够接收数据的前提条件是当前必须已经建立正确的连接,按照我们上面分析的过程,第一次进入这个do_work方法的时候,这几个过程都是跳过的,因为没有建立任何连接,我们接着分析下面的一个Switch case的大循环,由于通信状态state的初始值是我们设置的DISCONNECTED,所以首先会进入这个case,在这个case中第一件事情就是根据CommunicationType来决定 进行Tcp连接还是SerialPort连接,我们分别来看下这两个过程。
2.2 开启Tcp连接
这个过程是通过一个TryTcpConnect的方法开始的,我们来分析一下这个方法的实现。
/// <summary> /// Try establish tcp connection /// </summary> /// <param name="failReason"></param> /// <returns></returns> private bool TryTcpConnect(out string failReason) { failReason = ""; try { // release socket resource before each tcp connection if (tcpSocket != null) { try { Log.Write(LogCategory.Debug, ComponentFullPath, "Close and release socket resource"); tcpSocket.Dispose(); } catch (Exception e0) { Log.Write(LogCategory.Debug, ComponentFullPath, $"Close socket exception: {e0.Message}"); } finally { tcpSocket = null; } } tcpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); tcpSocket.Connect(this.TcpAddress, this.TcpPortNo); bool isSucc = TestTcpStatus(out failReason); if (isSucc) { /* * https://msdn.microsoft.com/en-us/library/8s4y8aff%28v=vs.110%29.aspx * If no data is available for reading, the Receive method will block until data is available, * unless a time-out value was set by using Socket.ReceiveTimeout. * If the time-out value was exceeded, the Receive call will throw a SocketException. * If you are in non-blocking mode, and there is no data available in the in the protocol stack buffer, * the Receive method will complete immediately and throw a SocketException. * You can use the Available property to determine if data is available for reading. * When Available is non-zero, retry the receive operation. */ tcpSocket.Blocking = false; } else { return false; } } catch (Exception ex) { failReason = ex.Message; return false; } return true; }
这个方法返回的bool类型表示连接成功或者失败,这个方法内部首先会判断成员变量tcpSocket是否为空,如果不为空则释放原来的Tcp连接,然后重新建立一个新的Tcp连接,建立完Tcp连接后我们这里使用了一个TestTcpStatus方法来判断当前的Tcp状态,这里我们看看这个方法的实现。
/// <summary> /// Check tcp status /// </summary> /// <param name="failReason"></param> /// <returns></returns> private bool TestTcpStatus(out string failReason) { failReason = ""; // This is how you can determine whether a socket is still connected. bool blockingState = tcpSocket.Blocking; try { byte[] tmp = new byte[1]; tcpSocket.Blocking = false; tcpSocket.Send(tmp, 0, 0); } catch (SocketException e) { // 10035 == WSAEWOULDBLOCK if (e.NativeErrorCode.Equals(10035)) failReason = "Still Connected, but the Send would block"; else failReason = $"Disconnected: error code {e.NativeErrorCode}"; } finally { tcpSocket.Blocking = blockingState; } return tcpSocket.Connected; }
在这个方法里面尝试发送一个字节的数据,发送完成后判断当前tcp的连接状态,这里两次设置了tcpSocket的Blocking属性,对于这个我们后面再做深入的分析。通过这个测试方法以后我们就能够判断当前TCP的连接状态,关于这个Socket的Blocking属性是用于设置当前的Socket是否处于阻塞状态,默认为true,我们这里设置为非阻塞方式,对于这个属性更好的解释,可以参考这个MSDN上面的解释。
The Blocking property indicates whether a Socket is in blocking mode.
If you are in blocking mode, and you make a method call which does not complete immediately, your application will block execution until the requested operation completes. If you want execution to continue even though the requested operation is not complete, change the Blocking property to false
. The Blocking property has no effect on asynchronous methods. If you are sending and receiving data asynchronously and want to block execution, use the ManualResetEvent class.
2.3 开启串口连接
这个是一个标准的串口通信的写法,如果之前对象不为空则释放原来的对象,然后开启串口连接,这里需要注意订阅串口的DataReceived事件。
private bool TrySerialPortConnect(out string failReason) { failReason = ""; try { //Close serial port if it is not null if (serialPort != null) { try { Log.Write(LogCategory.Debug, ComponentFullPath, "Close serial port"); serialPort.Close(); serialPort = null; } catch (Exception e0) { Log.Write(LogCategory.Debug, ComponentFullPath, $"Close serial port exception: {e0.Message}"); } } //Open Serial Port serialPort = new SerialPort { PortName = $"COM{SerialPortNo}", BaudRate = SerialBaudRate, RtsEnable = SerialPortRtsEnable, DtrEnable = SerialPortDtrEnable, Parity = (Parity) Enum.Parse(typeof(Parity), Parity.ToString()), StopBits = (StopBits) Enum.Parse(typeof(StopBits), StopBits.ToString()), DataBits = DataBits, ReadTimeout = (int) (ReadingTimeout * 1000), WriteTimeout = (int) (WritingTimeout * 1000) }; serialPort.DataReceived += SerialPort_DataReceived; serialPort.Open(); if (!serialPort.IsOpen) throw new Exception("Serial Port Open Failed"); } catch (Exception ex) { failReason = ex.Message; return false; } return true; }
在做完这一步后如果成功,则表明已经建立了Tcp连接或者串口连接,建立连接后我们需要做一些初始化工作:清空命令队列、清空ReceiveBufferSize......然后设置状态state为Idle。如果连接失败则启动重试连接,重试连接将状态重置为DISCONNECTED,并重复上面的过程,这里需要注意我们需要设置一个重试连接超时时间。
2.4 发送命令
在上面整个连接无误后我们就开始执行发送Command的操作了,在我们的操作中我们将每一条消息抽象成为一个Command,并且将这些Command都放入到一个Queue<Command>中,我们先来看看我们定义的Command对象。
/// <summary> /// Message package structure /// </summary> public class Command { protected Communicator communicator; ManualResetEvent manualEvent; bool commandSucc; bool commandFailed; string errorCode; public Command(Communicator communicator) { this.communicator = communicator; manualEvent = new ManualResetEvent(false); } public Command(Communicator communicator, string commandString, double timeoutSec, bool needReply) { Data = ASCIIEncoding.ASCII.GetBytes(commandString); NeedReply = needReply; this.communicator = communicator; TimeoutSec = timeoutSec; manualEvent = new ManualResetEvent(false); } public Command(Communicator communicator, byte[] commandString, double timeoutSec, bool needReply) { Data = new byte[commandString.Length]; Array.Copy(commandString, Data, commandString.Length); NeedReply = needReply; this.communicator = communicator; TimeoutSec = timeoutSec; manualEvent = new ManualResetEvent(false); } public bool NeedReply { get; protected set; } public byte[] Data { get; protected set; } public double TimeoutSec { get; protected set; } public override string ToString() { if (communicator.IsNeedParseNonAsciiData) { return ASCIIEncoding.UTF7.GetString(Data); //string retString = string.Empty; //foreach (var b in Data) // retString += (Char)b; //return retString; } else return ASCIIEncoding.ASCII.GetString(Data); } public ICommandResult Execute() { communicator._EnqueueCommand(this); OnCommandExecuted(); manualEvent.WaitOne((int)(TimeoutSec * 1000)); if (commandSucc) return CommandResults.Succeeded; else if (commandFailed) return CommandResults.Failed(errorCode); return CommandResults.Failed("Command executing timeout"); } /// <summary> /// Invoked when command was push into queue and send out /// </summary> protected virtual void OnCommandExecuted() { } /// <summary> /// Parse received message /// </summary> /// <param name="message"></param> /// <returns>True: indicate current command execution success, False: indicate current command execution failed, Null: still waiting next receiving message</returns> protected virtual bool? Receive(string message, out string errorCode) { errorCode = ""; return true; } /// <summary> /// Parse received message /// </summary> /// <param name="message"></param> /// <returns>True: indicate current command execution success, False: indicate current command execution failed, Null: still waiting next receiving message</returns> internal bool? _ParseReceviedMessage(string message) { string errorCode; var result = Receive(message, out errorCode); if (result.HasValue) { if (result.Value) { commandSucc = true; manualEvent.Set(); return true; } else { commandFailed = true; this.errorCode = errorCode; manualEvent.Set(); return false; } } return null; } }
在我们的代码中每一个Command都会和唯一的一个Communicator对象关联,这里面最关键的是里面的Execute方法,这个方法在执行的时候会将当前的Command命令加入到Communicator的定义的命令队列中去,并且还会通过定义一个ManualResetEvent来阻塞当前线程,并设置一个超时时间。
另外在这个Command中我们还定义了一个_ParseReceivedMessage的方法,这个方法用来接收当前Command发送以后收到的消息,从而决定当前Command的整个执行结果,再次回到主流程中我们每次从当前的CommandQueue中去取出最近的一条Command,然后通过Tcp或者SerialPort将命令发送出去,这里我们再来看看TryTcpSend和TrySerialSend两个方法。
/// <summary> /// Try socket sending data /// </summary> /// <param name="msg"></param> /// <param name="failReason"></param> /// <returns>True: succ</returns> private bool TryTcpSend(Command msg, out string failReason) { failReason = ""; try { tcpSocket.Send(msg.Data); //System.Diagnostics.Debug.WriteLine($"{DateTime.Now.ToString("HH:mm:ss")} {ComponentName} TCP Send: {msg.ToString()}"); var log = "[SEND] " + FormatLoggingMessage(msg.ToString()); Log.Write(LogCategory.Debug, ComponentFullPath, log); } catch (Exception ex) { failReason = ex.Message; return false; } return true; }
上面的方法调用Tcp的Send将命令发送出去,并记录相关日志。
/// <summary> /// Try serial port sending data /// </summary> /// <param name="msg"></param> private bool TrySerialSend(Command msg, out string failReason) { failReason = ""; try { serialPort.Write(msg.Data, 0, msg.Data.Length); //System.Diagnostics.Debug.WriteLine($"{ComponentFullPath} Serial Send: {msg.ToString()}"); var log = "[SEND] " + FormatLoggingMessage(msg.ToString()); Log.Write(LogCategory.Debug, ComponentFullPath, log); } catch (Exception ex) { failReason = ex.Message; return false; } return true; }
同样串口通过Write方法将数据发送出去,这里我们同样记录了当前的发送数据信息。
注意在这个发送的过程中如果发送失败,我们会再次将当前状态设置为CONNECTING_RETRY_WAIT,再次进入重连的过程。
2.5 等待发送命令Response
在我们发送命令的时候,每个命令都有一个NeedReply值表示当前命令是否需要回应,如果需要回应我们则会进入到WAITING_AFTER_CMD_SEND状态,如果发送完了延时一定的时间最后进入到WAITING_CMD_RESPONSE过程中,最后在这个过程中等待当前Command的回应,如果在设置的TimeOut时间内还没有回应,那么最终就会再次进入失败重连状态。
还记得我们在一开始分析这个过程的时候我们按照正常的流程来分析的时候,暂时没有分析数据接收的过程吗?在下篇我们将重点对这个过程进行分析。