返回 SC 笔记
SC Day 30

Rust 文件 IO + toml 配置 + tracing 日志 + CLI v3 完整版

### 一、std::fs — 文件系统操作

2026-04-30
第二阶段:框架实战 (Day 25-30)
Rust文件IOtomltracing日志CLI多链

日期: 2026-04-30 方向: Rust 阶段: 第二阶段:框架实战 (Day 25-30) 标签: #Rust #文件IO #toml #tracing #日志 #CLI #多链


今日目标

  1. 掌握 Rust 文件 IO(std::fs 读写文件)
  2. 使用 toml + serde 管理配置文件
  3. 学会 tracing 日志框架(替代 println! 调试)
  4. 完成 CLI v3 完整版:配置文件 + 日志 + 多链支持
  5. 总结 Production-ready Rust 代码的最佳实践

核心概念

一、std::fs — 文件系统操作

Rust 标准库提供同步的文件 IO API,对于配置文件读写足够使用。

1.1 基础读写

use std::fs;
use std::io::{self, Write, BufRead};
use std::path::Path;

fn file_io_basics() -> io::Result<()> {
    // ====== 读取文件 ======

    // 一次性读取整个文件为字符串
    let content = fs::read_to_string("config.toml")?;
    println!("File content:\n{}", content);

    // 一次性读取为字节
    let bytes = fs::read("binary_file.dat")?;
    println!("File size: {} bytes", bytes.len());

    // 按行读取 (大文件友好)
    let file = fs::File::open("large_file.txt")?;
    let reader = io::BufReader::new(file);
    for line in reader.lines() {
        let line = line?;
        if line.contains("error") {
            println!("Found: {}", line);
        }
    }

    // ====== 写入文件 ======

    // 一次性写入 (覆盖)
    fs::write("output.txt", "Hello, Blockchain!\n")?;

    // 追加写入
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open("log.txt")?;
    writeln!(file, "[{}] New entry", "2026-04-30")?;

    // ====== 文件操作 ======

    // 检查文件是否存在
    if Path::new("config.toml").exists() {
        println!("Config file found");
    }

    // 创建目录 (包含父目录)
    fs::create_dir_all("data/cache/rpc")?;

    // 复制文件
    fs::copy("config.toml", "config.toml.bak")?;

    // 删除文件
    fs::remove_file("temp.txt")?;

    // 列出目录内容
    for entry in fs::read_dir(".")? {
        let entry = entry?;
        let file_type = if entry.file_type()?.is_dir() { "DIR " } else { "FILE" };
        println!("{} {}", file_type, entry.path().display());
    }

    Ok(())
}

1.2 路径处理

use std::path::{Path, PathBuf};

fn path_examples() {
    // 构建路径
    let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
    let app_config = config_dir.join("chain-cli").join("config.toml");
    println!("Config path: {}", app_config.display());

    // 路径操作
    let path = Path::new("/home/user/projects/chain-cli/config.toml");
    println!("File name: {:?}", path.file_name());  // "config.toml"
    println!("Extension: {:?}", path.extension());    // "toml"
    println!("Parent: {:?}", path.parent());          // "/home/user/projects/chain-cli"
    println!("Exists: {}", path.exists());

    // 展开 ~ (home directory)
    // 需要 dirs 或 shellexpand crate
    let home = dirs::home_dir().unwrap();
    let expanded = home.join(".chain-cli").join("config.toml");
    println!("Expanded: {}", expanded.display());
}

二、TOML 配置文件

TOML (Tom's Obvious, Minimal Language) 是 Rust 生态首选的配置格式(Cargo.toml 就是 TOML)。

2.1 TOML 语法快速入门

# 这是注释

# 基础类型
title = "Chain CLI"
version = "0.3.0"
debug = false
timeout = 10

# 数组
supported_chains = ["ethereum", "arbitrum", "optimism", "base", "polygon"]

# 表 (类似 JSON object)
[default]
chain = "ethereum"
format = "table"

# 嵌套表
[api_keys]
etherscan = "YOUR_KEY_HERE"
alchemy = "YOUR_KEY_HERE"

# 表数组 (数组中的每个元素是一个表)
[[chains]]
name = "ethereum"
chain_id = 1
rpc_url = "https://eth.llamarpc.com"
explorer = "https://etherscan.io"
symbol = "ETH"

[[chains]]
name = "arbitrum"
chain_id = 42161
rpc_url = "https://arb1.arbitrum.io/rpc"
explorer = "https://arbiscan.io"
symbol = "ETH"

[[chains]]
name = "polygon"
chain_id = 137
rpc_url = "https://polygon-rpc.com"
explorer = "https://polygonscan.com"
symbol = "MATIC"

2.2 使用 serde 解析 TOML

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct AppConfig {
    title: String,
    version: String,
    debug: bool,
    timeout: u64,
    supported_chains: Vec<String>,
    default: DefaultConfig,
    api_keys: ApiKeys,
    chains: Vec<ChainConfig>,
}

#[derive(Debug, Serialize, Deserialize)]
struct DefaultConfig {
    chain: String,
    format: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct ApiKeys {
    etherscan: String,
    alchemy: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChainConfig {
    name: String,
    chain_id: u64,
    rpc_url: String,
    explorer: String,
    symbol: String,
}

fn load_config() -> Result<AppConfig, Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string("config.toml")?;
    let config: AppConfig = toml::from_str(&content)?;
    Ok(config)
}

fn save_config(config: &AppConfig) -> Result<(), Box<dyn std::error::Error>> {
    let content = toml::to_string_pretty(config)?;
    std::fs::write("config.toml", content)?;
    Ok(())
}

// 带默认值的配置
impl Default for AppConfig {
    fn default() -> Self {
        Self {
            title: "Chain CLI".to_string(),
            version: "0.3.0".to_string(),
            debug: false,
            timeout: 10,
            supported_chains: vec![
                "ethereum".to_string(),
                "arbitrum".to_string(),
                "polygon".to_string(),
            ],
            default: DefaultConfig {
                chain: "ethereum".to_string(),
                format: "table".to_string(),
            },
            api_keys: ApiKeys {
                etherscan: String::new(),
                alchemy: String::new(),
            },
            chains: vec![
                ChainConfig {
                    name: "ethereum".to_string(),
                    chain_id: 1,
                    rpc_url: "https://eth.llamarpc.com".to_string(),
                    explorer: "https://etherscan.io".to_string(),
                    symbol: "ETH".to_string(),
                },
            ],
        }
    }
}

三、tracing — 结构化日志

tracing 是 Rust 生态中最推荐的日志框架,比 log + env_logger 更强大。它提供结构化日志、span(跨度)和异步支持。

3.1 为什么用 tracing 而不是 println!

维度println!tracing
级别ERROR/WARN/INFO/DEBUG/TRACE
过滤不可过滤按级别/模块过滤
结构化文本结构化键值对
性能不可关闭编译期可零成本消除
异步不感知异步 span 跟踪
生产级不适合可输出到文件/JSON/OpenTelemetry

3.2 基础使用

use tracing::{info, warn, error, debug, trace, instrument};
use tracing_subscriber::{self, fmt, EnvFilter};

fn setup_logging(verbose: bool) {
    let filter = if verbose {
        "chain_cli=debug,reqwest=info"
    } else {
        "chain_cli=info,reqwest=warn"
    };

    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| EnvFilter::new(filter))
        )
        .with_target(true)          // 显示模块路径
        .with_thread_ids(false)     // 不显示线程 ID
        .with_file(true)            // 显示文件名
        .with_line_number(true)     // 显示行号
        .init();
}

// 基础日志
fn logging_examples() {
    // 不同级别
    error!("Critical failure: database connection lost");
    warn!("API rate limit approaching: {}/100 requests", 90);
    info!("Server started on port {}", 8080);
    debug!("Processing request: {:?}", "GET /balance");
    trace!("Raw response bytes: {:?}", vec![0u8; 10]);

    // 结构化字段
    info!(
        chain = "ethereum",
        address = "0x1234...5678",
        balance_eth = 42.5,
        "Balance query completed"
    );

    // 输出类似:
    // 2026-04-30T10:30:00Z INFO chain_cli::client: Balance query completed
    //   chain="ethereum" address="0x1234...5678" balance_eth=42.5
}

// #[instrument] 自动创建 span
#[instrument(skip(client), fields(chain = %chain_name))]
async fn fetch_balance(
    client: &reqwest::Client,
    rpc_url: &str,
    address: &str,
    chain_name: &str,
) -> Result<f64, Box<dyn std::error::Error>> {
    debug!("Sending RPC request to {}", rpc_url);

    // ... RPC 调用逻辑 ...

    info!(balance = 42.5, latency_ms = 150, "Balance fetched");
    Ok(42.5)
}

3.3 多层输出

use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};

fn setup_advanced_logging() {
    // 控制台输出层
    let console_layer = fmt::layer()
        .with_target(true)
        .with_ansi(true) // 终端颜色
        .compact();       // 紧凑格式

    // 文件输出层 (JSON 格式, 方便解析)
    let file = std::fs::File::create("chain-cli.log").unwrap();
    let file_layer = fmt::layer()
        .json()           // JSON 格式
        .with_writer(file)
        .with_ansi(false); // 文件不需要颜色

    tracing_subscriber::registry()
        .with(EnvFilter::new("chain_cli=debug"))
        .with(console_layer)
        .with(file_layer)
        .init();
}

代码实战

CLI v3 完整版

Cargo.toml

[package]
name = "chain-cli"
version = "0.3.0"
edition = "2021"
description = "Multi-chain blockchain query CLI"

[dependencies]
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
dirs = "5"

config.toml

# Chain CLI v3 Configuration

[app]
version = "0.3.0"
timeout_secs = 10
default_chain = "ethereum"
output_format = "table"

[api_keys]
etherscan = ""

[logging]
level = "info"
file = "chain-cli.log"
json_output = false

[[chains]]
name = "Ethereum"
id = "ethereum"
chain_id = 1
rpc_url = "https://eth.llamarpc.com"
explorer_url = "https://etherscan.io"
native_symbol = "ETH"
enabled = true

[[chains]]
name = "Arbitrum One"
id = "arbitrum"
chain_id = 42161
rpc_url = "https://arb1.arbitrum.io/rpc"
explorer_url = "https://arbiscan.io"
native_symbol = "ETH"
enabled = true

[[chains]]
name = "Optimism"
id = "optimism"
chain_id = 10
rpc_url = "https://mainnet.optimism.io"
explorer_url = "https://optimistic.etherscan.io"
native_symbol = "ETH"
enabled = true

[[chains]]
name = "Base"
id = "base"
chain_id = 8453
rpc_url = "https://mainnet.base.org"
explorer_url = "https://basescan.org"
native_symbol = "ETH"
enabled = true

[[chains]]
name = "Polygon"
id = "polygon"
chain_id = 137
rpc_url = "https://polygon-rpc.com"
explorer_url = "https://polygonscan.com"
native_symbol = "MATIC"
enabled = true

src/config.rs (v3)

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{info, warn};

#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    pub app: AppSection,
    pub api_keys: ApiKeysSection,
    pub logging: LoggingSection,
    pub chains: Vec<ChainEntry>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AppSection {
    pub version: String,
    pub timeout_secs: u64,
    pub default_chain: String,
    pub output_format: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ApiKeysSection {
    pub etherscan: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct LoggingSection {
    pub level: String,
    pub file: String,
    pub json_output: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainEntry {
    pub name: String,
    pub id: String,
    pub chain_id: u64,
    pub rpc_url: String,
    pub explorer_url: String,
    pub native_symbol: String,
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_true() -> bool {
    true
}

impl Config {
    /// 从文件加载配置
    pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        if !path.exists() {
            warn!("Config file not found at {}, creating default", path.display());
            let config = Config::default();
            config.save(path)?;
            return Ok(config);
        }

        let content = std::fs::read_to_string(path)?;
        let config: Config = toml::from_str(&content)?;
        info!("Config loaded from {}", path.display());
        Ok(config)
    }

    /// 保存配置到文件
    pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let content = toml::to_string_pretty(self)?;
        std::fs::write(path, content)?;
        info!("Config saved to {}", path.display());
        Ok(())
    }

    /// 获取默认配置文件路径
    pub fn default_path() -> PathBuf {
        // 优先从当前目录读取
        let local = PathBuf::from("config.toml");
        if local.exists() {
            return local;
        }

        // 否则用用户配置目录
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("chain-cli")
            .join("config.toml")
    }

    /// 获取启用的链配置
    pub fn enabled_chains(&self) -> Vec<&ChainEntry> {
        self.chains.iter().filter(|c| c.enabled).collect()
    }

    /// 根据 id 查找链配置
    pub fn find_chain(&self, id: &str) -> Option<&ChainEntry> {
        self.chains.iter().find(|c| c.id == id && c.enabled)
    }

    /// 合并环境变量 (覆盖配置文件)
    pub fn merge_env(&mut self) {
        if let Ok(key) = std::env::var("ETHERSCAN_API_KEY") {
            self.api_keys.etherscan = key;
        }
        if let Ok(level) = std::env::var("LOG_LEVEL") {
            self.logging.level = level;
        }
        if let Ok(timeout) = std::env::var("TIMEOUT_SECS") {
            if let Ok(t) = timeout.parse() {
                self.app.timeout_secs = t;
            }
        }
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            app: AppSection {
                version: "0.3.0".to_string(),
                timeout_secs: 10,
                default_chain: "ethereum".to_string(),
                output_format: "table".to_string(),
            },
            api_keys: ApiKeysSection {
                etherscan: String::new(),
            },
            logging: LoggingSection {
                level: "info".to_string(),
                file: "chain-cli.log".to_string(),
                json_output: false,
            },
            chains: vec![
                ChainEntry {
                    name: "Ethereum".to_string(),
                    id: "ethereum".to_string(),
                    chain_id: 1,
                    rpc_url: "https://eth.llamarpc.com".to_string(),
                    explorer_url: "https://etherscan.io".to_string(),
                    native_symbol: "ETH".to_string(),
                    enabled: true,
                },
                ChainEntry {
                    name: "Arbitrum One".to_string(),
                    id: "arbitrum".to_string(),
                    chain_id: 42161,
                    rpc_url: "https://arb1.arbitrum.io/rpc".to_string(),
                    explorer_url: "https://arbiscan.io".to_string(),
                    native_symbol: "ETH".to_string(),
                    enabled: true,
                },
                ChainEntry {
                    name: "Optimism".to_string(),
                    id: "optimism".to_string(),
                    chain_id: 10,
                    rpc_url: "https://mainnet.optimism.io".to_string(),
                    explorer_url: "https://optimistic.etherscan.io".to_string(),
                    native_symbol: "ETH".to_string(),
                    enabled: true,
                },
                ChainEntry {
                    name: "Base".to_string(),
                    id: "base".to_string(),
                    chain_id: 8453,
                    rpc_url: "https://mainnet.base.org".to_string(),
                    explorer_url: "https://basescan.org".to_string(),
                    native_symbol: "ETH".to_string(),
                    enabled: true,
                },
                ChainEntry {
                    name: "Polygon".to_string(),
                    id: "polygon".to_string(),
                    chain_id: 137,
                    rpc_url: "https://polygon-rpc.com".to_string(),
                    explorer_url: "https://polygonscan.com".to_string(),
                    native_symbol: "MATIC".to_string(),
                    enabled: true,
                },
            ],
        }
    }
}

src/logging.rs

use tracing_subscriber::{fmt, EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};

/// 初始化日志系统
pub fn init(level: &str, verbose: bool) {
    let effective_level = if verbose { "debug" } else { level };

    let filter = format!(
        "chain_cli={},reqwest=warn,hyper=warn",
        effective_level
    );

    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| EnvFilter::new(&filter))
        )
        .with_target(false)
        .with_timer(fmt::time::ChronoLocal::new("%H:%M:%S".to_string()))
        .init();

    tracing::debug!("Logging initialized at level: {}", effective_level);
}

src/client.rs (v3)

use crate::config::ChainEntry;
use reqwest::Client;
use serde::Serialize;
use std::time::{Duration, Instant};
use tracing::{debug, error, info, instrument, warn};

#[derive(Serialize)]
struct RpcRequest {
    jsonrpc: String,
    method: String,
    params: Vec<serde_json::Value>,
    id: u64,
}

#[derive(serde::Deserialize)]
struct RpcResponse {
    result: Option<serde_json::Value>,
    error: Option<RpcError>,
}

#[derive(serde::Deserialize, Debug)]
struct RpcError {
    code: i64,
    message: String,
}

/// 查询结果
#[derive(Debug)]
pub struct BalanceResult {
    pub chain_name: String,
    pub chain_id: String,
    pub native_symbol: String,
    pub address: String,
    pub balance_wei: u128,
    pub balance_display: f64,
    pub latency_ms: u128,
    pub success: bool,
    pub error: Option<String>,
}

pub struct BlockchainClient {
    http: Client,
}

impl BlockchainClient {
    pub fn new(timeout_secs: u64) -> Self {
        let http = Client::builder()
            .timeout(Duration::from_secs(timeout_secs))
            .build()
            .expect("Failed to create HTTP client");

        Self { http }
    }

    /// 查询单条链的余额
    #[instrument(skip(self), fields(chain = %chain.name))]
    pub async fn get_balance(
        &self,
        chain: &ChainEntry,
        address: &str,
    ) -> BalanceResult {
        let start = Instant::now();
        debug!("Querying balance via {}", chain.rpc_url);

        let request = RpcRequest {
            jsonrpc: "2.0".to_string(),
            method: "eth_getBalance".to_string(),
            params: vec![
                serde_json::Value::String(address.to_string()),
                serde_json::Value::String("latest".to_string()),
            ],
            id: 1,
        };

        let result = self.http
            .post(&chain.rpc_url)
            .json(&request)
            .send()
            .await;

        let latency = start.elapsed().as_millis();

        match result {
            Ok(response) => {
                match response.json::<RpcResponse>().await {
                    Ok(rpc_resp) => {
                        if let Some(err) = rpc_resp.error {
                            error!(code = err.code, "RPC error: {}", err.message);
                            return BalanceResult {
                                chain_name: chain.name.clone(),
                                chain_id: chain.id.clone(),
                                native_symbol: chain.native_symbol.clone(),
                                address: address.to_string(),
                                balance_wei: 0,
                                balance_display: 0.0,
                                latency_ms: latency,
                                success: false,
                                error: Some(err.message),
                            };
                        }

                        if let Some(hex) = rpc_resp.result.and_then(|v| v.as_str().map(String::from)) {
                            let balance_wei = u128::from_str_radix(
                                hex.trim_start_matches("0x"), 16
                            ).unwrap_or(0);
                            let balance_display = balance_wei as f64 / 1e18;

                            info!(
                                balance = balance_display,
                                latency_ms = latency,
                                "Balance fetched"
                            );

                            BalanceResult {
                                chain_name: chain.name.clone(),
                                chain_id: chain.id.clone(),
                                native_symbol: chain.native_symbol.clone(),
                                address: address.to_string(),
                                balance_wei,
                                balance_display,
                                latency_ms: latency,
                                success: true,
                                error: None,
                            }
                        } else {
                            warn!("Unexpected response format");
                            BalanceResult {
                                chain_name: chain.name.clone(),
                                chain_id: chain.id.clone(),
                                native_symbol: chain.native_symbol.clone(),
                                address: address.to_string(),
                                balance_wei: 0,
                                balance_display: 0.0,
                                latency_ms: latency,
                                success: false,
                                error: Some("Invalid response format".to_string()),
                            }
                        }
                    }
                    Err(e) => {
                        error!("Failed to parse response: {}", e);
                        BalanceResult {
                            chain_name: chain.name.clone(),
                            chain_id: chain.id.clone(),
                            native_symbol: chain.native_symbol.clone(),
                            address: address.to_string(),
                            balance_wei: 0,
                            balance_display: 0.0,
                            latency_ms: latency,
                            success: false,
                            error: Some(format!("Parse error: {}", e)),
                        }
                    }
                }
            }
            Err(e) => {
                error!("Request failed: {}", e);
                BalanceResult {
                    chain_name: chain.name.clone(),
                    chain_id: chain.id.clone(),
                    native_symbol: chain.native_symbol.clone(),
                    address: address.to_string(),
                    balance_wei: 0,
                    balance_display: 0.0,
                    latency_ms: latency,
                    success: false,
                    error: Some(format!("Request error: {}", e)),
                }
            }
        }
    }

    /// 并发查询多条链的余额
    #[instrument(skip(self, chains))]
    pub async fn get_multichain_balance(
        &self,
        chains: &[ChainEntry],
        address: &str,
    ) -> Vec<BalanceResult> {
        info!(chain_count = chains.len(), "Starting multi-chain query");
        let start = Instant::now();

        let mut handles = Vec::new();

        for chain in chains {
            let chain = chain.clone();
            let addr = address.to_string();
            let client = BlockchainClient::new(10);

            handles.push(tokio::spawn(async move {
                client.get_balance(&chain, &addr).await
            }));
        }

        let mut results = Vec::new();
        for handle in handles {
            match handle.await {
                Ok(result) => results.push(result),
                Err(e) => error!("Task failed: {}", e),
            }
        }

        let total_time = start.elapsed().as_millis();
        info!(
            total_ms = total_time,
            successful = results.iter().filter(|r| r.success).count(),
            failed = results.iter().filter(|r| !r.success).count(),
            "Multi-chain query completed"
        );

        results
    }
}

src/main.rs (v3)

use clap::{Parser, Subcommand, Args};
use tracing::info;

mod config;
mod logging;
mod client;

use config::Config;
use client::BlockchainClient;

#[derive(Parser)]
#[command(name = "chain-cli", version = "0.3.0")]
#[command(about = "Multi-chain blockchain query CLI with config & logging")]
struct Cli {
    /// Config file path
    #[arg(long, short = 'C')]
    config: Option<String>,

    /// Verbose output (debug level)
    #[arg(short, long, global = true)]
    verbose: bool,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Query balance on one or all chains
    Balance(BalanceArgs),
    /// Show current configuration
    Config,
    /// Initialize default config file
    Init,
}

#[derive(Args)]
struct BalanceArgs {
    /// Ethereum address (0x...)
    #[arg(short, long)]
    address: String,

    /// Chain ID (ethereum, arbitrum, polygon, etc.)
    #[arg(short, long)]
    chain: Option<String>,

    /// Query all enabled chains
    #[arg(long)]
    all: bool,
}

fn format_balance(value: f64, symbol: &str) -> String {
    if value >= 1000.0 {
        format!("{:.2} {}", value, symbol)
    } else if value >= 1.0 {
        format!("{:.4} {}", value, symbol)
    } else if value >= 0.001 {
        format!("{:.6} {}", value, symbol)
    } else if value > 0.0 {
        format!("{:.10} {}", value, symbol)
    } else {
        format!("0 {}", symbol)
    }
}

fn shorten(addr: &str) -> String {
    if addr.len() > 10 {
        format!("{}...{}", &addr[..6], &addr[addr.len() - 4..])
    } else {
        addr.to_string()
    }
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    // 加载配置
    let config_path = cli.config
        .map(std::path::PathBuf::from)
        .unwrap_or_else(Config::default_path);

    let mut config = Config::load(&config_path).unwrap_or_else(|e| {
        eprintln!("Warning: Failed to load config: {}. Using defaults.", e);
        Config::default()
    });
    config.merge_env();

    // 初始化日志
    logging::init(&config.logging.level, cli.verbose);
    info!(config_path = %config_path.display(), "CLI started");

    match cli.command {
        Commands::Balance(args) => cmd_balance(&config, args).await,
        Commands::Config => cmd_config(&config),
        Commands::Init => cmd_init(&config, &config_path),
    }
}

async fn cmd_balance(config: &Config, args: BalanceArgs) {
    let client = BlockchainClient::new(config.app.timeout_secs);

    if args.all {
        // 查询所有启用的链
        let chains: Vec<_> = config.enabled_chains().into_iter().cloned().collect();

        println!("\nMulti-chain balance for {}\n", shorten(&args.address));

        let results = client.get_multichain_balance(&chains, &args.address).await;

        println!("{:<14} {:>18} {:>10}", "Chain", "Balance", "Latency");
        println!("{}", "-".repeat(45));

        for r in &results {
            if r.success {
                println!(
                    "{:<14} {:>18} {:>8}ms",
                    r.chain_name,
                    format_balance(r.balance_display, &r.native_symbol),
                    r.latency_ms
                );
            } else {
                println!(
                    "{:<14} {:>18} {:>10}",
                    r.chain_name,
                    "Error",
                    r.error.as_deref().unwrap_or("Unknown")
                );
            }
        }

        println!("{}", "-".repeat(45));

        let total: f64 = results.iter()
            .filter(|r| r.success && r.native_symbol == "ETH")
            .map(|r| r.balance_display)
            .sum();

        println!("{:<14} {:>18}", "Total ETH", format_balance(total, "ETH"));
    } else {
        // 查询单条链
        let chain_id = args.chain.as_deref().unwrap_or(&config.app.default_chain);

        match config.find_chain(chain_id) {
            Some(chain) => {
                let result = client.get_balance(chain, &args.address).await;
                if result.success {
                    println!("\nChain:   {}", result.chain_name);
                    println!("Address: {}", shorten(&result.address));
                    println!("Balance: {}", format_balance(result.balance_display, &result.native_symbol));
                    println!("Latency: {}ms", result.latency_ms);
                } else {
                    eprintln!("Error: {}", result.error.unwrap_or_default());
                }
            }
            None => {
                eprintln!("Chain '{}' not found. Available chains:", chain_id);
                for chain in config.enabled_chains() {
                    eprintln!("  - {}", chain.id);
                }
            }
        }
    }
}

fn cmd_config(config: &Config) {
    println!("Current configuration:\n");
    println!("{}", toml::to_string_pretty(config).unwrap());
}

fn cmd_init(config: &Config, path: &std::path::Path) {
    match config.save(path) {
        Ok(()) => println!("Config file created at: {}", path.display()),
        Err(e) => eprintln!("Failed to create config: {}", e),
    }
}

关键要点总结

Production-Ready Rust 模式清单

模式实现方式说明
配置分层默认值 -> 配置文件 -> 环境变量 -> CLI 参数优先级从低到高
结构化日志tracing + tracing-subscriber可过滤、可输出到文件
错误处理Result + 自定义 Error + ?不要 unwrap
异步并发tokio::spawn + 超时不阻塞主线程
模块化lib.rs + 子模块可测试、可复用
配置文件TOML + serdeRust 生态标准

配置优先级

CLI 参数 (最高优先级)
    ↑
环境变量 (ETHERSCAN_API_KEY, LOG_LEVEL)
    ↑
配置文件 (config.toml)
    ↑
默认值 (Default trait)

tracing 级别选择

级别用途示例
error!不可恢复的错误RPC 连接完全失败
warn!可恢复但需关注单条链超时,其他正常
info!正常操作记录查询成功,余额多少
debug!调试详情请求 URL、响应内容
trace!极度详细原始字节、每次 poll

常见误区

  1. 配置文件路径硬编码: 应支持 CLI 参数 + 默认路径 + 环境变量
  2. 忘记 merge 环境变量: 环境变量应覆盖配置文件
  3. tracing 不设 filter: 所有日志都输出,包括依赖库的噪音
  4. 同步文件 IO 在 async 中: 读配置文件用 std::fs 没问题(启动时一次),但不要在热路径用
  5. TOML 字段名不匹配: serde 默认匹配 Rust 字段名,TOML 中下划线不需要 rename

面试关联

面试题本课关联
"如何设计 CLI 工具的配置系统?"分层配置: 默认 -> 文件 -> 环境变量 -> CLI
"Rust 项目如何做日志?"tracing 框架 + 结构化日志
"如何处理多链查询?"tokio::spawn 并发 + 配置驱动
"如何组织 Rust 项目结构?"lib.rs + mod + 关注点分离
"TOML vs YAML vs JSON 配置文件?"TOML: 可读性好,Rust 原生,适合配置

参考资源