本文是对 A simple ping library, parsing strings into IPv4 address 的整理与翻译。
内容结构概览
- 继续重构 ping 项目 :前面已经封装了
LoadLibrary,这一篇开始整理 ICMP 与 IPv4 相关代码。 - 拆分模块 :新增
icmp和ipv4模块,把IPAddr改成ipv4::Addr。 - 隐藏 Win32 ICMP 细节 :在
icmp模块内部再放一个私有的icmp_sys模块。 - 临时包装
IcmpCreateFile与IcmpSendEcho:通过前面写好的loadlibrary::Library动态加载IPHLPAPI.dll。 - 设计简单的
icmp::pingAPI :让主程序可以写成icmp::ping(ipv4::Addr([8, 8, 8, 8])).unwrap()。 - 当前实现的不足:硬编码超时、不传 IP options、忽略 reply、泄漏 ICMP handle,但先让模块结构跑起来。
- 开始做 CLI :使用
std::env::args()读取命令行参数。 - 处理缺少参数 :用
nth(1)、unwrap_or_else、exit(1)输出用法并退出。 - 从字符串解析 IPv4 地址 :把
"8.8.8.8"转成ipv4::Addr([8, 8, 8, 8])。 String、&str与AsRef<str>:理解所有权、借用和更灵活的参数设计。- 解析失败必须建模 :IPv4 字符串可能缺段、多段、段不是数字、数字超出
u8范围。 - 自定义
ParseAddrError:用Result和?传播错误。 - 把
ParseIntError转成自己的错误类型 :实现From<ParseIntError>。 - 改进重复解析逻辑 :从闭包方案到遍历
[u8; 4]的方案。 - 实现标准库的
FromStrtrait :让arg.parse()?可以直接得到ipv4::Addr。 - 让
main返回Result:用Box<dyn Error>串起解析错误和 ping 错误。 - 总结:这一篇重点不是网络协议,而是 Rust 模块设计、可见性、CLI 参数、解析与错误处理。
前面几篇已经完成了几个关键步骤。先从网络历史和协议分层讲清楚 ping 背后的基本逻辑,再观察 Windows 自带 ping.exe 如何调用系统 API,然后用 Rust 动态加载 IPHLPAPI.dll,调用 IcmpCreateFile 和 IcmpSendEcho 发出真正的 ICMP Echo 请求。上一篇又把 LoadLibraryA 和 GetProcAddress 包装成一个更安全的 loadlibrary 模块,避免主程序直接处理 C 字符串、动态库句柄、空指针和 transmute。
到了这一篇,问题变成了另一个方向:既然动态加载 DLL 的底层细节已经封装过了,那么 Win32 ICMP API 的细节也不应该继续堆在 main.rs 里。一个能跑的实验程序可以把所有代码写在一个文件里,但一个逐渐成型的小工具,应该有更清晰的模块边界。IPv4 地址应该有自己的模块,ICMP 相关逻辑应该有自己的模块,Win32 特有的 FFI 绑定也应该藏在更深一层,而不是暴露给业务代码。
这篇的另一个重点,是把硬编码地址变成命令行参数。前面一直在代码里写死 8.8.8.8,这对实验足够,但不像一个真正的命令行工具。自己的 ping 工具暂时叫 sup,它应该能像普通命令一样接收目标地址,例如 sup 8.8.8.8。一旦从命令行读取地址,就会遇到字符串解析、错误处理、所有权、借用、AsRef<str>、FromStr、Result 和 ?。这一篇表面上是在继续写 ping,实际上重点已经转向 Rust API 设计和命令行输入处理。
一、先把 IPv4 地址和 ICMP 逻辑拆出去
前一版代码里,IPv4 地址类型、ICMP Echo Reply 结构体、IP option 结构体、IcmpSendEcho 函数指针类型、动态库加载代码和真正的 main() 逻辑都混在一起。继续往下写之前,先拆模块是很自然的选择。项目中可以新增两个模块:icmp 和 ipv4。在 src/main.rs 里声明:
rust
pub mod icmp;
pub mod ipv4;
ipv4 模块先负责 IPv4 地址类型。前面已经写过一个 IPAddr([u8; 4]) 的 newtype,现在可以把它改名成更清楚的 ipv4::Addr,放到 src/ipv4.rs:
rust
use std::fmt;
pub struct Addr(pub [u8; 4]);
impl fmt::Debug for Addr {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let [a, b, c, d] = self.0;
write!(f, "{}.{}.{}.{}", a, b, c, d)
}
}
这里的 Addr(pub [u8; 4]) 仍然是 newtype。它底层只是四个字节,但在类型系统中已经和普通 [u8; 4] 区分开来。把字段设成 pub,是为了后面其他模块可以构造 ipv4::Addr([8, 8, 8, 8])。Debug 实现把数组格式 [8, 8, 8, 8] 改成更符合 IP 地址习惯的 8.8.8.8。
拆出 ipv4 模块后,之前所有 IPAddr 的地方都可以替换成 ipv4::Addr。这只是一个小改动,但代码表达会更清楚:这个类型属于 IPv4 领域,而不是随便定义在 main.rs 里的辅助结构。
二、ICMP 模块还要再分一层
icmp 模块比 ipv4 模块复杂一些。它需要对外暴露一个简单 API,比如 icmp::ping(dest);同时又要在内部调用 Windows 的 ICMP API。为了避免把 Win32 细节暴露出去,可以在 icmp 模块内部再建一个私有子模块 icmp_sys。目录结构可以是:
text
src/
main.rs
ipv4.rs
icmp/
mod.rs
icmp_sys.rs
src/icmp/mod.rs 是公开接口所在的位置,可以先写成:
rust
mod icmp_sys;
pub struct Request {
// TODO
}
pub struct Reply {
// TODO
}
注意这里的 mod icmp_sys; 没有加 pub。这意味着 icmp_sys 是 icmp 模块内部的实现细节,外部代码无法直接访问。这样做很重要。真正的 ping 用户不应该知道 IcmpSendEcho、IcmpEchoReply、IpOptionInformation 这些 Win32 结构体,也不应该接触 HANDLE、裸指针和 FFI 函数签名。外部最好只看到 icmp::ping、icmp::Request、icmp::Reply 这样的接口。
src/icmp/icmp_sys.rs 则负责存放 Win32 特有结构体:
rust
use crate::ipv4;
#[repr(C)]
#[derive(Debug)]
pub struct IpOptionInformation {
pub ttl: u8,
pub tos: u8,
pub flags: u8,
pub options_size: u8,
pub options_data: u32,
}
#[repr(C)]
#[derive(Debug)]
pub struct IcmpEchoReply {
pub address: ipv4::Addr,
pub status: u32,
pub rtt: u32,
pub data_size: u16,
pub reserved: u16,
pub data: *const u8,
pub options: IpOptionInformation,
}
这两个结构体仍然必须使用 #[repr(C)],因为它们要和 Windows C API 的结构体布局匹配。字段被标成 pub,不是为了暴露给项目外部,而是因为它们定义在 crate::icmp::icmp_sys 中,却要从上层 crate::icmp 模块读取。如果字段不公开,父模块也不能直接访问这些字段。Rust 的可见性系统非常细,模块边界不仅影响外部用户,也影响 crate 内部不同模块之间的访问。
三、临时包装 IcmpCreateFile
使用 Win32 ICMP API 时,需要先创建一个 ICMP handle。底层函数叫 IcmpCreateFile。在 icmp_sys.rs 中,可以先定义 handle 类型:
rust
use std::ffi::c_void;
pub type Handle = *const c_void;
然后理想上希望写一个函数:
rust
pub fn IcmpCreateFile() -> Handle {
unimplemented!()
}
但这个函数不应该由我们自己实现。真正的实现来自 IPHLPAPI.dll。一种最直接的写法是声明外部函数:
rust
extern "stdcall" {
pub fn IcmpCreateFile() -> Handle;
}
问题是,这样程序会尝试在链接阶段找到 IcmpCreateFile,而前面的设计路线不是静态链接 IPHLPAPI.dll,而是在运行时通过 LoadLibrary 动态加载它。也就是说,如果直接写 extern "stdcall" 并让链接器去找符号,就和上一篇设计的动态加载方式冲突了,程序无法按当前方式链接。
所以这里先用上一节写好的 loadlibrary::Library 临时包装:
rust
use crate::loadlibrary::Library;
type IcmpCreateFile = extern "stdcall" fn() -> Handle;
pub fn IcmpCreateFile() -> Handle {
let iphlp = Library::new("IPHLPAPI.dll").unwrap();
let IcmpCreateFile: IcmpCreateFile = unsafe {
iphlp.get_proc("IcmpCreateFile").unwrap()
};
IcmpCreateFile()
}
这段代码可以工作,但它不是长期方案。每次调用 IcmpCreateFile 都会打开一次 IPHLPAPI.dll,再查找一次 IcmpCreateFile 的地址。更严重的是,当前没有释放动态库 handle,会造成资源泄漏。作为临时实现,它能帮助模块结构先跑起来;作为长期设计,它必须重构。
这个取舍很现实。系统编程经常需要先让模块边界和功能主线跑通,再回头处理资源生命周期、缓存、错误类型和安全封装。现在先接受这个临时方案,是为了尽快把 icmp 模块从 main.rs 中拆出来。
四、临时包装 IcmpSendEcho
IcmpSendEcho 的包装也类似,只是函数签名更长。底层函数指针类型可以写成:
rust
type IcmpSendEcho = extern "stdcall" fn(
handle: Handle,
dest: ipv4::Addr,
request_data: *const u8,
request_size: u16,
request_options: Option<&IpOptionInformation>,
reply_buffer: *mut u8,
reply_size: u32,
timeout: u32,
) -> u32;
然后公开一个同名包装函数:
rust
pub fn IcmpSendEcho(
handle: Handle,
dest: ipv4::Addr,
request_data: *const u8,
request_size: u16,
request_options: Option<&IpOptionInformation>,
reply_buffer: *mut u8,
reply_size: u32,
timeout: u32,
) -> u32 {
let iphlp = Library::new("IPHLPAPI.dll").unwrap();
let IcmpSendEcho: IcmpSendEcho = unsafe {
iphlp.get_proc("IcmpSendEcho").unwrap()
};
IcmpSendEcho(
handle,
dest,
request_data,
request_size,
request_options,
reply_buffer,
reply_size,
timeout,
)
}
这段代码看起来很重复,也确实不优雅。它每次调用都加载 DLL,每次都查符号,还没有释放动态库资源。但现阶段先保留它,可以让 icmp 模块上层 API 先设计起来。后续再把动态库加载、函数指针缓存、资源释放等问题整理成更好的实现。
这也体现了一个工程习惯:重构可以分阶段。第一阶段先把代码从 main.rs 拆到合适模块里,即使内部仍然粗糙;第二阶段再优化模块内部实现。一次性追求完美,反而容易让主线停滞。
五、设计一个简单的 icmp::ping
有了 icmp_sys::IcmpCreateFile 和 icmp_sys::IcmpSendEcho,上层的 icmp 模块就可以提供一个简单 API。希望主程序能写成:
rust
fn main() {
icmp::ping(ipv4::Addr([8, 8, 8, 8])).unwrap();
}
也就是说,外部调用者只传一个目标地址,icmp::ping 内部负责创建 ICMP handle、准备请求数据、分配响应缓冲区、调用系统 API,并根据返回值判断成功或失败。先写一个最小可用版本:
rust
use crate::ipv4;
use std::mem::size_of;
pub fn ping(dest: ipv4::Addr) -> Result<(), String> {
let handle = icmp_sys::IcmpCreateFile();
let data = "O Romeo. Please respond.";
let reply_size = size_of::<icmp_sys::IcmpEchoReply>();
let reply_buf_size = reply_size + 8 + data.len();
let mut reply_buf = vec![0u8; reply_buf_size];
let timeout = 4000_u32;
match icmp_sys::IcmpSendEcho(
handle,
dest,
data.as_ptr(),
data.len() as u16,
None,
reply_buf.as_mut_ptr(),
reply_buf_size as u32,
timeout,
) {
0 => Err("IcmpSendEcho failed :(".to_string()),
_ => Ok(()),
}
}
这个版本非常朴素,但它能工作。请求数据仍然硬编码,超时时间固定为 4 秒,request_options 直接传 None,也就是说没有设置 TTL 之类的 IP option。响应缓冲区仍然按照前一篇学到的规则分配:ICMP_ECHO_REPLY 结构体大小,加上额外 8 字节,再加上请求数据长度。
返回值处理也很简单。IcmpSendEcho 返回 0 表示没有收到有效 reply 或调用失败,返回非 0 表示写入了至少一个 reply。这个函数暂时不解析 reply,也不显示往返时间,只把成功或失败抽象成 Result<(), String>。这样上层调用者可以用 unwrap 或 expect 暂时处理错误。
这段代码还有几个明显问题。超时写死为 4 秒,没有传 IP options,没有解析响应内容,ping 成功时没有任何输出,ICMP handle 在函数返回后也没有关闭,意味着泄漏了操作系统资源。这里的泄漏不是 Rust 堆内存泄漏,而是 Windows 内部维护的 ICMP 相关资源没有释放。对于短时间运行的小实验影响不大,但真正工具不能这样写。
六、没有输出时,很难确认程序真的工作
icmp::ping(ipv4::Addr([8, 8, 8, 8])).unwrap() 如果运行后没有报错,也没有输出,就很难说服自己它确实工作了。最直接的验证方式,是换一个明显不可达的地址,比如 0.0.0.0:
rust
fn main() {
icmp::ping(ipv4::Addr([0, 0, 0, 0])).unwrap();
}
如果这时程序报错,说明前面对返回值的判断大体有效。能 ping 通 8.8.8.8 时正常结束,ping 0.0.0.0 时失败,至少证明 icmp::ping 不是单纯"无论如何都返回 Ok"。
当然,这还只是很粗糙的验证。真正的 ping 工具应该输出响应来自哪个地址、耗时多少、TTL 是多少、返回数据长度是多少,并在最后给出统计信息。但那是后面的工作。当前阶段最重要的是把 API 边界搭起来。
七、开始把 sup 做成命令行工具
前面的调用方式仍然要改代码才能换目标地址:
rust
icmp::ping(ipv4::Addr([8, 8, 8, 8])).unwrap();
真正的命令行工具应该从用户输入里拿目标地址。Rust 标准库的 std::env::args() 可以读取命令行参数:
rust
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("args = {:?}", args);
}
env::args() 返回的是一个迭代器。调用 collect() 可以把它收集成 Vec<String>,然后打印出来观察。运行程序时,参数列表的第一个元素通常是可执行文件本身,比如 sup.exe,后面的元素才是用户传入的参数。
现在先只支持一个参数,也就是要 ping 的目标地址。可以用 nth(1) 取第二个参数:
rust
use std::{env, process::exit};
fn main() {
let arg = env::args().nth(1).unwrap_or_else(|| {
println!("Usage: sup DEST");
exit(1);
});
println!("dest = {}", arg);
}
这里有几个细节。env::args() 是迭代器,nth(1) 会跳过第 0 个元素,取第 1 个元素,也就是用户传入的第一个真正参数。如果用户没有传参数,nth(1) 返回 None。unwrap_or_else 会在 None 时执行闭包,打印用法并用非零退出码退出。非零退出码表示程序异常结束,这对命令行工具很重要,因为脚本和 CI 可以根据退出码判断命令是否成功。
这一步完成后,程序已经可以从命令行拿到目标字符串了。但现在拿到的是 String,而 icmp::ping 需要的是 ipv4::Addr。下一步就是把字符串解析成 IPv4 地址。
八、先写一个 ipv4::Addr::parse
最直觉的设计是给 ipv4::Addr 添加一个 parse 方法:
rust
impl Addr {
pub fn parse(s: String) -> Self {
unimplemented!()
}
}
这样主程序可以写成:
rust
fn main() {
let arg = env::args().nth(1).unwrap_or_else(|| {
println!("Usage: sup DEST");
exit(1);
});
let dest = ipv4::Addr::parse(arg);
icmp::ping(dest).expect("ping failed");
}
这个 API 先看起来很顺手,但签名有问题。String 是拥有所有权的类型,函数参数写成 String 意味着调用 Addr::parse(arg) 时,arg 的所有权会被移动进 parse。如果后面还想打印 arg,就会出现编译错误:
rust
let dest = ipv4::Addr::parse(arg);
println!("Just parsed {}", arg);
parse 其实不需要拥有这个字符串。它不会修改输入,也不会把输入保存起来,只需要短暂读取字符串内容。因此,参数更适合写成 &str:
rust
impl Addr {
pub fn parse(s: &str) -> Self {
unimplemented!()
}
}
这样调用时可以传 &arg:
rust
let dest = ipv4::Addr::parse(&arg);
这种写法避免了不必要的所有权转移。Rust 中经常需要做这种判断:函数是否真的需要拥有参数?如果只是读取,通常应该借用;如果需要保存或消费,才应该拿所有权。对 API 设计来说,参数类型不仅影响性能,也影响调用方能否继续使用原来的值。
九、用 AsRef<str> 同时支持 String 和 &str
&str 已经比 String 更合理,但还可以让 API 更灵活一点。很多时候,希望函数既能接收 &str,也能接收 String。Rust 标准库提供了 AsRef<str> trait,可以表达"这个类型可以被看作字符串切片"。
可以把 parse 写成泛型:
rust
impl Addr {
pub fn parse<S>(s: S) -> Self
where
S: AsRef<str>,
{
unimplemented!()
}
}
这样,Addr::parse("8.8.8.8") 可以工作,Addr::parse(arg) 也可以工作,Addr::parse(&arg) 也可以工作。函数内部只需要调用:
rust
let s = s.as_ref();
就能得到真正的 &str。
不过要注意,如果传入的是 String,所有权仍然会被移动进去。AsRef<str> 只是让函数可以接受多种类型,不会自动改变所有权规则。如果调用方还想在 parse 后继续使用原来的 String,仍然应该传 &arg。在当前主程序里,解析后不再需要原字符串,所以直接传 arg 也没问题。
这种设计在 Rust API 中很常见。类似地,很多文件路径相关 API 会接收 AsRef<Path>,这样既能传 &Path,也能传 PathBuf,还可以传字符串路径。AsRef 的价值是降低调用方负担,让函数接受一类可转换引用的输入,而不是固定死某个具体类型。
十、IPv4 地址解析不是一定成功的操作
实现解析时,最自然的思路是按点号分割字符串:
rust
impl Addr {
pub fn parse<S>(s: S) -> Self
where
S: AsRef<str>,
{
let tokens = s.as_ref().split(".");
unimplemented!()
}
}
IPv4 地址应该由四段组成,每段是一个 0 到 255 之间的十进制数字。比如 "8.8.8.8" 分割后得到四段:"8"、"8"、"8"、"8"。接下来要把这四个字符串转成 u8,再放进 [u8; 4]。
但一旦开始实现,就会发现 parse 不可能总是成功。输入可能是 "8.8.8",缺一段;也可能是 "8.8.8.8.231",多一段;也可能是 "hello",根本不是数字;还可能是 "300.1.1.1",数字能解析成整数,但超出了 u8 范围。既然输入可能非法,parse 就不应该返回 Self,而应该返回 Result<Self, Error>。
先定义一个错误类型:
rust
#[derive(Debug)]
pub enum ParseAddrError {
NotEnoughParts,
}
这里先只处理"不够四段"的情况。因为后面想用 unwrap() 或 expect() 调试 Result<T, E>,错误类型需要实现 Debug,所以加上 #[derive(Debug)]。
然后改 parse 签名:
rust
impl Addr {
pub fn parse<S>(s: S) -> Result<Self, ParseAddrError>
where
S: AsRef<str>,
{
let mut tokens = s.as_ref().split(".");
let a = tokens.next().ok_or(ParseAddrError::NotEnoughParts)?;
let b = tokens.next().ok_or(ParseAddrError::NotEnoughParts)?;
let c = tokens.next().ok_or(ParseAddrError::NotEnoughParts)?;
let d = tokens.next().ok_or(ParseAddrError::NotEnoughParts)?;
dbg!(a, b, c, d);
unimplemented!()
}
}
split(".") 返回的是迭代器,调用 next() 会推进迭代器内部状态,所以 tokens 必须声明为 mut。next() 返回 Option<&str>,因为迭代器可能已经没有下一项。ok_or(ParseAddrError::NotEnoughParts)? 的意思是:如果有下一项,就取出字符串;如果没有,就把 None 转成错误并提前返回。
dbg! 是一个非常方便的调试宏。它会打印表达式本身、表达式值,以及调用位置。临时观察变量时,它比手写 println! 更方便,但正式输出不应该依赖它。
十一、字符串段还要解析成 u8
现在拿到的 a、b、c、d 仍然是 &str。需要把它们转成 u8。Rust 的很多基本类型都实现了 FromStr,所以可以写:
rust
let a = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse::<u8>();
let b = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse::<u8>();
let c = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse::<u8>();
let d = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse::<u8>();
dbg!(a, b, c, d);
这段能编译,但打印出来会发现它们是 Ok(8) 这样的 Result<u8, ParseIntError>,而不是直接的 u8。这是正确的,因为不是所有字符串都能解析成 u8。比如 "abc" 不是数字,"999" 超出 u8 范围,这些都应该返回错误。
所以需要再加 ?:
rust
let a = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse::<u8>()?;
但这样又会遇到另一个编译错误。当前函数返回的是 Result<Self, ParseAddrError>,而 parse::<u8>() 返回的是 Result<u8, std::num::ParseIntError>。? 只能在错误类型可以转换时使用。也就是说,Rust 需要知道如何把 ParseIntError 转成我们自己的 ParseAddrError。
这时可以扩展错误类型:
rust
use std::num::ParseIntError;
#[derive(Debug)]
pub enum ParseAddrError {
NotEnoughParts,
ParseIntError(ParseIntError),
}
impl From<ParseIntError> for ParseAddrError {
fn from(e: ParseIntError) -> Self {
ParseAddrError::ParseIntError(e)
}
}
实现 From<ParseIntError> for ParseAddrError 之后,? 就能自动把 ParseIntError 转换成 ParseAddrError。这是 Rust 错误处理里的常见模式:底层错误通过 From 转成上层错误,函数体里用 ? 简洁传播。
这里还有一个需要注意的输入格式问题。虽然标准库的 u8::from_str 在这个场景下只接受十进制数字,但如果使用别的解析函数,可能会意外接受十六进制、科学计数法或其他格式。生产级 IPv4 解析不应该只凭"能转成整数"就算合法,应该确认解析规则和协议预期一致。否则可能悄悄接受不符合预期的输入,后续排查会很麻烦。
十二、减少重复代码:先试闭包
现在的代码重复度很高。四段地址的解析逻辑完全一样,只是执行四次。可以用一个闭包把"取下一段并解析成 u8"封装起来:
rust
impl Addr {
pub fn parse<S>(s: S) -> Result<Self, ParseAddrError>
where
S: AsRef<str>,
{
let mut tokens = s.as_ref().split(".");
let mut f = || -> Result<u8, ParseAddrError> {
Ok(tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse()?)
};
Ok(Self([f()?, f()?, f()?, f()?]))
}
}
这个闭包需要声明为 mut,因为它捕获了 tokens 的可变引用,每次调用都会推进迭代器状态。从 trait 角度看,它属于 FnMut。闭包返回类型也需要显式标注为 Result<u8, ParseAddrError>,否则编译器可能无法从 Ok(...) 和 ? 的组合中准确推断错误类型。
这版已经比重复写四段好很多,但仍然有一个问题:它不会拒绝多余字段。比如 "8.8.8.8.231",前四次 f() 会解析出 8.8.8.8,第五段 231 会被忽略。这显然不是一个严格的 IPv4 解析器。合法 IPv4 地址应该恰好四段,不能少,也不能多。
十三、更好的实现:遍历 [u8; 4]
为了同时处理四段并检查多余字段,可以先构造一个全 0 的地址,然后遍历内部数组的可变引用,逐个填入解析结果:
rust
#[derive(Debug)]
pub enum ParseAddrError {
NotEnoughParts,
TooManyParts,
ParseIntError(ParseIntError),
}
impl Addr {
pub fn parse<S>(s: S) -> Result<Self, ParseAddrError>
where
S: AsRef<str>,
{
let mut tokens = s.as_ref().split(".");
let mut res = Self([0, 0, 0, 0]);
for part in res.0.iter_mut() {
*part = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse()?;
}
if let Some(_) = tokens.next() {
return Err(ParseAddrError::TooManyParts);
}
Ok(res)
}
}
这版更好。res.0.iter_mut() 会依次拿到内部 [u8; 4] 的每个元素的可变引用。每次循环取出一个字符串段,解析成 u8,再写入当前元素。四次循环结束后,再调用一次 tokens.next(),如果还能拿到值,说明输入多于四段,就返回 TooManyParts。
这样,缺段、多段、数字解析失败都会被正确建模。缺段返回 NotEnoughParts,多段返回 TooManyParts,数字非法或超出 u8 范围返回 ParseIntError。相比闭包版,这个实现不仅减少重复,还修复了"多余段被忽略"的问题。
这个例子很好地说明了 Rust 里迭代器和可变引用的用法。iter_mut() 给的是数组元素的可变引用,写入时要用 *part = ... 解引用赋值。tokens.next() 需要 tokens 可变,因为每次取值都会改变迭代器当前位置。整个过程没有手写索引,也没有越界风险,逻辑非常直接。
十四、既然有 FromStr,就实现标准 trait
在实现 Addr::parse 的过程中,已经用到了 parse::<u8>()。这个方法背后依赖的是 std::str::FromStr trait。既然标准库已经有"从字符串解析成某个类型"的统一接口,那 ipv4::Addr 也应该实现它,而不是只提供一个自定义 parse 方法。
可以这样写:
rust
impl std::str::FromStr for Addr {
type Err = ParseAddrError;
fn from_str(s: &str) -> Result<Self, ParseAddrError> {
let mut tokens = s.split(".");
let mut res = Self([0, 0, 0, 0]);
for part in res.0.iter_mut() {
*part = tokens
.next()
.ok_or(ParseAddrError::NotEnoughParts)?
.parse()?;
}
if let Some(_) = tokens.next() {
return Err(ParseAddrError::TooManyParts);
}
Ok(res)
}
}
实现 FromStr 后,主程序不需要再写:
rust
let dest = ipv4::Addr::parse(arg).unwrap();
icmp::ping(dest).expect("ping failed");
而可以写成:
rust
icmp::ping(arg.parse().unwrap()).expect("ping failed");
这里的 arg.parse() 会根据上下文推断目标类型。如果 icmp::ping 的参数类型是 ipv4::Addr,编译器就知道 parse() 应该调用 Addr 的 FromStr 实现。
这就是实现标准 trait 的好处。自定义方法只能自己调用,标准 trait 能接入 Rust 生态的通用写法。命令行参数解析、配置解析、测试代码里,都可以用统一的 .parse() 形式。对使用者来说,"8.8.8.8".parse::<ipv4::Addr>() 也更符合 Rust 习惯。
十五、让 main 返回 Result
现在主程序中有两个可能失败的操作:第一个是把命令行字符串解析成 ipv4::Addr,第二个是执行 ping。之前可能会写 unwrap() 或 expect(),但 Rust 里也可以让 main 返回 Result,然后用 ? 把错误向上传递。
可以写成:
rust
use std::{env, error::Error, process::exit};
fn main() -> Result<(), Box<dyn Error>> {
let arg = env::args().nth(1).unwrap_or_else(|| {
println!("Usage: sup DEST");
exit(1);
});
icmp::ping(arg.parse()?)?;
Ok(())
}
这里看起来有两个连续的 ?,容易让新手困惑,但它们分别对应两个不同的失败点。arg.parse()? 负责把字符串解析成 ipv4::Addr,如果解析失败,就提前返回解析错误;icmp::ping(...)? 负责执行 ping,如果 ping 失败,就提前返回 ping 错误。最后 Ok(()) 表示程序正常结束。
为了让 ParseAddrError 能放进 Box<dyn Error>,它需要实现 std::error::Error。而实现 Error 的前提是实现 Debug 和 Display。Debug 已经通过 #[derive(Debug)] 有了,Display 可以先简单用 Debug 格式输出:
rust
use std::fmt;
impl fmt::Display for ParseAddrError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for ParseAddrError {}
这不是最漂亮的错误信息,但已经足够让错误类型进入标准错误处理体系。后续可以把 Display 改得更友好,比如 NotEnoughParts 输出"IPv4 address has fewer than four parts",TooManyParts 输出"IPv4 address has more than four parts",ParseIntError 输出具体数字解析失败原因。
Box<dyn Error> 是一种比较通用的错误返回方式。它允许 main 返回不同具体错误类型,只要这些类型实现了 Error。对于小工具来说,这样写非常方便;对于库代码,通常会定义更明确的错误枚举,让调用方可以精确匹配错误原因。
十六、当前版本的代码结构大概是什么样
整理到这里,项目已经从一个单文件实验,变成了更像程序的结构。main.rs 负责命令行入口,读取用户传入的目标地址,调用 parse() 得到 ipv4::Addr,再调用 icmp::ping。ipv4.rs 负责 IPv4 地址类型、调试输出、字符串解析和解析错误。icmp/mod.rs 负责对外暴露 ping API。icmp/icmp_sys.rs 负责 Win32 ICMP 结构体和函数包装。loadlibrary 模块则负责动态加载 DLL 和查找函数地址。
每一层的职责更清楚了。main 不再知道 IcmpSendEcho 的参数细节,也不再知道 IPv4 地址怎么拆成四段。icmp 不需要关心命令行参数怎么读取。ipv4 不需要知道 Windows ICMP API。icmp_sys 虽然还比较粗糙,但至少被藏在 icmp 模块内部,不会污染外部接口。
不过,当前版本仍然有不少问题。icmp_sys 每次调用都会重新打开 IPHLPAPI.dll 并查找函数地址,这会泄漏动态库 handle,也有性能浪费。IcmpCreateFile 返回的 ICMP handle 没有关闭,会泄漏 OS 资源。icmp::ping 仍然不解析响应,也不输出 RTT、TTL 和数据大小。Result<(), String> 也比较粗糙,错误类型后续应该改得更结构化。命令行参数也只支持最简单的一种形式,没有帮助信息、次数、超时、payload 长度等配置。
但这些问题并不妨碍这一篇的目标。当前重点不是把 ping 工具一次性做到完整,而是把项目拆成合理模块,并把硬编码地址改成命令行输入。模块边界一旦搭起来,后面处理资源释放、错误类型、输出格式和更多功能都会更容易。
十七、这一篇真正讲的是 Rust 的小型项目演进方式
这一篇虽然标题里有 ping 和 IPv4 解析,但真正重要的是小型 Rust 项目如何从"能跑"走向"能维护"。
第一步是模块拆分。ipv4::Addr 放到 ipv4 模块,ICMP 逻辑放到 icmp 模块,Win32 特有绑定放到私有 icmp_sys 模块。Rust 的可见性系统允许非常细粒度地控制哪些东西公开,哪些东西隐藏。对外 API 越干净,后续越不容易被底层实现绑死。
第二步是临时封装底层能力。IcmpCreateFile 和 IcmpSendEcho 现在的包装并不完美,但它让上层 icmp::ping 可以先成型。系统编程中经常需要接受阶段性不完美,只要能清楚标记问题,后面再重构即可。
第三步是命令行入口。std::env::args() 返回迭代器,可以用 collect() 一次性收集,也可以用 nth(1) 直接取需要的参数。缺少参数时,用 unwrap_or_else 打印用法并退出,这让程序开始有 CLI 工具的形态。
第四步是 API 参数设计。Addr::parse(s: String) 会拿走所有权,Addr::parse(s: &str) 更合理,而 Addr::parse<S: AsRef<str>>(s: S) 更灵活。这个过程体现了 Rust 中所有权和借用对 API 设计的影响。函数签名不是随便写的,它决定调用方是否方便,是否会发生不必要的移动。
第五步是失败建模。字符串解析 IPv4 地址一定可能失败,所以返回类型必须从 Self 改成 Result<Self, ParseAddrError>。缺段、多段、数字非法都应该变成明确错误,而不是 panic,也不是悄悄忽略。? 和 From 让不同错误类型可以自然转换并传播。
第六步是实现标准 trait。自定义 Addr::parse 能用,但实现 FromStr 后,arg.parse()? 就能直接工作。Rust 标准库里很多 trait 都是这种"接入通用生态"的接口。给自定义类型实现合适的标准 trait,往往比写一堆自定义方法更自然。
第七步是让 main 返回 Result。当程序里多个操作都可能失败时,继续堆 unwrap 会很快变乱。让 main 返回 Result<(), Box<dyn Error>>,再用 ? 传播错误,是小型命令行工具里非常实用的写法。
十八、总结
这一篇没有继续深入网络协议,也没有增加复杂 ICMP 功能,而是把重点放在项目结构和输入处理上。前面已经能用 Rust 调 Windows ICMP API 发出 ping 请求,但代码还像一个实验脚本。通过这一篇,项目开始拆出 ipv4、icmp、icmp_sys 等模块,底层 Win32 细节被藏到私有模块里,对外只保留更简单的 icmp::ping 接口。
同时,硬编码 IP 地址被命令行参数替代。为了完成这一点,需要理解 std::env::args()、迭代器、nth(1)、unwrap_or_else 和退出码;还需要把字符串解析成 IPv4 地址。解析过程中又引出了 String 和 &str 的所有权差异,AsRef<str> 的灵活参数设计,split 和迭代器状态,Option 到 Result 的转换,ParseAddrError 的设计,ParseIntError 的转换,闭包的 FnMut 特性,以及更好的数组遍历实现。
最后,通过实现 std::str::FromStr,ipv4::Addr 变成了一个更符合 Rust 习惯的类型。命令行入口也可以写成 icmp::ping(arg.parse()?)?;,两个 ? 分别处理地址解析失败和 ping 执行失败。为了让错误能统一向上传递,又给 ParseAddrError 实现了 Display 和 Error。
从功能角度看,当前版本仍然很粗糙:不关闭 ICMP handle,不释放动态库资源,不解析 reply,不输出 ping 结果,错误类型也很简单。但从结构角度看,它已经向前迈了一大步。一个能跑的底层实验,正在被整理成一个有模块、有入口、有解析、有错误处理的小型命令行程序。
这正是 Rust 系统编程中很典型的演进路径:先把 unsafe 和 FFI 调通,再把底层细节封装起来;先让功能能跑,再逐步把类型、错误、模块和 API 设计补齐。写 ping 只是表层任务,真正学到的是如何用 Rust 把一个靠近操作系统边界的程序整理成可维护的结构。