Почему архитектура в 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()
}
}