COM 简介
2023-05-17
这两天看了一篇讲 COM(Component Object Model)的文章。讲 COM 的前世今生,一路的演变,由浅入深,非常值得一读!
相比于 COM 的具体实现,作者 Markus Horstmann 将更多的重点和细节,放在 COM 为什么会被设计成现在这个样子。
文章写于 1995 年,和 COM 一样古老。
尽管后来 COM 并没有成为真正的工业界标准,其使用场景也基本被限制在 Windows 内,但其设计的理念,依然值得参考。
总的来说,很多时候技术本身,并没有绝对的优劣。历史不能重演,在上个世纪末,那个年代,COM 提供的可重用、互操作和可扩展的解决方案,是足够优雅的。相比于在网上人云亦云的说“COM 已死”,更重要的是深刻理解一种技术的产生背景,应用场景,具体的优劣势。
所谓好的技术,都是站在巨人的肩膀上。
1. 背景
按照维基百科的说法,组件对象模型,也就是 COM,是微软提出的一套用于开发软件组件的二进制接口标准。其理论基础来自两篇文章,一个是 1988 年 Anthony Williams 提出的“Object Architecture: Dealing with the Unknown or Type Safety in a Dynamically Extensible Class”和 1990 年的“On Inheritance: What It Means and How To Use it”。
不得不说,当初的设计还是很有野心的。
因为是基于组件的开发,所以使得对象的复用成为可能(可重用);另外由于是二进制标准,所以 COM 与实现的语言、编译器和操作系统无关,因此其使用可以跨越不同的开发环境,甚至不同的机器(互操作);最后,这是一套接口的标准,接口与实现分离,因此调用方无需了解被调用方内部实现,从而实现功能的透明升级(可扩展)。
因为有上述种种优势,COM 也的的确确通过大大小小的组件,最终从底层构建出 Windows 的大厦。当然,仅仅只是 Windows。
既然 COM 设计这么良好,为什么没有最终一统江湖,这个和很多因素有关。比如,自身学习曲线陡峭,跨平台迁移成本很高,新技术的出现(Java, .NET 等),BS 架构的兴起等。
从后往前看 COM 发展的历史,伴随商业和用户需求的变化,能很明显的感受到,软件工程没有万金油的解决方案,任何一种技术都有其擅长的场景。
脱离场景评判技术的好坏,都是耍流氓。
2. 简介
好的技术都是演变出来的,不是设计出来的。
文章借助几个不同的例子来阐述,COM 如何为对象之间安全(safe)且稳健(robust)的交互提供基础。
尽管 COM 是一套标准,不同的语言都能实现,但最直观的,还是 C++。
至少我在刚开始学习 COM 的时候,就觉得这俩东西实在太像了。
3.1 C++ Object 直接被 C++ Client 使用
我有一个现成的 C++ Object,可以负责操作 Database。
我需要一个 Database Client 来管理,需要有下面四个功能:
- Create
- Write
- Read
- Delete
最简单的方法,就是复用现有的 Database Object。如果有源码的话,直接编译即可。
3.2 C++ Object 在 DLL 里,被 C++ Client 使用
存在问题:上一种方式需要暴露源码。
但并非所有情况下,C++ Object 的开发者都愿意将自己的实现细节暴露出去,比如商业环境下。
如何在不暴露底层细节的前提下,依然能然外部用户使用?
Windows 提供了一种打包方式,将实现打包成 DLL,配合着头文件一起分发。
此时需要考虑三个问题:
- 导出函数
- 内存分配是在 DLL 还是在 EXE
- Unicode/ASCII 互操作
3.2.1 导出函数
最简单的导出函数的方式是使用__declspec(dllexport)
。但对于 C++ 而言,因为编译器需要解决函数重载的问题,因此存在一个叫做 name mangling 的机制,通俗来说,相同的函数名字在编译器视角下,会被添加一些额外信息,例如参数数量和类型用于区分不同的重载,这就导致在后面,函数动态加载的时候,使用GetProcAddress
变得极其复杂:我们需要知道 mangling 后的函数名字。
所以,使用静态链接(.lib
文件)通常会更好。
另外关于 name mangling,还存在兼容性方面的问题,因为这并不是一个标准,因此不同的编译器的生成结果有所差异。
3.2.2 内存分配
DLL 和 EXE 需要独立管理内存。如果 DLL 需要创建一个实例,这个内存会被放置在 DLL 的内存空间,此时如果 EXE 想要释放,Runtime 就会检查并报 General Protection Fault。
因此,DLL 和 EXE 需要独立管理各自的内存。
最直观的解决方式,就是 DLL 只暴露内存分配和释放的接口,实现在内部解决,那么 EXE 就可以通过简单的接口调用,间接管理内存。
我们使用一个全局工厂函数,借助它我们可获取到一个实例的指针,例如:CDBSrvFactory::CreateDB(CDB** ppDB)
。这其实也是 COM 采用的机制。
3.2.3 Unicode/ASCII 互操作
关于 Unicode/ASCII 的互操作,提供两种版本成本显然很高,最好的办法就是直接采用 Unicode,因为它是兼容 ASCII 的。
示例代码如下:
3.3 C++ Object 暴露纯虚基类
存在问题:
- 上一种实现仍然会暴露细节,尽管那不是必要的。比如某些 private 成员,在头文件中依然可以看到。这对于以 DLL(二进制)形式的分发而言,依然不够完美
- 另外还存在接口升级导致兼容性的问题:如果新的 Object 增加了某些成员,内存布局就会发生变化,然后 Client 就需要重新编译
因此我们可以暂时得出一个小小的结论:细节了解越多,耦合程度就越深。
而对于软件开发而言,耦合加重意味着兼容性和可维护性变差。
有没有一种办法,在我们不了解 Object 的内存布局的情况下,也可以使用它?毕竟对于 Client 而言,Object 长啥样并不需要关注,只要功能可用就行。
最直观的解决方式,就是在 Object 里不放置成员对象。但是没有成员变量我们怎么维护状态?通过继承。
简单来说,就是我们持有父类指针,但真实的函数调用发生在子类对象上。
C++ 提供了一种简单直白的方式,抽象基类(Abstract Base Class)。在 COM 中,这个东西叫interface。
对于 C++ 而言没有接口(Interface)的概念,抽象基类可以在某种程度上理解成对于接口的抽象。其本身不能被实例化,只能被继承。
同时 C++ 提供了运行时多态的能力,对于虚函数的调用,会自动匹配到合适的函数上。举个例子,两个子类继承同一个抽象基类,对同一个虚函数提供两套实现,此时,通过一个父类指针调用函数,真实的调用取决于父类指针指向的子类对象。
看似魔法的内部,是通过一套朴素的方式实现的:虚函数表。这一块内容完全值得另一篇文章。
下面看具体实现。
接口定义。
继承定义。
实现的细节都在继承类里,但是暴露出去的是接口的定义。
借助这样一种方式,不仅可以正常提供功能,而且不暴露底层细节。当然,这里多少会有些性能损耗,毕竟存在一次间接的函数调用(查虚函数表)。
3.4 C++ Object 在 DLL 中暴露纯虚基类
上面的方式同样可以用在 DLL 打包中。
3.2 的方式存在 name mangling 的问题,而且需要export所有的函数。
对于虚基类继承而言,这俩问题都不存在:纯虚基类没有数据成员,只有一个函数表的入口,而且函数调用是运行时动态查找,因此我们并不需要显式export成员函数。
一句话概括就是,3.2 是静态绑定,所以需要export,3.4 是动态绑定,所以不需要。
还是看具体代码。
接口定义。
跟 3.3 基本类似,除了export了一个 Factory 方法,方便后续 DLL 调用。
继承实现。
对于 C++ Client 而言,只需要通过LoadLibrary/GetProcAddress就可以直接使用 C++ Object,而它只需要知道接口定义的继承实现 DLL 接口。
没有 name mangling,成员函数也不需要显式导出。
3.5 COM:C++ Object 在 DLL 中,使用 COM 加载
上一个例子提供了一个非常灵活的机制用于对象打包:只需要在 DLL 中声明一个加载点(entry point)即可,其他的操作都是借助指向纯抽象基类的指针实现。
如果你想将多个对象打包到一个 DLL 中,你有两个选项:
- 每个 class 提供一个导出函数
- 提供一个标准导出函数,通过不同的传参得到不同结果
对于通用的组件模型而言,第二个选择无疑更加合适,这也是 COM 采取的方案:提供一个DllGetClassObject
导出函数。
这个函数接收一个类型为CLSID
(16 字节,而且是独一无二,可理解为某种 GUID)的参数,来指定调用者想要访问的 class。DLL 检查这个数字,如果存在就返回指向实际实现对象的指针。
借助这样一个全局导出函数,COM 库就可以将我们的 DLL 按照对象来管理。接下来我们就需要,将具体的CLSID
和我们需要的对象所在 DLL 绑定在一起。约定的接口是CoGetClassObject
。
剩下的问题是,该函数怎么发现给定CLSID
的 DLL?
这就需要一个标准的注册(Registration)机制。
对于 COM 库而言,所有对象相关的信息都会放在注册表 HKEY_CLASSES_ROOT\CLSID 的路径下。InprocServer32
将 DLL 的路径信息存储在这个地方。
至此,使用 COM 方式加载 DLL,进而获取到 C++ Object 的全部工作就完成了。
使用CoGetClassObject
来加载对应 DLL(LoadLibrary,提前注册好路径),进而获取到入口点:DllGetClassObject
(GetProcAddress),调用就可以得到对应的 object。
对于 object 的操作和上面的例子基本一致,COM 提供的,只是定位和加载对象的服务(it just provides the service of locating and loading the object)。
3.6 COM Object 在 DLL 中,加载使用 COM(2.0)
上一个例子只是展示了 COM 基础设施中最简单的,创建一个实例的部分,为了让我们的对象更像一个真实 COM 对象,还需要做这些事:
- 对于暴露接口使用引用技术
- 允许一个对象暴露多个接口
- 使用标准
IClassFactory
接口 - 使用
_stdcall
调用约定 - 允许动态卸载(unload)DLL
- 运行对象自注册(self-registration)
3.6.1 引用计数
如果多个 clients 同时使用一个 object,会存在内存管理的问题。如果其中一个 client 调用了Release
方法,就会导致 object 析构,其他 client 的访问就会出问题。
解决的办法,简单而经典,使用引用计数:每个 COM 对象持有一个计数器,表示对象引用的个数。当对象Release
的时候,计数器减一,当计数器为零的时候就可以放心的销毁了。
为了实现计数器的增减,需要有两个成员函数:
对于 client 而言,并不需要关注具体的引用计数,只需要在引用的时候增加计数,不用的时候减少计数,object 自己会通过引用计数管理自己。这对 client 使用而言,负担很小。
3.6.2 多接口
让我们假设一个场景:一个 object 想要为多个不同 client 返回不同接口。
client 当然需要一些方式来获取某个 object 的特定接口。对于 object 而言,可以借助传入不同的 IID 给DllGetClassObject
轻松实现,有点类似ClassFactory
。
但是如何区分,如果一个 object 里存在多个接口?
这就需要提供一个新的成员函数。
object 首先会检查接口 ID,并返回一个实现了给定功能的虚表指针(COM 对象都是实现多个接口,也就是虚抽象基类)。
QueryInterface
还提供了良好的后向兼容性,在调用前先检查,对于新接口而言,老的 DLL 并没有实现,会返回空,调用者可以替代请求一个老的接口,或者提供其他选择。
3.6.3 同时支持引用计数和多接口:IUnknown
COM 需要所有对象都实现上述三个函数,因此就需要一个“合同”(contract)。COM 于是定义了一个标准接口 ,IUnknown
。
所有的 COM 对象都需要继承这个虚抽象基类并实现三个给定函数。
这样就保证了接口的一致性。
3.6.4 标准类工厂接口:IClassFactory
类工厂需要检查 IID 并且实例化合适的 object,同时可以调用QueryInterface
查询特定接口。
在这里,区分类 ID (CLSID)和接口 ID(IID)很重要,类 ID 引用的是实现了特定接口的 object,接口 ID 是用于与 object 交流的特定 vtable(可以简单理解为 object 会实现多个接口,多个接口指向不同的 vtable)。
也有个标准接口可以做这件事。
不是所有情况下引用计数都有用,比如在 EXE 中,这时LockServer
就可以取到类似的效果。
3.6.5 动态卸载
使用隐式链接的时候,用 COM 加载的 DLL 没有办法主动卸载,因为LoadLibrary/FreeLibrary
无法使用。
COM 于是提供了一个替代的导出函数DllCanUnloadNow
做这件事。
我们可以使用一个全局的引用计数来实现它,当引用计数为 0 的时候就可以安全的卸载 DLL。
3.6.6 自注册
如果 object 可以自己实现注册,那无疑会很方便。
DllRegisterServer/DllUnregisterServer
可以轻松做到这件事。
3.7 COM 对象在独立进程
现在,我们基本了解了 COM 提供的机制,以及要解决的问题。
那么让我们走得更远一点,看看多进程环境下,COM 是如何被使用的。
- 如何在多个进程间共享 COM 对象
- 基于安全和可靠性的原因不想加载其他对象到内存空间
- 如何跨机器共享对象
COM 提供了一个简单的方案,与 vtable 类似,也是提供一个间接层。
调用者(proxy)将参数按需写入(marshal)内存,然后发送给其他进程,其他进程的被调用者(stub)从内存中取出(unmarshal)相应参数,然后调用具体函数,对于返回值以相同的方式发送给调用者。
看起来就是一个经典的 RPC 场景。
调用参数和返回值的 marshal/unmarshal 需要知道接口信息,这个都是定义在 IDL 文件中,有点类似 protobuf。
IDL 的处理有标准的工具,MIDL,proxy/stub 会自动生成。
4. 总结
利用 COM 可以:
- 以统一的方式打包对象,无论是 EXE 还是 DLL
- 将应用拆解为可复用的组件,每个都可以独立分发,降低耦合性并提高可维护性
- 允许将组件分发到不同的机器上,甚至可以实现高效的跨平台、跨语言的互操作
当你尝试开发新的组件的时候需要考虑:
- 是否能复用已有的接口,避免重复开发
- 尽可能让你的接口通用
5. 个人思考
技术,可以从技和术两个方面理解。
技是具体的,术是抽象的。
技指向的是解决问题,术指向的是思考问题。
COM 技术的核心,按照我的理解有这么几点:
- 可复用(解耦)
- 兼容性(间接层)
- 互操作(标准接口)
都是软件工程里典型的问题。
COM 提供了一个优雅的解决策略,无论在工作中你是否真正使用它,其思路和理念,都值得参考。
(完)
参考
- 本文作者:Plantree
- 本文链接:https://plantree.me/blog/2023/introduction-to-com/
- 版权声明:所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
最后更新于: 2024-11-20T09:44:17+08:00