COM技术纵横谈
-- tsingxiao
一:概述
PC机自从诞生以来,硬件经历了无数变化,CPU从最初的INTEL 8086到现在PIII满大街都是也只不过
十几年。微软的WINDOWS操作系统从最初的1.0版本到现在即将推出WIN2000,一直是桌面系统上装机量
最大的OS。 作为软件开发人员,使用着包括Visual Basic,Visual C++,Delphi包括最新的
Borland C++ builder等等在内的众多开发环境为WINDOWS开发应用程序。应该说现在的开发条件和若
干年以前比已经是大大的进步了。
如果你开发过16位的WINDOWS程序,你可能知道为了读取一个文件,我们不得不使用一小段汇编来调
用DOS例程,或者使用当时WINDOWS尚未公开的函数:_lopen()。在win32环境下,你所要做的全部是
调用 : :CreateFile()来获得一个文件句柄,当然如果使用MFC或是OWL之类的东西,你可以更简单的
做到。不过一般情况下,程序员仍然不得不从头开始写编写应用程序的每一行代码。
但这种情况得到了改变:微软提出了C O M(Component Object Model, 中文也可以译作“组件对
象模型”)概念,并且在最新的WINDOWS95/98以及WIN NT4中越来越广泛的使用它:我们有理由相信在
不久的将来,C O M 将成为构建应用程序最普遍的方法,如果你对此技术有兴趣,不妨参考本文,希望
从中你能学到想知道的知识。如果你已经是C O M老手,也欢迎你批评指正,我的email是 singxiao@bigfoot.com
本文是针对C++程序员写的。在介绍概念的时候,我尽量不把WIN32 API的知识混合进来,以便你能够
更清晰的看到C O M的本质。所有的例子都用Microsoft Visual C++5(SP3)编译通过。
一般的讲,一个应用程序总是由单个的二进制文件组成。在以前,如果这个程序需要做一些改进,就
要修改源代码,然后编译,声称新的文件,然后取代原来的文件。现在,我们用一种全新的角度来看问
题:把原先一整个的EXE可执行文件,分割成功能不同,但相对独立的几个部分,把他们拼装起来,组
成程序,组成软件。在未来程序发布以后,如果意识到需要对他进行修改,只要替换有问题的或是需要
升级的组建就可以了。甚至可以做到再不影响程序正常运行的情况下替换其中的部件。如果你熟悉
WINDOWS编程,可能会想到:DLL似乎就是你所说的东西:可以动态连接。事实上,COM正是充分利用了
Win32 DLL的灵活性才得以真正在Windows平台上实现的。
这样做有哪些优点呢?首先:用户一般希望能够定制所用的应用程序,而组件技术从本质上讲就是可
被定制的,因而用户可以用更能满足他们需要的某个组件来替换原来的那个。其次,由于组件是相对应
用程序独立的部件,我们可以在不同的程序中使用同一个组件而不会产生任何问题,软件的可重用性将
大大的得到增强。第三,随着网络带宽及其重要性的提高,分布式网络应用程序毫无疑问的成为软件市
场上越来越重要的买点。组件价构可以使得开发这类应用程序的过程得以简化。
那么,COM到底是什么呢?它是一个说明如何建立可动态互变组件的规范。 他定义了一些为保证能互
操作,客户(一个术语,指需要某种组件的程序)组件必须遵循的标准,COM规范就是一套为组件架构
设置标准的文档形式的规范。COM的发布形式是:以win32动态链接库(DLL)或者可执行文件(EXE)的形
式发布的可执行代码组成。
COM组件是动态连接的,而且COM组件是完全与语言无关的。同时,COM组件可以以二进制的形式发布。
COM组件还可以在不妨碍老客户的情况下被升级成新的版本。
你现在可以认为,COM所能提供的服务有些类似C++中的类。不过类是基于源代码的,COM则不是。不
过这里要澄清一些关于COM的错误观点:首先,COM不是一种计算机语言。把COM同某种计算机语言(如
C++, VB)相比较是没有意义的。其次,也不要把DLL和COM做比较,因为COM技术正是利用了DLL的动态
链接能力才得以实现的,而现在一般观点则认为,利用DLL动态链接能力最佳的方法是COM。当然,COM
也不是win32 API那样的一个函数集:它并没有支持或者提供类似MoveWindow这样的函数来对系统进行
特定的操作。COM也并不是类似于MFC那样的C++类库。COM给开发人员提供的是一种开发与语言无关的组
件库的方法,但COM本身并没有提供任何实现。在一定程度上可以认为COM是系统无关的,software AG
组织正在开发一系列COM支持系统,有望在不久的将来,包括从Mac OS,VMS,SCO UNIX到LINUX的操作
系统上都将得以实现COM。COM的确有一些具体的实现。COM本身要实现一个称为COM库(COM library)的
API,它提供诸如客户对组件的查询,以及组件的注册/反注册等一系列服务,一般来说,COM库由操作系
统加以实现,程序员不必关心其实现的细节。总体来看,COM提供了编写组件的一个标准方法。遵循COM
标准的组件可以被组合起来以形成应用程序。至于这些组件是谁编写的,是如何实现的并不重要。组件
和客户之间通过"接口"来发生联系。
二:什么是接口
前面已经提到过,COM组件与客户大家打交道的唯一办法是通过接口。在C++的实现中,我们一般用抽
象基类来定义接口,然后利用C++类的多重继承实现该组件。下面给出一个简单的示意:
////////////////
// iface.h
////////////////
#ifndef IFACE_H
#define IFACE_H 1
#define interface class
interface IA
{
public:
virtual func1() = 0;
virtual func2() = 0;
};
interface IB
{
public:
virtual func3() = 0;
virtual func4() = 0;
};
#endif
//////--iface.h end--//////
////////////////
// test.c
////////////////
#include "iface.h"
class Ca : public IA, IB
{
public:
Ca(int i) : m_Count(i) {}
virtual func1() { cout << "IA::func1 is " << m_Count * 1 << endl; } virtual func2() { cout << "IA::func2 is " << m_Count * 2 << endl; } virtual func3() { cout << "IB::func3 is " << m_Count * 3 << endl; } virtual func4() { cout << "IB::func4 is " << m_Count * 4 << endl; } int m_Count; }; main() { IA* pIa; IB* pIb; Ca* pCa="new" Ca(2); pIa="pCa;" pIa> func1();
pIa -> func2();
pIb -> func3();
pIb -> func4();
delete pCa;
}
//////--test.c end--//////
上例中,定义了IA,IB两个接口,你可以注意到他们所有的成员函数都被声明为virtual,并且在函数
末尾用 = 0 做了结束。类似这样的函数我们在C++中称之为纯虚函数,如果整个的类都由纯虚函数组
成,那么这个类就叫做抽象基类。抽象基类本身由于没有实体函数与变量,所以并不分配内存。一般它
的用途是为派生类指定内存结构。打个比方来说,就好像把房子分割成很多小间,规定以后哪些小间应
该放什么(函数的实体)但具体的东西则要等派生类来填放。
这里有一个概念需要说明一下:组件并不是类,上面我们用一个类就实现了两组接口,同样我们也
可以用它来实现更多接口。组件本身其实只是一个接口集及其实现的集合。一个组件可能包含了多个接
口,每一个接口都有各自的实现。同时,接口并非总是继承的,COM规范没有要求实现某个接口的类必
须从那个接口继承。这是因为客户并不了解COM组件的继承关系。对接口的继承只不过是一种实现细节
而已。
下面将介绍QueryInterface函数。这个函数被用来查询其他接口。客户于组件之间的通讯是通过
接口完成的。哪怕是客户查询其他一个组件时,也需要通过一个接口(换而言之,如果一个组件不支持
这个接口,那他一定不是一个COM组件)这个接口的名字叫IUnknown,它有三个函数,如下所示:
interface IUnknown
{
virtual HRESULT __stdcall QueryInterface(const IID& iid, void** ppv) = 0;
virtual ULONG __stdcall AddRef() = 0;
virtual ULONG __stdcall Release() = 0;
};
COM组件的所有接口都继承了IUnknown,这样一来,每一个接口的前三个函数都是QueryInterface,
这就是的所有的COM接口都可以被当成是IUnknown来处理。客户只要通过一个CoCreateInstance函数
就可以创建该组件的实例并且获取其IUnknown*。
HRESULT __stdcall CoCreateInstance(
const CLSID& clsid,
IUnknown* pIUnknownOuter,
DWORD dwClsContext,
const IID& iid,
void** ppv
);
下面的CODE演示创建一个组件:
extern "C" const GUID CLSID_COM1 = (
0x32bb8230, 0xb41b1 0x11cf, 0xa6, 0xbb, 0x0, 0x80, 0xc7, 0xb2, 0xd6, 0x82 );
extern "C" const GUID IID_IX = (
0x32bb8230, 0xb591c 0x11ff, 0xc1, 0xb0, 0xc7, 0xf8, 0x21, 0x35, 0x1c, 0x2f );
CoInitialize();
Ia* pIx = NULL;
HRESULT hr = ::CoCreateInstance(
CLSID_COM1,
NULL,
CLSCTX_INPROC_SERVER,
IID_IX,
(void**) &pIx
);
if (SUCCEEDED(hr))
{
pIx -> Fx();
pIx -> Release();
}
extern "C" const GUID其实是所谓的"全局唯一标示符"(Globally Unique Identifier)。我们规定用
它来表示不同的接口。换而言之,如果你发现有两个GUID完全相同,你完全有理由相信他们标示的是同一个
接口。(有专门的算法来产生该结构,确保它在时间和空间上都是唯一的。) 接下来的CoInitialize函数
初始化COM库。这一步是非常重要的,如果没有初始化,以后进行的操作都将失败。
下面我们来看看HRESULT值。这是一个32位的返回值,其最高位表示函数调用是否成功。第十六位包含
的就是函数的返回值,其余的15位包含的是此类型以及返回值起源的更详细信息。为了确定函数调用是否成
功,需要使用SUCCEED和FAILED宏。
CoCreateInstance函数是由COM函数库提供的,它的作用就是按照查询的组件和接口到系统中去寻找其
所在的文件(一般总是EXE或者DLL文件)然后创建该组件并查询其接口。一般来说,这个函数的具体实现是
与系统相关的,以后将会提到,在windows系统中,将查询注册表已确定某个特定的组件在哪个文件中。上
例查询的是CLSID_COM1这个组件,由于一个组件可能包含有多个接口,所以我们使用IID_IX来制定所需要的
接口,CLSCTX_INPROC_SERVER是一个常数,其指定组件所在的是一个DLL(由于DLL运行在客户的内存空间,
所以可以称为是进程内组件)。最后一个参数传入的是接口指针,它将返回查询到的接口指针。可以想见,
一个组件指针可能同时被几个客户所使用,所以需要一种手段来让组件实例知道自己正在被几个客户所使用,
这样他才能再合适的时候销毁自己以让出内存空间。如果销毁的实际不当,比如还有个指针正在使用中,那么
以后对该指针的调用就将失败并且用户程序将崩溃。COM采用相当简单的一种手段来进行所谓的引用计数:维
护一个组件或接口的全局变量,但该变量的值为零时,销毁自己的时间就到了。CoCreateInstance实际上产
生了该组件的实例,并在内部已经调用IUnknown的AddRef()函数来将引用计数置1了。正因为如此,例子最后
调用的Release()函数就是做了清理工作:这个接口指针已经完成了它的工作,所以调用Release()告诉它:
把你的引用计数减一。如果不这样做,组件将永远保留在内存中,直到应用程序结束的时候才从栈中被清除。
对AddRef和Release函数的调用是为了更好的控制组件的生命期,当然如果处理得当,可以适当的减少
AddRef/Release对以提高性能。一种特殊的情况就是当一个组件的生命期完全被包含在另一个组件内时,
我们对被包含的那个组件可以不予计数。我不准备详细讨论优化问题,因为对于一般应用来说,保证程序的
强壮和稳定才是最重要的。这里还得介绍一下ProgID。ProgID其实程序员给某个CLSID指定的易记名。某些
语言如visual basic使用ProgID而非CLSID来表示组件。这里请注意,程序员对ProgID的命名只不过是遵循
一个约定俗成的规定,并没有对具体的实现有任何的强制标准,所以其名字发生冲突也是有可能的。一般来
说,ProgID具有如下格式: ..
以我的注册表为例:
INSHandler.INSHandler.1
ImgUtil.CoSniffStream.1
StaticMetafile
Netscape.Help.1
不过由于ProgID没有专门的命名规则,所以出现不同于上述格式的名字也是完全有可能的。有时候客户并
不关心它所连接的组件版本,换而言之,客户只需要知道该组件存在就心满意足了。所以,组件经常会有
一个与版本无关的ProgID,此ProgID被映射成是所安装的最新版本的组件。完成从ProgID到CLSID的转换
非常简单,只需要利用COM库中提供的两个函数CLSIDFromProgID和ProgIDFromCLSID就可以了。
CLSID clsid;
CLSIDFromProgID(L"Netscape.Help.1", &clsid);
上面的L""是一个扩展宏,用来转换普通的ANSI字符串成为Unicode串。
下面需要讨论的问题是:假设现在我已经写好了一个组件,怎么才能在注册表中登记它的接口呢?非
常简单,我们只需要在组件中实现下面两个函数就可以了。
__declspec(dllexport) DllRegisterServer();
__declspec(dllexport) DllUnregisterServer();
具体而言,DllRegisterServer的实现实际上是通过直接调用注册表函数来实现的。为了注册或者取消某
个组件的注册,需要用的函数一般有:
RegOpenKeyEx
RegCreateKeyEx
RegSetValueEx
RegEnumKeyEx
RegDeleteKey
RegCloseKey
使用这些函数是需要#include 或者,并在additional librarys里加上advapi32.lib。现在的一个问
题是:客户怎样选择自己所需要的组件呢?开发人员需要的是一种无需创建组件实例就能知道它是否能
提供所需接口的方法。轮询系统中的所有组件和接口不失为一种解决的方法,但这样做的系统开销相当
大。为此引进了称为组件类别的方案。
一个组件类别实际上就是一个接口集合。我们分配给该集合一个GUID以唯一的标示它,它被称作
CATID。对于任何一个组件,如果它实现了某个组件类别的所有接口,那么它就可以把自己注册成是该
组件类别的一个成员。这样一来,客户只需要选择合适的组件类别并查询其下所有列出的组件就可以了。
对组件而言,并不限制它只能属于一个组件类别。反过来,属于某个组件类别的组件并不限于只实现改
组件类别中的接口。如果乐意,你可以写一个组件支持实现所有组件级别并且还有额外的接口。组件类
别是怎样被实现的?使用Component Category Manager(由windows提供),它是一个实现了
ICatRegister和ICatInformation接口的组件。ICatRegister可以完成新组件类别的登记或取消,也
可以将某个组件登记入某个组件类别,或取消之。ICatInformation则可以用来获取系统中某个组件类
别的数据。
组件中分配了一块内存,然后建起通过一个参数(可能是一个返回的指针)传递给了客户,这是
一种非常常见的做法。问题是:谁来释放这块内存?这主要是由于组建和客户可能是有不同的程序员
实现的,他们之间没有办法建立一种分配和释放内存的标准办法。COM解决中各问题的办法是提供一个
接口(IMalloc),它可以有CoGetMalloc返回。为了分配内存,只需要调用IMalloc::Alloc,而调
用改函数所分配的内存可以有IMalloc::Free负责释放。为了更加简单的实现,COM库提供了两个更加
简单的函数:
void CoTaskMemAlloc(ULONG cb /* size in bytes of block to be allocated */);
void CoTaskMemFree(void* pv);
如果你认真看了我的文章,到现在为止你大体上已经有了一个概念:COM究竟是一种什么概念,
它在哪些程度上需要程序员来实现,哪些则是由操作系统所提供的COM库完成的。不十分严格的说,
COM的目的是把各种各样的函数分类,然后封装成一个个物件,这些物件在windows系统中以DLL或者
EXE的形式具体存在,并且通过注册表,window随时随地可以知道某个特定组件的代码是在那个对应得
DLL或者EXE里。这里提一下,怎么告诉windows你需要哪个组件呢?我们使用GUID,其复杂的算法保
证了世界上没有两个个接口的ID标示号码是完全一样的!从而可以唯一的确定组件,包括内含的接口,
在客户需要该组件的时候windows也就可以正确的装载它了。同样也是因为这个唯一性,客户在任何
时候都可以直截了当的,明确无误的询问windows,我要的就是这个组件里的这个接口!告诉我你有
吗?这时候,通过一个CoCreateInstance函数,windows将返回接口指针,或者干脆的告诉你,没有
找到!那么,windows内部在执行这个函数的时候具体做了些什么呢?首先它查询了注册表,找寻你
所要的组件(组件也就是接口集,而所谓接口也就是一组函数所组成的集合的代名词,这么说你明白
了吗?)如果没有找到该组件,查询自然失败,函数返回,如果找到了,那么进一步的,内核将向
windows返回该组件的IUnknown*指针,windows随后利用IUnknown::QueryInterface函数查询你所
指定的那个接口是不是被该组件所实现(或者说支持)说到这里你一定可以发现,凡是接口,一般来
说总是要由你的代码去实现,IUnknown这个所有COM组件都必须实现的接口,其目的之一就是让
Windows知道如何查询你的组件。直到组件里实现了哪些接口的只有你自己--写这个组件的人,所以
你有责任妥当的好好些QueryInterface函数以便返回正确的指针,windows随后将该指针转给
CoCreateInstance的调用处,整件事情也就结束了。现在你了解了吗?