Understanding network programming on Rust
The Rust programming language has become quite popular due to its reliability, security and performance. In the framework of this article, we will not discuss the advantages of this language in detail, since many articles have already been written on this topic. Instead, we will consider the development of the simplest network application that works on the client-server principle.
Those readers who are already familiar with Rust can skip the next section and go straight to the network application. Well, for those who are not at all familiar with this language, it is recommended to install the necessary tools and familiarize yourself with the basic constructions of Rust.
Contents
Rust working environment
As an example, consider installing the necessary tools on Ubuntu. To download and run the installation script, execute the following command:
curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh
As the script runs, you will be prompted to select the type of installation. We select the first item 1) Proceed with installation (default).
In order to make sure that everything was installed successfully, execute the command:
$ rustc --version
And the traditional Hello world. We 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 the framework of the article, we will not consider the syntax and commands of Rust, since this material can also be easily found. So, let’s go straight to the main topic of the article – network programming.
Network tools
Libraries are used to work with Rust’s network components. All network-related functions are located in the std::net namespace; reading and writing to sockets also uses the read and write functions from std::io. The most important structure here is IpAddr, which represents a shared IP address, which can be either version 4 or 6. SocketAddr, which represents a shared socket address (combination of IP and port on the host), TcpListener and TcpStream for TCP data exchange , UdpSocket for UDP and more.
Yes, if we want to start listening on port 8090 on the working machine, we can do it with 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 hard-coded 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 the 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 was not possible to connect to a specific port, the program terminates with the message Could not bind.
The listener.incoming() method we use returns an iterator of threads that have connected to the server. We go through them and check if there is an error in any of them. In this case, we can print an error message and move on to the next connected client. Note that terminating the entire application with an error in this case is not appropriate, as the server may function normally if 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 client in an infinite loop. But running an infinite loop in the main thread will block it and no other clients will be able to connect. Such behavior is clearly undesirable for us. So we have to create a workflow to handle each client connection. The logic for reading each stream and writing back is encapsulated in a function called handle_client.
fn handle_client(mut stream: TcpStream) -> Result {
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 lock 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 store the data temporarily. We also make sure that the buffer is zeroed out. Then we start an infinite loop in which we read all the data from the stream. The read method on the stream returns the length of the data it read. It can return zero in two cases: if it 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 false. Thus, we break the loop (and the function) when the read method returns null. And here we return Ok(). We then write the same data back to the stream using the slice syntax. Note that we used eprintln! to output errors. This macro converts this line to standard error.
Let’s see the source code of our program in its entirety.
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::io::{Read, Write, Error};
fn handle_client(mut stream: TcpStream) -> Result {
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, execute the command
$ rustc имя_файла_сервера.rs
Work on errors
One can notice the apparent lack of error handling when reading from a stream and writing to a stream. But it’s actually not a problem. We used the operator ?
to handle errors in these calls. This statement converts the result to Ok if everything went well; otherwise, it returns an error calling the function prematurely. Given this setting, the return type of the function must be either void to handle success cases or io::Error to handle error cases. Note that in such cases it would be nice to implement user 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 to the client
Of course, you can communicate with servers using nc, however, it is still better to write a full-fledged client. In the example below, we read standard input in an infinite loop and then send that data to the server.
In case we can’t read the input or send to the server, the program exits with the 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 = 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"));
}
}
We also compile using rustc:
$ rustc имя_файла_клиента.rs
Conclusion
In this article, we covered the main points related to the work of Rust, as well as the development of simple network applications. In the next article, we will look at more complex examples of code related to network development.
You can learn more about programming languages by taking online courses led by expert practitioners.