了解 .NET 应用程序的经典 COM 互操作






4.96/5 (204投票s)
2001 年 3 月 4 日

1336541

11915
讨论了如何从托管代码中使用现有的 COM 组件。
(免责声明:本文和源代码中的信息根据 .NET 框架 SDK Beta 2 版本 - Build 1.0.2914.16 发布)
有没有想过,多年来你编写的 COM 组件如何与 .NET 运行时协同工作。如果你是一个铁杆 COM 开发者,想知道经典 COM 组件在 .NET 世界中的定位,或者 COM 感知客户端如何能够消费 .NET 组件,请继续阅读。
目录
- 引言
第一部分:从 .NET 应用程序中使用经典 COM 组件
- 入门
- 从 COM 类型库生成元数据
- 绑定到 COM 组件并从 .NET 应用程序调用其方法
- 访问其他支持的接口 (QueryInterface) 和动态类型发现
- COM 对象的后期绑定
- 事件处理 - 经典 COM 中的连接点与 .NET 中的委托事件模型
- 从 .NET 应用程序中使用 COM 集合
- 将 C# 中的方法参数关键字映射到 IDL 的 Directional 属性
- 在托管代码中重用经典 COM 组件
- 从 .NET 应用程序的角度理解 COM 线程模型和 Apartment
第二部分:从 COM 感知客户端消费 .NET 组件
- 创建 .NET 组件
- 使用属性调整生成的类型库元数据
- 异常处理 - .NET 异常与 COM HRESULT 的比较
- 处理来自 .NET 组件的事件(在非托管事件接收器中)
- 部署托管 .NET 组件的程序集
- 。NET 组件中的线程亲和性
引言
在试用了 .NET Beta 1 和 Beta 2 版本后,大多数开发人员无疑都认为 .NET 技术是构建企业级组件和分布式系统的强大方式。但是,您过去几年构建的现有可重用 COM 组件怎么办?更不用说那些咖啡杯和不眠之夜了。在 .NET 世界里,这些组件的时代是否就此结束了?这些组件是否能与 .NET 托管运行时协同工作?对于我们靠 COM 编程为生的人,以及那些信奉“COM 是爱”的人来说,有个好消息:COM 将继续存在,.NET 托管应用程序可以利用现有的 COM 组件。当然,微软不想强迫公司放弃所有现有组件,特别是那些用最广泛使用的对象模型开发桌面和分布式应用程序的组件。经典 COM 组件通过一个互操作层与 .NET 运行时进行互操作,该层将处理托管运行时和在非托管环境中运行的 COM 组件之间传递的消息的转换,反之亦然。现在,让我们来看看另一面。如果您决定使用您选择的、以 CLR 为目标的、对 COM 友好的语言编写组件,但仍希望 COM 感知客户端能够消费这些 .NET 组件,比如 VB 6.0 或 Classic ASP?别绝望。COM 感知客户端将非常乐意通过 COM 互操作与 .NET 组件协同工作。随 .NET 框架提供的工具可以让你将 .NET 组件暴露给 COM 感知客户端,就像它们是普通的 COM 组件一样。COM 互操作在后台处理所有繁重的工作和连接。在本文的第一部分,我们将重点介绍如何让 COM 组件与 .NET 应用程序协同工作,然后在后半部分,我们将探讨如何在非托管世界中从 COM 感知客户端消费 .NET 组件。希望在文章结束时,您能充分理解经典 COM 和 .NET 框架如何和谐共存并和谐共舞。所以,如果您准备好了,让我们一起踏上探索经典 COM 如何融入 .NET 世界宏大蓝图的旅程吧。
第一部分:从 .NET 应用程序中使用经典 COM 组件
入门
我们将从了解如何将经典 COM 组件暴露给 .NET 应用程序开始。我们的第一个任务是使用 ATL 编写一个简单的 COM 组件,该组件提供特定航空公司的到达详情。为简单起见,我们只返回“Air Scooby IC 5678”航空公司的详情,并对任何其他航空公司返回错误。这样,您也可以了解 COM 组件引发的错误如何传播并被调用它的 .NET 客户端应用程序捕获。
这是 IAirlineInfo
接口的 IDL 定义
..... interface IAirlineInfo : IDispatch { [id(1), helpstring("method GetAirlineTiming")] HRESULT GetAirlineTiming([in] BSTR bstrAirline, [out,retval] BSTR* pBstrDetails); [propget, id(2), helpstring("property LocalTimeAtOrlando")] HRESULT LocalTimeAtOrlando([out, retval] BSTR *pVal); }; .......
这是 GetAirlineTiming
方法的实现
....... CAirlineInfo::GetAirlineTiming(BSTR bstrAirline, BSTR *pBstrDetails) { _bstr_t bstrQueryAirline(bstrAirline); if(NULL == pBstrDetails) return E_POINTER; if(_bstr_t("Air Scooby IC 5678") == bstrQueryAirline) { // Return the timing for this Airline *pBstrDetails = _bstr_t(_T("16:45:00 - Will arrive at Terminal 3")).copy(); } else { // Return an error message if the Airline was not found return Error(LPCTSTR(_T("Airline Timings not available for this Airline" )), __uuidof(AirlineInfo), AIRLINE_NOT_FOUND); } return S_OK; } .......
既然我们的组件已经准备就绪,让我们来看看如何从组件的类型库生成元数据,以便 .NET 客户端可以使用这些元数据与我们的组件通信并调用其方法。
从 COM 类型库生成元数据
从 .NET 应用程序消费经典 COM 组件
需要与我们的 COM 组件通信的 .NET 应用程序无法直接消费它公开的功能。因此,我们需要生成一些元数据。此元数据层用于运行时以获取类型信息,以便在运行时使用此类型信息来制造所谓的运行时可调用包装器 (RCW)。RCW 处理 COM 对象的实际激活,并在 .NET 应用程序与之交互时处理封送处理要求。RCW 还执行大量其他任务,例如管理对象标识、对象生存期和接口缓存。对象生存期管理是一个非常关键的问题,因为 .NET 运行时会移动对象并进行垃圾回收。RCW 用于给 .NET 应用程序一种它正在与托管 .NET 组件交互的感觉,并让非托管领域的 COM 组件感觉它正在被一个老式 COM 客户端调用。RCW 的创建和行为因您是早期绑定还是后期绑定到 COM 对象而异。在底层,RCW 执行所有繁重的工作,并将所有方法调用封送为对存在于非托管世界中的 COM 组件的相应 v-table 调用。它是托管世界和非托管 IUnknown
世界之间的友好大使。
让我们为我们的 Airline COM 组件生成元数据包装器。为此,我们需要使用一个名为 TLBIMP.exe 的工具。类型库导入器 (TLBIMP) 随 .NET 框架 SDK 一起提供,可以在 SDK 安装的 Bin 子文件夹下找到。类型库导入器实用程序读取类型库并生成包含 .NET 运行时可理解的类型信息的相应元数据包装器。
从 DOS 命令行,键入以下命令
TLBIMP AirlineInformation.tlb /out:AirlineMetadata.dll
此命令指示类型库导入器读取您的 AirlineInfo
COM 类型库并生成一个名为 AirlineMetadata.dll 的相应元数据包装器。如果一切顺利,您应该会看到一条消息,表明已从类型库生成了元数据代理
Type library imported to E:\COMInteropWithDOTNET\AirlineMetadata.dll
这种生成的元数据包含什么类型的类型信息,它看起来是什么样的?作为 COM 人员,我们一直喜欢我们亲爱的 OleView.exe,有时当我们想一窥类型库的内容时,或者对于 OleView 能够做的其他大量事情。幸运的是, .NET SDK 随附了一个名为 ILDASM 的反汇编器,它允许我们查看为托管程序集生成的元数据和中间语言 (IL) 代码。每个托管程序集都包含自描述元数据,当您需要查看该 IL 代码和元数据时,ILDASM 是一个非常有用的工具。继续使用 ILDASM 打开 AirlineMetadata.dll。查看生成的元数据,您将看到 GetAirlineTiming
方法被列为 IAirlineInfo
接口的公共方法,该接口由 AirlineInfo
类实现。还为 AirlineInfo
类生成了一个构造函数。方法参数和返回值的数据类型也已被替换为其等效的 .NET 类型。在我们的示例中,具有 BSTR
数据类型的 GetAirlineTiming
方法的参数已被替换为string
(System.String
的别名)数据类型。还要注意,在 GetAirlineTiming
方法中标记为 [out,retval]
的参数已转换为方法的实际返回值(返回为string
)。从 COM 对象返回的任何失败 HRESULT
值(在出错或常规业务逻辑失败时)都会作为 .NET 异常抛出。
IL 反汇编器 - 查看托管程序集的元数据和 MSIL 的强大工具
绑定到 COM 组件并从 .NET 应用程序调用其方法
现在我们已经生成了 .NET 客户端所需的元数据,让我们尝试从 .NET 客户端调用我们 COM 对象中的 GetAirlineTiming
方法。这是一个简单的 C# 客户端应用程序,它使用我们之前生成的元数据创建 COM 对象并调用 GetAirlineTiming
方法。第一次方法调用应该顺利进行,我们得到航空公司“Air Scooby IC 5678”的详细信息。然后,我们将传入一个未知的航空公司,“Air Jughead TX 1234”,以便 COM 对象抛出我们定义的 AIRLINE_NOT_FOUND
错误。
....... String strAirline = "Air Scooby IC 5678"; String strFoodJunkieAirline = "Air Jughead TX 1234"; try { AirlineInfo objAirlineInfo; objAirlineInfo = new AirlineInfo(); // Call the GetAirlineTiming() method System.Console.WriteLine("Details for Airline {0} --> {1}", strAirline, objAirlineInfo.GetAirlineTiming(strAirline)); // This should make the COM object throw us the // AIRLINE_NOT_FOUND error as a COMException System.Console.WriteLine("Details for Airline {0} --> {1}", strFoodJunkieAirline, objAirlineInfo.GetAirlineTiming( strFoodJunkieAirline)); } catch(COMException e) { System.Console.WriteLine("Oops an error occured !. Error Code is : {0}. Error message is : {1}",e.ErrorCode,e.Message); } .......
输出将如下所示
Details for Airline Air Scooby IC 5678 --> 16:45:00 - Will arrive at Terminal 3
Oops an error occured !. Error Code is : -2147221502.
Error message is : Airline Timings not available for this Airline
在底层,运行时会创建一个RCW,它将元数据代理的类方法和字段映射到 COM 对象实现的接口所公开的方法和属性。每个 COM 对象实例会创建一个 RCW 实例。 .NET 运行时仅负责管理 RCW 的生存期并对其进行垃圾回收。RCW 负责维护其映射到的 COM 对象的引用计数,从而保护 .NET 运行时免于管理实际 COM 对象的引用计数。如 ILDASM 视图所示,AirlineInfo
元数据类定义在名为 AirlineMetadata
的命名空间下。此类实现了 IAirlineInfo
接口。您所要做的就是使用 new 运算符创建 AirlineInfo
类的实例,并调用创建对象的公共类方法。调用方法时,RCW 会将调用封送到相应的 COM 方法。RCW 还处理所有封送处理和对象生存期问题。对于 .NET 客户端来说,它看起来就像它正在创建托管对象并调用其公共类成员。任何时候 COM 方法引发错误,COM 错误都会被 RCW 捕获,错误会被转换为等效的 COMException
类(位于 System.Runtime.InteropServices
命名空间)。当然,COM 对象仍需要实现 ISupportErrorInfo
和 IErrorInfo
接口才能使此错误传播生效,以便 RCW 知道对象提供扩展错误信息。错误由 .NET 客户端使用普通的 try-catch 异常处理机制捕获,并且可以访问实际的错误编号、描述、异常源和其他任何 COM 感知客户端都可以获得的详细信息。您还可以返回标准的 HRESULT
,RCW 将负责将其映射到相应的 .NET 异常并抛回给客户端。例如,如果您从 COM 方法返回 HRESULT
E_NOTIMPL
,那么 RCW 将将其映射到 .NET NotImplementedException
异常并抛出该类型的异常。
请参阅本文的异常处理部分,了解 .NET 异常如何映射到 COM HRESULT
。
访问其他支持的接口和动态类型发现
当 .NET 客户端需要检查 COM 对象是否实现了特定接口时,经典的 QueryInterface
场景是如何工作的?要 QI(查询)另一个接口,您所要做的就是将对象强制转换为您正在查询的接口,如果成功,那么您的 QI 也成功了。如果您尝试将对象强制转换为对象不支持的任意接口,则会抛出 System.InvalidCastException
异常,表明 QI 失败。就这么简单。同样,RCW 在后台执行所有繁重的工作。这很像 VB 运行时如何保护我们免于编写任何显式的 QueryInterface
相关代码,并在您将一种对象类型设置为另一种关联类型的对象时为您执行 QI。
检查当前持有的对象实例是否支持或实现了特定接口的另一种方法是使用 C# 的 'is
' 运算符。'is
' 运算符执行运行时类型检查,以查看对象是否可以安全地强制转换为特定类型。如果返回true,则您可以安全地执行强制类型转换以完成 QI。这样,RCW 可确保您仅强制转换为 COM 对象实现的接口,而不是任意接口类型。您也可以使用 C# 的 'as
' 运算符将一种类型强制转换为另一种兼容类型,如上面的示例所示。这些简单的构造就是您需要使用的全部,以便以类型安全的方式在 COM 对象支持的接口之间进行切换。
.......
try
{
AirlineInfo objAirlineInfo = null;
IAirportFacilitiesInfo objFacilitiesInfo = null;
// Create a new AirlineInfo object
objAirlineInfo = new AirlineInfo();
// Invoke the GetAirlineTiming method
String strDetails = objAirlineInfo.GetAirlineTiming(strAirline);
// Check to see if the AirlineInfo object supports the
// IAirportFacilitiesInfo interface using C#'s 'is' operator
if(objAirlineInfo is IAirportFacilitiesInfo)
{
// Perform a cast to get the QI done
objFacilitiesInfo = (IAirportFacilitiesInfo)objAirlineInfo;
// There's always more than one way to skin a cat
// You could even perform the cast using C#'s 'as' operator
objFacilitiesInfo = objAirlineInfo as IAirportFacilitiesInfo;
//Invoke a method on the IAirportFacilitiesInfo interface
System.Console.WriteLine("{0}",
objFacilitiesInfo.GetInternetCafeLocations());
}
// Let's check against an arbitrary interface type
if(objAirlineInfo is IJunkInterface)
{
System.Console.WriteLine("We should never get here ");
}
else
{
System.Console.WriteLine("I'm sorry I don't implement" +
" the IJunkInterface interface ");
}
// And now let's ask for some trouble and have the
// interop throw us an invalid cast exception.
IJunkInterface objJunk = null;
objJunk = (IJunkInterface)objAirlineInfo;
}/* end try */
catch(InvalidCastException eCast)
{
System.Console.WriteLine("Here comes trouble" +
" ... Error Message : {0}",
eCast.Message);
}/* end catch */
.......
输出将如下所示
Your nearest Internet Cafe is at Pavilion 3 in Terminal 2 -
John Doe's Sip 'N' Browse Cafe
I'm sorry I don't implement the IJunkInterface interface
Here comes trouble ... Error Message :
An exception of type System.InvalidCastException was thrown.
COM 对象的后期绑定
上面看到的所有示例都使用了元数据代理来早期绑定 .NET 客户端到 COM 对象。尽管早期绑定提供了大量的优势,如编译时的强类型检查、为开发工具提供类型信息的自动完成功能,当然还有更好的性能,但在某些情况下,当您没有要绑定的 COM 对象的编译时元数据时,您确实需要后期绑定到经典 COM 对象。您可以通过一种称为反射的机制后期绑定到 COM 对象。这不仅适用于 COM 对象。即使是 .NET 托管对象也可以使用反射进行后期绑定和加载。此外,如果您的对象仅实现了纯dispinterface,那么您几乎只能使用反射来激活您的对象并在其接口上调用方法。要后期绑定到 COM 对象,您需要知道对象的 ProgID 或 CLSID。System.Activator
类的 CreateInstance
静态方法允许您指定特定类的Type信息,它将自动创建该特定类型的对象。但我们实际上拥有的是 COM ProgID 和 COM CLSID,而不是真正的 .NET Type 信息。因此,我们需要使用 System.Type
类的 GetTypeFromProgID
或 GetTypeFromCLSID
静态方法从ProgID或CLSID获取Type信息。System.Type
类是反射的核心启用程序之一。使用 Activator.CreateInstance
创建对象实例后,您可以使用从 Type.GetTypeFromProgID
或 Type.GetTypeFromCLSID
获取的 Type 对象的 System.Type.InvokeMember
方法来调用对象支持的任何方法/属性。您只需要知道方法的名称或属性名称以及方法调用接受的参数类型。参数被捆绑到一个通用的 System.Object
数组中并传递给方法。您还需要根据您是调用方法还是获取/设置属性值来设置适当的绑定标志。这就是后期绑定到 COM 对象的所有内容。
.......
try
{
object objAirlineLateBound;
Type objTypeAirline;
// Create an object array containing
// the input parameters for the method
object[] arrayInputParams= { "Air Scooby IC 5678" };
//Get the type information from the progid
objTypeAirline =
Type.GetTypeFromProgID("AirlineInformation.AirlineInfo");
// Here's how you use the COM CLSID
// to get the associated .NET System.Type
// objTypeAirline = Type.GetTypeFromCLSID(new Guid(
"{F29EAEEE-D445-403B-B89E-C8C502B115D8}"));
// Create an instance of the object
objAirlineLateBound = Activator.CreateInstance(objTypeAirline);
// Invoke the 'GetAirlineTiming' method
String str = (String)objTypeAirline.InvokeMember("GetAirlineTiming",
BindingFlags.Default |
BindingFlags.InvokeMethod,
null,
objAirlineLateBound,
arrayInputParams);
System.Console.WriteLine("Late Bound Call" +
" - Air Scooby Arrives at : {0}",str);
// Get the value of the 'LocalTimeAtOrlando' property
String strTime = (String)objTypeAirline.InvokeMember("LocalTimeAtOrlando",
BindingFlags.Default |
BindingFlags.GetProperty,
null,
objAirlineLateBound,
new object[]{});
Console.WriteLine ("Late Bound Call - Local" +
" Time at Orlando,Florida is: {0}",
strTime);
}/* end try */
catch(COMException e)
{
System.Console.WriteLine("Error code : {0}, Error message : {1}",
e.ErrorCode, e.Message);
}/* end catch */
.......
查看您将获得的输出
Late Bound Call - Air Scooby Arrives at 16:45:00 - Will arrive at Terminal 3
Late Bound Call - Local Time at Orlando,Florida is: Sun Jul 15 16:50:01 2001
事件处理 - 经典 COM 中的连接点与 .NET 中的委托事件模型
连接点事件处理机制,正如您所知,是您的 COM 组件与其组件使用者之间进行双向通信的主要启用程序之一。为了唤醒您的记忆,我将简要介绍一下经典 COM 组件中的事件处理机制。通常,支持事件通知的 COM 组件具有所谓的出站接口。出站接口用于组件在特定事件发生时调用客户端。出站接口在组件的IDL文件的coclass部分标记有[source]属性。IDL 中的[source]
属性允许开发工具和 IDE 解析类型库以检查对象是否支持出站接口。这些组件的消费者或客户端通常会设置一个接收器对象,该对象实现此出站接口。此接收器对象的接口指针由客户端传递给组件。组件通常会将此接口指针存储在一个映射中,该映射包含对有兴趣接收组件通知的接收器对象的出站接口指针列表。每当组件需要引发事件时,它都会使用映射来获取已订阅通知的接收器对象的接口指针列表。然后,它通过调用接收器对象实现的出站接口上的相应方法来通知它们。
经典 COM 中的连接点本质上,支持出站接口的 COM 对象实现了 经典 COM 中的连接点 简单来说,这就是经典 COM 组件中事件处理机制和双向通信的工作方式。大多数教授 COM 编程的书籍通常都有一个完整的章节来解释这种架构,您可能想查阅它们以进一步加深对该主题的理解。 |
创建源事件的 ATL COM 组件
让我们看看 COM 中的连接点事件处理机制如何转换为 .NET 世界中的委托事件处理机制。我们将了解如何使用 .NET 托管事件接收器来捕获来自 COM 对象发送的事件通知。要开始,让我们看一下将要向您的 .NET 应用程序源事件的 COM 对象。让我们构建一个简单的 COM 对象,当航班到达一个虚构的机场 John Doe 国际机场时,该对象将分页您的 .NET 应用程序。我们将从 .NET 应用程序订阅此分页服务,以便在飞机滑行到 John Doe 的跑道时获得分页。
我们将创建一个 ATL EXE 项目,托管一个名为 AirlineArrivalPager
的对象。AirlineArrivalPager
对象支持一个名为 IAirlineArrivalPager
的入站接口和一个名为 _IAirlineArrivalPagerEvents
的出站接口。这是 _IAirlineArrivalPagerEvents
出站接口的接口定义。在接口的 coclass 定义中,此接口标记有[source]属性。
..... interface IAirlineArrivalPager : IDispatch { [id(1), helpstring("method AddArrivalDetails")] HRESULT AddArrivalDetails([in] BSTR bstrAirlineName, [in] BSTR bstrArrivalTerminal); }; .... dispinterface _IAirlineArrivalPagerEvents { properties: methods: [id(1), helpstring("method OnAirlineArrivedEvent")] HRESULT OnAirlineArrivedEvent([in] BSTR bstrAirlineName, [in] BSTR bstrArrivalTerminal); }; .... coclass AirlineArrivalPager { [default] interface IAirlineArrivalPager; [default, source] dispinterface _IAirlineArrivalPagerEvents; }; .......
查看入站 IAirlineArrivalPager
接口的 AddArrivalDetails
方法的实现
....... STDMETHODIMP CAirlineArrivalPager::AddArrivalDetails( BSTR bstrAirlineName,BSTR bstrArrivalTerminal) { // Notify all subscribers that an Airline has hit the tarmac Fire_OnAirlineArrivedEvent(bstrAirlineName,bstrArrivalTerminal); // Return the status to the caller return S_OK; } .......
此方法的实现使用 Fire_OnAirlineArrivedEvent
帮助方法来通知所有实现 _IAirlineArrivalPagerEvents
并已订阅事件通知的接收器对象。Fire_OnAirlineArrivedEvent
是从 IConnectionPointImpl
派生的帮助代理类的成员,该类由ATL 实现连接点向导自动生成。本质上,它会遍历映射,其中存储了在调用 IConnectionPoint::Advise
时添加到接收器对象的接口指针,并使用这些接口指针调用客户端接收器对象实现的事件通知方法(OnAirlineArrivedEvent
)。
如果您是编写 COM 感知客户端应用程序以接收通知的 C++ 程序员,您将在客户端应用程序中设置一个实现 _IAirlineArrivalPagerEvents
接口的接收器对象。然后,您将创建 AirlineArrivalPager
对象,并通过调用 IConnectionPoint::Advise
将接收器对象的 IUnknown
接口指针传递给它,或者使用 AtlAdvise
等帮助方法将您的接收器连接到引发事件的对象,以便接收事件通知。使用 VB 6.0,只需在声明中使用 WithEvents
关键字并定义一个处理函数来接收通知即可。VB 将在后台处理所有繁重的工作,将出站接口上的通知挂接到适当的处理函数。
使用委托处理事件
如果您已经熟悉 .NET 中委托的用法,您可以跳过此部分,转到下一部分。 .NET 中的事件处理主要基于委托事件模型。委托类似于我们在 C/C++ 中使用的函数指针。基于委托的事件模型因其使用的简便性而广受欢迎,从WFC(Visual J++ 中的 Windows Foundation Classes)时代开始。委托允许将任何组件引发的事件连接到任何其他组件的处理函数或方法,只要处理函数或方法的函数签名与委托的签名完全匹配。请看下面这个简单的示例,它展示了如何运用委托。
// Here's the SayGoodMorning delegate
delegate string SayGoodMorning();
public class HelloWorld
{
public string SpeakEnglish() {
return "Good Morning";
}
public string SpeakFrench() {
return "Bonjour";
}
public static void Main(String[] args) {
HelloWorld obj = new HelloWorld();
// Associate the delegate with a method reference
SayGoodMorning english = new SayGoodMorning(obj.SpeakEnglish);
SayGoodMorning french = new SayGoodMorning(obj.SpeakFrench);
// Invoke the delegate
System.Console.WriteLine(english());
System.Console.WriteLine(french());
}
}/* end class */
这是您获得的输出
Good Morning
Bonjour
在上面的示例中,我们声明了一个名为 SayGoodMorning
的委托。然后,我们将委托链接到引用 HelloWorld
对象的 SpeakEnglish
和 SpeakFrench
方法。唯一的要求是 SpeakEnglish
和 SpeakFrench
方法具有与 SayGoodMorning
委托相同的签名。引用通常通过实例化委托,就像它是一个对象一样,并将其引用的方法作为参数传递。引用的方法可以是类的实例方法或静态方法。委托维护其调用正确事件处理程序的引用。这使得委托成为一流的面向对象公民,并且它们使用起来也是类型安全且安全的。 .NET 事件处理模型主要基于委托事件模型。请看以下示例
......
// Create a Button
private System.Windows.Forms.Button AngryButton = new Button();
....
// Add a delegate to the button's Click event list
AngryButton.Click += new System.EventHandler(AngryButton_Click);
.....
// Here's the handler function that the delegate references
protected void AngryButton_Click(object sender,EventArgs e)
{
MessageBox.Show("Please Stop clicking me !!");
}
.......
当您的应用程序处理控件并希望接收特定通知时,它会创建一个EventHandler委托的新实例,该实例包含对实际处理控件引发的事件的处理函数的引用。在上例中,EventHandler委托包含对 AngryButton_Click
方法的引用。AngryButton_Click
方法需要具有与EventHandler委托相同的函数签名。这是 System.EventHandler
委托的签名样子
public delegate void EventHandler(object sender, EventArgs e);
然后,必须将 EventHandler
委托实例添加到 Click
事件的委托实例列表中。扩展 System.MulticastDelegate
的委托允许您使用 C# 运算符(如 +=
和 -=
)向委托的调用列表添加多个处理函数,这些运算符是 Delegate.Combine
和 Delegate.Remove
方法的包装器。使用事件为用户提供了一种可靠的方案,即只能使用 +=
和 -=
运算符添加或删除委托实例到事件,而不会意外覆盖调用列表。当控件引发事件时,添加到按钮的 Click
事件的所有委托都将被调用,并且委托将将其路由到它引用的正确处理函数。
在我们的示例中,每当按钮中发生 Click
事件时,调用将被路由到 AngryButton_Click
方法。我想这足以让您对委托和事件在 .NET 框架的事件处理机制中所起的作用有一个相当清晰的认识。我解释委托的工作方式是因为它是 .NET 事件处理模型的主要启用程序之一,理解这一点对于欣赏 .NET 应用程序如何使用委托订阅经典 COM 组件的事件通知很重要。
在 .NET 应用程序中接收非托管 COM 事件
这是一个简单的 VB 客户端应用程序,它扮演 John Doe 国际机场的控制塔的角色,并调用入站接口中的 AddArrivalDetails
方法。此方法的实现反过来触发事件通知,随后被订阅了 OnAirlineArrivedEvent
事件通知的 .NET 应用程序中的处理函数捕获。AirlineArrivalPager
COM 对象本身是托管在进程外 COM 服务器中的单例对象。因此,相同的对象实例为触发事件的 VB 控制塔应用程序和订阅了 OnAirlineArrivedEvent
事件通知的 .NET 分页应用程序提供服务。
Dim AirlinePager As New AIRLINENOTIFYLib.AirlineArrivalPager
Private Sub AirlineArrived_Click()
AirlinePager.AddArrivalDetails Me.AirlineName, Me.ArrivalTerminal
End Sub
话虽如此,让我们看看 .NET 托管应用程序如何接收 AirlineArrivalPager
COM 对象生成的事件通知。首先,您需要从 COM 对象的类型库生成 .NET 元数据代理,以便 .NET 应用程序可以使用它。让我们使用类型库导入器 (TLBIMP) 为我们生成元数据代理程序集。
tlbimp AirlineNotify.tlb /out:AirlineNotifyMetadata.dll
此元数据代理将在您的 .NET 应用程序中引用。这是一个简单的 .NET Windows 窗体应用程序,它使用委托订阅来自 AirlineArrivalPager
COM 组件的事件通知。
......
using AirlineNotifyMetadata;
public class AirlineNotifyForm : System.WinForms.Form
{
private System.Windows.Forms.CheckBox checkBoxPaging;
private System.Windows.Forms.ListBox listPager;
private AirlineArrivalPager m_pager = null;
......
public AirlineNotifyForm() {
.....
// Subscribe to event notifications from
// the AirlineArrivalPager component
subscribePaging();
}
......
void subscribePaging() {
// Create an AirlineArrivalPager object
m_pager = new AirlineArrivalPager();
// Add the delegate instance that references
// the OnMyPagerNotify method
// to the OnAirlineArrivedEvent event list (ICP::Advise)
m_pager.OnAirlineArrivedEvent +=
new _IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler(
OnMyPagerNotify);
}/* end subscribePaging */
protected void checkBoxPaging_CheckedChanged (object sender,
System.EventArgs e) {
if(checkBoxPaging.Checked) {
// If checked, add the delegate instance
// that references OnMyPagerNotify
// to the OnAirlineArrivedEvent event list (ICP::Advise)
m_pager.OnAirlineArrivedEvent +=
new _IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler(
OnMyPagerNotify);
}
else {
// If Unchecked, remove the delegate
// instance that references OnMyPagerNotify
// from the OnAirlineArrivedEvent event list (ICP::Unadvise)
m_pager.OnAirlineArrivedEvent -= new
_IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler(
OnMyPagerNotify);
}
}/* end checkBoxPaging_CheckedChanged */
public int OnMyPagerNotify(String strAirline, String strTerminal) {
StringBuilder strDetails = new StringBuilder("Airline ");
strDetails.Append(strAirline);
strDetails.Append(" has arrived in ");
strDetails.Append(strTerminal);
listPager.Items.Insert(0,strDetails);
return 0;
}/* end OnMyPagerNotify */
}/* end class */
这里最重要的代码行是
m_pager.OnAirlineArrivedEvent += new
_IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler(
OnMyPagerNotify);
如果您理解委托的工作原理,您应该能够完全理解这里发生的事情。您所做的是将引用 OnMyPagerNotify
方法的 _IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler
委托实例添加到 OnAirlineArrivedEvent
事件列表中。通常,事件的名称(OnAirlineArrivedEvent
)与出站接口中的方法名称相同。委托名称(_IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler
)通常遵循 InterfaceName_EventNameEventHandler
的模式。这就是接收来自 COM 组件的事件通知的所有内容。您所要做的就是创建一个组件实例,然后将引用您的处理函数的委托添加到事件列表中。实际上,您在这里所做的事情与 COM 世界中的 IConnectionPoint::Advise
类似。每当 COM 组件引发 OnAirlineArrivedEvent
事件时,将调用 OnMyPagerNotify
方法来处理事件通知。在 .NET 中,将处理程序接收器连接到源事件的 COM 对象以接收事件通知非常简单。
经典 COM 中的连接点事件处理机制如何映射到 .NET 中的委托事件处理机制
当您不再希望接收通知时,可以通过调用以下命令从事件列表中删除委托
m_pager.OnAirlineArrivedEvent -= new
_IAirlineArrivalPagerEvents_OnAirlineArrivedEventEventHandler(OnMyPagerNotify);
这类似于 IConnectionPoint::Unadvise
方法调用,该调用通过使用在 Advise 调用中收到的cookie从映射中移除您的接收器对象的接口指针来撤销进一步的通知。但是,谁来处理经典 COM 中的连接点事件处理模型与.NET 中的委托事件模型之间的映射呢?类型库导入器 (TLBIMP) 生成的元数据代理包含充当适配器的类,它们通过运行时创建的RCW 存根将非托管世界中的连接点事件模型连接到 .NET 世界中的基于委托的事件模型。如果您有兴趣检查底层发生了什么,我鼓励您使用 IL 反汇编器 (ILDASM) 打开元数据代理(AirlineNotifyMetadata.dll)并检查帮助类中各种方法的MSIL代码。
从 .NET 应用程序中使用 COM 集合
使用基于 COM 的集合允许您将对象分类在一起,作为具有相似行为的组的一部分。例如,BookCollection
可以模拟图书馆中的所有书籍。存储在此集合中的每个Book对象都可以代表书籍的详细信息,例如作者、ISBN 等。迭代集合非常简单,允许您按需获取添加到集合中的对象。还有其他建模集合的方法,例如使用SAFEARRAY。SAFEARRAY
的问题是,如果集合很大,它会涉及将 SAFEARRAY
代表的整个数据块移动到客户端。使用集合允许您按需获取数据。此外,使用 For Each..Next
语法从 VB 等客户端迭代集合要优雅得多。如果您有代表基于 COM 的集合的现有 COM 对象,它们将继续与 .NET 应用程序很好地配合。这些集合可以被 .NET 客户端轻松枚举。我们很快就会看到如何做到这一点。如果您已经熟悉经典 COM 中集合的工作方式,您可能想跳过此部分,转到下一部分。
使用 ATL 创建 COM 集合组件
对于觉得使用 VB 编写 COM 组件更舒服的 VB 用户,您可能想跳过此部分,转到使用 VB 创建 COM 集合组件部分。首先,让我们使用 ATL 构建一个简单的 COM 组件,该组件模拟一个冰淇淋集合。Collection 类代表冰淇淋店的菜单,其中包含各种冰淇淋口味。查看此组件的 IDL 文件。
[
....
]
interface IIceCreamMenu : IDispatch
{
[propget, id(1), helpstring("property Count")]
HRESULT Count([out, retval] long *pVal);
[propget, id(DISPID_NEWENUM),
helpstring("property _NewEnum"), restricted]
HRESULT _NewEnum([out, retval] LPUNKNOWN *pVal);
[propget, id(DISPID_VALUE), helpstring("property Item")]
HRESULT Item([in] long lIndex,
[out, retval] VARIANT *pVal);
[id(2), helpstring("method AddFlavortoMenu")]
HRESULT AddFlavortoMenu([in] BSTR bstrNewFlavor);
};
Count
、_NewEnum
和 Item
属性是每个 COM 集合都支持的标准属性。_NewEnum
属性允许您使用 VB 中的 For Each .. Next
等构造进行迭代,并且始终分配 DISPID DISPID_NEWENUM
(-4) 以表示它是集合的枚举器。此属性通常返回实现 IEnumVariant
接口的对象的 IUnknown
接口指针。IEnumVariant
接口提供了您(例如 Next
、Skip
、Reset
、Clone
)枚举包含VARIANT的集合所需的所有方法。Item
属性被分配 DISPID DISPID_VALUE
(0) 以表示如果省略属性名称,则这是默认属性。Item
属性允许您使用索引在集合中定位项。索引本身可以是基于您的业务模型的任何类型。例如,Book Collection 可以提供字符串形式的 ISBN 号作为其 Item 索引,该索引可以作为 STL 映射中的键值来定位相应的书籍。在冰淇淋菜单示例中,我们使用类型为 long
的索引来定位指定索引处的冰淇淋。此外,由于我们的集合是基于STLvector建模的,因此类型为long的索引可以方便地访问vector中的特定元素。我们的 IceCreamMenu
集合组件派生的基类 ICollectionOnSTLImpl<>
为类型为 long
的索引提供了 Item
的默认实现。Count
属性返回集合中的元素数量。这三个都是只读属性。除了这些属性,您还可以添加任意数量的帮助方法来添加、删除和更新集合中的元素。查看使用 ATL 编写的 IceCreamMenu
集合组件的代码。
....... //forward definition class _CopyPolicyIceCream; // Define an STL vector to hold all the Icecream flavors typedef vector<_bstr_t> ICECREAM_MENU_VECTOR; // Define a COM Enumerator based on our ICECREAM_MENU_VECTOR typedef CComEnumOnSTL< IEnumVARIANT, &IID_IEnumVARIANT, VARIANT, _CopyPolicyIceCream, ICECREAM_MENU_VECTOR > VarEnum; // Collection Class Helper for STL based containers typedef ICollectionOnSTLImpl< IIceCreamMenu, ICECREAM_MENU_VECTOR, VARIANT, _CopyPolicyIceCream, VarEnum > IceCreamCollectionImpl; // Simulate Deep copy semantics for the elements in our collection class _CopyPolicyIceCream { public: static HRESULT copy(VARIANT* pVarDest,_bstr_t* bstrIceCreamFlavor) { // Assign to a CComVariant CComVariant varFlavor((TCHAR *)(*bstrIceCreamFlavor)); // Perform a deep copy return ::VariantCopy(pVarDest,&varFlavor); } static void init(VARIANT* pVar) { pVar->vt = VT_EMPTY; } static void destroy(VARIANT* pVar) { VariantClear(pVar); } }; // Begin IceCreamMenu Class class ATL_NO_VTABLE CIceCreamMenu : public CComObjectRootEx< CComSingleThreadModel >, public CComCoClass< CIceCreamMenu, &CLSID_IceCreamMenu >, public ISupportErrorInfo, public IDispatchImpl< IceCreamCollectionImpl, &IID_IIceCreamMenu, &LIBID_ICECREAMPARLORLib, 1, 0 > { public: ........... // IIceCreamMenu public: STDMETHOD(AddFlavortoMenu)(/*[in]*/ BSTR bstrNewFlavor); // These three methods are not required because the // base class ICollectionOnSTLImpl<> provides us with a default // implementation. // STDMETHOD(get_Item)(/*[in]*/ VARIANT Index, // /*[out, retval]*/ VARIANT *pVal); // STDMETHOD(get__NewEnum)(/*[out, retval]*/ LPUNKNOWN *pVal); // STDMETHOD(get_Count)(/*[out, retval]*/ long *pVal); };
CIceCreamMenu
类扩展的 ICollectionOnSTLImpl<>
类为 Item
、Count
和 _NewEnum
集合属性提供了默认实现。它表示的基础集合类型(在我们的例子中,是包含 _bstr_t
字符串的vector)由 m_coll
实例表示。要向集合添加项,只需用集合中的元素填充 m_coll
。这就是 FinalConstruct
尝试通过将一些冰淇淋口味添加到由 m_coll
表示的 vector< _bstr_t >
中来完成的操作。
....... HRESULT CIceCreamMenu::FinalConstruct() { // Fill up the menu with some flavors m_coll.push_back(_bstr_t(_T("Chocolate Almond Fudge"))); m_coll.push_back(_bstr_t(_T("Peach Melba"))); m_coll.push_back(_bstr_t(_T("Black Currant"))); m_coll.push_back(_bstr_t(_T("Strawberry"))); m_coll.push_back(_bstr_t(_T("Butterscotch"))); m_coll.push_back(_bstr_t(_T("Mint Chocolate Chip"))); return S_OK; } STDMETHODIMP CIceCreamMenu::AddFlavortoMenu(BSTR bstrNewFlavor) { m_coll.push_back(_bstr_t(bstrNewFlavor)); return S_OK; }
使用 VB 创建 COM 集合组件
为了 VB 用户的便利,这里是 VB 中 IceCreamMenu
COM 集合类的等效实现。确保将 NewEnum
函数标记为 DISPID -4(DISPID_NEWENUM
)。您可以使用 VB IDE 中的Tools->Procedure Attributes对话框执行此操作。您需要为 NewEnum 设置Procedure ID为 -4,并确保启用Hide this member属性复选框。
Option Explicit
Private mIceCreamFlavors As Collection
Private Sub Class_Initialize()
Set mIceCreamFlavors = New Collection
mIceCreamFlavors.Add "Chocolate Almond Fudge"
mIceCreamFlavors.Add "Peach Melba"
mIceCreamFlavors.Add "Black Currant"
mIceCreamFlavors.Add "Strawberry"
mIceCreamFlavors.Add "Butterscotch"
mIceCreamFlavors.Add "Mint Chocolate Chip"
End Sub
Public Function Count() As Integer
Count = mIceCreamFlavors.Count
End Function
Public Function Item(varIndex As Variant) As String
Item = mIceCreamFlavors(varIndex)
End Function
Public Function NewEnum() As IEnumVARIANT
Set NewEnum = mIceCreamFlavors.[_NewEnum]
End Function
Public Function AddFlavortoMenu(strNewFlavor As String)
mIceCreamFlavors.Add strNewFlavor
End Function
在 .NET 应用程序中消费 COM 集合
要让 .NET 应用程序消费我们上节中编写的集合 COM 组件,我们需要从组件的类型库生成 .NET 元数据代理。您可以从命令行使用以下命令执行此操作
tlbimp IceCreamParlor.tlb /out:IceCreamMenuMetadata.dll
现在使用IL 反汇编器 (ILDASM) 打开 IceCreamMenuMetadata.dll 并查看为 IceCreamMenu
类生成的成员。
TLBIMP 为 IceCreamMenu 集合组件生成的元数据代理
IceCreamMenu
类实现了两个接口:IIceCreamMenu
接口和 System.Collections.IEnumerable
接口。实现 IEnumerable
接口告诉消费者该类允许您迭代其集合中的元素。元数据代理对象中的 IIceCreamMenu
接口保留了来自 COM 组件的 IIceCreamMenu
接口的 Count
和 Item
属性。但是 TLBIMP 对代表我们集合的枚举器的 _NewEnum
属性做了什么?它已被 GetEnumerator()
方法替换,该方法返回处理实际枚举的对象 IEnumerator
接口。
在 .NET 应用程序中消费 COM 集合
由于 IceCreamMenu
类实现了 IEnumerable
接口,您可以使用非常简单的构造,如 C# 的 foreach
语句来迭代此类集合中的元素。这是您如何从 .NET 应用程序中消费 IceCreamMenu
集合 COM 组件。
using System;
using IceCreamMenuMetadata;
public class IceCreamMenuClient
{
public static void Main(String[] args)
{
// Create an instance of the Collection class
IceCreamMenu menu = new IceCreamMenu();
// Add a few more flavors to the Menu
menu.AddFlavortoMenu("Blueberry");
menu.AddFlavortoMenu("Chocolate Chip");
// Use the foreach statement to iterate through
// elements in the collection
foreach(Object objFlavor in menu)
{
System.Console.WriteLine("{0}",objFlavor);
}
}/* end Main */
}/* end class IceCreamMenuClient */
您可以使用以下命令行编译上述代码
csc /target:exe /r:IceCreamMenuMetadata.dll
/out:IceCreamMenuClient.exe IceCreamMenuClient.cs
这是您获得的输出
Chocolate Almond Fudge
Peach Melba
Black Currant
Strawberry
Butterscotch
Mint Chocolate Chip
Blueberry
Chocolate Chip
看看使用 C# 的 foreach
构造迭代集合中的元素有多么容易。同样,RCW 在后台驱动枚举,通过将基于 IEnumVARIANT
的 COM 集合语义转换为可以由基于 IEnumerator
的方法提供的表示,并使我们免于所有封送处理的繁琐工作。
枚举 .NET 集合中的元素
using System;
using System.Collections;
public class SevenDwarfs : IEnumerable , IEnumerator
{
private int nCurrentPos = -1;
private string[] strArrayDwarfs =
new String[7] {"Doc", "Dopey", "Happy", "Grumpy",
"Sleepy", "Sneezy" , "Bashful"};
SevenDwarfs() {}
// Method : IEnumerable.GetEnumerator
// Return an appropriate Enumerator for the collection
public IEnumerator GetEnumerator()
{
return (IEnumerator)this;
}
// Method : IEnumerator.MoveNext
// Move the enumerator to the next element in the collection
// and return boolean status whether we still have elements to
// enumerate
public bool MoveNext()
{
if(nCurrentPos < strArrayDwarfs.Length - 1)
{
nCurrentPos++;
return true;
}
else
{
return false;
}
}
// Method : IEnumerator.Reset
// Reset the enumerator to the beginning of the collection
public void Reset()
{
nCurrentPos = -1;
}
// Method : IEnumerator.Current
// Return the element at the current enumerator position
public object Current
{
get
{
return strArrayDwarfs[nCurrentPos];
}
}
public static void Main(String[] args)
{
// Create an instance of the SevenDwarfs object
SevenDwarfs SnowWhitesDwarfs = new SevenDwarfs();
// Enumerate through the Collection
foreach(string dwarf in SnowWhitesDwarfs)
{
System.Console.WriteLine("{0}",dwarf);
}
}/* end Main */
}/* end class SevenDwarfs */
您可以使用以下命令行编译上述代码 csc /target:exe /out:SevenDwarfs.exe SevenDwarfs.cs
这是您获得的输出 Doc
Dopey
Happy
Grumpy
Sleepy
Sneezy
Bashful |
将 C# 中的方法参数关键字映射到 IDL 的 Directional 属性
互操作在映射 C# 中的方法参数关键字(如 out、ref)到其相应的方向属性(如 [in]
、[out]
、[in,out]
、[out,retval]
)以及反之亦然时,有一些规则。
- 当 C# 中方法参数未被关键字限定时,它通常会映射到 IDL 中的[in]属性,假设是按值传递语义。
- C# 方法的返回值始终映射到 IDL 中的
[out, retval]
方向属性。 ref
方法参数关键字会映射到 IDL 中的[in,out]
方向属性。out
方法参数关键字会映射到 IDL 中的[out]
方向属性。- .NET 世界中发生的错误不是通过方法的返回值返回的。而是作为异常抛出。
在此处阅读更多关于错误处理的信息。以下是一些 C# 参数类型如何映射到 IDL 中的方向属性的示例
C# 方法 |
IDL 等效 |
C# 中的调用约定 |
public void Method(String strInput); |
HRESULT Method([in] BSTR strInput); |
obj.Method("Hello There"); |
public String Method(); |
HRESULT Method([out, retval] BSTR* pRetVal); |
String strOutput = obj.Method(); |
public String Method(ref String strPassAndModify); |
HRESULT Method([in, out] BSTR* strPassAndModify, [out, retval] BSTR* pRetVal); |
String strHello = "Hello There"; |
public String Method(out String strReturn); |
HRESULT Method([out] BSTR* strReturn, [out, retval] BSTR* pRetVal); |
//无需初始化 strHello |
public String Method(String strFirst, out String strSecond, ref String strThird); |
HRESULT Method([in] BSTR bstrFirst, [out] BSTR* strSecond, [in, out] BSTR* strThird, [out, retval] BSTR* pRetVal); |
String strFirst = "Hi There"; |
在托管代码中重用经典 COM 组件
互操作的一个优点是,您可以让您的托管 .NET 类使用继承或包含模型来重用现有 COM 组件提供的功能。这很棒,因为消费托管 .NET 组件的 .NET 应用程序永远不会知道该托管组件实际上是在内部利用经典 COM 组件的非托管代码实现。我们将看看托管 .NET 组件如何重用现有 COM 组件的几种方式。
我们称之为混合模式,因为我们的托管类重用了非托管 COM 组件中已有的代码和功能逻辑。
经典 COM 中的重用机制经典 COM 从未接受实现继承的理念,而只遵守接口继承。COM 中传统的重用机制是使用包含和聚合。 通过经典 COM 中的包含进行组件重用 为了刷新您的记忆,包含允许您公开一个外部组件,该组件完全包含内部组件。客户端只能看到外部组件的接口。外部组件的接口公开的方法通常自己处理实现,或在需要时将工作委托给内部组件。外部组件创建一个内部组件的实例,并在需要利用内部组件公开的某些功能时将调用转发给内部组件。从客户端的角度来看,它永远不知道有一个内部组件被外部组件屏蔽并为外部组件执行工作。 通过经典 COM 中的聚合进行组件重用 在聚合的情况下,外部对象不再将调用转发给内部组件。相反,它通过将内部组件的接口指针交给客户端,允许客户端直接与内部组件进行交互。外部组件不再能够拦截内部组件接口上的方法调用,因为客户端直接与内部组件交互。如果内部组件未被聚合,则使用默认的 |
让我们看看您可以通过哪些各种方式从 .NET 类重用现有 COM 组件中的非托管代码。
通过混合模式继承进行重用
在此重用模型中,您可以让您的托管 .NET 类扩展/继承一个非托管 COM coclass。此外,托管类可以选择覆盖 COM coclass 接口中的方法,或接受基 COM coclass 的实现。这是一个非常强大的模型,您可以在同一类中混合托管和非托管实现。
从托管类继承非托管代码
让我们看下面的代码片段以更好地理解这一点。我们将使用 ATL 创建一个名为 Flyer
的 COM 对象,该对象支持一个名为 IFlyer
的接口,其中有两个方法:TakeOff()
和 Fly()
。这是组件的 IDL 声明
[ .... ] interface IFlyer : IDispatch { [id(1), helpstring("method TakeOff")] HRESULT TakeOff([out,retval] BSTR* bstrTakeOffStatus); [id(2), helpstring("method Fly")] HRESULT Fly([out,retval] BSTR* bstrFlightStatus); }; [ ..... ] coclass Flyer { [default] interface IFlyer; };
这是两个方法的实现
STDMETHODIMP CFlyer::TakeOff(BSTR *bstrTakeOffStatus) { *bstrTakeOffStatus = _bstr_t(_T("CFlyer::TakeOff - This is COM taking off")).copy(); return S_OK; } STDMETHODIMP CFlyer::Fly(BSTR *bstrFlyStatus) { *bstrFlyStatus = _bstr_t(_T("CFlyer::Fly - This is COM in the skies")).copy(); return S_OK; }
在此组件可以被托管代码消费之前,您必须从其类型库生成此组件的元数据代理。为此,您需要从命令行发出以下命令
tlbimp MyFlyer.tlb /out:MyFlyerMetadata.dll
我们现在将创建继承自此组件的托管类,使用常规的继承语义,以便重用组件的功能。其中一个托管类 Bird
继承自 Flyer
COM 对象的元数据类型。这意味着它将继承 Flyer COM 组件的所有方法。另一个托管类 Airplane
使用自己的实现覆盖了 TakeOff
和 Fly
方法。有一个需要注意的地方是,您不能选择性地只覆盖托管代码中 COM coclass 的特定方法。如果您决定在托管代码中覆盖 COM coclass 的单个方法,您将不得不覆盖所有其他方法。换句话说,您不能只为 TakeOff
方法提供覆盖的实现,而隐式地让托管类使用 COM 对象中的 Fly
实现。您还必须在托管类中覆盖Fly方法并为其提供实现。如果您需要重用 COM coclass 的实现,您可以从托管类的 Fly
实现中调用 base.Fly
。您可能会在编译时选择性地覆盖特定方法。但在运行时,您最终会遇到 System.TypeLoadException
异常,其错误消息大致如下:“扩展 COM 对象的类型应覆盖基 COM 类实现的接口的所有方法”。
using System;
using MyFlyerMetadata;
// Inherit from the metadata type representing
// the unmanaged COM component
// Use the COM Component's implementation
// of TakeOff & Fly (Use the base class' implementation)
public class Bird : Flyer
{
}/* end class Bird */
// Inherit from the metadata type representing
// the unmanaged COM component
// Override the COM object's method implementations in our
// derived managed-class.
// (Also call base class implementation when
// necessary using base.MethodName())
public class Airplane : Flyer
{
// Override the COM Component's Flyer::TakeOff implementation
// with our own implementation
public override String TakeOff() {
return "Airplane::TakeOff - This is .NET taking off";
}/* end TakeOff */
// Override the COM Component's Flyer::Fly implementation
// with our own implementation
public override String Fly() {
// Can call the base class' implementation too if you
// wish to.
System.Console.WriteLine(base.Fly());
return "Airplane::Fly - This is .NET in the skies";
}/* end Fly */
}/* end class Airplane */
public class FlightController
{
public static void Main(String[] args)
{
Bird falcon = new Bird();
System.Console.WriteLine("BIRD: CLEARED TO TAKE OFF");
System.Console.WriteLine(falcon.TakeOff());
System.Console.WriteLine(falcon.Fly());
Airplane skyliner = new Airplane();
System.Console.WriteLine("AIRPLANE: CLEARED TO TAKE OFF");
System.Console.WriteLine(skyliner.TakeOff());
System.Console.WriteLine(skyliner.Fly());
}/* end Main */
}/* end FlightController */
您可以使用 DOS 命令行中的以下命令编译上述程序
csc /target:exe /out:FlightClient.exe /r:MyFlyerMetadata.dll FlightClient.cs
运行上述程序,会得到以下输出
BIRD: CLEARED TO TAKE OFF
CFlyer::TakeOff - This is COM taking off
CFlyer::Fly - This is COM in the skies
AIRPLANE: CLEARED TO TAKE OFF
Airplane::TakeOff - This is .NET taking off
CFlyer::Fly - This is COM in the skies
Airplane::Fly - This is .NET in the skies
Bird 和 Airplane 托管类的消费者无需知道这些类实际上是通过继承重用现有 COM 组件的。在必要时,托管类会用自己的实现覆盖 COM 组件中的所有方法。这种托管代码继承非托管代码的重用模型称为混合模式继承重用模型。
通过混合模式包含进行重用
在此模型中,托管类使用与经典 COM 中包含相同的原理。它所做的只是将代表非托管 COM 组件的元数据代理类的实例存储为一个成员。每当需要 COM 组件的服务时,它就会将请求转发给组件的方法。
通过包含/组合重用非托管 COM 代码
托管类能够在其自己的代码之前和之后注入代码,然后在调用包含的 COM 组件。这是一个例子
using System;
using MyFlyerMetadata;
.....
// Contains an instance of the metadata type representing
// the unmanaged COM Component
public class HangGlider
{
private Flyer flyer = new Flyer();
// Forward the call to the contained class' implementation
public String TakeOff()
{
return flyer.TakeOff();
}
// Forward the call to the contained class' implementation
public String Fly()
{
// Do what you need to do before or after fowarding the
// call to flyer
System.Console.WriteLine("In HangGlider::Fly - " +
"Before delegating to flyer.Fly");
return flyer.Fly();
}
}/* end class HangGlider */
public class FlightController
{
public static void Main(String[] args)
{
....
HangGlider glider = new HangGlider();
System.Console.WriteLine("HANGGLIDER: CLEARED TO TAKEOFF");
System.Console.WriteLine(glider.TakeOff());
System.Console.WriteLine(glider.Fly());
}/* end Main */
}/* end FlightController */
这是上面程序的输出
HANGGLIDER: CLEARED TO TAKEOFF
CFlyer::TakeOff - This is COM taking off
In HangGlider::Fly - Before delegating to flyer.Fly
CFlyer::Fly - This is COM in the skies
在上面的示例中,HangGlider
类创建了 Flyer
COM 组件的实例并将其存储为私有成员。每当收到需要 Flyer
组件服务的调用时,它都会通过之前存储的私有实例调用该组件。此外,HangGlider
类可以自由地在委托调用到 Flyer
组件的方法之前和之后注入代码。在混合模式继承重用模型中,除非您在托管类中覆盖所有基类 COM 方法,否则这是不可能实现的。
从 .NET 应用程序的角度理解 COM 线程模型和 Apartment
我记得当我刚开始用 COM 编程时,我还没有涉足 COM 线程模型和 Apartment 的复杂领域,对它们到底是什么知之甚少。我认为我的对象是自由线程的,并且简单地认为这将是性能最好的线程模型。我很少意识到幕后发生了什么。我从未意识到STA客户端线程创建我的MTA对象时会带来性能损失。而且,由于我的对象不是线程安全的,我从未意识到当并发线程访问我的对象时会遇到麻烦。当时,对 COM 线程模型的无知确实是一种幸福。然而,这种幸福是短暂的,我的服务器开始意外崩溃。那时,我被迫涉足 COM 线程模型领域,并了解每种模型如何工作,COM 如何管理 Apartment,以及在不兼容的 Apartment 之间调用时会产生哪些性能影响。如您所知,在线程可以调用 COM 对象之前,它必须声明其对 Apartment 的归属,声明它将进入STA还是MTA。STA客户端线程调用 CoInitialize(NULL)
或 CoInitializeEx(0, COINIT_APARTMENTTHREADED)
进入STA Apartment,而MTA线程调用 CoInitializeEx(0, COINIT_MULTITHREADED)
进入MTA。同样,在 .NET 托管世界中,您可以选择让托管空间中的调用线程声明其 Apartment 亲和性。默认情况下,托管应用程序中的调用线程选择驻留在MTA中。这就像调用线程使用 CoInitializeEx(0, COINIT_MULTITHREADED)
初始化自己。但是,考虑一下调用为公寓线程设计的经典STA COM 组件时可能带来的开销和性能损失。不兼容的 Apartment 会产生额外的代理/存根对开销,这当然会带来性能损失。您可以通过使用 System.Threading.Thread
类的 ApartmentState
属性来覆盖 .NET 应用程序中托管线程的默认 Apartment 选择。ApartmentState
属性接受以下任一枚举值:MTA, STA和Unknown。ApartmentState.Unknown
等同于默认的MTA行为。您需要在进行任何 COM 对象调用之前为调用线程指定 ApartmentState
。一旦创建了 COM 对象,就不可能更改 ApartmentState
。因此,在代码中尽早设置线程的 ApartmentState
是有意义的。
// Set the client thread's ApartmentState to enter an STA
Thread.CurrentThread.ApartmentState = ApartmentSTate.STA;
// Create our COM object through the Interop
MySTA objSTA = new MySTA();
objSTA.MyMethod()
作为一种替代方法,您可以在托管客户端的Main入口点方法上标记 STAThreadAttribute
或 MTAThreadAttribute
,以便使用所需的线程归属来启动它以消费 COM 组件。例如,请看下面的代码片段
public class HelloThreadingModelApp {
.....
[STAThread]
static public void Main(String[] args) {
System.Console.WriteLine("The apartment state is: {0}",
Thread.CurrentThread.ApartmentState.ToString());
}/* end Main */
}/* end class */
您将获得的输出将如下所示
The apartment state is: STA
如果设置了 MTAThread
属性,则 ApartmentState
将设置为MTA。如果客户端的 Main 入口点未指定线程状态属性,或者从中创建 COM 组件的线程未设置 ApartmentState
属性,则 ApartState
将为Unknown,默认情况下为 MTA 行为。
第二部分:从 COM 感知客户端消费 .NET 组件
在本节中,我们将学习如何从非托管 COM 感知客户端消费托管 .NET 组件。将.NET 组件的客户端限制为仅托管客户端对于许多多年来开发无法一夜之间移植到托管代码的应用程序的开发人员来说,将是难以接受的。 .NET 框架允许不同平台上的不同应用程序通过 SOAP 等SOAP等线协议与托管应用程序通信。非托管 COM 感知客户端仍然有更简单的方式与托管组件通信。 .NET 运行时允许非托管 COM 感知客户端通过 COM 互操作以及框架提供的工具无缝访问 .NET 组件。这确保了 COM 感知客户端可以与 .NET 组件通信,就像它们与普通 COM 组件通信一样。
创建 .NET 组件
首先,让我们构建一个简单的 .NET 组件,该组件允许您查找您所在城市的温度。只有公共类才会被添加到类型库并暴露给 COM 感知客户端。此外,如果类需要可以从 COM 感知客户端创建,则它必须有一个公共默认构造函数。一个公共类,它没有公共默认构造函数,仍然会出现在类型库中,尽管它不能直接从 COM 共同创建。Temperature 组件有两个方法 DisplayCurrentTemperature
和 GetWeatherIndications
。它有一个名为 Temperature 的公共读写属性,定义了相应的get/set方法。
using System;
using System.Windows.Forms;
using System.Runtime.InteropServices;
public enum WeatherIndications
{
Sunny = 0,
Cloudy,
Rainy,
Snowy
}
[ClassInterface(ClassInterfaceType.AutoDual)]
public class TemperatureComponent
{
private float m_fTemperature = 0;
// Public Constructor
public TemperatureComponent()
{
m_fTemperature = 30.0f;
}
//Public Property Accessors (Defines get/set methods)
public float Temperature
{
get { return m_fTemperature; }
set { m_fTemperature = value;}
}/* end Temperature get/set property */
// Public Method that displays the Current Temperature
public void DisplayCurrentTemperature()
{
String strTemp = String.Format("The current " +
"temperature at Marlinspike is : " +
"{0:####} degrees fahrenheit",
m_fTemperature);
MessageBox.Show(strTemp,"Today's Temperature");
}/* end DisplayCurrentTemperature */
// Another public method that returns an enumerated type
public WeatherIndications GetWeatherIndications()
{
if(m_fTemperature > 70) {
return WeatherIndications.Sunny;
}
else {
// Let's keep this simple and just return Cloudy
return WeatherIndications.Cloudy;
}
}/* end GetWeatherIndications */
}/* end class Temperature */
您还会注意到有一个名为 ClassInterface
的属性标记在 Temperature 类上,其值设置为 ClassInterfaceType.AutoDual
。我们将在窥探生成的类型库部分了解应用此属性的意义。现在,可以将其视为一种告诉类型库生成工具(如 REGASM.EXE 和 TLBEXP.EXE)将 .NET 组件类的公共成员导出到生成的类型库中的默认类接口的方法。同时请记住,使用类接口公开 .NET 类的公共方法通常不推荐,因为它不利于 COM 版本管理。我们将了解如何显式使用接口来实现相同的功能。显式定义接口,让您的 .NET 组件类派生自该接口,然后在 .NET 组件中实现该接口的方法,这是将 .NET 组件公开给 COM 感知客户端的推荐方法。我们将在窥探生成的类型库部分详细比较和对比这两种方法,并解释为什么前者不推荐。
如果您正在使用Visual Studio.NET,您可以创建一个Visual C# 项目并使用类库模板来编写上述组件。如果您是命令行高手,那么构建组件的命令是这样的。这将创建一个名为 Temperature.dll 的程序集。
csc /target:library /r:System.Windows.Forms.dll
/out:Temperature.dll TemperatureComponent.cs
从程序集中生成类型库并注册程序集
您刚刚生成的现在是一个 .NET 程序集,COM 感知客户端(如 Visual Basic 6.0)对此一无所知。您需要从中获取一些 COM 友好的类型信息,以便我们的 VB 客户端能够愉快地与之交互。之前,您使用了一个名为 TLBIMP(类型库导入器)的工具从 COM 类型库创建 .NET 元数据代理。您在这里需要做相反的事情。您需要采用 .NET 程序集并从中生成类型库,以便可以从 COM 感知客户端使用它。 .NET 框架提供了几个这样的工具。您可以使用类型库导出实用程序 (TLBEXP.exe) 或程序集注册实用程序 (Regasm.exe),它们都可以在您的 .NET SDK 安装的Bin目录中找到。REGASM是TLBEXP实用程序的超集,因为它还做了比生成类型库更多的工作。它还用于注册程序集,以便进行适当的注册表项以方便 COM 运行时和 .NET 运行时将 COM 感知客户端连接到 .NET 组件。我们将使用REGASM.EXE,因为我们可以一次完成程序集注册和类型库生成。但您也可以使用TLBEXP来生成类型库,然后使用REGASM来注册程序集。
regasm Temperature.dll /tlb:Temperature.tlb
对 REGASM.EXE 的上述调用进行了适当的注册表项设置,并从 .NET 程序集中生成了类型库(Temperature.tlb),以便可以从我们的 VB 6 客户端应用程序中引用该类型库。
从 VB 6.0 客户端消费组件
让我们快速构建一个基于 VB 窗体的应用程序,该应用程序创建并调用 .NET 组件,我们已经注册了其程序集并生成了类型库。创建组件与创建 COM 对象的方式相同。您可以引用类型库并早期绑定到组件,或者使用组件的ProgID执行 CreateObject
调用以后期绑定到组件。通常,生成的ProgID是类的完全限定名。在本例中,生成的 ProgID 将是 TemperatureComponent
。但是,您可以使用ProgIDAttribute指定用户定义的 ProgID 来覆盖默认生成的ProgID。
Private Sub MyButton_Click()
On Error GoTo ErrHandler
' Create an instance of the temperature component
Dim objTemperature As New TemperatureComponent
' Display the current temperature
objTemperature.DisplayCurrentTemperature
' Set the temperature property
objTemperature.Temperature = 52.7
' Display the current temperature after property mutation
objTemperature.DisplayCurrentTemperature
' Check the weather indications
If (objTemperature.GetWeatherIndications() = _
WeatherIndications_Sunny) Then
MsgBox "Off to the beach"
Else
MsgBox "Stay at home and watch Godzilla on TV"
End If
Exit Sub
ErrHandler:
MsgBox "Error Message : " & Err.Description, _
vbOKOnly, "Error Code " & CStr(Err.Number)
End Sub
为了使 .NET 程序集解析器能够找到包含您的组件的程序集,您需要将组件放置在与正在使用它的应用程序相同的目录中,或者将程序集作为共享程序集部署到全局程序集缓存 (GAC)。目前,只需将 Temperature.dll 复制到您的 VB 客户端应用程序可执行文件相同的目录中。如果 VB 可以使用普通的基于 COM 的调用机制,并且仍然能够调用和消费 .NET 组件,那么在 VB6 客户端和 .NET 组件之间必须有一个好心的好人,负责将 COM 调用请求连接到实际的 .NET 组件。我们很快就会看到,幕后发生了什么。
深入了解 COM 互操作的魔力
让我们快速查看一下 Regasm.exe 在我们注册程序集时所做的注册表项。
REGASM 在程序集注册期间创建的注册表项
您可以通过打开 REGASM 生成的类型库的 OLEVIEW.EXE 来检查您的组件的CLSID。查看coclass部分下的uuid属性。如果您导航到注册表中的 HKCR\CLSID\{...Component's CLSID...}
键,您可以看到 REGASM 已创建 COM 激活由 Inproc 服务器托管的对象所需的注册表项。此外,它还创建了一些其他注册表项,如 Class、Assembly 和 RuntimeVersion,这些由 .NET 运行时使用。Inproc Server 处理程序(由 InProcServer32 键的默认值指示)设置为 mscoree.dll,这是核心CLR 运行时执行引擎。COM 运行时调用 MSCOREE.dll(CLR 运行时)中的 DllGetClassObject 入口点。然后,运行时使用传递给 DllGetClassObject
的类 ID (CLSID) 来查找 InProcServer32 键下的Assembly和Class键,以加载和解析将服务此请求的 .NET 程序集。运行时还会动态创建一个COM 可调用包装器 (CCW) 代理(RCW 的镜像),以处理非托管代码和托管组件之间的交互。这使得 COM 感知客户端认为它们正在与经典 COM 组件交互,并使 .NET 组件认为它们正在接收来自托管应用程序的请求。每个 .NET 组件实例都会创建一个 CCW。
幕后:从 COM 感知客户端访问 .NET 组件
俗话说,“一图胜千言”,所以让这里的插图来讲述 COM 感知客户端与 .NET 组件交互时幕后发生的大部分事情。这里的关键参与者是CLR 运行时和由 .NET 运行时创建的COM 可调用包装器 (CCW)。从那时起,CCW 就开始处理大部分工作,以使两者协同工作。CCW 在这里处理生存期管理问题。非托管领域的 COM 客户端在 CCW 代理上维护引用计数,而不是在实际 .NET 组件上。CCW 只持有 .NET 组件的引用。 .NET 组件遵循 CLR 垃圾回收器的规则,就像其他任何托管类型一样。CCW 存在于非托管堆中,并在 COM 感知客户端不再拥有该对象的任何未决引用时被销毁。就像 RCW 一样,CCW 也负责封送在非托管客户端和托管 .NET 组件之间来回传递的方法调用参数。它还负责按需合成 v-table。特定接口的 v-table 是动态生成的。它们仅在 COM 感知客户端通过 QueryInterface
调用实际请求特定接口时才被懒惰地构建。对 CCW 代理的调用最终会被路由到一个存根,该存根实际上会调用到托管对象。
窥探生成的类型库
让我们快速看一下程序集注册实用程序 (REGASM) 生成的类型库中包含什么样的信息。通过OLEVIEW的类型库查看器打开类型库,以便您可以查看从类型库反向工程的 IDL 文件。
// Generated .IDL file (by the OLE/COM Object Viewer) // typelib filename: Temperature.tlb [ uuid(A9F20157-FDFE-36D6-90C3-BFCD3C8C8442), version(1.0) ] library Temperature { // TLib : Common Language Runtime Library : // {BED7F4EA-1A96-11D2-8F08-00A0C9A6186D} importlib("mscorlib.tlb"); // TLib : OLE Automation : // {00020430-0000-0000-C000-000000000046} importlib("STDOLE2.TLB"); // Forward declare all types defined in this typelib interface _TemperatureComponent; typedef [uuid(0820402E-B8B6-330F-8D56-FF079E5B4659), version(1.0), custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "WeatherIndications")] enum { WeatherIndications_Sunny = 0, WeatherIndications_Cloudy = 1, WeatherIndications_Rainy = 2, WeatherIndications_Snowy = 3 } WeatherIndications; [ uuid(01FAD74C-3DC4-3DE0-86A9-8490FAEE8964), version(1.0), custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "TemperatureComponent") ] coclass TemperatureComponent { [default] interface _TemperatureComponent; interface _Object; }; [ odl, uuid(C51D54FA-7C81-35A5-9998-3963EAB4AA12), hidden, dual, nonextensible, oleautomation, custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "TemperatureComponent") ] interface _TemperatureComponent : IDispatch { [id(00000000), propget] HRESULT ToString([out, retval] BSTR* pRetVal); [id(0x60020001)] HRESULT Equals([in] VARIANT obj, [out, retval] VARIANT_BOOL* pRetVal); [id(0x60020002)] HRESULT GetHashCode([out, retval] long* pRetVal); [id(0x60020003)] HRESULT GetType([out, retval] _Type** pRetVal); [id(0x60020004), propget] HRESULT Temperature([out, retval] single* pRetVal); [id(0x60020004), propput] HRESULT Temperature([in] single pRetVal); [id(0x60020006)] HRESULT DisplayCurrentTemperature(); [id(0x60020007)] HRESULT GetWeatherIndications([out, retval] WeatherIndications* pRetVal); }; };
如果您查看 coclass
部分,它将默认接口指定为类名的前缀是_(下划线)字符。此接口称为类接口,其方法由类的所有非静态公共方法、字段和属性组成。类接口的生成是因为您将 .NET 类标记为 ClassInterface
属性。此属性告诉类型库生成工具(如 RegAsm.exe 和 TlbExp.exe)生成一个称为类接口的默认接口,并将类所有公共方法、字段和属性添加到其中,以便它可以暴露给 COM 感知客户端。
|
只有公共方法在类型库中可见,并且可以被 COM 客户端使用。私有成员不会进入类型库,并对 COM 客户端隐藏。类的公共属性和字段被转换为 IDL propget
/propput
类型。我们示例中的 Temperature
属性同时定义了set和get访问器,因此,为此属性都发出了 propset
和 propget
。还有一个名为 _Object
的接口被添加到 coclass
中。类接口还包含 4 个其他方法。它们是
ToString
Equals
GetHashCode
GetType
这些方法被添加到默认的类接口中,因为它隐式继承自 System.Object
类。添加到接口的每个方法和属性都会获得一个自动生成的DISPID。您可以使用 DispId 属性用用户定义的 DISPID 覆盖此 DISPID。您会注意到 ToString
方法被分配了 DISPID 0,表示它是类接口中的默认方法。这意味着如果您省略方法名称,将调用 ToString
方法。
' Create an instance of the temperature component
Dim objTemperature As New TemperatureComponent
' Invoke the ToString method (the default method)
MsgBox objTemperature
让我们检查一下我们可以用来促进隐式类接口生成的各种方法。我们将从查看将 ClassInterfaceType.AutoDual
值应用于 ClassInterface
属性的效果开始。
[ClassInterface(ClassInterfaceType.AutoDual)]
public class TemperatureComponent
{
....
}
请注意,分配给 ClassInterface
属性的值(位置参数值)是 ClassInterfaceType.AutoDual
。此选项告诉类型库生成工具(如 RegAsm.exe)将 Class Interface 生成为 dual 接口,并将所有 type information(关于方法、属性等及其相应的 Dispatch IDs)导出到类型库。想象一下,如果您决定要为该类添加另一个公共方法,会发生什么情况。这会改变生成的 Class Interface,并破坏 COM 中基本的 interface immutability 法则,因为 v-table 的结构现在发生了变化。Late Bound 客户端在尝试使用该组件时也会遇到麻烦。由于添加了新方法,Dispatch IDs (DISPIDs) 会重新生成,这也破坏了 late bound 客户端。一般来说,使用 ClassInterfaceType.AutoDual
是非常糟糕的做法,因为它完全不考虑 COM 版本控制。让我们看看您可以为 ClassInterface
属性设置的下一个可能值。将 ClassInterface
属性标记为 ClassInterfaceType.AutoDispatch
会强制类型库生成工具避免在类型库中生成 type information。因此,如果您的 TemperatureComponent
类像下面这样被 ClassInterface
属性标记
[ClassInterface(ClassInterfaceType.AutoDispatch)]
public class TemperatureComponent
{
.....
}
那么,RegAsm.exe 生成的相应类型库将具有如下 IDL 结构
// Generated .IDL file (by the OLE/COM Object Viewer) // typelib filename: Temperature.tlb [ uuid(A9F20157-FDFE-36D6-90C3-BFCD3C8C8442), version(1.0) ] library Temperature { ...... [ uuid(01FAD74C-3DC4-3DE0-86A9-8490FAEE8964), version(1.0), custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "TemperatureComponent") ] coclass TemperatureComponent { [default] interface IDispatch; interface _Object; }; };
您会注意到,默认接口是 IDispatch
接口,类型库中既没有 DISPIDs 也没有方法 type information。这使得 COM 感知客户端只能通过 late-binding 来使用 .NET 组件。此外,由于 DISPID 详细信息未作为 type information 存储在类型库中,客户端会通过 IDispatch::GetIDsOfNames
等方式按需获取这些 DISPIDs。这使得客户端在不破坏现有代码的情况下使用组件的新版本。使用 ClassInterfaceType.AutoDispatch
比使用 ClassInterfaceType.AutoDual
安全得多,因为它在发布组件的新版本时不会破坏现有客户端代码,尽管前者只允许 late binding。建议将 .NET 组件建模以供 COM 感知客户端使用的方法是完全去掉 Class Interface,而是显式地将您公开的方法提取到单独的接口中,并让 .NET 组件实现该接口。使用 Class Interface 是将 .NET 组件公开给 COM 感知客户端的一种快速便捷的方式。但这不是推荐的方式。让我们尝试重写我们的 TemperatureComponent
,将其方法显式提取到一个接口中,看看类型库生成有何不同
// Define the ITemperature interface
public interface ITemperature {
float Temperature { get; set; }
void DisplayCurrentTemperature();
WeatherIndications GetWeatherIndications();
}/* end interface ITemperature */
// (1) Implement the ITemperature interface in the TemperatureComponent class
// (2) Set the ClassInterfaceType for the ClassInterface
// atrribute to ClassInterfaceType.None
[ClassInterface(ClassInterfaceType.None)]
public class TemperatureComponent : ITemperature
{
......
//Implement the methods in your class
// Property Accessors (Defines get/set methods)
public float Temperature
{
get { return m_fTemperature; }
set { m_fTemperature = value;}
}/* end Temperature get/set property */
// Displays the Current Temperature
public void DisplayCurrentTemperature() {
.....
}/* end DisplayCurrentTemperature */
// Returns an enumerated type indicating weather condition
public WeatherIndications GetWeatherIndications() {
....
}/* end GetWeatherIndications */
}/* end class Temperature */
生成的类型库的相应 IDL 文件如下所示。请注意,TemperatureComponent
类的默认接口现在是该类实现的 ITemperature
接口。
// Generated .IDL file (by the OLE/COM Object Viewer) // typelib filename: Temperature.tlb [ uuid(A9F20157-FDFE-36D6-90C3-BFCD3C8C8442), version(1.0) ] library Temperature { ...... [ odl, uuid(72AA177B-C6B2-3694-B083-4FF535B40AD2), version(1.0), dual, oleautomation, custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "ITemperature") ] interface ITemperature : IDispatch { [id(0x60020000), propget] HRESULT Temperature([out, retval] single* pRetVal); [id(0x60020000), propput] HRESULT Temperature([in] single pRetVal); [id(0x60020002)] HRESULT DisplayCurrentTemperature(); [id(0x60020003)] HRESULT GetWeatherIndications([out, retval] WeatherIndications* pRetVal); }; [ uuid(01FAD74C-3DC4-3DE0-86A9-8490FAEE8964), version(1.0), custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "TemperatureComponent") ] coclass TemperatureComponent { interface _Object; [default] interface ITemperature; }; };
这种将 .NET 类的所有方法显式提取到一个接口中,并让该类继承并实现该接口的方法,是向 COM 感知客户端公开 .NET 组件的推荐方式。ClassInterfaceType.None
选项告诉类型库生成工具您不需要 Class Interface。这可确保 ITemperature 接口是 默认接口。如果您不为 Class Interface 属性指定 ClassInterfaceType.None
值,那么 Class Interface 将成为 默认接口。这是我们在本节中学到的要点
|
另一个有趣的观察是,当 .NET 类或接口中存在重载方法并导出到类型库时,REGASM 和 TLBEXP 如何通过在 IDL 中附加一个 '_' 后跟一个序列号来生成混淆的方法名。例如,如果您公开了 .NET 组件中的以下接口
public interface MyInterface
{
String HelloWorld();
String HelloWorld(int nInput);
}
那么,REGASM/TLBEXP 生成的对应类型库的 IDL 将如下所示。
[ ..... ] interface MyInterface : IDispatch { [id(0x60020000)] HRESULT HelloWorld([out, retval] BSTR* pRetVal); [id(0x60020001)] HRESULT HelloWorld_2([in] long nInput, [out,retval] BSTR* pRetVal); };
请注意,第二个 HelloWorld
方法附加了 '_2',以区别于 IDL 文件中的第一个方法。
使用属性调整生成的类型库元数据
了解 RegAsm.exe 或 TlbExp.exe 工具如何通过内省 .NET 程序集来生成 IDL,并随后生成类型库,可以让您根据自己的需求定制类型库的生成。您可以在程序集中注入 .NET 属性,为这些工具提供线索,以修改 IDL 中的元数据,从而影响创建的类型库。例如,您可以使用 InterfaceTypeAttribute
将接口类型从 dual 更改为仅基于 IUnknown
的自定义接口或 纯 dispinterface。以下是一些注入属性以限定 .NET 组件中类型并根据您的需求修改生成类型库的方法。
更改接口类型
默认情况下,.NET 类使用的接口在 IDL 中转换为 dual 接口。这使客户端能够同时获得 early binding 和 late binding 的优势。但是,有时您可能希望该接口是 pure-dispinterface 或仅基于 IUnknown
的自定义接口。您可以使用 InterfaceTypeAttribute
覆盖接口的默认类型。看下面的例子。
[InterfaceType(ComInterfaceType.InterfaceIsDual)]
public interface ISnowStorm
{
....
}
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IHurricane
{
....
}
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
public interface ITyphoon
{
....
}
public interface IWeatherStatistics
{
.....
}
如上例所示,InterfaceType
属性用于将元数据信息发出到接口,以便像 RegAsm.exe 和 TlbExp.exe 这样的工具可以使用此信息来生成类型库中相应类型的接口。结果 IDL 将如下所示
[ odl, uuid(1423FBFA-BE13-3766-9729-9C1AAF5DB08A), version(1.0), dual, oleautomation, custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "ISnowStorm") ] interface ISnowStorm : IDispatch { .... }; [ odl, uuid(D95E54B8-FABC-3BDA-AA45-AC4EFF49AF92), version(1.0), oleautomation, custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "IHurricane") ] interface IHurricane : IUnknown { .... }; [ uuid(676B3B85-7DB8-306D-A1E9-B6AA1008EDF2), version(1.0), custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "ITyphoon") ] dispinterface ITyphoon { properties: methods: }; [ odl, uuid(A1A37136-341A-3631-9275-FC7B0F0DB695), version(1.0), dual, oleautomation, custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "IWeatherStatistics") ] interface IWeatherStatistics : IDispatch { ..... }
如上所示,将 InterfaceType
属性设置为 ComInterfaceType.InterfaceIsIUnknown
会生成仅基于 IUnknown
的自定义接口。将 InterfaceType
属性设置为 ComInterfaceType.InterfaceIsIDispatch
会生成纯仅 dispatch 的 dispinterface。将 InterfaceType
属性设置为 ComInterfaceType.InterfaceIsDual
或完全忽略 InterfaceType
属性(如示例中的 IWeatherStatistics
接口)会生成 dual 接口。
更改 GUID 和 ProgID
当这些类型导出到类型库时,像类和接口这样的类型的 GUID 会由 REGASM/TLBEXP 工具自动生成。但是,如果您需要为接口或类分配特定的 GUID,您仍然拥有最终决定权。您可以使用 Guid 属性指定用户定义的 GUID。您还可以影响特定类生成的 ProgID 的方式。默认情况下,RegAsm.exe 工具会将类的完全限定类型名称分配给 Progid
。您可以使用 ProgId
属性为您的 coclass 分配用户指定的 ProgID。
[
GuidAttribute("AD4760A9-6F5C-4435-8844-D0BA7C66AC50"),
ProgId("WeatherStation.TornadoTracker")
]
public class TornadoTracker {
.....
}
当 Guid
属性应用于类后,IDL 文件如下所示
[
uuid(AD4760A9-6F5C-4435-8844-D0BA7C66AC50),
version(1.0),
custom({0F21F359-AB84-41E8-9A78-36D110E6D2F9}, "TornadoTracker")
]
coclass TornadoTracker {
....
};
请注意,coclass
部分中的 uuid
属性(代表 COM 类的 CLSID)包含我们指定的 GUID 值。如果您检查 HCKR\CLSID\{AD4760A9-6F5C-4435-8844-D0BA7C66AC50}\ProgID 注册表项,您应该看到值 WeatherStation.TornadoTracker
,代表该类的 ProgID。
隐藏公共类型,不向 COM 公开
您之前看到,类中的所有公共类和接口如何自动添加到类型库中,以便 COM 感知客户端可以引用和使用它们。但有时您可能需要阻止某些公共接口和公共类对 COM 不可用。您可以使用 ComVisible
属性来实现此目的。将此属性设置为false 会阻止应用该属性的类型出现在类型库中。
[ComVisible(false)]
public interface IWeatherStatistics
{
float GetLowestTemperatureThisMonth();
float GetHighestTemperatureThisMonth();
}
在上例中,当程序集导出到类型库时,IWeatherStatistics
接口不会作为 type-information 导出到类型库。如果 IWeatherStatistics
类型被任何其他公开给 COM 的类型使用,它将在类型库中被替换为 IUnknown
接口。例如
HRESULT SetWeatherStatistics([in] IWeatherStatistics* pWeatherIndications);
变成
HRESULT SetWeatherStatistics([in] IUnknown* pUnkWeatherIndications);
不过,这里有一个注意事项。将 [ComVisible(true)]
应用于启用私有或受保护成员的可见性是没有意义的,因为它们永远无法公开给 COM。
ComVisible
属性不仅可以应用于类和接口,还可以应用于其他各种类型,如程序集、方法、字段、属性、委托、结构等。请看下面的示例,其中我们选择性地隐藏了接口中特定方法不向 COM 公开。
public interface IHurricaneWatch {
void AlertWeatherStations(String strDetails);
[ComVisible(false)]
void PlotCoordinates();
void IssueEvacuationOrders();
}
上面的代码片段指示类型库生成器(RegAsm.exe/TlbExp.exe)我们打算隐藏 IHurricaneWatch
接口的 PlotCoordinates
方法,使其不出现在生成的类型库中。以下是生成的类型库的IDL 如下
[ .... ] interface IHurricaneWatch : IDispatch { [id(0x60020000)] HRESULT AlertWeatherStations([in] BSTR strDetails); [id(0x60020002)] HRESULT IssueEvacuationOrders(); };
更改类型的封送处理行为
当运行时将托管类型封送到非托管世界时,它遵循某些数据类型转换规则。例如,托管类型(如 String
)始终转换为 BSTR
。像 String
这样的类型在非托管世界中可能有一个以上的可能表示形式,例如 ANSI 字符数组(LPSTR
)、UNICODE 字符数组(LPWSTR
)或 BSTR
。当非托管域中的目标数据类型对底层托管类型有多种可能的表示形式时,我们就称这些类型为非同构类型。COM Interop 将非同构类型转换为特定的默认目标类型(例如,String
始终转换为 BSTR
)。考虑 C# 类中的以下代码片段
public void SetProductName(String strProductName) { ... }
当上述代码通过 Typelibrary exporter(如 TLBEXP 或 REGASM)运行时,我们在 IDL 中与生成的类型库对应的结果如下
HRESULT SetProductName([in] BSTR strProductName);
但是,有时您可能需要为类型提供替代表示形式。例如,您可能希望将托管类型(如 String
)转换为以 null 结尾的 ANSI 字符数组(LPSTR
),而不是 BSTR
。这时 MarshalAs
属性就派上用场了。您可以使用 MarshalAs
属性来控制托管类型和非托管类型之间的转换方式。因此,如果您希望将托管 String
转换为以 null 结尾的 ANSI 字符数组,而不是 BSTR
,则需要在方法参数上应用 MarshalAs
属性,如下所示
public void SetProductName( [MarshalAs(UnmanagedType.LPStr)]
String strProductName) { .... }
最终的转换在 IDL 表示形式中看起来会像这样
HRESULT SetProductName([in] LPSTR strProductName);
因此,当您需要调整托管世界和非托管世界之间类型的表示映射时,MarshalAs
属性会非常有用。
异常处理 - .NET 异常与 COM HRESULT 的比较
让我们看看 .NET 组件引发的异常如何映射到基于 COM 的 HRESULT
。您会注意到,当通过类型库导出器运行时,.NET 组件方法返回类型会被转换为 [out,retval]
IDL 类型。IDL 中方法的实际返回类型是 HRESULT
,它指示方法调用的成功或失败。失败的 HRESULT
通常是系统异常或因业务逻辑失败而引发的用户定义异常的结果。如果方法调用正常进行,并且 .NET 组件没有引发异常,那么 CCW 会将返回的 HRESULT
填充为 0 (S_OK
)。如果方法调用失败或业务逻辑验证失败,则 .NET 组件应引发异常。此异常通常会分配一个失败的 HRESULT
和相关的错误描述。CCW 从 .NET 异常中提取错误代码、错误消息等详细信息,并以 COM 客户端可用的形式提供这些详细信息。它通过实现 ISupportErrorInfo
接口(表示它支持丰富的错误信息)和 IErrorInfo
接口(通过它提供所有错误信息详细信息),或者通过传递给 IDispatch::Invoke
调用的 EXCEPINFO
结构(如果 COM 感知客户端通过 dispinterface 进行 late binding)来实现这一点。错误传播是无缝发生的,.NET 异常被映射到其等效的 COM 对应项,并由 CCW 传递给 COM 客户端。让我们修改 Temperature
属性的 set
方法,以便在指定温度超出可接受范围时引发用户定义的错误。我们很快就会看到 VB 客户端如何使用常规的 COM 错误处理机制捕获此错误。
public class TemperatureComponent
{
private float m_fTemperature = 0;
/* Temperature Property (In Fahrenheit)*/
public float Temperature
{
get
{
return m_fTemperature;
}/* end get */
set
{
if((value < -30) || (value > 150))
{
TemperatureException excep = new TemperatureException(
"Marlinspike has never experienced" +
" such extreme temperatures. " +
"Please recalibrate your thermometer");
throw excep;
}
m_fTemperature = value;
}/* end set */
}
}/* end class TemperatureComponent */
class TemperatureException : ApplicationException
{
public TemperatureException(String message) : base(message)
{
}
}/* end class TemperatureException*/
这是一个 VB 客户端,它试图在 Marlinspike 中设置一个超出方法接受的温度读数范围的值,这会触发 .NET 组件的异常。
Private Sub MyButton_Click()
On Error GoTo ErrHandler
' Create an instance of the temperature component
Dim objTemperature As New TemperatureComponent
' Set the temperature to the boiling point of water
objTemperature.Temperature = 212
Exit Sub
ErrHandler:
MsgBox "Error Message : " & Err.Description, _
vbOKOnly, "Error Code: " & CStr(Err.Number)
End Sub
在上例中,VB6 客户端通过 On Error Goto
语句设置的 ErrorHandler 块使用 VB 的全局内置 Err
对象来获取 CCW 从 .NET 异常映射到实现了 IErrorInfo
接口的 COM 特定错误对象的所有错误详细信息。
.NET 组件在此引发了 TemperatureException
,它继承自 ApplicationException
类。通常会引发 ApplicationException
来指示与应用程序业务逻辑中常见的平凡失败相关的错误。TemperatureException
调用基类来初始化错误消息。由于没有指定显式 HRESULT
,ApplicationException
类会生成一个失败的 HRESULT
并返回。如果您想控制 HRESULT
的值,而不是接受基类自动生成的 HRESULT
值,那么您可以使用 ApplicationException
类中的 HResult
受保护成员(TemperatureException
类可以访问它)来指定要返回的特定 HRESULT
值。从 .NET 组件抛出异常的另一种方法是使用 System.Runtime.InteropServices.Marshal
类的 ThrowExceptionForHR
方法。此方法接受一个表示标准 HRESULT
参数的整数。这些标准 HRESULT
大多映射到 .NET 异常类型,并抛出相应的 .NET 异常。例如,如果您使用
System.Runtime.InteropServices.Marshal.ThrowExceptionForHR(COR_E_OUTOFMEMORY);
那么,将抛出 OutOfMemoryException
。
在非托管事件接收器中处理 .NET 组件的事件
我们之前看到,非托管世界中的 COM 对象如何使用Connection Points 异步引发事件,以及 .NET 应用程序如何消耗这些事件。现在我们将看看反过来的情况。我们将让 .NET 组件引发事件,然后让一个非托管接收器消耗这些事件。.NET 组件应声明表示其事件接口中每个方法的委托实例。当事件被引发时,该事件列表中的所有委托都将被调用。这些委托引用通知目标的处理程序,因此可以调用订阅者提供的正确处理程序函数。非托管接收器像与支持事件接口的 COM 对象交互一样订阅事件,使用连接点。CCW 负责映射这两种事件处理模型,以便 COM 客户端的非托管处理程序在托管 .NET 事件发生时仍然可以接收通知。
创建源事件的 .NET 组件
让我们创建一个 .NET 组件,它会在恶劣天气条件下通知订阅者。该组件允许气象站站长设置已记录的风速。如果风速超过某个限制(300 mph),它会感知到即将发生的龙卷风,并通过触发 OnTornadoWarning
事件来通知订阅者。
using System;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
using System.Reflection;
using System.Diagnostics;
// Outgoing Event Interface which the Sink implements. We'll
// use a dispinterface here so that it remains friendly to
// scripting clients
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
public interface ITornadoWatchEvents
{
void OnTornadoWarning(int nWindSpeed);
}/* end interface ITornadoWatchEvents */
// Incoming interface containing the methods
// being exposed to COM aware clients
public interface IWeatherNotify {
int WindSpeed { get; set; }
}/* end interface IWeatherNotify */
// Delegate representing the OnTornadoWarning method of the
// outgoing event interface
public delegate void TornadoWarningDelegate(int nWindSpeed);
[
ComSourceInterfaces("ITornadoWatchEvents"),
ClassInterface(ClassInterfaceType.None)
]
public class WeatherNotify : IWeatherNotify
{
// Indicates Windspeed in Miles Per Hour
private int m_nWindSpeed = 20;
// Define an event associated with the TornadoWarningDelegate
public event TornadoWarningDelegate OnTornadoWarning;
// Constructor
public WeatherNotify() {
}
public int WindSpeed
{
get {
// Return the current Wind speed
return m_nWindSpeed;
}
set {
// Set the WindSpeed to the new value
m_nWindSpeed = value;
// Check if the Wind Speed warrants an event notification
if(value >= 300) {
try {
// Check if the delegate instance for the event exists
if(null != OnTornadoWarning) {
// Twister on the loose. Run for cover !!!.
// Fire the event to all the managed/unmanaged sink handlers
// that have registered for this event.
OnTornadoWarning(m_nWindSpeed);
}/* end if */
}/* end try */
catch(Exception ex) {
Trace.WriteLine(ex.Message);
}/* end catch */
}/* end if */
}/* end set */
}/* End WindSpeed Property Accessors */
}/* end class WeatherNotify */
让我们稍微剖析一下代码,试着理解发生了什么。ITornadoWatchEvents
接口是事件接口,非托管接收器需要实现它才能接收事件通知。此接口包含一个方法 OnTornadoWarning
,它通知客户端发生龙卷风的可能性以及当前风速。您会注意到,事件接口 ITornadoWatchEvents
标记了 InterfaceType 属性,其位置参数为 ComInterfaceType.InterfaceIsIDispatch
。这是因为默认情况下,该接口将被导出到 IDL,然后导出到类型库中作为 dual 接口。脚本客户端通常在尝试接收 dual 接口时出现问题,而它们只对纯 dispinterface 友好。因此,我们注入了一个 InterfaceType
属性,其值为 ComInterfaceType.InterfaceIsIDispatch
,以强制类型库生成工具为事件接口生成纯 dispinterface。您需要定义一个委托(TornadoWarningDelegate
),该委托与事件接口中 OnTornadoWarning
方法的签名完全匹配。如果您在事件接口中有多个方法,则需要为每个方法定义匹配的委托。现在我们完成了事件接口和匹配的委托的定义。接下来是最重要的部分。您需要定义事件,这些事件代表您为事件接口中的方法定义的委托。
public event TornadoWarningDelegate OnTornadoWarning;
事件所代表的委托实例的名称需要与事件接口中相应方法的名称相同。这意味着代表 TornadoWarningDelegate
委托实例的事件将需要命名为 OnTornadoWarning
。仅此而已。您现在可以发送事件通知了。在我们的示例中,当 WindSpeed
设置为大于或等于 300 的值时,将发送事件通知。您首先需要检查事件的委托实例是否存在,然后触发 OnTornadoWarning
事件。由于事件所代表的委托是多播委托,因此委托调用列表中的所有 COM 接收器和订阅者都将收到有关即将到来的龙卷风的通知。您可以使用以下命令构建上述 .NET 组件
csc /target:library /r:System.dll /out:WeatherNotify.dll WeatherNotify.cs
您现在可以通过 RegAsm.exe 运行该程序集来注册它,并从中生成类型库。然后,您可以在 VB6 客户端中引用此类型库,该客户端将接收事件。
regasm WeatherNotify.dll /tlb:WeatherNotify.tlb
在 VB6 客户端应用程序中处理事件
这是一个 VB 6.0 客户端,它订阅 WeatherNotify
组件的 OnTornadoWarning
事件通知。这是一个简单的Form 应用程序,它使用 WithEvents
关键字订阅事件通知。当 WindSpeed
设置为大于或等于 300 的值时,objWeatherNotify_OnTornadoWarning
子例程会接收来自 .NET 组件的事件通知。
Dim WithEvents objWeatherNotify As WeatherNotify.WeatherNotify
Private Sub Form_Load()
' Create an instance of the WeatherNotify Component
Set objWeatherNotify = New WeatherNotify.WeatherNotify
End Sub
Private Sub SetWindSpeedButton_Click()
' Clear the warning label
Me.LabelWarning = ""
' Set the Windspeed property in the WeatherNotify component
objWeatherNotify.WindSpeed = Me.WindSpeed
End Sub
Private Sub objWeatherNotify_OnTornadoWarning(ByVal nWindSpeed As Long)
' We've received a notification from the WeatherNotify Component
' that there could be a Tornado on the prowl
Me.LabelWarning = "Tornado Warning: Current Wind Speeds : " & _
nWindSpeed & " mph"
End Sub
之前,您看到 RCW 和元数据助手类如何将 Connection point 事件处理转换为委托类事件处理语义,以便 .NET 应用程序可以接收 COM 组件的事件。类似地,这里的 CCW 完成了大部分底层工作,允许非托管代码订阅 .NET 组件的事件通知,并将这些事件传递给非托管 COM 领域中各自的处理程序。
部署托管 .NET 组件的程序集
在您之前看到的示例中,您将 .NET 组件和 COM 元数据代理程序集放在了消耗它的应用程序的同一目录下,以便运行时Assembly resolver 在探测程序集时能够找到它。以这种方式部署在引用它的应用程序同一目录中的程序集称为Private Assemblies。另一种定位已引用程序集的方法是使用配置文件来告知Assembly resolver 在哪里查找已引用程序集。当.NET runtime 解析并绑定到这些私有程序集时,它不会考虑版本控制。如果您的程序集经常被大量应用程序使用,那么将其部署到一个全局存储库中是有意义的,这样其他应用程序就可以共享和使用它们。这个共享程序集存储库称为Global assembly cache (GAC)。部署在 GAC 中的程序集称为Shared Name Assemblies 或Strong Name Assemblies。这是因为这些程序集具有与之关联的唯一“Shared Name”或“Strong Name”,并且它们的身份由文本名称、版本号、公钥标记、区域信息和数字签名唯一限定。那么,我们如何为程序集生成Strong name 呢?您需要做的第一件事是使用Strong Name utility (SN.exe) 生成公钥-私钥对。您可以在命令行上运行 SN.exe 来创建一个新的随机密钥对。
sn -k MyKeyPair.snk
如果一切顺利,您应该会看到以下消息
Key pair written to MyKeyPair.snk
生成密钥对后,您需要将此密钥对与您需要生成共享名称的程序集关联起来。您可以使用 System.Reflection.AssemblyKeyFileAttribute
来实现此目的。AssemblyKeyFile
属性将 SN.exe 生成的密钥对文件与程序集关联起来,以便可以使用公钥和数字签名(使用私钥和程序集清单中的信息生成)来生成程序集的共享名称。密钥对文件通常在编译时需要,以生成完全限定的共享名称签名。但出于安全原因,大多数组织都不太愿意将其私钥传递给程序集开发人员。因此,有一个延迟签名选项可用。System.Reflection.AssemblyDelaySignAttribute
允许您稍后用私钥对程序集进行签名。
让我们将我们之前使用的 Temperature.dll .NET 程序集作为Strong named assembly。为此,您需要在组件中添加 AssemblyKeyFile
属性。如果您使用的是 Visual Studio.NET,那么在项目解决方案的 AssemblyInfo.cs 文件中,有一个占位符用于此属性和其他类似的全局属性。您可以编辑该文件,并将 MyKeyPair.snk 指定为包含密钥对的文件。
[assembly:AssemblyKeyFile("MyKeyPair.snk")]
您可以使用 System.Reflection.AssemblyVersionAttribute
指定程序集的版本信息。此属性允许您指定程序集的版本。版本通常遵循 major.minor.build.revision 模式。在 Visual Studio.NET 的 C# 类库解决方案的 AssemblyInfo.cs 文件中,通常也有此属性的占位符。您可以进入并编辑此属性。
[assembly: AssemblyVersion("1.0.0.0")]
在编辑了 AssemblyInfo.cs 文件中指定的两个属性后,重新构建 Temparature 组件。您的程序集现在具有关联的strong name。要验证这一点,请使用 Strong Name utility 发出以下命令来列出程序集的公钥和令牌。
sn -Tp Temperature.dll
返回类似以下的输出
Public key is
0024000004800000940000000602000000240000525341310004000001000100ed87f0432cbf37
fc70eec5d0e59d7e47327729cd99e257a2790c690957691f20c01b47d46a72b20b4f37a829f6ad
82e6594221bbd0193b5499ca0a83db7fc9b78bcb07177f02ef9c827688246f6073f34405e9a441
37017cf6ed52c5001272b0b820926f078bbe8705fa9d411a18d692c94be9541bb3fde38b1b1f79
5a06dde8
Public key token is f80b1601a4d8a9dd
如果对没有强名称关联的私有程序集使用相同的命令,您将看到以下结果
sn -Tp WeatherNotify.dll
WeatherNotify.dll does not represent a strongly named assembly
现在,您已准备好将 Temperature.dll 部署到Global Assembly Cache (GAC),以便当应用程序尝试加载或使用该程序集时,程序集解析器可以在GAC 中找到它。您可以使用以下命令列出GAC 中所有程序集
gacutil -l
或者,您可以使用 shfusion.dll 中的 Windows shell 扩展,它允许您查看、添加和删除 GAC 中的程序集。如果导航到 Windows 目录下的Assembly 文件夹,此扩展将提供 GAC 中已部署程序集的视图,以及Name、Type、Version、Culture 和Public key token 等属性。要部署 Temperature.dll 程序集,只需在命令行中输入以下命令,它将部署到 GAC。
gacutil -i Temperature.dll
如果一切顺利,您将看到类似以下的消息
Assembly successfully added to the cache
您也可以将强命名程序集拖放到 shell 扩展提供的视图中,它会自动为您安装。一旦您的程序集部署到 GAC,您就不必将程序集放在应用程序的文件夹中,也不必修改配置文件来告诉解析器在哪里查找程序集。现在,程序集解析器将在 GAC 中找到该程序集。
将程序集部署到 GAC 具有优势,如不同版本号的程序集的侧面执行、程序集的单例(也称为代码页共享),允许运行时在多个应用程序使用时加载较少的程序集副本、减少加载时间,以及支持基于快速修复工程 (QFE) 的程序集的热部署。
。NET 组件中的线程亲和性
在从 .NET 应用程序角度理解 COM 线程模型和 Apartment 部分,您看到了 .NET 应用程序如何在创建经典 COM 组件之前声明调用线程的 Apartment 亲和性。现在,让我们看看另一面。特别是,.NET 组件在从非托管 COM 感知应用程序创建时所表现出的线程行为。 .NET 组件的线程亲和性由对象生存的上下文定义。Context 本质上是由AppDomain(轻量级进程)承载的环境,对象在该环境中创建。每个上下文又承载具有共同使用需求的 objRequest,例如Thread Affinity、Object pooling、Transaction、JIT Activation、Synchronization 等。这些上下文在运行时根据对象的属性和所需拦截服务按需创建。如果存在符合对象使用规则的现有上下文,那么运行时就会在该上下文中为其提供容纳。如果找不到匹配的上下文,则会为对象创建一个新上下文来容纳它。
话虽如此,每个AppDomain 也承载一个Default context。Default Context 又承载Context Agnostic (Context Agile) 对象。这些对象不绑定到任何上下文。Context Agile 对象不需要任何属性、特殊使用策略和拦截服务。请看下表,总结了 .NET 组件在其上下文敏捷性方面的跨上下文访问场景行为
.NET 类继承自 MarshalByRefObject | .NET 类继承自 ContextBoundObject | .NET 类既不继承自 MarshalByRefObject 也不继承自 ContextBoundObject | |
跨上下文调用在同一 AppDomain 中(Intra AppDomain 调用) |
Context-Agile。直接访问。(模拟聚合了 Free-threaded Marshaler 的经典 COM 对象) |
Context-Bound。对象只能通过代理从任何其他上下文访问。 | Context-Agile。直接访问。(模拟聚合了 Free-threaded Marshaler 的经典 COM 对象) |
跨上下文调用跨 AppDomain(Inter AppDomain 调用) | 表现出 Marshal-By-Reference 语义。对象只能通过代理从任何其他上下文访问。 | 表现出 Marshal-By-Reference 语义。对象只能通过代理从任何其他上下文访问。 |
表现出 Marshal-By-Value 语义。当标记为 |
当被非托管 COM 感知客户端访问时的线程中性行为
当程序集通过 REGASM.EXE 运行时,.NET 组件如何向 COM 公告其线程模型,以便为 COM 感知客户端查找创建适当的注册表项。
InprocServer32
下的 ThreadingModel
键的值为'Both'。在经典 COM 中,将 ThreadingModel
广告为'Both' 的对象愿意移入调用者的 Apartment,无论是 STA 还是 MTA。此外,'Both' 线程对象还会聚合 Free-threaded marshaler,并为它们所在的 Apartment 提供直接接口指针引用,而不是代理。Context Agile .NET 组件(那些不继承自 ContextBoundObject
的组件)类似于聚合了 free-threaded marshaler 的线程中性 COM 对象。让我们看看当我们在非托管客户端的 Apartment 之间传递接口引用到 .NET 组件时,.NET 组件的行为。看这个简单的 C# 类,我们将把它公开给非托管 COM 客户端
using System;
using System.Runtime.InteropServices;
public interface IHelloDotNet {
String GetThreadID();
}/* end interface IHelloDotNet */
[ClassInterface(ClassInterfaceType.None)]
public class HelloDotNet : IHelloDotNet
{
public HelloDotNet() {
}
public String GetThreadID() {
return AppDomain.GetCurrentThreadId().ToString();
}
}/* end class HelloDotNet */
上述类实现了 IHelloDotNet
接口的 GetThreadID
方法。此方法返回加载了该对象的AppDomain 中当前正在执行的线程的 ID。要将上述类构建为程序集并为 COM 创建适当的注册表项,请在命令行中发出以下命令
csc /target:library /out:HelloDotNet.dll HelloDotNet.cs
regasm HelloDotNet.dll /tlb:HelloDotNet.tlb
现在,我们将从 COM 感知客户端开始使用 .NET 组件。我们将使用一个 C++ 控制台应用程序,它将在其主线程(一个 STA)中创建 .NET 组件,然后通过生成两个工作线程将其传递到另外两个 Apartment(一个STA Apartment 和一个MTA Apartment)。我们将看看当对象原始接口指针跨 Apartment 传递时会发生什么。然后,我们将通过使用显式跨线程封送调用(使用 CoMarshalInterface
/CoUnmarshalInterface
API 系列)来查看当封送引用跨 Apartment 传递时会发生什么。看下面的代码(为简洁起见,省略了代码中的错误检查)
....... #import "mscorlib.tlb" // Import the .NET component's typelibrary #import "HelloDotNet.tlb" no_namespace // Worker Thread functions long WINAPI MySTAThreadFunction(long lParam); long WINAPI MyMTAThreadFunction(long lParam); // Use the compiler generated smart pointer wrappers IHelloDotNetPtr spHelloNET = NULL; // Stream ptr that will contain the marshaled interface IStream* g_pStream1 = NULL; IStream* g_pStream2 = NULL; int main(int argc, char* argv[]) { ....... // Make the primary thread enter an STA ::CoInitialize(NULL); // Log the thread id cout << "The Thread ID of the primary STA thread is : " << ::GetCurrentThreadId() << endl; // Create the .NET object via the COM Interop hr = spHelloNET.CreateInstance(__uuidof(HelloDotNet)); cout << "From .NET when called from the primary STA Thread : " << spHelloNET->GetThreadID() << endl; ....... // Marshal the interface pointer to a stream so that the // worker threads can get back an unmarshaled reference from it. hr = CoMarshalInterThreadInterfaceInStream(_uuidof(IHelloDotNet), spHelloNET, &g_pStream1); hr = CoMarshalInterThreadInterfaceInStream(_uuidof(IHelloDotNet), spHelloNET, &g_pStream2); // Create a worker thread that enters a STA hThreadSTA = CreateThread(NULL,0, (LPTHREAD_START_ROUTINE)MySTAThreadFunction, NULL,0 ,&dwThreadIDSTA); // Log the thread id cout << "The Thread ID of the STA based Worker thread is : " << dwThreadIDSTA << endl; // Create a worker thread that enters a MTA hThreadMTA = CreateThread(NULL,0, (LPTHREAD_START_ROUTINE)MyMTAThreadFunction, NULL,0,&dwThreadIDMTA); // Log the thread id cout << "The Thread ID of the MTA based Worker thread is : " << dwThreadIDMTA << endl; // Wait for both the worker threads to complete ::WaitForSingleObject(hThreadSTA,INFINITE); ::WaitForSingleObject(hThreadMTA,INFINITE); // Return the status return 0; }/* end main */ /* * Worker Thread Function that enters a STA Apartment */ long WINAPI MySTAThreadFunction(long lParam) { // Let the thread enter a STA ::CoInitializeEx(NULL,COINIT_APARTMENTTHREADED); // Invoke the method using the raw interface pointer cout << "From .NET when called from the STA Worker Thread (Direct Access) : " << spHelloNET->GetThreadID() << endl; // Unmarshal the interface pointer from the stream IHelloDotNetPtr spHello = NULL; HRESULT hr = CoGetInterfaceAndReleaseStream(g_pStream1, __uuidof(IHelloDotNet), (void **)&spHello); if(S_OK == hr) { cout << "From .NET when called from the STA Worker Thread (Marshaled) : " << spHello->GetThreadID() << endl; } // Exit from the thread return 0; }/* end MySTAThreadFunction */ /* * Worker Thread Function that enters a MTA Apartment */ long WINAPI MyMTAThreadFunction(long lParam) { // Let the thread enter a MTA ::CoInitializeEx(NULL,COINIT_MULTITHREADED); // Invoke the method using the raw interface pointer cout << "From .NET when called from the MTA Worker Thread (Direct Access) : " << spHelloNET->GetThreadID() << endl; // Unmarshal the interface pointer from the stream IHelloDotNetPtr spHello = NULL; HRESULT hr = CoGetInterfaceAndReleaseStream(g_pStream2, __uuidof(IHelloDotNet), (void **)&spHello); if(S_OK == hr) { cout << "From .NET when called from the MTA Worker Thread (Marshaled) : " << spHello->GetThreadID() << endl; } // Exit from the thread return 0; }/* end MyMTAThreadFunction */
控制台应用程序运行时会得到如下输出
The Thread ID of the primary STA thread is : 2220
From .NET when called from the primary STA Thread : 2220
The Thread ID of the STA based Worker thread is : 2292
The Thread ID of the MTA based Worker thread is : 2296
From .NET when called from the STA Worker Thread (Direct Access) : 2292
From .NET when called from the STA Worker Thread (Marshalled) : 2292
From .NET when called from the MTA Worker Thread (Direct Access) : 2296
From .NET when called from the MTA Worker Thread (Marshalled) : 2296
请注意,对于所有这些调用,客户端的调用线程和.NET 组件中实际调用方法的线程之间没有线程切换。换句话说,.NET 组件是Context agile,并且始终在调用者的线程中执行。从上面的代码片段可以看出,封送对象引用(使用 CoMarshalInterThreadInterfaceInStream
/CoGetInterfaceAndReleaseStream
等跨线程封送 API)的效果与在 Apartment 之间传递直接对象引用相同。最终,接收 Apartment 会获得一个Apartment-neutral 接口指针,可用于调用 .NET 组件。该 .NET 组件表现出所有类似于聚合了 free-threaded marshaler 的Both 线程的经典 COM 组件的行为。
结论:COM 在功能强大的 .NET 世界中的地位
在本文的第一部分,我们讨论了如何将经典 COM 组件公开给在Common Language Runtime (CLR) 范围内执行的 .NET 应用程序。我们看到了 COM Interop 如何无缝地允许您在托管代码中重用现有的 COM 组件。然后,我们简要介绍了如何同时使用early binding 和late binding 调用 COM 组件,以及如何进行运行时类型检查和动态类型发现。我们深入了解了委托在 .NET 中的工作原理、它们在 .NET 事件处理模型中的作用,以及 COM Interop 如何充当适配器,将经典 COM 中的连接点事件处理模型与 .NET 中的委托类事件处理模型连接起来。我们讨论了如何将 COM 集合公开给 .NET 应用程序,并使用 C# 的 foreach
语法轻松迭代集合元素。然后,我们研究了 IDL 文件中的方向属性如何映射到 C# 中相应的定向参数类型。我们还学习了 .NET 应用程序使用继承和组合重用经典 COM 组件的一些选项。最后,我们看到了托管线程在调用 COM 组件时如何声明其 Apartment 亲和性。
在本文的后半部分,我们深入探讨了 .NET 时代之前的 COM 感知客户端如何像使用经典 COM 组件一样使用 .NET 组件。我们看到了CCW 和CLR 如何在编程上无缝地实现这一点。我们简要探讨了使用属性将元数据发出到 .NET 类型以根据您的需求定制和微调生成类型库的可能性。我们研究了两个世界中的异常处理机制是如何关联的。我们还讨论了如何在非托管事件接收器中从 .NET 组件接收异步事件通知。然后,我们将注意力转向了部署选项,以及如何将 .NET 组件部署为共享程序集。最后,我们讨论了 .NET 组件的线程中性行为,并看到 Context-agile .NET 组件类似于聚合了 free-threaded marshaler (FTM) 的经典 COM 'Both' 线程组件。
作为一名 COM 开发人员,您可能会想,继续编写 COM 组件是否明智,或者直接过渡到 .NET 世界,使用 C#、VB.NET 或您喜欢的任何生成CLR 兼容托管代码的语言,将所有组件和业务逻辑代码包装为托管组件。在我看来,如果您有大量无法在短期内迁移到托管代码的 COM 代码,那么利用 Interop 的能力从 .NET 应用程序重用现有的 COM 组件是有意义的。但是,如果您从头开始编写新的业务逻辑代码,那么最好使用生成CLR 托管代码的语言之一,将您的代码包装为托管组件。这样,您就可以避免在托管和非托管边界之间转换时产生的性能损失。最终,我们 COM 开发人员不必绝望。我们心爱的 COM 组件将继续与 .NET 应用程序很好地配合。 .NET 框架提供的工具以及运行时提供的 COM Interop 机制使得从编程角度来看,无论您的 .NET 应用程序访问的是经典 COM 组件还是托管组件,都变得无缝。所以,本质上,COM 与这个崭新而强大的 .NET 世界的结合应该会是幸福的,而我们都喜爱和熟悉的 COM 仍将是我们生活的重要组成部分。
引言
"COM 编程模型的几乎所有方面都得以保留(接口、类、属性、上下文等等)。有些人可能认为 COM 已死,仅仅因为它认为 CLR 对象不依赖于
IUnknown
兼容的 vptrs/vtbls。我认为 CLR 为我花了七年时间工作的编程模型注入了新的生命,我知道还有其他程序员有同样的感受。"- Don Box,在他的 MSDN Magazine (2000 年 12 月) 的 'House of COM' 专栏中。
"COM 以精神而非肉体而存活!"
- Peter Foreman,在 Developmentor DOTNET 邮件列表中。
参考文献
- House of COM - 将本机代码迁移到 .NET CLR - MSDN Magazine,2001 年 5 月。
- House of COM - COM 已死? - MSDN Magazine,2000 年 12 月。
致谢
我谨向 Tom Archer 表示最诚挚的感谢,感谢他鼓励我写作,并将本教程的一部分收录在他的书 Inside C# 中。我还想感谢 Developmentor DOTNET 邮件列表 的精彩人士,他们每天为 .NET 开发者提供思考。