Building a LinesCodec

for Rust's TcpStream

Jun - 2020 (~5 minutes read time)

In the previous post we learned how to read & write bytes with TcpStream, but the calling code had to be aware of that and do the serialization/deserialization. In this demo, we'll continue using BufRead and BufReader to build:

LinesCodec

Our goals for this LinesCodec implementation are to abstract away:

A Type to the rescue!

Starting off with the I/O management abstraction, let's define a new type to own and interact with TcpStream. From the previous post, we know we'll want to use the BufReader and its read_line() method for reading data.

For writing data, we have three options:

I'm choosing to use LineWriter because it seems like a better approach to what we're wanting to do, based of of its documentation:

Wraps a writer and buffers output to it, flushing whenever a newline (0x0a, \n) is detected.

The BufWriter struct wraps a writer and buffers its output. But it only does this batched write when it goes out of scope, or when the internal buffer is full. Sometimes, you'd prefer to write each line as it's completed, rather than the entire buffer at once. Enter LineWriter. It does exactly that.

Great! Let's define our type and define LinesCodec::new() to build new instances:

lib.rs

use std::io::{self, BufRead, Write};
use std::net::TcpStream;

pub struct LinesCodec {
    // Our buffered reader & writers
    reader: io::BufReader<TcpStream>,
    writer: io::LineWriter<TcpStream>,
}

impl LinesCodec {
    /// Encapsulate a TcpStream with buffered reader/writer functionality
    pub fn new(stream: TcpStream) -> io::Result<Self> {
        // Both BufReader and LineWriter need to own a stream
        // We can clone the stream to simulate splitting Tx & Rx with `try_clone()`
        let writer = io::LineWriter::new(stream.try_clone()?);
        let reader = io::BufReader::new(stream);
        Ok(Self { reader, writer })
    }
}

Reading and Writing

With the LinesCodec struct and its buffered reader/writers, we can continue our implementation to borrow the reading and writing code from the previous post:

lib.rs

...
impl LinesCodec {
    /// Write the given message (appending a newline) to the TcpStream
    pub fn send_message(&mut self, message: &str) -> io::Result<()> {
        self.writer.write(&message.as_bytes())?;
        // This will also signal a `writer.flush()` for us; thanks LineWriter!
        self.writer.write(&['\n' as u8])?;
        Ok(())
    }

    /// Read a received message from the TcpStream
    pub fn read_message(&mut self) -> io::Result<String> {
        let mut line = String::new();
        // Use `BufRead::read_line()` to read a line from the TcpStream
        self.reader.read_line(&mut line)?;
        line.pop(); // Remove the trailing "\n"
        Ok(line)
    }
}

Using LinesCodec in the client

With the TcpStream out of the way, let's refactor our client code from the previous post and see how easy this can be:

client.rs

use std::io;
use std::new::TcpStream;

use crate::LinesCodec;


fn main() -> io::Result<()> {
    // Establish a TCP connection with the farend
    let mut stream = TcpStream::connect("127.0.0.1:4000")?;

    // Codec is our interface for reading/writing messages.
    // No need to handle reading/writing directly
    let mut codec = LinesCodec::new(stream)?;

    // Serializing & Sending is now just one line
    codec.send_message("Hello")?;

    // And same with receiving the response!
    println!("{}", codec.read_message()?);
    Ok(())

}

Using LinesCodec in the server

And now we have some very similar work to update our server to use LinesCodec. We'll define a handle_connection function that:

server.rs

use std::io;
use std::net::TcpStream;

use crate:LinesCodec;

/// Given a TcpStream:
/// - Deserialize the message
/// - Serialize and write the echo message to the stream
fn handle_connection(stream: TcpStream) -> io::Result<()> {
    let mut codec = LinesCodec::new(stream)?;

    // Read & reverse the received message
    let message: String = codec
        .read_message()
        // Reverse message
        .map(|m| m.chars().rev().collect())?;

    // And use the codec to return it
    codec.send_message(&message)?;
    Ok(())
}

Using the codec makes the business logic code much clearer and there are fewer opportunities to mis-manage newlines or forget flush(). Check out the demo client and server code for a full runnable example.

In the next post we dive even deeper to build a custom protocol with our own serialization/deserialization!