
🏗️ The 4-File Project Layout
We will create:
RespParser.java: Handles protocol serialization and deserialization.Database.java: Encapsulates the in-memory map and command routing.RedisServer.java: Manages the socket server, thread pool, and client read loops.Main.java: Bootstraps the application.
📦 Module 1: Building the Protocol Parser (RespParser.java)
Create a file named RespParser.java in your root directory. We will build it inside the public class RespParser { ... } block step-by-step.
Step 1.1: File Shell & Imports
Create the file and add the required standard Java imports:
import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; public class RespParser { // We will build all parser components inside this class block }
Step 1.2: Defining RESP Data Types (The Type Enum)
Under the RESP2 protocol, there are 5 core data types. We define an Enum to represent them.
Add this enum inside the RespParser class:
public enum Type { SIMPLE_STRING, ERROR, INTEGER, BULK_STRING, ARRAY }
Step 1.3: Creating the Data Holder (RespValue Class)
We need a class to hold parsed values and their type.
Add this static inner class skeleton inside the RespParser class:
public static class RespValue { public final Type type; public final Object value; // Can hold String, Long, or List<RespValue> public RespValue(Type type, Object value) { this.type = type; this.value = value; } // We will write the serialize() method next inside this RespValue class }
Step 1.4: Serialization — Simple String (+)
- RESP Protocol Rule: Prefix with
+, follow with string, and terminate with\r\n(CRLF). - Example:
"OK"$\rightarrow$"+OK\r\n"
We start implementing the serialize() method inside the RespValue class:
public byte[] serialize() { switch (type) { case SIMPLE_STRING: return ("+" + value + "\r\n").getBytes(StandardCharsets.UTF_8);
Step 1.5: Serialization — Error (-)
- RESP Protocol Rule: Prefix with
-, follow with error message, and terminate with\r\n. - Example:
"ERR"$\rightarrow$"-ERR\r\n"
Add this case to the switch statement in the serialize() method:
case ERROR: return ("-" + value + "\r\n").getBytes(StandardCharsets.UTF_8);
Step 1.6: Serialization — Integer (:)
- RESP Protocol Rule: Prefix with
:, follow with numeric digits, and terminate with\r\n. - Example:
1000$\rightarrow$":1000\r\n"
Add this case to the switch statement in the serialize() method:
case INTEGER: return (":" + value + "\r\n").getBytes(StandardCharsets.UTF_8);
Step 1.7: Serialization — Bulk String ($)
- RESP Protocol Rule: Prefix with
$, follow with the length of the string, terminate with\r\n, follow with the raw string contents, and terminate with a final\r\n. - Null Rule: If value is null, return
$-1\r\n. - Example:
"hello"$\rightarrow$"$5\r\nhello\r\n"
Add this case to the switch statement in the serialize() method:
case BULK_STRING: if (value == null) { return "$-1\r\n".getBytes(StandardCharsets.UTF_8); } String str = (String) value; return ("$" + str.length() + "\r\n" + str + "\r\n").getBytes(StandardCharsets.UTF_8);
Step 1.8: Serialization — Array (*)
- RESP Protocol Rule: Prefix with
*, follow with the array size, terminate with\r\n, and then serialize each element in the array sequentially. - Null Rule: If list is null, return
*-1\r\n.
Complete the serialize() method by adding the array case and closing the method block:
case ARRAY: if (value == null) { return "*-1\r\n".getBytes(StandardCharsets.UTF_8); } @SuppressWarnings("unchecked") List<RespValue> list = (List<RespValue>) value; ByteArrayOutputStream bos = new ByteArrayOutputStream(); try { bos.write(("*" + list.size() + "\r\n").getBytes(StandardCharsets.UTF_8)); for (RespValue val : list) { bos.write(val.serialize()); // Recursive serialization } } catch (IOException ignored) {} return bos.toByteArray(); default: throw new IllegalArgumentException("Unknown RESP type"); } }
Step 1.9: Parser Reader Setup (RespReader Class)
Now we build the class that reads from the network input stream and decodes it. We wrap the InputStream in a BufferedInputStream to avoid making slow, individual read system calls.
Add this static inner class skeleton inside the RespParser class (below RespValue class):
public static class RespReader { private final BufferedInputStream in; public RespReader(InputStream in) { this.in = new BufferedInputStream(in); } // We will add the reading helper methods here next }
Step 1.10: Reading Lines (readLine)
Most basic RESP lines terminate with \r\n (CRLF). We read bytes one-by-one until we hit \r\n.
Add this helper method inside the RespReader class:
private String readLine() throws IOException { StringBuilder sb = new StringBuilder(); int c; while ((c = in.read()) != -1) { if (c == '\r') { int next = in.read(); if (next == '\n') { break; // End of line reached successfully } sb.append('\r'); if (next != -1) { sb.append((char) next); } } else { sb.append((char) c); } } return sb.toString(); }
Step 1.11: Reading Bulk Strings (readBulkString)
To parse a bulk string:
- Parse the length integer (terminated by
\r\n). - If length is
-1, return a null bulk string. - Allocate a byte array. Read exactly
lengthbytes in a loop. - Read and consume the trailing
\rand\nbytes.
Add this helper method inside the RespReader class:
private RespValue readBulkString() throws IOException { String lenStr = readLine(); int length = Integer.parseInt(lenStr); if (length == -1) { return new RespValue(Type.BULK_STRING, null); } byte[] bytes = new byte[length]; int read = 0; while (read < length) { int currentRead = in.read(bytes, read, length - read); if (currentRead == -1) { throw new EOFException("Premature end of stream while reading bulk string content"); } read += currentRead; } // Consume CRLF int r = in.read(); int n = in.read(); if (r != '\r' || n != '\n') { throw new IOException("Expected CRLF (\\r\\n) at the end of bulk string"); } return new RespValue(Type.BULK_STRING, new String(bytes, StandardCharsets.UTF_8)); }
Step 1.12: Reading Arrays (readArray)
To parse an array:
- Parse the array size integer (terminated by
\r\n). - If size is
-1, return a null array. - Loop
sizetimes, recursively callingreadValue()for each nested element.
Add this helper method inside the RespReader class:
private RespValue readArray() throws IOException { String lenStr = readLine(); int size = Integer.parseInt(lenStr); if (size == -1) { return new RespValue(Type.ARRAY, null); } List<RespValue> elements = new ArrayList<>(size); for (int i = 0; i < size; i++) { elements.add(readValue()); // Recursive call } return new RespValue(Type.ARRAY, elements); }
Step 1.13: Master Router (readValue)
This is the parser entry point. It reads the first byte of any client message (the prefix) and routes to the correct parsing sub-logic.
Add this public method inside the RespReader class:
public RespValue readValue() throws IOException { int prefix = in.read(); if (prefix == -1) { throw new EOFException("Connection closed by client"); } switch (prefix) { case '+': return new RespValue(Type.SIMPLE_STRING, readLine()); case '-': return new RespValue(Type.ERROR, readLine()); case ':': return new RespValue(Type.INTEGER, Long.parseLong(readLine())); case '$': return readBulkString(); case '*': return readArray(); default: throw new IOException("Invalid RESP prefix: '" + (char) prefix + "'"); } }
📦 Module 2: Building the Database Store (Database.java)
Create a file named Database.java in your root directory.
Step 2.1: File Shell & Storage Map
We define our database engine. We initialize a ConcurrentHashMap so that concurrent thread operations from our server connection pool are completely safe.
Write this code into Database.java:
import java.util.List; import java.util.concurrent.ConcurrentHashMap; public class Database { private final ConcurrentHashMap<String, String> store = new ConcurrentHashMap<>(); // We will build executeCommand() next inside this class block }
Step 2.2: Implementing the Command Switch block
We parse and route commands inside a switch statement. In Milestone 3, we support PING (which returns its optional argument or "PONG") and ECHO.
Add this method inside the Database class:
public RespValue executeCommand(String commandName, List<RespValue> args) { switch (commandName.toUpperCase()) { case "PING": if (args.size() > 1) { return new RespValue(Type.BULK_STRING, args.get(1).value); } return new RespValue(Type.SIMPLE_STRING, "PONG"); case "ECHO": if (args.size() < 2) { return new RespValue(Type.ERROR, "ERR wrong number of arguments for 'echo' command"); } return new RespValue(Type.BULK_STRING, args.get(1).value); default: return new RespValue(Type.ERROR, "ERR unknown command '" + commandName + "'"); } }
📦 Module 3: Building the Network Server (RedisServer.java)
Create a file named RedisServer.java in your root directory.
Step 3.1: Server Setup & Main Sockets Loop
This class manages the TCP listener port, thread pool executor, and client connection accepts.
Write this code into RedisServer.java:
import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class RedisServer { private final int port; private final ExecutorService threadPool; private final Database db; public RedisServer(int port) { this.port = port; this.threadPool = Executors.newCachedThreadPool(); this.db = new Database(); } public void start() { System.out.println("Starting Java Redis Server on port " + port + "..."); try (ServerSocket serverSocket = new ServerSocket(port)) { serverSocket.setReuseAddress(true); System.out.println("Server is listening and ready to accept connections!"); while (true) { try { Socket clientSocket = serverSocket.accept(); System.out.println("Accepted connection from: " + clientSocket.getRemoteSocketAddress()); threadPool.submit(new ClientHandler(clientSocket)); } catch (IOException e) { System.err.println("Error accepting client connection: " + e.getMessage()); } } } catch (IOException e) { System.err.println("Failed to start server: " + e.getMessage()); } finally { threadPool.shutdown(); } } // We will build ClientHandler next inside this class block }
Step 3.2: Creating the Client Connection Worker
We create a thread-runnable task as a private inner class inside RedisServer.java.
Add this class skeleton at the bottom of the RedisServer class (before the final closing bracket):
private class ClientHandler implements Runnable { private final Socket clientSocket; public ClientHandler(Socket clientSocket) { this.clientSocket = clientSocket; } @Override public void run() { try (Socket socket = clientSocket) { InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream(); RespParser.RespReader reader = new RespParser.RespReader(inputStream); while (true) { try { RespValue request = reader.readValue(); RespValue response = handleRequest(request); outputStream.write(response.serialize()); outputStream.flush(); } catch (EOFException e) { break; // Client disconnected cleanly } catch (IOException e) { System.err.println("Protocol error on " + socket.getRemoteSocketAddress() + ": " + e.getMessage()); RespValue errorResponse = new RespValue(Type.ERROR, "ERR " + e.getMessage()); outputStream.write(errorResponse.serialize()); outputStream.flush(); break; } } System.out.println("Client disconnected: " + socket.getRemoteSocketAddress()); } catch (IOException e) { System.err.println("Socket error: " + e.getMessage()); } } // We will build handleRequest() next inside this ClientHandler class block }
Step 3.3: Processing Client Requests
We validate that the incoming command is a valid array of bulk strings, extract the command name, and send it to our database for execution.
Add this method inside the ClientHandler class (below run()):
private RespValue handleRequest(RespValue request) { if (request.type != Type.ARRAY) { return new RespValue(Type.ERROR, "ERR expected command array"); } @SuppressWarnings("unchecked") List<RespValue> elements = (List<RespValue>) request.value; if (elements == null || elements.isEmpty()) { return new RespValue(Type.ERROR, "ERR empty command"); } RespValue cmdElement = elements.get(0); if (cmdElement.type != Type.BULK_STRING) { return new RespValue(Type.ERROR, "ERR command name must be a bulk string"); } String commandName = (String) cmdElement.value; try { return db.executeCommand(commandName, elements); } catch (Exception e) { return new RespValue(Type.ERROR, "ERR executing command: " + e.getMessage()); } }
📦 Module 4: Bootstrapping (Main.java)
Create a file named Main.java in your root directory.
Step 4.1: The main Entrypoint
This bootstraps the application and starts the server.
Write this code into Main.java:
public class Main { public static void main(String[] args) { int port = 6379; RedisServer server = new RedisServer(port); server.start(); } }
📦 Module 5: Compilation and Testing
Step 5.1: Compilation command
In your terminal, run the following command to compile all 4 files:
javac Main.java RedisServer.java RespParser.java Database.java
Step 5.2: Start your server
Start the Java Redis server:
java Main
Step 5.3: Verification
Open another terminal window, and run:
- Test PING:
redis-cli PING$\rightarrow$ ExpectPONG - Test PING with Message:
redis-cli PING "Hello World!"$\rightarrow$ Expect"Hello World!" - Test ECHO:
redis-cli ECHO "Echo testing"$\rightarrow$ Expect"Echo testing"