COM简介
这两天看了一篇讲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。
typedef long HRESULT;
class CDB {
// Interfaces
public:
// Interface for data access
HRESULT Read(short nTable, short nRow, LPTSTR lpszData);
HRESULT Write(short nTable, short nRow, LPCTSTR lpszData);
// Interface for database management
HRESULT Create(short &nTable, LPCTSTR lpszName);
HRESULT Delete(short nTable);
// Interface for database information
HRESULT GetNumTables(short &nNumTables);
HRESULT GetTableName(short nTable, LPTSTR lpszName);
HRESULT GetNumRows(short nTable, short &nRows);
// Implementation
private:
CPtrArray m_arrTables;// Array of pointers to CStringArray (the "database")
CStringArray m_arrNames; // Array of table names
public:
~CDB();
};
我需要一个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的。
示例代码如下:
#define DEF_EXPORT _declspec(dllexport)
class CDB {
// Interfaces
public:
// Interface for data access
HRESULT DEF_EXPORT Read(short nTable, short nRow, LPTSTR lpszData);
HRESULT DEF_EXPORT Write(short nTable, short nRow, LPCTSTR lpszData);
ULONG DEF_EXPORT Release(); // Need to free an object from within the DLL.
};
class CDBSrvFactory {
// Interfaces
public:
HRESULT DEF_EXPORT CreateDB(CDB** ppObject);
ULONG DEF_EXPORT Release();
};
HRESULT DEF_EXPORT DllGetClassFactoryObject(CDBSrvFactory ** ppObject);
HRESULT CDBSrvFactory::CreateDB(CDB** ppObject) {
*ppObject = new CDB;
return NO_ERROR;
}
HRESULT DEF_EXPORT DllGetClassFactoryObject(CDBSrvFactory** ppObject) {
*ppObject = new CDBSrvFactory;
return NO_ERROR;
}
3.3 C++ Object暴露纯虚基类
存在问题:
- 上一种实现仍然会暴露细节,尽管那不是必要的,比如某些private成员,在头文件中依然可以看到。这对于以DLL(二进制)形式的分发而言,依然不够完美。
- 另外还存在接口升级导致兼容性的问题,如果新的Object增加了某些成员,内存布局就会发生,Client就需要重新编译。
因此我们可以暂时得出一个小的结论:细节了解越多,耦合程度就越深。对于软件开发而言,耦合加重意味着兼容性和可维护性变差。
有没有一种办法,我们不了解Object的内存布局,也可以使用它?毕竟对于Client而言,Object长啥样子并不需要关注,只要功能可用就行。
最直观的解决方式,就是在Object里不放置成员对象。但是没有成员变量我们怎么维护状态?通过继承。
简单来说,就是我们持有父类指针,但真实的函数调用发生在子类对象上。
C++提供了一种简单直白的方式,抽象基类(Abstract Base Class)。在COM中,这个东西叫interface。
对于C++而言没有接口(Interface)的概念,抽象基类可以在某种程度上理解成对于接口的抽象。其本身不能被实例化,只能被继承。
同时C++提供了运行时多态的能力,对于虚函数的调用,会自动匹配到合适的函数上。举个例子,两个子类继承同一个抽象基类,对同一个虚函数提供两套实现,此时,通过一个父类指针调用函数,真实的调用取决于父类指针指向的子类对象。
看似魔法的内部,是通过一套朴素的方式实现的:虚函数表。这一块内容完全值得另一篇文章......
下面看看具体实现。
接口定义。
class IDB {
// Interfaces
public:
// Interface for data access
virtual HRESULT Read(short nTable, short nRow, LPTSTR lpszData) =0;
virtual HRESULT Write(short nTable, short nRow, LPCTSTR lpszData) =0;
};
继承定义。
HRESULT CreateDB(IDB** ppObj);
class CDB : public IDB {
// Interfaces
public:
// Interface for data access
HRESULT Read(short nTable, short nRow, LPTSTR lpszData);
};
实现的细节都在继承类里,但是暴露出去的是接口的定义。
借助这样一种方式,不仅可以正常提供功能,而且不暴露底层细节。当然,这里多少会有些性能损耗,毕竟存在一次间接的函数调用(查虚函数表)。
3.4 C++ Object在DLL中暴露纯虚基类
上面的方式同样可以用在DLL打包中。
3.2的方式存在name mangling的问题,而且需要export所有的函数。
对于虚基类继承而言,这俩问题都不存在:纯虚基类没有数据成员,只有一个函数表的入口,而且函数调用是运行时动态查找,因此我们并不需要显示export成员函数。
一句话概括就是,3.2是静态绑定,所以需要export,3.4是动态绑定,所以不需要。
还是看具体代码。
接口定义。
class IDB {
// Interfaces
public:
// Interface for data access.
virtual HRESULT Read(short nTable, short nRow, LPWSTR lpszData) =0;
};
class IDBSrvFactory {
// Interface
public:
virtual HRESULT CreateDB(IDB** ppObject) =0;
virtual ULONG Release() =0;
};
HRESULT DEF_EXPORT DllGetClassFactoryObject(IDBSrvFactory** ppObject);
跟3.3基本类似,除了export了一个Factory方法,方便后续DLL调用。
继承实现。
class CDB : public IDB {
// Interfaces
public:
// Interface for data access.
HRESULT Read(short nTable, short nRow, LPWSTR lpszData);
};
class CDBSrvFactory : public IDBSrvFactory {
// Interface
public:
HRESULT CreateDB(IDB** ppObject);
ULONG Release();
};
HRESULT CDBSrvFactory::CreateDB(IDB** ppvDBObject) {
*ppvDBObject = (IDB*)new CDB;
return NO_ERROR;
}
HRESULT DEF_EXPORT DllGetClassFactoryObject(IDBSrvFactory** ppObject) {
*ppObject = (IDBSrvFactory*)new CDBSrvFactory;
return NO_ERROR;
}
对于C++ Client而言,只需要通过LoadLibrary/GetProcAddress就可以使用直接C++ Object,而它只需要知道接口定义个继承实现的DLL接口。没有name mangling,成员函数也不需要显示导出。
3.5 COM:C++ Object在DLL中,使用COM加载
上一个例子提供了一个非常灵活的机制用于对象打包:只需要在DLL中声明一个加载点(entry point)即可,其他的操作都是借助指向纯抽象基类的指针实现。
如果你想将多个对象打包到一个DLL中,你有两个选项:
- 每个class提供一个导出函数
- 提供一个标准导出函数,通过不同的传参得到不同结果
对于通用的组件模型而言,第二个选择无疑更加合适,这也是COM采取的方案:提供一个DllGetClassObject
导出函数。
STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID * ppObject);
这个函数接收一个类型为CLSID
(16字节,而且是独一无二,可理解为某种GUID)的参数,来指定调用者想要访问的class。DLL检查这个数字,如果存在就返回指向实际实现对象的指针。
借助这样一个全局导出函数,COM库就可以将我们的DLL按照对象来管理。接下来我们就需要,将具体的CLSID
和我们需要的对象所在DLL绑定在一起。约定的接口是CoGetClassObject
。
HRESULT CoGetClassObject(
REFCLSID rclsid,
DWORD dwClsContext, LPVOID pvReserved REFIID riid, LPVOID * ppv);
剩下的问题是,该函数怎么发现给定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的访问就会出问题。
解决的办法,简单而经典,使用引用计数:每个COIM对象持有一个一个计数器,表示多少个对象引用,当对象Release
的时候,计数器减一,当计数器为零的时候就可以放心的销毁了。
为了实现计数器的增减,需要有两个成员函数:
ULONG AddRef();
ULONG Release();
对于client而言,并不需要关注具体的引用计数,只需要在引用的时候增加计数,不用的时候减少技术,object自己会通过引用计数管理自己。这对client使用而言,负担很小。
3.6.2 多接口
让我们假设一个场景:一个object想要为多个不同client返回不同接口。
client当然需要一些方式来获取某个object的特定接口。对于object而言,可以借助传入不同的IID给DllGetClassObject
轻松实现,有点类似ClassFactory
。
但是如何区分,如果一个object里存在多个接口?
这就需要提供一个新的成员函数。
HRESULT QueryInterface(RIID riid, void** ppObj);
object首先会检查接口ID,并返回一个实现了给定功能的虚表指针(COM对象都是实现多个接口,也就是虚抽象基类)。
QueryInterface
还提供了良好的后向兼容性,在调用前先检查,对于新接口而言,老的DLL并没有实现,会返回空,调用者可以替代请求一个老的接口,或者提供其他选择。
3.6.3 同时支持引用计数和多接口:IUnknown
COM需要所有对象都实现上述三个函数,因此就需要一个“合同”(contract)。COM于是定义了一个标准接口 ,IUnknown
。
class IUnknown {
public:
virtual HRESULT QueryInterface(RIID riid, void** ppObj) =0;
virtual ULONG AddRef() =0;
virtual ULONG Release() =0;
};
所有的COM对象都需要继承这个虚抽象基类并实现三个给定函数。
这样就保证了接口的一致性。
3.6.4 标准类工厂接口:IClassFactory
类工厂需要检查IID并且实例化合适的object,同时可以调用QueryInterface
查询特定接口。
在这里,区分类ID (CLSID)和接口ID(IID)很重要,类ID引用的是实现了特定接口的object,接口ID是用于与object交流的特定vtable(可以简单理解为object会实现多个接口,多个接口指向不同的vtable)。
也有个标准接口可以做这件事。
class IClassFactory : public IUnknown {
virtual HRESULT CreateInstance(IUnknown *pUnkOuter,
REFIID riid, void** ppvObject) = 0;
virtual HRESULT LockServer(BOOL fLock) = 0;
};
不是所有情况下引用计数都有用,比如在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
- 版权声明: 本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接