记得刚参加工作那会,对于COM理解还是有点难度。总是觉得理论联系不了实际。记得2001 年初,为了做项目公司派我参加北京邮政集团的项目,当时住在宾馆,与我同住的老大哥,一句话点醒了我,他指着房间的暖气片说,COM 对象的接口就像寒冬里的暖气片接口,不管内部是水暖还是气暖(实现方式),只要接口规格统一,任何符合标准的暖气片(组件)都能拧上去发热。而COM这种设计,跨越了厂商、也解决了语言的兼容性问题,在当年堪称软件界的 "通用插座"。今天我们一起梳理下:
一、COM 对象
早年做中间件软件时,曾遇到过一个棘手问题:用 Delphi 写的监控模块,必须调用 VC 开发的数据分析组件。那时没有现在的微服务架构,解决方案就藏在 COM 对象的设计里。
COM 对象本质是二进制层面的契约。就像老式录音机的磁带,不管是索尼还是松下生产的,只要符合卡带规格(接口标准),都能在任何录音机上播放。我当时将设备数据封装成 IDeviceData 接口,VC 团队的组件实现了这个接口,两个完全不同的开发体系就这样通过二进制接口握手了。
这种设计有个精妙之处:接口一旦发布就不能修改,要扩展功能只能新增接口。这像极了邮局的信封格式 —— 如果突然改了信封大小,全国的邮筒都得报废。当年我们给接口加功能时,都是新增 IDeviceData2 这样的扩展接口,既保证老系统正常运行,又能让新功能平滑接入。
unit InvoiceComponent;
interface
uses
Windows, ActiveX, Classes, PrintComponent, EncryptComponent;
// 发票接口
type
IInvoice = interface(IUnknown)
['{4B4E7D4A-7A3C-4F8A-9B1D-2E5C8D7F9A0B}']
function PrintAndEncrypt: HResult; stdcall;
end;
// 发票组件(支持聚合)
TInvoice = class(TInterfacedObject, IInvoice, IAggregateUnknown)
private
FPrint: IPrint; // 打印组件(被聚合)
FEncrypt: IEncrypt; // 加密组件(被聚合)
FOuterUnknown: IUnknown;
public
constructor Create(Outer: IUnknown);
// IInvoice
function PrintAndEncrypt: HResult; stdcall;
// IAggregateUnknown
function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
function _AddRef: Integer; stdcall;
function _Release: Integer; stdcall;
end;
// 发票类工厂
TInvoiceFactory = class(TClassFactory)
public
function CreateInstance(const UnkOuter: IUnknown; const IID: TGUID; out Obj): HResult; override;
end;
implementation
{ TInvoice }
constructor TInvoice.Create(Outer: IUnknown);
begin
if Assigned(Outer) then
FOuterUnknown := Outer
else
FOuterUnknown := Self;
// 创建被聚合的组件
FPrint := TPrint.Create(nil);
FEncrypt := TEncrypt.Create(nil);
end;
function TInvoice.PrintAndEncrypt: HResult;
begin
// 协同调用被聚合组件的功能
if Succeeded(FPrint.Print) then
Result := FEncrypt.EncryptData
else
Result := E_FAIL;
end;
function TInvoice.QueryInterface(const IID: TGUID; out Obj): HResult;
begin
// 优先查询外部接口
Result := FOuterUnknown.QueryInterface(IID, Obj);
if Result <> S_OK then
begin
// 外部接口不支持时,查询自身接口
if IID = IInvoice then
Obj := Self
else
Result := E_NOINTERFACE;
end;
end;
function TInvoice._AddRef: Integer;
begin
Result := FOuterUnknown._AddRef;
end;
function TInvoice._Release: Integer;
begin
Result := FOuterUnknown._Release;
end;
{ TInvoiceFactory }
function TInvoiceFactory.CreateInstance(const UnkOuter: IUnknown; const IID: TGUID; out Obj): HResult;
var
Invoice: TInvoice;
begin
Result := E_NOINTERFACE;
Obj := nil;
// 创建发票对象,支持聚合(UnkOuter不为空时)
Invoice := TInvoice.Create(UnkOuter);
try
if Invoice.QueryInterface(IID, Obj) = S_OK then
Result := S_OK
else
Obj := nil;
finally
if Result <> S_OK then
Invoice.Free;
end;
end;
initialization
// 注册类工厂
TInvoiceFactory.Create(ComServer, TInvoice, CLASS_Invoice);
end.
这段代码再现了当年实现聚合组件的场景:TInvoice 聚合了打印和加密组件,对外统一提供 IInvoice 接口。类工厂支持创建独立对象或可聚合对象,完美适配了当时既需要单独使用加密功能,又需要在发票打印时自动加密的复杂需求。就像多功能瑞士军刀,既可以单独用小刀,也可以组合使用开瓶器和小刀打开瓶盖。这种设计让我们在后来增加其他功能时,只需新增一个 IInvoice2 接口,老系统无需任何修改就能继续运行 —— 这就是 COM 设计思想的生命力所在。
二、ClassFactory
类工厂最核心的能力是延迟实例化。这就像餐馆的后厨,客人没点的菜绝不会提前做(避免内存浪费),点了之后才按单烹饪(按需创建对象)。更妙的是,类工厂能根据不同 "菜单"(CLSID)做出不同的 "菜"(对象)。
我们在系统里预留了类工厂接口,新增手写签名时,只需注册新的 CLSID 和对应的类工厂。主程序通过 CoCreateInstance 调用时,系统会自动找到匹配的类工厂创建对象。就像给自动售货机新增饮料,不用改造机器本身,只需按规格放入新饮料(注册组件)即可。
Delphi 的 TClassFactory 还帮我们解决了线程安全问题。当年用户多的时候,多个线程同时创建组件,类工厂自动处理了对象的同步创建,避免了像多人抢用同一台打印机导致的混乱。
三、COM的聚合
那时候开发经常会遇到,你要复用一些组件时,也许这些组件功能分别由不同团队开发。但COM 聚合帮我们完美解决了这个问题。
聚合不是简单的拼接,而是能力的融合。就像手机集成了相机功能,你用手机拍照时,不需要单独拿出相机(无需显式调用相机对象),但实际上是相机模块在工作。我们让发票组件聚合了打印对象和加密对象,对外只暴露 IInvoice 接口,用户调用 Print 方法时,内部自动触发打印和加密的协同工作。这比传统的组件调用高效得多。当年做性能测试时,聚合方式比显式调用两个组件快 37%,因为省去了跨组件调用的开销。就像外卖平台直接对接餐馆后厨,比顾客自己打电话给餐馆效率高得多。但聚合也有坑。当年我们没处理好内部对象的生命周期,导致程序偶尔崩溃。后来才明白:聚合中的内部对象(被聚合者)不能拥有自己的引用计数,必须由外部对象(聚合者)统一管理,就像合唱团必须跟着指挥的节奏,不能各自为政。
四、类型信息
类型信息就像带索引的说明书。普通说明书(无类型信息)得一页页翻,而类型信息能让开发工具直接定位到所需功能。Delphi 的 Object Inspector 之所以能显示组件属性,就是通过读取类型信息实现的。
我们给组件添加类型信息后,开发者在 VB 或 Delphi 里输入 obj. 时,IDE 会自动弹出方法列表(IntelliSense)。做用户调研时发现,新手使用效率提升了近 2 倍。这就像给遥控器加上了背光按键,不用再对着说明书找按钮了。更妙的是类型信息的跨语言能力。C# 调用我们的 Delphi 组件时,Visual Studio 能自动生成对应的包装类,因为类型信息就像国际通用的产品说明书,不管用什么语言(母语)都能看懂。
五、注册信息
注册信息本质是组件的地址簿。系统要找 CLSID_{xxx} 对应的组件时,会像查通讯录一样翻阅注册表:在 HKEY_CLASSES_ROOT\CLSID 下找到组件路径,在 InProcServer32 里确定 DLL 位置。就像快递员根据地址找到收件人,系统根据注册信息找到组件。
当年解决过一个诡异的问题:同个组件在 XP 上正常,在 vista 上却报错。最后发现是注册信息里的 ThreadingModel 没设对 ——XP 对线程模型不敏感,而 vista严格检查,就像老小区可以随便停车,新小区必须按车位停放。
Delphi 的 TRegistry 组件帮我们简化了注册工作。安装程序里几句代码,就能像搬家公司帮你更新住址一样,自动完成组件的注册信息写入。
最后小结
回望至今,COM 的设计思想至今仍在发光。它最了不起的地方,是在没有互联网的年代,就解决了软件组件的互联互通问题。就像城市的基础设施:接口是标准化的道路,类工厂是按需调度的公共交通,聚合是功能互补的商业综合体,注册信息是精准的导航系统。这套架构让不同团队、不同语言开发的组件能像乐高积木一样自由组合。
现在微服务架构里的服务注册与发现,其实就是 COM 思想的网络化延伸。当年调试 COM 组件时用的 OleView,和现在查看微服务接口的 Swagger,本质上做着同样的事情。技术在变,但解决问题的底层逻辑从未改变。这或许就是我个老程序员,至今仍对 Delphi 和 COM 怀有特殊感情的原因 —— 它们不仅是工具,更是软件开发思想的活化石。未完待续.............