从零开始的 COM - 第一部分






4.89/5 (112投票s)
一篇关于 COM 的文章。
引言
本文档讨论 COM 和 COM 组件。通过实现一个简单的 COM 组件,它解释了 COM 技术背后的一些细节。本文档首先提供一些关于 COM 的背景信息,然后引导读者在示例中实现并改进一个简单的 COM 组件。示例从在同一个文件中实现一个 COM 组件和一个 COM 客户端开始。示例的改进在于将 COM 组件与其客户端分离。客户端和组件首先被分离到两个不同的文件中,然后组件会被放入一个 DLL 中,该 DLL 可以被加载到客户端的地址空间中。最后的改进是将组件注册到 Windows 注册表中,这样客户端就不再与组件绑定,而是能够通过类工厂创建它。在接下来的插图中,组件与其客户端之间的关系用一条链表示,当组件通过类工厂创建时,这条链将被完全断开。在最后一部分,通过在新的 COM 组件中重用已实现的组件来解释 COM 封装。本文档仅涉及第一部分,其他部分将在另外两篇文章中进行解释。演示应用程序的代码(客户端 + 3 个组件服务器)与文章中解释的示例非常相似,并且只使用一个窗口来可视化组件本身。下图说明了第一部分和第二部分。
假设
您熟悉基本的 C++ 和 Windows 编程。
第一部分 - 背景
一种创建软件组件的二进制标准
在面向对象编程中,如果未使用通用的标准框架,不同供应商创建的软件对象无法相互交互。COM 或 Component Object Model 是一种用于创建与语言无关的软件组件的二进制标准,它旨在将应用程序分解为独立的模块。COM 的实现对用户(COM 客户端)是隐藏的,这意味着 COM 组件以二进制形式提供,并且已经编译、链接并准备好使用,它们只是一堆 1 和 0,也就是说,它们只是机器代码,可以在客户端与它们交互时执行。以下是 COM 的一些优点:
- 语言无关性(可以使用不同的语言创建 COM 组件)。
- 重用应用程序架构。
- 无需重新构建即可轻松扩展应用程序的功能。
COM 组件的功能
COM 中的对象或组件是任何通过接口机制暴露其功能的结构。在 C++ 应用程序中,接口被定义为抽象基类。接口是一个 C++ 类,其中只包含纯虚成员函数。这意味着接口不包含任何实现,只规定了供其他类实现的函数签名。纯抽象基类定义了 COM 所需的接口的特定内存结构,当我们定义一个纯抽象基类时,我们实际上是在定义一个内存块的布局。在派生类中实现抽象基类之前,不会为该结构分配内存。因此,在下面的代码片段中,IComponent
既是一个接口(因为它遵循 COM 规范的内存布局),也是一个纯抽象基类。
//----------------------------------------------------------------//
// Definition of component's functionallity through an interface //
//----------------------------------------------------------------//
// The keyword "interface" is just an alias for "struct"
interface IComponent
{
// Methods for the implementation of the component's functionalities:
virtual void __stdcall Function1()=0;
virtual void __stdcall Function2()=0;
virtual void __stdcall Function3()=0;
};
如以下图像所示,在 C++ 中构成 COM 组件接口的纯抽象基类定义的内存块分为两部分(顶部图说明了演示应用程序中的 Component2)。
- 虚函数表或 vtbl,它是一个指向虚函数实现的指针数组。
- 指向 vtbl 的指针,称为 vtbl 指针。
结论:在 COM 中,组件的功能通过它们的接口获得,这些接口是指向组件方法的地址或入口。
COM 对象和接口的常见需求(基本操作)
在一个支持多种功能和接口的组件中,需要能够轻松访问这些接口。另一个要求是客户端应该能够管理组件的存在,并在使用完组件后释放它。COM 组件是一个支持通过所谓的 IUnknown
接口进行这些操作的组件。通过继承和实现这个接口,COM 对象允许客户端访问两个基本操作:
- 通过
QueryInterface
方法在对象上的多个接口之间导航。 - 通过称为
AddRef
和Release
的方法处理的引用计数机制来控制对象的生命周期。
这三个方法构成了 IUnknown
接口,所有其他接口都继承自它。所有 COM 接口都必须继承自 IUnknown
。这意味着 vtbl 的前三个条目对于所有 COM 接口都是相同的。它们是 IUnknown
接口中三个方法实现的地址。因此,在下面的代码中,IComponent
接口通过继承 IUnknown
接口成为一个 COM 接口。
//------------------------------------------------------------// // Being a COM interface by inheriting from IUnknown interface// //------------------------------------------------------------// interface IComponent :public IUnknown; { virtual void __stdcall Function1()=0; virtual void __stdcall Function2()=0; virtual void __stdcall Function3()=0; };
因为每个 COM 组件都继承自 IUnknown
,所以拥有 IUnknown
接口指针的客户端不知道它拥有什么类型的接口指针,它只能使用 QueryInterface
方法查询其他接口。这就是为什么这个接口被称为未知接口或 IUnknown
。下图显示了 IComponent
接口在继承自 IUnknown
接口后成为 COM 接口。
结论:IUnknown
接口是 COM 中的基础接口,它包含所有接口和对象的基本操作。
COM 组件的类(实现)
当组件的功能通过 COM 接口由其方法(如 Function1
-Function3
)定义时,可以通过从新创建的 COM 接口派生一个类来定义组件的类。在下面的示例中,该类称为 CComponent
。
//----------------------------------// //Definition of the Component's class //----------------------------------// class CComponent:public IComponent { public: //-------------------------------------------------------------------------// //IUnknown methods,which are the basic oparation //required for all COM components: //-------------------------------------------------------------------------// virtual HRESULT __stdcall QueryInterface(const IID& iid,void** ppv); virtual ULONG __stdcall AddRef(); virtual ULONG __stdcall Release(); //-------------------------------------------------------// //The new methods, which the interface supports: //-------------------------------------------------------// virtual void __stdcall Function1(); virtual void __stdcall Function2(); virtual void __stdcall Function3(); private: // Component's member data long m_cRef;// The reference count variable };
下图说明了 CComponent
类的内存布局。
如图所示,像 CComponent
类的 "this
" 指针这样的接口指针指向 vtbl 指针。在 COM 中,组件仅通过方法访问,绝不能直接通过变量访问,纯抽象基类只有纯虚函数,不包含实例数据。为了调用组件的方法之一(例如 Function1
),可以使用 IUnknown
指针查询或请求一个指向 IComponent
接口的指针,并通过使用该指针,可以调用所需的方法。
//---------------------------------------------// //Getting a pointer to the IComponent interface: //---------------------------------------------// IComponent* pIComponent=NULL; //The "IID_IComponent" is the interface ID for the IComponent interface pIUnknown->QueryInterface(IID_IComponent,(void**) &pIComponent; // Calling the component's method: pIComponent->Function1();
如前所述,我们可以通过 QueryInterface
方法在 COM 对象上的接口之间导航。接口封装了组件的实现细节。每当我们想访问一个组件时,我们想获得一些所需的功能,所以很明显,在使用组件之前,我们已经知道了组件的功能。如果我们不知道组件有哪些功能,我们就无法使用它。这意味着组件的客户端应该知道组件支持哪些类型的功能或接口。每个 COM 接口都有一个接口标识符,客户端可以使用它来查询特定的接口。接口标识符是 128 位的值,在前面的代码片段中,IComponent
接口由 IID_IComponent
标识,这是 IComponent
接口的接口标识符。因此,每当客户端想使用组件的某个功能时,它应该知道哪个接口实现了该功能,并且在通过 QueryInterface
方法查询该接口时,必须提供一个接口标识符。组件可以与窗口类比,其接口与窗口的菜单类比。窗口菜单就像一个接口;它是通过鼠标指针选择其项目来获得各种功能的入口。事实上,演示应用程序中三个组件的接口就像菜单,并且可以通过菜单项访问组件方法。下图显示了其中一个组件。
窗口对象被用作组件的成员数据,以可视化组件,并且如图所示,该组件是一个窗口,其菜单充当其接口,并且可以通过菜单项使用鼠标指针(类似于接口指针)来访问组件的方法。
结论:COM 类是特定接口的特定实现。
COM 对象实例化
如前所述,获取组件功能唯一的方法就是通过其接口,所以一旦我们获得一个指向组件 IUnknown
接口的指针,我们就可以访问组件支持的所有其他接口(使用 QueryInterface
方法),并且因为所有 COM 接口都继承自 IUnknown
接口,所以每个接口指针也是一个 IUnknown
接口指针。COM 对象是通过使用 new
运算符创建的,并且可以通过将指向对象的指针转换为接口指针或 IUnknown
指针来获得创建对象的接口指针,如下所示:
//When new operator is used to allocate a single object, //it gives a pointer to that object IUnknown* pIUnknown = static_cast<IComponent*>(new CComponent);
接口标识符
接口标识符是一种 GUID
(全局唯一标识符)类型的结构。GUID 有两种格式:字符串和数字。在 Windows 注册表中,GUID 的字符串格式出现在各种位置,然而,当 GUID 在客户端应用程序或实际 COM 对象实现中使用时,需要数字表示。_GUID
结构定义在 basetyps.h 头文件中,如下所示:
//-------------------------------------------// // GUID definition: //------------------------------------------// typedef struct _GUID { unsigned long Data1; // 32 bits unsigned short Data2; // 16 bits unsigned short Data3; // 16 bits unsigned char Data4[8];// 8*8 = 64 bits //-----------------------------------------// // Total bits = 128 bits } GUID;
您的开发环境将包含一个名为 UUIDGEN.EXE 或 GUIDGEN.EXE 的工具,它会为您提供一个或多个 GUID,您可以将其包含在源代码中。我在本文的所有示例中都使用了 GUIDGEN.EXE 来创建 GUID。
从零开始的示例
现在,您已经能够创建一个简单的 COM 对象并让客户端使用其功能。使用以下步骤,您可以创建文章开头插图中所示的示例。
您可以下载每个部分的源代码,并复制粘贴一些部分以快速创建您的应用程序。
步骤 1
使用 AppWizard,创建一个简单的 Win32 控制台应用程序并选择一个空项目。此应用程序将作为 COM 客户端,同时也将包含组件本身。
第二步
创建一个扩展名为 "cpp" 的源文件并为其命名。我使用了名称 ClientAndComponent.cpp。
步骤 3
我们来为组件确定一个基本功能,例如,组件应该能够在屏幕上打印短语 "COM from scratch"。如前所述,COM 组件通过接口机制暴露其功能,因此需要创建一个接口,并且如果组件要成为 COM 组件,该接口应该继承自 IUnknown
接口。所以,让我们将接口命名为 IComponent
,让它继承自 IUnknown
接口,并在一个名为 Print
的方法中定义所需的功能。
//-------------------------// //Interface definition: //-------------------------// interface IComponent:IUnknown { //The new method for assigning component's functionality virtual void __stdcall Print(const char* msg)=0; };
步骤 4
为了标识 IComponent
接口,请使用开发环境中的 UUIDGEN.EXE 或 GUIDGEN.EXE 工具为其创建一个接口标识符。
//-----------------------------------------------// //Interface identifier, which is a 128-bit value.// //-----------------------------------------------// // {853B4626-393A-44df-B13E-64CABE535DBF} string format static const IID IID_IComponent = { 0x853b4626, 0x393a, 0x44df, //Data1,Data2,Data3 { 0xb1, 0x3e, 0x64, 0xca, 0xbe, 0x53, 0x5d, 0xbf } }; //Data4
步骤 5
可以通过从定义的接口(IComponent
)派生类来定义组件的类。
//----------------------------------// //Definition of the Component's class //----------------------------------// class CComponent:public IComponent { public: //---------------------------------------------------------------------------// //IUnknown methods,which are the basic oparation //required for all COM components: //---------------------------------------------------------------------------// virtual HRESULT __stdcall QueryInterface(const IID& iid,void** ppv); virtual ULONG __stdcall AddRef(){return 0;} virtual ULONG __stdcall Release(){return 0;} //-------------------------------------------------------// //The new method, which the interface should support. //-------------------------------------------------------// virtual void __stdcall Print(const char* msg); };
步骤 6
现在有必要实现这些方法。
步骤 7
创建一个函数来实例化组件类的对象。
步骤 8
最后一步是创建客户端,该客户端通过组件的接口使用组件的功能。
//----------------// // Client //----------------// void main() { IUnknown* pIUnknown=CreateInstance(); IComponent* pIComponent=NULL; pIUnknown->QueryInterface(IID_IComponent,(void**)&pIComponent); pIComponent->Print("COM from scratch."); }
IUnknown
接口方法的更好实现
-
AddRef 方法
为了控制组件类实例化对象的生命周期,需要一个变量作为引用计数。当组件在使用中时,此变量应递增,当客户端不再使用组件时,应递减。
AddRef()
和Release()
方法可用于递增和递减此变量。因此,通过向组件类添加一个私有成员变量并调用AddRef()
和Release()
方法,可以在组件不再需要时控制组件的生命周期,并在该变量达到零值时释放为组件分配的内存。因此,为了控制组件的生命周期,请重新定义组件类。// // Component // class CComponent : public IComponent { //IUnknown interface's methods: virtual HRESULT __stdcall QueryInterface(const IID& iid, void** ppv) ; // method for incrementing the reference count variable "m_cRef" virtual ULONG __stdcall AddRef(); // method for decrementing the reference count variable "m_cRef" virtual ULONG __stdcall Release(); //IComponent interface's method: virtual void __stdcall Print(const char* msg); public: CComponent() ;// Constructor ~CComponent();// Destructor private: long m_cRef ;// The reference count variable };
在构造函数中将引用计数变量初始化为零。
///////////////////////////////// CComponent::CComponent() { Print("Constructing the component...") ; m_cRef=0; } //////////////////////////////////////// CComponent::~CComponent() { Print("Destructing the component...") ; }
实现
AddRef()
方法。////////////////////////////////////// ULONG __stdcall CComponent::AddRef() { Print("Incrementing the reference count variable..."); return InterlockedIncrement(&m_cRef); }
-
Release() 方法
如上所述,
Release()
方法用于递减引用计数,在该方法中,如果引用计数达到零,可以通过删除this
指针来销毁组件对象。////////////////////////////////////// ULONG __stdcall CComponent::Release() { Print("Decrementing the reference count variable..."); if(InterlockedDecrement(&m_cRef) == 0) { delete this ; return 0 ; } return m_cRef ; }
-
QueryInterface 方法
程序中
QueryInterface
方法的一个限制是它不会告知客户端是否查询了不支持的接口。HRESULT
是 COM 错误报告中的关键类型,它是一个简单的 32 位值。COM 组件使用HRESULT
向其客户端报告情况。与其他 COM 接口一样,QueryInterface
方法返回一个HRESULT
。HRESULT
的最高有效位(严重性字段)报告函数调用是成功还是失败。最后 16 位包含函数返回的代码。另外两位保留供将来使用,其余 13 位提供有关返回代码类型和来源的更多信息。下图说明了这一点。因此,使用以下实现,组件将能够告知其客户端某个特定接口是否不受组件支持。
/////////////////////////////////////////////////////////////////////////// HRESULT __stdcall CComponent::QueryInterface(const IID& iid, void** ppv) { if (iid == IID_IUnknown) { Print("Returning pointer to IUnknown...") ; *ppv = static_cast<IComponent*>(this) ; } else if (iid == IID_IComponent) { Print("Returning pointer to IComponent interface...") ; *ppv = static_cast<IComponent*>(this) ; } else { Print("Interface is not supported!.") ; *ppv = NULL ; return E_NOINTERFACE ; } //The reinterpret_cast operator allows any pointer to be converted into //any other pointer type. reinterpret_cast<IUnknown*>(*ppv)->AddRef() ; // Incrementing the Reference count variable return S_OK ; }
现在客户端可以通过调用组件的
Release()
方法来释放组件。//----------------// // Client //----------------// void main() { IUnknown* pIUnknown=CreateInstance(); IComponent* pIComponent=NULL; pIUnknown->QueryInterface(IID_IComponent,(void**)&pIComponent); pIComponent->Print("COM from scratch."); pIComponent->Release();// Releasing the Component }
下面的屏幕截图来自第一部分的示例。
摘要
- 通过组件构建应用程序有很多优点。
- 应该使用一种通用标准来创建软件组件,使其与语言无关,而 COM 是创建软件组件的二进制标准。
- 就像硬件组件一样,一些功能被分配给软件组件。
- COM 组件的功能通过接口机制获得。
- 在 C++ 应用程序中,接口被定义为抽象基类。
- C++ 编译器为纯抽象基类生成的内存布局与 COM 对接口所需的内存布局相同。
- 由于纯抽象基类只有纯虚函数且不包含实例数据,因此 COM 组件仅通过其方法访问,而绝不能直接通过成员变量访问。
- 通过继承
IUnknown
接口,一个接口就变成了一个 COM 接口,该接口包含三个方法:QueryInterface
,用于在对象上的多个接口之间导航,并返回指向被查询接口的指针。AddRef
,用于控制对象的生命周期。Release
,用于控制对象的生命周期。
- 在 COM 中,每个接口指针也是一个
IUnknown
接口指针。 - COM 组件是通过使用
new
运算符创建的。 - COM 组件使用
HRESULT
类型向其客户端报告情况。 - 每个接口都由一个接口标识符标识,这是一个 128 位的值,类型为 GUID。
- C++ 类和 COM 类之间有一个重要的区别。在 C++ 中,类是一种类型,而 COM 类仅仅是对对象的定义,不携带类型信息,尽管 C++ 程序员可能会使用 C++ 类来实现它。
第二部分将在下一篇文章中介绍。