1. 分布式组件对象模型
多少年来软件的开发过程并没有很大的改变,软件开发过程中需要面对的主要问题如:开发周期长,难于确保程序的正确性,难于维护等还没有得到非常好的解决,尽管出现了如面向对象,框架设计等等的概念和工具。组件对象模型是传统面向对象模型的扩充,传统面向对象模型的重点是源程序,以及系统分析和设计过程。组件的概念则强调大的软件系统如何由不同开发商的小型可执行组件构成。以下首先从面向对象模型开始对各种概念作一番梳理。
· 面向对象编程
面向对象是一个被广大编程人员和工业界认同已久的概念。面向对象程序设计语言让开发者按照现实世界里人们思考问题的模式来编写程序,它让开发者更好地用代码直接表达现实中存在的对象,这样开发代码简单并且易于维护。面向对象语言有以下三个最重要的概念:
封装(Encapsulation)- 强调隐藏对象的实现细节,对象的使用者仅仅通过定义好的接口使用对象。
继承(Inheritance)- 新的对象可以利用旧的对象的功能。
多态(Polymorphism)- 根据所使用的对象展现多种不同行为的能力。
而从程序的整体来看,面向对象编程提供给用户的最重要的概念则是代码的共享与重用,它对于提高编写程序的效率极为重要。但是代码的共享与重用一旦应用于实践中仍然存在种种问题,如版本的升级、接口的变化、在不同程序设计语言之间共享代码等等。对于这些困难原有的面向对象程序设计方法并没有相应的答案,这就是组件对象模型提出的背景。
· 组件对象模型
将工程分解为逻辑组件是面向组件分析和设计的基础,这也是组件软件的基础。组件软件系统由可重用的二进制形式的软件组件模块组成,只需要相当小的改动就可以将这些来自不同开发商的组件模块组合在一起。特别重要的是这样的组合并不需要源代码,也不需要重新编译,组件之间通过基于二进制的规范进行通讯,这被称为二进制重用。组件模块是独立于编程语言的,使用组件的客户程序和组件之间除了通过标准的方法进行通讯以外,彼此不做任何限定。
组件可以划分为不同的类型,包括可视化组件如按钮或者列表框;功能组件如打印或者拼写检查。例如一个基于组件的架构可以提供将多个开发商的拼写检查组件插入到另一个开发商的字处理应用程序中的机制,这样用户可以根据自己的喜好方便地选择和替换字处理软件。
组件结构中最重要的概念是接口。接口是集合在同一个名称(通常是一个系统唯一的ID值)下的相关方法的的集合。组件之间的通讯是基于接口的,接口是组件和其客户之间严格类型化的契约。实现相同接口的两个对象就被认为是多态的,这里的多态不包含诸如基类指针指向派生类对象的意义,这里是指同一个接口可以由多个对象以不同方法实现。
2. COM/DCOM的基本概念
· 概述:
以下将通过程序实例解释COM/DCOM的基本概念。基于微软的一贯作风,虽然COM/DCOM自称为是一个可跨平台支持异构的模型(也确实从根本上说是可以跨平台的),但它也是和Microsoft Windows系统中的其它概念紧密结合在一起的,而且除了Microsoft Windows系统以外很少有什么系统支持COM/DCOM,所以在以下概念的介绍中将基于Microsoft Windows系统。COM/DCOM模型主要包括三方面的内容:(A)程序编写的模式。(B)程序交互时遵循的二进制规范。(C)程序运行的辅助环境。首先通过图1描述COM/DCOM基本机制。
由图可见COM/DCOM是基于客户机和服务器模型的,客户程序和组件程序是相对的,进行功能请求调用的是客户程序而响应该请求的是组件程序。组件程序也可以作为客户程序去调用其它的组件程序,正是这种角色的转换和相互调用关系使组件程序最终构成一个软件系统。根据COM/DCOM中客户程序和组件程序的交互关系可以将组件分为进程内组件和进程外组件两种。所谓进程内组件是指客户程序和组件程序在同一个进程地址空间内;进程外组件指客户程序和组件程序分别处在不同的进程空间地址中。进程内组件是通过将组件作为动态连接库(DLL)来实现的,客户程序将组件程序加载到自己的进程地址空间后再调用组件程序的函数。对于这两种不同的组件,客户程序和组件程序交互的内在方式是完全不同的。但是对于功能相同的进程内和进程外组件,从程序编写的角度看,客户程序是以同样的方法来使用组件程序的,客户程序不需要做任何的修改。因此以下先通过进程内组件的实现来理解COM/DCOM的编程模式。
· 进程内组件:
例子程序:
以下是一个用C++语言编写的COM程序实例的主要内容:
头文件:component.h
interface DECLSPEC_UUID("10000001-0000-0000-0000-000000000001")
ISum : public IUnknown
{public:
virtual HRESULT STDMETHODCALLTYPE Sum( int x, int y, int __RPC_FAR *retval) = 0;
};
客户程序:
#include "component.h"
const CLSID CLSID_InsideCOM = {0x10000002,0x0000,0x0000,
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01}};
void main()
{
IUnknown* pUnknown;
ISum* pSum;
HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
hr = CoCreateInstance(CLSID_InsideCOM, NULL,
CLSCTX_INPROC_SERVER, IID_IUnknown, (void**)&pUnknown);
hr = pUnknown->QueryInterface(IID_ISum, (void**)&pSum);
if(FAILED(hr))
cout << "IID_ISum not supported. " << endl;
pUnknown->Release();
int sum;
hr = pSum->Sum(2, 3, &sum);
if(SUCCEEDED(hr))cout << "Client: Calling Sum(2, 3) = " << sum << endl;
pSum->Release();
CoUninitialize();
}
组件程序:
#include "component.h"
const CLSID CLSID_InsideCOM = {0x10000002,0x0000,0x0000,
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01}};
class CInsideCOM : public ISum{
public:
// IUnknown
ULONG __stdcall AddRef();
ULONG __stdcall Release();
HRESULT __stdcall QueryInterface(REFIID riid, void** ppv);
// ISum
HRESULT __stdcall Sum(int x, int y, int* retval);
CInsideCOM() : m_cRef(1) {}
private:
ULONG m_cRef;
};
ULONG CInsideCOM::AddRef()
{ return ++m_cRef; }
ULONG CInsideCOM::Release()
{ if(--m_cRef != 0) return m_cRef;
delete this;
return 0;
}
HRESULT CInsideCOM::QueryInterface(REFIID riid, void** ppv)
{
if(riid == IID_IUnknown)
{ *ppv = (IUnknown*)this; }
else if(riid == IID_ISum){
*ppv = (ISum*)this; }
else {
*ppv = NULL;
return E_NOINTERFACE;
}
AddRef();
return S_OK;
}
HRESULT CInsideCOM::Sum(int x, int y, int* retval)
{ *retval = x + y;
return S_OK;
}
class CFactory : public IClassFactory
{
public:
// IUnknown
ULONG __stdcall AddRef();
ULONG __stdcall Release();
HRESULT __stdcall QueryInterface(REFIID riid, void** ppv);
// IClassFactory
HRESULT __stdcall CreateInstance(IUnknown *pUnknownOuter, REFIID riid, void** ppv);
CFactory() : m_cRef(1) { }
private:
ULONG m_cRef;
};
ULONG CFactory::AddRef()
{ return ++m_cRef; }
ULONG CFactory::Release()
{
if(--m_cRef != 0) return m_cRef;
delete this;
return 0;
}
HRESULT CFactory::QueryInterface(REFIID riid, void** ppv)
{
if(riid == IID_IUnknown)
{*ppv = (IUnknown*)this; }
else if(riid == IID_IClassFactory)
{
*ppv = (IClassFactory*)this;
}
else{
*ppv = NULL;
return E_NOINTERFACE;
}
AddRef();
return S_OK;
}
HRESULT CFactory::CreateInstance(IUnknown *pUnknownOuter, REFIID riid, void** ppv){
CInsideCOM *pInsideCOM = new CInsideCOM;
HRESULT hr = pInsideCOM->QueryInterface(riid, ppv);
return hr;
}
HRESULT __stdcall DllGetClassObject(REFCLSID clsid, REFIID riid, void** ppv){
if(clsid != CLSID_InsideCOM)
return CLASS_E_CLASSNOTAVAILABLE;
CFactory* pFactory = new CFactory;
if(pFactory == NULL)
return E_OUTOFMEMORY;
HRESULT hr = pFactory->QueryInterface(riid, ppv);
return hr;
}
由于COM/DCOM系统组件之间通讯是和位置无关的,也即一个使用组件功能的客户程序在编写时不需要考虑组件的位置,组件的定位和通讯由系统完成。因此不妨将客户程序和组件程序分别加以分析。
客户端程序:
(1) 调用CoInitializeEx初始化。
因为程序的很多辅助功能是由库函数和操作系统中的各种服务自动完成的,如组件的定位和加载,并且这些工作很复杂,所以程序需要首先作一些初始化。
(2)调用CoCreateInstance创建对象。
第1个参数CLSID_InsideCOM是一个128位的标识-类标识符(CLSID),在程序中定义为 {0x10000002,0x0000,0x0000,{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01}},今后这样128位的标识在表示时将省略0x并用"-"代替",";第4个参数IID_IUnknown也是一个128位的标识-接口标识符(IID);第5个参数(void**)&pUnknown是一个指针,在返回时它指向一个接口实例的指针。
CoCreateInstance()是一个库函数,从语义上说它创建对应类标识(CLSID)的一个COM/DCOM对象实例,并获得该对象的一个接口实例指针。对于进程内组件一个COM/DCOM对象是在一个DLL中实现的,在Windows系统注册表中维护着CLSID和DLL文件路径的对应关系,CoCreateInstance首先查找注册表,然后加载对应的DLL程序,调用该DLL的DllGetClaseObject引出函数(任何作为组件的DLL都必须提供该函数)以及其他的一些操作创建一个对象实例。
一个COM/DCOM对象对于客户程序唯一可见的是它所包含的一组接口,每一个接口都由128位的IID标识,在整个COM/DCOM系统中都是唯一的(包括分布在不同机器上的COM/DCOM系统)。任何类型的COM/DCOM对象都必须支持IID_IUknown(标识为00000000-0000-0000-C000-000000000046)接口。不同语言编写的客户程序中基本都有某种机制来标识接口指针,COM/DCOM用对象的IID_IUnknown接口指针的值来区分对象而没有独立的对象引用,任何对象在生命期内返回给客户程序的IID_IUnknown接口指针值必须是相同的。注意COM/DCOM对其他的接口指针值没有如上的要求。
COM/DCOM规定IID_IUnknown接口由以下三个函数组成:
QueryInterface(const IID iid, void **ppv);
AddRef( );
Release( );
而且其他任何接口也必须包含这三个函数。其中AddRef和Release是用来控制对象生存周期的。 QueryInter- face则达到通过接口标识查询对象实现的接口,COM/DCOM规定通过对象的任何接口的QueryInterface函数可以获得同一个对象的其余接口指针。
(3)pUnknown->QueryInterface(IID_ISum, (void**)&pSum);获得接口标识符为IID_Isum的另一个接口实例指针pSum。
(4)hr = pSum->Sum(2, 3, &sum);通过接口实例指针pSum,调用接口的成员函数Sum。从编程模式的角度来看客户程序向组件程序发送功能请求在源程序中最终体现为调用接口的一个成员函数,并且实际上不论对于进程内组件还是对于进程外组件都是同样的。对于进程内组件这一调用就是通过调用同一个进程中的函数实现的,但是必须强调即使是进程内的调用遵循的仍然是二进制的规范,也即客户程序中的pSum指向的内存格式必须满足COM/DCOM的规范,至于这一规范是怎样的将在后文中讲述。对于一个确定的接口(确定值的IID),它的进程内组件不论用什么程序设计语言实现,生成的目标DLL返回给客户程序的接口指针所指向的内存格式都是一样的。
(5)CoUninitialize();调用清理函数。
通过以上对客户程序的分析,可见客户程序的编程模式为(a)创建对应CLSID的对象实例;(b)获得对象的初始接口指针;(c)通过接口指针的QueryInterfase函数查询其它接口指针;(d)通过接口指针调用接口的函数。(e)通过接口的AddRef()和Release()控制对象的生命期。客户程序和进程内组件程序遵循的二进制规范则体现在(a)128位的类表识CLSID和接口表识IID;(b)组件程序必须是一个合法的DLL,并且引出若干标准的函数如DellGetClassObject。(c)组件程序返回给客户程序的接口指针所指向的内存必须满足COM/DCOM规范。
组件程序:
现在分析一下组件程序的编写,了解对象是如何实现的。首先察看DllGetClassObject(const CLSID clsid, const IID id, (void **)ppv)函数,当客户程序加载该DLL后将首先调用该引出函数。该函数是一个进程内组件提供其服务的最基本的入口,也是进程内组件所遵循的二进制规范的一部分。DllGetClassObject的功能是根据CLSID判断本组件是否支持该类型的对象,一个组件可以支持多种类型的对象。DllGetClassObject根据CLSID生成对应的类厂对象,并根据输入参数const IID id将类厂对象的对应接口指针通过ppv返回给客户程序。这里的引入了类厂这个在客户程序中未提及的新概念。根据COM/DCOM规范,组件程序必须为自己支持的每个CLSID提供类厂对象,由类厂对象负责创建对应类型的COM/DCOM对象实例。类厂对象提供通常称为IID_IClassFactory的接口(其值为000001-0000-0000-C000-000000000046),客户程序通过调用该接口的CreateInstance(Iunknown *pUnknown-Outer, const IID iid, void **ppv)函数真正创建对象实例并获得对象的第一个接口指针。由此可见客户程序调用CoCreateInstance库函数实际完成了两个步骤的工作,它首先请求组件创建类厂对象,然后又通过类厂对象创建对应CLSID的对象实例。
上面组件程序的实例中,在DllGetClassObject函数中通过new Cfactory创建了类厂对象,在CFactory::CreateInstance 中通过new CInsideCOM创建类对象。COM/DCOM对象是以C++对象的形式实现的,接口指针是以C++中的对象指针的形式返回的。对于进程内组件,组件和客户程序在编写时是分别进行的,而在运行时处于同一个地址空间内又以指针的方式进行交互,那么交互的二进制兼容自然是基于内存格式的。
在了解进程内组件的编写后,读者最大的疑惑必然是如何确保客户程序和组件程序在彼此独立的编写的过程中(甚至使用不同的语言)如何确保二进制兼容的。如前所述对于进程内组件二进制兼容包含三个方面的内容,128位标识符的识别以及确保组件DLL程序的合法性是很容易做到的,而如何确保在基于内存的交互时接口指针所指向的内存格式符合规范则显得有些复杂。下一节将介绍IDL语言,它是解决以上问题的重要手段。
3. IDL语言
在上面的例子程序中,不论是客户程序还是组件程序都没有使用任何的辅助手段就达到了COM/DCOM所要求的二进制的规范。不难想象:符合一定结构的一般C++程序经过编译后生成的二进制代码是满足COM/DCOM二进制规范的。同样不难想象:为了达到符合COM/DCOM的二进制规范,一种简单的方法就是对于生成目标代码的源程序的格式作一定的限制,对于例子中的C++程序显然只要对实现对象的C++类定义作限制就可以了。同时考虑到COM/DCOM是和编程语言无关的,使用C++的头文件显然是行不通的,因此必须使用一种独立的语言来描述接口,微软选用的语言就是IDL。
IDL语言是开放软件基金会(OSF)为分布式计算环境RPC软件包开发的,IDL帮助RPC程序员保证工程的客户机和服务器都遵守同一接口。为了将IDL语言应用于COM/DCOM系统中,微软对IDL语言的语法进行了扩充。IDL本身不是一种编程语言,它是用来定义接口的一种工具,至于对IDL语言的解释由使用它的系统决定。COM/DCOM对IDL语言的解释和COM/DCOM的二进制规范密切相关,而这样的解释和其它利用IDL的系统毫无关系。
COM/DCOM通常并不直接将IDL语言定义的接口翻译成二进制代码。C++语言的用户使用微软提供的MIDL.EXE程序可将IDL语言翻译成对应的C++头文件,上面例子程序中的头文件就是由以下的IDL文件生成的,接口ISum 继承了接口IUnknown。接口定义文件精确地描述了接口所包含的函数,函数的参数及参数的类型。
import "unknwn.idl";
[ object, uuid(10000001-0000-0000-0000-000000000001) ]
interface ISum : IUnknown{
HRESULT Sum([in] int x, [in] int y, [out, retval] int* retval);
};
其中unknwn.idl是系统预定义的,其内容如下:
[local,
object,
uuid(00000000-0000-0000-C000-000000000046),
]
interface IUnknown{
HRESULT QueryInterface([in] REFIID riid, [out, iid_is(riid)] void **ppvObject);
ULONG AddRef();
ULONG Release();
}
由IDL生成的C++头文件在客户程序和组件程序中分别通过#include被包含。使用由MIDL.EXE翻译而成的C++头文件一方面确保客户程序和组件程序中接口指针所指的内存结构一致,解决了同一编程语言实现的组件间的互操作性;另一方面也确保了接口指针所指的内存结构符合COM/DCOM的二进制规范,解决不同编程语言实现的组件间的互操作性。不过由于微软的MIDL.EXE没有通过IDL文件直接生成其它语言(如VB, JAVA)相应头文件的功能,这些语言的用户需要其它的工具才能利用IDL,这里不作叙述。
4.组件对象的继承
COM/DCOM模型作为组件对象模型具有对象模型的基本特性,其中对象的封装性,多态性前已做过论述。但对象模型的另一个重要特性---继承性---还没有涉及。COM/DCOM通过包容和聚合提供类似的特性。
包容和聚合有一个共同的特点就是对象包容和聚合后必须使客户相信那是一个对象。如前所述,客户程序区别对象的唯一标志是对象的IID_IUnknown接口指针的值,而且通过同一对象的接口的QueryInterface函数必须能够查询到本对象的其它接口。图2表示包容和聚合的实现,对象B实现接口IID_IB和IID_ISum,其中IID_Isum的功能是通过创建另一个CLSID_InsideCOM类型的对象完成的。
如图所示当采用包容模式时,对象B简单地创建一个CLSID_InsideCom对象,客户程序所有对对象B接口IID_ISum的调用都可以利用CLSID_InsideCOM对象的IID_ISum接口完成。当采用聚合模式时,对象B仍然创建一个CLSID_InsideCom对象,但对象B自己并不实现IID_ISum接口而是将CLSID_InsideCom对象的接口指针直接返回给客户程序,为此在通常的实现中,对象B和CLSID_InsideCom对象相互保存对方的IID_IUnknown接口指针,当客户通过对象B的接口指针的QueryInterface函数请求CLSID_InsideCom对象的接口时,对象B会将请求传递给所保存的CLSID_InsideCom对象的IID_IUnknown接口指针的QueryInterface函数,反之亦然。最重要的是:不论是在对象B中还是在CLSID_InsideCom对象中实现的接口,当客户程序请求IID_IUnknown接口指针时都必须返回对象B的IID_IUnknown接口指针。
5.进程外组件
· 概述
进程外组件通常为一独立的可执行文件,也即.EXE文件。进程外组件可以独立运行,它和客户程序通过LPC(本地过程调用)或者RPC(远程过程调用)进行通讯。客户程序和组件程序在不同机器上运行时采用RPC进行通讯,下面将介绍这种情况下客户程序和组件程序的编写模式和它们之间的交互。
客户程序使用进程外组件时的编程模式和使用进程内组件时的编程模式完全一样,客户只须提出要创建某一CLSID的对象,创建这一对象的程序实体的位置对客户是透明的,由系统决定。CoCreateInstance函数通过查询系统注册表决定是加载一个进程内组件DLL,还是通知另一台机器上的系统服务启动一个进程外组件可执行程序。客户程序也有通过CoCreateInstance的参数对使用的组件类型加以限制的能力。
组件程序的编程模式则稍有区别,组件程序需要先创建所支持的各类厂对象,然后通过库函数CoRegisterClassObject注册类厂,并开始监听客户程序的RPC请求,实例代码如下:
IClassFactory *pClassFactory = new CFactory();
DWORD dwRegister;
CoRegisterClassObject(CLSID_InsideCOM,pClassFactory,CLSCTX_LOCAL_SERVER,
REGCLS_MULTIPLEUSE,&dwRegister);
以上代码位于程序的main函数中。
考察过程序的编写模式后就该分析客户程序和组件程序交互的二进制兼容性了。当处于同一个进程的地址空间时,客户和组件的二进制兼容性是基于内存的,当两者位于不同的机器时,二进制兼容自然是基于RPC通讯的格式。以上的的说明并不难于理解,而令人难以理解的是客户程序如何通过接口指针操作处于另一个进程地址空间内的组件对象,这涉及到代理/存根(Proxy/Stub),列集/散列(Marshaling/UnMarshaling)等概念,下面将对此作总体的介绍。
· 进程透明性
客户程序创建了组件对象之后,它通过接口指针调用组件对象的成员函数,但实际上,接口指针所指的是本进程中的代理对象(Proxy),客户调用的是代理对象的成员函数,由代理对象通过跨进程的调用方法(LPC/RPC)与组件进程中的存根代码(Stub)通讯,存根代码再调用组件中对象的成员函数,函数返回的顺序刚好相反。代理对象负责对调用的方法,调用的参数进行称为列集的处理,处理后的结果是一个符合COM/DCOM二进制规范的数据包。数据包被代理对象传递到存根代码后,首先由存根代码对其进行散列处理,存根代码根据处理后得到的信息调用组件中对象的相应函数。以上过程可由图3表示。
代理对象和存根代码的实现有多种方法,其中一种称为标准列集(Standard Marshaling)。采用标准列集时,代理对象和存根代码本身就是由COM/DCOM对象组成的,这些对象由进程内组件实现,对应的DLL以用户不可见的方法加载,因此客户程序能以同样的方式使用进程内组件和进程外组件。下面对代理对象作进一步的说明。
采用标准列集时代理对象实际上并不是一个单一的对象,而是由处于不同DLL中的若干COM/DCOM对象聚合而成的,通过聚合可以方便地使客户将若干对象视为同一个对象。其中一个对象称为代理管理者对象(Proxy Manager),该对象由微软提供的DLL实现,负责提供通讯这样的公共服务。对于每一个接口(由IID标识)都有一个对应的DLL,DLL中有接口代理对象的实现,主要负责对应接口中各函数的列集/散列处理,同时当列集/散列接口指针时负责动态创建其它接口的代理对象。接口DLL的C++源程序可由MIDL.EXE根据定义接口的IDL文件自动生成,经连接后便可以生成目标代码,系统注册表管理着接口标识(IID)和接口DLL路径名的对应关系。
总结
COM/DCOM是一种简单的分布式组件对象模型,它的编程模型非常简洁明了,使用该模型可以方便地将处于不同组件中的功能组合起来。该模型是微软在其操作系统和应用程序的开发过程中,为解决具体问题逐渐由OLE发展而来,它明显不如CORBA那样全面,对组件的分布性也没有太多的考虑和设施保证。可以说COM/DCOM以最简单的方法规范了组件之间的交互,不过即使这样,建立在COM/DCOM基础上的微软操作系统以及诸如OFFICE,Internet Explore等应用程序仍然是最好用的。