前言
COM(Component Object Model,组件对象模型)是 Windows 上的二进制组件接口标准。用 VC++(或 VB、Delphi、C#)写一个组件并且注册到系统之后,它就会在注册表里留下一个 ProgID(程序标识符)和一个 CLSID(类标识符)。PHP 只要能按 ProgID 把组件创建出来,就可以像调用普通对象一样调用它的属性和方法——这就是"PHP 调用 VC 写的 COM 组件"的全过程。
这里有两个特别常见的误解。第一个是以为 PHP 能直接"加载 DLL 并调用里面的导出函数":不能。PHP 不做LoadLibrary加函数名查找这一套,它只能通过 COM 的接口约定(IUnknown/IDispatch)去和组件对话,所以组件必须按 COM 规范实现并注册,包装成一个"可创建的对象",而不是裸的 DLL。第二个是以为这个能力跟操作系统无关:COM 是 Windows 独有的技术,只有 Windows 版本的 PHP 才编译了com_dotnet扩展,Linux 和 macOS 上的 PHP 里根本就不存在COM这个类,写什么代码都跑不起来。
还有一个版本层面的变化必须点明:从 PHP 5.3.15 / 5.4.5 起,com_dotnet不再默认随 PHP 编译启用,Windows 官方发行包里虽然带着php_com_dotnet.dll,但必须在php.ini里显式打开才有COM类。很多人照着 PHP 5.2 时代的老教程写代码,报Class 'COM' not found,原因就在这里。
本文讲清机制、配置、创建与调用、VARIANT 传值、事件回调,以及生产环境里真正会踩的坑。
一、前提条件与扩展启用
先明确支持范围,别在错误的平台上浪费时间。
| 环境 | 是否支持 COM | 说明 |
|---|
| Windows + PHP 8.x(NTS/TS) | 支持 | 需启用com_dotnet |
| Windows + PHP 5.3.15 之前 | 支持 | 默认已启用,历史行为 |
| Linux / macOS | 不支持 | 没有COM类,扩展不存在 |
PHP 8 的dotnet类 | 支持(Windows) | 需安装 .NET Framework |
启用扩展:编辑php.ini,在[PHP]节(不是自定义节)里加上一行,然后重启 Web 服务器或重新开一个命令行窗口。
; php.ini —— 仅 Windows 版 PHP 有效
extension_dir = "ext"
extension=com_dotnet
; DCOM(跨机器调用 COM)默认关闭,生产环境不要打开
com.allow_dcom = 0
; 自动加载类型库,让常量和接口定义可用
com.autoregister_typelib = 1
com.autoregister_verbose = 1几个配置项要留意:com.allow_dcom默认就是0(关闭),这是安全的默认值,开启它意味着允许跨网络创建远程组件,会显著扩大攻击面,除非有非常明确的需求,否则保持关闭。com.autoregister_casesensitive在 PHP 8.0 起不能再被关闭,com_load_typelib()的第二个参数也不能再传false——这是 PHP 8.0 的向后不兼容变更之一。另外com.dotnet_version(指定 .NET Framework 版本)是 PHP 8.0.0 才加入的配置项,PHP 7.x 上没有。
写个探测脚本确认环境:
<?php // 适用于 PHP 8.0+(Windows)
if (!class_exists('COM')) {
exit('com_dotnet 扩展未启用,请检查 php.ini' . PHP_EOL);
}
echo 'COM 类可用,PHP 版本:', PHP_VERSION, PHP_EOL;二、VC 组件要先注册,PHP 才能按 ProgID 找到它
PHP 不会去猜你的 DLL 在哪。它把 ProgID 交给 Windows 的 COM 库,由 COM 库查注册表找到对应的 CLSID 和实现文件。所以第一步永远是注册:
- 进程内组件(DLL,通常还带一个类型库
.tlb):用regsvr32 你的组件.dll注册,用regsvr32 /u反注册。 - 进程外组件(EXE,本地服务器):一般由组件自身提供
你的组件.exe /RegServer这样的开关来注册。
注册成功后,在注册表的HKEY_CLASSES_ROOT下能查到同名 ProgID 项。ProgID 通常长这样:MyLib.MyClass.1——库名点类名点版本号。PHP 里就这么写:
<?php // 适用于 PHP 8.0+(Windows)
$obj = new COM('MyLib.MyClass.1');如果你的组件其实是 C# 写的,用RegAsm.exe注册后也能这样调用,机制完全一样,只是宿主运行时换成了 .NET。这里说"VC 编写"只是把范围限定在原生 COM 服务器上。
有一个坑几乎人人会踩一次:组件必须实现IDispatch接口。PHP 走的是晚期绑定(late binding),它拿到对象后是靠IDispatch::GetIDsOfNames+Invoke按名字找方法和属性的。如果 VC 组件只实现了自己的自定义接口(纯 vtable 分派),而没有实现IDispatch,那么new COM()这一步可能成功,但一调方法就报"无法调用"或者干脆静默返回null。解决办法是在 VC 里用 ATL 的IDispatchImpl,或者把接口定义成 dual(双接口),让类型库把它标记为可派发。
调试时可以用这个函数把接口信息打印出来,比翻文档快得多:
<?php // 适用于 PHP 8.0+(Windows)
$obj = new COM('MyLib.MyClass.1');
com_print_typeinfo($obj); // 打印该可派发接口的 PHP 类定义三、属性与方法的调用语法
PHP 的COM类把 COM 的属性读、属性写、方法调用分别映射到不同的语法上。
| COM 里的形态 | PHP 里的写法 | 说明 |
|---|
| 属性(可读) | $obj->PropName | 当变量读 |
| 属性(可写) | $obj->PropName = $v; | 必须有 put 权限 |
| 带参数的属性 | $obj->PropName($arg) | 用调用语法 |
| 方法 | $obj->Method($a, $b) | 参数顺序按类型库 |
| 集合元素 | $obj->Item($i) | 组件是否提供 Item 之类的访问方法,取决于它的类型库 |
一个重要细节:无参数属性按属性读,带参数的属性要写成方法调用的样子。比如某个表格组件的Worksheets(1),在 PHP 里就是$book->Worksheets(1),不能写成$book->Worksheets[1]。
下面这个例子用的是每个 Windows 上都有的WScript.Shell,不需要自己注册任何组件就能直接验证环境是否正常:
<?php // 适用于 PHP 8.0+(Windows)
$shell = new COM('WScript.Shell');
$out = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'com_probe.txt';
// Run(命令, 窗口样式, 是否等待返回):0 = 隐藏窗口,true = 阻塞等待
$exitCode = $shell->Run('cmd /c echo COM OK > "' . $out . '"', 0, true);
echo '退出码:', var_export($exitCode, true), PHP_EOL;
echo '文件内容:', file_get_contents($out), PHP_EOL;
$shell = null; // 释放 COM 引用计数
unset($shell);创建自己组件时,把 ProgID 换成注册的那个即可。下面这个骨架演示了"读属性、调方法、异常兜底、释放"的完整形状;ProgID 是举例用的,请替换成你自己注册的:
<?php // 适用于 PHP 8.0+(Windows)
$app = null;
try {
$app = new COM('Acme.MathServer.1'); // 换成你注册的 ProgID
echo '版本:', $app->Version, PHP_EOL; // 无参数属性,按属性读
echo '加法结果:', $app->Add(3, 4), PHP_EOL;
$app->Precision = 6; // 写属性
echo '精度:', $app->Precision, PHP_EOL;
// 带参数的属性:写成方法调用
echo '第 2 项:', $app->Items(2), PHP_EOL;
} catch (com_exception $e) {
echo 'COM 调用失败:', $e->getMessage(), PHP_EOL;
} finally {
$app = null; // 进程外组件在最后一个引用释放后退出
unset($app);
}注意catch (com_exception $e):COM 调用出错时抛出的是com_exception(继承自Exception),PHP 8 里常见的还有Error。生产代码里至少要把com_exception兜住,否则一个组件异常就能把整个请求打成 500。
四、VARIANT 传值与事件回调
COM 用的是 OLE Automation 的类型系统,跟 PHP 的类型不是一一对应的。当你要把 PHP 数组按 SAFEARRAY 传给组件,或者要接收组件的"按引用输出"参数时,就得用VARIANT类。
VARIANT的构造函数第二个参数是一组VT_*类型标志,可以按位或用数组元素类型。常见标志有VT_I4(32 位整数)、VT_R8(双精度浮点)、VT_BSTR(字符串)、VT_BOOL(布尔)、VT_DATE(日期)、VT_ARRAY(数组标志位)。相关的辅助函数有variant_set()、variant_get_type()、variant_cast()、variant_date_from_timestamp()、variant_date_to_timestamp()。
<?php // 适用于 PHP 8.0+(Windows)
$app = new COM('Acme.MathServer.1');
// 用 VARIANT 明确类型,避免让组件去猜
$n = new VARIANT(42, VT_I4);
echo '类型标志:', variant_get_type($n), PHP_EOL;
echo '是不是 VT_I4:', var_export(variant_get_type($n) === VT_I4, true), PHP_EOL;
// 把 PHP 数组包装成 SAFEARRAY 传进去
// 元素类型标志要与组件期望的一致,具体以官方手册 VARIANT 章节为准
$arr = new VARIANT([1, 2, 3], VT_ARRAY | VT_VARIANT);
$total = $app->SumArray($arr);
echo '数组求和:', $total, PHP_EOL;关于日期类型,有一个跨语言协作上的经验:VT_DATE用的是 OLE Automation 日期——以 1899-12-30 为零点的浮点天数。这个零点很反直觉,而且和 Unix 时间戳、和 PHP 的DateTime都不一样,两边各自转换时很容易差一天(尤其涉及本地时区和夏令时)。稳妥的做法是在组件接口上干脆用 ISO 8601 字符串(例如2026-10-06T12:00:00+08:00)传时间和日期,把解析集中在一侧做,别让 VARIANT 帮你隐式转换。
事件回调与消息泵
除了一问一答的调用,有些组件还会在后台触发事件(比如下载完成、串口收到数据),COM 通过"连接点"(connection point)把事件回调给客户端。PHP 侧用com_event_sink()把组件事件接到一个 PHP 对象上:
<?php // 适用于 PHP 8.0+(Windows)
class Sink
{
// 事件名必须与类型库里的名字对应(大小写由 com.autoregister_casesensitive 决定)
public function OnProgress($percent): void
{
echo "进度:{$percent}%", PHP_EOL;
}
}
$app = new COM('Acme.Downloader.1');
$sink = new Sink();
// 第三个参数是类型库里的 dispinterface 名,可省略
com_event_sink($app, $sink, 'IDownloaderEvents');
// 阻塞式地泵消息,最多 5000 毫秒
com_message_pump(5000);这里必须理解com_message_pump()的作用:COM 的事件回调是通过窗口消息队列投递的,你不"泵"消息,回调函数就永远不会被调用。而且这套东西只在同一个线程里、并且组件是单线程套间(STA)时才顺畅。在 Web SAPI(php-fpm、Apache 模块)下,一次请求的生命周期只有几百毫秒,用消息泵等异步事件基本没有实际意义。真要做长时间的异步回调,正确做法是把组件放到一个常驻进程(CLI 脚本或 Windows 服务)里,PHP 那边通过数据库、消息队列或本地 socket 拿结果。
常见坑点
- ❌ 在 Linux/macOS 上写
new COM(...)然后疑惑为什么报类不存在
✅ COM 是 Windows 专有;跨平台方案要换成 HTTP/RPC,或把 COM 调用隔离在一个 Windows 侧的服务里。
- ❌ 在
php.ini里写了;extension=php_com_dotnet.dll(前面留着分号),或者写在[COM_DOT_NET]这种自定义节里
✅ 去掉注释、放进[PHP]节,改完必须重启 Web 服务器或重开命令行。
- ❌ 把 DLL 的文件名当 ProgID 传给
new COM()
✅ 先用regsvr32注册,再用注册表里那个 ProgID(形如MyLib.MyClass.1)。
- ❌ 组件实现了自定义接口就以为能被 PHP 调用,结果方法调不到、返回
null
✅ PHP 走晚期绑定,组件必须实现IDispatch(用 dual 接口最省事),先用com_print_typeinfo()确认接口暴露是否正常。
- ❌ 用 Office 的 COM 自动化处理完文档后直接结束请求,服务器上残留一堆
WINWORD.EXE/EXCEL.EXE进程
✅ 显式Quit(),再把所有指向它的变量置null并unset();同时确保finally里也做释放。更好的做法是根本不要在 Web 请求里做 Office 自动化。
- ❌ 把 COM 对象塞进
$_SESSION,或者试图serialize()它
✅ COM 对象持有的是非托管资源句柄,不可序列化;应当每次请求重新创建,会话里只存可以序列化的业务数据。
- ❌ 把用户输入直接当 ProgID 或方法名,打开
com.allow_dcom以便"远程也能调"
✅ ProgID 用白名单写死;com.allow_dcom保持默认的0;绝不用用户输入拼接 ProgID。
- ❌ 照抄 PHP 4 时代教程里的
com_load()、com_invoke()、com_get()、com_addref()
✅ 这些函数早已淘汰,PHP 8 里没有实际使用价值;一律用new COM()加属性/方法语法,参数类型问题交给VARIANT。
总结
| 要点 | 结论 |
|---|
| 平台 | 仅 Windows;Linux/macOS 无COM类 |
| 扩展 | Windows 包里的php_com_dotnet.dll,需在php.ini的[PHP]节显式启用 |
| 组件要求 | 必须注册(regsvr32//RegServer)且实现IDispatch |
| 调用方式 | 无参属性按属性读;带参属性、方法按方法调用 |
| 类型传递 | 数组与引用参数用VARIANT加VT_*标志;日期优先用 ISO 字符串 |
| 异常 | 调用失败抛com_exception,务必捕获 |
| 释放 | 置null+unset(),让引用计数归零 |
| 安全 | ProgID 白名单写死,com.allow_dcom保持关闭 |
COM 是 Windows 上让 PHP 触达原生代码生态的一条老路,机制简单、开销是跨进程或跨套间的一次调用,但它的代价是强绑定 Windows、组件必须按 COM 规范实现、以及调试信息相当不友好。新项目如果只是想把一个算法包给 PHP 用,优先考虑 HTTP 微服务、gRPC 或命令行子进程;只有当你要集成的对象本身就是现成的 COM 组件(例如某些工控、打印、读卡器、Office 自动化组件)时,com_dotnet才是那个对的选择。