Архитектура как конструктор LEGO: строим тестируемый Rust-сервис с абстракцией блокчейна
Представьте, что вы строите сервис для выдачи цифровых дипломов, который записывает хеши документов в блокчейн Solana. Все работает отлично, пока вы не пытаетесь написать первый юнит-тест. Внезапно оказывается, что для тестирования простой бизнес-логики нужно поднимать локальный валидатор Solana, иметь тестовые токены и молиться, чтобы сеть не упала посреди CI/CD пайплайна. А что если завтра заказчик попросит добавить поддержку Ethereum? Переписывать половину кодовой базы?
В этой статье я покажу, как мы решили эту проблему в реальном проекте, используя мощь системы типов Rust и паттерн "Стратегия". Вы узнаете, как правильно готовить async-trait, почему dyn Trait иногда лучше дженериков, и как написать тесты, которые выполняются за миллисекунды вместо минут.
Если вы предпочитаете читать код, а не статьи, я подготовил готовый шаблон архитектуры на GitHub. Там реализована вся описываемая здесь логика, плюс Docker, PostgreSQL и интеграционные тесты. testable-rust-architecture-template
Анти-паттерн: как мы страдали ДО рефакторинга
Давайте честно - первая версия нашего кода выглядела примерно так:
// ПЛОХО: Прямая зависимость от Solana везде use solana_client::rpc_client::RpcClient; use solana_sdk::{signature::Keypair, transaction::Transaction}; pub struct AppState { pub solana_client: RpcClient, // <- Конкретная реализация! pub keypair: Keypair, pub db_client: Postgrest, } pub async fn issue_diploma( State(state): State<Arc<AppState>>, // ... ) -> Result<Json<IssueResponse>, AppError> { // Бизнес-логика намертво связана с Solana let instruction = /* создаем Solana-специфичную инструкцию */; let transaction = Transaction::new(/* ... */); // Прямой вызов Solana RPC let signature = state.solana_client .send_and_confirm_transaction(&transaction)?; // А теперь попробуйте это протестировать... } #[cfg(test)] mod tests { #[tokio::test] async fn test_issue_diploma() { // Для теста нужен реальный Solana! // Варианты: // 1. Поднять solana-test-validator (медленно) // 2. Использовать devnet (нестабильно) // 3. Захардкодить моки... везде (кошмар поддержки) // В итоге: тест либо медленный, либо хрупкий, либо оба } }
Что здесь не так?
Vendor lock-in: Хотите перейти на Ethereum? Удачи с переписыванием всего кода
Медленные тесты: Каждый тест ждет реальные транзакции (30-60 секунд)
Дорогие тесты: На devnet нужны тестовые SOL, есть rate limits
Нестабильный CI/CD: Упала сеть? Все тесты красные
Спагетти-код: Бизнес-логика перемешана с деталями блокчейна
Проблема: когда блокчейн становится якорем
При разработке нашего сервиса верификации дипломов мы столкнулись с классической проблемой tight coupling (жесткой связи). Наш код напрямую зависел от Solana RPC клиента, что создавало целый букет проблем:
Тесты-мазохисты: Каждый тест требовал реального подключения к блокчейну
Медленная разработка: Ждать подтверждения транзакции при каждом запуске теста - удовольствие ниже среднего
Хрупкий CI/CD: Тесты падали из-за проблем с сетью, а не из-за багов в коде
Vendor lock-in: Переход на другой блокчейн означал бы переписывание большей части сервиса
Архитектура решения: визуальный гайд
Прежде чем погружаться в код, давайте посмотрим на общую картину нашей архитектуры:

Поток данных в разных окружениях:

Решение: трейт ChainClient как контракт с блокчейном
Вместо того чтобы везде таскать конкретную реализацию Solana-клиента, мы создали абстракцию - трейт, который описывает, что должен уметь делать любой блокчейн-клиент, не уточняя как:
// ./internal/blockchain/mod.rs use async_trait::async_trait; #[async_trait] pub trait ChainClient: Send + Sync { /// Writes a hash to the blockchain. async fn write_hash(&self, hash: &str, meta: &Diploma) -> Result<BlockchainRecord, AppError>; /// Looks up a hash on the blockchain. async fn find_by_hash(&self, hash: &str) -> Result<Option<BlockchainRecord>, AppError>; /// Performs a health check on the connection. async fn health_check(&self) -> Result<(), AppError>; }
Почему async_trait?
Заметили макрос #[async_trait]? Это не прихоть, а необходимость. На момент написания статьи, async функции в трейтах все еще не стабилизированы в Rust (хотя работа идет полным ходом). Библиотека async-trait решает эту проблему элегантным способом: под капотом она преобразует:
async fn write_hash(&self, ...) -> Result<...>
В примерно такой код:
fn write_hash(&self, ...) -> Box<dyn Future<Output = Result<...>> + Send>
Да, это добавляет небольшой оверхед из-за динамической диспетчеризации и аллокации на куче, но для операций ввода-вывода (а работа с блокчейном - это всегда I/O) это абсолютно незаметно.
Production-реализация: SolanaChainClient
Теперь давайте посмотрим на конкретную реализацию для Solana. Она инкапсулирует всю сложность работы с RPC и транзакциями:
// ./internal/blockchain/solana.rs pub struct SolanaChainClient { rpc_client: RpcClient, issuer_keypair: Keypair, } impl SolanaChainClient { pub fn new(rpc_url: String, issuer_keypair: Keypair) -> Result<Self, AppError> { let rpc_client = RpcClient::new(rpc_url); Ok(Self { rpc_client, issuer_keypair, }) } } #[async_trait] impl ChainClient for SolanaChainClient { async fn write_hash( &self, hash: &str, _meta: &Diploma, ) -> Result<BlockchainRecord, AppError> { // Используем Memo Program для записи хеша let memo_program_id = Pubkey::from_str("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr")?; let instruction = Instruction::new_with_bytes( memo_program_id, hash.as_bytes(), vec![], ); // Получаем последний blockhash и отправляем транзакцию let latest_blockhash = self.rpc_client.get_latest_blockhash()?; let message = Message::new(&[instruction], Some(&self.issuer_keypair.pubkey())); let mut transaction = Transaction::new_unsigned(message); transaction.sign(&[&self.issuer_keypair], latest_blockhash); let signature = self.rpc_client .send_and_confirm_transaction(&transaction)?; Ok(BlockchainRecord { tx_id: signature.to_string(), block_time: None, raw_meta: Some(hash.to_string()), }) } // Остальные методы... }
Вся магия Solana (работа с инструкциями, подписями, blockhash) спрятана внутри реализации. Для остального кода это просто "что-то, что умеет записывать хеши в блокчейн".
MockChainClient: тестовый рай разработчика
А вот где начинается настоящее волшебство - наша мок-реализация для тестов:
// ./internal/blockchain/mock.rs pub struct MockChainClient { storage: Mutex<HashMap<String, BlockchainRecord>>, } impl MockChainClient { pub fn new() -> Self { Self { storage: Mutex::new(HashMap::new()), } } } #[async_trait] impl ChainClient for MockChainClient { async fn write_hash( &self, hash: &str, _meta: &Diploma, ) -> Result<BlockchainRecord, AppError> { let mut storage = self.storage.lock().unwrap(); let record = BlockchainRecord { tx_id: format!("mock_tx_{}", hash), block_time: Some(Utc::now()), raw_meta: Some(hash.to_string()), }; storage.insert(hash.to_string(), record.clone()); Ok(record) } async fn find_by_hash(&self, hash: &str) -> Result<Option<BlockchainRecord>, AppError> { let storage = self.storage.lock().unwrap(); Ok(storage.get(hash).cloned()) } async fn health_check(&self) -> Result<(), AppError> { Ok(()) // Всегда здоров как бык! } }
Вместо реального блокчейна используем простой HashMap в памяти. Транзакции "подтверждаются" мгновенно, никаких сетевых задержек, никаких комиссий. Теперь можно написать тест для хендлера issue_diploma, который будет выполняться за миллисекунды:
#[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_issue_diploma_creates_blockchain_record() { // Arrange: создаем мок-клиент вместо реального let mock_client = Arc::new(MockChainClient::new()); let app_state = Arc::new(AppState { chain_client: mock_client.clone(), // ... другие поля }); // Act: вызываем бизнес-логику let result = issue_diploma(State(app_state), test_multipart).await; // Assert: проверяем, что хеш записан assert!(result.is_ok()); let response = result.unwrap(); // Можем даже проверить, что хеш действительно сохранен let record = mock_client.find_by_hash(&response.hash).await.unwrap(); assert!(record.is_some()); assert_eq!(record.unwrap().tx_id, format!("mock_tx_{}", response.hash)); } }
Dependency Injection через AppState
Теперь самое интересное - как все это собирается вместе в реальном приложении. Мы используем Axum (отличный веб-фреймворк для Rust), и вся магия происходит в AppState:
// ./internal/api/router.rs pub struct AppState { pub chain_client: Arc<dyn ChainClient>, // <- Вот оно! pub issuer_keypair: Keypair, pub db_client: Postgrest, } pub async fn create_router() -> Result<Router, AppError> { // Загружаем конфигурацию let config = Config::from_env()?; // Создаем конкретную реализацию для production let solana_client = SolanaChainClient::new( config.solana_rpc_url, client_keypair )?; // Упаковываем в AppState let app_state = Arc::new(AppState { chain_client: Arc::new(solana_client), // <- Инъекция зависимости! issuer_keypair, db_client, }); // Создаем роутер с внедренным состоянием Ok(Router::new() .route("/issue", post(issue_diploma)) .route("/verify/:hash", get(verify_diploma)) .with_state(app_state)) }
Что такое dyn ChainClient?
Заметили Arc<dyn ChainClient>? Это trait object - способ хранить любой тип, реализующий трейт ChainClient, без знания конкретного типа на этапе компиляции.
dynговорит компилятору: "это будет какой-то тип, реализующийChainClient, но какой именно - узнаем в рантайме"Arcнужен для безопасного разделения между потоками (Axum обрабатывает запросы параллельно)
dyn Trait vs Generics: битва титанов
Вы могли бы спросить: "А почему не использовать дженерики?" Отличный вопрос! Давайте сравним:
Вариант с дженериками (статическая диспетчеризация):
pub struct AppState<C: ChainClient> { pub chain_client: Arc<C>, // ... }
Плюсы:
Максимальная производительность (компилятор может заинлайнить вызовы)
Нет оверхеда на vtable lookup
Минусы:
Мономорфизация "раздувает" бинарник (для каждого типа
Cгенерируется отдельная копия кода)Нельзя поменять реализацию в рантайме
Все хендлеры тоже должны стать дженерик-функциями
Наш выбор: trait objects (динамическая диспетчеризация):
pub struct AppState { pub chain_client: Arc<dyn ChainClient>, // ... }
Плюсы:
Гибкость: можем выбирать реализацию в рантайме (например, based on environment variable)
Меньший размер бинарника
Проще интегрировать с веб-фреймворками
Минусы:
Небольшой оверхед на вызовы (vtable lookup)
Нужен
Arcдля работы с trait objects
Для нашего случая выбор очевиден: накладные расходы на vtable lookup ничтожны по сравнению с временем сетевых вызовов к блокчейну. А гибкость, которую мы получаем, бесценна.
Заключение: архитектура, которая эволюционирует вместе с вами
Что мы получили в итоге:
Молниеносные тесты: Юнит-тесты выполняются за миллисекунды, не требуя реального блокчейна
Легкая замена блокчейна: Хотите добавить Ethereum? Просто напишите EthereumChainClient implementing ChainClient
Четкое разделение ответственности: Бизнес-логика не знает ничего о деталях работы с Solana
Упрощенная отладка: Можно использовать MockChainClient даже в development окружении для быстрой итерации
Этот подход - не серебряная пуля, но для сервисов, взаимодействующих с внешними системами (будь то блокчейн, платежные системы или сторонние API), он дает огромную гибкость и тестируемость.
Помните: хорошая архитектура - это не та, которая предугадывает все будущие изменения, а та, которая позволяет легко их внести, когда они понадобятся. Трейты в Rust дают нам именно такую возможность.
P.S. Если вы все еще пишете тесты, которые требуют реального подключения к внешним сервисам - попробуйте этот подход. Ваши коллеги (и ваш CI/CD пайплайн) скажут вам спасибо!
Полезные ссылки
Источник - berektassuly.com
Репозиторий с примером - testable-rust-architecture-template
LinkedIn автора
