COM深入理解(下)
——方法参数类型为CRuntimeClass*、void*等
本文上篇已经说明了类对象实际是一个结构实例,并且为了实现进程间传递类对象指针以达到引用的目的,需要为此类专门编写一个代理类,并在传递时例示(即实例化)其一个对象以实现代理对象。而此代理类必定分成两部分,即一部分的成员函数专门在客户进程被调用,另一部分专门在组件进程被调用以实现进程间的数据传递进而将客户的调用命令原封不动地传递给组件对象以实现客户操作组件对象。
上面的做法实际就是编写自定义汇集操作时应该干的事,只不过还需照着COM的几个附加规定来做,如必须实现IMarshal接口等。本文说明如何为这样的类型传递编写标准的代理/占位组件以跨进程传递类对象的指针(使用MIDL来完成)。
为了在客户端生成一个代理对象,必须将某些信息传递过去,然后在客户端根据传递的信息构建一个代理对象。在IDL语言的类型定义中,没有类这种类型,因此是不可能让接口方法的参数类型为某个自定义类的指针。但是的确有此需要,则只能将类对象指针转成某种IDL中识别的类型,最好的候选人就是void*,然后借助MIDL生成的代码将构建代理对象的信息传递过去。
void*不带有任何语义,其仅表示一个地址,因此在IDL中传递void*是错误的,因为MIDL无法根据void*所带的语义确定应该如何汇集其指向内存中的内容。但是MIDL还是提供了多种途径来解决这个问题的,下面仅说明其中两个用得最多的方法:[call_as()]属性和[wire_marshal()]属性。
[local]和[call_as()]
[local] 接口或接口方法都可以加上[local]属性以表示此方法或此接口中的方法不需要生成汇集代码,进而就避免了上面由于void*不带有任何语义而不能汇集其指向内容这个问题,因为不需要生成汇集代码,进而其所修饰的方法的参数可以为void*。此属性所修饰的方法或接口被称为本地方法或本地接口,因为这些方法没有汇集代码,不能进行远程调用。这在COM的标准接口中应用十分广泛。如查看IUnknown的IDL代码,其就是一个本地接口。再如查看IClassFactory接口的IDL定义,如下:
[
object,
uuid(00000001-0000-0000-C000-000000000046),
pointer_default(unique)
]
interface IClassFactory : IUnknown
{
typedef [unique] IClassFactory * LPCLASSFACTORY;
[local]
HRESULT CreateInstance(
[in, unique] IUnknown * pUnkOuter,
[in] REFIID riid,
[out, iid_is(riid)] void **ppvObject);
[call_as(CreateInstance)]
HRESULT RemoteCreateInstance(
[in] REFIID riid,
[out, iid_is(riid)] IUnknown ** ppvObject);
[local]
HRESULT LockServer(
[in] BOOL fLock);
[call_as(LockServer)]
HRESULT __stdcall RemoteLockServer(
[in] BOOL fLock);
}
其中的CreateInstance和LockServer就是本地函数,MIDL将不会为这两个函数生成汇集代码,也就是代理/占位代码,其表现就是类似下面的两个函数原型的代码:
HRESULT STDMETHODCALLTYPE IClassFactory_LockServer_Proxy( IClassFactory * This,
BOOL fLock );
HRESULT STDMETHODCALLTYPE IClassFactory_LockServer_Stub( IClassFactory * This,
BOOL fLock );
也就是说,当在.idl文件中检测到一个接口方法的定义时,MIDL都会为这个方法生成两个附加的函数,名字分别为<InterfaceName>_<MethodName>_Proxy和<InterfaceName>_<MethodName>_Stub,以分别作为代理和占位的代码。如上面的RemoteCreateInstance,将生成IClassFactory_RemoteCreateInstance_Proxy和IClassFactory_RemoteCreateInstance_Stub这么两个函数的声明和定义。
但是当方法被[local]属性修饰时,则不会生成上面的两个函数的声明和定义,因为它们被假定一定用于直接调用,不会有汇集的需要,因此没有汇集代码,并被称为本地方法。但它们还是会被加入接口这个函数指针数组的行列,即生成的接口头文件中依旧可以看见这类方法的声明(但是在类型库中却没有,这可以认为是MIDL的一个BUG,不过是可以绕过的)。
[call_as()] 接口方法可以被加上[call_as()]属性进行修饰,以指定此方法将被作为括号中指定的本地方法调用的替代品,即被作为什么调用。它不像[local]属性修饰的方法,其依旧会生成汇集代码,但却不会出现在接口中,即生成的头文件中,看不见这类方法的声明(但是在类型库中却看得见,这是一个BUG,可以通过预定义宏绕过)。此被称为方法别名,因为其将两个方法关联了起来,其中一个([local]修饰的)是另一个([call_as]修饰的)的别名,被实际使用。
如前面的RemoteLockServer就带有属性[call_as(LockServer)]以表示此函数是当客户调用LockServer时,并且需要进行汇集操作时调用的。将[local]修饰的方法称为本地版,[call_as()]修饰称为远程版,则可以认为远程版函数解决了本地版函数没有生成汇集代码的问题,因为本地版函数可能有某些特殊要求(如参数类型为void*)而不能生成汇集代码。
既然[call_as()]产生了一个函数别名,对两个函数进行了关联,因此必须有一种机制实现这种关联。MIDL就是通过要求开发人员自己编写本地版方法的汇集代码来实现这个关联关系。对于上面的LockServer,MIDL将会为其生成两个函数原型,如下:
HRESULT STDMETHODCALLTYPE IClassFactory_LockServer_Proxy( IClassFactory * This,
BOOL fLock );
HRESULT __stdcall IClassFactory_LockServer_Stub( IClassFactory * This,
BOOL fLock );
但仅仅是原型,即声明,没有定义。因此开发人员需自己编写上面两个函数的定义。注意:虽然名字是IClassFactory_LockServer_Stub,但它的原型正好和RemoteLockServer对调,以实现将远程版函数传递过来的参数再转成本地版的参数。
因此关联的过程就是:客户调用IClassFactory_LockServer_Proxy,然后开发人员编写此函数,并在其中将传进来的MIDL不能或不希望被处理的参数类型转成IClassFactory_RemoteLockServer_Proxy的参数形式,并调用之以传递参数。在组件端,COM运行时期库调用开发人员编写的IClassFactory_LockServer_Stub(注意:此函数的原型不是LockServer,而是RemoteLockServer)以将通过网络传过来的参数换成原始的MIDL不能或不希望被处理的参数形式,并调用传进来的IClassFactory*参数的LockServer方法以实现调用了组件对象的方法,然后返回。下面举个简例:
有个自定义类CA,如下:
class CA
{
long m_a, m_b;
public:
long GetA();
void SetA( long a );
};
欲在下面的接口中传递其对象指针:
///////////////////////abc.idl/////////////////////////
import "oaidl.idl";
import "ocidl.idl";
[
object,
uuid(1A201ABC-A669-4ac7-9E02-2DA772E927FC),
pointer_default(unique)
]
interface IAbc : IUnknown
{
[local] HRESULT GetA( [out] void* pA );
[call_as( GetA )] HRESULT RemoteGetA( [out] long *pA, [out] long *pB );
};
新建一DLL工程,关掉“预编译头文件”编译开关,将生成的abc_i.c、abc_p.c、dlldata.c和abc.h加到工程中,并建立一个abc.def文件加入到工程中以导出几个必要的用于注册的函数,如下:
;;;;;;;;;;;;;;;;;;;;;;;;abc.def;;;;;;;;;;;;;;;;;;;;;;;;;
LIBRARY "abc"
EXPORTS
DllCanUnloadNow PRIVATE
DllGetClassObject PRIVATE
DllRegisterServer PRIVATE
DllUnregisterServer PRIVATE
并新添加一个abc.cpp文件,如下:
///////////////////////abc.cpp/////////////////////////
#include "abc.h"
#include <new>
class CA
{
public:
long m_a, m_b;
long GetA();
void SetA( long a );
};
HRESULT STDMETHODCALLTYPE IAbc_GetA_Proxy( IAbc *This, void *pA )
{
if( !pA )
return E_INVALIDARG;
CA *pAA = reinterpret_cast< CA* >( pA );
// 调用远程版的代理函数以传递参数,由MIDL生成
return IAbc_RemoteGetA_Proxy( This, &pAA->m_a, &pAA->m_b );
}
HRESULT STDMETHODCALLTYPE IAbc_GetA_Stub( IAbc *This, long *pA, long *pB )
{
void *p = CoTaskMemAlloc( sizeof( CA ) );
if( !p )
return E_FAIL;
CA *pAA = new( p ) CA; // 生成一个类对象
// 调用对象的本地方法
HRESULT hr = This->GetA( pAA );
if( SUCCEEDED( hr ) )
{
*pA = pAA->m_a;
*pB = pAA->m_b;
}
// 释放资源
pAA->~CA();
CoTaskMemFree( p );
return hr;
}
最后添加预定义宏REGISTER_PROXY_DLL和_WIN32_WINNT=0x500,并连接rpcrt4.lib库文件,确保没有打开/TC或/TP编译开关以保证对上面的abc.cpp进行C++编译,而对MIDL生成的.c的源文件进行C编译。
使用时如下:
IAbc *pA; // 假设已初始化
CA a;
pA->GetA( reinterpret_cast< void* >( &a ) );
而组件实现的代码如下:
STDMETHODIMP CAbc::GetA( void *pA )
{
if( !pA )
return E_INVALIDARG;
*reinterpret_cast< CA* >( pA ) = m_A;
return S_OK;
}
如上就实现了将类CA的对象进行传值操作,但不是传址操作。前面已说明,欲进行后者,必须编写相应的代理类。先使用上面的方法将必要的信息传递后,再根据传递的信息初始化类CA的代理对象以建立连接。一般如非得已最好不要编写代理对象,而通过将类转成接口形式,由MIDL辅助生成代理/占位组件以变相实现。
下面介绍使用[wire_marshal()]属性进行传值操作。
[wire_marshal()]
前面使用方法别名机制实现了传递自定义数据类型,但是其是以方法为单位进行处理的,当要多次使用某一个数据类型时,如前面的CA*,如果对每个使用到CA*的方法都进行上面的操作,很明显地效率低下,为此MIDL提供了[wire_marshal()]属性(当然不止这么一个属性)。
[wire_marshal()]属性只能用于类型定义,即typedef中,使用语法如下:
typedef [wire_marshal(wire_type)] type-specifier userm-type;
其将一个线类型(wire-type,即MIDL可以直接处理的类型)和一个描述类型(type-specifier,即不能或不打算被MIDL处理的特殊数据类型)相关联,并用一个可识别名字(userm-type)标识。其和[transmit_as()]属性类似,都是将两个类型进行关联,就如前面的[local]和[call_as()]将两个方法进行关联一样,只不过[wire_marshal()]是直接将描述类型按IDL的列集格式(网络数据描述NDR——Network Data Representation)列集到指定的缓冲区中,而[transmit_as()]还需汇集代码在中间再转换一次,因此[wire_marshal()]的效率要更高,只不过由于需要编写列集代码,因此需要了解NDR格式,处理数据对齐等问题,所以显得麻烦和复杂。最常见的应用就是句柄的定义,如下:
typedef union _RemotableHandle switch( long fContext ) u
{
case WDT_INPROC_CALL: long hInproc;
case WDT_REMOTE_CALL: long hRemote;
} RemotableHandle;
typedef [unique] RemotableHandle * wireHWND;
#define DECLARE_WIREM_HANDLE(name) typedef [wire_marshal(wire ## name)] void * name
DECLARE_WIREM_HANDLE( HWND );
也就是说我们常用的HWND类型是:
typedef [wire_marshal( wireHWND )] void* HWND;
即其在应用程序中(即客户或组件,即代理/占位的使用者)是void*类型,当需要传输时,实际是传输结构RemotableHandle的一个实例,而此结构是一个以fContext为标识的联合,实际为8字节长。
为了实现上面提到的void*和RemotableHandle*的关联,开发人员必须提供下面四个函数的定义:
unsigned long __RPC_USER < userm-type >_UserSize( // 返回欲请求的缓冲区大小
unsigned long __RPC_FAR *pFlags, // 一个标志参数,后叙
// 给出当前已经请求的缓冲区大小,返回的大小应该以此作为起点
unsigned long StartingSize,
< userm-type > __RPC_FAR * pUser_typeObject ); // 欲传递的描述类型的实例
unsigned char __RPC_FAR * __RPC_USER < userm-type >_UserMarshal( // 列集
unsigned long __RPC_FAR * pFlags, // 标志参数
unsigned char __RPC_FAR * Buffer, // 已分配的缓冲器有效指针
< userm-type > __RPC_FAR * pUser_typeObject ); // 欲列集的描述类型的实例
unsigned char __RPC_FAR * __RPC_USER < userm-type >_UserUnmarshal( // 散集
unsigned long __RPC_FAR * pFlags, // 标志参数
unsigned char __RPC_FAR * Buffer, // 列集数据的缓冲器指针
// 描述类型的实例指针,从列集数据中散集出描述类型后,放在此指针所指内存之中
< userm-type > __RPC_FAR * pUser_typeObject );
void __RPC_USER < userm-type >_UserFree( // 释放UserUnmarshal中分配的内存
unsigned long __RPC_FAR * pFlags, // 标志参数
// UserUnmarshal中的pUser_typeObject参数,一个描述类型的实例的指针
< userm-type > __RPC_FAR * pUser_typeObject );
对于前面的HWND,开发人员就必须提供如下四个函数的定义(当然Microsoft是已经提供了的):
unsigned long __RPC_USER
HWND_UserSize( unsigned long*, unsigned long, HWND* );
unsigned char* __RPC_USER
HWND_UserMarshal( unsigned long*, unsigned char*, HWND* );
unsigned char* __RPC_USER
HWND_UserUnmarshal( unsigned long*, unsigned char*, HWND* );
void __RPC_USER
HWND_UserFree( unsigned long*, HWND* );
在MIDL生成的汇集代码中,遇到方法参数类型为HWND时,发生如下事情:
1. 调用HWND_UserSize并传递应用程序(客户或组件,视HWND是in参数还是out参数)传进来的HWND的实例以得到欲传递此实例需要的缓冲区大小
2. 在RPC通道上分配相应的内存块
3. 调用HWND_UserMarshal,依旧传递前面的HWND实例以及分配到的缓冲区的指针以将此HWND实例列集到缓冲区中
4. 通过RPC通道将缓冲区内容传递到对方进程空间中
5. 调用HWND_UserUnmarshal,并传递通过RPC通道得到的列集数据缓冲区的指针和生成的一临时HWND实例的指针以记录散集出来的HWND实例
6. 以返回的HWND实例为参数调用应用程序的方法
7. 调用HWND_UserFree,传递前面因调用HWND_UserUnmarshal而生成的临时记录散集出的HWND实例的指针以释放因此分配的内存
以上,就是[wire_marshal()]属性对线类型和描述类型的绑定的实现。但其中漏了一点,就是标志参数pFlags的使用。此标志参数是一个4字节数字,其高16位是一些关于NDR格式的编码规则,以使得NDR引擎(将填写好的缓冲区内容按NDR格式串的规则进行排列以在网上传输的程序)能做出正确的数据转换。其低16位是一个MSHCTX枚举值,指明调用环境,是进程内还是跨进程、是远程还是本地(具体信息还请查阅MSDN),因而可以在上面的四个函数中根据此值作出相应的优化。
下面为上面的CA*实现[wire_marshal()]属性。
前面已经了解到,CA*由于在IDL中没有对应的类型,应该使用void*来进行传递,在abc.idl中增加如下代码:
typedef struct _SA
{
long a, b;
} *PSA;
typedef [wire_marshal( PSA )] void* PA;
并为接口IAbc增加一个方法:
HRESULT SetA( [in] PA a );
接着在abc.cpp中增加如下代码:
unsigned long __RPC_USER PA_UserSize( unsigned long* /* pFlags */,
unsigned long StartingSize,
PA* /* ppA */ )
{
// 之所以有StartingSize,因为此参数可能并不是第一个被列集的参数,
// 如:HRESULT SetA( [in] long tem1, [in] char tem2, [in] PA a );
// 此时的StartingSize就为sizeof( long ) + sizeof( char )
// 而之所以还要再将其传进来是为了对齐需要
// 此处没有进行对齐处理,因为结构_SA是只有两个unsigned long的简单
// 结构,无须再刻意对齐。
return StartingSize + sizeof( _SA );
}
unsigned char* __RPC_USER PA_UserMarshal( unsigned long *pFlags,
unsigned char *Buffer,
PA *ppA )
{
// 按线种类(即结构_SA)的定义填冲缓冲区,注意必须按照NDR传输格式
// 进行填充,这里由于_SA简单,所以只是简单地复制,没有什么对齐及一
// 致性数据的问题。关于NDR传输格式的详细内容,请参考
// http://www.opengroup.org/onlinepubs/9629399/chap14.htm
if( *pFlags & MSHCTX_INPROC )
{
// 是进程内调用,直接将CA*进行传递,而不进行拷贝
*reinterpret_cast< void** >( Buffer ) = *ppA;
}
else
{
CA *pA = reinterpret_cast< CA* >( *ppA );
PSA pSA = reinterpret_cast< PSA >( Buffer );
pSA->a = pA->m_a;
pSA->b = pA->m_b;
}
// 返回缓冲区的有效位置,当前位置后的sizeof( _SA )个字节
return Buffer + sizeof( _SA );
}
unsigned char* __RPC_USER PA_UserUnmarshal( unsigned long *pFlags,
unsigned char *Buffer,
PA *ppA )
{
if( *pFlags & MSHCTX_INPROC )
{
// 是进程内调用,直接将CA*进行传递,而不进行拷贝
*ppA = *reinterpret_cast< void** >( Buffer );
}
else
{
void *p = CoTaskMemAlloc( sizeof( CA ) );
if( !p )
return Buffer + sizeof( _SA );
CA *pAA = new( p ) CA; // 生成一个类对象
PSA pSA = reinterpret_cast< PSA >( Buffer );
pAA->m_a = pSA->a;
pAA->m_b = pSA->b;
*ppA = p;
}
// 返回缓冲区的有效位置,当前位置后的sizeof( _SA )个字节
return Buffer + sizeof( _SA );
}
void __RPC_USER PA_UserFree( unsigned long *pFlags,
PA *ppA )
{
if( !( *pFlags & MSHCTX_INPROC ) )
{
// 不是进程内汇集,分配了内存,释放资源
CA *pAA = reinterpret_cast< CA* >( *ppA );
pAA->~CA();
CoTaskMemFree( pAA );
}
}
使用中,则:
IAbc *pA; // 假设已初始化
CA a;
a.SetA( 654 );
PA pAA = &a;
pA->SetA( pAA ); // 或者直接pA->SetA( &a );
pA->GetA( &a );
非常明显,MIDL提供的可用于自定义类型传递的属性很正常地不止上面几个,如:[transmit_as()]、[handle]等,在此仅起抛砖引玉的作用,关于MIDL提供的其他属性,还请参考MSDN。上面的实现方法中,都不仅仅提供了汇集自定义数据类型的渠道,还提供了优化的途径(如上面的pFlags标志参数)。因此在编写代理/占位组件时,应考虑在关键地方应用类似的属性进行生成代码的优化。