1. 程式人生 > 其它 >C# 構建一個TCP和SerialPort通訊的通用類(上)

C# 構建一個TCP和SerialPort通訊的通用類(上)

背景

  在使用C#進行開發的時候我們需要去和外部進行通訊,而常見的通訊協議主要是TCPSerialPort和外部進行通訊,在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 定義系統中可供外界進行配置的引數

  這個主要是定義一些外部可以配置的一些引數,這些引數的配置可以通過一個介面配置來進行展示,通過這些配置我們就能夠實時去配置我們通訊系統中的配置,我們先來看看這個配置的截圖。

圖一 系統引數配置

二 核心過程

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上面的解釋。

TheBlockingproperty indicates whether aSocketis 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 theBlockingproperty tofalse. TheBlockingproperty has no effect on asynchronous methods. If you are sending and receiving data asynchronously and want to block execution, use theManualResetEventclass.

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時間內還沒有迴應,那麼最終就會再次進入失敗重連狀態。