Rust windows-rs 调用 COM 自动化: 控制 bpac 打印机实战
2025-05-05 10:48:20
用 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
接口是第一步,但接下来如何调用它的方法,比如 Open
、SetPrinter
这些,就涉及到 COM 的 Invoke
机制了,这比直接调用 Rust 函数要复杂得多。
为什么 Rust 操作 COM 更麻烦?
脚本语言(像 Ruby、Python)通常内置了对 COM 自动化的良好封装,隐藏了底层的复杂性。开发者可以直接像操作普通对象一样调用方法、访问属性。
Rust 则不同:
- 贴近底层:
windows-rs
提供了对 Windows API 的原始绑定,非常接近 C/C++ 的层面。这意味着你需要手动处理更多细节,比如 COM 初始化、线程模型、接口查询、参数打包、错误处理(HRESULT
)和资源释放。 IDispatch
机制:IDispatch
是 COM 实现自动化(特别是用于脚本语言)的核心接口。它允许“后期绑定”,意思是在运行时查找方法和属性(通过名称),然后调用它们。这需要通过IDispatch::GetIDsOfNames
把方法名(如 "Open")转换成一个数字 ID(DISPID
),再用IDispatch::Invoke
带着参数去调用这个 ID 对应的方法。这个过程相当繁琐。- 参数类型
VARIANT
:IDispatch::Invoke
的参数和返回值都打包在VARIANT
结构里。VARIANT
是一个可以包含多种数据类型(整数、字符串、布尔值、COM 对象等)的联合体。在 Rust 里构造和解析VARIANT
需要小心处理类型和内存。 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
类型同样管理其内存,从&str
或String
创建很方便。
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 类型的参数转换成VARIANT
。windows::core::VARIANT
通过From
trait 简化了这个过程。BSTR
: COM 世界里的标准字符串类型。windows::core::BSTR
负责SysAllocString
/SysFreeString
的调用,管理内存。
4. 安全建议
- 输入验证: 如果
label_path
或printer_name
来自用户输入或不可信来源,务必进行严格验证和清理,防止路径遍历攻击或注入恶意内容。例如,确保路径格式正确,不包含非法字符。 - 错误处理:
windows-rs
操作返回Result
。务必检查错误,尤其是 COM 调用返回的HRESULT
。不要忽略错误,记录日志或向用户报告有意义的信息。特定的HRESULT
可能表示组件未注册、方法不存在、参数无效等。 - 权限: 运行此 Rust 程序的用户需要有权限访问打印机和指定的
.lbx
文件。
5. 进阶使用技巧
- 获取返回值: 如果 COM 方法有返回值,你需要准备一个
VARIANT
变量,并将其指针传递给Invoke
的pVarResult
参数。调用成功后,解析这个VARIANT
以获取结果。记得在使用后调用VariantClear
(或者让windows::core::VARIANT
的Drop
来做)。 - 属性访问: 读写 COM 对象的属性也通过
Invoke
实现,但wFlags
需要设置为DISPATCH_PROPERTYGET
或DISPATCH_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
边界内)体系中。