COM基本概念

介绍COM的文章很多,但是却很少有从编程角度把它的体系、运行讲得浅显而透彻的。这篇文章,就是尝试从C/C++编程角度,把COM是怎么回事、怎么运转的给出入门理解。

COM的目的编辑

COM的目的是在Windows平台上二进制程序的跨(编程)语言的无缝的共享。DLL是为了C语言搞出来的,其它(脚本)语言调用起来不方便,而且还有DLL Hell问题。一个COM客户程序,需要调用别人写的一个COM模块提供的服务,这就需要根据客户程序提供的数字标识符(UUID),在Windows注册表中找到这个COM模块的存储位置并启动之(甚至可能是在网络上别的计算机上用RPC远程启动之),然后就是客户程序与提供服务的COM模块之间直接打交道了。

什么是 COM?为什么要用 COM?“COM 就是实现了自我管理的 DLL!”。为什么要用 COM?因为程序要用 DLL,而 DLL 又 有 DLL Hell 问题,于是微软定制了一些标准来消灭魔鬼,以使 DLL 的优点更好地发挥出来。COM 有很多优点,这里简单说三个:

  • 注册后,就不需要知道服务组件放在哪里;
  • COM DLL 一般只有几个标准的导出函数,这是好的,因为导出函数是可以自己取名的,重名问题很严重,静态链接起来就可怜了……
  • COM DLL 可以很容易添加被脚本语言调用的能力,一般 DLL 是不能被脚本语言直接调用的。

COM由三大类组成编辑

COM模块内部的东西可分为三大类:TypeLibrary、coclass、interface。这三类都有各自的UUID,分别叫做TypeLibID_id、clsid、iid。

TypeLib包含了若干个coclass,一般是一个工厂类、一个业务类。

coclass实现了若干个interface。

interface声明了若干成员函数可供客户程序调用,而且这个interface一旦定好了就永远不能改变。

TypeLib编辑

TypeLib是用于脚本语言查询有哪些界面、界面中有哪些数据成员、哪些成员函数、以及成员函数的参数与返回类型。C\C++客户程序可以直接根据COM的头文件去调用其界面的函数,不需要用低效的dispatch界面。但是,脚本语言就必须先查询类型描述(详见下述),再通过dispatch界面去调用相应的函数。typelib是在用“中间描述语言”写的idl文件中定义,然后用MIDL.exe把这个idl文件编译为3个文件:C\C++客户程序用的头文件与.c文件(里面是各个uuid的定义)、以及二进制类型库.tlb。tlb文件将要登记入Windows注册表,以便客户程序可以从COM的clsid即可以查询到其类型描述。

coclass编辑

coclass是实现了一个或多个interface的数据结构。在C++实现时,就是一个继承了interface的类;在C语言实现时,是一个struct,里面包含了一个指向函数表的指针以及若干个内部使用的变量。coclass的用途,就是(动态)构造一个这样的coclass之后,就可以调用各种interface上声明的函数为客户提供服务。它是interface的物理实现。

interface编辑

interface是向客户描述一个为了某个特定目的可提供服务的若干函数的声明。例如算术运算界面提供了加减乘除等函数。用C++语言来写,界面就是一个虚基类。用C语言来写,界面是一个struct,其第一个数据成员是一个函数表的指针,其实就是模仿了C++的虚表机制。

用户在自己的头文件中如下定义一个interface:

#undef  INTERFACE
#define INTERFACE   IExample
DECLARE_INTERFACE_ (INTERFACE, IUnknown)
{
    STDMETHOD  (QueryInterface)        (THIS_ REFIID, void **) PURE;
    STDMETHOD_ (ULONG, AddRef)        (THIS) PURE;
    STDMETHOD_ (ULONG, Release)        (THIS) PURE;
    STDMETHOD  (SetString)            (THIS_ char *) PURE;
    STDMETHOD  (GetString)            (THIS_ char *, DWORD) PURE;
};

在C++环境下,被解释为:

struct __declspec{novtable} IExample : public IUnknown{
   HRESULT QueryInterface(const IID &, void **) =0;
   ULONG   AddRef(void) =0;
   ULONG   Release(void) =0;
   HRESULT SetString(char*) =0;
   HRESULT GetString(char*, DWORD) =0;
}

在C环境下,被解释为:

typedef struct IExample { struct IExampleVtbl far * lpVtbl; } IExample;
typedef struct IExampleVtbl IExampleVtbl;
struct IExampleVtbl{
   HRESULT (__stdcall * QueryInterface) (IExample far * This, const IID &, void **) ;
   ULONG   (__stdcall * AddRef) (IExample far * This) ;
   ULONG   (__stdcall * Release) (IExample far * This) ;
   HRESULT (__stdcall * SetString) (IExample far * This, char*) ;
   HRESULT (__stdcall * GetString) (IExample far * This, char*, DWORD) ;
}

可见,C环境下,还需要用户手工填入这样的“虚表”的实例。

dispid编辑

interface内声明的各个函数,是用正整数的id来编号、使用。

调用COM的流程编辑

客户程序角度,首先为自己初始化COM环境(函数::CoInitialize(NULL), 等价于CoInitializeEx(NULL, COINIT_APARTMENTTHREADED),根据第1个参数(从 0开始),调用了 TLS 函数来给线程设置一个记号,这个记号叫套间模式。实质上是声明客户程序使用什么线程模型)。如果客户程序都是单线程的,则不存在同步问题,那么这个函数没必要存在;如果 COM 是为单线程设计的,而客户是多线程,那么明显存在同步问题,这时候只 能让客户程序或者 COM Runtime 来解决同步问题。让客户程序来解决是不合理的,违背 COM 的宗旨;那么就让 COM Runtime 来 解决吧。于是 COM Runtime 要先后询问过服务组件和客户程序,确定他们是不是一致。这就需要服务组件和客户程序双方诚实交代自己的套间模式,服务组件是通过注册时在注册表写的信息告诉 COM Runtime 的,而客户程序就是 用 CoInitializeEx 来给自己线程设置套间模式的,COM Runtime 在必要时去读取调用线程的套间模式值。

然后,获取类库中的工厂类:调用::CoGetClassObject,提供类ID(clsid)与工厂类的标准界面IID(即IID_IClassFactory);由COM模块所在DLL的一个标准输出函数DllGetClassObject(REFCLSID rclsid ,REFIID riid,void **ppv)创建一个工厂类并返回标准的工厂类界面IID_IClassFactory。然后通过该工厂类标准界面调用工厂类的成员函数CreateInstance产生业务类的实例的地址,并release不再需要的工厂类。再通过业务类实例来查获各种支持的界面,调用界面上定义的函数(必然是虚函数,因为界面是虚基类)。最后,程序结束前调用::CoUninitialize函数。

可见,这里面的要点是COM所在DLL的DllGetClassObject怎么写。你也可以把它写为直接创建业务类,压根不用工厂类。

也可以使用coclass的clsid与要使用的interface的iid作为参数调用::CoCreateInstance,获得要使用的COM业务类的实例的地址。::CoCreateInstance的内部实现是CoGetClassObject(rclsid, dwClsContext, NULL, IID_IClassFactory, &pCF); hresult = pCF->CreateInstance(pUnkOuter, riid, ppvObj); pCF->Release(); 实质上还是同一回事。

COM的server编辑

从编写COM模块的角度,不外乎就是编写一个提供各种服务功能的DLL或EXE。

如果是DLL,在DLLMain之外则必须写四个export函数:

  • 向Windows注册表登记COM对象使用的DllRegisterServer、DllUnregisterServer;用regsvr32命令注册(或者取消注册)当前这个DLL到Windows Registry时的回调函数。
  • COM客户加载COM对象时调用的DllGetClassObject。客户程序要求查询并建立一个coclass的实例时调用的函数。
  • DllCanUnloadNow。前两个是详见下文。是Windows询问某个COM Server application是否可以卸掉的回调函数。

一个COM模块,通常包括一个工厂类,一个或多个业务类。这些类你可以把它们视作C++类,但是不论是类名还是成员函数,都不用名字而必须用UUID去查询使用,这保证了不同的语言都可以调用COM对象。无论是工厂类还是业务类,都是从interface IUnknown派生的。这个单词interface,并不是C++关键字,而是#define interface struct,这种struct的成员函数都是纯虚函数。

一个coclass实现了多个接口,用QueryInterface来返回客户所需要的那个接口的指针,这其实是C++的派生类对象地址转为一个(多继承的)基类对象地址。用完了这个接口后必须调用Release通知COM对象。

COM Server application内部一般有两个引用计数变量:

  • OutstandingObjects:生成并存在着的COM类的数量,包括IClassFactory。
  • LockCount:通过IClassFactory的方法LockServer,来锁定COM Server application不被卸载,以免下次获取类厂对象时需要重新加载至内存。

IUnknown编辑

IUnknown是最基础的虚基类,声明了三个纯虚函数:AddRef、Release、QueryInterface。COM体系结构中所有类都是以IUnknown为基类。

代理 IUnknown 接口做什么的?  本质就是:if ( 被聚合 ) { 调用内部组件非代理 IUnknown 接口 } else { 调用外部组件 IUnknown 接口 };

类厂 IClassFactory编辑

类厂IClassFactory比较简单,在IUnknown之上,再声明两个纯虚函数:CreateInstance、LockServe。最后,COM模块由IClassFactory再派生一个实作的类厂,把上述五个纯虚函数都定义实现了。其中:

  • QueryInterface判断iid是IID_IUnknown或者IID_IClassFactory,就把自身(this)当作类厂返回。
  • CreateInstance成员函数首先创建(通常是new)一个业务类,然后调用业务类的QueryInterface得到符合界面iid的一个实例地址,客户程序就可以用这个实例地址去访问各种服务了。如果一个COM类库中有多个业务类,那么类厂应该根据CreateInstance函数的iid参数来决定创建(new)哪个业务类。
  • LockServer方法:
    • 对于进程内的COM服务器,保持IClassFactory接口的指针就足以使类厂对象不被释放。接口中的Lock方法用于在使用者释放了所有的单独(outstanding )的接口(包括IClassFactory )、在下一次创建实例请求前的这个时间段内,控制进程内COM程序库的加载/卸载/重加载。
    • 对于进程外的COM服务器,创建、获取、释放类厂接口指针,不影响服务器的上锁。这说明保持一个类厂接口指针,到用该类厂接口指针实际创建一个实例(CreateInstance),有可能服务器已经处于非加载状态。所以,类厂marshalling时,获取到类厂接口指针就自动调用IClassFactory::LockServer以阻止服务器卸载。

类厂只是COM的DLL实现中的一个工具助手,不登记到Registry中。也不写入.idl文件,因此在.tlb这种二进制类型库中也不描述类厂。

API函数CoGetClassObject返回IClassFactory接口的指针。

IDispatch编辑

为了让VB这种脚本解释执行的编程语言也可以调用其成员函数,就不能靠C++的虚表风格的COM对象指针来访问服务,而必须提供IDispatch接口(叫做DispInterface)。所以,业务类一般是以IUnknown为最基类,派生IDispatch,进一步再派生各个业务interface,最后派生把各个纯虚函数定义实现的业务类。业务类与多个业务interface是多继承的关系。IDispatch中声明了四个纯虚函数:GetTypeInfoCount、GetTypeInfo、GetIDsOfNames、Invoke。业务类重点实现GetIDsOfNames成员函数、Invoke成员函数。业务类中实现的各种界面上声明的函数,不必是虚函数,而是根据ID号访问的内部函数。GetTypeInfo成员函数,由于有了.tlb文件,所以可以直接调用::LoadRegTypeLib等API函数很方便的实现。

所以,Dual Interface就是支持IDispatch与VTBL两种方法访问的COM的界面

COM类库中的多个类编辑

如果COM类库中定义了多个类,那么COM API的::CoCreateInstance如何知道创建哪个coclass的实例?答案是:在COM模块的DllGetClassObject函数中创建哪个coclass,就以它为准。一般是创建类厂的实例。(当然,有时把类厂定义为DLL的一个静态变量。)

MIDL:微软中间描述语言编辑

在.idl文件中描述了COM的TypeLib、coclass、interface、界面内的各个函数及其参数,还有相关联的uuid(对于界面的函数则是id)。描述这些信息的.idl文件用midl.exe编译出一个头文件、一个C源文件以及.tlb二进制类型库文件。头文件中,每个coclass、interface的声明的struct/class关键字后面添加了__declspec( uuid("ComObjectGUID") )。 例如: class DECLSPEC_UUID("3BCFE27E-C88D-453C-8C94-F5F7B97E7841") MATHCOM; C源文件以"_i.c"为文件名后缀,定义了interface、coclass、library的GUID的常量。对于C\C++客户端,有了头文件就可以直接找到COM的界面中的函数。为了对脚本语言提供服务,需要把.tlb文件登记入Windows注册表。这样从CLSID或者IID,都可以查出是对应了哪个TypeLib,从而查获详尽的类型描述。

COM模块在注册表中登记编辑

COM模块不论放在硬盘什么位置,COM客户都能通过::CoCreateInstance等函数直接启动COM对象的实例。这是靠了regsrv32.exe的注册功能,此程序调用COM模块的DLL的DllRegisterServer函数完成注册。一个COM模块,作为一个类库(因为它包含了多个业务类以及一个工厂类),用各个业务类的CLSID标识。因此在注册后,Windows Registry的HKEY_CLASSES_ROOT的CLSID下(64位Windows系统是在HKEY_CLASSES_ROOT的Wow6432Node的CLSID下),以各个业务类的CLSID为KeyName,定义了缺省值(值为类库名)、子键“InprocServer32”(缺省值为DLL的全路径文件名)、子键“ProgID”(缺省值为一个字符串,如软件名.类库名.版本号,不保证唯一);另外在Windows注册表的HKEY_CLASSES_ROOT下(64位Windows系统是在HKEY_CLASSES_ROOT的Wow6432Node下),注册了键“ProgID”(同前述,不保证唯一)以及子键CLSID。所以Windows系统知道了类库UUID,查到它的存储位置就不是个问题了。

VBscript客户程序习惯用“ProgID”来查询、调用COM模块。 HKEY_CLASSES_ROOT是HKEY_CURRET_USER\SOFTWARE\Classes与HKEY_LOCAL_MACHINE \SOFTWARE\Classes的合并表示。COM模块登记到Windows注册表的情况可以概述如下:

注册表中的TypeLib编辑

一般都是用::RegisterTypeLib()来登记。该函数默认把该类型库登记入五处位置:

  • HKEY_CLASSES_ROOT\TypeLib\{uuid_TypeLib}
  • HKEY_CLASSES_ROOT\Wow6432Node\TypeLib\{uuid_TypeLib}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\TypeLib\{uuid_TypeLib}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\TypeLib\{uuid_TypeLib}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes\TypeLib\{uuid_TypeLib}

在每一处,下辖子键:

  • \MajorVer.MinorVer\ReleaseVer\win32\默认值为.tlb文件的绝对路径文件名

注册表中的coclass编辑

不属于.tlb包含范围。因此coclass都是COM模块实现自行写代码来登记。由于Windows Registry API的那些写入函数在64位Windows系统上自动把32位程序的调用写入相应的Wow6432Node目录中,所以32位的COM模块的clsid都登记入了相应的带有Wow6432Node的目录中:

  • HKEY_CLASSES_ROOT\CLSID\{clsid}
  • HKEY_CLASSES_ROOT\CLSID\Wow6432Node\{clsid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\\CLSID\{clsid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\CLSID\{clsid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes\CLSID\{clsid}

其默认值为软件名字符串。另有两个子键:InprocServer32,其默认值为dll的绝对路径文件名;ProgID,其默认值为 软件名.模块名.主版本号

注册表中的interface编辑

属于.tlb包含的范围。同TypeLib的情形。

  • HKEY_CLASSES_ROOT\Interface\{iid}
  • HKEY_CLASSES_ROOT\Wow6432Node\Interface\{iid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Interface\{iid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\Interface\{iid}
  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes\Interface\{iid}

在每一处,默认值为界面的名字;另下辖子键:ProxyStubClsid32、TypeLib。其中的TypeLib子键的默认值为界面所在TypeLib的uuid,还有一个“Version”值。

注册表中的ProgID编辑

类似于9.2的clsid的情形,也是由COM模块的开发者自己写代码登记入注册表。脚本语言会通过ProgID来查询、调用COM的coclass:

  • HKEY_CLASSES_ROOT\“ProgID”字符串\

其默认值为coclass的名字。另有子键\CLSID,其默认值为{clsid}

补充:由于64位Windows上的32位程序使用注册表,Wow6432对于程序来说是透明的。所以上述Wow6432Node注册表子键的情形在编程时可以忽略不予考虑。

  • HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Classes is symbolic linked to HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node;
  • HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Wow6432Node\Typelib is symbolic linked to HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Typelib

COM对象实例就是虚表指针编辑

COM实际上是用C++实现的,因此一个COM对象实例的指针,指向了一个结构,这个结构实现了多个interface。这个结构的第一个数据成员是一个指针,指向了多个interface的各个被实现的函数的指针组成的表。从客户程序的角度,可以说一个COM对象实例就是一个虚表指针。(注意,COM对象的实例不能说是一个虚表,而是虚表的指针。)换句话,创建对象后客户程序得到什么? 函数指针的列表的地址。

什么是 Marshal?编辑

很少人去翻译这个词,都直接用英语。可以意译为“安整”,全称“发现不按顺序排队,则整顿为序列化排列”!

COM 分进程内和进程间,暂时不考虑 COM+ 的“计算机间”。进程内和进程间的“安整”通 过 Local RPC 完成,其中进程间的“安整”是必须的,因为服务和客户都是独立的进程,必须有一个渠道让它们可以共用数据;进程内“安整”是在不同套间之间进行方法调用和传递对象接口时发生,也就是上面说到的 CoInitializeEx 后,COM Runtime 可能介入解决同步问题,它就是通过“安整”来解决的。

包容和聚合编辑

都是为了复用,用面向对象的术语来说就是“继承”,不过他们“继承”的是接口。

包容时,内部组件不需要做任何额外的支持;聚合时,需要内部组件支持被聚合。不过这个支持在 ATL 里只需要我们打个钩。

包容是设计了一个新的接口,新接口的实现是调用内部组件的方法;而聚合是外部组件得到内部组件的接口指针,直接把这个指针传给客户,客户访问的时候就不 需要再经过外部组件了,但这样一来,外部组件就无法对内部组件添加任何新功能。同时因为把内部组件的接口指针直接传给客户暴露了内部组件,使得客户可以用 这个接口指针去查询内部组件其他接口,这是违法的,所以内部组件还要把查询转回外部组件,所以代理 IUnknown 接口应运而生。 总结:这些基本知识表明,使用或者编写COM并不难。还是很容易理解的。

COM的并发控制编辑

进程加载DLL后,会在进程内为DLL的全局变量、静态变量分配内存空间,使用进程内的线程栈为DLL函数的局部变量分配内存。因此,多个进程访问同一种COM对象不存在并发控制问题。

一个进程的多个线程,可以分别申请创建并访问同一种COM对象。因此创建该COM对象的设施(如DllGetClassObject、IClassFactory)需要采取(无锁的、原子的)并发控制。然后动态创建COM的一个对象返回给请求的线程。注意,IClassFactory常常实现为COM DLL的一个静态全局对象。

客户程序获取到一个COM对象,可能会交给多个线程访问它。这就需要在客户线程与COM对象之间的并发控制。这就引入了套间“Apartment”的概念。有两种套件类型:

  • 按照单线程执行方式写COM对象的代码,完全不考虑并发执行问题。这样的每个COM对象只能由一个线程执行,该线程通过Windows消息队列实现多线程访问该COM对象的串行化从而并发安全。这种策略称作单线程套间(Single-Threaded Apartment,STA)。
  • COM对象的代码自身实现了并发控制(通过Windows互斥原语,如互斥锁、临界区、事件、信号量等)。因此实际上多线程可以直接调用该COM对象的方法,这是并发安全的。这种策略称作多线程套间(Multi-Threaded Apartment,MTA)。

套间本质上只是一个逻辑概念而非物理实体,没有句柄类型可以引用它,更没有可调用的API操纵它。套间是一个逻辑容器,收纳遵循相同线程访问规则的COM对象与COM线程(创建了COM对象的线程或者调用了COM对象的方法的线程)。

一个COM对象只能存在于一个套间。COM对象一经创建就确定所属套间,并且直到销毁它一直存在于这个套间。

一个COM线程从创建到结束都属于同一个套间。COM线程只有两种套间模式:STA或MTA。线程必须通过调用CoInitializeEx()函数并且设定参数为COINIT_APARTMENTTHREADED或COINIT_MULTITHREADED,来指明该线程的套间模式。调用了CoInitializeEx()函数的线程即已进入套间,直到线程调用CoUninitialize()函数或者自身终止,才会离开套间。COM为每个STA的线程自动创建了一个隐藏窗口,其Windows class是"OleMainThreadWndClass" 。线程与属于同一套间的对象可以直接执行方法调用而不需COM的辅助。线程跨套间边界去调用COM对象,传递的指针需要marshalling。如果通过标准的COM的API来调用,可以自动完成安整。例如,把一个COM接口指针作为参数传递给另外一个套间的COM对象的proxy的情形。但如果软件编程者跨套间传递接口指针而没有使用标准COM机制,就需要手工完成安整(通过CoMarshalInterThreadInterfaceInStream函数)与反安整(通过CoGetInterfaceAndReleaseStream函数获取COM接口的proxy)。例如,把COM接口指针作为线程启动时的参数传递的情形。本质上,其他线程通过unmarshalling就知道了向那个窗口发送消息以实现跨套间的调用。如果跨套间调用STA的COM对象,该对象所在STA的线程必须显式提供线程消息循环处理机制。