原文:http://www.codeproject.com/Articles/13862/COM-in-plain-C-Part-2
如何用C编写可以被Vbscript、VB和jscipt等脚本语言调用的COM组件
下载例程-93.5kb
内容:
在本系列的第一部分中,我们用标准C创建了一个COM组件,把它封装在一个动态库中,同时学习了怎样去注册和用C或者C++调用它。我们还创建了一个包含我们的GUID和对象VTable(函数)定义的头文件(IExample.h)。通过包含这个.H文件, C/C++应用程序可以知道我们对象VTable的成员(调用我们函数)、参数和返回值及对象的GUID和VTable。
尽管这些对一个C/C++程序的支持已经足够了,我们必须(给我们的IExample DLL)增加功能以支持解释性“脚本”语言,比如说Visual Basic、VBScript、Jscript和Python等。这篇文章的焦点就是如何添加这方面的支持。在我们增加了这个支持后,我们将写一个VBScript例子来使用更新后的IExample组件。
不是所有的语言都能识别我们的C/C++包含文件和获取其中的信息,例如Visual Basic、Python等。这些应用程序不知道我们对象中的函数、函数传递参数和返回值及我们的GUID。大多数的语言不支持从C/C++包含文件中获取信息。我们需要一种方法,用一个中立的语言格式来指定IExample.h中的所有信息,比较合适的约定好的“二进制”格式,而不像IExample.h。就像我们要看到的一样,我们通过创建一种叫类型库的来实现这一点。
为了使脚本引擎读取我们类型库更容易和标准化,我们将添加一些被命名为IDispatch接口的函数给我们的对象。脚本引擎只通过这些IDispatch函数来获得类型库。由于脚本引擎不直接存取我们的类型库,这意味着如果微软开发一个后期版本的类型库,这些杂乱的版本细节对于脚本引擎是隐藏的。
另一个麻烦事就是数据类型。考虑到一个ANSI C的字符串。一个C字符串是一个以0结尾的八位的字节串。但是在Pascal中字符串不是这样存储的。一个Pascal字符串是以一个表明后面有多少字节数的字开始。换句话说,一个Pascal字符串以一个长度字节开始,接下来是这个字符串内容,没有0这个结束符。还有UNICODE和ANSI之分,对于UNICODE,字符串中的每个字符占两个字节(也就是说是一个short类型)。Visual Basic字符串使用UNICODE而不是ANSI C。
我们需要处理如何解析这些数据类型的问题。我们需要找出一套可以工作在使用我们对象的所有语言上的“通用数据类型”,同时只能使用这些数据类型作为我们对象函数的参数(包括我们函数的返回值)。
为了支持多种语言(以及每种语言扩展,比如UNICODE),微软已经定义了一种通用的数据类型给COM对象使用。微软叫他们“自动化数据类型”。(以后,我们将看到自动化这个名字是从哪而来。)其中的一个自动化数据类型是BSTR。BSTR是什么?它是一个特殊格式的字符串指针。每个字符占两个字节(也就是说是一个short类型),并且它以一个表明接着有多少short类型数据的unsiged long作为前缀,它以一个short类型的0结束。它几乎可以适应每种语言中的字符串数据类型。但是它也意味着,一个C/C++应用程序或者我们的C对象本身有时也需要转换一个C字符串为一个以NULL结束的带长度前缀的UNICODE字符串。
为了说明,让我们看一下下面的ANSI C 字符串:
char MyString[] = "Some text";
首先,让我们把字符串分成单个字符的形式:
char MyString[] = {'S','o','m','e',' ','t','e','x','t', 0};
由于一个C字符串是以NULL结束,注意我们在数组的最后包括了一个0字节。现在,我们需要把这个字符串转换成UNICODE。这也意味这个每个char变成short。我们需要把数组重新定义为short类型:
short MyString[] = {'S','o','m','e',' ','t','e','x','t', 0};
现在,我们需要以这个数组的字节数作为这个数组的开始。注意我们没有把结束符的0作为short计算在内。所以,我们得到的长度是9乘以每个short占两个字节数(注意Intel处理器是小尾顺序):
short MyString[] = {18, 0, 'S','o','m','e',' ','t','e','x','t', 0};
一个BSTR被定义成特殊格式的UNICODE字符串指针。事实上,它是一个指向这个UNICODE字符串第三个short的指针。所以,在这里我们声明一个BSTR型的变量,把它指向上面的字符串:
BSTR strptr; strptr = &MyString[2];
现在我们把strptr传递给以BSTR作为参数的COM对象。同时如果我们对象的一个函数接受一个BSTR类型的参数,strptr就是这个被传递的参数。
但在此之前你可能认必须像以上一样手动重新格式化所有的字符串,其实是不需要的。幸运的是,可以通过操作系统提供的MutiByteToWideChar函数转换一个ANSI C字符串到UNICODE字符串,还可以用SysAllocString和SysAllocStringLen分配一个缓冲区来拷贝一个UNICODE字符串,用一个unsiged long在前面表明这个Buffer的大小。所以通过我们的原始char型字符串获取一个指向它的BSTR指针(忽略了错误检查),我们可以这么做:
DWORD len; BSTR strptr; char MyString[] = "Some text"; // 得到我们要转换的MyString为UNICODE所需要的UNICODE缓冲区的长度(宽字符) len = MultiByteToWideChar(CP_ACP, 0, MyString, -1, 0, 0); // 分配所需长度的UNICODE缓冲区。SysAllocStringLen也会分配一个以0结束的 // short类型空间和unsiged long类型的计数。SysAllocStringLen会以 // len*sizeof(wchar_t)的值来填充这个Unsiged long的值并且返回指向 // 分配的缓冲区的第三个short数据的指针。留给我们要做的只是简单的用 // 一个我们的C字符串的UNICODE版本来填充这个缓冲区。 strptr = SysAllocStringLen(0, len); // 转换MyString为UNICODE到SysAllocStringLen分配的缓冲区中 MultiByteToWideChar(CP_ACP, 0, MyString, -1, strptr, len); // strptr现在是一个指向自动化数据类型的字符串的指针(BSTR)
注意:接着必须有人通过传递strptr给SysFreeString来释放通过SysAllocStringLen分配的缓冲区。一般由调用SysAllocStringLen来分配缓冲区的人来释放它。
注意由于BSTR指针在unsiged long长度的后面,并且总是以NULL为结束符的字符串,你可以把BSTR看作是一个宽字符(wchar_t)数据类型。例如,你可以通过把它传给lstrlenW来获得它作为宽字符的长度。但还应该注意到,在这个字符串中嵌入0 (‘\0’字符)是完全可能的。如果你写一个入口参数是一个人性化易读的文本字符串,那么这个BSTR指向的内容不包含嵌入的0在里面是一个正确的假设。另一方面,如果这个BSTR指向一个二进制数据,那么这就不是一个好的假设了。既然这样,你能用SysStringLen检测这个宽字符字符的长度(或者你可以用SysStringByteLen得到有字节长度)。
也有其他“自动化数据类型”,比如一个LONG(也就是一个32位的值)。所以我们对象的函数不会局限于只传递自动化的字符串的参数。
实际上,有些语言支持传递参数的数据类型可选择的函数概念。比如说我们有一个Print()函数,这个函数可以接受一个字符串指针(BSTR)也可能是一个LONG,或者可能是一个其他自动化数据类型,并且不论传入什么它都会打印。例如,如果传入一个BSTR,它会打印输出字符串的字符。如果传入一个LONG,它首先会做类似这样的调用:
sprintf(myBuffer, "%ld", thePassedLong)
...然后打印输入这个myBuffer的字符串结果。
这个Print函数需要通过某种方法知道传入的是一个字符串还是长整型数据。如果我们要把这个函数放入我们的对象中,我们可以通过运用另一个被称为VARIANT的自动化数据来实现。一个VARIANT是一个封装了另一个自动化数据类型的简单结构。(这是所有支持COM的语言必须在内部理解和会创建的一种结构类型,只是为了传递参数给一个对象的函数或者接收返回数据)
一个VARIANT有两个成员。第一个成员叫vt,是一个标识你封装的VARIANT是哪种类型数据的值。另一个成员是一个联合,存储这个数据。
这样,如果我们要封装一个BSTR到一个VARIANT结构中,我们需要把这个VARIANT的VT成员的值置为VT_BSTR,存储我们的BSTR(也就是说,指向特殊格式UNICODE字符串的指针)到这个VARIANT的bstrVal成员中。如果我们要封装一个LONG,那么我们要把这个VARIANT的vt成员的值置成VT_I4,存储我们的long值到VARIANT的lVal成员中(由于它是一个联合,它会指向同一个数据像bstrVAL)。
总之,为了支持脚本语言,我们对象的函数必须要写成只能传递一种叫自动化数据类型的参数。同样的,我们返回的数据也必 须是自动化数据类型的数据。
如果你看我们的SetString函数定义,你会注意到一个问题。我们定义它接受传递一个这样的字符串指针:
static HRESULT STDMETHODCALLTYPE SetString(IExample *this, char *str)
这是一个标C的字符串,它不是一个自动化数据类型。我们需要用BSTR参数替换它。
我们的GetString函数对于脚本语言也有一些麻烦。不但它被定义接受一个标C的字符串指针,而且调用者要提供一个非UNICODE的缓冲区,同时我们的函数修改了它的内容。这不是自动化兼容的。当需要返回一个BSTR给脚本语言时,我们必须给这个字符串分配内存且把它传给脚本引擎。脚本引擎会在结束时调用SysFreeString来释放内存。
为了这些需求,最好的办法是改变我们IExample对象,使其buffer成员是一个BSTR。当脚本引擎传递一个BSTR给我们的SetString时,我们会复制一个这个字符串拷贝,把这个新BSTR存储到buffer成员中。我们然后修改我们的GetString函数使它接受一个由脚本引擎提供的指向一个BSTR(也就是一个句柄)的指针。我们会再创建一个我们的字符串拷贝,把它返回给脚本引擎(信任脚本引擎会释放它)。
这样我们不改变我们的原始代码,我拷贝这个IExample目录内容到一个IExample2的新目录中。我改变IExample.c为IExample2.c,IExample.h为IExample2.h,等等。
在IExample2.c中,我们重命名MyRealExample结构为MyRellExample2(为了与我们原来的代码区分),并且改变这个buffer成员的数据类型。(我们也把这个成员的名字改成“string”)。这是新的定义:
typedef struct { IExample2Vtbl *lpVtbl; DWORD count; BSTR string; }MyRealIExample2;
这是我们更新后的SetString和GetString函数:
static HRESULT STDMETHODCALLTYPE SetString(IExample2 *this, BSTR str) { // 检查调用者传入的字符串 if (!str) return(E_POINTER); // 首先,释放我们分配的旧的BSTR if (((MyRealIExample2 *)this)->string) SysFreeString(((MyRealIExample2 *)this)->string); // 构造一个调用者字符串的拷贝并存贮这个新的BSTR if (!(((MyRealIExample2 *)this)->string = SysAllocStringLen(str,SysStringLen(str)))) return(E_OUTOFMEMORY); return(NOERROR); } static HRESULT STDMETHODCALLTYPE GetString(IExample2 *this, BSTR *string) { // 检查调用者传入的句柄 if (!string) return(E_POINTER); // 创建一个我们的字符串拷贝并把这个BSTR放在他的句柄中返回。调用者负责释放它 if (!(*string = SysAllocString(((MyRealIExample2 *)this)->string))) return(E_OUTOFMEMORY); return(NOERROR); }
同样的,我们的Release函数必须在我们释放MyRealIExample2前正确的释放分配的string:
if (((MyRealIExample2 *)this)->string) SysFreeString(((MyRealIExample2 *)this)->string); GlobalFree(this);
最后,当我们的IClassFactory创建了一个MyRealIExample2时,它应该清除这个string成员:
((MyRealIExample2 *)thisobj)->string = 0;
当然,我们需要更新IExample2.h中的这些函数定义:
// 额外函数 STDMETHOD (SetString) (THIS_ BSTR) PURE; STDMETHOD (GetString) (THIS_ BSTR *) PURE;
我们现在已经更新我们的对象函数可以使用自动化数据类型了。
我们下一步是向我们的对象添加IDispatch函数。四个IDispatch的函数已经被微软定义好了。我们必须写这四个函数,然后把指向他们的指针添加到我们对象的VTable中。
特别注意:在我们的VTable 中,这四个指针必须叫GetTypeInfoCount、GetTypeInfo、GetIDsOfNames和Invoke。他们必须按这个顺序出现,在三个IUnknown函数(也就是在我们的虚表中开始的三个指针)的后面,但要在我们自己额外的函数(也就是SetString和GetString)之前。所以,我们编辑IExample2.h文件,添加这四个IDispatch函数定义。我们要改变我们对象的名字IExample为 IExample2。注意在我们的DECLARE_INTERFACE_宏中,我们用IDispatch替换了IUnknown。这说明我们添加到我们的对象中的是一个标准的IDispatch函数。
// IExample2的VTable #undef INTERFACE #define INTERFACE IExample2 DECLARE_INTERFACE_ (INTERFACE, IDispatch) { // IUnkown函数 STDMETHOD (QueryInterface) (THIS_ REFIID, void **) PURE; STDMETHOD_ (ULONG, AddRef) (THIS) PURE; STDMETHOD_ (ULONG, Release) (THIS) PURE; // IDispatch函数 STDMETHOD_ (ULONG, GetTypeInfoCount)(THIS_ UINT *) PURE; STDMETHOD_ (ULONG, GetTypeInfo) (THIS_ UINT, LCID, ITypeInfo **) PURE; STDMETHOD_ (ULONG, GetIDsOfNames) (THIS_ REFIID, LPOLESTR *, UINT, LCID, DISPID *) PURE; STDMETHOD_ (ULONG, Invoke) (THIS_ DISPID, REFIID, LCID, WORD, DISPPARAMS *, VARIANT *, EXCEPINFO *, UINT *) PURE; // 额外函数 STDMETHOD (SetString) (THIS_ BSTR) PURE; STDMETHOD (GetString) (THIS_ BSTR *) PURE; };
因为我正在创建的是另一个COM 组件,我运行GUIDGEN.EXE来创建一个新的GUID给我的IExample2和IExample2VTbl,把他们粘贴的IExample2中。这样我们可以测试这个新的DLL而不需要卸载我们原来的DLL,使用新的DLL也不会妨碍原来的。否则如果我们用同样的GUID,我们会注册两个使用同一个GUID的COM组件,这不是一个好方法。
我又第三次运行GUIDGEN.EXE生成第三个GUID给我们的类型库。我把它加到IExample2.h中并给它起了一个CLSID_TypeLib的C变量名。
现在,我们需要写这四个IDispath 函数。幸运的是微软的OLE32 DLL中有我们可以调用做这四个函数大部分工作的通用函数。我们需要真正做的的工作仅仅是加载我们的类型库(在本文后面我们会创建它),生成一个叫 ITypeInfo的COM对象给脚本引擎。但在这微软提供了做大量工作的函数,也就是加载类型库的LoadRegTypeLib和用于创建 ITypeInfo的GetTypeInfoOfGuid。
我们的GetTypeInfoCount函数非常简单。调用者传给我们一个UINT的句柄。如果我们有一个类型库我们用一个1来填充它,如果没有用0。我们这样做:
ULONG STDMETHODCALLTYPE GetTypeInfoCount(IExample2 *this, UINT *pCount) { *pCount = 1; return(S_OK); }
我们的GetTypeInfo函数必须返回(给脚本引擎)一个指向来自我们类型库我们创建的ITypeInfo对象指针。一个ITypeInfo对象都做些什么?它负责脚本引擎从我们的类型库访问所有的加载信息。记得我们说过我们不要其他人直接加载和访问我们的类型库。而是我们给脚本引擎一个ITypeInfo对象,引擎使用这个对象的函数列出我们类型库的信息并返回关于我们的IExample2对象的详细信息。例如一旦我们把我们ITypeInfo给了脚本引擎,脚本引擎可以调用 ITypeInfo的GetFunDesc函数确定要传递给我们的GetString函数几个参数、每个参数的类型和GetString返回值(如果有的话)。
“哦,有好多函数要写”你一定会这样认为。是的,我们可以写ITypeInfo对象的所有必要函数,并把它们放在了IExample2.C中。然后我们用IExample2的GetTypeInfo函数来分配一个我们的ITypeInfo对象,初始化(也就是把它的虚表存储在它里面)并把它返回给脚本引擎。
但我们不会这么做。我们要节省一点我们的工作。微软已经写了几个通用的ITypeInfo 函数(包含在OLE DLL中),给出一个我们可以调用它来分配一个ITypeInfo的函数,同时把它的虚表用这几个通用函数来填充。然后我们把这个“通用的” ITypeInfo给脚本引擎。我们需要做的是加载我们的类型库到内存并把它“给”这个通用的ITypeInfo,这样微软的函数会处理发放少量的信息给脚本引擎。为了利用微软的通用的ITypeInfo函数,我们必须遵守这样的规则,必须保证我们的IExample2对象被定义为一个叫双接口的形式。我们不必马上知道它是什么,但我们要遵守这个规则。
在我们着手我们的GetTypeInfo 函数前,让我来说一个关于类型库的事情。它可以包含一个要显示给人们的字符串。例如,如果我们需要的话,我们可以给我们的GetString函数加一个“文本描述”的标签:函数返回一个IExample2对象的字符串。然后,例如Visual BASIC IDE可以获得我们对象,通过调用它的GetDocumentation函数来获得GetString的这个描述,并把它显示给要调用我们的 IExample的GetString的VB程序员。有许多人类语言,就像你的EXE/DLL的资源里可以有不同的语言一样,所以类型库也有一个“语言 ID”(LCID)。对于本文,我们将只创建一个英文的类型库,忽略一些关于加载一个特殊语言类型库的版本。要知道的是如果脚本引擎需要其他语言,它可以传递给我们的GetTypeInfo函数一个不同与英文的LCID。
GetTypeInfo定义是这样的:
ULONG STDMETHODCALLTYPE GetTypeInfo(IExample2 *this, UINT itinfo, LCID lcid, ITypeInfo **pTypeInfo)
itinfo参数没有使用,设为0。LCID指定类型库使用的是那种语言。我们还是忽略它,因为我们只处理英文。最后一个参数是脚本引擎期望我们通过它返回一个指向我们的ITypeInfo对象的指针。
我们只需要一个ITypeInfo 对象,因为它的内容并不改变。所以我们的GetTypeInfo函数只是简单确保我们的类型库已经加载,同时获得了一个微软通用的ITypeInfo指针。我们把这个指针存放在一个全局变量中。每当脚本引擎调用我们的GetTypeInfo时我们需要增加ITypeInfo的引用计数(也就是调用它的 AddRef函数)。(当然,我们认为当脚本引擎使用完ITypeInfo时它会调用ITypeInfo的Release)。那么,这是我们的 GetTypeInfo函数,和一个我们写的做加载我们类型库并获得微软的通用的ITypeInfo真正工作的helper函数(loadMyTypeInfo)。注意我们的helper函数如何调用OLE API LoadRegTypeLib来加载我们的类型库的。我们需要传入一个我为我们的类型库创建的GUID(在IExample2.h中,给他起了一个 CLSID_TypeLib的变量名)。
ULONG STDMETHODCALLTYPE GetTypeInfo(IExample2 *this, UINT itinfo, LCID lcid, ITypeInfo **pTypeInfo) { HRESULT hr; // 提示错误 *pTypeInfo = 0; if (itinfo) hr = ResultFromScode(DISP_E_BADINDEX); // 如果我们的ITypeInfo已经创建,增加它的引用计数 else if (MyTypeInfo) { MyTypeInfo->lpVtbl->AddRef(MyTypeInfo); hr = 0; } else { // 加载我们的类型库,获得一个微软的通用ITypeInfo对象 hr = loadMyTypeInfo(this); } if (!hr) *pTypeInfo = MyTypeInfo; return(hr); } static HRESULT loadMyTypeInfo(void) { HRESULT hr; LPTYPELIB pTypeLib; // 加载我们的类型库并获得它的TYPELIB指针。 // 注意:一定要pTyeLib->lpVtble->AddRef(pTypeLib) if (!(hr = LoadRegTypeLib(&CLSID_TypeLib, 1, 0, 0, &pTypeLib))) { // 获得一个微软的通用ITypeInfo。把它给我们的已加载的类型库。 // 我们仅仅需要其中的一个,我们把它存储在一个全局变量中。 // 通过传递虚表的GUID告诉微软它是我们的IExample2的虚表。 if (!(hr = pTypeLib->lpVtbl->GetTypeInfoOfGuid(pTypeLib, &IID_IExample2, &MyTypeInfo))) { // 现在我们不再需要这个传给微软的通用ITypeInfo的TYPELIB指针。 // 注意:通用的ITypeInfo已经做了pTypeLib->lpVtble->AddRef(pTypeLib)。 // 所以TYPELIB保留到通用的ITypeInfo也做了pTypeLib->lpVtbl->Release。 pTypeLib->lpVtbl->Release(pTypeLib); // 由于调用者要我们返回给他我们的ITypeInfo指针。 // 我们需要增加它的引用计数。 // 调用者使用完它应该调用Release()。 MyTypeInfo->lpVtbl->AddRef(MyTypeInfo); } } return(hr); }
稍后在我们创建我们的类型库时,我们需要分配给每一个我们添加给IExample 的我们自己的函数(也就是GetString和SetString)一个数字值,微软称他们为DISPID(Dispatch ID)。它可以是我们选定的一些值,但每个函数必须用一个唯一的DISPID。(注意:不能使用数值0,或负值)。我们随意分配一个1作为DISPID给 GetString和2给SetString。脚本引擎在内部使用这个DISPID来快速调用我们的函数,因为它通过一个数值与函数名字符串比较来在表中查找函数。
接下来,我们写我们的GetIDsOfNames 函数。这个函数的目的是允许脚本引擎传给我们我们自己添加在Iexampel中的函数名(也就是GetString或SetString),我们返回分配给这个函数的DISPID,如果引擎传给我们“GetString”,那么我们就返回DISPID 1。
因为在我们创建我们的类型库时,我们要定义我们的IExample2 为“双接口”,然后我们调用OLE函数DispGetIDsOfNames,它会做我们的GetIDsOfNames需要做的一切(也就是分析我们类型库中的信息,查找我们分配给特定函数的DISPID)。我们需要做的只是确保我们获得一个ITypeInfo,因为需要把它传递给 DispGetIDsOfNames。因此,这是我们的GetIDsOfNames:
ULONG STDMETHODCALLTYPE GetIDsOfNames(IExample2 *this, REFIID riid, LPOLESTR *rgszNames, UINT cNames, LCID lcid, DISPID*rgdispid) { if (!MyTypeInfo) { HRESULT hr; if ((hr = loadMyTypeInfo())) return(hr); } // 让OLE32.DLL的DispGetIDsOfName()做用我们的类型库在 // 我们对象中查找请求函数的DISPID这些真正的工作。 return(DispGetIDsOfNames(MyTypeInfo, rgszNames, cNames, rgdispid)); }
我们要写的最后一个IDispatch 函数:Invoke。脚本引擎通过调用Invoke来间接调用我们添加给IExample2的函数(也就是GetString和SetString)。脚本引擎传递它要调用的函数的DISPID(也就是1对应GetString或2对应SetString)。引擎也传一个VARIANT结构数组,用传递给 GetString和SetString的参数来填充。它还传递一个我们用于填充来自函数的返回值的VARIANT(不是来自Invoke的HESULT 值,而是我们的类型库显示来自GetString和SetString的返回值)。
所以,我们的Invoke函数首先做的是查找DISPID。如果它是1,我们知道我们必须调用GetString。如果它是2,我们知道我们必须调用SetString。那么你马上会相当case语句。如果你给IExampe2添加了大量的函数它会是一个大的case语句。
接下来,我们需要从VARIANT中取出这些参数,把它传给相应要调用的函数。例如,如果脚本引擎调用SetString,我们要从第一个VARIANT中取出BSTR值并把它传给SetString。
有一个问题。脚本引擎可以传递一个封装了不是我们期望的严格的数据类型的VARIANT值,虽然可以强转成正确的类型。我们的SetString期望一个BSTR(指向一个字符串的指针)。但让我们假设脚本引擎要设置我们的字符串成员为字符串“10”。现在,引擎可以这样设置传递给我们的VARIANT:
VARIANT myArg; myArg.vt = VT_BSTR; myArg.bstrVal = SysAllocString(L"10");
好,我们可以很好的运行。它传给我们一个封装在VARIANT中的BSTR。
但引擎也可能改为传递一个long,像这样:
VARIANT myArg; myArg.vt = VT_I4; myArg.lVal = 10;
现在,我们的Invoke函数必须知道它不是一个VT_BSTR,在我们调用SetString前需要自己来转换它(忽略了错误检查):
// 检查调用者传递给我们的函数调用是基于以上的DISPID。 switch (dispid) { // GetString() case 1: // 在这我们用相应的参数调用GetString。 break; // SetString() case 2: // 它传递的是SetString期望的BSTR嘛? if (params->rgdispidNamedArgs[0]->vt != VT_BSTR) { // 不,我们开始尝试把它传入的值转成BSTR。 // 传入的是一个long嘛? if (params->rgdispidNamedArgs[0]->vt == VT_I4) { // 构造一个这个long的BSTR wchar_t temp[33]; wsprintW(temp, L"%d", params->rgdispidNamedArgs[0]->lVal); params->rgdispidNamedArgs[0]->bstrVal = SysAllocString(temp); // 注意:当引擎对params->rgdispidNameArgs[0] // 调用VariantClear时它会释// 放这个BSTR。 } else { // 在这我们需要检查、转换尽可能多的数据类型! // 如果已罗列出所有的可能逻辑,返回DISP_E_TYPEMISMATCH。 } } // 用这个BSTR参数调用SetString。 return(this->lpVtbl->SetString(this, params->rgdispidNamedArgs[0]->bStrVal)); }
“啊!这可能会给我带来很多的工作,因为还有很多函数!”,你可能这样认为。是这样的。但至少微软已经提供了一坨把VIARINANT转换为其他类型的OLE API。所以我们可以简单的这么做:
// SetString() case 2: if ((hr = VariantChangeType(params ->rgdispidNamedArgs[0],params->rgdispidNamedArgs[0], 0, VT_BSTR))) return(hr); return(this->lpVtbl->SetString(this,params->rgdispidNamedArgs[0]->bStrVal));
但,有一点还不错。因为我们定义IExample2 为一个双接口,我们可以调用微软的DispInvoke函数来为我们做所有的工作。它使用我们的类型库来对DISPID和要调用的适当的函数进行匹配,甚至把返回值放入引擎传入的指定VARIANT中。像在我们的GetIDsOfNames中我们需要做的仅仅是确保我们获得了一个ITypeInfo,因为我们需要把它传给DispInvoke。因此,这是我们的Invoke:
ULONG STDMETHODCALLTYPE Invoke(IExample2 *this, DISPID dispid, REFIID riid,LCID lcid, WORD wFlags, DISPPARAMS *params, VARIANT *result, EXCEPINFO *pexcepinfo, UINT *puArgErr) { // 我们仅仅实现一个“缺省”接口 if (!IsEqualIID(riid, &IID_NULL)) return(DISP_E_UNKNOWNINTERFACE); // 我们需要我们的ITypeInfo(传给DispInvoke) if (!MyTypeInfo) { HRESULT hr; if ((hr = loadMyTypeInfo())) return(hr); } // 让OLE32.DLL的DispInvoke()做在我们对象中调用相应函数的真正的工作, // 并把传入的函数转化为正确的格式 return(DispInvoke(this, MyTypeInfo, dispid, wFlags, params, result,pexcepinfo, puArgErr)); }
同样处理所有的IDispatch函数。
总之,为了支持脚本语言,我们对象的虚表必须包括IDispatch的四个标准函数。他们必须出现在IUnknown的三个函数(QueryInterface、AddRef和Release)后。为了更容易实现他们,我们可以把它的虚表定义为“双接口”(在我们的类型库中), 然后调用微软的OLE函数来做大部分实际的工作。
我们下一步要创建我们的类型库。首先,我们需要创建包含我们对象和它的虚表、他们的GUID定义的“源”文件,就像我们为了我们的C编译器创建的IExample.h那样。但这个源文件的格式会与IExample.h有所不同,我们用一个叫MIDL.EXE(它和你的编译器在一起,也可以在微软的Windows平台SDK中找到)特殊工具来编译它。
通常,这个源文件的扩展名为.IDL。如果这样的话,当我们把它加入我们的Visual C++工程中时,Visual C++的IDE会对它自动调用MIDL.EXE把它转换成一个扩展名为.TLB的二进制文件。这个TLB文件就是我们的类型库。在IExample2目录下,你会发现我们的类型库源文件IExample2_extra.idl。它的语法有点和C++的语法类似。用//开始行是注释。C风格的开始和结束括号{和}被用于包含一个具体的“结构”或实例。
一个IDL以下面几行的形式开始:
[uuid(E1124082-5FCD-4a66-82A6-755E4D45A9FC),version(1.0), helpstring("IExample2 COMserver")] library IExample2 {
第一行放在括号中。它包括三个详细的信息块,彼此之间用逗号分开。第一个信息块是我们类型库的GUID。注意它与IExample2.h中的CLSID_TypeLib(也就是我生成的用于我们类型库的GUID)是同一个GUID。
接下来是我们类型库的版本号(我把它设定为1.0)。
最后是些帮助描述(关于我们的类型库的),我们把它像一些对象浏览器那样显示给程序员(像Visual BASIC的对象浏览器可以做的)。在这,我标记我们的COM组件是什么和可以做什么。例如,如果你创建了一个读取条形码的COM组件,或许你的帮助字符串可以是“顶级品牌条形码阅读器”。
下一行必须以library关键字开始。之后,你可以给出类型库的名字。我指定为IExample2,但你可以选择其他名字给他。注意它与实际的TLB文件名没有关系。
最后,是一个左括号。在我们的IDL源中左括号与后面的右括号之间是我们的类型库部分。我们把我们的IExample2对象和它的虚表放在这。
下一行:
importlib(“STDOLE2.TLB”);与在.H文件中的#include表达式类似。它包括微软的定义了基本的COM对象例如IUnknown和IDispatch的STDOLE2.TLB文件(和你的编译器在一起,或者在SDK中)的内容。我们需要引用它,因为我们的IExample2的虚表在开始部分出现了IDispatch的函数(因此也会用到IUnkown函数)。
接着是我们IExample2的VTable定义。它看起来这样:
[uuid(B6127C55-AC5F-4ba0-AFF6-7220C95EEF4D), dual, oleautomation, hidden, nonextensible] interface IExample2Vtbl : IDispatch {
第一行在括号中,它包含我们的IExample的虚表(你会注意到它与IExample2.h中的IID_IExample2用同样的GUID标识;它必须这样)的GUID。
这行还包含几个我们虚表描述的关键字。最重要的关键字是“dual”。它表明IExample2是“双接口”类型。这意味着它除了允许C/C++程序通过IExample2的虚表(像你在第一篇中的C应用程序例子那样)直接调用我们的SetString和GetString函数外,同样虚表中也有四个IDispatch函数用于例如VBScript脚本引擎来通过Invoke(直接)调用SetString和GetString。这就是为什么它叫“dual”的原因。同一虚表可工作于直接调用我们的SetString和GetString函数的C/C++应用程序,也可以工作于需要通过我们的Invoke函数来调用他们的脚本引擎。
oleautomation关键字意味着我们只使用自动化数据类型的数据于SetString和GetString函数传递参数,以及返回值。
注意:dual关键字自动暗示了oleautomation。所以在技术上来说我们不需要指定“oleautomation”。但我做了。如果你用oleautomation关键字定义了一个虚表,然后你在你的函数里试图使用非自动化数据类型作为参数(或返回值),MIDL.EXE会报错。注意旧版本的MIDL.EXE可能给出一个新的自动化数据类型的错误,所以要保证你使用的MIDL.EXE是最新的SDK中的。
Hidden关键字只不过意味着我们不要我们的虚表本身在对象浏览器中被列举出。我们只显示我们目前要定义的IExample2对象。
nonextensible关键字意味着当我们真正分配并把IExample2对象给别人时,我们没有做些异常处理,像增加额外函数(在我们的类型库中没有定义)给IExample2的虚表。
下一行必须以interface开始来表明我们定义了一个VTable。接下来是我们在IDL源中给出的VTable的名字。我们可以叫它IExample2Vtbl,或其他。我用了“IExample2Vtbl”。我们也指定了IDispatch类型,因为我们的IExample2事实上以一个IDispatch开始(当然,在前面有IUnkown,因为IDispatch也暗含了IUnkown)。
注意:如果我们的VTable没有四个IDispatch函数,那么我们可以把它定义为IUnkown类型。
最后,我们用一个C的大括号开始。在这之后罗列了我们VTable的所有函数。它看起来是这样的:
[helpstring("Sets the test string.")] [id(1)] HRESULT SetString([in] BSTR str); [helpstring("Gets the teststring.")] [id(2)] HRESULT GetString([out, retval]BSTR *strptr);
首先,你会注意到我们没有定义IUnknown(QueryInterface、AddRef和Release)和IDispatch(GetTypeInfoCount、GetTypeInfo、GetIDsOfName和Invoke)的函数。我们不必做,因为我们把我们的VTable类型指定为IDispatch,所以MIDL.EXE会自动添加他们(因为我们导入了STDOLE.TLB,所以MIDL.EXE知道这些函数)。
所以我们只要列出那些我们添加到IExample2的VTable后面的函数(也就是SetString和GetString)。他们排列的顺序必须与出现在IExample2.h中的VTable定义顺序一致。
在每个函数前我们指定了一个帮助字符串。对象浏览器会把这些文本显示给程序员。
对于每个函数,我们首先要指定我们选择好的DISPID给它。记住我们任意选定1给SetString和2给GetString。
对每个函数定义除了一点外,其余定义看起来与IExample.h中一样。在每个参数前,我们必须指明是否这个参数是在传递给我们之前脚本引擎给它初始化一个特定的值(也就是一个[in]参数),或者是一个脚本引擎期望我们用一些值来填充它未初始化的参数(也就是一个[out]参数)。还有可能它是一个脚本引擎把它初始化为某个值的参数,并且还期望我们用一个新值来修改它(也就是一个[in,out]参数)。或者,或许它可以是传递给我们的这样的一个参数,脚本引擎期望我们的Invoke函数把返回值存储在引擎出入的指定VARIANT中(也就是一个[out,retval]参数)。
SetString期望传入的是引擎已经设置为特殊值的BSTR。所以我们指定它为[in]。
由于GetString期望传入的是一个由我们来用我们分配的字符串拷贝来填充的BSTR指针,我们把它指定为一个[out]参数。不幸的是,我们在这不能这么做,原因是微软不允许直接用[out].我们必须用[in,out]或者用[out,retval]。两者不同之处是是否我们要把我们分配的BSTR作为脚本引擎调用GetString(也就是[out,retval])的返回值返回给它,或者是否脚本会传给GetString一个变量名来让他填充(也就是[in,out])。换句话说,它取决于我们要脚本调用我们的GetString函数的方式。我们有两种选择,这是使用 VBScript的例子:
newString = GetString() ' [out, retval] GetString(newString) ' [in, out]
这个模型对于我看起来相当简单,所以我们继续并定义传递给GetString的参数为[out,retval]。
注意:你的函数的传递参数只有一个可以标记为[out,retval]。其他参数必须是[in]或[int,out]。
我们用}结束我们的VTable定义。
现在,我们开始定义我们的IExample2对象。它看起来是这样:
[uuid(520F4CFD-61C6-4eed-8004-C26D514D3D19), helpstring("IExample2object."), appobject] coclass IExample2 { [default] interface IExample2Vtbl; }
第一行指定我们的IExample2对象的GUID。还要注意它与我们在IExample2.h中的CLSID_IExample2是同一个GUID,并且它必须是这样。
我们指定一个帮助字符串,让对象浏览器知道这个具体的对象是什么、能做什么。
这个appobject关键字表明我们的IExample2对象可以通过CoGetClassObject获得。
下一行必须以coclass开始,后面是我们在IDL源中定义的我们的对象名。
在括号里,我们放一行来指出我们的对象是以我们在IDL文件中确定的“IExample2Vtbl” VTable开始。这行包括interface关键字,后面是我们VTable的名字。在它前面我们用default关键字(放在括号中)来指明它是一个脚本引擎会假定它可以从IExample2对象中获得的唯一VTable。(我们还没有谈到它,但对于一个对象来说在它内部有超过一个VTable是可能的。这被认为是“多接口”。但如何获得对脚本引擎起作用的这样一个对象比较麻烦,所以我们避免把它们放在一起。假设我们在IExample2中只有一个VTable,我们也需要标记它为缺省的)
我们以两个}结束,我们的IDL文件也结束了。你可以用MIDL.EXE编译它(或者添加IExample2_extra.idl到你的Visual C++工程中,让IDE来对它运行MIDL.EXE)。如果没问题,你会得到一个命名为IExample2.tlb的二进制文件。它就是我们的类型库。
把.TLB文件嵌入到我们的DLL中是可能的,并从其加载。但我喜欢把.TLB与DLL分离处理。为什么呢?因为C/C++程序不需要.TLB文件,Visual BASIC应用程序也不需要编译。只有解释性的Visual BASIC、VBScript和其他解释性语言需要.TLB文件。(你需要分发类型库给这些程序员)
总之,IDL文件包含我们的类型库的“源代码”。我们把我们对象的定义和他们的VTable放在这个文件中,就像我们在C中的 include文件一样,只不过是格式有些不同而已。它通过MIDL.EXE编译后产生我们的真实的二进制.TLB类型库文件。.TLB文件即可以嵌入到我们的DLL的资源中,也可以和我们的DLL在一起来让脚本引擎来使用我们的组件。
我们已更新完了我们的组件。当然,在其他人能使用前,我们需要安装、注册它。在第一篇文章中,你会记得,我们写了一个实用工具来注册我们的组件。我们需要向那块代码简单增加些代码,因为我们的类型库(.TLB文件)也需要注册。所以我们在同一个实用安装工具中来做它。微软提供了一个可以做注册类型库全部工作的API:RegisterTypeLib。
但在我们着眼于它前,有另一个麻烦事要讨论一下。Visual BASIC程序员不喜欢处理像“大数”这样复杂的事。对于他们来说一个GUID看起来太可怕了。所以微软决定COM开发者可以把他们的组件关联到一个“产品ID”(ProdID)上。一个Visual BASIC程序员可以使用我们ProdID来创建我们的对象,而不是用我们的IExample2的GUID。那么一个ProdID又是什么呢?它就是一个你选择来标识你的组件的特殊字符串。通常你可以用你的主对象的名字(在这,我们的是IExample2),在它后面加上接口的名字,可以再跟一个版本号,他们之间用一个.隔开。我就随便选择IExample2.object作为我的ProdID。为了把这个ProdID与我们的IExample2关联起来,我们需要设置一对额外的注册表键值。我们还是在我们的安装工具中做比较好。我们要做的是创建一个名叫“IExample2.object”的注册表键值(也就是我们选定的ProdID)。然后我们在它下面创建一个叫“CLSID”的子键,把它的缺省值设置为我们IExample2的GUID。唯一要处理的部分是这个GUID必须要格式化为一个人性化易读的字符串。
在RegIExample2目录下,有一个更新过的可以创建额外ProdID注册表键值的安装工具,它调用RegisterTypeLib来注册我们的类型库。增加部分不重要,所以你可以细读注释代码来了解细节。
运行完安装工具后,脚本引擎现在就可以使用我们的组件了。
当然,我们的反安装工具需要更新以便删除额外的注册表键值。微软也提供了UnRegisterTypeLib来撤销RegisterTypeLib做的事。细读在UnregIExample2目录下的更新后的代码。
总之,类型库(也就是TLB文件)必须得注册,就像我们的IExample对象它自身一样。用RegisterTypeLib来做相当容易。此 外,我们要选择一个ProdID,它就是一个VB程序员使用它来代替我们对象的GUID而不至于引起恐慌的字符串,通过注册表键值 来把ProdID和我们的GUID关联起来。
让我们来感受一下使用IExample2的一个普通VBScript例子。我们简单调用SetString来设置IExample2的字符串成员为“HelloWorld”。然后我们调用GetString来重新获得这个字符串(放入另一个VBScript变量中)并显示它(所以我们可以确定它被设置和重新获得是否正确)。
set myObj =CreateObject("IExample2.object") myObj.SetString("Hello world") copy = myObj.GetString() MsgBox copy, 0, "GetStringreturn"
首先,VBScript获得一个我们的IExample2对象。脚本调用VB引擎的CreateObject。注意应该传入我们IExample2的ProdID。CreateObject首先会调用CLSIDFromProgID来通过ProdID查询IExample2的GUID。然后CreateObject调用CoCreateInstance来获得我们DLL分配的一个IExample2的指针。脚本把它存储在myObj变量中。
接下来,脚本调用我们IExample2的SetString,把“HelloWorld”字符串传入。脚本引擎通过调用IExample2的GetIDsOfName来获得SetString的DISPID,然后调用IExample2的Invoke,传递一个封装了“Hello World”BSTR的VARIANT来完成这个操作
接下来,脚本调用我们IExample2的GetString。脚本引擎再次调用GetIDsOfName和Invoke。脚本存储我们(也就是事Invoke)返回的字符串到变量拷贝中。
最后,脚本显示GetString的返回值。
但这段脚本还不能运行。我们得先改变一下我们的组件。
考虑下面的C代码:
typedef struct { char * string; } IExample;
IExample例子;
example.string = "Hello World";
如果VBScript可以通过类似的操作来设置IExample2的字符串成员不是更好吗?例如,不是调用SetString,VBScript可以这么做:
myObj.string = "Hello World"
但是尽管它看起来像是脚本直接存取我们的IExample2的字符串成员,其实在内部被翻译成调用我们的SetString,传入“Hello World”。
因此脚本也可以这样做来获得IExample2的字符串成员的值(并把它赋值给copy变量):
copy = myObj.string
同样,它在内部会被翻译成对我们的GetString的调用,并把返回值赋值给copy。
我们能这样做吗?是的,我们可以这么做。但我们需要改变SetString和GetString的定义,不是我们C代码中的,也不是我们的.H文件,而是我们的.IDL文件中的定义。因为SetString是设置成员的值,我们需要把它标记为“property set”函数。而由于GetString是获得成员的值,我们需要把它标记为“property get”函数。在IDL文件中,我们也得重命名GetString和SetString为两个我们要VBScript使用的成员名。在这,我们选择“string”。所以这是我们在我们IDL文件中修改的部分:
[id(1), propput] HRESULT string([in] BSTR); [id(1), propget] HRESULT string([out,retval] BSTR *);
我们得添加“propput”到SetString中(并把函数名改为“string”)。我们还添加“propget”到GetString,并也把他的函数名改为“string”。
“等等,你怎么让两个函数使用同一个名字呢?”,你可能会问。我们没有。真正的函数名还是SetString和GetString。毕竟,在IExampl2.h中出现的VTable定义在IDL VTable定义中的同样的位置出现。只有我们的类型库会考虑两个“string”的名字,并且它知道他们是两个不同的函数,因为一个是设置值(propput)另一个是获取值(propget)。
但要把一个函数声明为propget,它必须要接受两个参数。第一个当然是指向我们的对象的指针。第二个参数必须申明为[in],它是要设置的成员的值。例如,因为IExample2的string成员需要通过一个BSTR(指针)来设置,所以我们把这个参数定义为BSTR类型。如果string成员已经被定义成long类型,我们就得把参数定义为“long”。只要我们使用自动化数据类型,一切都OK了。
同时为了把一个函数声明为propput,它必须严格接受两个参数。第一个当然是指向我们对象的指针。第二个参数必须要申明为[out,retval],它应该是一个存放我们返回成员值的指针,我们把这个参数定义为BSTR*(事实上它是一个句柄,因为BSTR本身就是一个指针)。如果string成员被定义为long型,我们也得把这个参数定义为“long*”。我修改了IExample2_extra.idl中上面两行,并把它重命名为IExample2.idl。对它运行MIDL.EXE生成新的.TLB文件。
现在,我们可以这样重写我们VBScript例子:
set myObj =CreateObject("IExample2.object") myObj.string = "Hello World" MsgBox myObj.string, 0, "GetStringreturn"
总之,如果你想让脚本可以设置你的对象的数据成员的值,可以通过添加函数并把它在IDL文件中标记为proput来设置这个值。 在IDL文件中,这个函数名与成员名一致。传给函数的第二个参数应该标记为[in],它是设置的期望值。如果你想让脚本获得你的 对象的数据成员的值,可以通过添加函数并把它在IDL文件中标记为propget来获得这个值。在IDL文件中,函数名与成员名一 致。函数的第二个参数应该标记为[out,retval],它是指向我们返回值的一个指针。
注意:你不需要对一个给出的成员同时支持propget和propput。你可以只支持一个或另一个。例如,如果我们要把string成员设置为只读,那么我们要去掉我们的SetString函数,并且把proput行从我们的IDL文件中删除(同时删除IExample2.h中VTable定义中的SetString)。
因为我们改变了传给SetString和GetString的数据类型,我们必须更新我们的C/C++客户端程序,使它传入正确的数据类型。这没什么难点,在我们需要获得BSTR地方使用SysAllockString、在需要释放它的地方使用SysFreeString来简单实现。更新后的C例子在IExampleApp2目录中。
我们可以把我们的COM组件打包到属于它自己的EXE中,而不是把它打包到DLL中,。这样做的优点是EXE运行在它自己的进程空间中,因此应用程序的崩溃不会使COM组件崩溃(或者反之亦然)。缺点是操作系统必须通过做“数据列集”来允许数据在两个进程边界间传递,所以这可能引起运行缓慢。
好消息是我们DLL只要非常小的改动。在IExampleExe目录中,你会发现。我把IExample2.c更名为IExampleExe.c、IExample2.h更名为IExampleExe.h和IExample2.idl为IExampleExe.idl。我也搜索并替换了所有的IExample2为IExampleExe。所以现在我们的对象转换为IExampleExe了。当然,我用新生成的GUID替换了IExampleExe.h和IExampleExe.idl中的GUID,这样EXE组件就不会与我们的DLL组件冲突了。因为我们创建的是一个EXE,我们就不再需要DEF文件了,所以从工程中删除它。
在IExampleExe.c中,我们必须把DllMain函数替换为WinMain。我们WinMain必须做IClassFactory和全局变量的初始化工作,就像DllMain要做的一样。但我们的WinMain也必须调有OLE API的CoInitialize来初始化COM,然后调用CoRegisterClassObject来把我们的EXE添加到COM的运行任务列表中。它是什么?它只不过是一个内部维护所有当前运行的COM组件EXE的列表。如果我们的EXE不在这个列表中,那么没有应用程序可以使用我们的组件。同时我们的EXE不在这个列表中直到它调用CoRegisterClassObject函数前。由于我们的WinMain调用CoRegisterClassObject,显而易见的是在我们的EXE开始前没有应用程序可以使用我们的组件。
当我们的EXE添加到运行任务表中,它只做一个消息循环直到它收到一个WM_QUIT消息。
当我们的EXE在内存中在做消息循环时,应用程序可以通过调用CoCreateInstance(或CoGetClassObct、CreateInstance)来获得我们的一个IExampleExe对象,并可以调用它的函数。为了调用这些函数,应用程序只需要做IExample2App例子中做的即可。操作系统处理所有细节来完成工作。
当我们的EXE准备退出时,它调用CoRevokeClassObject来从COM运行任务列表中删除自己,然后调用CoUninitialize来释放COM资源。
就这么点不同(尽管我在IExampleExe和IClassFactory对象的Release函数中增加了一点代码,检查当所有的应用程序都释放了我们的对象时是否我们要卸载DLL)。
当在EXE中注册COM组件时,相对于DLL,不创建名为“inprocServer32”的键,我们创建一个叫“LocalServer32”的键。在RegIExampleExe目录下是注册IExampleExe的例子。(没有提供卸载工具,这个简单的练习留给你来完成)。
为了验证新的EXE组件,你可以简单修改IExample2App.c中#includeIExampleExe.h来包含IExampleExe的GUID的实现:
#include "../IExampleExe/IExampleExe.h"
记住你必须安装、注册我们COM组件一次,然后在你运行修改后的IExample2App.exe测试程序前运行我们组件(IExample.exe)来把它加入COM任务列表中。
接下来是什么?
在下一个章节中,我们要从一个脚本引擎的观点来看IDispatch函数。脚本引擎是如何把代码翻译成它自己的语言,如何调用我们对象的IDisaptch函数?