COM编程大致梳理
比较旧的东西,以前写的文章现在发出来,转载请注明~!
1 COM编程思想--面向组件编程思想
1.1 面向组件编程
众所周知,由C到C++,实现了由面向过程编程到面向对象编程的过渡。而COM的出现,又引出了面向组件的思想。其实,面向组件思想是面向对象思想的一种延伸和扩展。
下面,我就简单介绍一下面向组件的思想。在以前,应用程序总是被编写成一个单独的模块,就是说一个应用程序就是一个单独的二进制文件。后来在引入了面向组件的编程思想后,原本单个的应用程序文件被分隔成多个模块来分别编写,每个模块具有一定的独立性,也应具有一定的与本应用程序的无关性。一般来说,这种模块的划分是以功能作为标准的。这样做的好处有很多,比如当对软件进行升级的时候,只要对需要改动的模块进行升级,然后用重新生成的一个新模块来替换掉原来的旧模块(但必须保持接口不变),而其他的模块可以完全保持不变。
总结一下:面向组件编程思想,归结起来就是四个字:模块分隔。这里的“分隔”有两层含义,第一就是要“分”,也就是要将应用程序(尤其是大型软件)按功能划分成多个模块;第二就是要“隔”,也就是每一个模块要有相当程度的独立性,要尽量与其他模块“隔”开。这四个字是面向组件编程思想的精华所在,也是COM的精华所在!
1.2 COM的几个重要概念
1.2.1 组件
上面已经解释过组件,现在我只想强调一下组件需要满足的一些条件。首先是封装性,组件必须向外部隐藏其内部的实现细节,使从外部所能看到的只是接口。然后是组件必须能动态链接到一起,而不必像面向对象中的class一样必须重新编译。现在我只想强调一下组件需要满足的一些条件。首先是封装性,组件必须向外部隐藏其内部的实现细节,使从外部所能看到的只是接口。然后是组件必须能动态链接到一起,而不必像面向对象中的class一样必须重新编译。
1.2.2 接口
由于组件向外部隐藏了其内部的细节,因此客户要使用组件时就必须通过一定的机制,也就是说要通过一定的方法来实现客户与组件之间的通信,这就需要接口。所谓接口就是组件对外暴露的、向外部客户提供服务的“连接点”。外部的客户见不到组件内部的细节,它所能看到的只是接口,这有点像OSI网络协议分层模型,每一层就像一个组件,它内部的实现细节对于其他层是不可见的;而每一层通过“服务接入点”向其上层提供服务。
1.2.3 客户
这里所说的客户不是指使用软件的用户,而是指要使用某一个组件的程序或模块。也就是说,这里的客户是相对组件来说的。
2 COM原理
2.1 COM与虚函数列表
COM中的接口实际上是一个函数地址表,当组件实现了这个接口后,这个函数地址表中就填满了组件所实现的那些接口函数的地址。而客户也就是通过这个函数地址表获得组件中那些接口函数的指针,从而获得组件所提供的服务的。从某种意义上说,我们可以把接口理解为c++中的虚拟基类;或者说,在c++中可以用虚拟基类来实现接口!这是因为COM中规定的接口的存储结构,和c++中的虚拟基类在内存中的结构是一致的,我们可以简单的用纯粹的C++的语法形式来描述COM是个什么东西:
class IObject
{
public:
virtual Function1(...) = 0;
virtual Function2(...) = 0;
....
};
class MyObject : public IObject
{
public:
virtual Function1(...){...}
virtual Function2(...){...}
....
};
IObject就是我们常说的接口,MyObject就是所谓的COM组件。记住接口都是纯虚类。COM中所有函数都是虚函数,都必须通过虚函数表VTable来调用,这一点是无比重要的,必需时刻牢记在心。为了让大家确切了解一下虚函数表是什么样子,从《COM+技术内幕》中COPY了下面这个示例图:
2.2 COM基本接口类
2.2.1 IUnknown
COM规范规定任何接口都必须从IUnknown继承,IUnknown包含三个函数,分别是 QueryInterface、AddRef、Release。这三个函数是无比重要的,而且它们的排列顺序也是不可改变的。
引用计数。AddRef用于增加引用计数。Release用于减少引用计数。首先我们考虑com对象只实现一个接口的情况,不妨把接口成为IsomeInterface, 因为IsomeInterface继承与IUnknown,所以ISomeInterface接口成员函数中包含IUnknown的三个函数。假设有一个客户程序的许多个逻辑模块使用到了该com对象, 从而在客户程序很多地方保持了该接口指针的引用, 比如说有三个地方分别用了pSomeInterface1 pSomeInterface2, pSomeInterface3只想该接口指针。 在客户程序这三个逻辑块中, 它可以调用接口成员函数一伙的接口所提供的服务,如果他一直要该接口提供的服务,把他就需要控制该对象使它一直保持的内存中。如果用完了该对象,那他就应该通知接口不再需要服务。 由于每个逻辑模块并不知道其他的逻辑模块是否在继续使用COM对象, 他们只能知道自己是否还需要该对象。而对于COM对象来说, 只要有任意一个逻辑模块还需要使用它, 那么他就必须驻留在内存中不能释放自己。COM采用了引用计数来解决这个问题。 当客户得到一个指向该对象接口的指针时,计数加1,用完计数减1;当对接口指针复制或赋值,引用计数加1.如果一个COM对象实现了多个接口,则可以采用同蝼蚁计数计数。只要计数不为0 它就继续生存下去,反之,则表示客户不再使用该对象,它就可以被清除了。
接口查询。当客户创建COM对象之后, 创建函数总会为我们返回一个接口指针, 因为搜有的接口都继承了IUnknown,所以我们就可以通过QueryInterface一个接口指针。
接口原则性:
(1)对于同一个COM对象的不同接口,查询到的IUnknown接口必须完全相同,也就是说每个IUnknown接口指针是唯一的,因此对于两个接口指针我们可以通过判断其查询到的IUnknown指针是否相同来判断他们是否指向同一个对象。 反之如果查询的不是IUnknown接口,而是其他接口,则通过不同而途径得到的接口指针允许不一样。这就允许有的对象可以在必要的时候才动态生成接口指针, 不用的时候可以把接口指针释放掉。
(2)接口对成型。对每一个接口查询其自身总应该成功。
(3)自反省。如果从一个接口指针查询到另一个接口指针, 则从第二个接口指针再回到第一个接口指针也必定成功。
(4)接口的传递性
(5)接口查询时间无关性
我们来看看 QueryIInterface的实现方法, 我们考虑这种支持多接口对象的的实现方法。在c++中实现多接口COm对象有两种简单方法, 一种是使用多重继承,八所支持的接口类作为基类,然后在对象类中实现接口函数。另一种试试先内嵌接口类成员。 我们这里使用多重集成的办法实现多个接口支持。
我们用字典对象做列子,字典对象实现两个接口:IDictionary和ISpellCheck首先我们来看一看
接口的转换过程中存在虚列表的裁剪问题。
2.2.2 IClassFactory
IClassFactory的作用是创建COM组件。COM类用一个全局唯一的ID(GUID)来标识,称为CLSID,COM利用类厂(ClassFactory)来得到实例化的COM对象。系统用公共空间保存所有可被重用的COM类的CLSID和其具体位置的对应(在Windows下是保存在注册表中),这样所有的用户只要知道CLSID,都能顺利找到COM类。然后COM类可以利用类厂生成COM对象。COM类和类厂的实现代码可以在DLL中,也可以在EXE中,可以在本地,也可以在网络的另一端。COM对象要实现多个接口(Interface),每个接口都包含一组函数,也用一个GUID来标识,称为IID。QueryInterface()就可以根据IID来得到接口指针。IClassFactory最重要的一个函数就是CreateInstance,顾名思议就是创建组件实例,一般情况下我们不会直接调用它,API函数都为我们封装好它了。
COM规定,每个COM对象类应该有一个相应的类厂对象,如果一个组件实现了多个COM对象类,则会有多个类厂。记下来我们看看类厂是如何避实用的。因为类厂本身也是一个COM对象,他被用于其他COM对象的创建过程。那么类厂对象又是谁创建的呢?答案是DllGetClassObject函数, 这个函数并不是COM库函数,而是一个组件程序的导出函数(类似DLL导出函数),我们来看下一下这个函数(ATL中自动生成)
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID* ppv)
{
return _AtlModule.DllGetClassObject(rclsid, riid, ppv);
}
在COM库中有三个函数用于COM接口的创建,他们分别是CoGetClassObject,CoCreateInstance,CoCreateInstanceEx。CoGetClassObject一般用来创建类厂接口。我们一般使用CoCreateInstance来创建COM接口指针。但是CoCreateInstance不能创建远程机器上对象如果要创建远程对象要使用CoCreateInstanceEx。
2.2.3 IDispatch
它的作用何在呢?除了C++还有很多别的语言,比如VB、 VJ、VBScript、JavaScript等等。可以这么说,如果没有这么多乱七八糟的语言,那就不会有IDispatch。COM组件是C++类,是靠虚函数表来调用函数的,对于VC来说毫无问题,这本来就是针对C++而设计的,以前VB不行,现在VB也可以用指针了,也可以通过VTable来调用函数了,VJ也可以,但还是有些语言不行,那就是脚本语言,典型的如 VBScript、JavaScript。不行的原因在于它们并不支持指针。现在网页上用的都是这些脚本语言,而分布式应用也是COM组件的一个主要市场,它不得不被这些脚本语言所调用,既然虚函数表的方式行不通,只能另寻他法,IDispatch应运而生。 调度接口把每一个函数每一个属性都编上号,客户程序要调用这些函数属性的时侯就把这些编号传给IDispatch接口就行了,IDispatch再根据这些编号调用相应的函数,仅此而已。当然实际的过程远比这复杂,仅给一个编号怎么调用一个函数,还要知道要调用的函数要带什么参数,参数类型什么以及返回什么东西,而要以一种统一的方式来处理这些问题是件很头疼的事。IDispatch接口的主要函数是Invoke,客户程序都调用它,然后Invoke再调用相应的函数,如果看看MS的类库里实现 Invoke的代码就会惊叹它实现的复杂了,因为必须考虑各种参数类型的情况,所幸我们不需要自己来做这件事。在ATL中
2.3 CLSID
CLSID其实就是一个号码,或者说是一个16字节的数。观察注册表,在HKEY_CLASSES_ROOT\CLSID\{......}主键下,LocalServer32(DLL组件使用InprocServer32) 中保存着程序路径名称。CLSID 的结构定义如下:
typedef struct _GUID {
DWORD Data1; // 随机数
WORD Data2; // 和时间相关
WORD Data3; // 和时间相关
BYTE Data4[8]; // 和网卡MAC相关
} GUID;
typedef GUID CLSID; // 组件ID
typedef GUID IID; // 接口ID
2.4 COM组件核心IDL
COM具有语言无关性,它可以用任何语言编写,也可以在任何语言平台上被调用。但至今为止我们一直是以C++的环境中谈COM,那它的语言无关性是怎么体现出来的呢?或者换句话说,我们怎样才能以语言无关的方式来定义接口呢?前面我们是直接用纯虚类的方式定义的,但显然是不行的,除了C++谁还认它呢?正是出于这种考虑,微软决定采用IDL来定义接口。说白了,IDL实际上就是一种大家都认识的语言,用它来定义接口,不论放到哪个语言平台上都认识它。
什么是IDL和MIDL?
IDL是接口定义语言。MIDL是Microsoft的IDL编译器。在用IDL对接口和组件进行了描述后,可以用MIDL进行编译,生成相应的代理和存根DLL的C代码。为得到一个代理/存根DLL,需要编译和链接MIDL生成的C文件。宏REGISTER_PROXY_DLL将完成代理/存根DLL在注册表中的注册操作。
客户与一个模仿组件的DLL进行通信,这个DLL可以完成参数的列集,此组件被称为代理。一个代理就是同另一个组件行为相同的组件组件还需要一个存根的DLL,以便对从客户传来的数据进行散集。存根也将对传回给客户的数据进行列集。
2.5 COM组件运行机制
构造一个创建COM组件的最小框架结构
IUnknown *pUnk=NULL;
IObject *pObject=NULL;
CoInitialize(NULL);
CoCreateInstance(CLSID_Object,
CLSCTX_INPROC_SERVER, NULL, IID_IUnknown, (void**)&pUnk);
pUnk->QueryInterface(IID_IOjbect,
(void**)&pObject);
pUnk->Release();
pObject->Func();
pObject->Release();
CoUninitialize();
这就是一个典型的创建COM组件的框架,看看CoCreateInstance内部做了一些什么事情。以下是它内部实现的一个伪代码:
CoCreateInstance(....)
{
.......
IClassFactory *pClassFactory=NULL;
CoGetClassObject(CLSID_Object,
CLSCTX_INPROC_SERVER, NULL, IID_IClassFactory, (void **)&pClassFactory);
pClassFactory->CreateInstance(NULL,
IID_IUnknown, (void**)&pUnk);
pClassFactory->Release();
........
}
它的意思就是先得到类厂对象,再通过类厂创建组件从而得到IUnknown指针。继续深入一步,看看CoGetClassObject的内部伪码:
CoGetClassObject(.....)
{
//通过查注册表CLSID_Object,得知组件DLL的位置、文件名
//装入DLL库
//使用函数GetProcAddress(...)得到DLL库中函数DllGetClassObject的函数指针。
//调用DllGetClassObject
}
DllGetClassObject是干什么的,它是用来获得类厂对象的。只有先得到类厂才能去创建组件.
下面是DllGetClassObject的伪码:
DllGetClassObject(...)
{
......
CFactory* pFactory= new
CFactory; //类厂对象
pFactory->QueryInterface(IID_IClassFactory,
(void**)&pClassFactory);
//查询IClassFactory指针
pFactory->Release();
......
}
CoGetClassObject的流程已经到此为止,现在返回CoCreateInstance,看看CreateInstance的伪码:
CFactory::CreateInstance(.....)
{
...........
CObject *pObject = new
CObject; //组件对象
pObject->QueryInterface(IID_IUnknown,
(void**)&pUnk);
pObject->Release();
...........
}
//见MyCom的例子
2.6 连接点
COM 中的典型方案是让客户端对象实例化服务器对象,然后调用这些对象。然而,没有一种特殊机制的话,这些服务器对象将很难转向并回调到客户端对象。COM 连接点便提供了这种特殊机制,实现了服务器和客户端之间的双向通信。使用连接点,服务器能够在服务器上发生某些事件时调用客户端。
有了连接点,服务器可通过定义一个接口来指定它能够引发的事件。服务器上引发事件时,要采取操作的客户端会向服务器进行自行注册。随后,客户端会提供服务器所定义接口的实现。客户端可通过一些标准机制向服务器进行自行注册。COM 为此提供了 IConnectionPointContainer 和 IConnectionPoint 接口。
[toc]## 一. 深入浅出COM编程COM是一种面向组件的编程。面向组件编程思想是面向对象的一种延伸和扩展,其核心归结起来就是四个字:模块分隔。所谓“分隔”有两层含义,首先是“分”,也就是将应用程序按功能划分成多个模块;第二就是要“隔”,也就是每个模块要有相当程度的独立性,尽量与其他模块“隔”开。我们也可以将COM看成一种标准,它能提供一套跨语言的二进制接口。COM是一套非常复杂与庞大的技术体系,本文只讲解COM的基本原理与应用。虽然COM技术历史悠久,但是Windows系统中仍然大量使用COM技术,其面向组件编程思想并没有过时,仍然值得借鉴。
### 1. COM中几个概念1)组件。上面解释过组件,组件应该具有封装性。组件必须向外部隐藏其内部的实现细节,使从外部所能看到的只是接口。组件具有封装性,组件需向外部隐藏其内部实现细节,使外部所能看见的只是接口。
2)接口。由于组件向外部隐藏了其内部的细节,因此客户使用组件时必须通过一定的机制,也就是说要通过某种方法实现客户与组件之间的通信,这就需要接口。外部客户看不到组件内部细节,它所能看到的只是接口。这有点类似OSI网络协议分层模型,每一层就像一个组件,它的内部实现细节对其它层是不可见的,每一层通过服务接入点向上层提供服务。
3)客户。这里所说的客户是指要使用某个组件的程序或者模块,此处的客户是相对于组件来说的。
### 2. COM原理#### 2.1. COM与虚函数列表Windows中COM组件大多通过C++实现。COM中的接口实际上是一个函数地址表,当组件实现这个接口后,这个函数地址表中就填满了组件所实现的那些接口函数地址。而客户也是通过这个函数地址表获得组件中那些接口函数的指针,从而获得组件所提供服务。从某种意义上说,我们可以把接口理解为C++中的虚基类,或者说,在C++中可以用虚基类来实现接口。这是因为COM中规定的接口的存储结构和C++中的虚函数指针列表在内存中的结构是一致的。我们可以简单的用纯粹的c++语法来描述COM是什么:```c++class IObject{public:virtual Function1(...) = 0;virtual Function2(...) = 0; //.... };
class MyObject : public IObject{public:virtual Function1(...){...}virtual Function2(...){...}//...};```IObject就是我们常说的接口,Myobject就是所谓的COM组件,记住接口都是纯虚函类。COM中所有函数都是虚函数,都必须通过虚函数表V-Table(virtual table)来调用,这一点很重要。关于C++的虚函数表,为了方便理解简单画了个示例图:![图2--1](http://km.oa.com/files/photos/pictures/202008/1598166602_43_w735_h521.png)
#### 2.2. COM基本接口类##### a. IUnknownCOM规范规定任何接口都必须从IUnknown继承,IUnknown包含三个函数,分别是 QueryInterface、AddRef、Release。这三个函数是无比重要的,而且它们的排列顺序也是不可改变的。AddRef用于增加引用计数,Release用于减少引用计数,COM组件使用引用计数管理其生命周期。QueryInterface用于接口查询,一个COM组件可以实现多个接口,QueryInterface可以支持查询其它接口。
我们来看看QueryIInterface的实现方法,考虑支持多接口的COM组件。我们用字典对象做例子,字典对象实现两个接口:IDictionary和ISpellCheck。```c++class CDictionary : public IDictionary, public ISpellCheck{public:CDictionary();~CDictionary();public://IUnknown member functionvirtual HRESULT QueryInterface(const IID& iid, void** ppv);virtual ULONG AddRef();virtual ULONG Release();//IDictionary member function virtual BOOL Initialize();//ISpellCheck member functionvirtual BOOL CheckWord(string word);//some other functions ....//......private:struct DictWord* m_pData;char* m_pDictFileName[128];int m_nRef;};```上述CDictionary对象模型如下图所示:![enter image description here](http://km.oa.com/files/photos/pictures/202008/1598191732_7_w723_h361.png)根据上图,我们实现的QueryInterface函数如下:```c++HRESULT CDictionary::QueryInterface(const IID& iid, void** ppv){if (iid == IID_IUnknown){*ppv = (CDictionary*) this;AddRef();}else if(iid == IID_Dictionary){*ppv = (IDictionary*)this;((IDictionary*)(*ppv))->AddRef();}else if (iid == IID_SpellCheck){*ppv = (ISpellCheck*)this;((ISpellCheck*)(*ppv))->AddRef();}else{*ppv = NULL;return E_NOINTERFACE;}return S_OK;}```根据上面CDictionary的对象模型,我们知道此处继承必定**不能使用虚继承**,否则就不能保证IDictionary和ISpellCheck的v-table与COM接口所要求的v-table的一致性了。上面的CDictionary的内存结构必须建立如下图左边这样的基类树结构基础上:![enter image description here](http://km.oa.com/files/photos/pictures/202008/1598191641_23_w756_h461.png)
COM接口在调用QueryInterface的过程中存在虚列表裁剪。
##### b. IClassFactoryIClassFactory的作用是创建COM组件。COM类用一个全局唯一的ID(GUID)来标识,称为CLSID,COM利用类厂(ClassFactory)来得到实例化的COM对象。系统用公共空间保存所有可被重用的COM类的CLSID和其具体位置的对应(在Windows下是保存在注册表中),这样所有的用户只要知道CLSID,都能顺利找到COM类。然后COM类可以利用类厂生成COM对象。COM类和类厂的实现代码可以在DLL中,也可以在EXE中,可以在本地,也可以在网络的另一端。COM对象要实现多个接口(Interface),每个接口都包含一组函数,也用一个GUID来标识,称为IID。QueryInterface()就可以根据IID来得到接口指针。IClassFactory最重要的一个函数就是CreateInstance,顾名思议就是创建组件实例,一般情况下我们不会直接调用它,API函数都为我们封装好它了。
COM规定,每个COM对象类应该有一个相应的类厂对象,如果一个组件实现了多个COM对象类,则会有多个类厂。记下来我们看看类厂是如何避实用的。因为类厂本身也是一个COM对象,他被用于其他COM对象的创建过程。那么类厂对象又是谁创建的呢?答案是DllGetClassObject函数, 这个函数并不是COM库函数,而是一个组件程序的导出函数(类似DLL导出函数),我们来看下一下这个函数(ATL中自动生成)```c++STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID* ppv){return _AtlModule.DllGetClassObject(rclsid, riid, ppv);}```在COM库中有三个函数用于COM接口的创建,他们分别是CoGetClassObject,CoCreateInstance,CoCreateInstanceEx。CoGetClassObject一般用来创建类厂接口。我们一般使用CoCreateInstance来创建COM接口指针。但是CoCreateInstance不能创建远程机器上对象如果要创建远程对象要使用CoCreateInstanceEx。##### c. IDispatchIDispatch接口是组件对象模型(COM)中的标准接口,也称自动化(Automation)接口,继承自IUnknown。它的作用何在呢?除了C++还有很多别的语言,比如VB、 VJ、VBScript、JavaScript等等。可以这么说,如果没有这么多乱七八糟的语言,那就不会有IDispatch。COM组件是C++类,是靠虚函数表来调用函数的,对于VC来说毫无问题,这本来就是针对C++而设计的,但还是有些语言不行,那就是脚本语言,典型的如VBScript、JavaScript。不行的原因在于它们并不支持指针。现在网页上用的都是这些脚本语言,而分布式应用也是COM组件的一个主要市场,它不得不被这些脚本语言所调用,既然虚函数表的方式行不通,只能另寻他法,IDispatch应运而生。调度接口把每一个函数每一个属性都编上号,客户程序要调用这些函数属性的时侯就把这些编号传给IDispatch接口就行了,IDispatch再根据这些编号调用相应的函数,仅此而已。当然实际的过程远比这复杂,仅给一个编号怎么调用一个函数,还要知道要调用的函数要带什么参数,参数类型什么以及返回什么东西,而要以一种统一的方式来处理这些问题是件很头疼的事。IDispatch接口的主要函数是Invoke,客户程序都调用它,然后Invoke再调用相应的函数,如果看看MS的类库里实现 Invoke的代码就会惊叹它实现的复杂了,因为必须考虑各种参数类型的情况,所幸我们不需要自己来做这件事,而且可能永远也没这样的机会。
#### 2.3. CLSID & IIDCLSID其实就是一个号码,或者说是一个16字节的数。观察注册表,在HKEY_CLASSES_ROOT\CLSID\{......}主键下,LocalServer32(DLL组件使用InprocServer32) 中保存着程序路径名称。CLSID 的结构定义如下:```c++typedef struct _GUID { DWORD Data1; // 随机数 WORD Data2; // 和时间相关 WORD Data3; // 和时间相关 BYTE Data4[8]; // 和网卡MAC相关} GUID;
typedef GUID CLSID; // 组件ID,COM对象标识typedef GUID IID; // 接口ID```#### 2.4. COM组件核心IDLCOM具有语言无关性,它可以用任何语言编写,也可以在任何语言平台上被调用。但至今为止我们一直是以C++的环境中谈COM,那它的语言无关性是怎么体现出来的呢?或者换句话说,我们怎样才能以语言无关的方式来定义接口呢?前面我们是直接用纯虚类的方式定义的,但显然是不行的,除了C++谁还认它呢?正是出于这种考虑,微软决定采用IDL来定义接口。说白了,IDL实际上就是一种大家都认识的语言,用它来定义接口,不论放到哪个语言平台上都认识它。
什么是IDL和MIDL?
IDL是接口定义语言。MIDL是Microsoft的IDL编译器。在用IDL对接口和组件进行了描述后,可以用MIDL进行编译,生成相应的代理和存根DLL的C代码。为得到一个代理/存根DLL,需要编译和链接MIDL生成的C文件。宏REGISTER_PROXY_DLL将完成代理/存根DLL在注册表中的注册操作。
COM中传递参数的过程是一个打包/拆包的过程,打包过程叫列集,拆包过程就叫散集。COM里,进程外组件用到代理(proxy)和存根(stub)。客户与一个模仿组件的DLL进行通信,这个DLL可以完成参数的列集,此组件被称为代理。一个代理就是同另一个组件行为相同的组件。组件还需要一个存根的DLL,以便对从客户传来的数据进行散集。存根也将对传回给客户的数据进行列集。
#### 2.5. COM组件运行机制构造一个创建COM组件的最小框架结构:```c++CoInitialize(NULL);IUnknown *pUnk=NULL;IObject *pObject=NULL;CoCreateInstance(CLSID_Object, CLSCTX_INPROC_SERVER, NULL, IID_IUnknown, (void**)&pUnk);pUnk->QueryInterface(IID_IOjbect, (void**)&pObject);pUnk->Release();pObject->Func();pObject->Release();CoUninitialize();```这就是一个典型的创建COM组件的框架,看看CoCreateInstance内部做了一些什么事情。以下是它内部实现的一个伪代码:```c++CoCreateInstance(....){ ....... IClassFactory *pClassFactory=NULL; CoGetClassObject(CLSID_Object, CLSCTX_INPROC_SERVER, NULL, IID_IClassFactory, (void **)&pClassFactory); pClassFactory->CreateInstance(NULL, IID_IUnknown, (void**)&pUnk); pClassFactory->Release(); ........} ```它的意思就是先得到类厂对象,再通过类厂创建组件从而得到IUnknown指针。继续深入一步,看看CoGetClassObject的内部伪码:```c++ CoGetClassObject(.....) { //通过查注册表CLSID_Object,得知组件DLL的位置、文件名 //装入DLL库 //使用函数GetProcAddress(...)得到DLL库中函数DllGetClassObject的函数指针。 //调用DllGetClassObject }```DllGetClassObject是干什么的,它是用来获得类厂对象的。只有先得到类厂才能去创建组件。下面是DllGetClassObject的伪码:```c++DllGetClassObject(...){ ...... CFactory* pFactory= new CFactory; //类厂对象 pFactory->QueryInterface(IID_IClassFactory, (void**)&pClassFactory); //查询IClassFactory指针 pFactory->Release(); ......}```CoGetClassObject的流程已经到此为止,现在返回CoCreateInstance,其内部调用CreateInstance,看看CreateInstance的伪码:```c++CFactory::CreateInstance(.....){ ........... CObject *pObject = new CObject; //组件对象 pObject->QueryInterface(IID_IUnknown, (void**)&pUnk); pObject->Release(); ...........} ```具体可以参考下面Mycom的测试例子。#### 2.6. 连接点 COM 中的典型方案是让客户端对象实例化服务器对象,然后调用这些对象。然而,没有一种特殊机制的话,这些服务器对象将很难转向并回调到客户端对象。COM连接点便提供了这种特殊机制,实现了服务器和客户端之间的双向通信。使用连接点,服务器能够在服务器上发生某些事件时调用客户端。
有了连接点,服务器可通过定义一个接口来指定它能够引发的事件。服务器上引发事件时,要采取操作的客户端会向服务器进行自行注册。随后,客户端会提供服务器所定义接口的实现。客户端可通过一些标准机制向服务器进行自行注册。COM为此提供了IConnectionPointContainer和IConnectionPoint 接口。IConnectionPointContainer比较简单,它暴露给客户,用于查询组件是否支持当前连接点。IConnectionPoint 用于连接点管理。客户实现连接点需要实现一个接收器,此处不细讲,参考下面DEMO。连接点的实现机制类似观察者模式。其大致步骤如下:(1)COM对象需实现IConnectionPointContainer和IConnectionPoint 接口。(2)假设客户接受连接点事件的类为CEventSink,它需要继承_IXXXEvents的类,这个类实际上继承了 IDispath,客户必须重写Invoke这个虚函数。(3)客户调用Advise函数注册,伪代码为`Advise(pCom, pEventSink,...)`,pCom为需要回调客户端的COM接口指针,pEventSink为连接点事件接收对象指针,COM对象中通过Invoke回调客户端。(4) 客户不需要接收连接点事件或者自身释放时,需要调用UnAdvis取消注册。
### 3. COM接口与连接点使用DEMO在Windows中开发一个COM组件一般使用ATL。一般进程内的COM都是一个DLL。下面将通过过一个Demo来讲解COM接口与连接点的使用。此处我的DEMO都是使用ATL框架来实现,ATL中使用大量模板类以及宏,让我们少写很多代码,但是也让代码阅读和理解难度增加。#### 3.1. COM接口演示如下图所示,MyCom工程是一个COM组件工程,CMyCar是一个COM组件的实现类,它实现了两个接口IMyCar和IMyCar2。TestCom是一个客户,它调用了MyCom中的COM组件。实际上ATL已经大大方便了COM编程,它能自动生成很多代码,减轻了工作量。CMyCar已经实现一个接口IMyCar,我们此处演示下如何添加一个COM接口IMyCar2。![enter image description here](http://km.oa.com/files/photos/pictures/202008/1598361979_59_w332_h745.png)(1)首先在上图所述工程中,我们打开MyCom.idl,上文已经说过idl是一种通用的用来定义COM接口的语言,我们添加一段这样的代码:```c++[object,uuid(66C2F7BD-5466-428D-A2D0-EAF68A665978),pointer_default(unique)]interface IMyCar2 : IUnknown{ [id(1)] HRESULT Run2(); [id(2)] HRESULT Test2([in] LONG lp1); }```若要支持Automation接口此处需继承IDispatch。这段代码一般可以在类视图中右键菜单添加自动生成。若要手动添加,uuid可以VS中用工具生成,里面其他属性字段参考IDL语法。函数前面的ID可以自己随意定义一个数字,注意不要重复,这个数字ID是有意义的,如果是支持IDispatch的自动化调度接口需要根据这个ID调用接口。(2)在MyCom.idl中添加完接口之后,需要重新编译下这个IDL文件,编译成功会生成MyCom_i.c和MyCom_i.h两个文件。(3)打开MyCar.h头文件,这个头文件中已经包含了MyCom_i.h,让CMyCar类继承IMyCar2。实现IMyCar2中的两个函数,注意两个函数都是虚函数。(4)重新构建MyCom工程,生成了MyCom.dll, 此时我们就可以去调用这个COM组件中的IMyCar2接口了。(5)在TestCom工程中调用COM接口,其关键代码大致如下:```c++/* 调用COM组件时候,需要包含这两个头文件,这两个头文件都是由IDL接口定义文件编译生成的 如果调用的第三方组件库,没有这两个文件, 也可以通过import导入COM组件*/#include "../MyCOM/MyCOM_i.h" #include "../MyCOM/MyCOM_i.c"
int _tmain(int argc, _TCHAR* argv[]){CoInitializeEx(0, COINIT_APARTMENTTHREADED); //初始化COM库并设置并发模式//IMyCar 接口CComPtr<IMyCar> spCar;HRESULT hr = spCar.CoCreateInstance(CLSID_MyCar, NULL, CLSCTX_INPROC_SERVER);if (SUCCEEDED(hr)) {spCar->Run();//IMyCar2接口 通过IMyCar查询到接口IMyCar2CComPtr<IMyCar2> spCar2;hr = spCar->QueryInterface(IID_IMyCar2, (void**)&spCar2);if(SUCCEEDED(hr)){spCar2->Run2();}}CoUninitialize(); //关闭COM库}```#### 3.2. 连接点演示在上面的项目中IMyCar接口有一个Run函数,假设客户调用Run函数之后想知道当前汽车跑的距离和速度,该怎么做呢?此时就需要COM组件能主动通知客户端,这就要用到连接点了。##### a. 源对象回调接口定义和事件激发首先源对象(COM组件)大概步骤如下:(1)在MyCom.idl中定义回调客户端的接口,在library MyComLib下添加一段接口描述代码:```c++[uuid(2CF347A8-63ED-4CE0-8A6D-F98D60C98B8C)]dispinterface _IMyCarEvents{properties:methods: [id(100)] HRESULT OnDriveDistace([in] FLOAT Distance); [id(200)] HRESULT OnSpeed([in] FLOAT Distance2); }; ``` 同样,这段代一样可以手写,也可以自动生成(VS自动生成有时候会出bug),注意前面ID,这个很重要。 (2)重新编译下MyCom.idl。 (3)让CMyCar实现IConnectionPointContainer,使用ATL直接继承IConnectionPointContainerImpl<CMyCar>,这个接口主要管理支持哪些类型连接点,详情参考附件代码。 (4)让CMyCar实现IConnectionPointImpl(这个是ATL中使用的, 跟IConnectionPoint一样)。此处一般用一个代理类,如CProxy_IMyCarEvents继承IConnectionPointImpl,然后让CMyCar继承CProxy_IMyCarEvents。我们需要实现两个事件的激发函数Fire_OnDriveDistace和Fire_OnSpeed,当然这个名字是我们可以随意取,我们在需要连接(回调)客户端时调用它。这部分代码通常也能想到自动生成, 但是通常我们还是手动去写。我们看下Fire_OnDriveDistace这个函数:```c++HRESULT Fire_OnDriveDistace(FLOAT Distance){HRESULT hr = S_OK;T * pThis = static_cast<T *>(this);int cConnections = m_vec.GetSize();
for (int iConnection = 0; iConnection < cConnections; iConnection++){pThis->Lock();CComPtr<IUnknown> punkConnection = m_vec.GetAt(iConnection);pThis->Unlock();
IDispatch * pConnection = static_cast<IDispatch *>(punkConnection.p);
if (pConnection){CComVariant avarParams[1];avarParams[0] = Distance;avarParams[0].vt = VT_R4;CComVariant varResult;
DISPPARAMS params = { avarParams, NULL, 1, 0 };hr = pConnection->Invoke(100, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_METHOD, ¶ms, &varResult, NULL, NULL);}}return hr;}```实际上关键的代码是这一段:![enter image description here](http://km.oa.com/files/photos/pictures/202008/1598537563_45_w1056_h355.png)实际上此处还有个很重要的信息,pConnection是一个IDispatch指针,回调到客户端是通过其Invoke虚函数。所以结论就是**COM组件中连接点实际上是通过IDispatch接口回调客户端,客户端接收器继承IDispatch,实现Invoke,在注册之后,就可以接收连接点事件**。这很像观察者模式。##### b. 客户端事件接收器的实现对于客户端,我们要实现一个接收器。我们知道_IMyCarEvents实际上是个空接口类,它除了继承IDispatch没有其它方法和数据。我们有两种方法实现接收器:创建一个接收器类,直接继承_IMyCarEvents接口,实现其中的Invoke函数;使用ATL中的IDispEventImpl或者IDispEventSimpleImpl模板类,我们此处演示IDispEventImpl,用这个模板类更简单。(1)创建一个CEventSink1实现_IMyCarEvents接口来接受连接点事件, 必须实现关键函数Invoke:```c++class CEventSink1 :public CComObjectRoot,public _IMyCarEvents{BEGIN_COM_MAP(CEventSink1)COM_INTERFACE_ENTRY(_IMyCarEvents)END_COM_MAP()public:virtual ~CEventSink1() {}STDMETHODIMP GetTypeInfoCount(UINT *pctinfo) { return E_NOTIMPL; }STDMETHODIMP GetTypeInfo(UINT iTInfo, LCID lcid, ITypeInfo **ppTInfo) { return E_NOTIMPL; }STDMETHODIMP GetIDsOfNames(REFIID riid, LPOLESTR *rgszNames, UINT cNames, LCID lcid, DISPID *rgDispId) { return E_NOTIMPL; }//我们在此处重写Invoke方法STDMETHODIMP Invoke(DISPID dispIdMember, REFIID riid, LCID lcid, WORD wFlags, DISPPARAMS *pDispParams, VARIANT *pVarResult, EXCEPINFO *pExcepInfo, UINT *puArgErr){//关键代码switch (dispIdMember){//上文IDL中_IMyCarEvents中OnDriveDistace接口为100 case 100:{OnDriveDistace(pDispParams->rgvarg[0].fltVal);break;}//上文IDL中_IMyCarEvents中OnSpeed接口为200 case 200:{OnSpeed(pDispParams->rgvarg[0].fltVal);break;}default: break;}return S_OK;}
STDMETHOD(OnDriveDistace)(FLOAT Distance){printf("CEventSink1::OnDriveDistace\r\n");return S_OK;}STDMETHOD(OnSpeed)(FLOAT Distance2){printf("CEventSink1::OnSpeed\r\n");return S_OK;}};
//注册连接点事件DWORD cookies = 0;CComObject<CEventSink1>* sinkptr1 = NULL;CComObject<CEventSink1>::CreateInstance(&sinkptr2);
//spCar 为IMyCar接口//注册连接点事件AtlAdvise(spCar, sinkptr2, __uuidof(_IMyCarEvents), &cookies);spCar->Run();//调用Run接口, 内部会发射连接点事件//取消注册AtlUnadvise(spCar, __uuidof(_IMyCarEvents), cookies);delete pEventSink;```(2)使用ATL模板类IDispEventImpl实现接收器。实际上也可以使用IDispEventSimpleImpl,但是用这个会比较麻烦点。使用IDispEventImpl可以在BEGIN_SINK_MAP和END_SINK_MAP宏中将对应的函数ID和本地函数映射起来,实际上IDispEventImpl模板类中已经实现了Invoke函数,它映射了ID和本地函数指针,我们只需用SINK_ENTRY_EX添加对应ID到函数的映射就行了。我们要注意的是IDispEventImpl中模板参数一定不能出错,包括wMajor、wMinor, 否则接收器会无法接受到连接点事件。代码如下:```c++//使用IDispEventImpl接收com连接点事件class CEventSink2 : public IDispEventImpl<0, CEventSink2, &DIID__IMyCarEvents, &LIBID_MyComLib, 1, 0>{public:BEGIN_SINK_MAP(CEventSink2)SINK_ENTRY_EX(0, DIID__IMyCarEvents, /*dispid =*/ 100, OnDriveDistace)SINK_ENTRY_EX(0, DIID__IMyCarEvents, /*dispid =*/ 200, OnSpeed)END_SINK_MAP()
CEventSink2(){}virtual ~CEventSink2(){}
STDMETHOD(OnDriveDistace)(FLOAT Distance){printf("CEventSink::OnDriveDistace\r\n");return S_OK;}STDMETHOD (OnSpeed)(FLOAT Distance2){printf("CEventSink::OnSpeed\r\n");return S_OK;}};
//注册连接点事件CEventSink2* pEventSink2 = new CEventSink2;
//注册连接点事件1pEventSink2->DispEventAdvise(spCar);spCar->Run();//调用Run接口, 内部会发射连接点事件pEventSink2->DispEventUnadvise(spCar);
delete pEventSink2;```
### 4. COM编程小结在本文中,我已经尽量用通俗易懂的方式来描述COM技术,然而由于COM本身很复杂且晦涩难懂,很多东西没有一定基础确实会很难理解。ATL方便了COM编程,但也让COM本质变得扑朔迷离,如果我们要真正的理解COM或者充分挖掘ATL潜力,我们需对ATL中模板类、多继承技术等有一定理解,了解ATL最好的方式就是阅读源码。
COM是一套很庞大的体系,本文还有很多内容没涉及到,如进程外COM、RPC、IDispatch中接口详解,包括COM的一些衍生ActiveX/OLE(虽然真的已过时)等等,但是其基本思想就是我上面所说这样了。随着Windows技术发展,微软在主推.NET和CLR,上层语言架构在CLR之上,那么语言之间的互通就很好办,但是COM仍无法被替代,不仅仅是为了向下兼容。在我看来,掌握好COM技术,对快速掌握Windows下的其他技术有着非常大帮助。COM确实
## 二. 后记COM是一项非常复杂庞大的技术体系,本文也只是讲解了其基本原理和应用。COM组件编写使用都相当麻烦,由于一些历史原因,Q音客户端中大量使用COM技术,其给调试、维护都带来一定困难。显然,我们可以对此进行优化,前提是你熟悉COM,了解其优点与弊端,毕竟我们无法去优化一套自己不熟悉的体系和技术。后面将继续探索客户端如何去除COM,用更加简洁和高效方式来进行模块交互。
最后,想深入解COM,推荐几本书,都是很老的书:《Inside C++ Object Model>》,中文译《深度探索C++对象模型》,这本书让你对C++的OO有深入了解《COM原理与应用》《COM本质论》《COM技术内幕》