COM简介

2023-05-17 pv

这两天看了一篇讲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暴露纯虚基类

存在问题:

  1. 上一种实现仍然会暴露细节,尽管那不是必要的。比如某些private成员,在头文件中依然可以看到。这对于以DLL(二进制)形式的分发而言,依然不够完美
  2. 另外还存在接口升级导致兼容性的问题:如果新的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的访问就会出问题。

解决的办法,简单而经典,使用引用计数:每个COM对象持有一个计数器,表示对象引用的个数。当对象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提供了一个优雅的解决策略,无论在工作中你是否真正使用它,其思路和理念,都值得参考。

(完)

参考

  1. From CPP to COM🔗
  2. 维基百科-组件对象模型🔗
  3. 怎么通俗的解释COM组件?🔗
在 GitHub 上编辑本页面

最后更新于: 2024/03/04 06:51:47