Bytes become commands
A command dispatcher, plus PING and ECHO.
You will build
a command dispatcher, plus PING and ECHO
You will understand
why dispatch belongs in its own class, the difference between an error reply and a broken connection, and why error strings are an API
- roughly 60 lines, one new class
- Stages 11 to 13
Where we are going
$ redis-cli
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> echo "hey there"
"hey there"
127.0.0.1:6379> foo
(error) ERR unknown command 'foo'
127.0.0.1:6379> ping
PONG
That last exchange is the interesting one. An error, and then the connection still works.
flowchart LR
A[Part 3<br/>PONG to everything] --> B[Stages 11 and 12<br/>CommandDispatcher]
B --> C[Stage 13<br/>ECHO]
C --> D([Part 5<br/>a real database])
This is a short post, which is the point of it.
Stages 11 and 12, the dispatcher
Goal. The command name decides the reply.
The idea
Your Part 3 server parses RESP correctly and replies +PONG to everything. Send it GET, get
PONG. Send it garbage, get PONG. It speaks the protocol and understands nothing.
The obvious fix is a chain of if statements inside the connection loop. Two reasons not to.
Testability. The connection loop needs a socket. A dispatcher takes String[] and returns
byte[], with no I/O, so every command becomes a two-line test, exactly like the parser was.
One place that knows the command table. By the end of this series there are 67 commands. If dispatch lives in the connection loop, that method is enormous, and every new command means editing code that also handles sockets, buffers, and disconnects.
flowchart LR
subgraph handleClient
direction TB
H["moves bytes<br/><i>knows nothing about<br/>what commands mean</i>"]
end
subgraph CommandDispatcher
direction TB
D["knows what commands mean<br/><i>knows nothing about sockets</i>"]
end
handleClient -->|"String[]"| CommandDispatcher
CommandDispatcher -->|"byte[]"| handleClient
The code
src/main/java/com/example/redis/CommandDispatcher.java:
package com.example.redis;
import java.util.Locale;
// Turns a parsed command into a RESP reply. Knows nothing about sockets.
public class CommandDispatcher {
public static byte[] execute(String[] command) {
if (command.length == 0) {
return RespWriter.error("ERR empty command");
}
// command names are case-insensitive, arguments never are
String name = command[0].toUpperCase(Locale.ROOT);
return switch (name) {
case "PING" -> ping(command);
default -> RespWriter.error("ERR unknown command '" + command[0] + "'");
};
}
private static byte[] ping(String[] command) {
if (command.length == 1) {
return RespWriter.simpleString("PONG");
}
if (command.length == 2) {
return RespWriter.bulkString(command[1]);
}
return RespWriter.error("ERR wrong number of arguments for 'ping' command");
}
}
Use toUpperCase(Locale.ROOT), not the no-argument version. Part 3’s capture showed redis-cli
sending ping in lowercase, so matching must be case-insensitive. The no-argument version uses the
default locale, and under a Turkish locale "i" uppercases to "İ", so PING typed lowercase
stops matching on a machine in Istanbul. Rare, real, free to avoid.
Arguments are never case-folded. Only the name. It is tempting to write
Arrays.stream(command).map(String::toUpperCase), and that silently destroys every key and value
in the system.
The switch expression with -> gives exhaustive-looking dispatch with no fallthrough bugs, and
grows one line per command.
The connection loop collapses to two lines:
String[] command;
while ((command = parser.next()) != null) {
outputStream.write(CommandDispatcher.execute(command));
outputStream.flush();
}
No command names, no protocol knowledge. That is the boundary holding.
Two kinds of failure
An unknown command is not a server fault. The client sent something valid at the protocol level that means nothing at the command level, so it gets an error reply and the connection continues.
A malformed byte stream is different. Nothing after it can be trusted.
flowchart TD
A[bytes arrive] --> B{valid RESP?}
B -->|no| C["parser throws<br/>close the connection"]
B -->|yes| D{known command?}
D -->|no| E["-ERR unknown command<br/>connection continues"]
D -->|yes| F{right arity?}
F -->|no| G["-ERR wrong number of arguments<br/>connection continues"]
F -->|yes| H[run it]
So handleClient needs a second catch:
} catch (IOException e) {
System.err.println("Client error: " + e.getMessage());
} catch (IllegalStateException e) {
// the byte stream is broken, nothing after this can be trusted
System.err.println("Protocol error, closing connection: " + e.getMessage());
}
Run it
$ mvn test
[INFO] Tests run: 30, Failures: 0, Errors: 0
One session, error then success:
127.0.0.1:6379> foo
(error) ERR unknown command 'foo'
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> ping hello
"hello"
127.0.0.1:6379> ping a b
(error) ERR wrong number of arguments for 'ping' command
Malformed bytes:
$ printf 'garbage\r\n' | nc localhost 6379
Protocol error, closing connection: expected array, got byte 103
Client disconnected
Notice that
Byte 103 is g. The parser rejected the very first byte, the connection closed cleanly, no stack
trace, and the server kept listening.
Notice the second ping after the error too. Three separate redis-cli invocations would not have
proved that, because each one opens its own connection. The interactive session does.
Try it yourself
- Change the catch to
IllegalArgumentExceptionand send garbage again. Nothing is caught, and the worker thread dies with a stack trace and no clean close. This was my bug. They are sibling classes, not parent and child, so the catch never matched. Unit tests cannot reach it. Onlyprintf 'garbage'found it. - Add
case "TIME" -> RespWriter.integer(System.currentTimeMillis() / 1000);. One line, one new command. That is the return on building a dispatcher. - Uppercase the arguments as well as the name, then run
redis-cli ping hello. You getHELLO. Imagine that applied to every key in a database.
What usually goes wrong
Every command returns PONG. The loop still writes a hardcoded reply instead of calling the
dispatcher.
A typo in a value kills the connection. An exception is escaping handleClient. Catch it where
the failure belongs.
ping works but PING does not, or the reverse. You compared without normalising case.
Error strings are an API
-ERR unknown command 'foo'
-ERR wrong number of arguments for 'get' command
Copy Redis’s wording exactly, including the quotes and the word order. Clients and test suites
match on these strings. It feels like pedantry until something greps for wrong number of arguments and finds nothing.
Stage 13, ECHO
Goal. ECHO hello returns hello.
The idea
This stage is five lines, and that is its whole purpose. Adding a command should now touch one class and nothing else.
before handleClient: read loop, framing, response bytes, command meaning
after CommandDispatcher: one case, one method
If it takes more than a few minutes, the boundary from the previous stage leaked, and this is the moment to notice rather than at command thirty.
The code
case "ECHO" -> echo(command);
private static byte[] echo(String[] command) {
if (command.length != 2) {
return RespWriter.error("ERR wrong number of arguments for 'echo' command");
}
return RespWriter.bulkString(command[1]);
}
Always a bulk string here, never a simple string. ECHO is the first command that hands user data
straight back, which makes the choice concrete. Encode the argument as +a\r\nbc\r\n and a client
reads a as a complete reply, then tries to parse bc\r\n as the start of the next one. Every
later reply on that connection is wrong.
Why PING and ECHO differ
They look similar. Both return their argument as a bulk string. They differ with no argument:
PING → +PONG\r\n a status the server invented
ECHO → error there is nothing to echo
PING answers with something the server made up, so a simple string is right. ECHO only ever
returns what the client sent.
Run it
$ redis-cli -t 2 echo "hey there"
"hey there"
$ redis-cli -t 2 echo ""
""
$ redis-cli -t 2 echo
(error) ERR wrong number of arguments for 'echo' command
Byte level, to prove the length is a byte count:
$ printf '*2\r\n$4\r\nECHO\r\n$5\r\ncaf\xc3\xa9\r\n' | nc localhost 6379 | xxd
00000000: 2435 0d0a 6361 66c3 a90d 0a $5..caf....
Notice that
$5 for four characters, because é is two bytes in UTF-8. Your writer already handled this in
Part 3. This is the first command where a real client can prove it.
Also echo "" returns "", not (nil). Empty and null are different values, and the tests keep
them apart.
Try it yourself
- Echo a value containing CRLF and confirm it survives intact.
- Change
echoto returnsimpleString(command[1])and send that same value. Watchredis-cliget confused. Change it back. - Which files did you touch to add
ECHO? Which did you not? That is the answer to whether the dispatcher was worth building.
What you built
A dispatcher with two commands, and a shape that will carry sixty-five more without changing.
Your server still has no memory. SET is an unknown command.
Checkpoint
- Why is an unknown command an error reply, while malformed RESP closes the connection?
- Why uppercase the command name but never the arguments?
- Why does
PINGreturn a simple string butPING helloa bulk string? - Why can the dispatcher be tested without a socket?
- What would the connection loop look like at thirty commands without one?
Resources
- Redis PING and Redis ECHO
- Java switch expressions
Locale.ROOTJavadoc, the Turkish-i problem
Next
The server stops being a protocol demo and becomes a database, which means confronting shared mutable state and time.