用 Builder Pattern 改造 Ping:让 Rust FFI 代码更干净

本文是对 The builder pattern, and a macro that keeps FFI code DRY 的整理与翻译。

内容结构概览

  1. 当前 ping API 的问题:只能传目标地址,不能设置 TTL、超时、payload,也拿不到响应信息。
  2. 为什么不用一堆位置参数ping(dest, 128, 4000, data) 可读性差,也失去了默认值。
  3. 为什么不用一堆 Option 参数ping(dest, Some(128), Some(4000), None) 更难读。
  4. 引入 builder pattern :把 ping(dest) 改成 Request::new(dest).ttl(...).timeout(...).data(...).send()
  5. send(self) 为什么消费请求:发送后请求不应再被修改或复用。
  6. Into<Vec<u8>> 的作用 :让 .data("Pretty cool!") 这种调用更自然。
  7. 修复 ICMP handle 泄漏 :新增 IcmpCloseHandle,发送完成后关闭 handle。
  8. 动态加载函数重复问题IcmpCreateFileIcmpSendEchoIcmpCloseHandle 不该每次都重新加载 DLL。
  9. Functions 聚合三个 FFI 函数 :一次加载 IPHLPAPI.dll,一次性取出所有函数指针。
  10. once_cell::sync::Lazy 做懒初始化单例:首次调用时加载 DLL,后续复用。
  11. 用宏消除重复 FFI 绑定代码:生成函数指针结构体、懒初始化静态变量和包装函数。
  12. 让 ping 返回 Reply :不再只返回 Result<(), E>,而是返回地址、数据、RTT、TTL。
  13. 在 main 中循环 ping 四次 :打印类似 Reply from ... bytes=... time=... TTL=... 的输出。
  14. 总结与下一步:目前仍依赖 Windows API 代发 ICMP,后面会继续往更底层走。

前面几篇已经把自制 ping 工具推进到了一个能工作的阶段。程序可以从命令行读取 IPv4 地址,把字符串解析成 ipv4::Addr,再通过 Windows 的 IPHLPAPI.dll 调用 IcmpCreateFileIcmpSendEcho,向目标地址发送 ICMP Echo 请求。代码已经不再是单文件实验,而是拆出了 ipv4icmpicmp_sysloadlibrary 等模块。

但上一版 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 能放进 u84000 更像毫秒数。但这依赖读者有上下文知识。只看调用点,很难立刻判断这些数字分别表示什么。即使 IDE 能提示参数类型,u8u32 本身也不能表达"这是 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_orunwrap_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 个字节,复制成本极低,可以给它派生 CloneCopy

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>>Noneunwrap_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>,但调用方可能有字符串字面量、StringVec<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 的代码仍然很别扭。IcmpCreateFileIcmpSendEchoIcmpCloseHandle 每个包装函数都要重复加载 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);

这个版本已经比之前好。至少 IcmpCreateFileIcmpSendEchoIcmpCloseHandle 三个函数在一次 Functions::get() 中一起加载,代码也被组织到同一个结构体里。但问题还没有完全解决:每次调用 Functions::get(),仍然会重新打开 DLL 并查找三个符号。

更理想的是,Functions 应该是一个单例。第一次真正需要这些函数时初始化,之后所有调用都复用同一个实例。


九、为什么不能直接写普通 static

最自然的想法是定义一个静态变量:

rust 复制代码
pub static FUNCTIONS: Functions = Functions::get();

但这不能工作。Rust 的普通 static 初始化要求在编译期或 const 上下文中完成,而 Functions::get() 需要运行时调用 LoadLibraryGetProcAddress,这些显然不是编译期常量表达式。动态加载 DLL 只能在程序运行时发生。

这类需求通常叫"懒初始化"。也就是说,不是在程序启动时或编译期初始化,而是在第一次使用时初始化;初始化完成后,后续访问直接复用。Rust 生态中常见方案包括 lazy_staticonce_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

当简化版宏能正确生成结构体和函数壳子后,就可以把完整逻辑填进去。


十二、完整宏:生成 FunctionsLazy 和包装函数

完整宏大致如下:

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) 会把标识符变成字符串。比如 $nameIcmpSendEchostringify!($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,查找 IcmpCreateFileIcmpSendEchoIcmpCloseHandle,保存函数指针;之后每次调用都复用这些函数指针。

这就是宏在这里的价值。它不是为了炫技,而是为了让一类高度重复且容易写错的代码保持一致。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,但 timeTTL 还是问号。原因很简单: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 绑定样板代码。IcmpCreateFileIcmpSendEchoIcmpCloseHandle 的绑定结构很相似,如果全部手写,就会重复大量代码。宏把"函数声明"变成唯一信息源,再自动生成结构体、懒初始化变量和包装函数,减少复制粘贴错误。

第七,公开 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 里的动态加载逻辑被进一步整理。先把 IcmpCreateFileIcmpSendEchoIcmpCloseHandle 聚合进 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 程序。

相关推荐
用户9000434815313 小时前
Python并发编程:多线程与多进程的实战指南
后端
geovindu4 小时前
go: Generators Pattern
开发语言·后端·设计模式·golang·生成器模式
程序猿阿越4 小时前
AutoMQ源码(一)读、写、Compaction
java·后端·源码
foggyprojects4 小时前
一个企业查询问题,如何从自然语言走到 DSL 再走到 SQL
后端
掘金者阿豪4 小时前
PDO连金仓数据库(下篇):预处理语句、大对象和批量操作
后端
RealPluto4 小时前
Rancher证书轮换过期导致不能访问UI问题处理
后端
Asize4 小时前
Bun + TypeScript 实战:从接口约束到 RESTful 路由设计
后端·typescript·代码规范
鱼人5 小时前
Go 操作 MySQL:常用写法与最佳实践
后端
挖坑的张师傅5 小时前
方便 Mac 本机运行 e2b 的沙箱方案 e2b-local
人工智能·后端