Архитектура как конструктор 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 автора
