OSKit作为一套开发操作系统的工具,其最大的特色是在操作系统的整体设计中采用了COM的思想,将操作系统中各个不同功能的部分设计成独立的COM模块,使得操作系统的开发者能很方便地按照COM的规范开发出符合自己需要的功能模块,将OSKIT中的相应模块替换,但却能继续使用OSKIT中的其它模块。而且对每个COM模块内部的接口也可以进行改写,添加其未实现的功能,删除不必要的功能,最终实现真正满足自己要求的操作系统。这些OSKIT特性的实现无一不是有赖于COM的机制。因此,本章对COM中的基本概念和COM规范进行简单的介绍。
2.1 COM的基本概念
由于COM是由组件技术发展而来,因此在介绍COM机制之前有必要先对组件进行必要的说明,然后再对COM的基本概念进行说明。
2.1.1 组件技术
在计算机软件发展的早期,一个应用系统往往是一个单独的应用程序。应用越复杂,程序就越庞大,系统开发的难度也就越大。而且,一旦系统的某个版本完成后,在下个版本出来之前,应用程序不会再有所改变。而对于庞大的程序来讲,更新版本的周期很长,在两个版本之间,如果由于操作系统发生了变化,或者硬件平台有了变化,则应用系统就很难适应这种变化。所以这类单体应用程序已经不能满足计算机软硬件的发展需要。
从软件模型角度来考虑,一个很自然的想法就是把一个庞大的应用程序分成多个模块,每个模块保持一定的功能独立性,在协同工作时,通过相互之间的接口完成实际的任务。我们把每一个这样的模块称为组件,一个设计良好的应用系统往往被切分成一些组件,这些组件可以单独开发,单独编译,甚至单独调试和测试。当所有的组件开发完成后,把它们组合在一起就得到了完整的应用系统。当系统的软硬件环境发生变化或者用户的需求有所更改时,并不需要对所有的组件进行修改,而只需对受影响的组件进行修改,然后重新组合得到新的升级软件。这种组件化程序设计技术,不同于传统的结构化程序设计技术,也不同于现在被广泛采用的面向对象程序设计技术。可以说,组件化程序设计位于这两者之上,它更注重于系统的全局,要求从系统的全方位进行考察。
2.1.2 什么是COM
COM,即组件对象模型,是一种以组件为发布单元的对象模型,这种模型使各软件组件可以用一种统一的方式进行交互。COM既提供了组件之间进行交互的规范,也提供了实现交互的环境,因为组件对象之间交互的规范不依赖于任何特定的语言,所以COM也可以是不同语言协作开发的一种标准。COM不仅仅提供了组件之间的接口标准,它还引入了面向对象的思想。在COM规范中,把对象称为COM对象。组件模型为COM对象提供了活动的空间,COM对象以接口的方式提供服务,下图表明了COM组件、COM对象和COM接口三者之间的关系。
一个组件程序可以包含多个COM对象,而且每个COM对象可以实现多个接口。当另外的组件或普通程序(即组件的客户程序)调用组件的功能时,它首先创建一个COM对象或者通过该对象所实现的COM接口调用它所提供的服务。当所有的服务结束后,如果客户程序不再需要该COM对象,那么应该释放掉对象所占有的资源,包括对象自身。
2.1.3 COM结构
前面已经提到过,COM为组件和应用程序之间进行通信提供了统一的标准,它为组件程序提供了一个面向对象的活动环境。
COM标准包括规范和实现两大部分,规范部分定义了组件和组件之间通信的机制,这些规范不依赖于任何特定的语言和操作系统,只要按照该规范,任何语言都可以使用。在这里主要讨论COM规范,至于其在OSKIT中的实现,在后面的章节中讨论。
COM主要是由对象和接口两部分组成。对象是某个类(class)的一个实例;而类则是一组相关的数据和功能组合在一起的一个定义。使用对象的应用(或另一个对象)称为客户,有时也称为对象的客户。接口是一组逻辑上相关的函数集合,其函数称为接口成员函数。按照习惯,接口名常以"I"为前缀,例如"IUNKNOWN".对象通过接口和成员函数为客户提供各种形式的服务。
2.1.4客户/服务器模型
可以很容易看出,对象和客户之间的相互作用是建立在客户/服务期模型基础上的,客户/服务器模型的一个很大的优点是稳定性好,而稳定性正是COM模型的目标,尤其对于跨进程的程序通信,稳定性更会带来性能上的高可靠性。
客户/服务器模型是一种发展比较成功的软件模型,因为这种模型有以下一些优势:
稳定性好、可靠性高。客户/服务器模型简化了应用,把任务进行分离,客户和服务器各司其职,共同完成任务。
软件的可扩展性好。一个服务器进程可以为多个客户提供服务,客户也可以连接到不同的服务器上,这种模型的连接非常灵活。
可以很容易看出,对象和客户之间的相互作用是建立在客户/服务器模型的基础上的,客户/服务器模型的一个很大优点是稳定性好,而稳定性正是COM模型的目标,尤其对于跨进程的程序通信,稳定性更会带来性能上的高可靠性。
然而,COM不仅仅是一种简单的客户/服务器模型,有时客户也可以反过来提供服务,或者服务方本身也需要其他对象的一些功能,在这些情况下,一个对象可能既是服务器也是客户。COM能够有效地处理这种情况。
2.2 COM对象
在COM规范中,并没有对COM对象进行严格的定义,但COM提供的是面向对象的组件模型,COM组件提供给客户的是以对象形式封装起来的实体。客户程序与COM组件程序进行交互的实体是COM对象,它并不关心组件模块的名称和位置(即位置透明性),但它必须知道自己在与那个COM对象进行交互。
类似于C++语言中类(class)的概念,COM对象也包括属性(也包括状态)和方法(也称为操作),对象的状态反映了对象的存在,也是区别于其他对象的要素;而对象所提供的方法就是对象提供给外界的接口,客户必须通过接口才能获得对象的服务。对于COM对象来说,接口是它与外界交互的唯一途径,因此,封装特性是COM对象的基本特征。
COM对象可以由多种语言来实现,例如C++,JAVA,C(正如在OSKIT中)。如果用C++来实现COM对象,则很自然可以用类(class)来定义COM对象,类的每个实例代表一个COM对象,类的数据成员可用于反映对象的属性,而接口自然可以定义成类的成员函数。但在非面向对象语言,例如C语言中,对象的概念可能变成一个逻辑概念,如果两个对象同时存在,则在接口实现中必须明确知道所进行的操作是针对哪个对象的,这个过程可由COM接口的定义来保证。
2.2.1 COM对象的标识-CLSID
前面已经说过,COM组件的位置对客户来说是透明的,因为客户并不直接去访问COM组件,客户程序通过一个全局标识符进行对象的创建和初始化工作。如果从标识符的可读性来考虑,使用字符串是最简单的方法,但这样做会增加名字冲突的可能性,这样组件的唯一性就很难保证,所以不能采用这种方法。而如果按照TCP/IP网络协议标识计算机采用的IP地址标识方法,那么每个组件对象都应该分配一个整数,该整数唯一标识了组件对象。问题在于,为了保证唯一性,必须有一个专门的权威机构为COM组件分配整数标识符,对于COM组件的开发和使用,显然不能满足实际需要。
使用定长位数的整数来标识组件对象是合理的,为了在没有中心机构管理的情况下保证唯一性,COM规范采用了128位全局唯一标识符GUID,这是一个随机数,并不需要专门机构进行分配和管理。因为GUID是随机数,所以并不绝对保证唯一性,但发生标识符相重的可能性非常小。从理论上讲,如果一台机器每秒产生10 000 000个GUID,则可以保证(概率意义上),3240年不重复。
下面是一个GUID的例子:
{54BF6567--1007--11D1--B0AA--444553540000}。
在C/C++语言中可以用这样的结构来描述:
typedef struct_GUID
{
DWORD Data1;
WORD Data2;
WORD Data3;
Byte Data4[8];
} GUID;
于是前面的GUID例子可以定义为
extern "c" const GUID CLSID_MYCLASID =
{ 0x54bf6567,0x1007,0x11d1,{0xb0,0xaa,0x44,0x45,0x53,0x54,0x00,0x00}};
CLSID是用来标识COM对象的GUID,因此,CLSID在结构定义上GUID一致。GUID并不是专门用来定义COM对象标识符的,它也用于定义其它实体的标识符,比如接口标识符。
2.3 COM接口
因为COM对象的客户与对象的服务之间通过接口进行交互,所以组件之间接口的定义至关重要。COM规范的核心内容是关于接口的定义。
2.3.1 接口的定义和标识
从技术上讲,接口是包含了一组函数的数据结构,通过这组数据结构,客户代码可以调用组件对象的功能。接口定义了一组成员函数,这组成员函数是组件对象暴露出来的所有信息,客户程序利用这些函数获得组件对象的服务。
客户程序用一个指向接口数据结构的指针来调用接口成员函数。如图所示,接口指针实际上又指向另一个指针,这第二个指针指向一组函数,称为接口函数表,接口函数表中每一项为4个字节长的函数指针,每个函数指针与对象的具体实现连接起来。通过这种方式,客户只要获得了接口指针,就可以调用到对象的实际功能。
通常,接口函数表被称为虚函数表(virtual function table,简称vtable),在OSKIT中称为函数动态派遣表。对于一个接口来说,它的虚函数表是确定的,因此接口的成员函数个数是不变的,而且成员函数的先后顺序也是不变的;对于每个成员函数来说,其参数和返回值也是确定的。在一个接口的定义中,所有这些信息都必须在二进制一级确定,不管什么语言,只要能支持这样的内存结构描述,就可以定义接口。此外,成员函数除了参数类型是确定的,还要使用同样的调用习惯。客户程序在调用成员函数之前,必须先把参数压入栈中,然后再进入成员函数中,成员函数依次把参数从栈中取出来,在函数返回之前或返回后,必须恢复栈的当前位置,才能保证函数正常运行。由于OSKIT是用C语言来实现的,因此客户程序只需要包含接口声明的头文件,就可以调用COM对象的接口,而组件程序必须提供具体的实现过程,也就是说,如果一个COM对象实现了这个接口,则它提供的接口指针所指向的结构中,每个成员必须是有效的函数指针。
因为接口被用于组件程序和客户程序的通信桥梁,所以接口应该具有不变性,一个COM对象可以支持多个接口。为了让客户程序标识每个接口,类似于COM对象的标识方法,COM接口也使用全局唯一标识符,它被称为接口标识符(IID,interface identifier)。
例如:
extern "c" const IID IID_Iunknown =
{ 0x00000000,0x0000,0x0000, { 0xc0,0x00,0x00,0x00, 0x00, 0x00, 0x00,0x46} };
如果客户程序要使用一个COM对象的某个接口,则它必须知道该接口的IID和接口所能提供的方法(即接口成员函数)。
2.3.2 接口的一些特点
* 二进制特性
接口规范并不建立在任何编程语言的基础上,而是规定了二进制一级的标准。任何语言只要有足够的数据表达能力,它就可以对接口进行描述,从而可以用于与组件程序有关的开发。
* 接口不变性
接口是组件客户程序和组件对象之间的桥梁,接口如果经常发生变化,则客户程序和组件程序也要跟着变化,这对于系统的开发非常不利,也不符合组件化程序设计的思想。因此,接口应该保持不变,只要客户程序和组件程序都按照既定的接口设计进行开发,则可以保证在两者独立开发结束后,它们的协作运行能力能达到预期的效果。
* 继承性
COM接口具有不变性,但接口也需要发展。类似与C++中的继承性,接口也可以继承发展。与C++中的继承不同,接口继承只是说明继承,即派生的接口只继承了基接口的成员函数说明,并没有继承基接口的实现,因为接口定义不包括函数实现部分,而且接口继承只允许单继承,不允许多重继承。根据COM规范,所有的接口都必须从Iunknown接口派生,而且一般的接口都是直接派生于Iunknown接口。
* 多态性-运行过程中的多态性
多态性是面向对象系统的重要特征,COM对象也具有多态性,其多态性通过COM接口体现。多态性时客户程序可以用统一的方法处理不同的对象,甚至是不同类型的对象,只要它们实现了同样的接口。如果几个不同的COM对象实现了同一个接口,则客户程序可以用同样的代码调用这些COM对象。因为COM规范允许一个对象实现多个接口,因此,COM对象的多态性可以在每个接口上得到体现。
2.4 Iunknown接口
COM规范说明,COM定义的每一个接口都必须从Iunknown继承过来,其原因在于Iunknown接口提供了两个非常重要的特征:生存期控制和接口查询。客户程序只能通过接口与COM对象进行通信,需要控制对象的存在与否。如果客户还要继续对对象进行操作,则它必须保证对象能一直存在于内存中;如果客户对对象的操作已经完成,而且不再需要该对象,则它必须及时地把对象释放掉,以提高资源的利用率。Iunknown引入了"引用计数"(reference counting)方法,可以有效地控制对象的生存周期。
另一方面,如果一个COM对象实现了多个COM接口,在初始时刻,客户程序不大可能得到该对象所有的接口指针,它只会拥有一个接口指针。Iunknown使用了"接口查询"(QueryInterface)的方法来完成接口之间的跳转。
Iunknown包含了三个成员函数:QueryInterface、Addref和Release。函数QueryInterface用于查询COM对象的其它接口指针,函数Addref和Release用于对引用计数进行操作。
2.4.1 引用计数
COM采用了"引用计数"技术来解决内存管理的问题,COM对象通过引用计数来决定是否继续生存下去,对于一个COM对象来说,只要有任一个逻辑模块还需要使用它,那么它就必须驻留在内存中,不能释放自己。因此,每一个COM对象都记录了一个称为"引用计数"的数值,该数值的含义是有多少个有效指针在引用该COM对象。当客户得到了一个指向该对象的接口指针时,引用计数增1;当客户用完了该接口指针后,引用计数减1。当引用计数减到0时,COM对象就应该把它自己从内存中清除掉。当客户程序对一个接口指针进行了复制,则引用计数也应该相应增加。Iunknown的接口成员函数Addref和Release分别完成引用计数的加1和减1操作。通过引用计数,COM对象的客户程序可以通过接口指针很好地控制对象的生存期。
2.4.2 实现引用计数
按照COM规范,一个COM组件可以实现多个COM对象,而且每个COM对象又可以支持多个COM接口。因此可以实现在COM组件一级,COM对象一级,甚至于对象的每个接口一级设置引用计数。
如果在组件一级设置引用计数,那么可以控制组件模块的生存周期,但不能控制COM对象的生存周期。如果一个组件有两个COM对象,则必须等到所有的COM对象都使用完以后,所有的COM对象才可以一起被释放。这样做降低了系统资源的利用率。
如果在接口一级设置引用计数,可以跟踪客户对象COM对象的使用情况。对于实现多个接口的对象,很有可能某些接口没有被客户使用,那么这些接口相关的资源可以不被占用。但每当一个接口的引用计数减到0时,它必须给对象发出通知,对象在接到通知后,需要判断是否所有的接口引用计数为0,若是,就把自己释放,然后再进一步通知组件程序,组件程序接到通知后判断是否所有的对象都已被清除,若是,则它可以被卸出内存。这个过程比较繁琐,而且也需要占用一部分的时间。
从折中的角度出发,比较合理的方案是采用对象一级的引用计数以便控制对象和组件的生存周期。这样使多个对象的组件程序可以有效地提高系统资源利用率。当一个对象被释放掉以后,它必须通知组件程序,如果组件程序发现已经没有对象存在了,则组件模块应该可以从内存中卸出。因此,组件程序应该保持一份有效对象的记录,可以用一个全局的对象计数值来控制组件的生存周期。
2.4.3 接口查询
按照COM规范,一个COM对象可以实现多个接口,客户程序可以在运行时刻对COM对象的接口进行询问,只有对象实现了该接口,对象才能提供这样的接口的服务。要实现接口查询,就要使用Iunknown的成员函数QueryInterface。
QueryInterface函数的IDL(interface description language,接口描述语言)语言说明:
HRESULT QueryInterface([in] REFIID iid,[out] void **ppv);
函数的输入参数iid为接口标识符IID,它是与GUID一样的128位整数,用来标识一个COM对象所支持的接口。输出参数ptv为查询得到的结果接口指针,如果对象没有实现iid所标识的接口,则输出参数ptv指向空(NULL)。 当客户创建了COM对象后,创建函数总会返回一个接口指针,因为所有的接口都继承于Iunknown,所以,所有的接口都有QueryInterface成员函数,于是,在得到了初始的接口指针之后,可以通过它的QueryInterface函数获得该对象支持的任何一个接口指针。
2.4.4 COM对象的接口原则
COM规范对接口的查询给出了以下一些规则:
* 对于同一个对象的不同接口指针,查询得到的IUnknown接口必须完全相同。也就是说,每个对象的Iunknown接口指针是唯一的,因此,对两个接口指针,可以通过判断其查询到的Iunknown接口是否相等来判断它们是否指向同一个对象。
* 接口对称性。对一个接口查询其自身总应该成功。
* 接口自反性。如果从一个接口指针查询到另一个接口指针,则从第二个接口指针再查询第一个接口指针必定成功。
* 接口传递性。如果从第一个接口指针查询到第二个接口指针,从第二个接口指针可以查询到第三个接口指针,则从第三个接口指针可以查询到第一个接口指针。
* 时间无关性。如果一个接口在某一个时刻可以查询到另一个接口指针,则以后任何时候再查询相同的接口指针,一定可以查询成功。
2.5 COM特性
COM规范所定义的组件模型,除了前面提到的面向对象的特性和客户/服务器特性这两个基本特性外,值得重点说明的就是COM规范的语言无关性,对进程的透明性和它的可重用机制。
2.5.1语言无关性
COM对象的定义不依赖于特定的语言,因此,编写组件对象所使用的语言与编写客户程序的语言可以有所不同,只要它们都能生成符合COM规范的可执行代码即可。COM标准与面相对象的编程语言不同,它所采用的是一种二进制代码级的标准,而不是源代码级的标准。因此,COM的语言无关性实际上为跨语言合作开发提供了统一标准。在OSKIT中,所有的COM对象都是用C语言写成的,但只要依据COM规范,完全可以用C++改写其中的某些模块,然后单独编译该模块,而可以继续使用OSKIT中其它的原有模块。
2.5.2 进程透明特性
COM所提供的服务组件对象在实现时有两种进程模型,进程内对象和进程外对象。如果是进程内对象,则它在客户进程空间运行;如果是进程外对象,则它运行在同一机器上的另一个进程空间,或者在远程机器的进程空间中。虽然COM对象有不同的进程模型,但这种区别对于客户机来说是透明的,因此客户程序在使用组件对象时可以不管这种区别的存在,只要遵循COM规范即可。然而,在实现COM对象时,还是应该慎重选择进程模型。进程内模型的优点是效率高,但组件不稳定会引起客户进程崩溃,因此组件进程可能会危及客户;进程外模型的优点是稳定性好,组件进程不会危及客户程序,一个组件进程可以为多个客户程序提供服务,但进程外组件开销大,而且调用效率相对低一些。
实现这种进程透明性的关键在于COM库,COM库负责组件程序的定位,管理组件对象的创建和对象与客户之间的通信。当客户创建组件对象时,COM库负责装入组件模块或者启动组件进程。因此,客户程序可以不管组件对象的进程模型,即使组件的进程模型发生了变化,客户程序也不需要重新编译。
2.5.3 可重用性
可重用性是任何对象模型的实现目标,尤其是大型系统,可重用性非常重要。而且,由于COM标准是建立在二进制代码级的,因此COM对象的可重用性与一般的面向对象语言有所不同。
对于COM对象的客户程序来说,它只是通过接口使用对象提供的服务,它并不知道对象内部的实现过程,因此,组件对象的重用性建立在组件对象的行为方式上,而不是具体的实现上,这是重用的关键。
COM用两种方式实现对象的重用。假定有两个COM对象,对象1希望能重用对象2的功能,对象1称为外部对象,对象2称为内部对象。
包容方式。对象1包含了对象2,当对象1需要用到对象2的功能时,它可以简单地把实现交给对象2来完成,虽然对象1和对象2实现同样的接口,但对象1在实现接口时实际上调用了对象2的实现。
聚合方式。对象1只需简单地把对象2的接口递交给客户即可,对象1并没有实现对象2的接口,但它把对象2的接口也暴露给客户程序,而客户程序并不知道内部对象2的存在。
对象重用是COM规范很重要的一个方面,它保证COM可用于构造大型的软件系统,而且,它使复杂系统简化为一些简单的对象模块,体现了面向对象的思想。