Привет, Хабр!

Rust, как любимый многими разработчиками знаменит своей скоростью и безопасностью. Но его истинная сила заключается в экосистеме крейтов — библиотек и инструментов, которые могут превратить сложные и трудоёмкие задачи в удивительно простые и приятные процессы.

Крейты в Rust – это пакеты, которые можно использовать для расширения функциональности проектов.

В этой статье рассмотрим 9 полезных крейтов в Rust.

Для начала о том, как устанавливать крейты

Нужен естественно Rust и Cargo. Rust поддерживает Windows, Linux, macOS, FreeBSD и NetBSD. Установка Rust на Unix-системах происходит через терминал с помощью команды curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh, а на Windows - скачиванием и запуском файла rustup-init.exe с официального сайта.

Для создания проекта юзаем команду cargo new <название_проекта>, которая сгенерирует базовую структуру проекта, включая файл Cargo.toml для конфигурации и зависимостей.

Чтобы добавить крейт как зависимость, нужно отредактировать файл Cargo.toml, добавив строку в секцию [dependencies], например: serde = "1.0". Это можно сделать вручную или с помощью команды cargo add serde, если установлен cargo-edit.

cargo install <имя_крейта> позволяет установить бинарные крейты, то есть программы или инструменты, которые можно запускать из командной строки. Можно уточнить версию с помощью флага --version, выбрать конкретный бинарный файл с --bin или установить примеры с --example.

Чтобы обновить зависимости проекта до последних версий, юзаем cargo update. Для удаления бинарного крейта, установленного через cargo install, юзаемcargo uninstall <имя_крейта>.

Для компиляции проекта используем cargo build, а для запуска - cargo run. Эти команды автоматически скачивают и устанавливают необходимые зависимости, компилируют проект и, в случае cargo run, запускают выполнение программы.

Сериализация данных с serde

Serde — это фреймворк для сериализации и десериализации структур данных Rust. В отличие от многих ЯПов, которые полагаются на рефлексию во время выполнения для сериализации данных, Serde основан на мощной системе трейтов Rust. Структуры данных, которые знают, как сериализоваться и десериализоваться, реализуют трейты Serialize и Deserialize Serde или используют атрибуты derive для автоматической генерации реализаций на этапе компиляции.

Для сериализации и десериализации в JSON используется крейт serde_json. Например, для структуры Person:

use serde::{Serialize, Deserialize};

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

Для сериализации объекта Person в строку JSON и обратно:

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 поддерживает множество форматов, таких как JSON, YAML, TOML и др. Например, для работы с YAML используется крейт serde_yaml, а для TOML — крейт toml.

Для более сложных случаев, таких как условное включение полей или изменение структуры сериализуемого объекта, Serde предоставляет различные атрибуты. Например, можно использовать атрибут #[serde(flatten)], чтобы "сплющить" структуру при сериализации, избегая лишнего уровня вложенности:

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

Более подробные примеры использования и документацию можно найти на официальном сайте Serde и в документации крейта.

Асинхронное программирование с tokio

Tokio — это асинхронный runtime для Раста, предназначенный для создания сетевых приложений и поддержки асинхронных операций ввода-вывода, масштабируемых и надёжных.

Для создания нового проекта на Tokio, нужно установить зависимости вCargo.toml. Для большинства проектов достаточно использовать флаг features = ["full"] для включения всех доступных функций:

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

Простейший пример использования Tokio — это асинхронная функция main, аннотированная с #[tokio::main], что позволяет использовать async/await синтаксис:

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

А вот так может выглядеть асинхронная задача задержки с использованием Tokio:

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

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

В этом примере задержка на 5 секунд не блокирует исполнение программы, позволяя выполнять другие задачи.

Также имеются инструменты для работы с асинхронным вводом-выводом, включая поддержку TCP, UDP, таймеров и др. Вот пример асинхронного TCP эхо-сервера на 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;
                }
            }
        });
    }
}

Веб-разработка с warp

Warp является высокопроизводительным веб-фреймворком для Rust, позволяющим строить асинхронные веб-приложения. Он использует систему фильтров для обработки запросов, делая создание веб-серверов удобным и гибким.

Простейший сервер на Warp может быть создан с использованием только нескольких строк кода. Например, создание обработчика, возвращающего приветствие, может выглядеть так:

#[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;
}

Для работы с JSON в Warp используются макросы Serialize и Deserialize из крейта serde. Пример создания эндпоинта, принимающего JSON, выглядит так:

#[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;
}

Создание полноценного CRUD API требует организации работы с БД и обработки различных HTTP методов. В Warp это можно организовать с помощью фильтров и асинхронных функций. Например, для обработки GET и POST запросов можно использовать следующий код:

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;

Для каждого эндпоинта можно определить свой обработчик, который будет выполнять необходимую логику, например, обращение к БД и возвращение результата клиенту.

Подробнее с warp можно ознакомиться здесь.

Работа с БД с diesel

Diesel представляет собой ORM и конструктор запросов, который поддерживает работу с PostgreSQL, MySQL и SQLite. Он предназначен для упрощения взаимодействия с БД, минимизации шаблонного кода и предотвращения ошибок времени выполнения, не жертвуя при этом производительностью. Diesel полностью интегрируется с системой типов Rust!

Для начала работы с Diesel необходимо добавить его в Cargo.toml файл, указав нужные функциональности, в зависимости от того, с какой БД идет работа.

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

После добавления зависимости идентификация схемы БД и генерация соответствующего кода осуществляется через использование макросов Diesel и команды Diesel CLI для миграций.

Миграции позволяют контролировать версии схемы БД аналогично системам контроля версий кода. В Diesel CLI это делается так:

diesel migration generate create_students

Это создаст структуру каталогов для миграций, включая файлы up.sql и down.sql для каждой миграции, в которые добавляется SQL код для изменений схемы и отката изменений соответственно.

Diesel предлагает две основные абстракции для работы с данными: структуры для представления строк таблицы и структуры для вставки новых записей. Пример структуры, представляющей таблицу:

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

А для вставки новых записей используется Insertable:

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

Для выполнения операций с БД например, для добавления записи, можно использовать следующий код:

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")
}

Этот код вставляет новую запись в таблицу и возвращает её как структуру Post.

Также есть механизмы чтения. Например, для получения всех записей, которые соответствуют определённым критериям:

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

Diesel поддерживает сложные запросы и операции, такие как соединения, фильтрация, сортировка и пагинаци.

Подробнее с Diesel можно ознакомиться здесь.

Многопоточность и параллелизм с rayon

Rayon очень мощная вещь, которая позволяет достигать параллелизма данных в Rust. Она позволяет легко выполнять операции в параллельном режиме.

Для начала работы с Rayon, добавьте зависимость в ваш Cargo.toml:

[dependencies]
rayon = "1.5.1"

И импортируйте трейты, предоставляемые Rayon, используя предварительную загрузку:

use rayon::prelude::*;

Параллельные итераторы являются самым простым и часто наиболее используемым способом использования Rayon. Они позволяют автоматически преобразовать последовательные вычисления в параллельные, обеспечивая при этом защиту от гонок данных. Параллельные итераторы поддерживают множество методов, аналогичных обычным итераторам в Rust, включая map, for_each, filter, fold и многие другие.

Пример использования параллельного итератора для изменения элементов массива:

use rayon::prelude::*;

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

Код параллельно уменьшит каждый элемент массива на единицу.

Для более тонкой настройки параллельных вычислений Rayon предлагает использовать методы join и scope, которые позволяют делить работу на параллельные задачи. Метод join используется для одновременного выполнения двух замыканий, а scope создает область видимости, в которой можно создать произвольное количество параллельных задач.

Пример использования метода join для параллельного выполнения двух задач:

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

Метод хорош для задач, привязанных к CPU, но его не следует использовать для блокирующих операций, таких как ввод-вывод.

Подробнее про крейт - здесь.

GUI разработка с iced

Iced является кросс-платформенной библиотекой для создания графических пользовательских интерфейсов на Rust, которая ориентирована на простоту и типобезопасность. Вдохновлённая Elm, Iced предлагает интуитивно понятную модель для создания реактивных приложений с чётким разделением состояния приложения, пользовательских взаимодействий, логики отображения и обновления состояния.

Основные возможности Iced:

  • Простой и удобный API "всё включено".

  • Реактивная модель программирования на основе типов.

  • Поддержка кросс-платформенности: Windows, macOS, Linux и веб.

  • Адаптивная компоновка.

  • Встроенные виджеты (текстовые поля, прокрутка и многое другое).

  • Поддержка пользовательских виджетов.

  • Отладочная панель с метриками производительности.

  • Встроенная поддержка асинхронных действий через futures.

  • Модульная экосистема с возможностью интеграции в существующие системы【.

Рассмотрим пример кода для создания простого GUI приложения с счётчиком. Реализуем основные концепции Iced, такие как моделирование состояния, обработка сообщений от пользователя, логика отображения и обновления состояния:

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()
    }
}

Здесь будет простое приложение с счётчиком, который можно увеличивать и уменьшать с помощью двух кнопок. Состояние счётчика хранится в поле value структуры Counter. Для каждой кнопки создается свой экземпляр button::State, который используется для отслеживания состояния кнопки. Взаимодействия юзера с кнопками генерируют сообщения Message, которые обрабатываются в методе update, изменяя состояние счётчика. Виджеты для отображения составляются в методе view, который возвращает layout с кнопками и текстом, отображающим текущее значение счётчика.

Парсинг и анализ кода с syn и quote

Процедурные макросы в Rust позволяют манипулировать синтаксическими деревьями кода на этапе компиляции. Два крейта, syn и quoteпозволяют создавать такие макросы. syn используется для парсинга кода Rust в структуры данных, которые можно исследовать и манипулировать, а quote позволяет генерировать код Rust из этих структур.

syn предоставляет возможности для парсинга токенов Rust в синтаксическое дерево кода. Например, для создания атрибутного макроса, который отслеживает изменения переменных, можно использовать syn для парсинга функций и инъекции дополнительного кода, который будет выполнять необходимые действия при изменении значения переменной.

Пример макроса, который парсит атрибут с переменными и добавляет вывод в консоль при их изменении:

#[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 используется для генерации кода Rust. Он позволяет встраивать фрагменты кода в макросы, используя интерполяцию переменных.

Простой пример макроса с использованием quote, который генерирует функцию:

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

Рассмотрим пример, в котором создадим процедурный макрос на Rust, использующий syn и quote для анализа структуры и генерации функции, которая считает сумму значений её числовых полей.

Представим, что есть структура Point, содержащая два поля x и y, и мы хотим сгенерировать функцию sum, которая будет возвращать их сумму.

Сначала определим структуру Point и напишем макрос derive_summation, который будет генерировать функцию 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()
}

Теперь в основном проекте или другом крейте, юзаем макрос SumFields для автоматической генерации метода sum для структуры 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());
}

Кстати, подробнее про макросы можно по читать в нашей статье про макросы в Rust.


Напоследок, не забывайте, что успех вашего проекта зависит не только от выбранных инструментов, но и от вашего умения их использовать.

А про главные особенности разработки приложения на Rust мои коллеги из OTUS расскажут в рамках бесплатного вебинара.

Комментарии (14)


  1. igumnov
    10.04.2024 18:39
    +4

    А почему diesel, а не sea_orm?

    Ну и warp, а не axum?

    Мне кажется это более в тренде сейчас.


    1. qalisander
      10.04.2024 18:39

      Как минимум странно упомянуть сихронный diesel и асинхронный warp рядом. Их точно имеет смысл использовать вместе?


    1. Dgolubetd
      10.04.2024 18:39
      +2

      Существует diesel_async. Sqlx тоже подошёл бы. А вот sea_orm - ну это для тех кто еще не прошел через ORM стадию.

      Axum, действительно, предпочтительнее.


    1. Hesser
      10.04.2024 18:39

      согласен c axum, я бы тогда сюда reqwest(можно ещё вспомнить всякие вспомогательные типа chrono)


  1. gmtd
    10.04.2024 18:39

    Для чего вводить новый термин (крейт) для тех же сущностей (библиотека/пакет/зависимость)?


    1. skovoroad
      10.04.2024 18:39
      +1

      Потому что термины library, package, module заняты в расте. К примеру, крейт вообще не обязан быть библиотекой.


    1. InoyChel
      10.04.2024 18:39
      +2

      https://doc.rust-lang.org/book/ch07-01-packages-and-crates.html

      Это скорее набор файлов, который может быть скомпилирован в единую сущность. Причем это сущность может быть как библиотекой, так и исполняемым файлом. Пакетов в рамках языка называется набор крейтов. Получается они захотели дать одно название и для библиотек и для исполняемых файлов, да вообще для любых файлов которые могут быть распростронены в рамках экосистемы. Например так они реализовали воозможность установки "приложений" и расширений в рамках cargo: cargo install и cargo-expand


    1. domix32
      10.04.2024 18:39
      +2

      Потому что фактически - оно единица трансляции компилятора, а не библиотека/пакет/зависимость.


  1. eee
    10.04.2024 18:39
    +8

    И как минимум стоило упомянуть thiserror, anyhow, chrono, tracing


    1. sdramare
      10.04.2024 18:39

      Я бы еще derive_more упомянул, сильно уменьшает boilerplate код. А для базы по мне sqlx лучше, генерация sql это неоднозначное решение.


    1. hrls
      10.04.2024 18:39

      structopt в кучу


      1. eee
        10.04.2024 18:39

        clap уже умеет из коробки


  1. domix32
    10.04.2024 18:39
    +2

    и анализ кода

    syn и quote не анализируют код. Они занимаются обработкой синтаксиса


  1. tttinnny
    10.04.2024 18:39

    Раз уж зашла тема про Токио, стоило упомянуть еще мега удобный асинхронный рантайм под no_std, как embassy.