让COM组件可被跨语言调用
错误修正
首先修正一下上篇(《裸写一个进程内 COM 组件》)中的例子的一个小问题。类厂的CreateInstance里面,上次是这么写的:
STDMETHODIMP ClassFactory::CreateInstance(_In_opt_ IUnknown *pUnkOuter, _In_ REFIID riid, _COM_Outptr_ void **ppvObject) { if (riid == __uuidof(ISampleInterface) && m_clsid == __uuidof(SampleClass)) { ISampleInterface *p = new SampleClass; p->QueryInterface(riid, ppvObject);
return S_OK; }
return CLASS_E_CLASSNOTAVAILABLE; }
|
其中一开始就检查了IID,如果不是ISampleInterface,就返回错误,错误信息是“类无效”(应该是“接口不存在”),这不科学。后面p->QueryInterface的时候,还会对IID做一次检查,因此前面的IID检查可以去掉。实际上,有些使用者在获取类厂后,会来个CreateInstance(..., IID_IUnknown, ...),这是个合理的行为,应该予以支持,而像上面这样写就不支持了。纠正为:
STDMETHODIMP ClassFactory::CreateInstance(_In_opt_ IUnknown *pUnkOuter, _In_ REFIID riid, _COM_Outptr_ void **ppvObject) { if (m_clsid == __uuidof(SampleClass)) { ISampleInterface *p = new SampleClass; p->QueryInterface(riid, ppvObject);
return S_OK; }
return CLASS_E_CLASSNOTAVAILABLE; } |
同理,DllGetClassObject中,原先是:
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID *ppv) { if (riid == __uuidof(IClassFactory) && rclsid == __uuidof(SampleClass)) { IClassFactory *p = new ClassFactory(rclsid); p->QueryInterface(riid, ppv);
return S_OK; }
return CLASS_E_CLASSNOTAVAILABLE; } |
做了IID和CLSID的双重检查。而IID刚才说过了,具体类的QueryInterface会检查;CLSID,类厂的CreateInstance会检查,因此这里大可不必检查。改为:
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _Outptr_ LPVOID *ppv) { IClassFactory *p = new ClassFactory(rclsid); return p->QueryInterface(riid, ppv); } |
引言
好了,回到主题。看过COM介绍的,一般都会听说,哦,可以跨语言调用,真牛逼!好吧,现在就来调调看。写段VBScript:
Set obj = WScript.CreateObject("Streamlet.COMProvider.SampleClass.1") obj.SampleMethod |
使用CScript调用这个脚本,报错:
Test.vbs(1, 1) Microsoft VBScript 运行时错误: 类不能支持 Automation 操作
不支持……需要组件支持“自动化”操作。这是脚本。
还有非脚本的,比如古老的VB6,来试试看。加入外部引用:
这里显示的都是类型库,而我们根本没有注册类型库……
类型库
类型库信息一般位于PE文件的资源段,如下图的TypeLib资源:
类型库也可以单独存在于一个扩展名为.tlb的文件里。
本来一直想避开IDL写COM,可是产生TLB的工作做不了,于是只好借助IDL编译器来产生TLB了。于是把工程改造一下。
原先有个Interface.h,内容为:
#include <Unknwn.h>
struct __declspec(uuid("{83C783E3-F989-4E0D-BFC5-631273EDFFDA}")) ISampleInterface : public IUnknown { STDMETHOD(SampleMethod)() PURE; };
class __declspec(uuid("{0DECBFF5-A8A5-49E8-9962-3D18AAC6088E}")) SampleClass; |
本来用于定义接口,提供给使用者用。现在不需要了。添加一个COMProvider.idl,内容为:
import "oaidl.idl"; import "ocidl.idl";
[ object, uuid(83C783E3-F989-4E0D-BFC5-631273EDFFDA), ] interface ISampleInterface : IUnknown { [id(1)] HRESULT SampleMethod(); };
[ uuid(22935FC2-282E-4727-B40F-E55128EA1072), version(1.0), ] library COMProviderLib { importlib("stdole2.tlb"); [ uuid(0DECBFF5-A8A5-49E8-9962-3D18AAC6088E) ] coclass SampleClass { [default] interface ISampleInterface; }; };
import "shobjidl.idl"; |
后面library这一段便定义了类型库,类型库ID为{22935FC2-282E-4727-B40F-E55128EA1072}。单独编译这个文件,会在源代码目录产生四个文件:
同时还会在$(IntDir)(通常为$(ProjectDir)\$(Configuration),即源代码目录下的Debug或Release目录)产生一个COMProvider.tlb。
COMProvider_h.h中包含了原先Interface.h中得所有信息。现在可以把原先#inlcude “Interface.h”的地方都改成“COMProvider_h.h”了。
然后添加一个空的资源脚本文件(*.rc),查看代码:
找到 3 TEXTINCLUDE(第一个红框),改成:
3 TEXTINCLUDE BEGIN "1 TYPELIB ""COMProvider.tlb""\r\n" "\0" END |
再找到第二个红框,改成:
#ifndef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 3 resource. // 1 TYPELIB "COMProvider.tlb"
///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED |
再改下项目属性中的资源的Include目录:
加入$(IntDir):
因为编译IDL后,TLB文件是产生在$(IntDir)的。
现在重新编译,用资源工具(比如resource hacker)查看,发现资源里有了TypeLib信息了。
然后改下DllRegisterServer中得代码,加几行注册TypeLib的:
STDAPI DllRegisterServer(void) { TCHAR szModulePath[MAX_PATH] = {}; GetModuleFileName(g_hModule, szModulePath, ARRAYSIZE(szModulePath));
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("CLSID\\{0DECBFF5-A8A5-49E8-9962-3D18AAC6088E}\\InprocServer32"), _T(""), szModulePath);
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("CLSID\\{0DECBFF5-A8A5-49E8-9962-3D18AAC6088E}\\ProgID"), _T(""), _T("Streamlet.COMProvider.SampleClass.1"));
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("Streamlet.COMProvider.SampleClass.1"), _T(""), _T("SampleClass Class"));
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("Streamlet.COMProvider.SampleClass.1\\CLSID"), _T(""), _T("{0DECBFF5-A8A5-49E8-9962-3D18AAC6088E}"));
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("CLSID\\{0DECBFF5-A8A5-49E8-9962-3D18AAC6088E}\\TypeLib"), _T(""), _T("{22935FC2-282E-4727-B40F-E55128EA1072}"));
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("TypeLib\\{22935FC2-282E-4727-B40F-E55128EA1072}\\1.0"), _T(""), _T("COMProvider Type Library"));
xl::Registry::SetString(HKEY_CLASSES_ROOT, _T("TypeLib\\{22935FC2-282E-4727-B40F-E55128EA1072}\\1.0\\0\\Win32"), _T(""), szModulePath);
return S_OK; } |
好了,改造完成,重新编译吧。
在VB6中调用COM组件
新建一个VB6项目,选择菜单“工程”|“引用...”:
看到一个可用的引用列表:
我们的DLL已经赫然在目了!勾上它!
然后在默认窗口上放个按钮加几行代码:
Private Sub Command1_Click()
Dim obj As COMProviderLib.SampleClass obj = CreateObject("Streamlet.COMProvider.SampleClass.1") obj.SampleMethod()
End Sub |
运行结果如下:
运行成功。
可见,VB6调用COM对象,只需要COM对象带上类型库就可以了,可以不需要自动化。但是WSH环境下的VBScript仍然需要自动化支持。
自动化
“自动化”这个名字起得很好听,但实际上,只是“要求接口支持IDispatch”这么个含义。下面我们来支持自动化。
IDL
首先修改IDL,添加属性“dual”(实际上不加dual也可以通过下文所有测试,这是为啥?有木有达人来解释下?),以及把基类修改为IDispatch:
import "oaidl.idl"; import "ocidl.idl";
[ object, uuid(83C783E3-F989-4E0D-BFC5-631273EDFFDA), dual ] interface ISampleInterface : IDispatch { [id(1)] HRESULT SampleMethod(); };
[ uuid(22935FC2-282E-4727-B40F-E55128EA1072), version(1.0), ] library COMProviderLib { importlib("stdole2.tlb"); [ uuid(0DECBFF5-A8A5-49E8-9962-3D18AAC6088E) ] coclass SampleClass { [default] interface ISampleInterface; }; };
import "shobjidl.idl"; |
改完后最好先单独编译一下IDL,以便IDE能认识新的符号。然后修改SampleClass.h,把基类修改为IDispatch,暴露IDispatch,然后添加IDispatch的四个方法:
class SampleClass : public xl::ComClass<SampleClass>, public xl::IDispatchImpl<ISampleInterface> { public: SampleClass(); ~SampleClass();
public: STDMETHOD(SampleMethod)();
public: // IDispatch Methods STDMETHOD(GetTypeInfoCount)(UINT *pctinfo); STDMETHOD(GetTypeInfo)(UINT iTInfo, LCID lcid, ITypeInfo **ppTInfo); STDMETHOD(GetIDsOfNames)(REFIID riid, LPOLESTR *rgszNames, UINT cNames, LCID lcid, DISPID *rgDispId); STDMETHOD(Invoke)(DISPID dispIdMember, REFIID riid, LCID lcid, WORD wFlags, DISPPARAMS *pDispParams, VARIANT *pVarResult, EXCEPINFO *pExcepInfo, UINT *puArgErr);
public: XL_COM_INTERFACE_BEGIN(SampleClass) XL_COM_INTERFACE(ISampleInterface) XL_COM_INTERFACE(IDispatch) XL_COM_INTERFACE_END() }; |
接下来我们逐一实现这四个方法。等等……先把IDispatch的四个方法声明去掉,使用IDispatchImpl中默认的,看看会是什么结果:
Test.vbs(1, 1) Microsoft VBScript 运行时错误: 对象不支持此操作: 'obj.SampleMethod'
嗯,错误提示变了。好吧,继续老老实实把这四个方法都写了。
GetTypeInfoCount
首先是GetTypeInfoCount,如果有类型信息(可使用GetTypeInfo获取),就从输出参数会返回1,否则返回0。实现如下:
STDMETHODIMP SampleClass::GetTypeInfoCount(UINT *pctinfo) { if (pctinfo == nullptr) { return E_INVALIDARG; }
*pctinfo = 1; return S_OK; } |
GetTypeInfo
第二个函数,GetTypeInfo,顾名思义,取类型信息。我们知道,类型信息源在IDL文件,IDL编译产生了二进制的TLB,TLB被我们放在了DLL资源里。遗憾的是,IDL编译器并不产生生成TypeInfo的源代码,我们也无心手工构造一个TypeInfo对象,所以只好从资源里读取TLB,然后解析TLB得到ITypeInfo。这个过程分两步,第一步是使用LoadTypeLib得到一个TypeLib对象,第二步是使用ITypeLib的GetTypeInfoOfGuid方法取得某个接口的TypeInfo。因为GetTypeInfo可能会被多次调用,所以不适合每次都跑整个过程。第一步是针对整个模块的,可以在模块加载的时候做;第二步是针对某个接口的,可以在COM类构造的时候做。
DllMain附近改动如下:
#include <Windows.h> #include <OAIdl.h>
HMODULE g_hModule = nullptr; ITypeLib *g_pTypeLib = nullptr;
void GetTypeInfo() { TCHAR szModulePath[MAX_PATH] = {}; GetModuleFileName(g_hModule, szModulePath, ARRAYSIZE(szModulePath));
LoadTypeLib(szModulePath, &g_pTypeLib); }
void ReleaseTypeInfo() { if (g_pTypeLib != nullptr) { g_pTypeLib->Release(); g_pTypeLib = nullptr; } }
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: g_hModule = hModule; GetTypeInfo(); break; case DLL_THREAD_ATTACH: break; case DLL_THREAD_DETACH: break; case DLL_PROCESS_DETACH: ReleaseTypeInfo(); break; default: break; }
return TRUE; } |
然后SampleClass中加个成员变量ITypeInfo *m_pTypeInfo,构造函数和析构函数改为:
SampleClass::SampleClass() : m_pTypeInfo(nullptr) { InterlockedIncrement(&g_nModuleCount);
if (g_pTypeLib != nullptr) { g_pTypeLib->GetTypeInfoOfGuid(__uuidof(ISampleInterface), &m_pTypeInfo); } }
SampleClass::~SampleClass() { if (m_pTypeInfo != nullptr) { m_pTypeInfo->Release(); m_pTypeInfo = nullptr; }
InterlockedDecrement(&g_nModuleCount); } |
最后,GetTypeInfo实现为:
STDMETHODIMP SampleClass::GetTypeInfo(UINT iTInfo, LCID lcid, ITypeInfo **ppTInfo) { if (m_pTypeInfo == nullptr) { return E_FAIL; }
if (iTInfo != 0) { return DISP_E_BADINDEX; }
return m_pTypeInfo->QueryInterface(__uuidof(ITypeInfo), (LPVOID *)ppTInfo); } |
GetIDsOfNames
有个API可以帮我们搞定这件事,DispGetIDsOfNames,需要传入一个ITypeInfo *,而我们刚才已经获取了ITypeInfo *并保存在成员中了,恰好用上:
STDMETHODIMP SampleClass::GetIDsOfNames(REFIID riid, LPOLESTR *rgszNames, UINT cNames, LCID lcid, DISPID *rgDispId) { if (riid != IID_NULL) { return E_INVALIDARG; }
return DispGetIDsOfNames(m_pTypeInfo, rgszNames, cNames, rgDispId); } |
Invoke
也有个API,DispInvoke,传入ITypeInfo *外,还要传入一个IDispatch实现类的指针,这个指针当然是SampleClass自己咯。
STDMETHODIMP SampleClass::Invoke(DISPID dispIdMember, REFIID riid, LCID lcid, WORD wFlags, DISPPARAMS *pDispParams, VARIANT *pVarResult, EXCEPINFO *pExcepInfo, UINT *puArgErr) { if (riid != IID_NULL) { return E_INVALIDARG; }
return DispInvoke(this, m_pTypeInfo, dispIdMember, wFlags, pDispParams, pVarResult, pExcepInfo, puArgErr); } |
至此,自动化工作全部完成。
脚本调用
WSH
先测试一下最初的VBScript调用COM的情况,运行结果如下图:
VB6
还是VB6,不过这次通过自动化接口调用。去掉对COMProvider的引用,然后改写代码:
Private Sub Command1_Click()
Dim obj As Object Set obj = CreateObject("Streamlet.COMProvider.SampleClass.1") obj.SampleMethod
End Sub |
同样能够运行:
要测试确实是通过自动化接口运行的而不是通过虚函数表运行的,可以把暴露IDispatch那一行注释掉,结果就报错了:
网页
再写个网页玩一下:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head> <title>Test</title> <meta http-equiv="content-type" content="text/html; charset=UTF-8" /> </head>
<body>
<script type='text/javascript'> var objCom = new ActiveXObject("Streamlet.COMProvider.SampleClass.1"); objCom.SampleMethod(); </script>
</body>
</html> |
运行结果:
小结
好了,我想不出什么语言了,就写到这里吧。本节的例子见COMProtocol2.rar(http://pan.baidu.com/s/1ntqlUTr)。
总结一下,COM组件被使用的方式有两种,一种是通过虚函数表直接调用,另一种是通过IDispatch接口调用,后者俗称自动化。在C/C++以及其他认识虚函数表的语言(比如VB6)中,可以使用虚函数表的方式调用。多数脚本语言的引擎并不认识虚函数表,它们为了实现简洁,往往只支持IDispatch接口,在这种环境下,COM组件只能以自动化的方式被调用了。既支持虚函数表方式调用又支持自动化方式调用的接口被称为双接口,这也是我们在IDL里写的“dual”属性的含义。无论以何种方式调用,暴露类型库信息都是必要的。实现自动化支持从代码角度并不是很复杂,这得益于DispXXX系列API的帮助,我们只要从自身资源中拿到了类型库信息,就可以轻松使用此系列函数来实现IDispatch接口了。