返回

Rust windows-rs 调用 COM 自动化: 控制 bpac 打印机实战

windows

用 Rust 调用 Windows COM 自动化:bpac 打印实例

想用 Rust 操作 Windows 上的 COM 组件?比如控制像 Brother QL 系列标签打印机这样的设备。你可能之前用 Ruby 这类语言通过几行代码就搞定了,像这样操作 bpac.Document 对象:

# Ruby 示例
doc = WIN32OLE.new "bpac.Document" # 创建 COM 对象
doc.open 'some_label.lbx'        # 打开标签模板
doc.SetPrinter "Brother QL-810W", true # 设置打印机
doc.StartPrint("", 0)             # 开始打印任务
doc.PrintOut(1, 0)                # 打印一份
doc.EndPrint                      # 结束打印任务

但换到 Rust,事情就变得不那么直接了。当你尝试使用 windows-rs 这个 crate 和 Win32 API 打交道时,可能会像下面这样开了个头,然后就卡在如何调用 COM 对象的方法上:

use windows::Win32::System::{Com, Ole};
use windows::core::Result;

pub fn print() -> Result<()> {
    // 初始化 COM 环境
    unsafe { Com::CoInitializeEx(std::ptr::null(), Com::COINIT_APARTMENTTHREADED)? };

    // 通过 ProgID 获取 CLSID
    let clsid = unsafe { Com::CLSIDFromProgID("bpac.Document")? };
    // println!("获取到 CLSID: {:?}", clsid); // 调试输出

    // 创建 COM 对象实例,获取 IDispatch 接口
    let dispatch: Com::IDispatch = unsafe { Com::CoCreateInstance(&clsid, None, Com::CLSCTX_ALL)? };
    // println!("获取到 IDispatch: {:?}", dispatch); // 调试输出

    // ... 卡在这里了,怎么用这个 dispatch 对象调用 Open, SetPrinter 等方法?

    // 清理 COM 环境 (需要确保在函数退出时调用)
    unsafe { Com::CoUninitialize() };
    Ok(())
}

拿到 IDispatch 接口是第一步,但接下来如何调用它的方法,比如 OpenSetPrinter 这些,就涉及到 COM 的 Invoke 机制了,这比直接调用 Rust 函数要复杂得多。

为什么 Rust 操作 COM 更麻烦?

脚本语言(像 Ruby、Python)通常内置了对 COM 自动化的良好封装,隐藏了底层的复杂性。开发者可以直接像操作普通对象一样调用方法、访问属性。

Rust 则不同:

  1. 贴近底层: windows-rs 提供了对 Windows API 的原始绑定,非常接近 C/C++ 的层面。这意味着你需要手动处理更多细节,比如 COM 初始化、线程模型、接口查询、参数打包、错误处理(HRESULT)和资源释放。
  2. IDispatch 机制: IDispatch 是 COM 实现自动化(特别是用于脚本语言)的核心接口。它允许“后期绑定”,意思是在运行时查找方法和属性(通过名称),然后调用它们。这需要通过 IDispatch::GetIDsOfNames 把方法名(如 "Open")转换成一个数字 ID(DISPID),再用 IDispatch::Invoke 带着参数去调用这个 ID 对应的方法。这个过程相当繁琐。
  3. 参数类型 VARIANT IDispatch::Invoke 的参数和返回值都打包在 VARIANT 结构里。VARIANT 是一个可以包含多种数据类型(整数、字符串、布尔值、COM 对象等)的联合体。在 Rust 里构造和解析 VARIANT 需要小心处理类型和内存。
  4. unsafe 代码: 直接调用 Win32 API 和操作 COM 指针通常需要 unsafe 块,因为这涉及到了 FFI(外部函数接口)和裸指针,Rust 编译器无法保证其内存安全。

解决方案:直接使用 IDispatch::Invoke

虽然繁琐,但直接使用 windows-rs 提供的 IDispatch 接口及其 Invoke 方法是最根本的解决方案。这能让你完全控制调用过程。

下面我们把前面的 Rust 代码片段扩展为一个能实际工作的例子,模拟 Ruby 代码中的操作。

1. 初始化和清理 COM

使用 RAII(Resource Acquisition Is Initialization)模式管理 COM 的初始化和清理是个好习惯,可以确保 CoUninitialize 总能被调用。

struct ComEnvironment;

impl ComEnvironment {
    fn new() -> Result<Self, windows::core::Error> {
        unsafe {
            // 使用单线程单元 (STA),很多 UI 和自动化组件需要这个
            windows::Win32::System::Com::CoInitializeEx(
                std::ptr::null_mut(), // 必须是 null_mut() 或具体的 COINITSEX 枚举
                windows::Win32::System::Com::COINIT_APARTMENTTHREADED,
            )?;
        }
        Ok(Self)
    }
}

impl Drop for ComEnvironment {
    fn drop(&mut self) {
        unsafe { windows::Win32::System::Com::CoUninitialize() };
        // println!("COM 环境已清理"); // 调试输出
    }
}

2. 完整打印示例

use windows::core::{BSTR, HRESULT, VARIANT, PCWSTR};
use windows::Win32::Foundation::{BOOL, DISP_E_MEMBERNOTFOUND};
use windows::Win32::System::Com::{
    CoCreateInstance, CoInitializeEx, CoUninitialize, CLSIDFromProgID, IDispatch,
    CLSCTX_ALL, COINIT_APARTMENTTHREADED, DISPATCH_FLAGS, DISPATCH_METHOD, DISPATCH_PROPERTYPUT,
    DISPPARAMS, VARIANT_INIT, // VT_EMPTY
    VARENUM, // for VT_BOOL, VT_I4 etc.
};
use windows::Win32::System::Ole::{VariantClear, VariantInit}; // VariantInit 实际在 System::Com 里,但通常和 Ole 一起提
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr;

// RAII Guard for COM Initialization
struct ComGuard;

impl ComGuard {
    fn init() -> Result<Self, windows::core::Error> {
        unsafe {
            // 使用单线程单元 (STA),对很多 UI 和自动化组件是必要的
            CoInitializeEx(ptr::null_mut(), COINIT_APARTMENTTHREADED)?;
        }
        Ok(ComGuard)
    }
}

impl Drop for ComGuard {
    fn drop(&mut self) {
        unsafe { CoUninitialize(); }
        // println!("COM 环境已清理"); // 调试用
    }
}


// 主打印函数
pub fn print_label(label_path: &str, printer_name: &str) -> Result<(), Box<dyn std::error::Error>> {
    // 初始化 COM 环境,使用 Guard 确保清理
    let _com_guard = ComGuard::init()?;

    // 1. 获取 bpac.Document 的 CLSID
    let clsid = unsafe { CLSIDFromProgID(pcwstr("bpac.Document"))? };

    // 2. 创建 IDispatch 实例
    let dispatch: IDispatch = unsafe { CoCreateInstance(&clsid, None, CLSCTX_ALL)? };

    // 3. 获取需要调用的方法的 DISPID
    // 要获取 DISPID,我们需要将方法名转换为宽字符(UTF-16)
    let method_open = vec![wide_null("Open")];
    let method_set_printer = vec![wide_null("SetPrinter")];
    let method_start_print = vec![wide_null("StartPrint")];
    let method_print_out = vec![wide_null("PrintOut")];
    let method_end_print = vec![wide_null("EndPrint")];

    let mut dispids_open = [0i32]; // DISPID 通常是 i32
    let mut dispids_set_printer = [0i32];
    let mut dispids_start_print = [0i32];
    let mut dispids_print_out = [0i32];
    let mut dispids_end_print = [0i32];

    // LCID 通常用 0 (用户默认或中性)
    let lcid = 0;

    // 使用 GetIDsOfNames 获取 DISPID
    unsafe {
        dispatch.GetIDsOfNames(&windows::core::GUID::default(), &method_open, 1, lcid, &mut dispids_open)?;
        dispatch.GetIDsOfNames(&windows::core::GUID::default(), &method_set_printer, 1, lcid, &mut dispids_set_printer)?;
        dispatch.GetIDsOfNames(&windows::core::GUID::default(), &method_start_print, 1, lcid, &mut dispids_start_print)?;
        dispatch.GetIDsOfNames(&windows::core::GUID::default(), &method_print_out, 1, lcid, &mut dispids_print_out)?;
        dispatch.GetIDsOfNames(&windows::core::GUID::default(), &method_end_print, 1, lcid, &mut dispids_end_print)?;
    }

    // 4. 调用方法
    let dispid_open = dispids_open[0];
    let dispid_set_printer = dispids_set_printer[0];
    let dispid_start_print = dispids_start_print[0];
    let dispid_print_out = dispids_print_out[0];
    let dispid_end_print = dispids_end_print[0];

    // 准备参数调用 `Open`
    // doc.open 'some_label.lbx'
    {
        let label_path_bstr = BSTR::from(label_path); // BSTR 会自动管理内存
        let mut args = vec![VARIANT::from(label_path_bstr)]; // 参数是反向压栈的
        let mut dp = DISPPARAMS {
            rgvarg: args.as_mut_ptr(),
            rgdispidNamedArgs: ptr::null_mut(),
            cArgs: args.len() as u32,
            cNamedArgs: 0,
        };

        // 调用 Open 方法
        invoke_method(&dispatch, dispid_open, &mut dp)?;
        // BSTR 和 VARIANT 在超出作用域时会被清理
    }
    // println!("调用 Open 成功");

    // 准备参数调用 `SetPrinter`
    // doc.SetPrinter "Brother QL-810W", true
    {
        let printer_name_bstr = BSTR::from(printer_name);
        let fit_to_page = VARIANT::from(true); // 布尔值
        let mut args = vec![fit_to_page, VARIANT::from(printer_name_bstr)]; // 参数反向:fit_to_page 在前,printer_name 在后
        // args 需要是可变的,因为 VARIANT::from 可能会分配内存,后面 clear 时要用
        let mut dp = DISPPARAMS {
            rgvarg: args.as_mut_ptr(),
            rgdispidNamedArgs: ptr::null_mut(),
            cArgs: args.len() as u32,
            cNamedArgs: 0,
        };

        // 调用 SetPrinter 方法
        invoke_method(&dispatch, dispid_set_printer, &mut dp)?;
        // println!("调用 SetPrinter 成功");
    }


    // 准备参数调用 `StartPrint`
    // doc.StartPrint("", 0)
    {
        let doc_name = BSTR::from(""); // 空字符串
        let options = VARIANT::from(0i32); // 打印选项,用 i32 类型
        let mut args = vec![options, VARIANT::from(doc_name)]; // 反向
        let mut dp = DISPPARAMS {
            rgvarg: args.as_mut_ptr(),
            rgdispidNamedArgs: ptr::null_mut(),
            cArgs: args.len() as u32,
            cNamedArgs: 0,
        };
        invoke_method(&dispatch, dispid_start_print, &mut dp)?;
        // println!("调用 StartPrint 成功");
    }


    // 准备参数调用 `PrintOut`
    // doc.PrintOut(1, 0)
    {
        let copies = VARIANT::from(1i32); // 打印份数
        let options = VARIANT::from(0i32); // 打印选项
        let mut args = vec![options, copies]; // 反向
        let mut dp = DISPPARAMS {
            rgvarg: args.as_mut_ptr(),
            rgdispidNamedArgs: ptr::null_mut(),
            cArgs: args.len() as u32,
            cNamedArgs: 0,
        };
        invoke_method(&dispatch, dispid_print_out, &mut dp)?;
        // println!("调用 PrintOut 成功");
    }

    // 调用 `EndPrint` (无参数)
    {
        let mut dp = DISPPARAMS::default(); // 没有参数
        invoke_method(&dispatch, dispid_end_print, &mut dp)?;
        // println!("调用 EndPrint 成功");
    }

    // _com_guard 在函数结束时自动 Drop,调用 CoUninitialize

    Ok(())
}


// 辅助函数:调用 IDispatch::Invoke (方法)
fn invoke_method(dispatch: &IDispatch, dispid: i32, dp: &mut DISPPARAMS) -> Result<(), windows::core::Error> {
    unsafe {
        dispatch.Invoke(
            dispid,
            &windows::core::GUID::default(), // 通常是 IID_NULL
            0, // LCID
            DISPATCH_METHOD, // 标记为调用方法
            dp, // 参数
            ptr::null_mut(), // 不获取返回值
            ptr::null_mut(), // 不获取异常信息
            ptr::null_mut(), // 不获取参数错误索引
        )?;
    }
    Ok(())
}

// 辅助函数:将 &str 转换为 Vec<u16> (宽字符,null 结尾)
fn wide_null(s: &str) -> Vec<u16> {
    OsStr::new(s).encode_wide().chain(Some(0)).collect()
}

// 辅助函数:创建 PCWSTR (注意生命周期)
// 这里简单地创建一个指向静态或已存在 Vec<u16> 的指针
// 实践中可能需要更安全的处理,例如固定 Vec<u16> 的地址
// 在上面的 GetIDsOfNames 调用中,Vec 生命周期足够长
fn pcwstr(s: &str) -> PCWSTR {
   PCWSTR::from_raw(wide_null(s).as_ptr())
}

// 主函数示例入口
// fn main() {
//     let result = print_label("C:\\path\\to\\your\\label.lbx", "Brother QL-810W");
//     if let Err(e) = result {
//         eprintln!("打印失败: {}", e);
//     } else {
//         println!("打印命令已发送");
//     }
// }

注意:

  • 上面的 pcwstr 只是一个简单示例。直接从 wide_null(s).as_ptr() 创建 PCWSTR 时,必须保证返回的 Vec<u16>PCWSTR 使用期间一直存活。在 GetIDsOfNames 调用中,我们创建了 Vec<u16>,它的生命周期覆盖了 GetIDsOfNames 的调用,这是可以的。如果是传递给需要长期持有指针的函数,需要更小心。
  • 错误处理:上面的代码使用了 ? 操作符来传播错误。实际应用中,你可能需要更精细地处理 HRESULT 错误码,了解具体失败的原因。
  • 参数准备:IDispatch::Invoke 的参数(DISPPARAMS.rgvarg)是按反向顺序 排列的。比如 SetPrinter("name", true),在 rgvarg 数组里,true 对应的 VARIANT 在索引 0,"name" 对应的 VARIANT 在索引 1。
  • VARIANT 管理:windows::core::VARIANT 提供了 From 实现,可以方便地从 Rust 类型(如 bool, i32, BSTR)创建 VARIANT。重要的是,当 VARIANT 包含需要分配内存的类型(如 BSTR)时,其 Drop 实现会自动调用 VariantClear 来释放资源。在 Invoke 调用后,即使你不显式清理 VARIANT,只要它们的所有权正确(比如在作用域结束时被丢弃),内存通常也能被回收。但要留意 VARIANT 的拷贝和生命周期。
  • BSTR:COM 通常使用 BSTR 类型(一种带长度前缀的宽字符串)传递字符串。windows::core::BSTR 类型同样管理其内存,从 &strString 创建很方便。

3. 原理和作用解释

  • CoInitializeEx / CoUninitialize: 初始化和卸载当前线程的 COM 库。每个需要使用 COM 的线程都必须先调用初始化函数。COINIT_APARTMENTTHREADED 指定了线程模型为“单元线程”,这是许多 COM 组件(特别是那些与 UI 或自动化相关的)所要求的。RAII Guard 确保即使发生错误或提前返回,CoUninitialize 也会被调用。
  • CLSIDFromProgID: COM 组件通过类标识符 (CLSID) 来唯一识别。但人们更容易记住程序标识符 (ProgID),比如 "bpac.Document"。这个函数就是用 ProgID 去注册表里查找对应的 CLSID。
  • CoCreateInstance: 使用 CLSID 创建 COM 对象的一个实例。它会返回一个指向对象某个接口的指针,这里我们请求的是 IDispatch 接口。CLSCTX_ALL 表示我们允许在进程内服务器 (DLL)、本地服务器 (EXE) 或远程服务器上创建实例。
  • IDispatch::GetIDsOfNames: 将方法或属性的名称(字符串)映射到一个整数标识符 (DISPID)。COM 组件内部维护着一个名称到 DISPID 的映射表。后续调用就用这个 DISPID,比每次都传递字符串更高效。
  • IDispatch::Invoke: 这是 IDispatch 的核心方法,用于执行由 DISPID 标识的方法或访问属性。
    • dispidMember: 要调用的方法或属性的 ID。
    • riid: 保留参数,总是 GUID::default() (IID_NULL)。
    • lcid: 本地化标识符,通常为 0。
    • wFlags: 指定操作类型,如 DISPATCH_METHOD (调用方法) 或 DISPATCH_PROPERTYGET/DISPATCH_PROPERTYPUT (读/写属性)。
    • pDispParams: 指向 DISPPARAMS 结构的指针,包含了传递给方法的参数。参数数组 rgvarg 是反向的。
    • pVarResult: 指向 VARIANT 的指针,用于接收返回值(如果需要)。
    • pExcepInfo: 指向异常信息结构(如果发生 COM 异常)。
    • puArgErr: 指向一个整数,指示第一个出错参数的索引(如果调用因参数错误失败)。
  • VARIANT: 一个自的类型,可以持有多种数据类型。在调用 Invoke 前,需要把 Rust 类型的参数转换成 VARIANTwindows::core::VARIANT 通过 From trait 简化了这个过程。
  • BSTR: COM 世界里的标准字符串类型。windows::core::BSTR 负责 SysAllocString/SysFreeString 的调用,管理内存。

4. 安全建议

  • 输入验证: 如果 label_pathprinter_name 来自用户输入或不可信来源,务必进行严格验证和清理,防止路径遍历攻击或注入恶意内容。例如,确保路径格式正确,不包含非法字符。
  • 错误处理: windows-rs 操作返回 Result。务必检查错误,尤其是 COM 调用返回的 HRESULT。不要忽略错误,记录日志或向用户报告有意义的信息。特定的 HRESULT 可能表示组件未注册、方法不存在、参数无效等。
  • 权限: 运行此 Rust 程序的用户需要有权限访问打印机和指定的 .lbx 文件。

5. 进阶使用技巧

  • 获取返回值: 如果 COM 方法有返回值,你需要准备一个 VARIANT 变量,并将其指针传递给 InvokepVarResult 参数。调用成功后,解析这个 VARIANT 以获取结果。记得在使用后调用 VariantClear (或者让 windows::core::VARIANTDrop 来做)。
  • 属性访问: 读写 COM 对象的属性也通过 Invoke 实现,但 wFlags 需要设置为 DISPATCH_PROPERTYGETDISPATCH_PROPERTYPUT。写属性时,通常会有一个特殊的 DISPID (DISPID_PROPERTYPUT) 用于命名参数,指明哪个参数是赋给属性的值。
  • 更高级的 COM 接口: 如果你知道组件除了 IDispatch 还实现了其他更具体的接口(例如 IBpacDocument,如果存在的话),可以通过 IDispatch::QueryInterface 获取这些接口的指针。调用特定接口的方法通常比 IDispatch::Invoke 更直接、类型更安全、性能也可能更好。不过这需要你有接口的定义(通常来自 IDL 文件或类型库)。windows-rs 也支持生成这类接口的绑定。
  • 事件处理 (Connection Points): 某些 COM 对象可以触发事件。处理这些事件需要实现一个事件接收器接口,并通过 COM 连接点机制将其注册到源对象上。这比方法调用要复杂得多。
  • 使用生成器: windows-rs 可以根据 Windows 元数据 (.winmd 文件) 生成更高级别的绑定。如果你能获取到 bPac SDK 的类型库信息并转换为 .winmd,可能会生成更易用的 Rust 包装。

这个详细的例子展示了在 Rust 中使用 windows-rs 直接与 IDispatch 接口交互来控制 COM 对象的完整流程。虽然比脚本语言的代码长很多,但它让你能够精细地控制每一个步骤,并融入 Rust 的强类型和内存安全(尽可能地在 unsafe 边界内)体系中。