COM 邮箱 -为 VB 设计异步 COM 组件





5.00/5 (9投票s)
此示例 COM 组件提供了 3 个 COM 对象,用于使用 Win32 邮件槽 IPC 机制。如果您需要从 VB 使用邮件槽进行通信,该组件可能很有用。
概述
此示例 COM 组件提供了三个 COM 对象,用于使用 Win32 邮件槽 IPC 机制。如果您需要从 VB 使用邮件槽进行通信,该组件可能很有用。然而,我编写它的原因是演示如何创建 C++ 中的 COM 组件,该组件可以与 VB 很好地集成并可以触发异步事件。
COM 组件包含一个对象工厂,用于创建邮件槽操作对象的实例。有三个邮件槽对象:一个 ClientMailsot
对象,它提供邮件槽连接的“写入”端;一个同步的 ServerMailslot
对象,它提供邮件槽连接的“读取”端,但需要轮询才能接收数据;以及一个异步的 AsyncServerMailslot
对象,它通过触发事件来发出数据到达的信号。首先,我们将看看组件的对象模型,因为它揭示了使 VB 轻松使用这些对象的一些技巧。然后,我们将研究实现,并解决当组件可以异步运行时出现的线程问题。
接口设计:对象创建
工厂对象之所以存在,是因为您可以将创建和配置邮件槽对象作为一个单步过程。COM 对象不能拥有 C++ 构造函数的等效项,因此如果没有工厂对象,您将需要创建邮件槽对象然后对其进行配置。如果您忽略配置对象,或者尝试配置它但配置失败,您可能会得到一个存在于您的程序中但无用的对象。通过将对象的创建和配置包装成一个单一的步骤,可以更好地防止创建这些“僵尸”对象。如果成功,您将获得一个已正确配置并可运行的对象;如果失败,则您根本不会获得任何对象。
工厂对象接口 IDL 如下所示
interface IMailslotFactory : IDispatch { HRESULT CreateClientMailslot( [in] BSTR name, [in, optional] VARIANT computerOrDomain, [out, retval] IClientMailslot **ppSlot); HRESULT CreateServerMailslot( [in] BSTR name, [in, optional] VARIANT maxMessageSize, [in, optional] VARIANT readTimeOut, [out, retval] IServerMailslot **ppSlot); HRESULT CreateAsyncServerMailslot( [in] BSTR name, [in, optional] VARIANT maxMessageSize, [out, retval] IAsyncServerMailslot **ppSlot); };
工厂对象本身被标记为 appobject
,这意味着这些方法可以在 VB 中使用,而无需显式指定对象引用,从而可以编写如下代码
Dim slot As JBCOMMAILSLOTLib.ClientMailslot
Set slot = CreateClientMailslot("MySlot")
每个方法都会创建并配置相应的邮件槽对象。如果配置失败,则不返回任何对象,并会引发错误。
在内部,对象工厂通过创建 COM 对象实例来工作,但请求的是“初始化”接口而不是正常的客户端接口。初始化接口不需要在 IDL 或类型库中公开,因为它仅供组件内部使用。
ClientMailslot
对象的初始化接口定义如下
class __declspec(uuid("589E7114-50EE-4598-9140-92610D9BC20F")) IClientMailslotInit; class ATL_NO_VTABLE IClientMailslotInit : public IUnknown { public : STDMETHOD(Init)( /*[in]*/ BSTR name, /*[in]*/ VARIANT computerOrDomain) = 0; };
对象工厂将用户提供的参数传递给 ClientMailslot
,后者可以尝试自行配置。失败将导致工厂销毁 ClientMailslot
并将错误返回给调用者。
如果对象初始化成功,工厂会查询 ClientMailslot
的客户端接口 IClientMailslot
,并释放初始化接口。然后,ClientMailslot
将作为完全初始化并可运行的对象返回给调用者。
为防止用户直接创建对象而不是使用对象工厂,其他对象在 IDL 中被标记为 noncreatable
,同时请注意 IDL 中没有提及初始化接口。
[
uuid(30A92485-94D2-4CBA-AC32-EF276B7F777B),
helpstring("ClientMailslot Class"),
noncreatable
]
coclass ClientMailslot
{
[default] interface IClientMailslot;
};
ServerMailslot 和 AsyncServerMailslot 以相同的方式由工厂创建。
接口设计:数据传输
数据可以作为字符串或字节数组发送。同样,数据也可以这两种格式接收。以一种格式发送并不意味着服务器必须以该格式接收数据。
ClientMailsot 接口的 IDL 如下所示
interface IClientMailslot : IDispatch { HRESULT WriteString( [in] BSTR data); HRESULT Write( [in] VARIANT arrayOfBytes); };
这可以这样使用
Private Sub SendString_Click()
m_slot.WriteString MessageEdit.Text
End Sub
Private Sub SendBytes_Click()
Dim stringLength As Integer
stringLength = Len(MessageEdit.Text)
Dim bytes() As Byte
ReDim bytes(stringLength)
Dim i As Integer
For i = 0 To stringLength - 1
bytes(i) = Asc(Mid(MessageEdit.Text, i + 1, 1))
Next i
m_slot.Write bytes
End Sub
请注意,通过发送字符串,您实际上是在发送 Unicode 字符串:通过发送“AAAA”作为字符串,您实际上发送的是以下字节:0x65 0x00 0x65 0x00 0x65 0x00 0x65 0x00。
同步的 ServerMailsot
提供了相应的读取方法。当消息可用时,它可以按字节或字符串读取。但是,调用任一读取方法都会消耗当前消息。您不能调用 Read()
将消息读取为字节数组,然后调用 ReadString()
将同一消息读取为字符串,调用 ReadString()
将尝试读取下一条可用消息。如果您尝试读取消息但在此读取超时时间内没有可用消息,则会引发错误。因为读取调用是同步的,所以您的代码可能会在读取调用中阻塞读取超时时长。读取超时是在创建 ServerMailslot
时指定的,而不是按每次读取指定的。
异步的 AsyncServerMailslot
通过事件传递消息。事件接口的 IDL 如下所示
dispinterface _IAsyncServerMailslotEvents { properties: methods: HRESULT OnDataRecieved( [in] IMailslotData *mailslotData); };
并在 VB 中可以这样处理事件
Private Sub m_slot_OnDataRecieved(ByVal mailslotData As JBCOMMAILSLOTLib.IMailslotData)
Dim stringData as String
stringData = mailslotData.ReadString()
' do something with the string...
Dim bytes() As Byte
bytes = mailslotData.Read()
' do something with the bytes
End Sub
MailslotData
对象封装了一个邮件槽消息,不应在事件处理程序的作用域之外保留。如果您想保留数据,请将其提取为字符串或字节数组,并保留该表示形式。此限制是由于 AsyncMailslotServer
对象如何优化事件分派机制 - 每个 AsyncMailslotServer
只有一个 MailslotData
对象,并且它会被重用于到达的每条消息。
与 ServerMailslot
不同,您可以同时对 MailslotData
对象调用 ReadString()
和 Read()
,以在两种格式下接收相同的消息数据。
实现问题:CCOMMailslot 辅助对象
ClientMailslot
对象的实现非常简单。它只提供两个写入方法和内部初始化方法。所有实际工作都委托给一个辅助对象 CCOMMailslot
,该对象处理所有邮件槽 COM 对象之间的通用代码。ClientMailslot
实际完成的唯一工作是从提供的字节数组中提取数据。
ServerMailslot
也同样直接。大多数方法由 CCOMMailslot
实现,只有 Read
方法需要在 ServerMailslot
对象本身内完成。Read()
和 ReadString()
都调用 CCOMMailslot::Read()
,然后将结果数据打包为 BSTR
或字节的 SafeArray
。
CCOMMailslot
完成的大部分工作仅仅是参数检查和封装 Win32 邮件槽 API。唯一稍微复杂一些的代码位于 Read()
方法中。邮件槽可以创建为具有最大消息大小,在这种情况下,我们知道读取操作所需的缓冲区大小;它们也可以创建为具有未指定消息大小,可接受任何大小的消息。如果邮件槽是使用最大消息大小创建的,那么我们只需分配一个足够大的缓冲区并将其用于每次读取。如果未指定最大值,那么我们首先调用 SizeOfWaitingMessage()
来查看是否有消息等待,如果有,则检索消息的大小。如果有消息等待,我们会根据需要扩大缓冲区大小,以便有足够的空间读取消息,然后读取消息。
实现问题:CAsyncCOMMailslot 辅助对象
毫不奇怪,最复杂的对象是异步的 AsyncServerMailslot
。该对象是多线程的,并在消息到达邮件槽时生成事件。通常认为创建多线程 DLL 托管的 COM 组件是不明智的[1]。但是,此组件中创建的线程与 AsyncServerMailslot
对象的生命周期相关联,因此我们可以保证所有工作线程将在组件卸载之前停止。如果您对此感到担忧,可以将代码轻松地放入 EXE 组件中。我认为在这种情况下,拥有一个包含所有必需的代理/存根代码的单一 DLL 组件的便利性是值得的。
当创建一个 AsyncServerMailslot
时,它会生成一个工作线程,该线程对邮件槽句柄执行无限阻塞的、重叠的读取。工作线程会阻塞,等待读取完成或其关闭事件被发出信号。当读取完成时,接收到的数据会被封装在一个 MailslotData
对象中,并触发事件以通知客户端。
由于“COM 规则”,事件接收器必须从注册它的同一线程触发,或者必须将事件接收器接口封送(marshal)到将要触发事件的线程。由于 ATL 为我们生成连接点代码的方式,将每个事件接收器封送到工作线程以触发事件并不实用,因此我们选择从组件的主线程触发事件。要从组件的主线程触发事件,我们需要让工作线程与主线程通信,一种方法是使用窗口消息,另一种方法是将接口从主线程封送到工作线程。窗口消息方法在微软的一篇知识库文章[2]中有解释,但需要我们创建一个虚拟窗口并添加其他代码混乱;接口封送方法稍微复杂一些,但为我们提供了一些优势。
当一个对象需要以多线程方式运行并需要通过 COM 回调自身时,它应该使用 CoMarshalInterThreadInterfaceInStream()
将接口封送到工作线程。在工作线程内部,使用 CoGetInterfaceAndReleaseStream()
取消封送接口,并通过 COM 安全地与组件的主线程通信。这在组件需要卸载时会一直工作。不幸的是,通过在组件内部封送接口,您创建了一个循环引用。组件本质上持有自身的一个引用,这个未完成的引用将阻止组件被销毁。由于内部引用将一直持有直到工作线程关闭,而工作线程仅在组件被销毁时关闭,因此您可以看到我们遇到了问题。
实现问题:引用循环和弱身份
循环引用问题通常使用“分割身份”或“弱引用”惯用法[3]来解决。其思想是通过一个不影响对象引用计数的引用来打破引用循环,或者对象公开第二个身份(COM 对象),尽管它是主对象的一部分,但它不影响主对象的引用计数。这两种技术都允许主对象在所有外部引用释放后开始关闭。在 ATL 中实现弱引用相当复杂,尽管在 [3] 中提供了一个解决方案,但对于我们这里所需的内容来说,它过于复杂且具有侵入性。
我们解决工作中线程中持有接口所产生的引用循环问题的方法是为 AsyncServerMailslot
创建一个弱身份。这个弱身份支持工作线程和组件主线程之间通信所需的接口。弱身份本身就是一个简单的 COM 对象,它有自己的 AddRef()
、Release()
和 QueryInterface()
实现,并且由于返回的 IUnknown
接口与主对象不同,它在 COM 中拥有自己的身份。
我们用于线程间通信的接口的 IDL 如下所示
interface _AsyncServerEvent : IUnknown { HRESULT OnDataRecieved(); };
本质上,这只是工作线程“触碰”主线程的一种方式。此接口出现在 IDL 文件中,因为虽然我们仅在内部使用此接口,并且没有任何公共可见对象公开该对象,但我们需要为其生成代理/存根代码。
我们需要的弱身份如下所示
class CAsyncServerEventHelper : public _AsyncServerEvent { public : CAsyncServerEventHelper(_AsyncServerEvent &theInterface); STDMETHOD(OnDataRecieved)(); // IUnknown methods ULONG STDMETHODCALLTYPE AddRef(); ULONG STDMETHODCALLTYPE Release(); STDMETHOD(QueryInterface(REFIID riid, PVOID *ppvObj)); private : _AsyncServerEvent &m_interface; };
并按如下方式实现
CAsyncServerEventHelper::CAsyncServerEventHelper(_AsyncServerEvent &theInterfce) : m_interface(theInterfce) { } STDMETHODIMP CAsyncServerEventHelper::OnDataRecieved() { return m_interface.OnDataRecieved(); } ULONG STDMETHODCALLTYPE CAsyncServerEventHelper::AddRef() { return 2; } ULONG STDMETHODCALLTYPE CAsyncServerEventHelper::Release() { return 1; } STDMETHODIMP CAsyncServerEventHelper::QueryInterface(REFIID riid, PVOID *ppvObj) { if (riid == IID_IUnknown || riid == IID__AsyncServerEvent) { *ppvObj = this; AddRef(); return S_OK; } return E_NOINTERFACE; }
我们主要的 COM 身份有一个类型为 CAsyncServerEventHelper
的成员变量,它在其构造函数中用指向自身的指针(this)进行初始化。然后,它将弱身份的 _AsyncServerEvent
接口封送到其工作线程,这会创建适当的代理,以便对接口的调用能够正确地跨线程封送,但它不会影响主身份的引用计数。
当工作线程从邮件槽读取数据时,它会调用已解封接口的 OnDataRecieved()
方法,这会导致调用被封送到组件的主线程,然后在那里,辅助对象将调用传递给主对象。在此示例中,我们不费力地在调用中传递任何数据,我们只是将其用作一个线程“戳”另一个线程的方式。工作线程读取数据到读取缓冲区并戳主线程,然后主线程使用刚刚读取的数据并触发所有连接客户端中的事件。乍一看这可能很危险,但我们使用的是主对象 STA apartment 的同步性来提供跨调用的同步。工作线程将阻塞直到主线程完成事件分派。
当然,这只是实现弱身份的一种方法,但它相对简单、不显眼且效果很好。
结论
遵循一些简单的接口设计规则,为 VB 设计易于集成的 COM 组件是相当直接的。
如果您遵循 COM 规则,了解何时创建引用循环,并知道如何打破它们,那么处理异步事件会很简单。
参考文献
- [1] Effective COM - Item 32
- [2] Q196026 - PRB: Firing Event in Second Thread Causes IPF or GPF
- [3] COM IDL & Interface Design - Chapter 5
修订历史
- 2002年4月11日 - 初始修订。