用纯 C++ 编写 BHO
如何仅使用 C++ 和 Windows API 编写 Internet Explorer 插件(
目录
引言
浏览器帮助对象 (也称为 BHO) 是作为 Internet Explorer 插件的 COM 组件。BHO 可用于以任何方式自定义 Internet Explorer:从用户界面修改到 Web 过滤器到下载管理器。
在本文中,我们将学习如何在不使用 ATL 或 MFC 等外部框架的情况下,用 C++ 编写和安装一个简单的 BHO。这个示例 BHO 可以作为您自己 BHO 的起点。
背景
COM — 或称组件对象模型 — 是一项与语言无关的技术,广泛用于 Windows,以实现软件模块的组件化和重用。
用 C++ 编写的大多数 COM 代码(包括 Web 上的示例)都使用 Active Template Library (ATL) 和/或 Microsoft Foundation Classes (MFC) 框架来帮助完成工作。然而,有效学习使用 ATL 和 MFC 通常会成为创建 COM 对象(尤其是 BHO 之类的简单对象)的又一个障碍。
本文将教您编写自己的 BHO 所需了解的 COM 和 BHO 知识,并且只使用 C++ 和 Windows API 来完成,而不必陷入 ATL 或 MFC 这种更复杂的框架。
入门
理解本文的最佳方法是从上面的链接下载源代码并进行跟随。源代码有详细的注释,应该易于理解。
理解 COM 代码
COM 术语
首先,我们来解释一些 COM 术语。
- 接口 (interface) — 一组对其他对象可见的方法。它相当于 C++ 纯虚类的公共方法。
- CoClass — 派生自一个或多个接口,并用具体功能实现其所有方法。它相当于一个可实例化的 C++ 类,派生自一个或多个纯虚类。
- 对象 (object) — CoClass 的一个实例。
- GUID — Globally Unique IDentifier — GUID 是一个 128 位唯一数字。可以通过 guidgen.exe 工具生成。
- IID — Interface IDentifier — 标识接口的 GUID。
- CLSID — CLaSs IDentifier — 标识 COM 组件的 GUID。您可以在 common.h 文件中找到示例 BHO 的 CLSID,名为
CLSID_IEPlugin_Str
。每个 COM 组件都有一个不同的标识符,如果您决定从示例 BHO 代码构建自己的 BHO,则应生成一个新的。
IUnknown 接口
为了创建 COM 对象,我们必须编写实现接口的 CoClass。所有 COM 对象都必须实现一个称为 IUnknown
的接口。此接口具有三个非常基本的方法,允许其他对象管理 CoClass 的对象内存,以及向对象查询其他接口。这三个方法称为 QueryInterface
、AddRef
和 Release
。由于我们将实现的所有各种 CoClass 都派生自 IUnknown
,因此创建一个实现这三个 IUnknown
方法的 IUnknown
CoClass 是很有意义的。我们将这个 CoClass 称为 CUnknown
,并让我们的所有其他 CoClass 都派生自它,这样我们就不必为每个 CoClass 单独编写 IUnknown
方法的实现。
注意:您可以在 unknown.h 文件中找到 CUnknown
的定义和实现。
COM DLL 导出
每个 COM DLL 都导出四个函数,COM 系统使用这些函数来创建和管理 DLL 中的 COM 对象,以及安装和卸载 DLL。这些函数是:
DllGetClassObject
DllCanUnloadNow
DllRegisterServer
DllUnregisterServer
注意:您可以在 main.cpp 文件中找到这些函数,并在 dll.def 文件中将它们声明为导出。
我们的 DLL 必须有一个实现 IClassFactory
接口的 CoClass。我们将这个 CoClass 称为 CClassFactory
。DllGetClassObject
函数创建 CClassFactory
对象并返回指向它们的接口指针。IClassFactory
接口将在稍后更详细地解释。
DllCanUnloadNow
函数由 COM 调用,以确定我们的 DLL 是否可以从进程中卸载。我们所要做的就是检查我们的 DLL 当前是否正在管理任何对象,如果没有,则返回 S_OK
;如果正在管理,则返回 S_FALSE
。我们可以通过在 CoClass 的构造函数中增加一个 DLL 全局引用计数器 DllRefCount
,并在其析构函数中减少该计数器来实现。如果引用计数器不为零,则意味着我们的 CoClass 的实例仍然存在,此时不应卸载 DLL。
DllRegisterServer
由希望安装我们 DLL 的程序调用。我们必须在系统中注册我们的 COM 组件,并将其注册为 BHO。我们通过创建以下注册表项来实现:
HKEY_CLASSES_ROOT\CLSID\<CLSID_IEPlugin_Str>
— 此项的默认值应设置为 COM 组件的可读描述。在这种情况下,是“CodeProject Example BHO”。HKEY_CLASSES_ROOT\CLSID\<CLSID_IEPlugin_Str>\InProcServer32
— 此项的存在表明此 COM 组件可以作为 DLL 加载到想要使用它的进程中。我们需要为此项设置两个值,如下所示:(default)
— 指定包含 COM 组件的 DLL 路径的REG_SZ
或REG_EXPAND_SZ
值。ThreadingModel
— 指定 COM 组件的线程模型。这是一个更高级的概念,我们不需要担心它。我们只需将其设置为“Apartment”。HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\ Explorer\Browser Helper Objects\<CLSID_IEPlugin_Str>
— 此项的存在将我们的 COM 组件注册为 BHO。我们在该项下创建一个名为NoExplorer
的值,并将其设置为REG_DWORD
类型,值为 1。通常,BHO 也会被 explorer.exe 加载,但此值可以防止 explorer.exe 不必要地加载我们的 BHO。
DllUnregisterServer
用于执行与 DllRegisterServer
完全相反的操作——取消注册我们的 COM 组件,并将其从 BHO 中移除。为此,我们只需删除在 DllRegisterServer
中创建的注册表项。
IClassFactory 接口
COM 使用由我们 DLL 的 DllGetClassObject
函数创建的 IClassFactory
对象来获取 DLL 支持的其他接口实现的实例。IClassFactory
接口定义了 CreateInstance
和 LockServer
方法。我们称实现 IClassFactory
接口的 CoClass 为 CClassFactory
。
注意:您可以在 ClassFactory.h 中找到 CClassFactory
的定义,在 ClassFactory.cpp 中找到实现。
CreateInstance
的作用正如其名——创建我们 DLL 中支持给定 IID 的 CoClass 的实例。由于我们是一个 BHO,我们只需要创建支持 IObjectWithSite
接口的 CoClass 的实例。
LockServer
用于锁定或解锁我们 DLL 在内存中的状态。根据是锁定还是解锁,我们对 LockServer
的实现只需增加或减少 DLL 全局的 DllRefCount
变量。
理解 BHO 代码
Internet Explorer 如何加载 BHO
当 Internet Explorer 要加载 BHO 时,它会调用一个名为 CoCreateInstance
的 COM 函数,并将我们的 BHO 的 CLSID 和一个名为 IObjectWithSite
的接口的 IID 传递给它。IObjectWithSite
接口将在下面更详细地解释。COM 会通过在注册表中查找我们的 CLSID,将我们的 DLL 加载到 Internet Explorer 进程中,然后调用我们导出的 DllGetClassObject
函数来获取我们 CClassFactory
CoClass 的实例。一旦 COM 获得了我们 CClassFactory
对象的指针,COM 就会调用其 CreateInstance
方法,并将 Internet Explorer 提供的 IID 传递给它。我们对 CreateInstance
的实现会创建一个我们实现的 IObjectWithSite
的实例,该实例称为 CObjectWithSite
,并从中获取所请求 IID 的接口指针,然后将其返回给 COM,COM 再将其传递给 Internet Explorer。然后 Internet Explorer 使用 IObjectWithSite
接口指针与我们的 BHO 进行交互。
IObjectWithSite 接口
BHO 需要实现 IObjectWithSite
接口,这是 Internet Explorer 与 BHO 进行通信的方式。此接口有两个方法:SetSite
和 GetSite
。我们 DLL 中实现 IObjectWithSite
接口的 CoClass 称为 CObjectWithSite
。
注意:您可以在 ObjectWithSite.h 中找到 CObjectWithSite
的定义,在 ObjectWithSite.cpp 中找到实现。
SetSite
由 Internet Explorer 调用,以向我们提供一个指向站点对象的指针。站点对象仅仅是 Internet Explorer 创建的一个 COM 对象,我们的 BHO 可以使用它与 Internet Explorer 进行通信。我们对 SetSite
的实现会从站点对象获取指向 IConnectionPointContainer
接口的接口指针。然后,我们使用 IConnectionPointContainer
中的 FindConnectionPoint
方法获取指向支持 DWebBrowserEvents2
调度接口的对象的一个 IConnectionPoint
接口指针。调度接口是一种特殊的接口,它派生自 IDispatch
,并通过其 Invoke
方法接收事件通知。我们对 DWebBrowserEvents2
的实现称为 CEventSink
,将在下一节中更详细地解释。我们使用 IConnectionPoint
接口的 Advise
方法来告知 Internet Explorer 将事件传递给我们的 CEventSink
对象。
我们对 SetSite
的实现还会从站点对象获取指向 IWebBrowser2
接口的接口指针。IWebBrowser2
接口由 Internet Explorer 的站点对象实现,它具有一些方法,我们可以使用它们与 Internet Explorer 进行交互。
注意:由于此示例 BHO 仅接收来自 Internet Explorer 的事件通知,而不实际控制 Internet Explorer,因此我们不使用 IWebBrowser2
中的任何方法。但是,我包含了获取 IWebBrowser2
接口的代码,以便您在需要时可以在自己的 BHO 中使用它。您可以在 此处找到 IWebBrowser2
的文档。
GetSite
由 Internet Explorer 调用,以了解我们当前设置的站点对象是什么。Internet Explorer 向我们传递它需要的、来自当前设置的站点对象的某个接口的 IID,我们只需从站点对象获取该接口并将其返回给 Internet Explorer。
CEventSink CoClass
CEventSink
CoClass 是我们对 DWebBrowserEvents2
调度接口的实现。DWebBrowserEvents2
派生自 IDispatch
,但它本身不实现任何方法。相反,DWebBrowserEvents2
的存在仅是为了让其唯一的 DIID(调度 IID)得以存在。此 DIID 标识了 DWebBrowserEvents2
的 CoClass 在其对 IDispatch
方法 Invoke
的实现中可以接收的事件。
注意:您可以在 EventSink.h 中找到 CEventSink
的定义,在 EventSink.cpp 中找到实现。
当 Internet Explorer 要通知我们一个事件时,它会调用 CEventSink::Invoke
方法,并将事件 ID 放在 dispIdMember
参数中,并将事件的其他信息放在 pDispParams
参数中。除了 Invoke
方法之外,IDispatch
还有三个方法(GetTypeInfoCount
、GetTypeInfo
和 GetIDsOfNames
),但我们不需要为它们实现任何功能,因为我们只接收事件。
您可能还会注意到 CEventSink
与我们其他 CoClass 的另一个不同之处在于,它不派生自 CUnknown
。这是因为我们只需要一个 DLL 全局的、静态分配的 CEventSink
实例,称为 EventSink
。因此,我们不需要实现任何引用计数,也就不需要 CUnknown
的引用计数和内存管理功能。
处理事件
Internet Explorer 调用 CEventSink::Invoke
来通知我们事件。dispIdMember
参数包含一个标识正在触发哪个事件的 ID,而 pDispParams->rgvarg[]
数组包含事件本身的参数,作为一个 VARIANT
数组。您可以查看 DWebBrowserEvents2
文档(可在 此处找到)来了解事件的参数。参数在 pDispParams->rgvarg[]
数组中的顺序与事件文档中列出的顺序相反。为了将这些参数从 VARIANT
转换为更易用的类型,我们首先为将要使用的每个参数声明一个 VARIANT
,并使用 VariantInit
API 函数对其进行初始化。然后,我们可以使用 VariantChangeType
API 函数将 pDispParams->rgvarg[]
数组中的 VARIANT
转换为更易用的类型的 VARIANT
。对所有需要的参数完成此操作后,我们可以通过使用转换后的 VARIANT
变量的相应成员,将参数的值传递给我们的 Event_*
方法。事件处理程序方法返回后,通过对每个转换后的 VARIANT
调用 VariantClear
方法来释放其使用的任何资源。下面给出了一个具体的示例。
BeforeNavigate2 事件
我们的示例 BHO 只处理一个事件:BeforeNavigate2
事件。BeforeNavigate2
的文档可在 此处找到。当 Internet Explorer 即将导航到新位置时,就会触发此事件。
查看文档,我们发现 BeforeNavigate2
提供了七个参数。我们不关心 pDisp
参数,它只是站点对象的 IDispatch
接口指针。
事件参数以 VARIANT
的形式存储在 pDispParams->rgvarg[]
中。但在使用这些参数之前,我们需要将这些 variant
转换为不同类型,以便于使用。为了做到这一点,我们在 Invoke
方法中有一个 variant
数组,它存储了转换后的 variant
。我们首先需要使用 VariantInit
函数初始化这些 variant
中的每一个,并在完成后使用 VariantClear
函数释放任何使用的资源。
Invoke
方法中的 variant
数组
VARIANT v[5];
// Used to hold converted event parameters before
// passing them onto the event handling method
...
for(n=0;n<5;n++) VariantInit(&v[n]); // initialize the variant array
... // use the variant array here
for(n=0;n<5;n++) VariantClear(&v[n]); // free the variant array
url
参数包含正在导航到的 URL。它是倒数第五个参数(最后一个是第 0 个),所以我们通过 pDispParams->rgvarg[5]
访问它。我们将此 variant
转换为 VT_BSTR
类型,因为它可能不是该格式,并将转换后的 variant
存储在 v[0]
中。然后,我们可以将 URL 访问为BSTR
字符串,方法是使用 v[0].bstrVal
。BSTR
字符串在 COM 中常用于传递字符串数据。它由一个 4 字节前缀组成,指示字符串的长度,后面是双字节字符 NULL 终止的 Unicode 字符串形式的字符串数据。BSTR
变量始终指向字符串数据,而不是它之前的前 4 字节前缀,这使我们能够方便地将其用作 C 风格的字符串。双字节字符字符串类型在 Windows API 头文件中声明为 LPOLESTR
,无论程序默认是否使用宽字符集。因此,我们可以将字符串 v[0].bstrVal
作为 LPOLESTR
参数传递给 Event_BeforeNavigate2
事件处理方法。
如何访问 url
参数
VariantChangeType(&v[0],&pDispParams->rgvarg[5],0,VT_BSTR);
// make sure the argument is of variant type VT_BSTR
...
Event_BeforeNavigate2( (LPOLESTR) v[0].bstrVal , ... );
// pass the url argument to the event handler
Flags
参数包含有关导航是来自外部窗口还是选项卡的信息。它是倒数第四个参数,所以我们通过 pDispParams->rgvarg[4]
访问它。我们将此 variant
转换为 VT_I4
类型,并将转换后的 variant
存储在 v[1]
中。VT_I4
表示一个 4 字节有符号整数,所以我们可以通过 v[1].lVal
将其作为 long
访问,并将其传递给 Event_BeforeNavigate2
。
如何访问 Flags
参数
VariantChangeType(&v[1],&pDispParams->rgvarg[4],0,VT_I4);
// make sure the argument is of variant type VT_I4 (a long)
...
Event_BeforeNavigate2( ... , v[1].lVal , ... );
// pass the Flags argument to the event handler
TargetFrameName
参数包含导航正在发生的框架的名称。它是倒数第三个参数,所以我们通过 pDispParams->rgvarg[3]
访问它。我们将此 variant
转换为 VT_BSTR
类型,并将转换后的 variant
存储在 v[2]
中。然后,我们可以像访问 url
参数一样,通过 v[2].bstrVal
将字符串值作为 LPOLESTR
访问,并同样将其传递给 Event_BeforeNavigate2
。
如何访问 TargetFrameName
参数
VariantChangeType(&v[2],&pDispParams->rgvarg[3],0,VT_BSTR);
// make sure the argument is of variant type VT_BSTR
...
Event_BeforeNavigate2( ... , (LPOLESTR) v[2].bstrVal , ... );
// pass the TargetFrameName argument to the event handler
PostData
参数包含 POST 请求的导航是否包含 POST 数据。它是倒数第二个参数,所以我们通过 pDispParams->rgvarg[2]
访问它。文档指出 PostData
是 VT_BYREF|VT_VARIANT
类型。这意味着 PostData
实际上是指向另一个 variant
的指针。进一步阅读文档的“备注”部分,我们可以看到所指向的 variant
包含一个 SAFEARRAY
。SAFEARRAY
通常在 COM 中用于包含数组数据。我们将 PostData
variant
转换为 VT_UI1|VT_ARRAY
类型,并将转换后的 variant
存储在 v[3]
中。VT_UI1|VT_ARRAY
意味着转换后,v[3]
是一个指向 SAFEARRAY
的 variant
,该 SAFEARRAY
是一个包含 1 字节无符号整数的一维数组。在访问 v[3]
中的 SAFEARRAY
数据之前,我们首先需要检查是否成功转换为 VT_UI1|VT_ARRAY
。如果导航不包含 POST 数据,则转换将不成功,v[3]
的 variant
类型将是 VT_EMPTY
。另一方面,如果数据存在,我们可以使用 v[3].parray
访问 variant
指向的 SAFEARRAY
,并使用 SafeArray*()
API 函数访问该 SAFEARRAY
中的数据。
首先,我们使用 SafeArrayGetLBound
和 SafeArrayGetUBound
函数获取数组中数据的大小。这些函数分别检索数组的下界和上界。用上界减去下界再加上 1 即可得到数组中的元素数量。然后,我们使用 SafeArrayAccessData
函数访问实际数据,该函数提供指向数据的指针并锁定数组。由于数组的元素是 1 字节无符号整数类型,因此我们可以将其作为 unsigned char
的 C 风格数组来访问数据。我们将数据指针和数据大小传递给 Event_BeforeNavigate2
。之后,我们通过调用 SafeArrayUnaccessData
来解锁数组。
如何访问 PostData
参数
PVOID pv;
LONG lbound,ubound,sz;
...
VariantChangeType(&v[3],&pDispParams->rgvarg[2],0,VT_UI1|VT_ARRAY);
// make sure the argument is a variant containing
// a SAFEARRAY of 1-byte unsigned integers
if(v[3].vt!=VT_EMPTY) {
// If the conversion was successful, we have POST data
SafeArrayGetLBound(v[3].parray,0,&lbound); // get the lower bound (first element index)
SafeArrayGetUBound(v[3].parray,0,&ubound); // get the upper bound (last element index)
sz=ubound-lbound+1; // use the bounds to calculate the data size
SafeArrayAccessData(v[3].parray,&pv); // get access to the data
} else {
// If the conversion was not successful, we do not have any POST data
sz=0; // set data size to zero
pv=NULL; // set the data pointer to NULL
}
...
Event_BeforeNavigate2( ... , (PUCHAR) pv , sz , ... );
// pass the pointer to the data and the data size
// of the PostData argument to the event handler
...
if(v[3].vt!=EMTPY) SafeArrayUnaccessData(v[3].parray);
// if we had previously accessed the data in the SAFEARRAY, unaccess it now
Headers
参数包含为导航发送的任何其他 HTTP 标头。它是倒数第二个参数,所以我们通过 pDispParams->rgvarg[1]
访问它。我们将此 variant
转换为 VT_BSTR
类型,并将转换后的 variant
存储在 v[4]
中。我们以与 url
和 TargetFrameName
参数相同的方式将数据传递给 Event_BeforeNavigate2
。
如何访问 Headers
参数
VariantChangeType(&v[4],&pDispParams->rgvarg[4],0,VT_BSTR);
// make sure the argument is of variant type VT_BSTR
...
Event_BeforeNavigate2( ... , (LPOLESTR) v[4].bstrVal , ... );
// pass the Headers argument to the event handler
Cancel
参数是一个返回值,用于指示 Internet Explorer 是继续导航还是取消导航。它是一个 VT_BYREF|VT_BOOL
类型的 variant
,这意味着它包含一个指向 VARIANT_BOOL
类型的指针,该类型的值为 VARIANT_TRUE
或 VARIANT_FALSE
。它是最后一个参数,所以我们通过 pDispParams->rgvarg[0]
访问它。我们可以通过 *(pDispParams->rgvarg[0].pboolVal)
访问 Cancel
参数指向的 VARIANT_BOOL
。如果我们将 Cancel
参数指向的 VARIANT_BOOL
设置为 VARIANT_TRUE
,Internet Explorer 将取消导航。如果将其设置为 VARIANT_FALSE
,Internet Explorer 将照常继续导航。由于可能有一个以上的 BHO 在处理 BeforeNavigate2
事件,因此 Cancel
参数指向的 VARIANT_BOOL
的预先存在的值可能对应于之前处理 BeforeNavigate2
的 BHO 设置的值。我们将现有值作为 bool
值传递给 Event_BeforeNavigate2
,以便 Event_BeforeNavigate2
可以决定是覆盖现有值还是保留它。Event_BeforeNavgiate2
的返回值是一个 bool
,指示 Cancel
参数的新值。
如何访问 Cancel
参数
bool b;
...
b = Event_BeforeNavigate2( ... ,
( (*(pDispParams->rgvarg[0].pboolVal)) != VARIANT_FALSE ) );
// pass the pre-existing value of the Cancel argument
// to the event handler, and get the new value for it
...
// Set the new value of the Cancel argument based upon
// the return value of Event_BeforeNavigate2()
if(b) *(pDispParams->rgvarg[0].pboolVal)=VARIANT_TRUE;
else *(pDispParams->rgvarg[0].pboolVal)=VARIANT_FALSE;
注意:您可以在 EventSink.cpp 文件中找到 BeforeNavigate2
事件处理程序 Event_BeforeNavigate2
的代码。
安装 BHO
安装 BHO 只需让进程调用我们 BHO 的 DllRegisterServer
函数即可。这可以通过 regsvr32.exe 工具轻松完成。只需运行命令 regsvr32.exe <BHO dll 的路径>,BHO 就应该被注册了。要卸载 BHO,进程需要调用我们 BHO 的 DllUnregisterServer
函数。这也可以通过 regsvr32.exe 完成,运行命令 regsvr32.exe /u <BHO dll 的路径>。
使用代码
将此示例 BHO 的源代码用作您自己 BHO 的起点。
注意:别忘了为自己的 BHO 生成新的 CLSID_IEPlugin
和 CLSID_IEPlugin_Str
!您可以使用 guidgen.exe 工具生成新的 CLSID。您可以在 main.cpp 中找到 CLSID_IEPlugin
的定义,在 common.h 中找到 CLSID_IEPlugin_Str
。
您可以从自定义 Event_BeforeNavigate2
事件处理程序开始,以满足您的需求。您还可以通过向 CEventSink
类添加新的事件处理方法,并在 CEventSink
的 Invoke
方法中调用它们来处理更多事件。
注意:您可以在 EventSink.h 文件中找到 CEventSink
的定义,在 EventSink.cpp 文件中找到 CEventSink
和 Event_BeforeNavigate2
的实现。
结论
希望您喜欢阅读本文并学到了一些新东西。欢迎在评论中提出问题、更正和建议!
修订历史
- 2009-06-06:
- 初次发布。