本文是对 The builder pattern, and a macro that keeps FFI code DRY 的整理与翻译。
内容结构概览
- 当前 ping API 的问题:只能传目标地址,不能设置 TTL、超时、payload,也拿不到响应信息。
- 为什么不用一堆位置参数 :
ping(dest, 128, 4000, data)可读性差,也失去了默认值。 - 为什么不用一堆
Option参数 :ping(dest, Some(128), Some(4000), None)更难读。 - 引入 builder pattern :把
ping(dest)改成Request::new(dest).ttl(...).timeout(...).data(...).send()。 send(self)为什么消费请求:发送后请求不应再被修改或复用。Into<Vec<u8>>的作用 :让.data("Pretty cool!")这种调用更自然。- 修复 ICMP handle 泄漏 :新增
IcmpCloseHandle,发送完成后关闭 handle。 - 动态加载函数重复问题 :
IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle不该每次都重新加载 DLL。 - 用
Functions聚合三个 FFI 函数 :一次加载IPHLPAPI.dll,一次性取出所有函数指针。 - 用
once_cell::sync::Lazy做懒初始化单例:首次调用时加载 DLL,后续复用。 - 用宏消除重复 FFI 绑定代码:生成函数指针结构体、懒初始化静态变量和包装函数。
- 让 ping 返回
Reply:不再只返回Result<(), E>,而是返回地址、数据、RTT、TTL。 - 在 main 中循环 ping 四次 :打印类似
Reply from ... bytes=... time=... TTL=...的输出。 - 总结与下一步:目前仍依赖 Windows API 代发 ICMP,后面会继续往更底层走。
前面几篇已经把自制 ping 工具推进到了一个能工作的阶段。程序可以从命令行读取 IPv4 地址,把字符串解析成 ipv4::Addr,再通过 Windows 的 IPHLPAPI.dll 调用 IcmpCreateFile 和 IcmpSendEcho,向目标地址发送 ICMP Echo 请求。代码已经不再是单文件实验,而是拆出了 ipv4、icmp、icmp_sys、loadlibrary 等模块。
但上一版 API 仍然很简陋。它大概长这样:
rust
pub fn ping(dest: ipv4::Addr) -> Result<(), String>
调用方式也很简单:
rust
ping(ipv4::Addr([8, 8, 8, 8])).unwrap();
这当然能工作,但限制非常明显:不能指定 TTL,不能指定超时时间,不能指定要随 ICMP Echo 一起发送的数据,也拿不到响应里的任何信息。一个真正像样的 ping 工具至少应该能知道往返时间、返回数据长度、TTL 等信息。如果 API 只能告诉你"成功或失败",那它还只是一个最小实验品。
这一篇要解决两个问题。第一个问题是 API 设计:怎样让 ping 请求既能保留默认参数,又能让调用方按需配置 TTL、timeout 和 payload,而且调用处还要清楚可读。第二个问题是 FFI 代码重复:icmp_sys 里每个 Win32 函数都要手写一遍动态加载逻辑,既啰嗦又容易出错。解决第一个问题会引出 builder pattern,解决第二个问题会引出 once_cell::sync::Lazy 和 Rust 宏。
一、为什么不能简单增加参数
为了让 ping 支持更多配置,最直接的方式是把所有参数都塞进函数签名:
rust
pub fn ping(
dest: ipv4::Addr,
ttl: u8,
timeout: u32,
data: Vec<u8>,
) -> Result<(), String>
这样功能是完整了一些,但调用处很快变难读:
rust
ping(
ipv4::Addr([8, 8, 8, 8]),
128,
4000,
"Some data".into(),
)
对熟悉网络的人来说,128 看起来像 TTL,4000 看起来像超时时间,因为 128 能放进 u8,4000 更像毫秒数。但这依赖读者有上下文知识。只看调用点,很难立刻判断这些数字分别表示什么。即使 IDE 能提示参数类型,u8 和 u32 本身也不能表达"这是 TTL"或"这是 timeout"。
位置参数在参数少的时候很自然。一旦超过两三个,而且几个参数类型又相似,调用处就会变得不直观。比如 ping(dest, 64, 1000, data) 和 ping(dest, 1000, 64, data) 看起来都像合法代码,但语义完全不同。编译器只能检查类型,不能理解业务含义。
更麻烦的是,增加必填参数后就失去了默认值。上一版里 TTL 可以默认 128,timeout 可以默认 4000 毫秒,payload 可以默认空数据。现在每个调用方都必须显式传这些值,即使它们完全接受默认配置。这让 API 使用成本变高,也让调用代码充满无意义参数。
二、为什么一堆 Option 也不好
为了保留默认值,可以把这些参数改成 Option:
rust
pub fn ping(
dest: ipv4::Addr,
ttl: Option<u8>,
timeout: Option<u32>,
data: Option<Vec<u8>>,
) -> Result<(), String>
这样调用方想设置 TTL 就传 Some(128),想使用默认值就传 None。函数内部可以用 unwrap_or 或 unwrap_or_default 填默认值。这个方案从功能上可行,但调用体验更差:
rust
ping(
ipv4::Addr([8, 8, 8, 8]),
Some(128),
Some(4000),
Some("Some data".into()),
)
如果全部使用默认值,则变成:
rust
ping(
ipv4::Addr([8, 8, 8, 8]),
None,
None,
None,
)
这段代码几乎没有可读性。None, None, None 到底分别代表什么?不看函数签名根本不知道。它虽然保留了默认值,却让调用处更像谜语。
这类 API 的问题在于,它把"参数名"这个重要信息从调用点拿掉了。Rust 没有命名参数,所以不能写成类似 ping(dest, ttl = 128, timeout = 4000) 的形式。要想同时拥有默认值和可读调用,就需要另一种设计方式。
这就是 builder pattern 的用武之地。
三、把 ping 请求建模成 Request
与其把所有参数都挤进一个函数,不如先引入一个结构体表示 ICMP 请求。请求本身有一个必填字段:目标地址。其他字段都有合理默认值:TTL 默认 128,超时默认 4000 毫秒,payload 默认空。
可以先定义一个 Request:
rust
pub struct Request {
dest: ipv4::Addr,
}
目标地址放在私有字段里。外部代码可以通过构造函数创建请求,但不能随意修改内部字段:
rust
impl Request {
pub fn new(dest: ipv4::Addr) -> Self {
Self { dest }
}
}
接着,把原来的 ping(dest) 移到 Request::send():
rust
impl Request {
pub fn send(&self) -> Result<(), String> {
// 原来的 ping 逻辑
// 只是这里使用 self.dest
}
}
这样主程序就可以改成:
rust
icmp::Request::new(arg.parse()?).send()?;
不过这里马上会遇到一个 Rust 所有权问题。send(&self) 里要把 self.dest 传给 IcmpSendEcho,而 ipv4::Addr 如果没有实现 Copy,就不能从 &self 里移动出去。解决方式很简单:IPv4 地址只是 4 个字节,复制成本极低,可以给它派生 Clone 和 Copy:
rust
#[derive(Clone, Copy)]
pub struct Addr(pub [u8; 4]);
这样 self.dest 就可以被复制传递,而不是移动。对于 IPv4 地址这种小值类型,Copy 很自然;但对于包含堆内存、文件句柄、socket 之类资源的类型,就不能随便实现 Copy,否则会破坏资源所有权。
四、给 Request 加默认配置
现在可以把 TTL、timeout 和 data 放进 Request:
rust
pub struct Request {
dest: ipv4::Addr,
ttl: u8,
timeout: u32,
data: Option<Vec<u8>>,
}
Request::new 初始化默认值:
rust
impl Request {
pub fn new(dest: ipv4::Addr) -> Self {
Self {
dest,
ttl: 128,
timeout: 4000,
data: None,
}
}
}
这样,目标地址仍然是唯一必填参数,其他字段都有默认值。send() 里使用这些字段:
rust
impl Request {
pub fn send(self) -> Result<(), String> {
let handle = icmp_sys::IcmpCreateFile();
let data = self.data.unwrap_or_default();
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 ip_options = icmp_sys::IpOptionInformation {
ttl: self.ttl,
tos: 0,
flags: 0,
options_data: 0,
options_size: 0,
};
match icmp_sys::IcmpSendEcho(
handle,
self.dest,
data.as_ptr(),
data.len() as u16,
Some(&ip_options),
reply_buf.as_mut_ptr(),
reply_buf_size as u32,
self.timeout,
) {
0 => Err("IcmpSendEcho failed :(".to_string()),
_ => Ok(()),
}
}
}
这里有两个细节值得注意。第一,send 改成接收 self,而不是 &self。这意味着调用 send() 会消费整个请求。请求一旦发出,就不能再被修改,也不能再次发送。这个设计能避免"发送之后继续改配置"之类的错误。对于 builder pattern 来说,消费式 API 很常见:一步步构造,最后调用终结方法消耗构建好的对象。
第二,self.data.unwrap_or_default() 用来处理可选 payload。如果调用方没有设置 data,Option<Vec<u8>> 是 None,unwrap_or_default() 会返回 Vec<u8> 的默认值,也就是空向量。如果设置了 data,就取出已有向量。因为 send(self) 已经拿到了整个请求的所有权,所以可以直接移动 self.data,不需要克隆。
五、普通 setter 可用,但不够优雅
现在 Request 里有了私有字段,外部不能直接改 TTL、timeout 和 data。可以先提供传统 setter:
rust
impl Request {
pub fn ttl(&mut self, ttl: u8) {
self.ttl = ttl;
}
}
调用方式是:
rust
let mut req = icmp::Request::new(arg.parse()?);
req.ttl(60);
req.send()?;
这当然能用。如果继续添加 timeout 和 data setter,就会变成:
rust
let mut req = icmp::Request::new(arg.parse()?);
req.ttl(60);
req.timeout(2000);
req.data("Oh hey".into());
req.send()?;
问题是,这种写法不够流畅。它要求变量必须是 mut,而且配置步骤分散在多行赋值式调用中。对于少量配置还行,配置多了之后就显得啰嗦。更重要的是,它没有把"构建请求"这件事表达成一个连续过程。
builder pattern 更常见的写法是:setter 接收 self,修改字段后再返回 Self。也就是说,方法会消费当前请求,然后返回修改后的请求:
rust
impl Request {
pub fn ttl(mut self, ttl: u8) -> Self {
self.ttl = ttl;
self
}
}
调用处变成:
rust
Request::new(dest)
.ttl(60)
.send()?;
继续给 timeout 加同样方法:
rust
impl Request {
pub fn timeout(mut self, timeout: u32) -> Self {
self.timeout = timeout;
self
}
}
再给 data 加一个方法。不过 data 不希望调用方每次都手动 .into(),所以可以让它接收任何能转换成 Vec<u8> 的类型:
rust
impl Request {
pub fn data<D>(mut self, data: D) -> Self
where
D: Into<Vec<u8>>,
{
self.data = Some(data.into());
self
}
}
这样调用处就可以写成:
rust
use icmp::Request;
let dest = arg.parse()?;
Request::new(dest)
.ttl(60)
.timeout(1000)
.data("Pretty cool!")
.send()?;
这就是 builder pattern 在这里的价值。它保留了默认值,也让非默认配置在调用处有名字。.ttl(60) 比第二个位置参数 60 清楚得多;.timeout(1000) 比第三个位置参数 1000 清楚得多;.data("Pretty cool!") 也比 Some("Pretty cool!".into()) 更自然。
六、builder pattern 解决了哪些问题
builder pattern 不是为了写得花哨,而是解决一类常见 API 设计问题:参数很多、部分参数有默认值、调用处需要可读性。
直接用位置参数会导致调用处难读,尤其是多个参数类型相似时。用 Option 参数虽然支持默认值,但会出现 None, None, None 这种不明所以的调用。builder pattern 则把每个可选配置变成带名字的方法。默认值由 new() 提供,调用方只设置自己关心的字段。
send(self) 消费请求也很重要。它让请求有一个清晰生命周期:先创建,再配置,最后发送。发送后对象被移动,不能继续复用。这可以避免一些"发送后继续修改"的逻辑错误。Rust 的所有权系统和 builder pattern 配合得很好,因为 self -> Self 的链式调用天然表达了值在不同步骤之间移动。
Into<Vec<u8>> 则提升了调用体验。内部需要的是 Vec<u8>,但调用方可能有字符串字面量、String、Vec<u8> 或其他能转换成字节向量的类型。让 data 接收 D: Into<Vec<u8>>,调用方就可以传更自然的值,转换细节留给方法内部。
这一版 API 已经比上一节好很多。主程序不需要知道 Request 内部字段,也不需要传一堆位置参数。它只是在描述一个请求:目标是谁,TTL 是多少,超时多久,附带什么数据,然后发送。
七、修复 ICMP handle 泄漏
上一节留下了一个问题:IcmpCreateFile 创建出来的 handle 没有关闭。Windows API 通常要求成对管理资源,创建出来的 handle 应该在不用时关闭。ICMP API 中对应的函数是 IcmpCloseHandle。
在 icmp_sys 中先添加它:
rust
type IcmpCloseHandle = extern "stdcall" fn(handle: Handle);
pub fn IcmpCloseHandle(handle: Handle) {
let iphlp = Library::new("IPHLPAPI.dll").unwrap();
let IcmpCloseHandle: IcmpCloseHandle = unsafe {
iphlp.get_proc("IcmpCloseHandle").unwrap()
};
IcmpCloseHandle(handle)
}
然后在 Request::send() 中,调用 IcmpSendEcho 后关闭 handle:
rust
let ret = icmp_sys::IcmpSendEcho(
handle,
self.dest,
data.as_ptr(),
data.len() as u16,
Some(&ip_options),
reply_buf.as_mut_ptr(),
reply_buf_size as u32,
self.timeout,
);
icmp_sys::IcmpCloseHandle(handle);
match ret {
0 => Err("IcmpSendEcho failed :(".to_string()),
_ => Ok(()),
}
这解决了 ICMP handle 泄漏问题,但 icmp_sys 的代码仍然很别扭。IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle 每个包装函数都要重复加载 IPHLPAPI.dll,重复查找函数地址。更糟糕的是,每次调用这些函数都会重新打开 DLL。虽然 Windows 可能内部有引用计数和缓存,但从代码设计看,这显然不是理想方案。
这就进入第二个主题:如何让 FFI 动态加载代码不再重复。
八、把多个 FFI 函数组织到一个 Functions 结构里
当前感兴趣的 IPHLPAPI.dll 函数有三个:
text
IcmpCreateFile
IcmpSendEcho
IcmpCloseHandle
它们来自同一个 DLL,应该被视为一组。理想情况下,程序运行时只打开一次 IPHLPAPI.dll,一次性取出这三个函数地址,后面所有调用都复用这组函数指针。
可以定义一个结构体来保存这些函数指针:
rust
pub struct Functions {
pub IcmpCreateFile: extern "stdcall" fn() -> Handle,
pub 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,
pub IcmpCloseHandle: extern "stdcall" fn(handle: Handle),
}
然后给它实现一个 get():
rust
impl Functions {
pub fn get() -> Self {
let iphlp = Library::new("IPHLPAPI.dll").unwrap();
Self {
IcmpCreateFile: unsafe {
iphlp.get_proc("IcmpCreateFile").unwrap()
},
IcmpSendEcho: unsafe {
iphlp.get_proc("IcmpSendEcho").unwrap()
},
IcmpCloseHandle: unsafe {
iphlp.get_proc("IcmpCloseHandle").unwrap()
},
}
}
}
这样 Request::send() 可以先拿到函数集合:
rust
let fns = icmp_sys::Functions::get();
let handle = (fns.IcmpCreateFile)();
let ret = (fns.IcmpSendEcho)(
handle,
self.dest,
data.as_ptr(),
data.len() as u16,
Some(&ip_options),
reply_buf.as_mut_ptr(),
reply_buf_size as u32,
self.timeout,
);
(fns.IcmpCloseHandle)(handle);
这个版本已经比之前好。至少 IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle 三个函数在一次 Functions::get() 中一起加载,代码也被组织到同一个结构体里。但问题还没有完全解决:每次调用 Functions::get(),仍然会重新打开 DLL 并查找三个符号。
更理想的是,Functions 应该是一个单例。第一次真正需要这些函数时初始化,之后所有调用都复用同一个实例。
九、为什么不能直接写普通 static
最自然的想法是定义一个静态变量:
rust
pub static FUNCTIONS: Functions = Functions::get();
但这不能工作。Rust 的普通 static 初始化要求在编译期或 const 上下文中完成,而 Functions::get() 需要运行时调用 LoadLibrary、GetProcAddress,这些显然不是编译期常量表达式。动态加载 DLL 只能在程序运行时发生。
这类需求通常叫"懒初始化"。也就是说,不是在程序启动时或编译期初始化,而是在第一次使用时初始化;初始化完成后,后续访问直接复用。Rust 生态中常见方案包括 lazy_static 和 once_cell。这里使用的是 once_cell::sync::Lazy。
添加 once_cell 依赖后,可以这样写:
rust
use once_cell::sync::Lazy;
pub static FUNCTIONS: Lazy<Functions> =
Lazy::new(|| Functions::get());
再把 Functions::get() 改成私有函数:
rust
impl Functions {
fn get() -> Self {
// LoadLibrary + GetProcAddress
}
}
这样外部不能再随便调用 Functions::get(),只能通过 FUNCTIONS 访问这组懒初始化函数指针。Request::send() 中也可以改成:
rust
let fns = &icmp_sys::FUNCTIONS;
然后继续调用:
rust
let handle = (fns.IcmpCreateFile)();
once_cell::sync::Lazy 会确保 FUNCTIONS 在第一次使用时初始化,并且在多线程场景下也能安全处理。对于这种"从同一个 DLL 加载一组函数指针"的场景,它非常合适。
十、字段函数调用为什么要加括号
使用 Functions 后,有一个小细节比较烦:
rust
let handle = (icmp_sys::FUNCTIONS.IcmpCreateFile)();
为什么函数名前面要加括号?因为 IcmpCreateFile 不是方法,而是结构体字段。字段里保存的是一个函数指针。Rust 需要先取出这个字段,再把它当函数调用。写成:
rust
icmp_sys::FUNCTIONS.IcmpCreateFile()
会被理解成方法调用,而不是调用字段里的函数指针,所以不符合语法。加上括号后,含义就是"先访问字段,再调用这个函数指针"。
这虽然只是语法问题,但读起来不太舒服。而且还有一个设计问题:为什么外部要通过 icmp_sys::FUNCTIONS.IcmpCreateFile 调用?从使用者角度看,更自然的是直接写:
rust
icmp_sys::IcmpCreateFile()
icmp_sys::IcmpSendEcho(...)
icmp_sys::IcmpCloseHandle(...)
也就是说,希望 icmp_sys 看起来像一个普通模块,直接导出函数;但内部实现仍然使用 once_cell 缓存函数指针。要同时满足这两个要求,可以手写包装函数,但三个函数都要写一遍,重复仍然很多。这里就可以用宏来生成重复代码。
十一、用宏生成 FFI 绑定样板代码
理想的调用方式是写一个宏:
rust
bind! {
fn IcmpCreateFile() -> Handle;
fn IcmpCloseHandle(handle: Handle) -> ();
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;
}
希望这个宏自动生成三类东西:
第一,声明一个 Functions 结构体,里面有同名函数指针字段。
第二,声明一个 once_cell::sync::Lazy<Functions> 静态变量,第一次使用时加载 IPHLPAPI.dll 并查找所有函数。
第三,为每个函数生成一个同名包装函数。外部调用 icmp_sys::IcmpSendEcho(...),内部实际转发到 FUNCTIONS.IcmpSendEcho。
可以先写一个简化版宏:
rust
macro_rules! bind {
($(fn $name:ident($($arg:ident: $type:ty),*) -> $ret:ty;)*) => {
struct Functions {
$(
pub $name: extern "stdcall" fn(
$($arg: $type),*
) -> $ret
),*
}
$(
pub fn $name($($arg: $type),*) -> $ret {
unimplemented!()
}
)*
};
}
这个宏语法看起来复杂,但可以拆开理解:
rust
$( ... )*
表示重复匹配零次或多次。里面的模式:
rust
fn $name:ident($($arg:ident: $type:ty),*) -> $ret:ty;
匹配一条函数声明。$name:ident 匹配函数名,$arg:ident 匹配参数名,$type:ty 匹配参数类型,$ret:ty 匹配返回类型。外层重复表示可以一次写多条函数声明。宏展开时,会为每条声明生成对应结构体字段和包装函数。
调试宏展开时,可以使用 cargo-expand。它能显示宏展开后的 Rust 代码,非常适合检查宏是否生成了预期内容。安装后可以运行类似命令查看某个模块展开结果:
text
cargo install cargo-expand
rustup toolchain add nightly
cargo expand icmp::icmp_sys
当简化版宏能正确生成结构体和函数壳子后,就可以把完整逻辑填进去。
十二、完整宏:生成 Functions、Lazy 和包装函数
完整宏大致如下:
rust
macro_rules! bind {
($(fn $name:ident($($arg:ident: $type:ty),*) -> $ret:ty;)*) => {
struct Functions {
$(
pub $name: extern "stdcall" fn(
$($arg: $type),*
) -> $ret
),*
}
static FUNCTIONS: once_cell::sync::Lazy<Functions> =
once_cell::sync::Lazy::new(|| {
let lib = crate::loadlibrary::Library::new("IPHLPAPI.dll")
.unwrap();
Functions {
$(
$name: unsafe {
lib.get_proc(stringify!($name)).unwrap()
}
),*
}
});
$(
#[inline(always)]
pub fn $name($($arg: $type),*) -> $ret {
(FUNCTIONS.$name)($($arg),*)
}
)*
};
}
这里有几个重要点。
stringify!($name) 会把标识符变成字符串。比如 $name 是 IcmpSendEcho,stringify!($name) 就是 "IcmpSendEcho"。这样就不用手写函数名字符串,也避免了声明名和查找名不一致的复制粘贴错误。
Lazy::new 内部只打开一次 IPHLPAPI.dll,然后把每个函数名查出来,存到 Functions 结构体里。之后包装函数调用时,只是访问 FUNCTIONS 并调用字段里的函数指针。
#[inline(always)] 表示希望编译器尽量内联这些包装函数。包装函数只是转发一层,理论上内联后开销很小。不过这只是优化提示,真正是否内联仍由编译器决定。
这个宏把重复样板压缩到了一个地方。以后如果要再绑定一个 IPHLPAPI.dll 的函数,只需要在 bind! 块里加一条声明,宏会自动生成对应字段、加载逻辑和包装函数。这样既减少重复,也减少手写错误。
需要注意的是,这个宏把 "IPHLPAPI.dll" 写死了。如果想做得更通用,可以把 DLL 名称也做成宏参数。但当前只服务于 ICMP 模块,硬编码在这里可以接受。
十三、宏让 icmp_sys 像普通模块一样可用
引入宏之后,icmp_sys 的使用方式回到了理想状态。上层 icmp 模块不需要知道 Functions,不需要知道 FUNCTIONS,也不需要给函数字段加括号。它只需要正常调用:
rust
let handle = icmp_sys::IcmpCreateFile();
let ret = icmp_sys::IcmpSendEcho(
handle,
self.dest,
data.as_ptr(),
data.len() as u16,
Some(&ip_options),
reply_buf.as_mut_ptr(),
reply_buf_size as u32,
self.timeout,
);
icmp_sys::IcmpCloseHandle(handle);
从外部看,这就像普通 Rust 函数。内部实际发生的是:第一次调用时 Lazy 初始化,打开 IPHLPAPI.dll,查找 IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle,保存函数指针;之后每次调用都复用这些函数指针。
这就是宏在这里的价值。它不是为了炫技,而是为了让一类高度重复且容易写错的代码保持一致。FFI 绑定往往有很多相似函数,如果每个函数都手写"函数指针类型、GetProcAddress、包装函数",出错概率会越来越高。宏把重复结构集中成一个模板,声明本身成为唯一信息源。
十四、现在 ping 还只返回成功或失败
API 变漂亮了,FFI 动态加载也更干净了,但 Request::send() 仍然只返回:
rust
Result<(), String>
也就是说,它只能告诉调用方"成功了"或者"失败了"。可是 ping 工具真正想展示的是响应信息。Windows 自带 ping.exe 会显示类似内容:来自哪个地址的回复、返回多少字节、耗时多少、TTL 多少。
为了模拟这种输出,可以先在 main 里循环四次:
rust
fn main() -> Result<(), Box<dyn Error>> {
let arg = env::args().nth(1).unwrap_or_else(|| {
println!("Usage: sup DEST");
exit(1);
});
use icmp::Request;
let dest = arg.parse()?;
let data = "O Romeo.";
println!();
println!(
"Pinging {:?} with {} bytes of data:",
dest,
data.len()
);
use std::{thread::sleep, time::Duration};
for _ in 0..4 {
match Request::new(dest)
.ttl(128)
.timeout(4000)
.data(data)
.send()
{
Ok(_) => println!(
"Reply from {:?}: bytes={} time=? TTL=?",
dest,
data.len()
),
Err(_) => println!("Something went wrong"),
}
sleep(Duration::from_secs(1));
}
println!();
Ok(())
}
这个版本输出结构已经接近 ping,但 time 和 TTL 还是问号。原因很简单:send() 没有把响应信息返回出来。要填上这些信息,就要把前面在第 3 篇里解析过的 IcmpEchoReply 用起来。
十五、不能直接返回 Win32 的 IcmpEchoReply
icmp_sys 里已经定义过 Windows 结构体:
rust
#[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,
}
看起来可以直接让 Request::send() 返回它。但这样不合适,原因有两个。
第一,它属于 icmp_sys 模块。这个模块本来就是内部实现细节,不应该把 Win32 类型泄漏到公开 API。外部用户不应该依赖 IcmpEchoReply 的字段布局,也不应该知道 Windows C API 的结构体长什么样。
第二,它包含裸指针字段 data: *const u8。这个指针指向 reply buffer 内部的数据区域,而 reply buffer 是 send() 里的局部变量。如果直接把 IcmpEchoReply 返回出去,里面的 data 指针很可能指向已经释放的内存。这会制造悬垂指针,属于严重安全问题。
正确做法是定义一个属于 icmp 模块的安全 Reply 类型,只挑选外部真正关心的字段,并把数据复制成拥有所有权的 Vec<u8>:
rust
use std::time::Duration;
pub struct Reply {
pub addr: ipv4::Addr,
pub data: Vec<u8>,
pub rtt: Duration,
pub ttl: u8,
}
这个类型没有裸指针。addr 是返回地址,data 是响应带回来的 payload,rtt 是往返时间,使用 std::time::Duration 表示,ttl 是响应中的 TTL。这样对外 API 更安全,也更符合 Rust 风格。
十六、让 Request::send() 返回 Reply
现在可以把 send() 的返回值从 Result<(), String> 改成:
rust
pub fn send(self) -> Result<Reply, String>
调用 IcmpSendEcho 后,如果返回 0,仍然表示失败;如果成功,就从 reply_buf 中解析出 IcmpEchoReply 和数据。核心代码大致如下:
rust
pub fn send(self) -> Result<Reply, String> {
let handle = icmp_sys::IcmpCreateFile();
let data = self.data.unwrap_or_default();
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 ip_options = icmp_sys::IpOptionInformation {
ttl: self.ttl,
tos: 0,
flags: 0,
options_data: 0,
options_size: 0,
};
let ret = icmp_sys::IcmpSendEcho(
handle,
self.dest,
data.as_ptr(),
data.len() as u16,
Some(&ip_options),
reply_buf.as_mut_ptr(),
reply_buf_size as u32,
self.timeout,
);
icmp_sys::IcmpCloseHandle(handle);
match ret {
0 => Err("IcmpSendEcho failed :(".to_string()),
_ => {
let reply: &icmp_sys::IcmpEchoReply = unsafe {
transmute(&reply_buf[0])
};
let data: Vec<u8> = unsafe {
let data_ptr: *const u8 =
transmute(&reply_buf[reply_size + 8]);
slice::from_raw_parts(
data_ptr,
reply.data_size as usize,
)
}
.into();
Ok(Reply {
addr: reply.address,
data,
rtt: Duration::from_millis(reply.rtt as u64),
ttl: reply.options.ttl,
})
}
}
}
这段代码继续沿用第 3 篇学到的 reply buffer 布局。IcmpSendEcho 写入的缓冲区并不只是一个结构体,而是结构体、额外 8 字节和返回数据的组合。缓冲区开头可以解释为 IcmpEchoReply,返回数据从 reply_size + 8 开始,长度由 reply.data_size 指定。
这里仍然有 unsafe,因为把字节缓冲区解释成结构体、把某个字节位置解释成指针、从裸指针构造 slice 都是编译器无法自动证明安全的操作。好在这些 unsafe 仍然被限制在 send() 内部。对外返回的 Reply 是安全结构体,不包含裸指针。
rtt 使用 Duration::from_millis(reply.rtt as u64)。这比直接返回 u32 更有语义。Duration 自带 Debug 输出,会根据时间长度显示合适单位,比如毫秒、微秒等。对调用方来说,它比一个裸整数更清楚。
十七、在 main 中打印真正的响应信息
send() 返回 Reply 后,主程序就可以填上之前的问号:
rust
for _ in 0..4 {
match Request::new(dest)
.ttl(128)
.timeout(4000)
.data(data)
.send()
{
Ok(res) => println!(
"Reply from {:?}: bytes={} time={:?} TTL={}",
res.addr,
res.data.len(),
res.rtt,
res.ttl,
),
Err(_) => println!("Something went wrong"),
}
sleep(Duration::from_secs(1));
}
现在输出就更像一个真正的 ping 工具了。它会连续发送四次请求,每次等待一秒,并打印返回地址、返回数据长度、往返时间和 TTL。虽然还没有像 Windows ping.exe 那样统计最小、最大、平均 RTT,也没有统计丢包率,但主体体验已经接近了。
这一步也说明了为什么要返回结构化 Reply。如果 send() 只返回 Ok(()),上层永远无法做更多事情;一旦返回 Reply,上层就可以自由决定如何展示、统计或记录结果。API 的返回值决定了后续能力的上限。
十八、这一篇真正学到的东西
这一篇表面上在继续写 ping,实际学到的是 Rust API 设计、资源管理、懒初始化和宏的组合使用。
首先,builder pattern 是解决多可选参数 API 的好工具。它让 Request::new(dest).ttl(60).timeout(1000).data("...").send() 这种调用既有默认值,又保持可读性。相比位置参数和一堆 Option,builder pattern 更适合参数逐渐变多的场景。
其次,接收 self 可以表达消费语义。send(self) 表示请求发送后不再可用,避免发送后继续修改配置。配合 builder pattern,整个对象的生命周期非常清楚:创建、配置、发送、结束。
再次,Into<Vec<u8>> 让 API 更友好。内部真正需要的是 Vec<u8>,但调用方不应该总是手动 .into()。用泛型约束表达"任何能转成字节向量的类型都可以传进来",可以减少调用负担。
第四,系统资源要成对管理。IcmpCreateFile 创建 handle,使用后就应该调用 IcmpCloseHandle。上一节为了跑通功能暂时忽略了释放,这一篇补上了。后续还可以继续改进,用 RAII 和 Drop 封装 handle,让资源释放自动发生。
第五,once_cell::sync::Lazy 适合懒初始化全局状态。动态加载 DLL 和查找函数地址必须在运行时做,不能用普通 static 直接初始化。Lazy 可以做到首次使用时初始化,后续复用,非常适合"某个 DLL 的一组函数指针"这种场景。
第六,宏可以减少 FFI 绑定样板代码。IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle 的绑定结构很相似,如果全部手写,就会重复大量代码。宏把"函数声明"变成唯一信息源,再自动生成结构体、懒初始化变量和包装函数,减少复制粘贴错误。
第七,公开 API 不应该暴露不安全内部结构。Win32 的 IcmpEchoReply 包含裸指针,不适合直接返回。对外定义安全的 Reply,把数据复制成 Vec<u8>,把 RTT 转成 Duration,是更合理的接口设计。
十九、当前版本还有哪些不足
到这里,sup 已经越来越像真正的 ping 工具。它有命令行参数,有可配置请求,有连续发送,有响应输出,有 RTT 和 TTL。但它仍然不完整。
第一,还没有统计信息。真正的 ping 通常会在结束时打印发送多少包、收到多少包、丢失多少包、最小 RTT、最大 RTT、平均 RTT。当前版本只是循环四次并逐行打印结果。
第二,错误处理仍然比较粗糙。Request::send() 返回 Result<Reply, String>,失败时只是 "IcmpSendEcho failed :("。真实工具应该区分超时、目标不可达、权限不足、缓冲区不足、系统 API 调用失败等情况,并尽量从 Windows 错误码中提取原因。
第三,资源管理还能继续改进。现在虽然调用了 IcmpCloseHandle,但如果中间某一步 panic 或提前返回,handle 是否一定关闭还需要更严格设计。更 Rust 的做法是把 handle 包成一个类型,在 Drop 里自动关闭。
第四,宏现在绑定的是固定 DLL "IPHLPAPI.dll"。如果未来要绑定其他 DLL,宏需要扩展成可传 DLL 名称。当前实现对本项目足够,但通用性有限。
第五,仍然完全依赖 Windows 的 ICMP API。也就是说,真正构造 ICMP 包、计算校验和、通过 socket 发送、处理底层权限等工作都交给了 Windows。这个工具看起来像自己写的 ping,但底层最关键的 ICMP 逻辑仍然由系统 API 代劳。
这也引出了后续方向:如果想更深入理解 ping,就不能一直依赖 Windows 帮忙"说 ICMP"。还可以继续往更底层走,自己构造数据包,自己处理协议细节。
二十、总结
这一篇是整个系列中很关键的一次整理。上一节的 ping(dest) 只是一个最小可用函数,功能有限,也缺少响应信息。这一篇先用 builder pattern 把它改造成 Request API:目标地址必填,TTL、timeout、data 都有默认值,也可以通过链式方法按需设置。调用处从一堆位置参数变成了清楚的描述式代码:
rust
Request::new(dest)
.ttl(60)
.timeout(1000)
.data("Pretty cool!")
.send()?;
接着,icmp_sys 里的动态加载逻辑被进一步整理。先把 IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle 聚合进 Functions 结构体,再用 once_cell::sync::Lazy 做懒初始化,避免每次调用都重新加载 DLL。最后用宏生成函数指针结构体、懒初始化静态变量和同名包装函数,让 icmp_sys 对上层看起来像普通 Rust 模块。
最后,Request::send() 不再只返回成功或失败,而是返回结构化 Reply。它从 Win32 的 IcmpEchoReply 中提取地址、数据、RTT 和 TTL,再包装成不含裸指针的安全 Rust 类型。主程序也因此能打印类似 ping 的输出:Reply from ... bytes=... time=... TTL=...。
这篇真正强调的是:系统编程不是把底层函数调通就结束了。调通之后,还要把 API 设计好,把默认值处理好,把资源关闭好,把重复 FFI 样板消除掉,把 unsafe 内部结构转换成安全返回类型。Rust 的优势不只是能调用底层 API,而是能把这些危险边界逐步收束起来,让上层代码越来越像普通、安全、可维护的 Rust 程序。