Архитектура как конструктор 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 пайплайн) скажут вам спасибо!

Полезные ссылки