Getting to grips with network programming in Rust

The Rust programming language has become quite popular due to its reliability, security and performance. In this article, we will not discuss in detail the advantages of this language, since many articles have already been written on this topic. Instead, we will look at developing a simple network application that works on the client-server principle.

Readers already familiar with Rust can skip the next section and jump straight to the networking application. Well, those who are completely unfamiliar with this language are encouraged to install the necessary tools and become familiar with the basic constructs of Rust.

Rust workbench

As an example, let's look at installing the necessary tools on Ubuntu. To download and run the installation script, run the following command:

curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh

While the script is running, you will be asked to select the installation type. Select the first item 1) Proceed with installation (default).

To make sure that everything was installed successfully, run the command:

$ rustc --version

Well, the traditional Hello world. Create a file with the extension rs.

$ nano hello.rs

And with the following content:

fn main() {
    println!("Hello world!");
}

Next, compile using the rustc command and run:

$ rustc test.rs

$ ./test

In this article, we will not consider the syntax and commands of Rust, since this material can also be easily found. So let's move straight to the main topic of the article – network programming.

Network tools

Rust uses libraries to work with networking components. All network related functions are located in the std::net namespace; read and write functions from std::io are also used to read and write to sockets. The most important structure here is the IpAddr, which is a common IP address, which can be either version 4 or 6. SocketAddr, which is a common socket address (a combination of IP and port on the host), a TcpListener, and a TcpStream for TCP communications , UdpSocket for UDP and much more.

So, if we want to start listening to port 8090 on a working machine, we can do this using the following command:

    let listener = TcpListener::bind("0.0.0.0:8090").expect("Could not bind");

In the main() function we create a new TcpListener, which in Rust is a TCP socket that listens for incoming connections from clients. In our example, we hardcoded the local address and port; a local address value of 0.0.0.0 tells the kernel to bind this socket to all available interfaces on this host. As a result, any client that can connect to a network connected to this host will be able to communicate with this host on port 8090

In a real application, the port number should be a configurable parameter taken from the CLI, an environment variable, or a configuration file.

If it fails to bind to a specific port, the application exits with the message Could not bind.

The listener.incoming() method we use returns an iterator for the threads that have connected to the server. We go through them and check if any of them encountered an error. In this case, we can print the error message and move on to the next connected client. Note that it is not practical to crash the entire application in this case, since the server may still function normally if for some reason some clients encounter errors.

    for stream in listener.incoming() {
        match stream {
            Err(e) => { eprintln!("failed: {}", e) }
            Ok(stream) => {
                thread::spawn(move || {
                    handle_client(stream).unwrap_or_else(|error| eprintln!("{:?}", error));
                });
            }
        }
    }

Now we have to read data from each of the clients in an endless loop. But running an infinite loop on the main thread will block it and no other clients will be able to connect. This behavior is definitely not desirable for us. So we have to create a worker thread to handle each client connection. The logic for reading from each thread and writing back is encapsulated in a function called handle_client.

fn handle_client(mut stream: TcpStream) -> Result<(), Error> {
    println!("Incoming connection from: {}", stream.peer_addr()?);
    let mut buf = [0; 512];
    loop {
        let bytes_read = stream.read(&mut buf)?;
        if bytes_read == 0 { return Ok(()) }
        stream.write(&buf[..bytes_read])?;
    }
}

Each thread receives a closure that calls this function. This closure must be a move because it must read a variable (stream) from the enclosing scope. In the function, we output the remote endpoint's address and port, and then define a buffer to temporarily store the data. We also make sure that the buffer is reset to zero. We then start an infinite loop where we read all the data from the stream. The read method on a stream returns the length of the data it read. It can return zero in two cases: if it has reached the end of the stream or if the length of the given buffer was zero. We know for sure that the second case is incorrect. This way we break the loop (and the function) when the read method returns null. In this case we return Ok(). We then write the same data back to the stream using the slice syntax. Please note that we used eprintln! to display errors. This macro converts the given string to standard error.

Let's look at the source code of our application in full.

use std::net::{TcpListener, TcpStream};
use std::thread;
use std::io::{Read, Write, Error};

fn handle_client(mut stream: TcpStream) -> Result<(), Error> {
    println!("Incoming connection from: {}", stream.peer_addr()?);
    let mut buf = [0; 512];
    loop {
        let bytes_read = stream.read(&mut buf)?;
        if bytes_read == 0 { return Ok(()) }
        stream.write(&buf[..bytes_read])?;
    }
}

fn main() {
    let listener = TcpListener::bind("0.0.0.0:8888").expect("Could not bind");
    for stream in listener.incoming() {
        match stream {
            Err(e) => { eprintln!("failed: {}", e) }
            Ok(stream) => {
                thread::spawn(move || {
                    handle_client(stream).unwrap_or_else(|error| eprintln!("{:?}", error));
                });
            }
        }
    }
}

To compile, run the command

$ rustc имя_файла_сервера.rs

Work on mistakes

You may notice an obvious lack of error handling when reading from and writing to a stream. But in reality this is not the case. We used the operator ? to handle errors in these calls. This statement will convert the result to Ok if everything went well; otherwise, it returns an error to the calling function prematurely. Given this setting, the return type of the function must be either empty to handle success cases or type io::Error to handle error cases. Note that in such cases it would be a good idea to implement custom errors and return them instead of built-in errors. Also note that the ? operator cannot currently be used in the main function because the main function does not return a result.

In order to make sure that our server is working, you can contact it and send any set of bytes.

We are writing a client

Of course, you can communicate with servers using nc, however, it is better to write a full-fledged client. In the example below, we read standard input in an infinite loop and then send this data to the server.

In case we are unable to read the input or pass it to the server, the application terminates with appropriate messages.

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

fn main() {
    let mut stream = TcpStream::connect("127.0.0.1:8888").expect("Could not connect to server");
    loop {
        let mut input = String::new();
        let mut buffer: Vec<u8> = Vec::new();
        io::stdin().read_line(&mut input).expect("Failed to read from stdin");
        stream.write(input.as_bytes()).expect("Failed to write to server");

        let mut reader = BufReader::new(&stream);

        reader.read_until(b'\n', &mut buffer).expect("Could not read into buffer");
        print!("{}", str::from_utf8(&buffer).expect("Could not write buffer as string"));
    }
}

Let's also compile using rustc:

$ rustc имя_файла_клиента.rs

Conclusion

In this article, we looked at the main points related to working in Rust, as well as developing simple network applications. In the next article, we'll look at more advanced code examples related to network development.

You can learn more about programming languages on online courses under the guidance of expert practitioners.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *