9 Useful Crates in Rust

In this article, we'll look at 9 useful crates in Rust.

First, let's talk about how to install crates

Naturally, you need Rust and Cargo. Rust supports Windows, Linux, macOS, FreeBSD and NetBSD. Installing Rust on Unix systems occurs through the terminal using the command curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shand on Windows – by downloading and running the file rustup-init.exe With official website.

To create a project we use the command cargo new <название_проекта>which will generate the basic project structure, including the file Cargo.toml for configuration and dependencies.

To add a crate as a dependency, you need to edit the file Cargo.tomlby adding a line to the section [dependencies]For example: serde = "1.0". This can be done manually or using the command cargo add serdeif cargo-edit is installed.

cargo install <имя_крейта> allows you to install binary crates, that is, programs or tools that can be launched from the command line. You can specify the version using the flag --versionselect a specific binary file with --bin or install examples with --example.

To update the project dependencies to the latest versions, use cargo update. To remove a binary crate installed via cargo installusecargo uninstall <имя_крейта>.

To compile the project we use cargo buildand to start – cargo run. These commands automatically download and install the necessary dependencies, compile the project and, if cargo runstart execution of the program.

Serializing data with serde

Serde – This framework for serializing and deserializing data structures Rust. Unlike many languages ​​that rely on runtime reflection to serialize data, Serde is based on a powerful system Rust traits. Data structures that know how to serialize and deserialize implement traits Serialize And Deserialize Serde or use derive attributes to automatically generate implementations at compile time.

A crate is used for serialization and deserialization in JSON serde_json. For example, for the structure Person:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Person {
    name: String,
    age: u8,
    is_active: bool,
}

To serialize an object Person to a JSON string and back:

let person = Person {
    name: "Alex".to_owned(),
    age: 28,
    is_active: true,
};

let serialized = serde_json::to_string(&person).unwrap();
println!("serialized = {}", serialized);

let deserialized: Person = serde_json::from_str(&serialized).unwrap();
println!("deserialized = {:?}", deserialized);

Serde supports many formats, such as JSON, YAML, TOML, etc. For example, a crate is used to work with YAML serde_yamland for TOML – crate toml.

For more complex cases, such as conditionally including fields or changing the structure of a serializable object, Serde provides various attributes. For example, you can use the attribute #[serde(flatten)]to “flatten” the structure during serialization, avoiding unnecessary levels of nesting:

#[derive(Serialize, Deserialize)]
struct Request {
    calculation: Calculation,
    #[serde(flatten)]
    shape: Shape,
}

More detailed usage examples and documentation can be found on the official website Serde and in the documentation crate.

Asynchronous programming with tokio

Tokyo – This asynchronous runtime for Rasta, designed to create networked applications and support asynchronous I/O operations that are scalable and reliable.

To create a new project on Tokio, you need to install dependencies inCargo.toml. For most projects it is enough to use the flag features = ["full"] to enable all available features:

tokio = { version = "1", features = ["full"] }

The simplest example of using Tokio is an asynchronous function mainannotated with #[tokio::main]which allows you to use async/await syntax:

#[tokio::main]
async fn main() {
    println!("Hello, Tokio!");
}

And this is what an asynchronous delay task using Tokio might look like:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("Start delay");
    sleep(Duration::from_secs(5)).await;
    println!("Delay complete");
}

In this example, the 5 second delay does not block program execution, allowing other tasks to be performed.

There are also tools for working with asynchronous I/O, including support for TCP, UDP, timers, etc. Here is an example of an asynchronous TCP echo server on Tokio:

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    loop {
        let (mut socket, _) = listener.accept().await?;
        tokio::spawn(async move {
            let mut buf = [0; 1024];
            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(n) if n == 0 => return,
                    Ok(n) => n,
                    Err(e) => {
                        eprintln!("failed to read from socket; err = {:?}", e);
                        return;
                    },
                };
                if let Err(e) = socket.write_all(&buf[0..n]).await {
                    eprintln!("failed to write to socket; err = {:?}", e);
                    return;
                }
            }
        });
    }
}

Web development with warp

Warp is high-performance web framework for Rust, allowing you to build asynchronous web applications. He uses filter system to process requests, making the creation of web servers convenient and flexible.

A simple Warp server can be created using just a few lines of code. For example, creating a handler that returns a greeting might look like this:

#[tokio::main]
async fn main() {
    let route = warp::path::end().map(|| warp::reply::html("Hello, Habr!"));
    warp::serve(route).run(([127, 0, 0, 1], 3030)).await;
}

Macros are used to work with JSON in Warp Serialize And Deserialize from the crate serde. An example of creating an endpoint that accepts JSON looks like this:

#[derive(Deserialize, Serialize, Clone)]
struct Item {
    name: String,
    quantity: i32,
}

fn json_body() -> impl Filter<Extract = (Item,), Error = warp::Rejection> + Clone {
    warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}

#[tokio::main]
async fn main() {
    let add_items = warp::post()
        .and(warp::path("item"))
        .and(json_body())
        .map(|item: Item| {
            warp::reply::json(&item)
        });
    warp::serve(add_items).run(([127, 0, 0, 1], 3030)).await;
}

Creating a full-fledged CRUD API requires organizing work with the database and processing various HTTP methods. In Warp, this can be done using filters and asynchronous functions. For example, to process GET And POST queries you can use the following code:

let get_route = warp::get()
    .and(warp::path("items"))
    .and(with_db(pool.clone()))
    .and_then(handlers::get_items);

let post_route = warp::post()
    .and(warp::path("items"))
    .and(json_body())
    .and(with_db(pool.clone()))
    .and_then(handlers::add_item);

let routes = get_route.or(post_route);
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;

For each endpoint, you can define your own handler that will perform the necessary logic, for example, accessing the database and returning the result to the client.

More details with warp can be found here.

Working with a database with diesel

Diesel is an ORM and query builder that supports PostgreSQL, MySQL and SQLite. It is designed to simplify database interaction, minimize boilerplate code, and prevent runtime errors without sacrificing performance. Diesel integrates fully with the Rust type system!

To start working with Diesel you need to add it to Cargo.toml file, indicating the required functionality, depending on which database you are working with.

[dependencies]
diesel = { version = "1.0", features = ["postgres", "sqlite", "mysql"] }

After adding a dependency, identifying the database schema and generating the corresponding code is done through the use of Diesel macros and the Diesel CLI command for migrations.

Migrations allow you to control database schema versions in a similar way to code version control systems. In Diesel CLI this is done like this:

diesel migration generate create_students

This will create a directory structure for migrations including files up.sql And down.sql for each migration, in which SQL code is added for schema changes and rollback of changes, respectively.

Diesel offers two basic abstractions for working with data: structures for representing table rows and structures for inserting new records. An example of a structure representing a table:

#[derive(Queryable)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
}

And to insert new records it is used Insertable:

#[derive(Insertable)]
#[table_name="posts"]
pub struct NewPost<'a> {
    pub title: &'a str,
    pub body: &'a str,
}

To perform operations with the database, for example, to add a record, you can use the following code:

pub fn create_post<'a>(conn: &PgConnection, title: &'a str, body: &'a str) -> Post {
    let new_post = NewPost {
        title: title,
        body: body,
    };

    diesel::insert_into(posts::table)
        .values(&new_post)
        .get_result(conn)
        .expect("Error saving new post")
}

This code inserts a new record into the table and returns it as a structure Post.

There are also reading mechanisms. For example, to get all records that match certain criteria:

let results = posts.filter(published.eq(true))
    .limit(5)
    .load::<Post>(&connection)
    .expect("Error loading posts");

Diesel supports complex queries and operations such as joins, filtering, sorting and pagination.

More details from Diesel check it out here.

Multithreading and parallelism with rayon

Rayon is a very powerful thing that allows you to achieve data parallelism in Rust. It allows you to easily perform operations in parallel.

To get started with Rayon, add a dependency to your Cargo.toml:

[dependencies]
rayon = "1.5.1"

And import the traits provided by Rayon using preload:

use rayon::prelude::*;

Parallel iterators are the simplest and often most used way to use Rayon. They allow you to automatically convert serial computations into parallel ones, while providing protection against data races. Parallel iterators support many techniques similar to regular iterators in Rust, including map, for_each, filter, fold and many others.

An example of using a parallel iterator to change array elements:

use rayon::prelude::*;

fn main() {
    let mut arr = [0, 7, 9, 11];
    arr.par_iter_mut().for_each(|p| *p -= 1);
    println!("{:?}", arr);
}

The code will simultaneously decrease each element of the array by one.

To fine-tune parallel computing, Rayon suggests using methods join And scope, which allow you to divide work into parallel tasks. Method join is used to perform two closures simultaneously, and scope creates a scope in which an arbitrary number of parallel tasks can be created.

Example of using the method join to perform two tasks in parallel:

rayon::join(|| do_something(), || do_something_else());

The method is good for CPU-bound tasks, but should not be used for blocking operations such as I/O.

More details about the crate – Here.

GUI development with iced

Iced is a cross-platform library for creating graphical user interfaces in Rust that focuses on simplicity and type safety. Inspired by Elm, Iced offers an intuitive model for building reactive applications with a clear separation of application state, user interactions, display logic, and state updating.

Key features of Iced:

  • A simple and convenient all-inclusive API.

  • Reactive type-based programming model.

  • Cross-platform support: Windows, macOS, Linux and web.

  • Adaptive layout.

  • Built-in widgets (text fields, scrolling and much more).

  • Support for custom widgets.

  • Debug panel with performance metrics.

  • Built-in support for asynchronous actions via futures.

  • Modular ecosystem with the ability to integrate into existing systems.

Let's look at a code example for creating a simple GUI application with a counter. We implement the basic concepts of Iced, such as state modeling, processing messages from the user, logic for displaying and updating state:

use iced::{button, executor, Application, Button, Column, Command, Element, Settings, Text};

pub fn main() -> iced::Result {
    Counter::run(Settings::default())
}

struct Counter {
    value: i32,
    increment_button: button::State,
    decrement_button: button::State,
}

#[derive(Debug, Clone, Copy)]
enum Message {
    IncrementPressed,
    DecrementPressed,
}

impl Application for Counter {
    type Executor = executor::Default;
    type Message = Message;
    type Flags = ();

    fn new(_flags: ()) -> (Counter, Command<Self::Message>) {
        (
            Counter {
                value: 0,
                increment_button: button::State::new(),
                decrement_button: button::State::new(),
            },
            Command::none(),
        )
    }

    fn title(&self) -> String {
        String::from("A simple counter")
    }

    fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
        match message {
            Message::IncrementPressed => {
                self.value += 1;
            }
            Message::DecrementPressed => {
                self.value -= 1;
            }
        }

        Command::none()
    }

    fn view(&self) -> Element<Self::Message> {
        Column::new()
            .push(
                Button::new(&mut self.increment_button, Text::new("Increment"))
                    .on_press(Message::IncrementPressed),
            )
            .push(Text::new(self.value.to_string()).size(50))
            .push(
                Button::new(&mut self.decrement_button, Text::new("Decrement"))
                    .on_press(Message::DecrementPressed),
            )
            .into()
    }
}

There will be a simple application with a counter that can be increased and decreased using two buttons. The counter status is stored in the field value structures Counter. Each button has its own instance button::State, which is used to track the state of the button. User interactions with buttons generate messages Messagewhich are processed in the method update, changing the state of the counter. Widgets for display are composed in the method viewwhich returns a layout with buttons and text displaying the current counter value.

Parsing and analyzing code with syn and quote

Procedural macros in Rust allow you to manipulate syntax trees of code at compile time. Two crates, syn And quoteallow you to create such macros. syn used to parse Rust code into data structures that can be explored and manipulated, and quote allows you to generate Rust code from these structures.

syn provides capabilities for parsing Rust tokens into a syntax tree of code. For example, to create an attribute macro that tracks changes to variables, you can use syn for parsing functions and injecting additional code that will perform the necessary actions when the value of a variable changes.

An example of a macro that parses an attribute with variables and adds output to the console when they change:

#[proc_macro_attribute]
pub fn trace_vars(metadata: TokenStream, input: TokenStream) -> TokenStream {
    let input_fn = parse_macro_input!(input as ItemFn);
    // парсинг аргументов макроса
    let args = parse_macro_input!(metadata as Args);
    // генерация нового кода
    TokenStream::from(quote!{fn dummy(){}})
}

quote used to generate Rust code. It allows you to embed code snippets into macros using variable interpolation.

A simple macro example using quotewhich generates a function:

#[proc_macro]
pub fn minimal(input: TokenStream) -> TokenStream {
    let Combinations { name, n } = parse_macro_input!(input as Combinations);
    (quote!{
        fn #name() -> i32 {
            #n
        }
    }).into()
}

Let's look at an example in which we will create a procedural macro in Rust using syn And quote to analyze the structure and generate a function that calculates the sum of the values ​​of its numeric fields.

Let's imagine that there is a structure Pointcontaining two fields x And yand we want to generate a function sumwhich will return their amount.

First let's define the structure Point and write a macro derive_summationwhich will generate the function sum:

// в файле lib.rs крейта с процедурными макросами

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(SumFields)]
pub fn derive_summation(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    
    // получение имени структуры
    let name = &input.ident;
    
    // генерация кода функции sum
    let gen = quote! {
        impl #name {
            pub fn sum(&self) -> i32 {
                self.x + self.y
            }
        }
    };
    
    gen.into()
}

Now in the main project or another crate, use the macro SumFields for automatic method generation sum for structure Point.

use my_macro_crate::SumFields; // Замените my_macro_crate на имя вашего крейта с макросами

#[derive(SumFields)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let point = Point { x: 1, y: 2 };
    println!("Sum of fields: {}", point.sum());
}

By the way, you can read more about macros in our article about macros in Rust.


Finally, do not forget that the success of your project depends not only on the tools you choose, but also on your ability to use them.

And my colleagues from OTUS will talk about the main features of developing an application in Rust as part of free webinar.

Similar Posts

Leave a Reply

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