Network I/O Operations
To demonstrate how to use network streams, we'll attempt to create a client-server chat application. The client connects to the server, and both parties can exchange messages.
First and foremost, it's important to note that only binary data circulates over a network. It cannot be assumed that text will be transmitted as-is during sending and receiving. The data that circulates in a network is always encapsulated in binary format within a transfer unit over a protocol. The communication protocol is a pre-established method by which participants agree on the format of the transmitted data and the method of interaction. The data transfer unit, often referred to as a packet, contains a header that describes certain information about the data to be transmitted, as well as mechanisms to ensure data integrity.
For our example, we will transmit a text message framed in binary format within a packet of the form ||||<message_length>||||<message>, where "|" is a delimiter character. It's worth mentioning that the message length will be written in network byte order, specifically in big-endian order. Historically, big-endian order was used to transmit data over networks because it could accommodate communication between both big-endian and little-endian computers. This choice was made to ensure compatibility. If a value of type int is transmitted as-is in memory from one architecture to another, it might end up being read in a different order and could lead to issues. Therefore, the number needs to be converted to a specific order. The IPAddress class can be used here for conversion.
In the following example, TCP connections will be used to transmit data. A client will connect to an IP address on a specific port, where a remote process is waiting for connections. The client will directly create a TcpClient, which exposes a NetworkStream through which messages can be exchanged. The server will bind to the network interfaces of the machine and wait for connections. Upon receiving a connection, it will also generate a new TcpClient representing the newly connected client.
// We are abstracting the communication channel.
public abstract class AbstractChannel
{
// We describe constants for the message header structure in bytes.
private const int HeaderStartSize = 4;
private const int HeaderEndSize = 4;
// In the header, we want to include the length of the message we want to send.
private const int HeaderPayloadLengthSize = sizeof(int);
private const int HeaderSize = HeaderStartSize + HeaderPayloadLengthSize + HeaderEndSize;
// We use a special character to identify the header and ensure it's well-formed.
private static readonly byte StartCharacter = Convert.ToByte('|');
// The TCP connection will be established by classes inheriting the channel, and we make it abstract.
protected abstract TcpClient? Client { get; set; }
public abstract void Stop();
// If we encounter an error multiple times, we store it as a property.
private static Exception MakeNotConnectedException => new InvalidOperationException("The client is not connected!");
private static Exception MakeConnectionAbortedException => new("The client is not connected!");
// The message header will look like ||||<messageSize>||||, and we create it here.
private static byte[] MakeHeader(int messageSize)
{
// Create the header as an array and populate it as desired.
var header = new byte[HeaderSize];
Array.Fill(header, StartCharacter, 0, HeaderStartSize);
Array.Fill(header, StartCharacter, HeaderStartSize + HeaderPayloadLengthSize, HeaderEndSize);
// To avoid issues if we send an int from big-endian to little-endian, convert the number to network byte order.
messageSize = IPAddress.HostToNetworkOrder(messageSize);
Array.Copy(BitConverter.GetBytes(messageSize), 0, header, HeaderStartSize, HeaderPayloadLengthSize);
return header;
}
// Check if the header is valid.
public static bool IsValidHeader(byte[] header)
{
// We can extract parts of the header to verify them.
return header[..HeaderStartSize].All(e => e == StartCharacter) &&
header[(HeaderStartSize + HeaderPayloadLengthSize)..].All(e => e == StartCharacter);
}
// When receiving a header, we need to extract the message size from it.
public static int GetSizeFromHeader(byte[] header)
{
// Since we sent it in network byte order, we need to convert it back to the current architecture.
var messageSize = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(header, HeaderStartSize));
return messageSize;
}
// In networking, we send binary data, not text, so we need to transform everything into bytes and back.
public static byte[] MakePayload(string message) => Encoding.ASCII.GetBytes(message);
public static string GetPayload(byte[] buffer) => Encoding.ASCII.GetString(buffer);
// We need to send the message over the network and will use the client's stream.
public void SendMessage(string message)
{
if (Client == null)
{
throw MakeNotConnectedException;
}
// Simply use the stream to write the header and message as bytes.
Client.GetStream().Write(MakeHeader(message.Length));
Client.GetStream().Write(MakePayload(message));
}
// Receiving is a bit more complicated as we can't always expect to receive all the data at once.
public void ReceiveBytes(byte[] buffer, int size)
{
if (Client == null)
{
throw MakeNotConnectedException;
}
var bytesReceived = 0;
// We expect to receive a certain number of bytes and loop to receive them.
do
{
// We might receive a part of the data, so we need to keep track of what we've received.
bytesReceived += Client.GetStream().Read(buffer, bytesReceived, size - bytesReceived);
// If we received 0 bytes, the connection was closed.
if (bytesReceived == 0)
{
Stop();
throw MakeConnectionAbortedException;
}
} while (bytesReceived < size);
}
// Similar to sending, first we receive the header, then we try to receive the message.
public string ReceiveMessage()
{
if (Client == null)
{
throw new InvalidOperationException("The client is not connected!");
}
// We know how the header looks and wait to receive its fixed size.
var headerBuffer = new byte[HeaderSize];
ReceiveBytes(headerBuffer, HeaderSize);
if (!IsValidHeader(headerBuffer))
{
throw MakeNotConnectedException;
}
// After extracting the message size from the header, we wait to receive the message itself.
var messageSize = GetSizeFromHeader(headerBuffer);
var payloadBuffer = new byte[messageSize];
ReceiveBytes(payloadBuffer, messageSize);
return GetPayload(payloadBuffer);
}
/* Declare the main loop for reading from the keyboard and sending messages
* here for convenience, otherwise we could declare it elsewhere.
*/
public Task SendMessagesAsync()
{
return Task.Run(() =>
{
while (true)
{
try
{
var message = Console.ReadLine();
if (Client?.Connected != true)
{
break;
}
if (message == "exit")
{
Stop();
break;
}
if (string.IsNullOrWhiteSpace(message))
{
continue;
}
SendMessage(message);
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
break;
}
}
});
}
// Declare the loop for receiving as well.
public Task ReceivedMessagesAsync()
{
return Task.Run(() =>
{
while (true)
{
try
{
Console.WriteLine("Received: {0}", ReceiveMessage());
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
break;
}
}
});
}
}
// For the sender
public class Program
{
private const string HostAddress = "localhost";
private const int HostPort = 8989;
public static void Main()
{
/* Create the sender and use the "using" keyword to automatically
* Dispose it when leaving the scope.
*/
using var sender = new ChatClient(HostAddress, HostPort);
Console.WriteLine("Starting the sender!");
try
{
// Start the client and attempt to connect to the server.
sender.Connect();
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return;
}
Console.WriteLine("Sender ready to chat!");
/* Start the asynchronous send and receive loops so we can send and
* receive at the same time.
*/
var sendTask = sender.SendMessagesAsync();
var receiveTask = sender.ReceivedMessagesAsync();
sendTask.Wait();
receiveTask.Wait();
}
}
// For the server
public class Program
{
private const int BindPort = 8989;
public static void Main()
{
/* Create the receiver and use the "using" keyword to automatically
* Dispose it when leaving the scope.
*/
using var receiver = new ChatServer(BindPort);
Console.WriteLine("Starting the receiver!");
try
{
// Start and wait for client connections.
receiver.StartAndAccept();
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return;
}
Console.WriteLine("Receiver ready to chat!");
/* Start the asynchronous send and receive loops so we can send and
* receive at the same time.
*/
var sendTask = receiver.SendMessagesAsync();
var receiveTask = receiver.ReceivedMessagesAsync();
sendTask.Wait();
receiveTask.Wait();
}
}
In the given example, it's important to mention that especially in network communication, we cannot rely on either the data being intact at the end of transmission (i.e., not altered) or that the connection cannot be interrupted. There can be numerous error scenarios that we have omitted in order to simplify the example. However, in practice, when working with network I/O, you need to consider them. To gain a better understanding of the issues that can arise in network communication, it's advisable to follow a course that specifically addresses this topic. As a recommended book, consider COMPUTER NETWORKS 5th Edition; ANDREW S. TANENBAUM, DAVID J. WETHERALL.
Exercises
Modify the given example to invoke asynchronous read and write functions using CancellationToken for easier termination of the client and server.