Написание производственного кода на Rust требует не только знания синтаксиса и системы типов, но и глубокого понимания архитектуры крейтов (Crates) и стандартов инженерии надежности.
Эта статья открывает практическую серию по разработке инструментов и библиотек в Rust, созданную на основе материалов книги John Arundel “The Secrets of Rust: Tools”.
Мы системно разберем, как правильно разделять ответственность между библиотекой и бинарным приложением, почему публичный API обязан быть Data-Oriented, и как техника Bebugging предотвращает ошибки в юнит-тестах.
1. Архитектура Crate: разделение src/lib.rs и src/main.rs#
Одно из главных правил архитекторов Rust — разделение ответственности между вычислениями и отображением:
src/lib.rs (Библиотечный компонент): содержит всю бизнес-логику, агрегаторы данных, структуры и вычисления.
src/main.rs (Бинарная точка входа): содержит минимальный код, который отвечает исключительно за чтение параметров из CLI или конфигураций и вывод готового результата пользователю.
Библиотечные функции категорически не должны напрямую выводить данные в терминал через println!. Взамен они обязаны возвращать структуры данных или строки (Data-Oriented API).
Это гарантирует:
Переиспользуемость: одну и ту же библиотеку можно подключить к CLI-утилите, Web/gRPC микросервису или GUI-интерфейсу.
Тестируемость: состояние системы можно мгновенно проверить в тестах без перехвата стандартного потока вывода stdout.
Ниже представлен пошаговый пример проектирования библиотеки телеметрии и точки входа:
Архитектура Crate и Data-Oriented API
Шаг 1/2
1// Шаг 1: Проектирование чистой библиотеки (src/lib.rs) без побочных эффектов
2 3#[derive(Debug, PartialEq)] 4pubstructTelemetryReport{ 5pubservice_name: String, 6pubcpu_usage_pct: f64, 7pubactive_connections: usize, 8} 910pubstructTelemetryAggregator{11service_name: String,12}1314implTelemetryAggregator{15pubfnnew(service_name: implInto<String>)-> Self{16Self{17service_name: service_name.into(),18}19}2021// Главный принцип библиотеки: функция ВОЗВРАЩАЕТ структуру данных, а не выводит её в stdout через println!
22pubfngenerate_report(&self,cpu_raw: u64,connections: usize)-> TelemetryReport{23letcpu_usage_pct=(cpu_rawasf64)/100.0;24TelemetryReport{25service_name: self.service_name.clone(),26cpu_usage_pct,27active_connections: connections,28}29}30}3132fnmain(){33letaggregator=TelemetryAggregator::new("auth-service");34letreport=aggregator.generate_report(4550,120);35println!("Библиотечный модуль сгенерировал данные: {report:?}");36}37
1. Data-Oriented API в библиотеках
Публичный интерфейс библиотеки должен быть Data-Oriented: функции возвращают структуры, не вызывая println! внутри.
Это обеспечивает идеальную тестируемость, возможность повторного использования в CLI, Web/gRPC и отсутствие побочных эффектов.
Результат выполнения:
1// Шаг 2: Точка входа приложения (src/main.rs) отвечает за отображение
2 3// ?hidden:start
4#[derive(Debug, PartialEq)] 5pubstructTelemetryReport{ 6pubservice_name: String, 7pubcpu_usage_pct: f64, 8pubactive_connections: usize, 9}1011pubstructTelemetryAggregator{12service_name: String,13}1415implTelemetryAggregator{16pubfnnew(service_name: implInto<String>)-> Self{17Self{18service_name: service_name.into(),19}20}2122pubfngenerate_report(&self,cpu_raw: u64,connections: usize)-> TelemetryReport{23TelemetryReport{24service_name: self.service_name.clone(),25cpu_usage_pct: (cpu_rawasf64)/100.0,26active_connections: connections,27}28}29}30// ?hidden:end
3132fnrender_report_to_terminal(report: &TelemetryReport){33println!("=== ТЕЛЕМЕТРИЯ СЕРВИСА: {} ===",report.service_name);34println!("Загрузка CPU: {:.2}%",report.cpu_usage_pct);35println!("Активных соединений: {}",report.active_connections);36}3738fnmain(){39// В src/main.rs логика вызова вынесена отдельно:
40letaggregator=TelemetryAggregator::new("payment-gateway");41letreport=aggregator.generate_report(8230,450);4243// Только main() принимает решение, КАК отобразить данные пользователю:
44render_report_to_terminal(&report);45}46
2. Разделение ответственности (src/lib.rs и src/main.rs)
Библиотечный файл src/lib.rs отвечает за вычисления и типы данных.
Точка входа src/main.rs держит минимум кода и управляет вводом/выводом в консоль или форматированием.
Результат выполнения:
2. Юнит-тестирование, подробные утверждения и Bebugging
#
Тесты в Rust размещаются в специальном подмодуле #[cfg(test)] mod tests. Благодаря атрибуту #[cfg(test)] весь код тестов и их вспомогательные структуры полностью исключаются из финального релизного бинарника (cargo build --release), не создавая никакого оверхеда.
Bebugging — это инженерная практика намеренного внесения дефекта в реализацию функции. Если после внесения ошибки ваш тест всё равно остается «зеленым», значит тест написан неверно или имеет избыточную толерантность.
Именование функций-тестов в виде понятных полных предложений на английском языке (например health_index_calculates_correctly_under_normal_load) позволяет использовать сторонние утилиты вроде cargo-testdox для автоматической сборки спецификации требований прямо из кода.
Изучите пошаговый пример написания информативных юнит-тестов и проверки методом Bebugging:
Юнит-тесты, Bebugging и самодокументируемый код
Шаг 1/2
1// Шаг 1: Информативные утверждения в тесте с контекстными сообщениями
2 3pubfncalculate_health_index(load: f64,errors: usize)-> f64{ 4ifload>100.0{ 5return0.0; 6} 7leterror_penalty=(errorsasf64)*2.5; 8(100.0-load-error_penalty).max(0.0) 9}1011fnmain(){12letindex=calculate_health_index(20.0,2);13println!("Вычисленный индекс здоровья системы: {index}");14}1516#[cfg(test)]17modtests{18usesuper::*;1920#[test]21fnhealth_index_calculates_correctly_under_normal_load(){22letload=20.0;23leterrors=2;24letwant=75.0;// 100 - 20 - 5.0
25letgot=calculate_health_index(load,errors);2627// Информативное сообщение с подробными данными о несовпадении:
28assert_eq!(29got,want,30"Индекс здоровья рассчитан неверно для load={load}, errors={errors}: want '{want}', got '{got}'"31);32}33}34
1. Информативные юнит-тесты
Тесты размещаются в подмодуле #[cfg(test)] mod tests, который не компилируется в релизную сборку.
Использование расширенных контекстных сообщений в assert_eq! позволяет мгновенно понять причину сбоя без дебаггера.
Результат выполнения:
1// Шаг 2: Техника Bebugging и самодокументируемые тесты (cargo-testdox)
2 3// В рамках техники Bebugging разработчик намеренно вносит ошибку в формулу
4// для проверки того, что тесты действительно падают с понятным отчетом:
5pubfncalculate_discount_price(original_price: f64,discount_pct: f64)-> f64{ 6// Намеренный баг для Bebugging: умножение вместо вычитания скидки
7// original_price * (1.0 - discount_pct / 100.0)
8original_price// ОШИБКА: вернет исходную цену без скидки!
9}1011fnmain(){12letprice=calculate_discount_price(200.0,10.0);13println!("Итоговая цена: {price}");14}1516#[cfg(test)]17modbebugging_tests{18usesuper::*;1920// Именование функций в стиле самодокументируемых предложений для cargo-testdox:
21#[test]22fndiscount_price_applies_percentage_deduction_properly(){23letprice=200.0;24letdiscount=10.0;25letwant=180.0;26letgot=calculate_discount_price(price,discount);2728assert_eq!(29got,want,30"Bebugging check: test successfully caught the error! want '{want}', got '{got}'"31);32}33}34
2. Bebugging и cargo-testdox
Bebugging: намеренная поломка кода для подтверждения того, что тесты чувствительны к багам.
cargo-testdox: именование тестов полными предложениями генерирует спецификацию требований к системе.