深入理解COM(一)
前言
最近需要深入学习下COM,来解决工作上的问题。本文根据《COM原理与应用》这本书来写的。
COM简单介绍
COM是微软提出的组件标准,它定义了组件程序之间进行交互的标准,提供了组件程序运行所需的环境。
进程内组件:指的是dll
进程外组件:指的是exe
组件程序可能会包含多个组件对象,所以程序与程序进行通信时,通信双方为COM对象。
COM的历史发展
随着桌面程序之间的交互不断深入,在OLE技术发展过程中产生了COM。
大家都知道Windows操作系统,可以将一个应用程序里面写的文字复制到另一个程序中。
这其中需要程序与程序之间进行通信。
刚开始微软采用的是OLE规范,在OLE1中,进程间通信采用的是DDE技术,这种技术最大的缺点是效率低,稳定性不好,使用不方便。
OLE2中,放弃了DDE通信,采用COM方式进行通信。
既然提到了OLE,简单介绍下OLE是什么?
OLE原先是对象链接和嵌入,主要用于复合文档,但是自从OLE2之后,OLE变成了在桌面系统上进行程序通信的一个技术统称。
COM组件的组成
一个COM组件就是一个dll文件或者一个exe文件,一个组件可以包含多个COM对象,并且每个COM对象可以实现多个接口。
COM对象是通过接口来进行进程间通信的。
在进程通信中,COM规范采用的是客户/服务器模型。
一般而言,服务器为COM组件,调用方为客户。
COM对象用一个128位全局唯一标识符(GUID)来标识,称之为CLISID。
COM接口也是用一个128位的GUID来标识,称之为IID。
客户通过CLISID来创建COM对象,得到一个指向对象某个接口的指针之后(在此处可以得到该对象的所有接口指针),然后再调用该指针,就可以调用该接口提供的所有服务。
客户可以同时拥有两个相同CLISID的COM对象。
重要的IUnknown接口
所有的COM接口都要继承IUnknown接口,所有的COM对象都需要实现IUnknown接口。
IUnknown接口提供了非常重要的两个特性:生存期控制和接口查询。
客户端程序虽然只能通过接口与COM对象进行通信,但是也要控制COM对象的存在与否。
我们看一下IUnknown接口的定义:
1 | class IUnknown |
QueryInterface用于查询接口,AddRef用于增加引用计数,Release用于减少引用计数。
进程内组件
进程内组件和客户程序运行在同一个进程地址空间中,它是作为dll加载到客户程序的内存中。
dll本身是独立的,不依赖客户程序。
客户程序如果想要调用dll必须有标准的约定(二进制上的约定),dll程序包含一个引出函数表。
引出函数表中包含函数名称、函数序号以及函数地址。
在C++语言中,一般指定DLL的引出函数为_stdcall调用方式,如果使用了_cdecl,那么有些编程语言就不能使用dll。
因为C++会对dll的每个引出函数自动生成修饰名,但对于不同的编译器并不兼容,所以为了通用性,我们可以不要编译器使用修饰名,即在每个函数定义前面加上 extern “C”。
1 | extern "C" int _stdcall MyFunction(int n); |
光这个还不够,还需要描述DLL程序的模块信息。我们可以有两种方式去描述,一种是使用DEF文件,另一种是直接在函数说明时使用_declspec(dllexport)说明符:
1 | //def 文件 |
进程外组件
进程外组件是组件程序独占一个进程而不使用客户程序的进程空间。
这样就产生了两个问题
- 一个进程如何调用另一个进程中的函数
- 参数如何从一个进程被传递到另一个进程中
COM采用了本地过程调用(LPC)和远过程调用(RPC)的方法进行进程间通信。
LPC用于同一机器上的不同进程之间通信,RPC用于在不同机器上的进程间进行通信。
可以看一下普通程序如何通过LPC来调用另一个进程的:
COM进程外组件:
可以看到多出来代理dll和存根dll,它们是用来完成LPC调用,还会对参数和返回值进行翻译和传递。
所有的跨进程的操作被COM库封装了,实际使用过程和进程内组件差不多。
COM组件注册信息
com库会通过系统注册表所提供的信息进行组件的创建工作。
COM只用到了HKEY_CLASSES_ROOT,HKEY_CLASSES_ROOT下最主要的是CLISID子键,CLISID下面有很多组件项。
每个组件项底下有组件信息:
1 | InprocServer32 ---------------进程内组件dll的全路径 |
有关Implemented Categories列出的类型,可以在HKEY_CLASSES_ROOT键下有个子键Component Categories中找到。
微软也提供了OleView工具来列出机器上的所有类型以及组件对象列表。
进程内组件注册时会调用DllRegisterServer方法进行注册,卸载时会调用DllUnregisterServer进行卸载。
而进程外组件需要支持/RegServer和UnregServer来完成注册或卸载。
类厂
创建COM对象之前,我们先了解下类厂的概念。
类厂:创建类的工厂。
每个类厂只针对特定的COM类对象,类厂都会继承并实现IClassFactory:
1 | class IClassFactory:public IUnkown |
类厂也是COM对象,类厂是由DllGetClassObject来创建的。
1 | //原型 |
在COM库中,有三个API可用于创建对象:
CoGetClassObject
CoCreateInstance
CoCreateInstanceEx
1 | HRESULT CoGetClassObject(const CLISID& clsid, |
如果是进程内组件,CoGetClassObject会调用dll中的DllGetClassObject函数,iid通常为接口IClassFactory的标识符IID_IClassFactory,然后返回类厂对象接口指针。
如果是进程外组件,那么就比较复杂。首先CoGetClassObject函数启动组件进程,等待组件进程把它支持的COM类对象的类厂注册到COM中,然后CoGetClassObject函数把COM中相应的类厂信息返回。
进程外组件被COM库启动时,它必须把所支持的COM类的类厂对象通过CoRegisterClassObject函数注册到COM中,以便COM库创建COM对象使用。
当进程退出时,必须调用CoRevokeClassObject函数以便通知COM所注册的类厂对象不再有效。
CoRegisterClassObject函数与CoRevokeClassObject函数必须配对。
这样一描述是挺麻烦的。。。
1 | HRESULT CoCreateInstance(const CLSID& clsid, |
CoCreateInstance是一个包装的辅助函数,在它内部调用了CoGetClassObject函数。参数pUnknownOuter与类厂接口的CreateInstance中对应的参数一致。
使用这个函数,客户程序可以不用和类厂打交道,直接获取到了COM对象的接口指针。
CoCreateInstance不能创建远程机器的对象,因为服务器参数内部默认为NULL。
如果想要创建远程对象,可以使用CoCreateInstanceEx:
1 | HRESULT CoCreateInstanceEx(const CLISID& clsid, |
pServerInfo用于指定服务器信息,dwCount和rgMultiQI指定了一个结构数组,可用于保存多个对象接口指针,其目的在于一次获取多个接口指针,减少网络交互。
COM库
如果应用程序需要使用COM库,那么必须先调用COM库的初始化函数:
1 | HRESULT CoInitialize(IMalloc* pMalloc);//pMalloc为内存分配限制,默认为NULL,使用缺省的内存分配器 |
1 | void CoUninitialize(void);//释放COM库 |
因为COM是基于二进制标准建立起来的,COM库的内存分配与管理需要使用COM库中的内存分配与释放函数。
1 | void* CoTaskMemAlloc(ULONG cb);//分配内存 |
COM重用
COM重用有两种方式,包含和聚合。
看图说话:
我在工作中常用包含,A对象包含B对象,如果用C++表示:
1 | class B |
聚合比较复杂了,首先要了解什么是聚合?
聚合就是把一堆东西放在一块。
我们可以画一个图来表示COM聚合:
假如客户程序调用A对象,A对象聚合B对象,那么实现聚合需要依赖A对象的QueryInterface成员函数。
首先我们要了解QueryInterface会根据传入参数不同,返回不同的接口指针。
客户程序现在可以拥有两个接口指针,客户程序根据不同的接口指针,看到的世界不一样。
COM 规范
- 对于同一个对象的不同接口指针,查询得到的IUnknown接口必须完全相同
- 接口的对称性。对一个接口查询其自身总应该成功
- 接口自反性。如果从一个接口指针查询到另一个接口指针,则从第二个接口指针再回到第一个接口指针必定成功
- 接口传递性。如果从第一个接口指针查询到第二个接口指针,从第二个接口指针可以查询到第三个接口指针,则从第三个接口指针一定可以查询到第一个接口指针。
- 接口查询时间无关性。如果在某一时刻可以查询到某个接口指针,那么任何时刻都可以查询到。
根据图所示,客户程序是把整个COM组件当做对象A来调用。从外边看,对象A实现了两个接口。
但是根据COM规范,它违反了第1、3规范,第4规范更不用提了。
如何解决这个问题呢?
当客户程序通过IB接口指针查询IUnknown接口时,把IA的IUnknown接口传出去。这样做就不会违背COM第1规范。
如何才能做到这点呢?可以通过CoCreateInstance方法创建COM接口IA时候做到:
1 | HRESULT CoCreateInstance(const CLSID& clsid, |
IA接口需要实现两种IUnknown接口,一个是正常的IUnknown(非委托IUnknown),另一个是聚合的IUnknown(委托IUnknown)。
接下来我们就用代码实现下,因为C++不支持同时实现两个IUnknown,所以委托IUnknown和非委托IUnknown不能都使用IUnknown类,但是我们可以定义一个新的类。
1 | class INondelegationUnknown |
因为对象B支持聚合,所以它要实现INondelegationUnknown
1 | class B:public IB,public INondelegationUnknown |
搞清楚了COM接口支持聚合要实现的具体逻辑,下面考虑下支持聚合后COM对象如何创建?
在创建COM A对象的时候同时创建COM B对象。
1 | HRESULT A::Init() |
B对象被A创建的时候,需要注意两点:
第一点,B工厂创建B对象的时候
1 | HRESULT BFactory::CreateInstance(IUnknown* pUnknownOuter, |
第二点,B对象构造函数
1 | B::B(IUnknown* pUnknownOuter) |
这些是聚合的细节,有些复杂。。。可以简单的从客户程序角度看,聚合就是把多个COM对象整成一个对象、多个接口,想要用哪个就调用哪个接口。
整体流程
到目前为止,大体流程已经介绍完毕,让我们整理下:
简单来看就三个步骤,下一篇让我们接着深入理解COM。