Friday, December 28, 2018

Event-Driven TCP

Merry Christmas and/or Happy Holidays! Happy New Year too! Here's a present for you ... the long-awaited, easy-to-use set of TCP Client classes that I use in my own applications (maybe abbreviated a bit, but not by much)!

First, let me start off mentioning the best site that I've found for an "almost perfect" class to use for easily writing event-driven TCP clients. The site is called DaniWeb: http://www.daniweb.com/software-development/csharp/code/422291/user-friendly-asynchronous-event-driven-tcp-client I say "almost perfect", because it needed some tweaking for my purposes.

I had initially made an addition of one Timer, and called it tmrTestConnection. It turns out that it was all that was ever needed, none of the other Timers in the original DaniWeb EventDrivenTCPClient class are necessary and can actually cause problems (or at least the way I needed to use this class). I also added several variables: m_LastReported, m_LastReceived (DateTime) and TCPFailureInterval (configurable), all dealing with connectivity.

Here's how my test Client application uses TCP:

  • The Client connects to a TCP Server (I will provide a bare-bones Server example for your testing, in case you don't have a TCP Server handy).
  • The Server can either respond to the Client (once a Connection has been established):
    • by sending data (the Server retrieves some data from a File, which is part of the solution)
    • or do nothing until the Client sends a "Handshake" message, and then respond to the Client by sending the data.
  • That will, of course, depend on your application and the requirements of the Server that you are connecting.
    • In my test example here, I originally wrote it so that the Server would send data as soon as the Client connects.
    • But then decided to change it so that the Server waits for the "Handshake" message before sending any data.

And, there's connectivity error handling:

  • If the Server is not available when the Client first starts, the Client will continue to try to Connect, periodically logging Connect attempts at a configurable interval.
  • If the Server goes down, the Client continually tries to Connect again, periodically logging Connect attempts at a configurable interval (in the tmrTestConnection_Elapsed event handler).
  • While the Client is connected to the Server, if data is not received on a regular basis, it is logged periodically at a configurable interval (also in the tmrTestConnection_Elapsed).

There's too much code to post here in my blog. I'll post bits and pieces of it to illustrate the above points, but I've zipped up the two solutions and they are on my Google Drive: here’s the link for the Client and here’s the link for the Server . The Client zip file also includes the original code from DaniWeb (not added to the project, but the file DaniWeb.cs is with all the other files), but you should still take a look at the DaniWeb link that I posted at the start of this blog. It might be a good idea to download my zip files now and follow along:

The EventDrivenTCPClient is not supposed to be used directly. There is another class, TcpListener, that does the job of starting it up and hooking up a few events and then calling its Connect() method to get it all started. Here is the code for the StartListener() method in the TcpListener class:


protected virtual void StartListener()
{
    if (this.TestFromFiles)
    {
        this.GetDataFromFile();
        return;
    }
    this.TcpEvent = new EventDrivenTCPClient(IPAddress.Parse(this.ipAddress), this.Port);
    if (this.ReconnectInterval != 0)
        this.TcpEvent.ReconnectInterval = this.ReconnectInterval;
    this.TcpEvent.ConnectionStatusChanged += new EventDrivenTCPClient.delConnectionStatusChanged(TcpEvent_ConnectionStatusChanged);
    this.TcpEvent.DataReceived += new EventDrivenTCPClient.delDataReceived(TcpEvent_DataReceived);
    this.TcpEvent.Connect();
}

I'll explain the purpose of the TestFromFiles in a minute, because as you notice from the above code, it is not starting up anything! There is a method to my madness, however, and I will get to that soon.

So, if you're not testing from files, you'll receive the data from the Server in the TcpEvent_DataReceived() event handler. The TcpEvent (aka the EventDrivenTCPClient) fires the event whenever data is received from the TCP port. Now, here's where more work needs to be done, here in the TcpListener class ... because the data that is received from the TcpEvent, is in chunks based on the buffer size. We need to parse out that data into meaningful complete pieces of data.

In other words, the data received can be a partial set of data,  in which case we have to continue waiting to receive the rest of the set ...
Or it could contain the rest of the set and part of the next one, in which case, we'll do something with the complete set and wait to receive the rest of the next one ...
Or it could contain the rest of the set and complete multiple sets, in which case, we'll do something with the multiple complete sets.

I just so happen to have a BufferedDataParser class that will do just that (see the DataParser.cs file). The TcpEvent_DataReceived() event handler does only one thing: it calls the ProcessDataReceived() method, which makes use of the DataParser, like so:


protected void ProcessDataReceived(object data)
{
    if (this.DataParser != null)
    {
        List<string> DataList = this.DataParser.ProcessDataReceived(ref buffer, data.ToString());
        foreach (string Xml in DataList)
        {
            this.SendMessage(Xml);
            if (this.IsRunningFromConsole)
            {
                Console.WriteLine("******************* Begin Raw Data ***********************");
                Console.WriteLine(Xml);
                Console.WriteLine("*********************** End Raw  *************************");
                Console.WriteLine("Length: {0}", Xml.Length);
            }
        }
    }
    else
    {
        LogOutput.WriteLine("No DataParser!");
        if (this.IsRunningFromConsole)
            Console.WriteLine("Length: {0}", data.ToString().Length);
        this.SendMessage(data.ToString());
    }
}

Notice in the code above, the parameter "ref buffer" in the call to the DataParser.ProcessDataReceived(ref buffer, data.ToString()) method. That gives the continuity necessary for the DataParser to contain the entire partial set of data with every chunk of data received from the TCP port, until the Parser determines it has at least a full set of data (the current chunk of data may contain a partial set or multiple sets of data, hence the need for a List<string> returned from the Parser).

The BufferedDataParser is an abstract class, that is meant to be inherited by different kinds of Parsers that rely on receiving buffered data. I've included two different kinds of Parsers (one of which is demonstrated in the application). So, here's the abstract class:


public abstract class BufferedDataParser : IBufferedDataParser
{
    #region Declarations

    protected string HeartBeat = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><ENDOFXML/>";
    protected string ValidStart = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
    protected string ValidEnd = "</AXmlTargets>";
    protected bool IncludeDelimiters = true;

    #endregion

    #region Methods

    public List<string> ProcessDataReceived(ref string buffer, string dataReceived)
    {
        List<string> DataList = new List<string>();
        try
        {
            //add received data to the buffer
            buffer += dataReceived;

            this.RemoveHeartbeatData(ref buffer);

            int whileLoopWatchdog = 0;
            while (buffer.Length > 0)
            {
                this.RemoveDataPrecedingStart(ref buffer);

                //verify that a complete message is present, if so process just that
                string Complete = this.ProcessCompleteMessage(ref buffer);
                if (Complete.IsNotNullOrEmpty())
                {
                    string Xml;
                    if (this.IncludeDelimiters)
                        Xml = Complete;
                    else
                        Xml = this.ParseOutDelimiters(Complete);

                    DataList.Add(Xml);

                    //remove the data that was pulled out for processing
                    //this will replace multiple identical occurrences but that
                    //is fine
                    buffer = buffer.Replace(Complete, "");
                }

                //HACK: prevent endless while loop
                //(although 20+ complete sets of data at once probably wouldn't
                //happen, and if it did the remainder would be caught on
                //next data receive)
                whileLoopWatchdog++;
                if (whileLoopWatchdog > 20)
                {
                    whileLoopWatchdog = 0;
                    break;
                }
            }
        }
        catch (Exception ex)
        {
            LogOutput.WriteLine(ex);
        }
        return DataList;
    }

    protected virtual void RemoveHeartbeatData(ref string buffer)
    {
        int whileLoopWatchdog = 0;

        //remove any "heartbeat" data
        while (buffer.Contains(HeartBeat))
        {
            //this will replace multiple identical occurrences but that
            //is fine
            buffer = buffer.Replace(HeartBeat, "");

            //HACK: prevent endless while loop
            //(although 100+ heartbeats should never happen)
            whileLoopWatchdog++;
            if (whileLoopWatchdog > 100)
            {
                break;
            }
        }
    }
    protected virtual void RemoveDataPrecedingStart(ref string buffer)
    {
        //discard any text that precedes the valid start
        if (buffer.IndexOf(ValidStart) > 0)
        {
            buffer = buffer.Substring(
            buffer.IndexOf(ValidStart));
        }
    }
    protected virtual string ProcessCompleteMessage(ref string buffer)
    {
        string Complete = "";

        if ((buffer.StartsWith(ValidStart)) && (buffer.Contains(ValidEnd)) && (buffer.IndexOf(ValidStart) < buffer.IndexOf(ValidEnd)))
        {
            Complete = buffer.Substring(buffer.IndexOf(ValidStart),
            buffer.IndexOf(ValidEnd) + ValidEnd.Length - buffer.IndexOf(ValidStart));
        }

        return Complete;
    }
    protected virtual string ParseOutDelimiters(string Complete)
    {
        return Complete.Substring(Complete.IndexOf(ValidStart) + ValidStart.Length, Complete.IndexOf(ValidEnd) - ValidStart.Length);
    }

    #endregion
}

Some DataParsers, simply need to use different delimiters and let the base class handle everything else, like the StxEtxDataParser:


public class StxEtxDataParser :BufferedDataParser
    {
    public StxEtxDataParser()
    {
        this.HeartBeat = "no heartbeat";
        this.ValidStart = new string(new char[] { (char)2 });
        this.ValidEnd = new string(new char[] { (char)3 });
        this.IncludeDelimiters = false;
    }
}

STX/ETX are common delimiters for TCP transmissions. STX is a (char)2 and ETX is a (char)3.

The DataParsers.cs file also contains a CountDelimiterDataProcessor. It inherits from the BufferedDataParser also, but has a totally different way of parsing the received data. I won't post it here, you can check it out when you look at the downloaded zip file.

Now, as promised, I'll get back to the purpose of TestFromFiles: You should have sample data from the Server you're trying to connect to saved in a file. You obviously have to have this, or how would you ever be able to develop an application without knowing something about the data!?! By using this saved file to test from, you can quickly determine if your DataParser is parsing the data correctly without worrying about connecting to a Server. If there's a problem, keep refactoring your DataParser (not the abstract class! That one should not be touched!) until it's perfect.

I think that's about it for now. Download the two zip files and give it a test yourself. It's good to go "right out of the box" ... you only have to change the ipaddress (and port if you want to) in the app.configs of both Client and Server.

Happy Coding!  =0)

No comments:

Post a Comment