第四章 COM特性
1、面向对象系统的三个最基本的特性
封装性、多态性、重用性。
2、COM特性的概述
COM对象的封装特性是很彻底的,所有的对象状态信息必须通过接口才能访问;而COM的多态性完全通过接口体现出来,而且,COM分别在三个层次上体现了多态性:接口成员函数、单个接口、一组接口(对象类别既implemented category)。而COM的重用性相对复杂。
3、重用性
所谓重用性是指,当一个程序单元能够对其他的程序单元提供功能服务时,尽可能地重用原先程序单元的代码,既可以在源代码一级重用,也可以在可执行代码一级重用。
C++语言的重用性位于源代码一级,一个类可以继承于另一个类,从而把父类的功能重用。但对于COM组件则情形有所不同,因为COM是建立在二进制代码基础上的标准,所以其重用性也必然建立于二进制代码一级。
COM重用性是指一个COM对象如何重用已有的COM对象的功能,而不是重复实现老的功能服务。按照COM的标准,实现这种重用性有两条途径:包容和聚合。
4、包容和聚合
对象B调用对象A的相应成员函数实现ISomeInterface接口。因此,对象B的ISomeInterface接口提供的功能可以超过对象A的接口功能,返回结果也可以不一致。甚至,对象B的接口与对象A的接口不一定相同。一般来说,对象A的生存期包含在对象B的生存期之内。
在聚合模型中,被聚合的对象A虽然直接向对象B的客户程序提供功能服务,但它的生存期仍受对象B控制,而且其他的一些行为也受对象B的控制,包括内部状态初始化、获取数据等等。
为了使聚合能够顺利实现,对象A必须能够适应在被聚合的情况下进行特殊的处理,尤其是接口的QueryInterface成员函数,在被聚合情况下,当客户请求它所不支持的接口或者IUknown接口时,它必须把控制交给外部对象,由外部对象决定客户程序的请求结果。
聚合涉及到聚合对象和被聚合对象双方的协作,并不是每个对象都能够支持聚合特性,但聚合体现了组件软件真正意义上的重用。而包容的重用性完全建立在客户/服务器模型相对性的基础上,实际上也就是客户程序和组件程序的嵌套关系。这是包容和聚合本质的不同。
5、委托IUnknown和非委托IUnknown
对象创建函数CoCreateInstance的第二个参数pUnknownOuter用于解决聚合中IUnknown接口的问题。当其为NULL时表示正常使用,不为NULL时被聚合使用。内部对象实现两个IUnknown 分别用于这两种情况:委托IUnknown和非委托IUnknown(delegating unknown和nondelgating unknown)。
按照通常使用方式实现的IUnknown为非委托IUnknown,而委托IUnknown在不同的情况下有不同的行为:当对象被正常使用时,委托IUnknown把调用传递给对象的非委托IUnknown;当对象被聚合使用时,委托IUnknown把调用传递到外部对象的IUnknown接口,即对象被创建时传递进来的pUnkownOuter参数,并且,这时外部对象通过非委托IUnknown对内部对象进行控制。委托IUnknown本身不进行任何操作。
因为C++类不支持同时实现两个IUnknown,所以委托IUnknown和非委托IUnknown不能都使用IUnknown类,但我们可以定义一个新的类。因为COM不是通过类名来识别接口,而是通过vtable来调用接口成员函数。
6、COM接口调用的进程透明性
客户程序创建COM对象具有进程透明特性,不管是进程内组件还是进程外组件,客户程序可以使用一致的方法创建COM对象。对于进程内组件,无论是创建过程,还是客户程序对接口函数的调用过程,都可以按照一般的同一进程内部函数调用的过程来理解组件和客户之间的交互操作;但对于进程外组件,实际的情形要复杂得多,因为组件程序户程序拥有不同的进程空间,所以,它们之间所有的交互过程都涉及到进程之间的通信过程。然而,COM客户程序创建进程外组件程序成功后,它就得到了组件对象的一个接口指针,通过该指针间接调用组件对象的成员函数,如同调用本进程内的函数一样,这正是COM所期望达到的透明效果。
7、进程外组件对象与客户程序之间通信过程
接口指针所指的是本进程中的代理对象(proxy),客户调用的是代理对象的成员函数,由代理对象通过跨进程的调用方法(LPC/RPC)与对象进程中的存根代码(stub)通信,存根代码再调用组件对象成员函数。函数返回的顺序刚好相反。在这个交互过程中,可以看到,客户仍然在调用同一进程内的组件对象,而组件对象也被同一进程内的客户调用,从客户和组件对象两个角度丝毫感觉不到进程的边界,所有跨进程的操作完全由代理对象和存根代码包揽了。
8、列集(marshaling)与散集(unmarshaling)
列集是指客户进程可以透明地调用另一进程中的对象成员函数的一种参数处理机制。
代理对象用列集手段处理成员函数的参数,通过列集处理后得到一个数据包(数据流),然后通过一种跨进程的数据传输方法,比如共享内存方法,甚至是网络协议等,当数据包传输到对象进程后,存根代码用散集(unmarshaling,列集的反过程)的方法把数据包参数解译出来,再用这些参数去调用组件对象;当组件对象成员函数返回后,存根代码又把返回值和输出参数列集成新的数据包,并把数据包传到客户进程中,代理对象接收到数据包后,把数据包解译出来再返回给客户函数,从而完成一次调用。
9、连接
连接是指客户进程与组件进程的一种依赖关系,简单地说,客户程序的一个有效接口指针就代表了一个连接。
连接是在函数调用的过程中产生的,最常使用的QueryInterface就是一个很好的例子。
连接是跨进程通信的基础,新的连接本身也是在其他连接的调用过程中产生的。
10、不同参数的列集处理
32位整数只要把4字节的数据顺序装到数据包中或者从数据包中去出来即可;字符串或者结构类型的数据列集过程也可以按此方法处理。
对指针的列集处理过程是:列集时,把指针所指的数据装到数据包中;散集时,在进程中分配一块内存,把数据包中的数据拷贝到内存中,所得内存的地址即为散集的结果。
如果函数的参数中包含了指向接口的指针,则情形要复杂得多。接口的列集包含了代理对象和存根代码的创建过程,实际上接口指针的列集过程也包括了连接的创建过程。
11、列集过程的两种实现方式:
自定义列集法(custom marshaling),也称为基本列集法(basic marshaling architecture)。其列集过程完全由对象自身控制,对象指定其代理对象的CLSID,代理对象控制了其所有接口的列集过程,包括接口参数的列集和散集,以及代理对象和存根代码之间的跨进程通信过程。
标准列集法(standard marshaling),是由COM提供缺省的代理对象和存根代码,因为列集过程涉及到操作系统的一些复杂特性的编程,如共享内存操作或其他跨进程数据传输机制,甚至通过网络协议传输数据,所以COM提供了缺省的代理和存根代码以及一套标准的列集方法,可以处理常用数据类型的列集和散集,包括指针类型和接口指针类型。
标准列集法的原理以及其列集过程与自定义列集法完全一致,事实上标准列集法是自定义列集法的一个特例。但两者有一个基本的不同:自定义列集法其列集过程完全由对象自身控制,所以它以整个对象为列集单位,即对象指定的代理对象和存根代码必须处理对象支持的所有接口;而标准列集法使用COM提供的标准代理对象和存根代码,实际上该代理对象和存根代码只是列集过程的管理器,因此,标准列集法是以接口为列集单位,COM提供的很多标准接口,其列集过程已经由COM库提供了,程序员只需要提供自定义接口的列集代码即可。
12、自定义列集
13、标准列集
14、标准列集的实现
COM已经提供了缺省的代理对象、存根管理器以及RPC通道,我们只需要实现每个接口的代理/存根模块。一旦系统中安装了某个接口的代理/存根程序并正确地进行了注册,则代理管理器和存根管理器会在需要的时候自动加载接口代理和接口存根。因此,从实现的角度来讲,我们的任务就是针对接口实现代理/存根程序。
代理/存根组件是一个DLL程序,除了实现接口代理和接口存根之外,还应该实现相应的类厂,代理/存根组件要求类厂支持IPSFactoryBuffer接口,通过IPSFactoryBuffer::CreateProcy和IPSFactoryBuffer::CreateStub成员函数创建接口代理和接口存根对象。接口代理对象支持两个接口:它本身提供列集特性的接口和IRpcProxyBuffer接口,其中IRpcProxyBuffer接口只有Connect和Disconnect成员函数,被代理管理器用于创建或取消它与RPC通道的连接;它本身提供列集特性的接口的成员函数接受客户程序的调用,并把客户的调用参数放到RPC通道中,然后调用RPC通道的SendReceive成员函数,函数返回后,把返回值和输出参数解译出来。这些操作是接口代理对象应该完成的。与此相对应,接口存根只要实现IRpcStubBuffer接口,除了存根管理器所调用的几个与RPC通道连接的函数外,最主要的成员函数为IRpcStubBuffer::Invoke,RPC通道调用此函数 以响应客户进程的SendReceive调用,Invoke函数把客户进程传递过来的参数解译出来,然后调用组件对象的接口成员函数,并把返回结果或者输出参数经过RPC通道传回到客户进程的RPC通道中。
接口代理对象和接口存根对象必须非常小心地处理接口成员函数的参数,尤其是一些指针或者结构参数,如果成员函数中包含接口指针类型,则还需要调用CoMarshalInterface或者CoUnmarshalInterface函数,以便创建相应的存根或者代理对象。在代理和存根中对参数的处理必须严格一致,否则会发生不可预料的后果。如果客户进程和组件进程在同一台机器上运行,则COM会根据注册表中的接口信息,在两个进程中使用相同的代理/存根程序,所以我们只要保证接口的代理/存根程序中对参数的列集和散集格式一致,参数传递就不会有问题;但如果客户与组件程序在两台机器上,则不能严格保证两个进程会使用相同的代理/存根程序,那么对参数的列集和散集最好使用统一的数据格式表示,以保证参数传递的正确性。
如果一个进程外组件实现了多个COM接口,那么是否需要为每一个接口实现其代理/存根组件程序呢?在这些接口中,如果它是COM提供的标准接口,或者是OLE标准接口,则COM或者OLE已经提供了其代理/存根程序,我们可以不管这些接口,直接使用即可;如果自定义的接口,则必须自己实现代理/存根程序,并注册到系统中,然后才能真正使用这些接口。
15、自定义接口的代理/存根程序的实现
Microsoft提供了MIDL实用工具帮助我们建立自定义接口的代理/存根程序。首先我们使用IDL(接口描述语言)语言建立接口描述文件,然后运行MIDL工具,它会根据接口描述文件生成一些C语言源代码文件,用这些源代码文件可以创建代理/存根组件程序。它为我们提供了接口代理/存根组件的一种标准实现方法。
用IDL描述接口与C++描述接口有一些相似之处,但IDL是一种平台无关的标准化描述语言。Win32 SDK提供了所有COM或者OLE标准接口的IDL描述,包括unknown.idl(定义了IUnknown接口),可以在Visual C++的include目录下找到这些IDL文件。Microsoft的RPC开发包中包括运行程序MIDL.EXE和接口列集使用的RpcProxy.h、Unknown.idl和wtypes.idl,以及RPC调用所需要的静态连接库和动态连接库。
一般地,用MIDL程序可以产生实现代理/存根组件程序所需要的所有C语言源代码文件:***.h为接口说明头文件;***_p.c为接口代理和存根的实现文件;***_i.c为定义所有GUID描述符的文件;dlldata.c包含代理/存根程序的入口函数及类厂所需的数据结构。
运行NMAKE程序可以生成代理/存根组件程序,在集成开发环境中也可以生成。在集成开发环境中创建一个工程,并把MIDL生成的源代码文件的DEF文件加入到工程中,并在编译选项中加入REGISTER_PROXY_DLL,在连接选项中加入rpcrt4.lib、uuid.lib。
COM库能够提供代理管理器和存根管理器,并且MIDL又能够自动生成自定义接口的代理和存根源代码,但目前,COM库还不能在运行过程中根据接口的描述自动生成接口代理和接口存根来处理自定义接口的列集过程。
16、MIDL创建自定义接口代理/存根组件程序的过程
(1) 编写接口的IDL文件;
(2) 运行MIDL工具生成相关的源代码文件;
(3) 编写DEF文件;
(4) 编写MAK文件;
(5) 编译连接得到接口/存根组件程序;
(6) 运行regsvr32.exe注册组件程序。
17、COM的安全性
安全性不是COM的主要目的,但既然COM是一种平台独立的软件模型,而且提供了跨进程甚至跨网络的客户/服务器软件结构,则安全性是不可缺少的保护机制。建立一种适合各种操作系统的安全性机制是不可能的,所以COM规范也只是提供了安全性机制框架。在Windows平台上实现的COM版本基本上基于Windows NT的鉴定服务(authentication service)机制。
18、Windows NT安全机制
Windows NT作为网络操作系统,具有完全的保护机制,系统的所有资源都是受保护的,这些资源包括文件、外设、进程、线程,甚至同步对象、共享内存、注册表中的键等等。所谓受保护是指这些资源与特定的访问权限联系在一起,当这些资源被访问时,操作系统要对权限进行验证,以便允许访问或者禁止访问。
19、RPC鉴定的5个层次
(1) 无鉴定操作即正常的RPC调用;
(2) 连接时进行鉴定;
(3) 每一个接口调用时进行鉴定
(4) 对每个请求进行鉴定,并对接收到的数据包进行完整性检验;
(5) 进行所有的鉴定并对数据包加密。
20、COM提供了两种类型的安全性
激活安全性(activation security),不同于激发安全性(launch security),包括COM对象如何被安全地启动、客户如何与对象建立连接,以及如何保护公共的资源,比如全局运行对象表、系统注册表等。
调用安全性(call security),是指在已经建立连接的基础上,客户调用组件程序的安全保护问题。
21、激活安全性
SCM是COM库中负责找到并启动组件程序的组件。当客户向COM库请求创建新的COM对象或者连接已经运行的组件对象时,负责处理请求的正是SCM。因此,激活安全性也通过SCM实现。
激活安全性是进程一级的安全性,即进程中所有的对象和所有对象的成员函数共享的安全性,它分两种情况:静态安全性和动态安全性。当SCM接收到激活对象的请求时,它检查注册表中安全配置信息,以便满足合法用户的请求,这称为静态安全性检查;另一种情况是,在程序运行过程中设置进程的安全性,这称为动态安全性检查。
Windows提供的工具DCOMCNFG.EXE(在控制面板中)可以对组件的安全性进行设置。
22、调用安全性
调用安全性的实现方法之一是使用IClientDecurity接口,方法二是使用COM提供的API函数。IClientDecurity是接口代理选择实现的接口,它的三个主要成员函数:CopyProxy、QueryBlanket和SetBlanket。COM提供了几个API函数封装了接口IClientDecurity的调用:CoQueryProxyBlanket、CoSetProxyBlanket和CoCopyProxy。
MIDL生成的接口代理对象实现了IClientDecurity接口,并且系统代理管理器也实现了IClientDecurity接口,所以并不需要自己实现IClientDecurity接口。
23、Win32线程和COM线程
Win32提供两种线程:UI线程(user-interface thread,也称为用户界面线程)和辅助线程(worker thread)
对应于Win32的两种线程,COM也有两种线程类型:套间线程(apartment thread)(对应于UI线程)和自由线程(free thread)(对应于辅助线程)。
COM线程特性是针对特定的COM对象,而不是针对COM组件程序,所以在同一个COM组件中的不同对象可以运行在不同的线程类型上。
24、COM线程的使用
(1)进程内组件对象
如果一个COM对象运行在一个套间线程中,那么此COM对象与UI线程中的窗口对象有很类似的特性。COM对象属于创建此对象的套间线程所有。套间线程通过消息控制函数被自动同步,所以,运行在套间线程中的COM对象,不需要进行同步处理,但套间线程外的客户的其他线程要访问此线程只能通过代理/存根实现。
如果一个COM对象运行在一个自由线程中,那么同一进程中的其他线程(即客户线程)可以直接调用此对象成员函数,但对象成员函数必须进行同步处理,以保证其线程安全性。
(2)进程外组件对象
如果是进程外组件对象,则不管其运行在套间线程还是自由线程中,客户调用必须跨进程,因此调用始终是间接进行的,所以列集对于进程外组件对象是必须的,而列集的结果是自动实现同步的,对象成员函数可以不处理同步。
套间线程中的对象被跨线程调用时,与跨进程调用有着类似的特性。所以套间线程有自己的COM库初始化和终结过程调用。
25、列集和同步
COM对象的不同线程模型影响的主要是列集处理和同步处理。结果列集处理的调用总是通过代理和存根间接进行,因此,其效率也自然有所降低,但列集使所有对对象的调用通过消息循环中转,所以调用被自动进行同步处理,某一时刻至多只能有一个调用在进行,所以COM对象不需要进行同步处理,也就是说COM对象可以不是线程安全的(thread-safe)。反过来,不通过列集处理的调用虽然是直接进行的,效率也比较高,但某一时刻可能会有多个客户同时调用,因此,对象必须要进行同步处理,以便保证对象是线程安全的。
26、不同线程模型(客户线程与对象线程的不同组合)对列集和同步的不同要求
(1)客户和对象运行在不同的进程中。客户调用进程外组件总是要通过代理和存根,所以列集是必须的。因此,COM自动实现了调用的同步处理,对象不必进行同步处理。
(2)客户和对象运行在同一个线程中。与对象处于同一线程中的客户调用对象总是直接进行的,而且同一线程中的调用不可能冲突,所以客户调用即不需要列集,而且对象也不必进行同步处理。
(3)客户和对象运行在同一个进程中,对象运行在套间线程中,客户运行在另一个套间线程或自由线程中。因为对象运行在它自己的套间线程中,所以客户调用总需要列集处理,COM自动实现同步处理,对象不必考虑同步。COM会自动为我们实现接口指针的列集处理,我们也可以自己对接口指针进行列集处理。
(4)客户和对象运行在同一个进程中,对象运行在自由线程中,客户运行在另一个套间线程或自由线程中。当客户调用自由线程中的对象时,虽然对象被自由线程所创建,但调用实际上在客户线程中执行,所以客户对接口的调用是直接进行的,因此接口列集是不必要的,但对象必须自己处理同步,因为多个客户有可能同时调用接口成员函数。
27、套间线程
在套间线程的主函数中有一个消息循环,而且主函数必须对COM库进行初始化。COM在套间线程中创建了一个隐藏的窗口,主函数的消息循环负责接收消息并分发消息(包括客户对对象的调用的消息)。
对于运行在套间线程中的COM对象来说,因为这样的对象只能被此线程访问,其他的线程只能通过代理/存根调用接口函数,所以对象可以不必担心同步问题,但对象仍然需要保护全局变量,因为对象的成员函数被所有的同类对象所共享,因而函数有可能会重入。进程内组件DLL程序的入口在多线程环境下有可能被同时访问到,因此,这些入库函数如DllGetClassObject和DllCanUnliadNow仍然需要进行同步处理,以保证多线程访问时不会发生冲突。进一步来讲,DLL组件程序的类厂也必须满足一定的要求,以保证多个线程同时访问类厂对象时不会引发冲突问题,尤其当用类厂对象创建多个组件对象时,类厂必须是线程安全的,即内部提供了同步处理。使类厂线程安全只需对引用计数操作进行同步保护即可。
如果套间中的函数要把接口指针传给另一个线程,不管此线程是套间线程还是自由线程。列集和散集是必须要进行的,列集处理分两种情况:自动列集和手工列集。自动列集的情况比较简单,凡是通过COM传递的接口指针,COM都会自动列集,包括装入接口代理和存根代码等等。手工处理列集也是可能的,因为客户线程与对象线程在同一个进程中,因此,通过其他途径传递接口指针也很方便。
28、自由线程
自由线程在概念上与Win32的辅助线程完全一致。它们只有一个主函数,当主函数执行完成后,线程就自动结束。在自由线程的主函数中,必须调用CoInitializeEx函数,而且dwCoInit参数必须指定为COINIT_MULTITHREADED,以便COM知道这是一个自由线程。自由线程中的COM对象必须是线程安全的,所有的同步工作由对象自己处理。
与套间线程类似,自由线程即可以由客户程序创建,也可以由类厂创建,但不管哪种情况,COM对象总是由自由线程的主函数来创建。
29、进程内组件的线程模型
通常进程内组件并不调用CoInitialize或者CoInitializeEx标识其对象所使用的线程模型,但是,COM需要知道进程内对象的线程模型,以便正确处理跨线程情况下接口指针的传递以及对象调用的同步处理,所以,我们要在系统注册表中指定对象的线程模型。
对于进程内组件程序,为了支持多线程的情形,不管是套间线程模型还是自由线程模型,其入口函数DllGetClassObject和DllCanUnloadNow应该是线程安全的,尤其需要对引用计数包括对象引用计数器以及锁计数器等进行同步保护。
在实际使用过程中,通常对象被客户线程所创建,因此客户线程模型与对象的线程模型有可能不一致,这种不一致性包含两种可能:支持套间线程模型的对象被自由线程所创建,则COM会生成一个套间线程来运行对象,并把列集后的接口指针传给客户线程;第二,支持自由线程的对象被套间线程所创建,则COM会生成一个自由线程来创建对象,并把接口指针经列集后(可以优化)传给套间线程。
套间线程模型的COM对象,如果用到了自定义接口,则即使是进程内组件程序,也需要提供自定义接口的代理/存根程序,以便客户跨套间调用对象时能够使用正确的接口代理和接口存根。