返回首页错误处理 节点从 panic 到 Result:用 Rust 重新整理一个 ping 项目的错误处理

> 一个能跑的程序和一个不会崩的程序之间,差了一套像样的错误处理。
适用版本:Rust 1.85+ · thiserror 2.x · anyhow 1.x · 2026-06-21
〇、先搭环境:Cargo.toml
> 以下所有代码均可编译运行,不再是伪代码。先创建项目:
cargo new ping-rs && cd ping-rs
# Cargo.toml
[package]
name = "ping-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
thiserror = "2"
anyhow = "1"
一、反面教材:能跑但一碰就碎
假设你写了一个简单的 ping 工具——发 ICMP,等回复,打 RTT。初版大概长这样:
use std::net::{IpAddr, ToSocketAddrs};
use std::process::Command;
fn main() {
let args: Vec = std::env::args().collect();
// 💥 参数不足 → 索引越界 panic
let target = &args[1];
let ip = resolve(target);
let rtt = ping(ip);
println!("{}: rtt={:.2}ms", target, rtt);
}
fn resolve(host: &str) -> IpAddr {
let addr = (host, 0)
.to_socket_addrs()
.unwrap() // 💥 DNS 查询失败 → panic
.next()
.unwrap() // 💥 没有地址返回 → panic
.ip();
addr
}
fn ping(ip: IpAddr) -> f64 {
let output = Command::new("ping")
.arg("-c")
.arg("1")
.arg(ip.to_string())
.output()
.unwrap(); // 💥 ping 命令不存在 → panic
if !output.status.success() {
panic!("ping failed"); // 💥 网络不通 → panic(无上下文)
}
let stdout = String::from_utf8(output.stdout)
.unwrap(); // 💥 非 UTF-8 输出 → panic
parse_rtt(&stdout).unwrap() // 💥 输出格式变化 → panic
}
fn parse_rtt(s: &str) -> Option {
s.lines()
.find(|l| l.contains("time="))?
.split("time=")
.nth(1)?
.split_whitespace()
.next()?
.replace("ms", "")
.parse()
.ok() // 💥 解析失败静默吞掉
}
二、问题在哪
| 问题 | 表现 | 后果 |
|---|
unwrap() 到处用 | 任意一步失败 → panic | 程序崩溃,无恢复机会 |
| 错误信息不可读 | thread 'main' panicked at src/main.rs:15:10 | 用户看到一行谜语 |
| 错误类型丢失 | Option 把失败原因吞了 | 不知道是网络不通还是解析失败 |
从 panic 到 Result:用 Rust 重新整理一个 ping 项目的错误处理 — 志趣 ZhiQupanic! 无上下文 | "ping failed" 区分不了无网络 / 无命令 / 目标拒绝 | 排查全靠猜 |
| 无法交给上层决策 | panic 直接穿透调用栈 | 做不了重试、降级 |
> 本质上,你在用"程序员的便利"换"用户的体验"。
三、第一轮重构:全部返回 Result
消灭所有 unwrap() 和 panic!,让每个函数把错误传上去:
use std::net::{IpAddr, ToSocketAddrs};
use std::process::Command;
fn main() -> Result<(), Box> {
let args: Vec = std::env::args().collect();
let target = args
.get(1)
.ok_or("用法: ping-rs ")?; // ✅ 参数缺失 → 友好提示
let ip = resolve(target)?;
let rtt = ping(ip)?;
println!("{}: rtt={:.2}ms", target, rtt);
Ok(())
}
fn resolve(host: &str) -> Result {
(host, 0)
.to_socket_addrs()
.map_err(|e: std::io::Error| format!("DNS 解析失败 '{}': {}", host, e))?
.next()
.ok_or_else(|| format!("未找到 '{}' 的 IP 地址", host))
.map(|addr| addr.ip())
}
fn ping(ip: IpAddr) -> Result {
let output = Command::new("ping")
.arg("-c").arg("1")
.arg(ip.to_string())
.output()
.map_err(|e: std::io::Error| format!("无法执行 ping 命令: {}", e))?;
if !output.status.success() {
return Err(format!("ping {} 失败(目标不可达或无网络)", ip));
}
let stdout = String::from_utf8(output.stdout)
.map_err(|_| "输出包含非 UTF-8 字符".to_string())?;
parse_rtt(&stdout)
.ok_or_else(|| format!("无法从输出中解析 RTT 值:\n{}", stdout))
}
fn parse_rtt(s: &str) -> Option {
s.lines()
.find(|l| l.contains("time="))?
.split("time=")
.nth(1)?
.split_whitespace()
.next()?
.replace("ms", "")
.parse::()
.ok()
}
| 原来 | 现在 |
|---|
args[1] 索引越界 | args.get(1).ok_or(...) |
.unwrap() 链路 | ? 向上传递 |
仅返回 Option | 返回 Result<_, String> 带文案 |
panic! 终止进程 | main 返回 Result,优雅退出 |
用户不再看到 backtrace,而是 "DNS 解析失败 '不存在的域名': ..."。
但这里有个隐藏缺陷:String 做错误类型,调用方没法区分 DNS 失败还是 ping 失败,也拿不到原始 std::io::Error。
> ⚠️ Result<_, String> 只适合一次性脚本。正式项目禁止使用。
四、第二轮:thiserror 定义结构化错误
4.1 错误枚举(完整版)
use thiserror::Error;
use std::net::IpAddr;
#[derive(Error, Debug)]
pub enum PingError {
#[error("DNS 解析失败: {host}")]
DnsResolve {
host: String,
#[source]
source: std::io::Error, // ✅ 保留原始 IO 错误
},
#[error("未找到 IP 地址: {0}")]
NoIpAddress(String),
#[error("无法执行 ping 命令")]
PingCommandFailed(
#[source] std::io::Error, // ✅ ping 二进制不存在 / 权限不足
),
#[error("ping {ip} 失败(目标不可达)")]
PingFailed { ip: IpAddr },
#[error("输出包含非 UTF-8 字符")]
NonUtf8Output {
raw: Vec, // ✅ 保留原始字节,方便排查
},
#[error("无法解析 RTT 值")]
RttParseFailed {
output_snippet: String, // ✅ 截取一段原始输出供排查
},
}
4.2 配套函数实现
use std::net::{IpAddr, ToSocketAddrs};
use std::process::Command;
fn resolve(host: &str) -> Result {
(host, 0)
.to_socket_addrs()
.map_err(|e| PingError::DnsResolve {
host: host.to_string(),
source: e,
})?
.next()
.map(|addr| addr.ip())
.ok_or_else(|| PingError::NoIpAddress(host.to_string()))
}
fn ping(ip: IpAddr) -> Result {
let output = Command::new("ping")
.arg("-c").arg("1")
.arg(ip.to_string())
.output()
.map_err(PingError::PingCommandFailed)?;
if !output.status.success() {
return Err(PingError::PingFailed { ip });
}
let stdout = String::from_utf8(output.stdout.clone())
.map_err(|e| PingError::NonUtf8Output {
raw: e.into_bytes(), // ✅ 把原始字节带回来
})?;
parse_rtt(&stdout).ok_or_else(|| PingError::RttParseFailed {
output_snippet: stdout.chars().take(200).collect(),
})
}
fn parse_rtt(s: &str) -> Option {
s.lines()
.find(|l| l.contains("time="))?
.split("time=")
.nth(1)?
.split_whitespace()
.next()?
.replace("ms", "")
.parse::()
.ok()
}
4.3 按错误类型分支处理
这才是强类型错误的真正价值——调用方可以按错误种类做不同决策:
use std::net::IpAddr;
use std::time::Duration;
/// 可配置的重试策略
struct RetryConfig {
max_attempts: u32,
delay: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
delay: Duration::from_secs(1),
}
}
}
fn ping_with_retry(ip: IpAddr, config: &RetryConfig) -> Result {
for attempt in 1..=config.max_attempts {
match ping(ip) {
Ok(rtt) => return Ok(rtt),
Err(PingError::PingFailed { .. }) if attempt < config.max_attempts => {
eprintln!(
"{} 第 {}/{} 次失败,{} 秒后重试...",
ip, attempt, config.max_attempts, config.delay.as_secs()
);
std::thread::sleep(config.delay);
// 继续下一轮
}
Err(e) => return Err(e), // 其他错误直接返回,不重试
}
}
// 理论上不会到这里(最后一次循环不满足 if attempt < max_attempts 会走 Err(e) 分支)
unreachable!()
}
use anyhow::Context;
fn main() -> anyhow::Result<()> {
let args: Vec = std::env::args().collect();
let target = args
.get(1)
.ok_or_else(|| anyhow::anyhow!("用法: ping-rs "))?;
let config = RetryConfig::default();
match resolve(target) {
Ok(ip) => {
let rtt = ping_with_retry(ip, &config)
.context("ping 请求最终失败")?; // ✅ anyhow 附加上下文
println!("{}: rtt={:.2}ms", target, rtt);
}
Err(PingError::DnsResolve { host, .. }) => {
eprintln!("{} 的 DNS 解析失败,可尝试指定 IP 或切换 DNS 服务器", host);
std::process::exit(1);
}
Err(e) => {
eprintln!("{:#}", e); // ✅ 打印完整错误链
std::process::exit(1);
}
}
Ok(())
}
关键价值:PingFailed → 重试;DnsResolve → 切备用 DNS;PingCommandFailed → 直接报错(没装 ping 重试也没用)。
五、第三轮:thiserror + anyhow 分层
| thiserror | anyhow |
|---|
| 用在哪 | 库(library) | 应用(application) |
| 特点 | 结构化错误枚举,可 match | 链式追加上下文,随写随用 |
| 能做 match 吗 | ✅ | ❌(动态类型擦除) |
| 写起来 | 先定义再使用 | ? 直接传,爽 |
| 典型签名 | Result | anyhow::Result |
| 场景 | 用什么 | 示例 |
|---|
| 公共库(给别人用) | thiserror | 把 PingError 放 lib.rs 导出 |
| 内部工具库(组内用) | thiserror 或 thiserror + 可选 anyhow | 看是否需要灵活添上下文 |
| 二进制程序(main) | anyhow | main() -> anyhow::Result<()> |
thiserror 通过 #[derive(Error)] 自动为你的枚举实现 std::error::Error trait。而 anyhow::Error 有一个 blanket impl:
// anyhow 内部逻辑(简化)
impl From for anyhow::Error { ... }
所以任何 PingError 都可以直接通过 ? 或 .into() 转为 anyhow::Error,无需手写 From。
ping-rs/
├── Cargo.toml
├── src/
│ ├── main.rs # anyhow: 编排流程 + 附加上下文
│ ├── error.rs # thiserror: PingError 枚举定义
│ └── ping.rs # 核心函数,返回 Result<_, PingError>
// ===== src/error.rs =====
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PingError { ... } // 完整定义见上一节
// ===== src/ping.rs =====
use crate::error::PingError;
// resolve() / ping() / parse_rtt() 全部返回 Result<_, PingError>
// ===== src/main.rs =====
mod error;
mod ping;
use anyhow::Context;
fn main() -> anyhow::Result<()> {
let ip = ping::resolve(&target).context("DNS 解析失败")?;
let rtt = ping::ping(ip).context("ping 请求失败")?;
println!("rtt={:.2}ms", rtt);
Ok(())
}
六、进阶:错误链打印 & 结构化日志
6.1 递归打印完整错误链
fn print_error_chain(err: &dyn std::error::Error) {
eprintln!("错误: {}", err);
let mut source = err.source();
while let Some(inner) = source {
eprintln!(" 原因: {}", inner);
source = inner.source();
}
}
// 使用
if let Err(e) = ping_with_retry(ip, &config) {
print_error_chain(&e);
// 输出:
// 错误: ping 1.1.1.1 失败(目标不可达)
// 原因: ping 1.1.1.1 失败(目标不可达)
// (PingFailed 无底层 source 时就是自己)
}
6.2 结构化日志(为监控铺路)
// 借助 thiserror 的 Debug 输出,可轻松序列化
#[derive(serde::Serialize)]
struct ErrorLog {
timestamp: chrono::DateTime,
error_kind: String, // "DnsResolve" | "PingFailed" | ...
message: String,
host: Option,
retry_count: Option,
}
impl From<&PingError> for ErrorLog {
fn from(e: &PingError) -> Self {
let kind = format!("{:?}", e)
.split('(')
.next()
.unwrap_or("Unknown")
.to_string();
Self {
timestamp: chrono::Utc::now(),
error_kind: kind,
message: e.to_string(),
host: None, // 按需从具体变体提取
retry_count: None,
}
}
}
> 这正是 String 错误做不到的——DnsResolve 上有 host、PingFailed 上有 ip,按变体拆字段,监控/告警一目了然。
七、跨平台补充
| 参数 | Linux / macOS | Windows |
|---|
| 发包数量 | -c 1 | -n 1 |
| 超时时间 | -W 1(秒) | -w 1000(毫秒) |
fn ping_cmd(ip: &IpAddr) -> Command {
let mut cmd = Command::new("ping");
if cfg!(target_os = "windows") {
cmd.arg("-n").arg("1").arg("-w").arg("1000");
} else {
cmd.arg("-c").arg("1").arg("-W").arg("1");
}
cmd.arg(ip.to_string());
cmd
}
// 替换原来的 Command::new("ping").arg("-c").arg("1")...
let output = ping_cmd(&ip).output().map_err(PingError::PingCommandFailed)?;
八、最终对比
| panic 版本 | Result + thiserror 版本 |
|---|
| DNS 失败 | thread 'main' panicked at ... | DNS 解析失败: example.notexist |
| ping 不通 | 崩溃 | 自动重试 3 次(可配置) |
| 缺参数 | 索引越界 panic | 用法: ping-rs |
| 非 UTF-8 输出 | panic | 错误携带原始字节,可排查 |
| 上游处理 | 什么都做不了 | 按错误类型分支(重试 / 降级 / 报错) |
| 日志/监控 | backtrace 里翻 | 按错误变体提取结构化字段 |
九、核心要点
unwrap() 只在原型里用,永远别留在生产代码中
? 不是省字符的语法糖——它把 panic 思维切换为 Result 思维
- 库层用
thiserror 暴露结构化错误 → 调用方按类型决策
- 应用层用
anyhow 快速附加上下文 → 定位问题快
- 两者无需手动桥接:
thiserror 自动实现 Error,anyhow 自动 From
- 好的错误处理 = 错误携带足够信息 + 上层能按类型做不同处理 + 人类可读
总结
把 panic 改成 Result,代码量多了不到 15%,但程序从"一碰就碎"变成了"知道自己为什么失败、让上层决定怎么处理"。
Rust 的 Result + thiserror + anyhow 不是负担——它是一套精确的类型系统,让你在编译期就把所有失败路径想清楚。这才是 Rust 给程序员的核心安全感。
本文首发于 [志趣社区] —— 中文 AI 工具实战论坛。