This newsletter post is a continuation of my previous introductory post on coding distributed systems from the bare bones. If you haven't read it yet, I recommend giving it a read.
In this post, I'll be discussing the implementation of a basic TCP/IP server that will, down the road, enable us to implement a node cluster for implementing replicated state machines (You'll find the background info on this in the previous post). With that being said, let's get started.
Implementing a TCP/IP Server
Here is the code for a bare-bones single-threaded blocking TCP/IP server:
public class TCPServer {
private static final Logger logger = LoggerFactory.getLogger(TCPServer.class);
public static void main(String[] args) {
int port = 6523;
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
logger.info("TCP server listening on port " + port);
while (true) {
Socket clientSocket = serverSocket.accept();
logger.info("Client connected: " + clientSocket.getInetAddress());
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
String message;
while ((message = in.readLine()) != null) {
logger.info("Message received from the client: " + message);
out.println("Server received: " + message);
logger.info("Message sent back to the client: " + message);
}
clientSocket.close();
logger.info("Client disconnected.");
}
} catch (IOException e) {
logger.error("Error: " + e.getMessage(), e);
} finally {
try {
if (serverSocket != null) {
serverSocket.close();
}
} catch (IOException e) {
logger.error("Error closing server socket: " + e.getMessage(), e);
}
}
}
}
Let's break it down step by step, in addition to understanding the concepts involved.
public static void main(String[] args) {
Java's main method does not need an explanation; we are all aware that it is the entry point for running a Java program (that's why it's called main). The JVM calls it when it loads the class into the memory without the need to create an instance of the class first.
int port = 6523;
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
logger.info("TCP server listening on port " + port);
In the above lines of code, we have a port and a socket. What are these and how do they work together? Let's quickly understand them.
IP Address, Ports, and Sockets
IP Address
For the client to connect to the server, it needs to know its IP Address, and when the client sends a request to the server over TCP/IP, the request is encapsulated in a series of network packets. These packets contain the source IP and the destination IP.
Upon receiving the request, the server determines the client's IP address from the incoming packets, enabling the server to respond to the correct IP address. This makes IP an essential component of web communication.
Every machine online has an IP (Internet Protocol) address via which they communicate with each other over the internet. The IP address is a unique identifier assigned to devices and DNS resolvers translate the human-readable domain names into IP addresses to enable end users to connect to an IP without the need to memorize the hard-to-remember IP addresses.
TCP (Transmission Control Protocol) enables reliable data exchange between machines over the internet. Most web communication today runs over the TCP/IP model, whether it’s the communication between the client and the server, sending emails, files, and so on. You can read more about the underlying IP layers in network communication, including the TCP/IP and the OSI model, on my blog post.
Ports
A port is a virtual logical connection managed by the machine's OS. A port number is always associated with an IP address.
The IP address helps us determine the machine we intend to connect to and the port number helps us determine the service or the program running on that machine we intend to interact with. The service can be an email service, a web page rendering service, an FTP service, etc.
Port numbers are reserved for specific services. For instance, all the HTTP connections hit port 80 on a server, HTTPS port 443. All the FTP connections hit port 21, SSH port 22, email port 25, DNS port 53, NTP port 123 and so on. Ports help servers understand the function they have to perform with the data they receive over different ports. A client can hit different ports of a server to execute different processes.
As an example, the TCP server runs on my local machine on port 6523. When the client sends a request to localhost:6523, it can connect with to the TCP server.
Now, since the server is running on my local machine and I am running the client program on the same machine, it can send a request to localhost:6523. If it were running on a separate machine, I would have to replace the localhost with my system's IP.
Sockets
Sockets are linked to the ports and can be seen as endpoints of process communication. The TCP connection between the client and the server is facilitated by two endpoints, aka sockets (the client socket and the server socket).
Hopping Back to Our Code
int port = 6523;
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
logger.info("TCP server listening on port " + port);
while (true) {
Socket clientSocket = serverSocket.accept();
logger.info("Client connected: " + clientSocket.getInetAddress());
6523 is the port at which our program runs. serverSocket = new ServerSocket(port) creates a server socket object and binds it to port 6523. This socket listens for incoming client connections via the accept() method: Socket clientSocket = serverSocket.accept();
The accept() method of the ServerSocket object accepts the incoming client connections and returns an instance of the Socket object, which represents the client socket object.
When our server starts, the accept() method runs and blocks the server thread of execution. In other words, it suspends the execution of the program until a client connection request arrives, i.e., it waits for the incoming client connection requests.
In network programming, server sockets typically use blocking I/O operations to wait for incoming client connections or to send and receive data. Blocking operations can cause the thread executing the operation to enter a waiting state, during which it consumes minimal CPU resources.
When the client connection request arrives, the accept() method returns a socket object representing an established connection between the client and the server, enabling bidirectional data exchange.
The clientSocket object encapsulates the communication channel between the server and the specific client that initiated the connection.
After the connection is established, the server proceeds to handle the client request (whatever that is).
Running While Loop Indefinitely
while (true) {
We use the while loop to enable the server to continuously listen for incoming client connections via the accept() method. Once the current client request is handled, the server loops back to the accept() method, that waits for the subsequent incoming client connection request.
This loop continues indefinitely, enabling the server handle multiple client connections sequentially.
logger.info("Client connected: " + clientSocket.getInetAddress());
clientSocket.getInetAddress() method is used to retrieve the IP address of the client.
Communicating with the Client via Streams
The communication between the client and the server involves exchanging bytes between each other, which in Java is based on streams. Streams are a way to handle input-output operations representing a sequence of data that can be read from and written to.
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
After the connection with the client is established, the server handles the request by reading data from the clientSocket input stream and sends the response back to the output stream.
The InputStreamReader class takes the byte stream from the underlying input stream and converts it to the character stream. The BufferedReader class reads text from the character-input stream, obtaining information sent from the client.
There are two types of streams in Java: byte streams and character streams. Byte streams are used to handle raw binary data, such as images, audio files, and other non-text data. They operate at the byte level and provide a way to read and write raw bytes of data.
Character streams are used to handle text data. These streams are built on top of byte streams and are capable of converting bytes to characters and vice versa. The InputStreamReader is a byte stream class and the BufferedReader is the character stream class.
Depending on the type of data being processed, we can choose the fitting stream type to perform input/output operations efficiently.
The data through the network is typically transmitted as a sequence of bytes. However, when dealing with text-based communication over the network, such as sending and receiving strings or textual data, it's often more convenient and efficient to work with characters rather than raw bytes. This is why we converted the byte stream to the character stream.
Converting the byte stream to a character stream will allow us to perform text processing tasks (if required), such as parsing, tokenization, and manipulation directly on the text data, without having to deal with low-level byte manipulation. When dealing with textual data, it's more natural to work with characters as opposed to raw bytes.
The PrintWriter class creates an output stream to write data to the client socket. This class does the reverse operation and converts the character data to the byte data before writing it to the output stream.
Reading Data & Echoing It Back to the Client
String message;
while ((message = in.readLine()) != null) {
logger.info("Message received from the client: " + message);
out.println("Server received: " + message);
logger.info("Message sent back to the client: " + message);
}
The while loop runs until the received message is not null or the end of the stream is reached. out is the PrintWriter object that writes the message to the output stream.
clientSocket.close();
logger.info("Client disconnected.");
After the communication is complete, the server closes the connection for the resources to be released.
} catch (IOException e) {
logger.error("Error: " + e.getMessage(), e);
} finally {
try {
if (serverSocket != null) {
serverSocket.close();
}
} catch (IOException e) {
logger.error("Error closing server socket: " + e.getMessage(), e);
}
}
}
}
This part of the code handles any exceptions that might arise, logging them appropriately, in addition to final resource management and cleanup tasks if required.
So, folks, we've covered the bare-bones implementation of a TCP/IP server along with the associated concepts. We've understood the underpinnings of the communication between the client and the server over the network, the different layers of the OSI network involved and so on.
Our TCP/IP server listens to incoming client requests and echoes back any messages received from them. After the response is sent, the connection is closed.
Time to test our server.
Testing Our TCP/IP Server
I've used Telnet to test the remote connection with the server. Telnet stands for Teletype Network. It's a protocol that enables a machine to connect to the other machine over the network operating on the client-server principle.
Since the server program is running on my local machine, I connected to it via the cmd command 'telnet localhost 6523'. 6523 is the port number the program is running on.
I then sent a message to the server and received it back. Our TCP/IP server is confirmed up and running. Woohoo!
Here are our server logs:
Single-threaded Blocking Behavior
As I mentioned before, our TCP/IP server is a single-threaded blocking server that handles client requests sequentially, one request at a time. To test this, I wrote a client program that spawns five threads to send concurrent connection requests to the server.
public class TCPConcurrentClients {
private static final Logger logger = LoggerFactory.getLogger(TCPConcurrentClients.class);
public static void main(String[] args) {
int concurrentClients = 5;
String serverAddress = "localhost";
int serverPort = 6523;
for (int i = 0; i < concurrentClients; i++) {
Thread clientThread = new Thread(() -> {
try {
logger.info("Client " + Thread.currentThread().getName() + " connecting to server...");
Socket clientSocket = new Socket(serverAddress, serverPort);
logger.info("Client " + Thread.currentThread().getName() + " connected to server.");
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
logger.info("Client " + Thread.currentThread().getName() + " sending message to server...");
out.println("Hello from client " + Thread.currentThread().getName());
String response = in.readLine();
logger.info(
"Response from server to client " + Thread.currentThread().getName() + ": " + response);
clientSocket.close();
logger.info("Client " + Thread.currentThread().getName() + " disconnected from server.");
} catch (IOException e) {
e.printStackTrace();
}
});
clientThread.start();
}
}
}
The above program fires concurrent requests to our server and our server handles them sequentially. Though the server handles the request sequentially, these connection requests may be queued by the operating system or if we have an explicit request backlog queue implemented (More on this in the upcoming posts). These requests may also experience delays or timeouts depending on the server implementation.
Here are the server logs that show the sequential handling of the clients' requests:
The server receives the connection request of a thread, reads the message, echoes it back, closes the connection and processes the subsequent thread request.
In my next post, I’ve delved into how our server can handle concurrent requests to increase the request throughput. Check it out.
Though the code in this post is in Java, you can learn to code distributed systems in the backend programming language of your choice with CodeCrafters (Affiliate).
CodeCrafters is a platform that helps us code distributed systems like Redis, Docker, Git, a DNS server, and more step-by-step from the bare bones in the programming language of our choice. With their hands-on courses, we not only gain an in-depth understanding of distributed systems and advanced system design concepts but can also compare our project with the community and then finally navigate the official source code to see how it’s done. It’s a headstart to becoming an OSS contributor.
You can use my unique link to get 40% off if you decide to make a purchase. Cheers!
Both the system design/software architecture concepts and the distributed system fundamentals are a vital part of the system design interviews and coding distributed systems.
If you wish to master the fundamentals, check out my Zero to Software Architecture Proficiency learning path, comprising three courses that go through all the concepts starting from zero in an easy-to-understand language. The courses educate you, step by step, on the domain of software architecture, cloud infrastructure and distributed services design.
You can also check out several system design case studies and blog articles that I have written in this newsletter and my blog.
If you found this newsletter post helpful, consider sharing it with your friends for more reach.
If you are reading the web version of this post, consider subscribing to get my posts delivered to your inbox as soon as they are published.
You can get a 50% discount on my courses by sharing my posts with your network. Based on referrals, you can unlock course discounts. Check out the leaderboard page for details.
You can find me on LinkedIn & X and can chat with me on Substack chat as well. I'll see you in the next post. Until then, Cheers!
Always nice to see technical details accompanied by actual code snippet! Good stuff.