A protocol is a defined method for entities to communicate. One of the most widely recognized protocols is HTTP/HTTPS. Its implementation is governed by a specification documented in the RFC 1945. To develop your own protocols, proficiency in network programming is essential.
I’ve recently delved into the Tokio ecosystem, where the crates provided simplify network programming. My interest was piqued, leading me to explore the excellent “mini-redis” tutorial. After going through the tutorial, I felt compelled to challenge myself by attempting to implement a simple protocol.
I knew I wanted to document my journey implementing a protocol for my blog, so simplicity was key. Thus, I devised a straightforward protocol for a Client/Server
calculator. In this protocol, the client sends an operation followed by the operands separated by :
, with the end of the payload indicated by \r\n
. For instance, an addition operation is encoded as +
followed by {num1}:{num2}\r\n
, where num1
and num2
are u64
integers. This protocol also supports subtraction and multiplication. You can learn more about it here.
In the Client/Server communication model, both the Client and Server can transmit bytes over the network, and either party can parse these bytes to interpret the request. This process of adding structure to the transmitted bytes is known as Framing. Tokio has the excellent bytes crate that makes working with bytes easier.
Let’s look at some of the interesting aspects of the implementation, starting with the framing layer for our protocol.
#[derive(Clone, Debug)]
pub enum Frame {
Addition(u64, u64),
Subtraction(u64, u64),
Multiplication(u64, u64),
OpResult(u64),
}
The initial three enum variants are operations that can be executed, while the last variant is employed to transmit the result. What’s advantageous about the framing abstraction is its versatility, as it can be utilized seamlessly by both the client and the server.
Another interesting aspect is the parse functionality.
pub fn parse(src: &mut Cursor<&[u8]>) -> Result<Frame, Error> {
match get_u8(src)? {
b'+' => {
let first_opereand = get_first_operand(src)?;
let second_operand = get_second_operand(src)?;
Ok(Frame::Addition(first_opereand, second_operand))
}
b'-' => {
let first_opereand = get_first_operand(src)?;
let second_operand = get_second_operand(src)?;
Ok(Frame::Subtraction(first_opereand, second_operand))
}
b'*' => {
let first_opereand = get_first_operand(src)?;
let second_operand = get_second_operand(src)?;
Ok(Frame::Multiplication(first_opereand, second_operand))
}
_ => !unimplemented!(),
}
}
}
We begin by reading the first byte, which indicates the type of operation (Addition, Subtraction, Multiplication), followed by parsing the first and second operands.
Author’s Opinion: We should avoid using
unimplemented!()
macro in any production code and instead employ proper error handling techniques.
If you examine the signature of the parse()
function, another noteworthy observation arises: the argument is a &mut Cursor
. In this context, the Cursor serves as a wrapper around the bytes, with the type system ensuring that only one thread can manipulate the underlying slice
Another piece of interesting functionality is the check()
function.
pub fn check(src: &mut Cursor<&[u8]>) -> Result<(), Error> {
match get_u8(src)? {
b'+' => {
get_line(src)?;
Ok(())
}
b'-' => {
get_line(src)?;
Ok(())
}
b'*' => {
get_line(src)?;
Ok(())
}
default => Err(format!("protocol error, invalid type byte {}", default).into()),
}
}
The check()
method is called in layers above the frame, this method verifies if the buffer contains sufficient data to parse a complete frame, without validating the frame’s integrity.
In the next part we will look at the client/server implementations and how this Frame
abstraction is used. Here is the complete implementation of Frame.
“Rumi Quote”: Would you become a pilgrim on the road of love? The first condition is that you make yourself humble as dust and ashes.