Архитектурные паттерны в Rust: как писать надёжные приложения без боли

Онлайн-тренажер Python для начинающих

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

Начать курс

Почему архитектура в Rust — это не роскошь, а необходимость

Когда вы пишете на Rust, система типов и borrow checker уже дают вам 80% защиты от ошибок. Но безопасность памяти — это лишь фундамент. Если вы строите на нём хаотичную архитектуру, рано или поздно проект превратится в лабиринт из взаимных зависимостей, где изменение одного модуля ломает десять других.

Архитектурные паттерны в Rust — это не дань моде и не пережиток ООП-мира. Это проверенные решения типовых задач: как создать объект, который не нарушит инварианты? Как связать компоненты, чтобы их можно было тестировать по отдельности? Как организовать логику, чтобы её можно было расширять без правки существующего кода?

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

Порождающие паттерны: создаём объекты правильно

Builder — спаситель от конструкторов с 10 параметрами

В Rust нет перегрузки конструкторов. Если у структуры больше 3–4 полей, написать один конструктор для всех случаев — значит обречь пользователя на чтение длинной сигнатуры с Option и Default. Builder решает эту проблему элегантно.

struct ServerConfig {
    host: String,
    port: u16,
    workers: u8,
    enable_tls: bool,
    max_connections: u32,
}

struct ServerConfigBuilder {
    host: Option<String>,
    port: Option<u16>,
    workers: Option<u8>,
    enable_tls: Option<bool>,
    max_connections: Option<u32>,
}

impl ServerConfigBuilder {
    fn new() -> Self {
        Self {
            host: None,
            port: None,
            workers: None,
            enable_tls: None,
            max_connections: None,
        }
    }

    fn host(mut self, host: &str) -> Self {
        self.host = Some(host.to_string());
        self
    }

    fn port(mut self, port: u16) -> Self {
        self.port = Some(port);
        self
    }

    fn workers(mut self, workers: u8) -> Self {
        self.workers = Some(workers);
        self
    }

    fn enable_tls(mut self, enable: bool) -> Self {
        self.enable_tls = Some(enable);
        self
    }

    fn max_connections(mut self, max: u32) -> Self {
        self.max_connections = Some(max);
        self
    }

    fn build(self) -> Result<ServerConfig, String> {
        Ok(ServerConfig {
            host: self.host.unwrap_or_else(|| "127.0.0.1".to_string()),
            port: self.port.unwrap_or(8080),
            workers: self.workers.unwrap_or(4),
            enable_tls: self.enable_tls.unwrap_or(false),
            max_connections: self.max_connections.unwrap_or(100),
        })
    }
}

// Использование
let config = ServerConfigBuilder::new()
    .host("0.0.0.0")
    .port(443)
    .enable_tls(true)
    .build()
    .expect("Invalid config");

Почему это работает в Rust? Паттерн Builder идеально сочетается с системой владения: цепочка методов потребляет и возвращает self, а финальный build либо возвращает готовый объект, либо ошибку валидации. Никаких мутабельных ссылок и гонок данных.

Фабрика — когда тип возврата зависит от параметра

Представьте, что вам нужно парсить разные форматы: JSON, YAML, TOML. Вместо match в каждом месте используем фабричный метод.

trait Parser {
    fn parse(&self, input: &str) -> Result<serde_json::Value, String>;
}

struct JsonParser;
impl Parser for JsonParser {
    fn parse(&self, input: &str) -> Result<serde_json::Value, String> {
        serde_json::from_str(input).map_err(|e| e.to_string())
    }
}

struct YamlParser;
impl Parser for YamlParser {
    fn parse(&self, input: &str) -> Result<serde_json::Value, String> {
        serde_yaml::from_str(input).map_err(|e| e.to_string())
    }
}

fn create_parser(format: &str) -> Box<dyn Parser> {
    match format {
        "json" => Box::new(JsonParser),
        "yaml" => Box::new(YamlParser),
        _ => panic!("Unsupported format: {}", format),
    }
}

Это классическая фабрика. В Rust она часто использует Box<dyn Trait> или enum для статической диспетчеризации. Второй вариант быстрее, но менее гибок.

Структурные паттерны: собираем компоненты в систему

Компоновщик (Composite) — работаем с деревьями без боли

Типичный сценарий: у вас есть файловая система (каталоги и файлы), или HTML-элементы, или компоненты UI. Паттерн Composite позволяет обрабатывать отдельные объекты и их группы единообразно.

trait Component {
    fn render(&self) -> String;
}

struct Button {
    label: String,
}

impl Component for Button {
    fn render(&self) -> String {
        format!("<button>{}</button>", self.label)
    }
}

struct Container {
    children: Vec<Box<dyn Component>>,
}

impl Component for Container {
    fn render(&self) -> String {
        let inner: String = self.children.iter()
            .map(|c| c.render())
            .collect();
        format!("<div>{}</div>", inner)
    }
}

// Использование
let page = Container {
    children: vec![
        Box::new(Button { label: "OK".into() }),
        Box::new(Container {
            children: vec![Box::new(Button { label: "Cancel".into() })],
        }),
    ],
};
println!("{}", page.render());
// Вывод: <div><button>OK</button><div><button>Cancel</button></div></div>

Важный нюанс: в Rust Composite часто реализуют через Vec<Box<dyn Trait>>. Если производительность критична, используйте enum с вариантами для каждого типа компонента — это даёт статическую диспетчеризацию и уменьшает аллокации.

Адаптер — когда чужой API не подходит

Допустим, вы используете библиотеку, которая возвращает Result<String, Error>, а ваша система ожидает Option<String>. Адаптер преобразует один интерфейс в другой.

trait Target {
    fn get_data(&self) -> Option<String>;
}

struct ExternalService;
impl ExternalService {
    fn fetch(&self) -> Result<String, String> {
        Ok("data from external".to_string())
    }
}

struct Adapter {
    service: ExternalService,
}

impl Target for Adapter {
    fn get_data(&self) -> Option<String> {
        self.service.fetch().ok()
    }
}

Поведенческие паттерны: управляем логикой и потоком

Стратегия (Strategy) — переключаем алгоритмы на лету

Блоги

Рекомендаций по книгам